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

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

一、SSR、CSR

1. 什么是服务端渲染

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

2. 什么是客户端渲染

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

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

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

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

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 是没有意义的。

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

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

2. 一个同构案例

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

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

代码如下:

js 复制代码
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

js 复制代码
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的文件里:

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 模板如下:

js 复制代码
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 了:

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

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

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

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

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

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

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

js 复制代码
// 组件中的代码
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 标签中:

js 复制代码
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;否则就让客户自己再请求一次接口,代码如下:

js 复制代码
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 服务端渲染和同构原理
相关推荐
腾讯TNTWeb前端团队1 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰5 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪5 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪5 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy6 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom6 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom7 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom7 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom7 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom7 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试