整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

HTML的两种渲染方法

eb 服务存在两种 HTML 渲染方法。

最早的HTML(web 1.0时代),都是服务器端渲染的,浏览器发送请求,服务器端将整个html作为一个完整文档发送给浏览器。最早响应浏览器请求的被称为CGI .

CGI

Java语言进入web 开发领域后,首先出现的技术是 servlet,这个技术模仿的是CGI.也是在服务器端渲染好整个HTML文档,然后反馈给浏览器。

Servlet能够很好地组织业务逻辑代码,但是在Java源文件中通过字符串拼接的方式生成动态HTML内容会导致代码维护困难、可读性差。于是产生了JSP技术,JSP在静态HTML内容中嵌入Java代码,Java代码被动态执行后生成HTML内容,类似的还有ASP,PHP等技术,这些技术本质上都是服务端渲染好整个HTML文档,都属于服务器端渲染。

web2.0时代 最大的思想革命本质不是前后端分离,而是把网页当作独立的应用程序(app)。前后端分离只是实现这一新架构的必然结果。web 2.0 时代最重要的就是ajax技术。

使用ajax技术后,HTTP GET拿到的不是渲染后的网页,而是一个由html和Javascript组成的应用, 这个应用以浏览器为虚拟机。装载和显示数据是app启动之后的运行逻辑。传统上应用叫什么?叫Client,也就是前端。于是前后端就这么分离了,浏览器变成了应用的运行环境,后端蜕化成了单纯的业务逻辑和数据接口。最典型的ajax 应用就是gmail,gmail实质上就是把过去桌面端的email 应用搬到了浏览器中。ajax这种技术也就是客户端渲染。

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

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

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

点击这里查看完整代码和PPT

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. 一个最简单的同构案例

查看完整的代码可以点击这里。

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: () => ({ count: 1 }),
    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: () => ({ count: 1 }),
    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: () => ({ count: 1 }),
    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了。

查看完整的代码可以点击这里。

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

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

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

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

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

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

查看完整的代码可以点击这里。

3.1 实现服务器端脱水

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

为了让服务器获取到我们要请求的接口,我们可以在vue组件中挂载一个自定义函数,然后在服务器端调用这个函数即可(需要注意的是,服务器环境不能直接使用fetch,应该用axios或者node-fetch替代)。如下:

// 组件中的代码
import { createSSRApp } from 'vue'
function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`,
    // 自定义一个名为asyncData的函数
    asyncData: async () => { 
        // 在处理远程数据并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: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`,
    // 自定义一个名为asyncData的函数
    asyncData: async () => { 
        // 在处理远程数据并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();
      }
    }
  });
}


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

查看完整的代码可以点击这里。

4. 同构渲染要(坑)点

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

4.1 避免状态单例

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

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

4.2 避免访问特定平台api

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

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

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

尤其需要注意的是,一些组件库可能也会因为编写的时候没有考虑到服务器端渲染的情况,导致渲染出错。这时候可以借助一些第三方组件,如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官网就有教你如何进行服务器端渲染。实际上全局状态管理库的处理就是脱水和注水,所以这里不做详细解释了。

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

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

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

4.5 处理预加载资源

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

详情可查看这里。

5. 服务器端优化

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

点击这里查看完整代码。

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调试工具的使用可以参考这篇文章。

5.2 多进程优化

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

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

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

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

5.3 内存溢出处理

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

5.4 处理未捕获异常

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

也可以借助一些第三方监控平台如sentry来处理这类问题。sentry在node端的部署方法可以参考这里。

5.5 心跳包检测

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

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

具体步骤如下:

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

主进程代码如下:

子进程代码如下:

5.6 子进程自动重建

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

点击这里查看完整代码。

完结

文章到这里就结束了,如果有需要补充或者错误的地方,欢迎在评论区指出。


作者:monet
链接:https://juejin.cn/post/7289661061984501819

能大家在看到这个标题的时候,会觉得,只不过又是一篇烂大街的 SSR 从零入门的教程而已。别急,往下看,相信你或多或少会有一些不一样的收获呢。

在落地一种技术的时候,我们首先要想一想:

  1. 是否一定需要引入这种技术呢?他能解决什么问题,或者能带来什么收益?
  2. 为什么要采用这种技术选型而不是其他的?
  3. 引入了这种技术后,会带来什么问题吗(比如额外的开发成本等)?

上面三个问题思考清楚之后,才能真正地去落地。上面三个问题思考清楚之后,才能真正地去落地。而有赞教育接入服务端渲染,正是为了优化 H5 页面的首屏内容到达时间,带来更好的用户体验(顺便利于 SEO)。

说了这么多,以下开始正文。

一、后端模版引擎时代

在较早时期,前后端的配合模式为:后端负责服务层、业务逻辑层和模版渲染层(表现层);前端只是实现页面的交互逻辑以及发送 AJAX。比较典型的例子就是 JSP 或 FreeMarker 模板引擎负责渲染出 html 字符串模版,字符串模版里的 js 静态资源才是真正前端负责的东西。

而这种形式,就是天然的服务端渲染模式:用户请求页面 -> 请求发送到应用服务器 -> 后端根据用户和请求信息获取底层服务 -> 根据服务返回的数据进行组装,同时 JSP 或 FreeMarker 模版引擎根据组装的数据渲染为 html 字符串 -> 应用服务器讲 html 字符串返回给浏览器 -> 浏览器解析 html 字符串渲染 UI 及加载静态资源 -> js 静态资源加载完毕界面可交互。

那么既然后端模版引擎时代带来的效果就是我们想要的,那为啥还有以后让前端发展服务端渲染呢?因为很明显,这种模式从开发角度来讲还有挺多的问题,比如:

  1. 后端需要写表现层的逻辑,但其实后端更应该注重服务层(和部分业务逻辑层)。当然,其实也可以让前端写 JSP 或 FreeMarker,但从体验上来说,肯定不如写 JS 来的爽;
  2. 本地开发的时候,需要启动后端环境,比如 Tomcat,影响开发效率,对前端也不友好;
  3. 所赋予前端的能力太少,使得前端需要的一些功能只能由后端提供,比如路由控制;
  4. 前后端耦合。

二、SPA 时代

后来,诞生了 SPA(Single Page Application),解决了上面说的部分问题:

  1. 后端不需要关心表现层的逻辑,只需要注重服务层和业务逻辑层就可以了,暴露出相应的接口供前端调用。这种模式也同时实现了前后端解耦。
  2. 本地开发的时候,前端只需要启动一个本地服务,如:dev-server 就可以开始开发了。
  3. 赋予了前端更多的能力,比如前端的路由控制和鉴权,比如通过 SPA + 路由懒加载的模式可以带来更好的用户体验。

但同时,也带来了一些问题:

  1. 页面的 DOM 完全由 js 来渲染,使得大部分搜索引擎无法爬取渲染后真实的 DOM,不利于 SEO。
  2. 页面的首屏内容到达时间强依赖于 js 静态资源的加载(因为 DOM 的渲染由 js 来执行),使得在网络越差的情况下,白屏时间大幅上升。

三、服务端渲染

正因为 SPA 带来的一些问题(尤其是首屏白屏的问题),接入服务端渲染显得尤为必要。// 终于讲到服务端渲染这个重点了。

而正是 Node 的发展和基于 Virtual DOM 的前端框架的出现,使得用 js 实现服务端渲染成为可能。因此在 SPA 的优势基础上,我们顺便解决了因为 SPA 引入的问题:

  1. 服务端渲染的首屏直出,使得输出到浏览器的就是完备的 html 字符串模板,浏览器可以直接解析该字符串模版,因此首屏的内容不再依赖 js 的渲染。
  2. 正是因为服务端渲染输出到浏览器的是完备的 html 字符串,使得搜索引擎能抓取到真实的内容,利于 SEO。
  3. 同时,通过基于 Node 和前端 MVVM 框架结合的服务端渲染,有着比后端模版引擎的服务端渲染更明显的优势:可以优雅降级为客户端渲染(这个后续会讲,先占个坑)。

3.1 实现

既然服务端渲染能带来这么多好处,那具体怎么实现呢?从官网给出的原理图,我们可以清晰地看出:

  • Source 为我们的源代码区,即工程代码;
  • Universal Appliation Code 和我们平时的客户端渲染的代码组织形式完全一致,只是需要注意这些代码在 Node 端执行过程触发的生命周期钩子不要涉及 DOM 和 BOM 对象即可;
  • 比客户端渲染多出来的 app.js、Server entry 、Client entry 的主要作用为:app.js 分别给 Server entry 、Client entry 暴露出 createApp() 方法,使得每个请求进来会生成新的 app 实例。而 Server entry 和 Client entry 分别会被 webpack 打包成 vue-ssr-server-bundle.json 和 vue-ssr-client-manifest.json(这两个 json 文件才是有用的,app.js、Server entry 、Client entry 可以抽离,开发者不感知);
  • Node 端会根据 webpack 打包好的 vue-ssr-server-bundle.json,通过调用 createBundleRenderer 生成 renderer 实例,再通过调用 renderer.renderToString 生成完备的 html 字符串;
  • Node 端将 render 好的 html 字符串返回给 Browser,同时 Node 端根据 vue-ssr-client-manifest.json 生成的 js 会和 html 字符串 hydrate,完成客户端激活 html,使得页面可交互。

3.2 优化

按照 Vue SSR 官方文档建立起一个服务端渲染的工程后,是否就可以直接上线了呢?别急,我们先看看是否有什么可以优化的地方。

3.2.1 路由和代码分割

一个大的 SPA,主文件 js 往往很大,通过代码分割可以将主文件 js 拆分为一个个单独的路由组件 js 文件,可以很大程度上减小首屏的资源加载体积,其他路由组件可以预加载。

 复制代码

// router.js
constIndex =()=>import(/* webpackChunkName: "index" */'./pages/Index.vue');
constDetail =()=>import(/* webpackChunkName: "detail" */'./pages/Detail.vue');
constroutes = [
{
path:'/',
component: Index
},
{
path:'/detail',
component: Detail
}
];
constrouter =newRouter({
mode:'history',
routes
});

3.2.2 部分模块(不需要 SSR 的模块)客户端渲染

因为服务端渲染是 CPU 密集型操作,非首屏的模块或者不重要的模块(比如底部的推荐列表)完全可以采用客户端渲染,只有首屏的核心模块采用服务端渲染。这样做的好处是明显的:1. 较大地节省 CPU 资源;2. 减小了服务端渲染直出的 html 字符串长度,能够更快地响应给浏览器,减小白屏时间。

 复制代码

// Index.vue
asyncData({ store }) {
returnthis.methods.dispatch(store);// 核心模块数据预取,服务端渲染
}
mounted() {
this.initOtherModules();// 非核心模块,客户端渲染,在 mounted 生命周期钩子里触发
}

3.2 3 页面缓存 / 组件缓存

页面缓存一般适用于状态无关的静态页面,命中缓存直接返回页面;组件缓存一般适用于纯静态组件,也可以一定程度上提升性能。

 复制代码

// page-level caching
constmicroCache = LRU({
max:100,
maxAge:1000// 重要提示:条目在 1 秒后过期。
})
server.get('*', (req, res) => {
consthit = microCache.get(req.url)
if(hit) {// 命中缓存,直接返回页面
returnres.end(hit)
}
// 服务端渲染逻辑
...
})

 复制代码

// component-level caching
// server.js
constLRU =require('lru-cache')
constrenderer = createRenderer({
cache: LRU({
max:10000,
maxAge: ...
})
});
// component.js
exportdefault{
name:'item',// 必填选项
props: ['item'],
serverCacheKey:props=>props.item.id,
render (h) {
returnh('div',this.item.id)
}
};

3.2.4 页面静态化

如果工程中大部分页面都是状态相关的,所以技术选型采用了服务端渲染,但有部分页面是状态无关的,这个时候用服务端渲染就有点浪费资源了。像这些状态无关的页面,完全可以通过 Nginx Proxy Cache 缓存到 Nginx 服务器,可以避免这些流量打到应用服务器集群,同时也能减少响应的时间。

3.3 降级

进行优化之后,是否就可以上线了呢?这时我们想一想,万一服务端渲染出错了怎么办?万一服务器压力飙升了怎么办(因为服务端渲染是 CPU 密集型操作,很耗 CPU 资源)?为了保证系统的高可用,我们需要设计一些降级方案来避免这些。具体可以采用的降级方案有:

  • 单个流量降级 – 偶发的服务端渲染失败降级为客户端渲染
  • Disconf / Apollo 配置降级 – 分布式配置平台修改配置主动降级,比如可预见性的大流量情况下(双十一),可提前通过配置平台将整个应用集群都降级为客户端渲染
  • CPU 阈值降级 – 物理机 / Docker 实例 CPU 资源占用达到阈值触发降级,避免负载均衡服务器在某些情况下给某台应用服务器导入过多流量,使得单台应用服务器的 CPU 负载过高
  • 旁路系统降级 – 旁路系统跑定时任务监控应用集群状态,集群资源占用达到设定阈值将整个集群降级(或触发集群的自动扩容)
  • 渲染服务集群降级 – 若渲染服务和接口服务是独立的服务,当渲染服务集群宕机,html 的获取逻辑回溯到 Nginx 获取,此时触发客户端渲染,通过 ajax 调用接口服务获取数据

3.4 上线前准备

3.4.1 压测

压测可以分为多个阶段:本地开发阶段、QA 性能测试阶段、线上阶段。

  • 本地开发阶段:当本地的服务端渲染开发完成之后,首先需要用 loadtest 之类的压测工具压下性能如何,同时可以根据压测出来的数据做一些优化,如果有内存泄漏之类的 bug 也可以在这个阶段就能被发现。
  • QA 性能测试阶段:当通过本地开发阶段的压测之后,我们的代码已经是经过性能优化且没有内存泄漏之类严重 bug 的。部署到 QA 性能测试环境之后,通过压真实 QA 环境,和原来的客户端渲染做对比,看 QPS 会下降多少(因为服务端渲染耗更多的 CPU 资源,所以 QPS 对比客户端渲染肯定会有下降)。
  • 线上阶段:QA 性能测试阶段压测过后,若性能指标达到原来的预期,部署到线上环境,同时可以开启一定量的压测,确保服务的可用性。

3.4.2 日志

作为生产环境的应用,肯定不能“裸奔”,必须接入日志平台,将一些报错信息收集起来,以便之后问题的排查。

3.4.3 灰度

如果上线服务端渲染的工程是提供核心服务的应用,应该采用灰度发布的方式,避免全量上线。一般灰度方案可以采用:百分比灰度、白名单灰度、自定义标签灰度。具体采用哪种灰度方式看场景自由选择,每隔一段时间观察灰度集群没有问题,所以渐渐增大灰度比例 / 覆盖范围,直到全量发布。

3.5 落地

在有赞电商的服务端渲染的落地场景中,我们抽离了单独的依赖包,提供各个能力。

3.6 效果

从最终的上线效果来看,相同功能的页面,服务端渲染的首屏内容时间比客户端渲染提升了 300%+。

3.7 Q & A

Q1:为什么服务端渲染就比客户端渲染快呢?

A:首先我们明确一点,服务端渲染比客户端渲染快的是首屏的内容到达时间(而非首屏可交互时间)。至于为什么会更快,我们可以从两者的 DOM 渲染过程来对比:

客户端渲染:浏览器发送请求 -> CDN / 应用服务器返回空 html 文件 -> 浏览器接收到空 html 文件,加载的 css 和 js 资源 -> 浏览器发送 css 和 js 资源请求 -> CDN / 应用服务器返回 css 和 js 文件 -> 浏览器解析 css 和 js -> js 中发送 ajax 请求到 Node 应用服务器 -> Node 服务器调用底层服务后返回结果 -> 前端拿到结果 setData 触发 vue 组件渲染 -> 组件渲染完成

服务端渲染:浏览器发送请求 -> Node 应用服务器匹配路由 -> 数据预取:Node 服务器调用底层服务拿到 asyncData 存入 store -> Node 端根据 store 生成 html 字符串返回给浏览器 -> 浏览器接收到 html 字符串将其激活:

我们可以很明显地看出,客户端渲染的组件渲染强依赖 js 静态资源的加载以及 ajax 接口的返回时间,而通常一个 page.js 可能会达到几十 KB 甚至更多,很大程度上制约了 DOM 生成的时间。而服务端渲染从用户发出一次页面 url 请求之后,应用服务器返回的 html 字符串就是完备的计算好的,可以交给浏览器直接渲染,使得 DOM 的渲染不再受静态资源和 ajax 的限制。

Q2:服务端渲染有哪些限制?

A:比较常见的限制比如:

  1. 因为渲染过程是在 Node 端,所以没有 DOM 和 BOM 对象,因此不要在常见的 Vue 的 beforeCreate 和 created 生命周期钩子里做涉及 DOM 和 BOM 的操作
  2. 对第三方库的要求比较高,如果想直接在 Node 渲染过程中调用第三方库,那这个库必须支持服务端渲染

Q3:如果我的需求只是生成文案类的静态页面,需要用到服务端渲染吗?

A:像这些和用户状态无关的静态页面,完全可以采用预渲染的方式(具体见 Vue SSR 官方指南),服务端渲染适用的更多场景会是状态相关的(比如用户信息相关),需要经过 CPU 计算才能输出完备的 html 字符串,因此服务端渲染是一个 CPU 密集型的操作。而静态页面完全不需要涉及任何复杂计算,通过预渲染更快且更节省 CPU 资源。