NextJS 入门指南

前言

最近在研究SSR服务端渲染,NextJS 算是比较经典的框架了,所以了解了解其用法对SSR或许能加深理解。如果能了解其实现原理,那就更好了。

服务端渲染

简单理解就是:你访问网页的时候,会一次性把完整的HTML返回给你。区别于 React,Vue 项目,HTML 文档只是一个壳子,需要运行 js 才能得到首屏的 Dom。

简介

Next.js 是一个轻量级的 React 服务端渲染应用框架。

Get Started

初始化项目,安装 next

1
npm install --save next react react-dom

将下面脚本添加到 package.json 中:

1
2
3
4
5
6
7
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
}

新建 ./pages/index.js 到你的项目中:

1
export default () => <div>Welcome to next.js!</div>

运行 npm run dev 命令并打开 http://localhost:3000。 如果你想使用其他端口,可运行 npm run dev -- -p <设置端口号>.

如何使用服务端渲染?

默认支持服务端渲染,获取网络请求写在 getInitialProps 内部,就可以在 render 中获取数据,直接渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react'

export default class extends React.Component {
static async getInitialProps({ req }) {
const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
return { userAgent }
}

render() {
return (
<div>
Hello World {this.props.userAgent}
</div>
)
}
}

原理

应用入口

使用了 node command line,能让我们仅仅写了 page 的代码,就能让整个应用跑起来。以至于我们都找不到入口。nextjs 真的是很厉害~封装的很好

可以看到,当我们使用 yarn start 的时候,应用执行了 next-start

1
2
3
4
5
6
7
8
9
const commands: { [command: string]: () => Promise<cliCommand> } = {
build: async () => await import('../cli/next-build').then(i => i.nextBuild),
start: async () => await import('../cli/next-start').then(i => i.nextStart),
export: async () =>
await import('../cli/next-export').then(i => i.nextExport),
dev: async () => await import('../cli/next-dev').then(i => i.nextDev),
telemetry: async () =>
await import('../cli/next-telemetry').then(i => i.nextTelemetry),
}

next-start 开启了一个服务

1
2
3
4
5
6
7
8
9
10
11
12
13
startServer({ dir }, port, args['--hostname'])
.then(async app => {
// tslint:disable-next-line
console.log(
`> Ready on http://${args['--hostname'] || 'localhost'}:${port}`
)
await app.prepare()
})
.catch(err => {
// tslint:disable-next-line
console.error(err)
process.exit(1)
})

startServer 的实现,获取 next 实例,然后使用 http 创建服务,主要的请求逻辑的代码都在 next 的 getRequestHandler 里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default async function start(
serverOptions: any,
port?: number,
hostname?: string
) {
const app = next(serverOptions)
const srv = http.createServer(app.getRequestHandler())
await new Promise((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
}

handleRequest 处理请求

1
2
3
4
5
6
7
8
9
10
11
12
private handleRequest(
req: IncomingMessage,
res: ServerResponse,
parsedUrl?: UrlWithParsedQuery
): Promise<void> {
res.statusCode = 200
return this.run(req, res, parsedUrl).catch(err => {
this.logError(err)
res.statusCode = 500
res.end('Internal Server Error')
})
}

run 获取 route 实例并进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected async run(
req: IncomingMessage,
res: ServerResponse,
parsedUrl: UrlWithParsedQuery
) {
this.handleCompression(req, res)
try {
const fn = this.router.match(req, res, parsedUrl)
if (fn) {
await fn()
return
}
} catch (err) {
if (err.code === 'DECODE_FAILED') {
res.statusCode = 400
return this.renderError(null, req, res, '/_error', {})
}
throw err
}

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

router 是在项目初始化的时候进行创建的,其中一个就是根据,默认都会有很多路由处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
protected generateRoutes(): Route[] {
const publicRoutes = fs.existsSync(this.publicDir)
? this.generatePublicRoutes()
: []
const staticFilesRoute = fs.existsSync(join(this.dir, 'static'))
? [
{
match: route('/static/:path*'),
fn: async (req, res, params, parsedUrl) => {
const p = join(this.dir, 'static', ...(params.path || []))
await this.serveStatic(req, res, p, parsedUrl)
},
} as Route,
]
: []
const routes: Route[] = [
{
match: route('/_next/static/:path*'),
fn: async (req, res, params, parsedUrl) => {
await this.serveStatic(req, res, p, parsedUrl)
},
},
{
match: route('/_next/data/:path*'),
fn: async (req, res, params, _parsedUrl) => {
await this.render(
req,
res,
pathname,
{ _nextSprData: '1' },
parsedUrl
)
},
},
{
match: route('/_next/:path*'),
fn: async (req, res, _params, parsedUrl) => {
await this.render404(req, res, parsedUrl)
},
},
...publicRoutes,
...staticFilesRoute,
{
match: route('/api/:path*'),
fn: async (req, res, params, parsedUrl) => {
const { pathname } = parsedUrl
await this.handleApiRequest(
req as NextApiRequest,
res as NextApiResponse,
pathname!
)
},
},
]

if (this.nextConfig.useFileSystemPublicRoutes) {
this.dynamicRoutes = this.getDynamicRoutes()
routes.push({
match: route('/:path*'),
fn: async (req, res, _params, parsedUrl) => {
const { pathname, query } = parsedUrl
if (!pathname) {
throw new Error('pathname is undefined')
}
await this.render(req, res, pathname, query, parsedUrl)
},
})
}

return routes
}

默认页面路由都是走到最后,也就是执行 render,获取渲染的 html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public async render(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
query: ParsedUrlQuery = {},
parsedUrl?: UrlWithParsedQuery
): Promise<void> {
const url: any = req.url
if (isInternalUrl(url)) {
return this.handleRequest(req, res, parsedUrl)
}

if (isBlockedPage(pathname)) {
return this.render404(req, res, parsedUrl)
}

const html = await this.renderToHTML(req, res, pathname, query, {
dataOnly:
(this.renderOpts.ampBindInitData && Boolean(query.dataOnly)) ||
(req.headers &&
(req.headers.accept || '').indexOf('application/amp.bind+json') !==
-1),
})
// Request was ended by the user
if (html === null) {
return
}

return this.sendHTML(req, res, html)
}

其中 renderToHTML 实现了具体逻辑,通过 sendHTML 返回页面 HTML

renderToHTML 实现,首先通过 findPageComponents 获取路由具体页面 Compenent,然后使用renderToHTMLWithComponents 进行 React 转换为 HTML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public renderToHTML(
...
): Promise<string | null> {
return this.findPageComponents(pathname, query)
.then(
result => {
return this.renderToHTMLWithComponents(
....
result,
{ ...this.renderOpts, amphtml, hasAmp, dataOnly }
)
},
)
}

renderToHTML,在 renderToHTMLWithComponents 内部执行

省略了很多代码,为了了解主要逻辑。

首先会执行 loadGetInitialProps,也就是业务代码中的 props 获取,运行在服务端。

然后使用 Document 的 getInitialProps 创建 Document,并把 component 赋值,获取最终的 html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
export async function renderToHTML(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
query: ParsedUrlQuery,
renderOpts: RenderOpts
): Promise<string | null> {
....
let props: any


const AppContainer = ({ children }: any) => (
<RouterContext.Provider value={router}>
<DataManagerContext.Provider value={dataManager}>
<AmpStateContext.Provider value={ampState}>
<LoadableContext.Provider
value={moduleName => reactLoadableModules.push(moduleName)}
>
{children}
</LoadableContext.Provider>
</AmpStateContext.Provider>
</DataManagerContext.Provider>
</RouterContext.Provider>
)

try {
props = await loadGetInitialProps(App, {
AppTree: ctx.AppTree,
Component,
router,
ctx,
})
......
}
....
renderPage = (
options: ComponentsEnhancer = {}
): { html: string; head: any } => {
const renderError = renderPageError()
if (renderError) return renderError

const {
App: EnhancedApp,
Component: EnhancedComponent,
} = enhanceComponents(options, App, Component)

return render(
renderElementToString,
<AppContainer>
<EnhancedApp
Component={EnhancedComponent}
router={router}
{...props}
/>
</AppContainer>,
ampState
)

}
const documentCtx = { ...ctx, renderPage }
const docProps = await loadGetInitialProps(Document, documentCtx)
// the response might be finished on the getInitialProps call
if (isResSent(res) && !isSpr) return null

let dataManagerData = '[]'
if (dataManager) {
dataManagerData = JSON.stringify([...dataManager.getData()])
}

if (!docProps || typeof docProps.html !== 'string') {
const message = `"${getDisplayName(
Document
)}.getInitialProps()" should resolve to an object with a "html" prop set with a valid html string`
throw new Error(message)
}

if (docProps.dataOnly) {
return dataManagerData
}

const dynamicImportIdsSet = new Set<string>()
const dynamicImports: ManifestItem[] = []

for (const mod of reactLoadableModules) {
const manifestItem = reactLoadableManifest[mod]

if (manifestItem) {
manifestItem.map(item => {
dynamicImports.push(item)
dynamicImportIdsSet.add(item.id as string)
})
}
}

const dynamicImportsIds = [...dynamicImportIdsSet]
const inAmpMode = isInAmpMode(ampState)
const hybridAmp = ampState.hybrid

// update renderOpts so export knows current state
renderOpts.inAmpMode = inAmpMode
renderOpts.hybridAmp = hybridAmp

let html = renderDocument(Document, {
...renderOpts,
dangerousAsPath: router.asPath,
dataManagerData,
ampState,
props,
headTags: await headTags(documentCtx),
bodyTags: await bodyTags(documentCtx),
htmlProps: await htmlProps(documentCtx),
docProps,
pathname,
ampPath,
query,
inAmpMode,
hybridAmp,
dynamicImportsIds,
dynamicImports,
files,
devFiles,
polyfillFiles,
})

if (inAmpMode && html) {
// use replace to allow rendering directly to body in AMP mode
html = html.replace(
'__NEXT_AMP_RENDER_TARGET__',
`<!-- __NEXT_DATA__ -->${docProps.html}`
)
html = await optimizeAmp(html)

if (renderOpts.ampValidator) {
await renderOpts.ampValidator(html, pathname)
}
}

if (inAmpMode || hybridAmp) {
// fix &amp being escaped for amphtml rel link
html = html.replace(/&amp;amp=1/g, '&amp=1')
}

return html
}

使用 renderElementToString 渲染获取 html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function render(
renderElementToString: (element: React.ReactElement<any>) => string,
element: React.ReactElement<any>,
ampMode: any
): { html: string; head: React.ReactElement[] } {
let html
let head

try {
html = renderElementToString(element)
} finally {
head = Head.rewind() || defaultHead(isInAmpMode(ampMode))
}

return { html, head }
}

到这里基本就结束了

Nextjs 还可以有很多自定的东西,与 koa,express 结合~

参考

https://nextjs.org/

后续

博大精深啊