《Next.js》源碼淺析

Next.js 源碼淺析


前言:在Web1.0時代,很多項目都是有JSP、PHP生成的頁面,瀏覽器負責(zé)展示服務(wù)端輸出的頁面,輸出什么展示什么,所有對頁面的邏輯控制都放在了WebServer端,基本上部署一個Tomcat或Apache服務(wù)器就能搞定,但隨著互聯(lián)網(wǎng)多元化的出現(xiàn),單一架構(gòu)模式已經(jīng)沒辦法滿足當(dāng)前復(fù)雜的業(yè)務(wù)發(fā)展,于是出現(xiàn)了各種架構(gòu)模式,Ajax的出現(xiàn)更是加快了前后端分離的步伐,把JSP中靜態(tài)的HTML部分剝離了出來,動態(tài)數(shù)據(jù)部分通過調(diào)用Ajax方式從服務(wù)端獲取,操作DOM,完成最終頁面的展示。
但很快通過Ajax抓取數(shù)據(jù)再渲染頁面的弊端也顯露出來,最重要的問題就是對搜索引擎的抓取很不友好,導(dǎo)致排名下降,SEO體驗變得很差。所以還是得重回服務(wù)端渲染的老路子。
那么問題來了,有沒有一種方法既可以解決SEO&首屏加載問題,又能有良好的開發(fā)體驗?
答案:同構(gòu)模式了解一下。

什么是同構(gòu)渲染?

簡單來說就是一份代碼,既可以跑在服務(wù)端又能跑在客戶端。

首先先看下最原始的服務(wù)端渲染的實現(xiàn)(基于NodeJS+Express實現(xiàn))

const express = require('express');
const app = express();

app.get("/", (req, res) =>
  res.send(`
    <html>
      <head>
          <title>ssr demo</title>
      </head>
      <body>
        <h1>Hello world</h1>
      </body>
    </html>
`)
);

app.listen(3000, () => console.log("Example App listening on port 3000 ..."));

例子很簡單,瀏覽器訪問根目錄的時候,服務(wù)端返回一個簡單的頁面。這里同學(xué)們可能注意到返回的是一個字符串,沒錯。瀏覽器會解析Content-Type: text/html;按頁面類型顯示(顯示畫面自行腦補)
因為服務(wù)端沒有DOM,所以不能處理事件等DOM相關(guān)行為,只能輸出HTML String。
因此相同的代碼客戶端需要再跑一次,把DOM的行為再加上,這樣才能輸出一張功能完整的頁面供用戶使用,這也是同構(gòu)渲染的意義所在。
話不多說,直接上基于React+NodeJS+打包套件若干實現(xiàn)的同構(gòu)渲染。

客戶端代碼 client.js
import React from "react";
import Page from "../comp/Page";
import ReactDOM from "react-dom";

ReactDOM.hydrate(<Page />, document.getElementById("root"));
服務(wù)端代碼 server.js
import express from "express";
import React from "react";
import { renderToString, renderToStaticMarkup } from "react-dom/server";
import Page from "../comp/Page";

const app = express();
app.use(express.static("public"));

// 將組件渲染成字符串
const content = renderToString(<Page />);

app.get("/", (req, res) =>
  res.send(`
    <html>
      <head>
          <title>ssr demo</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <button style="background: tan;" onClick="alert(6)">Server點擊</button>
      </body>
      <script src="/index.js"></script>
      <script>
        window.__DATA__ = '${content}'
      </script>
    </html>
`)
);

app.listen(3000, () => console.log("Exampleapp listening on port 3000 ..."));

通過代碼可以看出,Page中的這段代碼同時被client&server都加載了。只是在客戶端被當(dāng)作一個組件直接引入,在服務(wù)端通過了renderToString方法轉(zhuǎn)換后得到了具體的字符串在輸出。各個API(renderToString/renderToStaticMarkup/hydrate/...)的具體作用就不做細致介紹了,自行學(xué)習(xí)。
在server.js例子中有兩點需要特別注意的地方

1、<script src="/index.js"></script>
2、window.DATA = '${content}'

注意一下:
當(dāng)瀏覽器執(zhí)行了script標簽就會發(fā)起加載index.js,這時服務(wù)端就必須要有對應(yīng)的路由返回index.js文件,例子中的做法是把public文件夾設(shè)置成靜態(tài)文件訪問的根目錄,這樣就可以通過設(shè)置的路徑訪問對應(yīng)的文件了。
再則將content內(nèi)容賦值給了名叫DATA的全局對象,理解了這種形式,對后續(xù)Next是怎么傳值給客戶端有一定的參考意義。

附:例子源碼GitHub【react-ssr-demo】

此致我們了解了SSR的實現(xiàn)的基本思路,下面就正式開啟Next.js的大門。

What's NextJS ?


Next.js gives you the best developer experience with all the features you need for production: hybrid static & server rendering, TypeScript support, smart bundling, route pre-fetching, and more. No config needed.
原文引用一波:JS為您提供了生產(chǎn)所需的所有特性的最佳開發(fā)人員體驗:混合靜態(tài)和服務(wù)端渲染、TypeScript支持、智能綁定、路由預(yù)加載等等。不需要配置 開箱即用。

Next.js特性總覽

大致了解了Next能為我們提供的功能后,我們先來熟悉下Next提供的幾個命令行的作用。

/// next.js/packages/next/bin/next.ts
const commands: { [command: string]: () => Promise<cliCommand> } = {
  build: () => import('../cli/next-build').then((i) => i.nextBuild),
  start: () => import('../cli/next-start').then((i) => i.nextStart),
  export: () => import('../cli/next-export').then((i) => i.nextExport),
  dev: () => import('../cli/next-dev').then((i) => i.nextDev),
  lint: () => import('../cli/next-lint').then((i) => i.nextLint),
  telemetry: () => import('../cli/next-telemetry').then((i) => i.nextTelemetry),
}

通過create-next-app命令初始化一個Next項目,生成的目錄結(jié)構(gòu)如下:

image.png

通過目錄文件可以得到一個完整的可運行的Next項目,主要的幾個目錄及文件

/pages
/pages/api
/public
/next.config.js

因為Next實現(xiàn)了一套基于文件系統(tǒng)的路由,/pages就是作為路由根目錄。
/public是靜態(tài)文件服務(wù)的根目錄,下面所有文件都可被訪問。
next.config.js作為Next項目的配置文件。

基本上就是開箱即可,做到了零配置。

步入正題,Next作為一款服務(wù)端框架,它是怎么實現(xiàn)服務(wù)端渲染的行為呢?
首先我們通過yarn dev命令,大致了解下它的運行過程。

commands[command]()
  .then((exec) => exec(forwardedArgs))
  .then(() => {
    if (command === 'build') {
      // ensure process exits after build completes so open handles/connections
      // don't cause process to hang
      process.exit(0)
    }
  })

if (command === 'dev') {
  const { CONFIG_FILE } = require('../shared/lib/constants')
  const { watchFile } = require('fs')
  watchFile(`${process.cwd()}/${CONFIG_FILE}`, (cur: any, prev: any) => {
    if (cur.size > 0 || prev.size > 0) {
      console.log(
        `\n> Found a change in ${CONFIG_FILE}. Restart the server to see the changes in effect.`
      )
    }
  })
}

以上是next.js/packages/next/bin/next.ts文件中的實現(xiàn)代碼,當(dāng)command不同引用不同的處理文件,特別是command === 'dev'的情況下,開啟了對CONFIG_FILE(next.config.js) watchFile方法進行改動后提示服務(wù)重啟的接聽。

接下來順藤摸瓜,dev最終執(zhí)行的是cli/next-dev.ts文件。

image.png

首先對用戶輸入的命令行參數(shù)進行解析,得到args,從源碼可以看出對 --help & 自定義執(zhí)行根目錄的支持。

yarn dev --help
yarn dev /path #自定義執(zhí)行根路徑, 默認'.'

  const dir = resolve(args._[0] || '.')

  // Check if pages dir exists and warn if not
  if (!existsSync(dir)) {
    printAndExit(`> No such directory exists as the project root: ${dir}`)
  }

當(dāng)dir不是有效存在路徑,給出錯誤提示,并異常退出process.exit(1)

接下來重點看下之后干了什么

import startServer from '../server/lib/start-server'

const port =
    args['--port'] || (process.env.PORT && parseInt(process.env.PORT)) || 3000
const host = args['--hostname'] || '0.0.0.0'
const appUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`

startServer({ dir, dev: true, isNextDevCommand: true }, port, host)
.then(async (app) => {
  startedDevelopmentServer(appUrl, `${host}:${port}`)
  // Start preflight after server is listening and ignore errors:
  preflight().catch(() => {})
  // Finalize server bootup:
  await app.prepare()
}).catch(() => { //do something else... })

startServer 方法;在yarn dev的情況下傳入的參數(shù)dev & isNextDevCommand 寫死為true, port 及 host。

/// next.js/packages/next/server/lib/start-server.ts

import http from 'http'
import next from '../next'

export default async function start(
  serverOptions: any,
  port?: number,
  hostname?: string
) {
  const app = next({
    ...serverOptions,
    customServer: false,
  })
  const srv = http.createServer(app.getRequestHandler())
  await new Promise<void>((resolve, reject) => {
    // This code catches EADDRINUSE error if the port is already in use
    srv.on('error', reject)
    srv.on('listening', () => resolve())
    srv.listen(port, hostname)
  })
  // It's up to caller to run `app.prepare()`, so it can notify that the server
  // is listening before starting any intensive operations.
  return app
}

通過引用關(guān)系,一層層往下挖。next方法干了什么?

/// next.js/packages/next/server/next.ts

import './node-polyfill-fetch'
import { default as Server, ServerConstructor } from './next-server'
import { NON_STANDARD_NODE_ENV } from '../lib/constants'
import * as log from '../build/output/log'
import loadConfig, { NextConfig } from './config'
import { resolve } from 'path'
import {
  PHASE_DEVELOPMENT_SERVER,
  PHASE_PRODUCTION_SERVER,
} from '../shared/lib/constants'
import { IncomingMessage, ServerResponse } from 'http'
import { UrlWithParsedQuery } from 'url'

type NextServerConstructor = ServerConstructor & {
  /**
   * Whether to launch Next.js in dev mode - @default false
   */
  dev?: boolean
}

let ServerImpl: typeof Server

const getServerImpl = async () => {
  if (ServerImpl === undefined)
    ServerImpl = (await import('./next-server')).default
  return ServerImpl
}

export class NextServer {
  private serverPromise?: Promise<Server>
  private server?: Server
  private reqHandlerPromise?: Promise<any>
  private preparedAssetPrefix?: string
  options: NextServerConstructor

  constructor(options: NextServerConstructor) {
    this.options = options
  }

  getRequestHandler() {
    return async (
      req: IncomingMessage,
      res: ServerResponse,
      parsedUrl?: UrlWithParsedQuery
    ) => {
      const requestHandler = await this.getServerRequestHandler()
      return requestHandler(req, res, parsedUrl)
    }
  }
  
  // 省略其他方法,著重關(guān)注服務(wù)是如何開啟的

  private async createServer(
    options: NextServerConstructor & {
      conf: NextConfig
      isNextDevCommand?: boolean
    }
  ): Promise<Server> {
    if (options.dev) {
      const DevServer = require('./dev/next-dev-server').default
      return new DevServer(options)
    }
    return new (await getServerImpl())(options)
  }

  private async loadConfig() {
    const phase = this.options.dev
      ? PHASE_DEVELOPMENT_SERVER
      : PHASE_PRODUCTION_SERVER
    const dir = resolve(this.options.dir || '.')
    const conf = await loadConfig(phase, dir, this.options.conf)
    return conf
  }

  private async getServer() {
    if (!this.serverPromise) {
      setTimeout(getServerImpl, 10)
      this.serverPromise = this.loadConfig().then(async (conf) => {
        this.server = await this.createServer({
          ...this.options,
          conf,
        })
        if (this.preparedAssetPrefix) {
          this.server.setAssetPrefix(this.preparedAssetPrefix)
        }
        return this.server
      })
    }
    return this.serverPromise
  }

  private async getServerRequestHandler() {
    // Memoize request handler creation
    if (!this.reqHandlerPromise) {
      this.reqHandlerPromise = this.getServer().then((server) =>
        server.getRequestHandler().bind(server)
      )
    }
    return this.reqHandlerPromise
  }
}

// This file is used for when users run `require('next')`
function createServer(options: NextServerConstructor): NextServer {
  const standardEnv = ['production', 'development', 'test']
  // do something else ...

  return new NextServer(options)
}

// Support commonjs `require('next')`
module.exports = createServer
exports = module.exports

// Support `import next from 'next'`
export default createServer

可以看出dev通過http模塊http.createServer([requestListener])啟了一個Node服務(wù),具體事件的監(jiān)聽函數(shù)將由getRequestHandler實現(xiàn)。

/// next.js/packages/next/server/next.ts

private async createServer(
    options: NextServerConstructor & {
      conf: NextConfig
      isNextDevCommand?: boolean
    }
  ): Promise<Server> {
    if (options.dev) {
      const DevServer = require('./dev/next-dev-server').default
      return new DevServer(options)
    }
    return new (await getServerImpl())(options)
  }

最終真正執(zhí)行的就是next-dev-server.ts這個文件,,當(dāng)然DevServer也是繼承了next-server中的方法。

/// next.js/packages/next/server/dev/next-dev-server.ts

import Server, {
  WrappedBuildError,
  ServerConstructor,
  FindComponentsResult,
} from '../next-server'

export default class DevServer extends Server 
/// next.js/packages/next/server/lib/start-server.ts
const srv = http.createServer(app.getRequestHandler())
/// next.js/packages/next/server/next-server.ts
  public getRequestHandler() {
    return this.handleRequest.bind(this)
  }

到此為止,我們可以清晰的看到next.js利用Node的http模塊,開啟了一個http服務(wù),每條請求都有handleRequest方法處理。接下來我們重點看下基于文件系統(tǒng)的路由是怎么實現(xiàn)的?
通過源碼可以看到handleRequest方法體對basePath&i18n做了一系列的處理后,最終還是調(diào)用了run方法。

return await this.run(req, res, parsedUrl)
  protected async run(
    req: IncomingMessage,
    res: ServerResponse,
    parsedUrl: UrlWithParsedQuery
  ): Promise<void> {
    this.handleCompression(req, res)

    try {
      const matched = await this.router.execute(req, res, parsedUrl)
      if (matched) {
        return
      }
    } catch (err) {
      if (err.code === 'DECODE_FAILED' || err.code === 'ENAMETOOLONG') {
        res.statusCode = 400
        return this.renderError(null, req, res, '/_error', {})
      }
      throw err
    }

    await this.render404(req, res, parsedUrl)
  }


從源碼上可以一眼就能看出處理邏輯,先進行請求體壓縮,然后執(zhí)行匹配路由,最后404頁面兜底,整體流程還是簡單明了的。到此為止,只是請求的鏈路處理,和基于文件系統(tǒng)的路由貌似沒有多大關(guān)系,的確沒有體現(xiàn),接下來我們看下run方法里最核心的一段代碼。

await this.router.execute(req, res, parsedUrl)

Next.js中一大核心主角:Router

this.router = new Router(this.generateRoutes())

首先看下allRouter路由分布


Route.png

https://naotu.baidu.com/file/03959a75d00ad07532b72f45764bd2d4?token=13abf7d51654167a

框架工具庫:https://github.com/pillarjs/path-to-regexp

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容