Next.js 源码浅析
前言:在Web1.0时代,很多项目都是有JSP、PHP生成的页面,浏览器负责展示服务端输出的页面,输出什么展示什么,所有对页面的逻辑控制都放在了WebServer端,基本上部署一个Tomcat或Apache服务器就能搞定,但随着互联网多元化的出现,单一架构模式已经没办法满足当前复杂的业务发展,于是出现了各种架构模式,Ajax的出现更是加快了前后端分离的步伐,把JSP中静态的HTML部分剥离了出来,动态数据部分通过调用Ajax方式从服务端获取,操作DOM,完成最终页面的展示。
但很快通过Ajax抓取数据再渲染页面的弊端也显露出来,最重要的问题就是对搜索引擎的抓取很不友好,导致排名下降,SEO体验变得很差。所以还是得重回服务端渲染的老路子。
那么问题来了,有没有一种方法既可以解决SEO&首屏加载问题,又能有良好的开发体验?
答案:同构模式
了解一下。
什么是同构渲染?
简单来说就是一份代码,既可以跑在服务端又能跑在客户端。
首先先看下最原始的服务端渲染的实现(基于NodeJS+Express实现)
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 ..."));
例子很简单,浏览器访问根目录的时候,服务端返回一个简单的页面。这里同学们可能注意到返回的是一个字符串,没错。浏览器会解析Content-Type: text/html;
按页面类型显示(显示画面自行脑补)
因为服务端没有DOM,所以不能处理事件等DOM相关行为,只能输出HTML String。
因此相同的代码客户端需要再跑一次,把DOM的行为再加上,这样才能输出一张功能完整的页面供用户使用,这也是同构渲染的意义所在。
话不多说,直接上基于React+NodeJS+打包套件若干
实现的同构渲染。
客户端代码 client.js
import React from "react";
import Page from "../comp/Page";
import ReactDOM from "react-dom";
ReactDOM.hydrate(<Page />, document.getElementById("root"));
服务端代码 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都加载了。只是在客户端被当作一个组件直接引入,在服务端通过了renderToString
方法转换后得到了具体的字符串在输出。各个API(renderToString/renderToStaticMarkup/hydrate/...)的具体作用就不做细致介绍了,自行学习。
在server.js例子中有两点需要特别注意的地方
1、<script src="/index.js"></script>
2、window.DATA = '${content}'
注意一下:
当浏览器执行了script标签就会发起加载index.js,这时服务端就必须要有对应的路由返回index.js文件,例子中的做法是把public文件夹设置成静态文件访问的根目录,这样就可以通过设置的路径访问对应的文件了。
再则将content内容赋值给了名叫DATA的全局对象,理解了这种形式,对后续Next是怎么传值给客户端有一定的参考意义。
此致我们了解了SSR的实现的基本思路,下面就正式开启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为您提供了生产所需的所有特性的最佳开发人员体验:混合静态和服务端渲染、TypeScript支持、智能绑定、路由预加载等等。不需要配置 开箱即用。
大致了解了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项目,生成的目录结构如下:
通过目录文件可以得到一个完整的可运行的Next项目,主要的几个目录及文件
/pages
/pages/api
/public
/next.config.js
因为Next实现了一套基于文件系统的路由,/pages就是作为路由根目录。
/public是静态文件服务的根目录,下面所有文件都可被访问。
next.config.js作为Next项目的配置文件。
基本上就是开箱即可,做到了零配置。
步入正题,Next作为一款服务端框架,它是怎么实现服务端渲染的行为呢?
首先我们通过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文件中的实现代码,当command不同引用不同的处理文件,特别是command === 'dev'
的情况下,开启了对CONFIG_FILE(next.config.js) watchFile方法进行改动后提示服务重启的接听。
接下来顺藤摸瓜,dev最终执行的是cli/next-dev.ts文件。
首先对用户输入的命令行参数进行解析,得到args,从源码可以看出对 --help & 自定义执行根目录的支持。
yarn dev --help
yarn dev /path #自定义执行根路径, 默认'.'
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}`)
}
当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
的情况下传入的参数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
}
通过引用关系,一层层往下挖。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)
}
}
// 省略其他方法,着重关注服务是如何开启的
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服务,具体事件的监听函数将由getRequestHandler
实现。
/// 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)
}
最终真正执行的就是next-dev-server.ts
这个文件,,当然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服务,每条请求都有handleRequest方法处理。接下来我们重点看下基于文件系统的路由
是怎么实现的?
通过源码可以看到handleRequest方法体对basePath&i18n做了一系列的处理后,最终还是调用了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)
}
从源码上可以一眼就能看出处理逻辑,先进行请求体压缩,然后执行匹配路由,最后404页面兜底,整体流程还是简单明了的。到此为止,只是请求的链路处理,和基于文件系统的路由
貌似没有多大关系,的确没有体现,接下来我们看下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