⚡️手把手带你将 CSR / SSR 应用迁移到 ESR 🚀

1,690 阅读18分钟

前言

正所谓,天下大势,分久必合,合久必分,前端亦不例外。纵观整个前端的发展历程,我们不难发现整个的一个趋势似乎的确符合这个场景,从最开始的 JSP ,许多的页面渲染都交由服务端渲染完毕之后推送到浏览器展示,服务端在这一环上承担起了数据的供给和消费的工作,当然也就不可避免的造成代码的臃肿与维护成本的提升;这种痛点在 AJAX 出现之后,开始朝着前后端分离的方向不断演化,这项痛点也逐渐得到缓和,随着 JQuery 的出现,前端在操作 Dom 的动作上也愈发娴熟,当许多人觉得这已然足够方便的时候,React、Vue 这类前端框架开始逐步崛起,让我们发现原来我们连操作 Dom 的过程都可以进一步简化,大多数情况下我们甚至只需要关心数据就已经可以完成大部分的页面渲染工作。 ​

当然,任何事物的发展终究是从不满足于当下而产生的,React、Vue 这些框架在给我们提供便利的同时,也给我们带来了新的问题。 ​

引子

一则小故事,如果更关心 ESR 技术相关的可以直接跳过此节。

很久很久以前,你和张三分别写了个页面,你采用了当下流行的 React 作为技术栈,而隔壁张三选择了 JQuery ,你在开发过程中不断赞不绝口那种开发体验,而张三总和你说操作 Dom 真累,要吐了,你心里也满是得意,你们的项目都如期上线了,你也很少再和张三再聊起这个。有一天,你在用某度的时候,无意间浏览到了一个网站,你看着很眼熟,点进去一看,居然是张三的网站,你顿时一惊,然后用某度不断搜索这你的网站关键字,你发现不论怎么搜都搜不到,你心里想,我的网站做的这么好,怎么张三的搜索权重比你高这么多,你表示不服气,最后你问了下隔壁老王,老王淡淡的说了句,你有 Free Style( SEO )吗?你当场裂开,这什么玩意,于是你在老王手摸手的介绍下,你知道了原来你的网站采用的是 CSR 的模式,某度的搜索爬虫每次过来都抓不到你的关键信息,因为对于 CSR 应用来说,首次请求拿到的 HTML 几乎是个空壳,在没有执行渲染之前,基本啥也没有,而张三的网站因为是用 JQuery +原生写的,所以他的页面里在首次抓取的时候,HTML 内容就全部抓到了,这就给了爬虫进一步分析的能力,所以他的权重比你更高。

你这一听就不答应了,那不行,我也想我的 SEO 提上来,让大家也能搜索到我的网站,特别是隔壁张三,不能比他差。最后在你一顿烧烤的诱惑下,老王把他的无敌大法传授给了你,你打开他给你发的被某 Q 标记为不安全的网站链接,迎面而来就是那各种闪动的荷官在线发牌的图片,这是老王的个人博客,这篇博客的标题是深入理解 Vue SSR 服务端渲染的“爱恨情仇”,你带着满腔的热血研读了这篇文章之后,你长叹一声,原来 SSR 就可以解决我当下面临的问题。 ​

于是在你的改造下,你的页面逐渐变得开始能被搜索到了,虽然刚开始排名还是比较落后,但在你的细心维护下,终于超过了张三,你高兴的一整宿没睡觉。经过了这次优化之后,你对你的网站更加上心了,慢慢有了很多用户开始使用你的网站,同时你也开始不断给这个网站加了很多新功能,你也慢慢沉浸在这惬意的开发当中。 ​

好景不长,这是一个夕阳铺满整个院子的下午,你正和张三、老王不断吹嘘你的网站今天又增长了多少多少用户,未来的规划等等等等,突然,一个 QQ 消息的闪动,你随意的拿起手机开始浏览者信息的内容,慢慢的从最初的笑颜眉开,开始变得凝重,你站起身来对张三和老王说,今天先吹到这,哦不,聊到这,你们先回去吧,我还有点事要处理,于是你看着他们离去的背景,然后快步的走进房间,打开你的电脑,开始查看着你的网站,以及不断切换着 Tab,你的面前是一个个数字,还有一些奇奇怪怪的图,你不断刷新着页面,试图确认着什么。那天晚上风很大,月亮也是散发着微弱的光芒,有好事者说那天晚上看到你抱着电脑鬼鬼祟祟去了隔壁老王家,很久都没出来,不知道在筹谋着什么...... ​

后来有人问你,那天你去隔壁老王家干啥去了,你这才吐露实情,原来是有个用户找到你,跟你说感觉你的网站现在进来越来越慢了,好像白屏时间也开始增长了,于是你赶紧回家试了试,发现的确开始慢了点,然后你用了一些工具测量了一下各项指标数据,发现 HTML 文档的下载时间有点过长了,你想尽办法拆包、解包、压缩,都无济于事,你的服务端数据到客户端的速度太慢了,在百般措施无果后,你又想到了老王,所以你火急火燎的去了老王家求取解决方案,睡梦中的老王被你大耳光拍醒,睡眼惺忪的被你拉起来,在你的胁迫下,他写下了下面这篇文章...... ​

什么是 CDN

CDN 全称是 Content Delivery Network 即内容分发网络. image.png

图片来自网络,侵删。

用最通俗的话来说,把你的网站资源部署到 CDN 里,那么用户在访问你的资源时,该 CDN 系统会分配给用户离他最近的 CDN 节点 IP ,然后他就能访问这个离他更近的节点里的资源了,相当于极大地减少了在网络传输过程中的时间损耗,从而提升你的页面加载速度。 ​

那这个时候问题又来了,一些静态资源的确可以部署到 CDN 上,但是 SSR 这项能力是依赖服务端计算能力的,而一般的 CDN 仅仅只用于存储一些静态资源的能力,无法满足我的需求怎么办。在一番探索下,发现阿里云已经开始支持 CDN 的边缘节点计算能力,只不过还在 Beta 版本,预计也将很快推出正式版,需要使用的话目前还需要申请,与此同时, Cloudflare 目前已经支持在边缘节点执行计算能力,本文将基于 Cloudflare 进行介绍。 ​

ESR 冲浪之旅

什么是 ESR

对比查阅了相关资料,有人称他为 Edge Side Rendering ,也有人称他为 Edge Slice Rendering ,本质上没有太大区别,都是将原本在 Server 端做的事情搬到 CDN 层面来做,除了离用户更近以外,也能充分利用 CDN 所带来的的优势。

CDN 边缘计算初探

Cloudflare 边缘节点使用文档:Get started guide 首先我们先初步了解一下 Cloudflare 使用边缘计算的初步操作,我们新建一个项目,新建一个 worker.js 文件: ​

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  return new Response('Hello world')
}

这个是小例子是从官方文档搬过来的,这里我们可以清晰的看到,就是做了一个监听 fetch 事件的操作,其实也做了一个拦截请求的能力,我们在这个事件回调函数里可以轻松的修改请求内容,就比如上面代码中的 handleRequest ,我们可以看到这个函数只做了一件事情就是返回了一个 Response 实例,内容是 Hello world ,然后我们根据文档,将其部署到边缘节点中,我们就可以通过访问指定 URL 看到页面渲染除了这个 Hello world 。 ​

其实我们从这种模式上来看,我们可以类比下 Service Worker 的运行机制,对于 Service Worker 来说,它是作为一个拦截器的存在,拦截页面请求,同时去处理返回的内容,你可以自由选择缓存相关的能力,设定自己的缓存策略,也可以仅仅只选择修改其中的某个部分。而对于这里 Cloudflare 提供边缘计算 Worker 而言,它又像是提供了一个计算服务的能力,解析请求,加载不同的资源进行返回。 ​

详细的部署方案可以直接翻看文档,这里可以稍微提一下步骤:

  • 编写 wrangler.toml 文件
  • 安装 @cloudflare/wrangler 这个官方的 CLI 包
  • 按照文档创建一个页面用于承载你的项目
  • 然后再发布即可

前置解析

其实本文的表述的核心更多的还是在于将 SSR 做的渲染逻辑搬到边缘节点上来处理,故最好是你有了解过 React 或 Vue 的 SSR 渲染相关逻辑,这样对于你后面理解整个流程有一定的帮助,本文将以 React 为例,如果你是 Vue 用户,可以参考这个思路进行项目改写即可。当然了,将 SSR 在服务端做的事情搬到边缘节点当然还是需要改造的,所以我们这里改造的主要点还是集中在 Server 端的改造,毕竟运行环境变成了浏览器环境,还是有着一些限制的。 ​

Cloudflare Workers uses the V8 JavaScript engine from Google Chrome.

这里可以选择直接 clone 笔者的项目源码,这也是笔者比较推荐的方案,你可以免去改写项目的烦恼便于后续逻辑解析的理解。

项目地址:github.com/STDSuperman…

具体如何使用调试示例项目可以看项目下 README 相关描述。

ESR 起步

为了实现 ESR 的能力,第一步要做的就是——“搬”,也就是先把原先在 Server 中做的渲染能力搬到 CDN 节点,那就需要对我们的项目进行进一步改造。 ​

跟 SSR 流程差不多的是,我们先确保拥有 client 端和 server 端构建的入口文件,在笔者项目中,以下两个入口文件是存在与 src 目录下的。 ​

Client 端入口文件

// entry.client.ts
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import 'antd/dist/antd.css'
import { routes } from './router'
import { renderRoutes } from 'react-router-config'
import { renderMode, RenderModeEnum } from '../global.config'

ReactDom[renderMode === RenderModeEnum.ESR ? 'hydrate' : 'render'](
  <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>,
  document.getElementById('root')
)

我们单从这个代码来看,我们可以很清晰的发现他跟在做 SSR 渲染时似乎没区别的,在浏览器端调用 hydrate 复用已经创建好的 Dom ,减少不必要的重复创建开销。当然,为了支持 CSR ,也是在这里加了一个判断。

Server 端入口文件

这里首先先演示了常规的 SSR 流程直接搬到 CDN 节点的方式,也就是等待服务端渲染完组件才返回内容。

// server.client.ts
import ReactDOMServer from 'react-dom/server'
import { StaticRouter, Route } from 'react-router-dom'
import { routes } from './router'
import { matchRoutes } from 'react-router-config'

/**
 * 阻塞式渲染
 * @param url
 * @returns
 */
export async function render(url: string) {
  const { pathname } = new URL(url)
  const branch = matchRoutes(routes, pathname)
  const route = branch[0]?.route
  const MatchedComponent = route?.component as any
  const initialData = MatchedComponent?.getInitialProps
    ? await MatchedComponent?.getInitialProps(route)
    : {}
  const renderContent = ReactDOMServer.renderToString(
    <StaticRouter location={url}>
      <MatchedComponent data={initialData} />
    </StaticRouter>
  )
  // @ts-ignore: 编译时替换
  const template = __HTML_CONTENT__
  return template
    .replace(
      '<!-- ssr-out-let -->',
      `<div id="root" data-server-rendered="true">${renderContent}</div>`
    )
    .replace(
      '<!-- ssr-initial-data -->',
      `<script>window.__INITIAL_STATE__=${JSON.stringify(initialData)}</script>`
    )
}

这里的逻辑自定义即可,最终函数返回值就是页面最终渲染的内容,当然也是需要进行构建的,这个具体的构建逻辑后面会进行解析。 ​

基本逻辑解析

首先这个函数的入参是请求的 url ,我们可以通过这个请求的 url 解析出当前请求的路由路径,这里使用了 react-router-config 来进行路由的查找,最后拿到匹配到的路由组件,然后使用 ReactDOMServer.renderToString 执行渲染逻辑。渲染完之后,读取 HTML 模板文件(比如笔者这里的 HTML_CONTENT ),注入渲染后的组件内容。 ​

这里采用方式是直接替换模板文件中的指定注释位置,同时将渲染的 DOM 跟节点标记为 server-rendered 表示是由服务端渲染的。

**Note:**注意的是笔者这里的 HTML_CONTENT 是在构建时会被其他逻辑直接替换,也就是实际构建后的产物是一个字符串,那为什么需要这么多此一举呢,为什么不直接采用 fs.readFile 的方式直接读取模板文件内容呢?其实是因为这个文件导出的内容最终是编译给 Cloudflare Worker 环境使用的,而该环境我们可以理解差不多是一个 Web Worker 环境,所以是没有使用 node api 的能力的,故需要在构建时将这里直接替换成字符串使用。 ​

具体替换可以看下文构建部分解析

然后我们基础的 client 端和 server 端的入口文件就准备好了。 ​

Worker 逻辑

准备好了渲染的相关逻辑,接下来需要处理的就是如何将这渲染的逻辑提交处理了。像在 SSR 的链路中,渲染相关的逻辑一般是拉起一个服务,同时借助这个服务去执行渲染逻辑同时返回给前端。而在 ESR 中,我们需要做的就是将渲染的逻辑交给计算 worker 去做这件事。

请求拦截

addEventListener('fetch', (event: any) => {
  event.respondWith(handleRequest(event))
})

同理,正如上述初探的 demo 代码所示,这里需要拦截页面请求,并篡改返回值来支持渲染页面,这里的 handleRequest 就是用来处理各项请求的。 ​

在用法上有没有感到似曾相识?Service Worker 也有类似的使用逻辑?

service worker 中类似用法

self.addEventListener('fetch', (event: any) => {
  evt.respondWith(xxx)
})

应用渲染逻辑

async function handleRequest(event: any) {
  const request: Request = event.request
  const mimeType = getResContentType(request)
  if (!mimeType) {
    const content =
      renderMode === RenderModeEnum.CSR
        ? await getCsrHtml()
        : await renderESR(request)
    return new Response(content, {
      headers: {
        'Content-Type': 'text/html; charset=utf-8'
      }
    })
  }
}

因为页面请求不仅包含 html 文档请求,还包含一些静态资源的请求,故我们需要根据 mime 类型进行区分。这里我们主要关注这个 renderESR 方法即可,它是主要处理渲染页面的逻辑的。

import { render } from '__SSR_SERVER__' // 构件时会将entry.server.ts文件构建产物path替换此字符串
const renderESR = async (request: Request) => {
  return render(request.url, request)
}

这里主要调用了我们上面编写的 entry.server.ts 文件中导出的渲染函数,这里的 SSR_SERVER 字符串会在 entry.server.ts 构建完毕后将产物 path 替换这个字符串,也就是我们构建时实际执行的导入代码的大概是长成下面这样:

import { render } from './dist/xxx/xxx.js'

这里对于 entry.server.ts 文件的处理首先是使用 vite 编译成 node 环境产物,然后再使用 esbuild 编译成浏览器环境能使用的产物给与边缘节点消费。

静态资源处理

async function handleRequest(event: any) {
  if (!mimeType) {
  } else {
    return await getAssetsResource(event)
  }
}

const getAssetsResource = async (event: any) => {
  try {
    const cacheTime = 12 * 60 * 60
    const response = await getAssetFromKV(event, {
      cacheControl: {
        browserTTL: cacheTime,
        edgeTTL: cacheTime
      }
    })

    if (!response.ok) {
      throw new Error('Get Error')
    }

    return await response
  } catch (error) {
    return new Response('404')
  }
}

这里对于静态资源的处理呢,是在构建时会自动帮你上传到你的 Cloudflare KV 储存空间中,从而可以让你拦截到该类资源请求时,直接读取,具体的读取 API 可以看 Cloudflare KV 相关文档 ,这里直接使用第三方封装好的 getAssetFromKV 包来处理静态资源的读取处理。 ​

同时为了页面加载的速度提升,这里对静态资源也设定了一定的缓存,这里目前设定的时间是 1 天。 ​

兼容 CSR

为了演示对比 CSR 与 ESR 的数据,这里也兼容了一下支持 CSR 的方式,也就是直接吐给客户端空 HTML 文件(服务端不执行渲染逻辑)。 ​

相关代码:

const getCsrHtml = async () => {
  // @ts-ignore: 编译时替换
  return __HTML_CONTENT__.replace(
    '<!-- ssr-out-let -->',
    `<div id="root"></div>`
  )
}

完整代码

// @ts-ignore
import { render } from '__SSR_SERVER__' // 构件时会将entry.server.ts文件构建产物path替换此字符串
import mime from 'mime'
import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'
import { renderMode, RenderModeEnum } from './global.config'

interface Request {
  cf: Record<string, unknown>
  signal: Record<string, unknown>
  fetcher: Record<string, unknown>
  redirect: string
  headers: Record<string, unknown>
  url: string
  method: string
  bodyUsed: boolean
  body: unknown[]
}

addEventListener('fetch', (event: any) => {
  event.respondWith(handleRequest(event))
})
/**
 * Respond with hello worker text
 * @param {Request} request
 */
async function handleRequest(event: any) {
  const request: Request = event.request
  const mimeType = getResContentType(request)
  if (!mimeType) {
    const content =
      renderMode === RenderModeEnum.CSR
        ? await getCsrHtml()
        : await renderESR(request)
    return new Response(content, {
      headers: {
        'Content-Type': 'text/html; charset=utf-8'
      }
    })
  } else {
    return await getAssetsResource(event)
  }
}

const renderESR = async (request: Request) => {
  return render(request.url, request)
}

const getCsrHtml = async () => {
  // @ts-ignore: 编译时替换
  return __HTML_CONTENT__.replace(
    '<!-- ssr-out-let -->',
    `<div id="root"></div>`
  )
}

const getAssetsResource = async (event: any) => {
  try {
    const cacheTime = 12 * 60 * 60
    const response = await getAssetFromKV(event, {
      cacheControl: {
        browserTTL: cacheTime,
        edgeTTL: cacheTime
      }
    })

    if (!response.ok) {
      throw new Error('Get Error')
    }

    return await response
  } catch (error) {
    return new Response('404')
  }
}

const getResContentType = (request: Request) => {
  return mime.getType(request.url)
}

构建配置

具体的构建配置可以直接看根目录下 build-core 文件夹下 index 文件,主逻辑都在里面。

构建客户端代码( entry.client.ts )

import { build, resolveConfig } from 'vite'

const buildClient = async (viteConfig: ResolvedConfig) => {
  const distDir = viteConfig.build?.outDir ?? path.resolve(CWD, 'dist')
  const clientViteConfig = defineConfig({
    mode,
    build: {
      outDir: path.resolve(distDir, 'client'),
      ssrManifest: true,
      emptyOutDir: false,
      target: 'es2015',
      rollupOptions: {
        input: path.resolve(CWD, 'src/entry.client.tsx')
      }
    }
  }) as InlineConfig
  const buildResult = await build(clientViteConfig)
  return {
    buildResult,
    clientViteConfig
  }
}

这里截取了部分代码,其实也没做太多事情,主要配置了构建 client 端的 vite 配置对象,然后再调用 vite.build 方法执行构建逻辑,我们的入口文件是 entry.client.ts 文件,然后将构建后的文件输出到 dist 目录下,同时将构建结果和客户端构建配置返回给其他需要使用的地方进行读取。 ​

构建 Server( entry.server.ts )端代码

const buildServer = async (
  viteConfig: ResolvedConfig,
  buildResult: RollupOutput,
  clientViteConfig: InlineConfig
) => {
  const distDir = viteConfig.build?.outDir ?? path.resolve(CWD, 'dist')
  const entryFile = path.resolve(CWD, 'src/entry.server.tsx')
  const serverOutputFile = path.resolve(distDir, 'server')
  const htmlContent = JSON.stringify(
    generateHtmlContent(buildResult, clientViteConfig)
  )
  // 读取骨架屏数据
  const skeletonContent = JSON.stringify(
    fs
      .readFileSync(path.resolve(__dirname, '../public/skeleton.html'))
      .toString()
  )
  const serverViteConfig = {
    publicDir: '',
    ssr: {
      target: 'node'
    },
    mode,
    build: {
      outDir: serverOutputFile,
      emptyOutDir: true,
      ssr: true,
      ssrManifest: false,
      target: 'es2019',
      rollupOptions: {
        input: entryFile,
        output: {
          format: 'es'
        },
        plugins: [
          replace({
            preventAssignment: true,
            values: {
              __HTML_CONTENT__: htmlContent,
              __SKELETON_HTML__: skeletonContent
            }
          })
        ]
        // external: ["react", "react-dom"]
      }
    }
  } as InlineConfig
  await build(serverViteConfig)
  return {
    serverViteConfig,
    htmlContent
  }
}

这里主要逻辑与客户端构建差不多,但是这里要注意几个点,一个是我们的 target 需要设置为 node ,表示产物是交给 node 端进行消费的,也就是基本跟在构建 SSR 的 Server 端产物的方式差不多,利用 Vite 的构建能力进行构建处理,同时我们这里配置了 replace 插件,用于在构建过程中将指定字符串替换成设置的字符串,这里 HTML_CONTENT 也就是上文提到的,也就是说在构建过程中一旦发现在构建的目标文件中有该段字符串就会直接被替换。 ​

解释:

  • HTML_CONTENT:服务端( CDN 节点)执行渲染时依赖的 HTML 模板内容
  • SKELETON_HTML:骨架屏 HTML 内容(后面做流式渲染会介绍到)

我们可以稍微看下这个 generateHtmlContent 函数。 ​

const generateHtmlContent = (
  clientBuildResult: RollupOutput,
  clientViteConfig: InlineConfig
) => {
  let indexHtmlContent = fs
    .readFileSync(path.resolve(CWD, './index.html'))
    .toString()

  const assetDirPath = clientViteConfig?.build?.outDir || '/dist'

  const assets = clientBuildResult.output?.map(item => {
    const filePath = normalizePathSeparator(
      path.relative(assetDirPath, item.fileName)
    )
    if ((item as OutputChunk)?.isEntry) {
      return `<script type="module" src="${filePath}"></script>`
    } else if (item.type === 'chunk') {
      return `<script rel="modulepreload" crossorigin href="${filePath}"></script>`
    } else if (
      item.type === 'asset' &&
      !item.fileName.endsWith('manifest.json')
    ) {
      return `<link rel="stylesheet" href="${filePath}">`
    }
  })

  indexHtmlContent = indexHtmlContent.replace(
    /(\<\/head\>)/g,
    `${assets.join('')}$1`
  )
  return indexHtmlContent
}

其实也就是对 html 模板文件处理了下,并且把读取文件的操作交给在构件时处理掉了,因为 worker 的环境无法使用 node 相关 API 来读取文件,所以需要采用这种方式进行直接字符串替换。

不仅如此,这段代码还对 client 端构建的产物进行了消费,首先将构建的产物进行遍历解析处理,如果是入口文件则直接作为 script 标签插入页面进行执行,如果是 chunk 文件则使用预加载的方式植入 html 中,如果是其他静态资源比如 css ,则直接使用 link 标签插入。 ​

这里只对这三类文件进行了处理,但是可能也会涉及到其他静态资源的处理不兼容情况。

最后将处理好后的字符串插入到 html 模板的头部,返回处理后的 html 字符串。 ​

编译 Worker.ts

export const SERVER_OUTPUT_MODULE_PATH_NAME = '__SSR_SERVER__'

const buildWorker = async (
  viteConfig: ResolvedConfig,
  serverViteConfig: InlineConfig,
  htmlContent: string
) => {
  const outputFilePath = path.resolve(
    viteConfig.build.outDir,
    'worker',
    'index.js'
  )
  esbuild({
    entryPoints: [path.resolve(CWD, 'worker.ts')],
    format: 'esm',
    target: 'es2020',
    platform: 'browser',
    outfile: outputFilePath,
    minify: mode === 'production',
    treeShaking: true,
    bundle: true,
    external: [SERVER_OUTPUT_MODULE_PATH_NAME],
    plugins: [
      esbuildReplace({
        [SERVER_OUTPUT_MODULE_PATH_NAME]: checkRelativeModulePrefix(
          normalizePathSeparator(
            path.relative(CWD, serverViteConfig?.build?.outDir as string)
          )
        ),
        __HTML_CONTENT__: htmlContent
      })
    ]
  })
}

对于 worker.ts 的处理是使用了 esbuild 来进行编译,设置了 platform:"browser" ,表示产物将用于浏览器环境使用,除此之外,也使用到了替换的插件,用来将我们上文看到的在 worker.ts 文件中导入 entry.server.ts 路径改写为构建够的产物路径,这里为了兼容 Win 环境下的路径标识符,故做了一层转换,主要的逻辑就是将\替换成/的方式,否则在 Win 环境下的构建会报错。 ​

export const normalizePathSeparator = (path: string) => {
  return path.replace(/\\/g, '/')
}

当然了,这里也涉及到了编译时替换字符串的操作,我们可以看 esbuildReplace 这个插件中所传入的参数,这里主要将 SSR_SERVER 替换成构建好的 entry.server.ts 文件的路径,同时也将 entry.server.ts 文件中 HTML_CONTENT 替换成处理好的 html 模板内容字符串。

这里需要注意的是,我们的 worker 文件产物的输出路径需要指定为我们在根目录下 package.json 设置的路径,表示整个应用的入口文件。 ​

至此,整个项目的核心逻辑就解析的差不多了,如果有遗漏的还望指出。 ​

项目中 CSR 与 ESR 模式的切换

这里为了方便大家如果需要使用笔者上述提供的项目进行数据测试的话,可以通过以下方式进行两种模式的互相切换。 ​

找到 global.config.ts 文件,然后修改下面的变量值即可。 ​

// global.config.ts
export const enum RenderModeEnum {
  ESR = 'esr',
  CSR = 'csr'
}

export const renderMode: RenderModeEnum = RenderModeEnum.ESR // csr;

如果你想切换到 CSR 模式,那么你直接将 renderMode 切换为 CSR 即可。 ​

那么这里笔者是怎么处理这 CSR 模式的渲染呢?这里可以直接参考 worker.ts 中的 getCsrHtml 函数。 ​

const getCsrHtml = async () => {
  // @ts-ignore: 编译时替换
  return __HTML_CONTENT__.replace(
    '<!-- ssr-out-let -->',
    `<div id="root"></div>`
  )
}

其实逻辑也是十分的简单,直接拿到 HTML 模板文件内容,然后注入一个挂载的根标签,即可满足要求,当然,最大的区别还是这种处理方式是没有调用 render 函数的,也就是不会在 worker 中渲染页面 dom 内容,相当于该 CDN 节点只是提供了静态资源返回的能力,渲染的操作还是得在客户端进行。

数据分析

Lighthouse 测试数据

后面也会进一步将 CSR 与 ESR 的数据进行对比。

CSR

Mobile 模式

image.png

Desktop 模式

image.png

CSR 模式下 mobile 与 desktop 端数据概览
FCPTTISITBTLCPCLS
Mobile2.0s3.9s4.4s0.01s5.5s
Desktop0.7s0.7s1.3s0s1.3s

ESR

Mobile 模式

image.png

Desktop 模式

image.png

ESR 模式下 mobile 与 desktop 端数据概览
FCPTTISITBTLCPCLS
Mobile1.6s1.6s3.0s0s4.4s
Desktop0.6s0.6s1.1s0s1.1s

除了这些数据指标之外,其实我们还可以注意一个细节,我们可以观察 Lighthouse 测量的结果截图中,关于测试的过程输出的页面截图来看,CSR 的白屏帧数也是要高于 ESR ,单从帧数上来分析,CSR 大约一般是 4-5 帧的白屏,而 ESR 大约是 3 帧左右的白屏数。

Clouflare 测试报告

CSR

image.png

ESR

image.png

Measure 测试报告

measure 测试网站:链接 这里基于 PC 页作为测试平台得到以下的数据。

CSR

image.png

ESR

image.png

数据对比总结

通过多个平台的对比测试,这里将以 PC 端的测试数据进行汇总,以便于更为直观的观察不同模式下的页面渲染速度差异。 ​

Lighthouse & Measure

FCPTTISITBTLCPCLS
LighthouseCSR0.7s0.7s1.3s0s1.3s
ESR0.6s0.6s1.1s0s1.1s
MeasureCSR1.7s2.7s3.3s460ms3.1s
ESR1.4s2.7s3.6s90ms1.5s

Cloudflare

TTFB(首字节时间)FCPFMPTTI(首次交互)SI(速度指数)LOAD(总加载时间)
CSR0.2s1.2s1.2s1.6s1.6s1.6s
ESR0.2s0.9s0.9s1.5s1.3s1.2s

从不同平台测试的整体数据来看,从 CSR 切到 ESR 的速度提升还是非常明显的,不仅如此,几乎在所有指标的对比之下,ESR 的表现大多都比 CSR 模式下表现的要好,甚至在某些指标上尤为突出。如果说你的首屏内容十分复杂或者是网络速度较慢的情况下,那么这里的指标差异只会更大。 ​

当然,这里的测量结果可能多多少少会有一定的误差,但是这里笔者通过多次测量拿到的一个较为平均的数据来看,总体趋势上还是可以作为 ESR 渲染表现比 CSR 提升比较明显的凭证的。

ESR —— 流式渲染

其实对于以上的 ESR 方案来说,还是存在一个弊端,那就是在 response 返回之前, 用户页面也还是白屏,那有没有什么办法再次降低首帧时间呢?

其实在边缘节点中可以做的事情还有很多,比如也可以尝试切换到流式渲染的方式,也就是以流的形式将页面结果返回给前端,这样能更快的让用户看到首帧内容,不过这种方案对于动态内容的处理还是需要时间,故可以考虑对静态内容和动态内容进行分离,先将静态内容推送到浏览器,待动态内容渲染完后再逐渐渲染页面内容。 ​

改造 render 逻辑

这里为了实现流式渲染,我们只需要更改 entry.server.ts 中的 render 函数即可。

更改返回内容为可读流
/**
 * 流式渲染
 * @param url
 * @returns
 */
export const render = async (url: string) => {
  // 创建一个流
  const { writable, readable } = new TransformStream()
  const writer = writable.getWriter()

  // 写入骨架
  writeSkeletonHtml(writer)
  // 写入动态渲染后的数据
  writeContentToStream(writer, await SSRRender(url))
  writer.close()
  return readable
}

先大致看下这里的逻辑。接收到请求之后,首先是创建了一个流,然后立马写入骨架屏数据。

写入骨架屏逻辑
/**
 * 写入骨架屏
 * @param writer
 */
const writeSkeletonHtml = (writer: WritableStreamDefaultWriter) => {
  // @ts-ignore: 编译时替换
  const skeletonHtmlContent = __SKELETON_HTML__
  writeContentToStream(writer, skeletonHtmlContent)
}

SKELETON_HTML 这个是在构建时被替换的骨架屏 HTML 内容

其实就是很简单将骨架屏的 HTML 数据写入流中,先行返回。当然了,这里其实也可以配合 SSG ,构建时先采用 SSG 生成页面静态内容,然后在这里直接返回,接着渲染动态内容,渲染完毕后再写入到页面中进行替换。这样静态内容由于不需要进行 Server( CDN 节点)的渲染,同时又基于 CDN 的优势,能更快的响应用户首帧数据。 ​

渲染动态内容并移除骨架屏
/**
 * 渲染React组件
 * @param url
 * @returns
 */
const SSRRender = async (url: string) => {
  const { pathname } = new URL(url)
  const branch = matchRoutes(routes, pathname)
  const route = branch[0]?.route
  const MatchedComponent = route?.component as any
  const initialData = MatchedComponent?.getInitialProps
    ? await MatchedComponent?.getInitialProps(route)
    : {}
  const renderContent = ReactDOMServer.renderToString(
    <StaticRouter location={url}>
      <MatchedComponent data={initialData} />
    </StaticRouter>
  )

  // @ts-ignore
  const template = __HTML_CONTENT__
  return template
    .replace(
      '<!-- ssr-out-let -->',
      `<div id="root" data-server-rendered="true">${renderContent}</div>`
    )
    .replace(
      '<!-- ssr-initial-data -->',
      `<script>window.__INITIAL_STATE__=${JSON.stringify(
        initialData
      )}</script>${getRemoveSkeletonScript()}`
    )
}

这里我们可以重点关注这个 SSRRener 函数,这里其实就是做了我们一般在 Server 端执行渲染的逻辑。不过这里新添加了一个移除骨架屏的逻辑,也就是这个 getRemoveSkeletonScript 函数返回的字符串。

getRemoveSkeletonScript
const getRemoveSkeletonScript = () => {
  return `
    <script>
      window.addEventListener('load', () => {
        window.SKELETON && SKELETON.destroy();
      })
    </script>
  `
}

其实也是比较简单,直接调用骨架屏 HTML 中挂载到 Window 的 destroy 方法,将骨架的 DOM 元素直接 remove 掉即可满足需求,这里移除骨架屏的时机是在浏览器 load 事件触发后,也就是页面资源下载完成并且完成渲染之后再移除,能进一步提高用户体验。 ​

说了这么多,当然也要跑下数据看看效果了。 ​

数据对比

Measure 测试截图

同样是基于 Desktop 模式拿到的测试数据。 image.png

模式FCPTTISITBTLCPCLS
Measure普通渲染1.4s2.7s3.6s90ms1.5s
流式渲染0.8s2.7s2.2s50ms2.4s

我们从体感上来看,可以看到增加了骨架屏之后,在页面内容 dom 渲染完毕之前,用户看到空白帧的时间能大大降低,FCP 时间降到了 0.8s。不过由于新增了骨架屏图片的加载与移除操作,故可能在一定几率下跑出来的数据会比不加骨架屏差。但不得不说,用户的体验的增长还是十分明显的。

总结

其实整个项目的核心,这里更多在介绍的,还是如何将 SSR 环境渲染逻辑搬迁到边缘节点当中去,所以我们更多还是在处理编译的产物问题,其他的渲染相关的逻辑其实跟在 SSR 流程差不多。那为什么要把在 SSR 中的逻辑搬到边缘节点中呢,其实也是利用了 CDN 的优势,那就是离用户更近,能更快的将渲染结果返回给用户。 ​

不仅如此,对于一些静态页面,我们也可以在边缘节点中将渲染的结果进行缓存。不得不说,技术的演进还是十分有意思的一个过程,让很多我们似乎以前很难解决的问题,变得越来越简单。在这些基础能力的不断完善的大背景下,我们可以做的事情也会越来越多,这或许就是技术人 Coding 的艺术所在吧。