服务端渲染 SSR 、最核心的同构渲染

956 阅读11分钟

由于我司的 C 端商城项目是基于 Vue2 + Nuxt.js 框架实现服务端渲染的海外电商家具平台,维护了这个项目很久,但是一直没去了解服务端渲染的相关知识,趁着现在刚好有时间深入了解 SSR 的内容,网上的相关资料感觉比较乱,因此自己总结了一些关于服务端渲染的知识,在这里与大家分享!

一、SSR、CSR

1. 什么是服务端渲染

服务端渲染(Server-Side Rendering,SSR)是一种将页面的渲染过程从客户端移动到服务器端的技术。服务端渲染首先是在服务器端生成完整的 HTML 页面,然后再将其发送给客户端;服务器端执行一部分或全部的页面渲染工作,包括数据获取、模板渲染等,最终生成带有动态内容的完整 HTML 页面返回给客户端;客户端接收到的页面已经包含了初始化的内容,用户可以更快地看到页面的完整内容和交互功能。

image.png

2. 什么是客户端渲染

客户端渲染(Client-Side Rendering,CSR):在用户访问页面时,会先下载 HTML、CSS 和 JavaScript 文件,然后通过 JavaScript 在客户端完成页面的渲染。

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

image.png

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

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

image.png

3. 客户端渲染的优缺点

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

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

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

4. 服务端渲染的优缺点

相对于客户端渲染,服务端渲染有以下几个主要优势:

  1. 首屏加载速度更快:由于服务器端已经在渲染过程中生成了完整的 HTML 页面,可以直接发送给客户端,用户无需等待 JavaScript 文件下载和执行,可以更快地看到页面内容。
  2. 更好的 SEO:搜索引擎爬虫可以直接抓取到完整的 HTML 页面内容,能够更好地索引和理解页面的信息,对搜索引擎优化(SEO)更友好。
  3. 更好的用户体验:用户在等待页面加载完成时不会看到空白页面或加载中的状态,可以更快地与页面进行交互,提升用户体验。

需要注意的是,服务端渲染也有一些局限性:

  1. 由于服务端渲染会在每次请求时都重新生成完整的 HTML 页面,页面的状态不会像客户端渲染那样被保留,可能需要额外的开发工作来处理页面状态的恢复和持久化。
  2. 同构资源的处理:劣势在于程序需要具有通用性。结合 Vue 的钩子来说,能在 SSR 中调用的生命周期只有 beforeCreatecreated,这就导致在使用三方 API 时必须保证运行不报错;在三方库的引用时需要特殊处理使其支持服务端和客户端都可运行。
  3. 部署构建配置资源的支持:劣势在于运行环境单一,程序需处于 node.js server 运行环境。基于 node 的服务端渲染,难得不是渲染而是高可用的 node 服务才是麻烦的地方。
  4. 服务器更多的缓存准备:劣势在于高流量场景需采用缓存策略,应用代码需在双端运行解析,cpu 性能消耗更大,负载均衡和多场景缓存处理比 SPA 做更多准备。

二、同构渲染

1. 什么是同构渲染

CSRSSR 的优劣势是互补的,所以只要把它们二者结合起来,就能实现理想的渲染方法,也就是同构渲染。同构的理念十分简单,最开始的步骤和 SSR 相同,将生成的 html 字符串返回给浏览器即可;但同时可以将 CSR 生成的 JS 也一并发送给用户;这样浏览器在接收到 SSR 生成的 html 后,页面还会再执行一次 CSR 的流程。

一般是指服务端和客户端同构,意思是服务端和客户端运行同一套代码程序,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。SSR 的核心就是同构,没有同构的 SSR 是没有意义的。

image.png

当然同构渲染也是有一些缺点的:

  1. 浏览器特定的代码只能在某些生命周期钩子函数中使用
  2. 一些外部的库可能要经过特殊的处理才能在服务端渲染中使用
  3. 不能在服务端渲染期间操作DOM
  4. 某些代码需要区分运行环境

2. 一个同构案例

服务器端渲染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);
});

通过服务器发送html字符串: 启动服务器,然后在浏览器访问 http://localhost:3000

image.png

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')
})

激活客户端渲染: 如果你访问过上面的地址,就会发现页面上的按钮是点不动的,这是因为通过 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 防止重复渲染。

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

const htmlStr = `
  <!DOCTYPE html>
  <html>
    <head>
      <title>Vue SSR Example</title>
      // 将client-entry.js文件路径写入script
      <script type="module" src="/client-entry.js"></script>
    </head>
    <body>
      <div id="app">${html}</div>
    </body>
  </html>
`;

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

image.png

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

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

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

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

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

注水其实就是前面提到过的客户端激活,区别只是前面的没有数据,而这次我们会试着加上数据。

实现服务器端脱水: 为了让服务器获取到我们要请求的接口,我们可以在 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__ 里面,此时客户端就可以从这个对象里面拿到数据了。

实现客户端注水: 先判断 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. 同构需要注意的几点

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

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

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

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

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

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

上面的讲解只是一个最基础的同构渲染,但距离一个能在开发中实际使用的框架还差得很远。如果要创建实际生产中的同构应用,至少还要解决下面几个问题:

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

三、Nuxt.js 框架

Nuxt 是基于 Vue ssr 之上,集成了 Vue-Router,Vuex,Webpack 等框架、组件的一个服务端渲染框架,其实 Nuxt 就是一个升级版的 Vue ssr,为我们预设了服务端渲染的应用所需要的各种配置,但是相应的,Nuxt 的入侵性是特别高的,我们需要理解 Nuxt 的思路,才能发挥它的优势。

这里不再对 nuxt 展开讲解,需要进一步了解该框架的可以参考以下文章:

  1. Nuxt中文文档
  2. Vue同构赋能之 NUXT 篇

四、本文参考的文章

  1. 前端页面秒开的关键 - 小白也能看懂的同构渲染原理和实现(含nodejs服务端测试与优化,附PPT)
  2. 同构渲染--Nuxt
  3. 长文慎入 一文吃透 React SSR 服务端渲染和同构原理