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是怎么傳值給客戶端有一定的參考意義。
此致我們了解了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能為我們提供的功能后,我們先來熟悉下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)如下:

通過目錄文件可以得到一個完整的可運行的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文件。

首先對用戶輸入的命令行參數(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路由分布

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