前端页面秒开的关键 - 小白也能看懂的同构渲染原理和实现

前端Sharing

共 18782字,需浏览 38分钟

 · 2023-10-30


大概在昨年下半年,我利用同构渲染技术,把公司中一个需要7、8秒才能打开的vue3项目成功优化至秒开(当然除了同构之外也配合了一些其他手段),由于那段时间vue3推出不久,很多框架这部分功能还没有跟上,我便试着用vue和vite本身提供的api来完成同构,最终取得了令人满意的效果,自己在这个过程中也获益匪浅。

如今各大框架的功能已经完善,如果你现在想做同构渲染,我推荐直接使用next.js(react)或nuxt.js(vue)来进行开发,而不是像我一样手动进行实现。本文主要是对于同构原理的描述,不涉及框架的使用。

为了让小白也能看懂,文章会包含很多特别基础的理论描述,如果觉得没必要了解,你可以通过标题跳转到自己感兴趣的部分。文章中的代码主要以vue为例,但是原理不局限于任何框架。

点击这里查看完整代码和PPT(https://github.com/fuxiang123/Isomorphic-test/tree/master)

1. 什么是同构渲染?为什么使用它?

1.1 什么是渲染?

以现在前端流行的react和vue框架为例。react中的jsx和vue里面的模板,都是是无法直接在浏览器运行的。将它们转换成可在浏览器中运行的html,这个过程被称为渲染。

1.2 什么是客户端渲染(client-side-render, 以下简称csr)

CSR是现在前端开发者最熟悉的渲染方式。利用vue-cli或create-react-app创建一个应用,不作任何额外配置直接打包的出来代码就是CSR。

你可以用如下的方法辨别一个web页面是否是CSR:打开chrome控制台 - 网络面板,查看第一条请求,就能看到当前页面向服务器请求的html资源;如果是CSR(如下图所示),这个html的body中是没有实际内容的。


那么页面内容是如何渲染出来的呢?仔细看上面的html,会发现存在一个script标签,打包器正是把整个应用都打包进了这个js文件里面。

当浏览器请求页面的时候,服务器先会返回一个空的html和打包好的js代码;等到js代码下载完毕,浏览器再执行js代码,页面就被渲染出来了。因为页面的渲染是在浏览器中而非服务器端进行的,所以被称为客户端渲染。

CSR的优劣

CSR会把整个网站打包进js里,当js下载完毕后,相当于网站的页面资源都被下载好了。这样在跳转新页面的时候,不需要向服务器再次请求资源(js会直接操作dom进行页面渲染),从而让整个网站的使用体验上更加流畅。

但是这种做法也带来了一些问题:在请求第一个页面的时候需要下载js,而下载js直至页面渲染出来这段时间,页面会因为没有任何内容而出现白屏。在js体积较大或者渲染过程较为复杂的情况下,白屏问题会非常明显。

另外,由于使用了CSR的网站,会先下载一个空的html,然后才通过js进行渲染;这个空的html会导致某些搜索引擎无法通过爬虫正确获取网站信息,从而影响网站的搜索引擎排名(一般称之为搜索引擎优化Search Engine Optimization,简称SEO)。

「总而言之,客户端渲染就是通过牺牲首屏加载速度和SEO,来获取用户体验的一种技术。」

1.3 什么是服务器端渲染(server-side-render, 以下简称SSR)

理解了CSR,SSR也很好理解了,其实就是把渲染过程放在了在服务器端。以早年比较流行的java服务器端渲染技术jsp为例,会先写一个html模板,并用特殊的语法<%...%>标记动态内容,里面可以写一些java程序。

渲染的时候,jsp会通过字符串替换的方式,把<%...%>替换为程序执行的结果。最后服务器将替换完毕的html以字符串的形式发送给用户即可。

同时我们还可以写很多个JSP,根据用户的http请求路径返回相应的文件,这样就完成了一个网站的开发。

 // jsp示例
 <body>
  <hr>
  <hr>
  <h2>java脚本1</h2>
  <%
        Object obj = new Object();
        System.out.println(obj);
        out.write(obj.toString()); // 这一行表示把结果输出到最终的html中
  %>
  <hr>
  <hr>
  <%
    out.write(obj.toString());
  %>
  </body>

像jsp这类SSR技术,优劣势和客户端渲染正好相反:因为html在服务器端就已经渲染好了,所以不存在客户端的白屏和seo问题;相对应地,每次跳转页面都要向服务器重新请求,意味着用户每次切换页面都要等待一小段时间,所以用户体验方面则不如客户端。

还有一点显而易见的问题,就是SSR相比CSR会占用较多的服务器端资源。

「总而言之,服务器端渲染拥有良好的首屏性能和SEO,但用户体验方面较差。且会占用较多的服务器端资源。」

1.4 什么是同构(Isomorphic)

可以看到,CSR和SSR的优劣势是互补的,所以只要把它们二者结合起来,就能实现理想的渲染方法,也就是同构渲染。

同构的理念十分简单,最开始的步骤和SSR相同,将生成的html字符串返回给用户即可;但同时我们可以将CSR生成的JS也一并发送给用户;这样用户在接收到SSR生成的html后,页面还会再执行一次CSR的流程。

这导致用户只有请求的第一个页面是在服务器端渲染的,其他页面则都是在客户端进行的。这样我们就拥有了一个同时兼顾首屏、SEO和用户体验的网站。

当然这只是最简单的概念描述,实际操作起来仍然有不少难点。我将在后面的内容一一指出。

1.5 CSR、SSR、同构渲染对比

以下摘自《vue.js设计与实现》


CSR SSR 同构
SEO 不友好 友好 友好
白屏问题
占用服务器资源
用户体验

2. 一个最简单的同构案例

查看完整的代码可以点击这里(https://github.com/fuxiang123/Isomorphic-test/tree/master/%E7%AE%80%E5%8D%95%E5%90%8C%E6%9E%84)。

2.1 服务器端渲染html字符串

前面说过,同构渲染可以看作把SSR和CSR进行结合。单独完成SSR和CSR都很简单:CSR就不用说了;SSR的话,vue和react都提供了renderToString函数,只要将组件传入这个函数,可以直接将组件渲染成html字符串。

还有一点需要注意的是,在客户端渲染里我们会使用createApp来创建一个vue应用实例,但在同构渲染中则需要替换成createSSRApp。如果仍然使用原本的createApp,会导致首屏页面先在服务器端渲染一次,浏览器端又重复渲染一次。

而使用了createSSRApp,vue就会在浏览器端渲染前先进行一次检查,如果结果和服务器端渲染的结果一致,就会停止首屏的客户端渲染过程,从而避免了重复渲染的问题。

代码如下:

import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'

// 一个计数的vue组件
function createApp({
  // 通过createSSRApp创建一个vue实例
  return createSSRApp({
    data() => ({ count1 }),
    template`<button @click="count++">{{ count }}</button>`,
  });
}

const app = createApp();

// 通过renderToString将vue实例渲染成字符串
renderToString(app).then((html) => {
  // 将字符串插入到html模板中
  const htmlStr = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
  `
;
  console.log(htmlStr);
});

将上述代码拷贝进任意.js文件,然后执行node xxx.js,即可看到控制台打印出渲染好的字符串,如下:


2.2 通过服务器发送html字符串

为了简便,这里使用比较流行的express作为服务器。代码很简单,直接看注释就能理解。

import express from 'express'
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'

// 一个计数的vue组件
function createApp({
  return createSSRApp({
    data() => ({ count1 }),
    template`<button @click="count++">{{ count }}</button>`,
  });
}

// 创建一个express实例
const server = express();

// 通过express.get方法创建一个路由, 作用是当浏览器访问'/'时, 对该请求进行处理
server.get('/', (req, res) => {

  // 通过createSSRApp创建一个vue实例
  const app = createApp();
  
  // 通过renderToString将vue实例渲染成字符串
  renderToString(app).then((html) => {
    // 将字符串插入到html模板中
    const htmlStr = `
      <!DOCTYPE html>
      <html>
        <head>
          <title>Vue SSR Example</title>
        </head>
        <body>
          <div id="app">${html}</div>
        </body>
      </html>
    `
;
    // 通过res.send将字符串返回给浏览器
    res.send(htmlStr);
  });
})

// 监听3000端口
server.listen(3000, () => {
  console.log('ready http://localhost:3000')
})

同样在控制台输入node xxx.js,即可启动服务器,然后在浏览器访问http://localhost:3000/ ,就能访问到页面了。


2.3 激活客户端渲染

如果你访问过上面的地址,就会发现页面上的按钮是点不动的。这是因为通过renderToString渲染出来的页面是完全静态的,这时候就要进行客户端激活。

激活的方法其实就是执行一遍客户端渲染,在vue里面就是执行app.mount。我们可以创建一个js,在里面写入客户端激活的代码,然后通过script标签把这个文件插入到html模板中,这样浏览器就会请求这个js文件了。

如下所示,首先写一段客户端激活的代码,放到名为client-entry.js的文件里:

import { createSSRApp } from 'vue'

// 通过createSSRApp创建一个vue实例
function createApp({
  return createSSRApp({
    data() => ({ count1 }),
    template`<button @click="count++">{{ count }}</button>`,
  });
}

createApp().mount('#app');

可以看到,这里的createApp函数和服务器端的counter组件是完全相同的(在实际开发中,createApp代表的就是你的整个应用),所以客户端激活实际上就是把客户端渲染再执行一遍,唯一区别就是要使用createSSRApp这个api防止重复渲染。

另外,要使用vue激活,我们还需要在客户端下载vue。因为我们的代码没有经过打包器转换,所以没法在浏览器中直接使用import { createSSRApp } from 'vue'这样的语法。为了方便,这里借用了Import Map功能,这样就支持import直接使用了。如果想进一步了解可以自行搜索Import Map关键字。

改造后的如下html模板如下:

const htmlStr = `
  <!DOCTYPE html>
  <html>
    <head>
      <title>Vue SSR Example</title>
      // 使用Import Map
      <script type="importmap">
      {
        "imports": {
          "vue""https://unpkg.com/vue@3/dist/vue.esm-browser.js"
        }
      }
      
</script>
      // 将client-entry.js文件路径写入script
      <script type="module" src="/client-entry.js"></script>
    </head>
    <body>
      <div id="app">${html}</div>
    </body>
  </html>
`;

这样我们的按钮就可以点击了,而且查看控制台,请求的html资源也是有内容的,不再是csr那种空白的html了。


「查看完整的代码可以」点击这里(https://github.com/fuxiang123/Isomorphic-test/tree/master/%E7%AE%80%E5%8D%95%E5%90%8C%E6%9E%84)。

3. 实现脱水(Dehydrate)和注水(Hydrate)

同构应用还有一个比较重要的点,就是如何实现服务器端的数据的预取,并让其随着html一起传递到浏览器端。

例如我们有一个列表页,列表数据是从其他服务器获取的;为了让用户第一时间就看到页面内容,最好的方法当然是在服务器就拿到数据,然后随着html一起传递给浏览器。浏览器拿到html和传过来的数据,直接对页面进行初始化,而不需要再在客户端请求这个接口(除非服务器端因为某些原因获取数据失败)。

为了实现这个功能,整个过程分为两部分:

  1. 「服务器端获取到数据后,把数据随着html一起传给客户端的过程,一般叫做脱水(Dehydrate)」
  2. 「客户端拿到html和数据,利用这个数据来初始化组件的过程叫做注水(Hydrate)」

注水其实就是前面提到过的客户端激活,区别只是前面的没有数据,而这次我们会试着加上数据。国内也有翻译成"水合"的,现在你应该知道了,注水、客户端激活、水合还有Hydrate其实都是一码事。

「查看完整的代码可以」点击这里(https://github.com/fuxiang123/Isomorphic-test/tree/master/%E5%AE%9E%E7%8E%B0%E8%84%B1%E6%B0%B4%E5%92%8C%E6%B3%A8%E6%B0%B4)。

3.1 实现服务器端脱水

要在服务器端直接请求一个接口当然很简单,但是为了保持最基本的前后端分离,我们最好的写法还是将接口请求写在组件中。

为了让服务器获取到我们要请求的接口,我们可以在vue组件中挂载一个自定义函数,然后在服务器端调用这个函数即可。如下:

// 组件中的代码
import { createSSRApp } from 'vue'
function createApp({
  return createSSRApp({
    data() => ({ count1 }),
    template`<button @click="count++">{{ count }}</button>`,
    // 自定义一个名为asyncData的函数
    asyncDataasync () => { 
        // 在处理远程数据并return出去
        const data = await getSomeData()
        return data; 
    }
  });
}

// 服务器端的代码
const app = createApp();
// 保存初始化数据
let initData = null;
// 判断是否有我们自定义的asyncData方法,如果有就用该函数初始化数据
if (app._component.asyncData) {
    initData = await app._component.asyncData();
}

拿到数据后该如何传递到浏览器呢?其实有一个很简单的方法:我们可以把数据格式化成字符串,然后用如下的方式,直接将这个字符串放到html模板的一个script标签中:

const htmlStr = `
  <!DOCTYPE html>
  <html>
    <head>
      ...
      // 将数据格式化成json字符串,放到script标签中
      <script>window.__INITIAL_DATA__ = ${JSON.stringify(initData)}</script>
    </head>
    ...
  </html>
`;

当html被传到浏览器端的时候,这个script标签就会被浏览器执行,于是我们的数据就被放到了window.__INITIAL_DATA__里面。此时客户端就可以从这个对象里面拿到数据了。

3.2实现客户端注水

实现了脱水,注水就很简单了。我们先判断window.__INITIAL_DATA__是否有值,如果有的话直接将其赋值给页面state;否则就让客户对自己请求一次接口。代码如下:

function createApp({
  return createSSRApp({
    data() => ({ count1 }),
    template`<button @click="count++">{{ count }}</button>`,
    // 自定义一个名为asyncData的函数
    asyncDataasync () => { 
        // 在处理远程数据并return出去
        const data = await getSomeData()
        return data; 
    },
    async mounted() {
      // 如果已经有数据了,直接从window中获取
      if (window.__INITIAL_DATA__) {
        // 有服务端数据时,使用服务端渲染时的数据
        this.count = window.__INITIAL_DATA__;
        window.__INITIAL_DATA__ = undefined;
        return;
      } else {
        // 如果没有数据,就请求数据
        this.count = await getSomeData();
      }
    }
  });
}

这样我们就实现了一套完整的注水和脱水流程。

「查看完整的代码可以」点击这里(https://github.com/fuxiang123/Isomorphic-test/tree/master/%E5%AE%9E%E7%8E%B0%E8%84%B1%E6%B0%B4%E5%92%8C%E6%B3%A8%E6%B0%B4)。

4. 同构渲染要(坑)点

服务器端和浏览器端环境不同,所以我们不能像写csr代码一样写同构代码。根据我的踩坑经历,写同构应用需要尤其注意以下几点:

4.1 避免状态单例

服务器端返回给客户端的每个请求都应该是全新的、独立的应用程序实例,因此不应当有单例对象——也就是避免直接将对象或变量创建在全局作用域,否则它将在所有请求之间共享,在不同请求之间造成状态污染。

在客户端中,vue/pinia/vue-router都是以单例的形式存在,为此可以用函数的形式将vue/pinia/vue-router等进行初始化。也就是像上面的例子那样,用一个函数进行包裹,然后调用这个函数进行应用的初始化。

image.png

4.2 避免访问特定平台api

服务器端是node环境,而客户端是浏览器环境,如果你在node端直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误;反之,在浏览器使用了node端的api也是如此。

需要注意的是,在vue组件中,服务器端渲染时只会执行beforeCreate和created生命周期,在这两个生命周期之外执行浏览器api是安全的。所以推荐将操作dom或访问window之类的浏览器行为,一并写在onMounted生命周期中,这样就能避免在node端访问到浏览器api。

如果要在这两个生命周期中使用浏览器端api,可以利用相关打包工具提供的变量(如vite提供了import.meta.env.SSR),来避免服务器端调用相关代码。

image.png

尤其需要注意的是,一些组件库可能也会因为编写的时候没有考虑到服务器端渲染的情况,导致渲染出错。这时候可以借助一些第三方组件,如nuxt中的ClientOnly,可以避免这些出错的组件在服务器端进行渲染。

4.3 避免在服务器端生命周期内执行全局副作用代码

vue服务器端渲染会执行beforeCreate和created生命周期,应该避免在这两个生命周期里产生全局副作用的代码。

例如使用setInterval设置定时器。在纯客户端的代码中,我们可以设置一个定时器,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来,最终造成服务器内存溢出。

5. 创建实际生产中的同构应用

上面的例子是一个最基础的同构渲染,但距离一个能在开发中实际使用的框架还差得很远。如果把这些内容都细细讲完,我估摸文章要到三万字了,实在太累,而且也很难让新手程序员看得懂。所以这些难点我只讲解一下关键点,如果有兴趣深究的可以下来自己研究。

按照我踩坑的经历,至少还要解决下面几个问题:

  1. 集成前端工具链,如vite、eslint、ts等
  2. 集成前端路由,如vue-router
  3. 集成全局状态管理库,如pinia
  4. 处理#app节点之外的元素。如vue的teleport,react的portal
  5. 处理预加载资源

顺带一提,vue社区有一篇vue ssr指南也值得一看,虽然只有vue2版本的,但是仍然有很多值得学习的地方。

4.1 集成前端工具链

这部分内容实在太多太杂,需要对打包工具有比较好的掌握才能理解。好在vite官方已经有了一篇完善的教程,而且提供了完整的代码示例,想深入了解的可以点进去看看。

4.2 集成前端路由

前端路由都提供了相关的api来辅助服务器端进行处理。如vue-router进行服务器端处理的流程如下:

  1. 使用createMemoryHistory创建路由。
  2. 在服务器端获取用户请求的路径,将路径传入router.push函数,这样router就会处理该路径对应的页面。
  3. router在处理页面的时候,可能会碰到一些异步代码,所以vue-router提供了router.isReady这个异步函数。await这个函数后,再渲染整个应用,获取的就是当前用户请求的页面了。

4.3 集成全局状态管理库

官方文档一般就有详细教程,如pinia官网就有教你如何进行服务器端渲染(https://pinia.web3doc.top/ssr/)。实际上全局状态管理库的处理就是脱水和注水,所以这里不做详细解释了。

4.4 处理#app节点之外的元素

页面内容一般会渲染到id为app的节点下,但像vue中的teleport和react的portal独立于app节点外,因此需要单独处理。

这里建议把所有的根节点之外的元素统一设置到一个节点下面,如teleport可以通过设置to属性来指定挂载的节点;同时vue也提供了方法来获取所有的teleport(https://cn.vuejs.org/guide/scaling-up/ssr.html#teleports)。拿到teleport的信息后,即可通过字符串拼接的方式,将它们一并放到html模板中的目标节点下面了。

4.5 处理预加载资源

使用打包器可以生成manifest,它的作用是将打包后的模块 ID 与它们关联的 chunk 和资源文件进行映射(简单理解就是通过它你可以知道js、图片等页面资源的位置在哪儿)。依靠这个manifest获取资源的路径,然后创建link标签拼接到html模板中即可。

详情可查看这里(https://cn.vitejs.dev/guide/ssr.html#generating-preload-directives)。

5. 服务器端优化

虽然我们写好了服务端的代码,但是这样的代码是十分脆弱的,无论性能还是可靠性都没有保障,是没法在实际生产中应用的。为此我们需要对服务端代码进行一系列优化。

点击这里查看完整代码(https://github.com/fuxiang123/Isomorphic-test/blob/master/%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%AB%AF%E4%BC%98%E5%8C%96/processDaemon.js)。

5.1 服务器端测试

压力测试

为了衡量服务器优化的指标,我们可以借助一系列测试工具,apach bench、jmeter等。我使用的是apach bench,它可以模拟一系列并发请求,用来对服务器进行压力测试。

apach bench可以通过执行abs -n <请求总数> -c <并发数> <测试路径>来进行测试。例:abs -n 1000 -c 100 http://localhost:3000/,表示以100并发的形式发送1000个请求到localhost:3000。

因为我们的服务本身比较简单,所以这里我以1000并发的形式发送了10000个请求,结果如下:


可以看到Time taken for tests这一栏,总共花了6.6秒左右。

Node调试工具

除此之外,我们还可以用Chrome浏览器的"开发者工具"作为node服务器的调试工具。使用node调试工具不仅能方便地进行调试,还可以清楚地看到诸如内存使用情况等指标,对代码进行更精确地优化。

关于node调试工具的使用可以参考这篇文章(https://www.ruanyifeng.com/blog/2018/03/node-debugger.html)。

5.2 多进程优化

node内置了cluster模块,可以快速方便地创建子进程。如下:

image.png

通过os模块判断当前的cpu总数,然后通过cluster.isMaster判断当前是否是主进程,最后通过cluster.fork即可创建一个子进程。

在主进程里,我们进行一些创建、维护子进程的工作,而在子进程里我们则运行真正的node服务。如下图所示,我们启动多线程再进行测试:

image.png

可以看到速度提升到了3.7秒,明显快了很多。

5.3 内存溢出处理

通过process.memoryUsage();可以判断当前子进程用掉的内存,当占用内存大于某个数(如300M)的时候,我们便将这个子进程关掉,防止内存泄露。


5.4 处理未捕获异常

在子进程中,通过process.on('uncaughtException', err => {})可以获取到该进程中的未捕获异常(如服务器端渲染时候发生的一些错误)。当捕获到错误后,我们可以对错误进行上报或写入日志。

也可以借助一些第三方监控平台如sentry来处理这类问题。sentry在node端的部署方法可以参考这里(https://docs.sentry.io/platforms/node/)。


5.5 心跳包检测

所谓心跳包检测,就是主进程每隔一段时间向子进程发送一个信息,子进程收到这个信息后,立即回应给主进程一个信息;如果主进程在某次信息发送后,子进程没有回应,说明子进程卡死了。这时候就需要杀死这个子进程然后重新创建一个。

所以心跳包检测的作用主要是为了防止子进程卡死。

具体步骤如下:

  1. 主进程通过woker.send方法可以向子进程发送信息(woker为cluster创建的子进程引用)
  2. 子进程通过process.on('message', () => {})订阅主进程发送的信息,并在收到信息后通过process.send方法返回给主进程信息
  3. 主进程通过woker.on('message', () => {})订阅子进程发送的信息。如果累计一定次数没有收到子进程返回的信息,则关闭子进程。

主进程代码如下:

子进程代码如下:


5.6 子进程自动重建

在上面的代码里,如果子进程因为某种错误(如内存溢出)而被关闭的时候,我们需要重新创建一个子进程,这样就能保证线上服务能够长时间运行了。通过如下代码即可监听子进程关闭并重新创建子进程。


点击这里查看完整代码(https://github.com/fuxiang123/Isomorphic-test/blob/master/%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%AB%AF%E4%BC%98%E5%8C%96/processDaemon.js)

浏览 46
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报