好久没写前端文章了,写一篇来凑个数吧。
本文相关GitHub:https://github.com/zsxsoft/nextjs-csr
效果见:https://codesandbox.io/s/github/zsxsoft/nextjs-csr
请注意:本文并非一种新思路的介绍,仅仅是使用Nextjs解决具体的某一类业务场景的实现。
Nextjs作为一个优秀的React SSR框架,被各厂商广为使用。但是,在它强大而高性能的同时,也有不少限制。例如,纯CSR渲染。
问题
Why CSR?
为什么要纯CSR渲染呢?这个问题首先要回到为什么需要SSR。
SSR带来的好处主要包括搜索引擎收录、首屏渲染速度提升。根据我的实测,使用React和Vuejs渲染的网页,谷歌都有相当大的概率渲染失败,导致收录大量重复内容。但相应的代价便是,不得不增加服务器投入。以及,实现CSR和SSR的同构,在开发上更是麻烦。
Nextjs在一定程度上为我们解决了开发成本这一问题,但是服务器成本这一问题仍然无法解决。无论怎么说,SSR都在服务器上进行了渲染,该做的一点不少。这个时候,一般都会想到通过缓存来解决性能压力。当然,对于游客和搜索引擎来说没什么问题。但对于登录用户这种需要显示状态的部分,要么就只能接受增加大量缓存服务器的客观事实,要么就实现“部分SSR”,即,主体页面SSR,但对类似用户信息、推荐页面等小模块进行CSR。对后者来说,不优雅不说,还提升了开发成本,还是不太经济。
如果不在意首屏渲染速度的话,我们可以实现这么一套方案:
- 对于游客和搜索引擎,中端直接SSR完整页面,交给nginx或是CDN Edge Node上进行缓存,不再回源。
- 对于登录用户,直接由CDN Edge Node返回静态HTML,不回源,让这一部分用户完全CSR。
这套方案能够大大降低服务器成本,开发成本也相对比“部分SSR”来的更低。
Nextjs上的难点
Nextjs是好用,但它的限制之一便是其Router。这个功能并不算完整的Router即使在next-routes
的帮助下,也并称不上好用。这里,我们的需求纯CSR就无法直接通过这个Router实现。
这个Router设计时并没有考虑纯CSR这一需求(对于一个SSR框架来说不考虑比较正常)。Nextjs在渲染时会给页面增加__NEXT_DATA__
这一变量存储初始内容,它也会把当前页面的参数等存储在这个变量内。next/router
在首次CSR渲染时,就依赖于这个值,而不会自己动态去获取。
__NEXT_DATA__ = {"props":{"pageProps":{},"ssr":false},"page":"/Home","query":"","buildId":""};__NEXT_LOADED_PAGES__=[];__NEXT_REGISTER_PAGE=function(r,f){__NEXT_LOADED_PAGES__.push([r, f])}
Nextjs官方的确是有提供静态文件方案,没错。使用next export
后会生成一大堆的static html。事实上,我们通常都会搭配诸如next-routes
这一类库进行路由,并关闭useFileSystemPublicRoutes
,显然直接export出来的html不太顶用。并且,那么多入口,也实在算不得优雅。
解决方案
本文要提到的就是,在使用next-routes
,并关闭useFileSystemPublicRoutes
的情况下,到底怎么编写一套黑魔法,使得我们的需求可以被实现。请注意,本文为简明起见,代码仅供示范。
我们的需求分以下几步。
确定是否需要SSR
首先我们必须先想办法判断当前页面是否需要SSR。编写以下_app.js
:
import App, { Container } from 'next/app'
import Router from '../route'
import * as React from 'react'
const checkSSR = (ctx) => {
if (ctx && ctx.req && ctx.req.headers && ctx.req.headers.cookie) {
if (/token=[a-zA-Z0-9]/.test(ctx.req.headers.cookie)) {
return false
}
}
return true
}
export default class MyApp extends App {
static async getInitialProps ({ Component, ctx, ...props }) {
let pageProps = {}
let ssr = false
if (!process.browser && checkSSR(ctx)) {
ssr = true
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx)
}
}
return {
pageProps,
ssr
}
}
render () {
const { Component, pageProps, ssr } = this.props
if (!ssr && !process.browser) {
return <Container><div /></Container>
}
return (
<Container>
<Component pageContext={this.pageContext} {...pageProps} />
</Container>
)
}
}
可以看到,我这里直接做了一个简单实现:如果token存在,不调用子组件的getInitialProps
且完全不渲染子组件。实际情况肯定更为复杂,但框架通用。
在客户端初始化__NEXT_DATA__
这里的问题包括两个,第一个是怎么初始化,第二个是拿什么来初始化。
问题
仅以我的博客为例。因为没有需求,我并没有在我的博客部署这套方案,代码相对更“Nextjs原生态”。
从我博客的源码可见到,此处我直接使用了<NextScript />
标签。
https://github.com/zsxsoft/blog.zsxsoft.com/blob/14a766528604bf41a4b2d9d71abc7e07520ba1b1/src/pages/_document.js#L55
其会自动把页面JS插入在__NEXT_DATA__
之后,没有任何修改的余地。但我们必须在这之间插入我们的客户端路由初始化代码。
我们的问题1就是应当如何插入,问题2是,我要插入什么代码,数据从何而来?
How?
解决问题1的关键在于,我们必须重写NextScript
。因此,我们需要先把NextScript
复制出来:https://github.com/zeit/next.js/blob/canary/packages/next/pages/_document.js#L145
修改其render
函数,在中间插入:
const { page, buildId, ssr } = __NEXT_DATA__
// .....
{
ssr
?
(page !== '/_error' && <script async={true} id={`__NEXT_PAGE__${page}`} src={`${assetPrefix}/_next/static/${buildId}/pages${pagePathname}`} nonce={this.props.nonce} />)
:
(this.props.clientSideScript && <script nonce={this.props.nonce} src={`${assetPrefix}${this.props.clientSideScript}`} />)
}
接着,在getInlineScriptSource
内也加入SSR判断,让它在CSR的时候返回空路由信息。当然这一步并不必要,只是为了让它看起来足够“静态”。
static getInlineScriptSource (documentProps) {
const { __NEXT_DATA__ } = documentProps
const { page, ssr } = __NEXT_DATA__
if (!ssr) {
__NEXT_DATA__.page = ''
__NEXT_DATA__.query = ''
}
return `__NEXT_DATA__ = ${htmlescape(__NEXT_DATA__)};__NEXT_LOADED_PAGES__=[];__NEXT_REGISTER_PAGE=function(r,f){__NEXT_LOADED_PAGES__.push([r, f])}`
}
现在问题来了:clientSideScript
怎么生成?
Where?
注意到,为了使用next-routes
,我们都会写一个routes.js
。既然路由统一,那么我们当然可以对这个路由文件进行解析或生成。
为旧代码迁移及保持原有写法,我们直接沿用next-routes
的格式,并为其加上分界线。这里并不是一个最佳解决方案,考虑到多人团队开发,建议定义一套JSON文件格式,通过文件动态创建路由。
const routes = require('next-routes')()
// DONT DELETE THIS SEPARATOR LINE!!!
/* ========= */
routes.add('home', '/', 'Home')
routes.add('page1', '/page1', 'Page1')
routes.add('page2', '/page2', 'Page2')
/* ========= */
module.exports = routes
阅读next-routes
的代码,可以发现它是通过path-to-regexp
来将路由转换成正则表达式的。因此,我们可以同样这么搞。我实现了一个仅支持三个参数的routes.add,如果有其他的需要自行实现。
const util = require('util')
const pathToRegExp = require('path-to-regexp')
const { parse } = require('url')
const uglifyjs = require('uglify-es')
const routeString = fs.readFileSync(__dirname + '/route.js', 'utf-8').split('/* ========= */')[1]
const clientSideScript = (() => {
const generateRoute = (routeString) => {
const routes = {}
const ret = []
routes.add = (name, pattern, component) => {
const keys = []
const re = pathToRegExp(pattern, keys)
ret.push([component, re, keys])
}
// 此处不需要考虑性能问题,仅执行一次
// 因输入点可控,也不需要考虑安全问题
// 此处的eval仅为解决上述的写法统一问题,不是最佳解决方案。强烈建议从JSON来生成路由。
eval(routeString)
return ret
}
const routes = generateRoute(routeString)
const code = `
(function () {
function match (item, path) {
const values = item[1].exec(path)
if (values) {
return valuesToParams(item[2], values.slice(1))
}
}
function valuesToParams (keys, values) {
return values.reduce((params, val, i) => {
if (val === undefined) return params
return Object.assign(params, {
[keys[i].name]: decodeURIComponent(val)
})
}, {})
}
const routes = ${util.inspect(routes, {depth: null})}; // 因为routes内含有正则表达式,不是JSON,因此不能使用JSON.stringify
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const a = match(route, location.pathname)
if (a) {
window.__NEXT_DATA__.page = '/' + route[0]
window.__NEXT_DATA__.query = a
console.log(window.__NEXT_DATA__.page, window.__NEXT_DATA__.query)
return
}
}
window.__NEXT_DATA__.page = '/_error'
})()
`
return uglifyjs.minify(code).code
})()
——clientSideScript
变量内,存储的就将会在前端可以直接被使用的静态router了。将它插入NextScript
即可。
——这么一大坨也不好插入页面,这不够优雅。当然要让它自动生成到一个文件里了!
那就需要魔改server.js
了。将以上代码插入后,加几个中间件和路由(我使用express
)。 更好的解决方案是通过webpack生成静态文件。
const hashClientSideScript = md5(clientSideScript)
const clientSideScriptFileName = `/_next/static/client.${hashClientSideScript}.js`
server.use((req, res, next) => {
req.clientSideScript = clientSideScriptFileName
next()
})
server.get(clientSideScriptFileName, (req, res) => {
return res.end(clientSideScript)
})
通过在req
内插入clientSideScript
变量,可以让我们在页面内的getInitialProps
里得到这个变量的值。最后再修改一下_document.js
:
import Document, { Head, Main } from 'next/document'
import NextScript from '../component/NextScript'
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
clientSideScript: ctx.req ? ctx.req.clientSideScript : '',
}
}
render() {
const { clientSideScript } = this.props
return (
<html>
<Head>
<style>{`body { margin: 0 } /* custom! */`}</style>
</Head>
<body className="custom_class">
<Main />
<NextScript clientSideScript={clientSideScript} />
</body>
</html>
)
}
}
最后
在我们实际的业务服务器上,在应用该解决方案之前,首页完全由服务器进行渲染,单核单进程,千兆内网测试,43 QPS。同时,因为我们的业务面向全球,境外CDN回源缓慢。因此,为已登录用户完整加载一次页面的HTML和JS(不含图片资源)通常需要2秒以上。
在应用该解决方案后,经测试,这种“半静态CSR”可跑至2476.06 QPS。且因为大部分请求不需回源,无论是SSR还是CSR,首次页面渲染时间均有了较大的提升。
配合CDN缓存页面之后,业务服务器压力骤减。我们的CDN同时将不需身份验证的API也进行了缓存,因此回源请求被砍到只剩下原先的约10%。现业务服务器现在只处理以下情况,CPU负载率大幅降低:
- 需要身份验证的API;
- 带副作用的API;
- SSR页面首次访问。
最后,让我们来个在线示例:https://codesandbox.io/s/github/zsxsoft/nextjs-csr