Nextjs实现纯CSR

zsx in 记录整理 / 0 / 12347

好久没写前端文章了,写一篇来凑个数吧。
本文相关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。对后者来说,不优雅不说,还提升了开发成本,还是不太经济。

如果不在意首屏渲染速度的话,我们可以实现这么一套方案:

  1. 对于游客和搜索引擎,中端直接SSR完整页面,交给nginx或是CDN Edge Node上进行缓存,不再回源。
  2. 对于登录用户,直接由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负载率大幅降低:

  1. 需要身份验证的API;
  2. 带副作用的API;
  3. SSR页面首次访问。

最后,让我们来个在线示例:https://codesandbox.io/s/github/zsxsoft/nextjs-csr

如果本文对你有帮助,你可以用支付宝支持一下:

Alipay QrCode