服务端渲染-SSR

372 阅读10分钟

定义

Server-Side Rendering 我们称其为SSR,意为服务端渲染

指由服务侧完成页面的 HTML 结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程

先来看看Web3个阶段的发展史:

  • 传统服务端渲染SSR-网页内容在服务端渲染完成,⼀次性传输到浏览器,浏览器拿到的是全部的dom结构
  • 单页面应用SPA-单页应用优秀的用户体验,使其逐渐成为主流,页面内容由JS渲染出来,这种方式称为客户端渲染,打开页面查看源码,浏览器拿到的仅有宿主元素#app,并没有内容
  • 服务端渲染SSR-SSR解决方案,后端渲染出完整的首屏的dom结构返回,前端拿到的内容包括首屏及完整spa结构,应用激活后依然按照spa方式运行,其实这是同构渲染,无论是Vue还是React的SSR实现原理都类似,是服务端渲染和客户端渲染的结合

优缺点

SSR主要解决了以下两种问题:

  • seo:搜索引擎优先爬取页面HTML结构,使用ssr时,服务端已经生成了和业务想关联的HTML,有利于seo
  • 首屏呈现渲染:用户无需等待页面所有js加载完成就可以看到页面视图(压力来到了服务器,所以需要权衡哪些用服务端渲染,哪些交给客户端)

但是使用SSR同样存在以下的缺点:

  • 复杂度:整个项目的复杂度

  • 库的支持性,代码兼容

  • 服务器负载变大,相对于前后端分离务器只需要提供静态资源来说,服务器负载更大,所以要慎重使用

Vue-SSR

渲染流程

  • 首先浏览器向服务器请求,然后服务器根据请求的路由,会匹配相关的路由组件,然后执行组件的自定义服务端生命周期(例:Nuxt的asyncData)或者自定义获取数据的hook,并且把执行后的数据收集起来,统一在window的属性中存储

  • 然后vue的组件会被renderToString渲染成静态HTML字符串,替换掉index.html的提前指定的占位代码。然后index.html改变后的静态字符串发给客户端

  • 客户端拿到后,首先对数据进行初始化,然后进行激活,因为当前html只是静态数据,激活主要做两件事

    1. 把页面中的DOM元素与虚拟DOM之间建立联系
    2. 为页面中的DOM元素添加事件绑定

例子

1. 创建项目

  • 首先用vite命令创建项目pnpm create vite vue-ssr --template vue-ts

    • 安装相关依赖:pnpm add express pinia vue-router@4
  • 创建三个文件 touch server.js src/entry-client.ts src/entry-server.js

    • server.js:服务端启动文件
    • entry-client.ts:客户端入口,应用挂载元素
    • entry-server.js:服务端入口,处理服务端逻辑和静态资源
  • 修改package.json运行脚本

    "scripts": {
      "dev": "node server", // 运行开发环境
    }
    
  • 然后需要把应用创建都改为函数的方式进行调用创建,因为在SSR环境下,和纯客户端不一样,服务器只会初始化一次,所以为了防止状态污染,每次请求必须是全新的实例

    // src/main.ts
    import { createSSRApp } from 'vue'
    import App from './App.vue'
    import { createRouter } from './router'
    import { createPinia } from 'pinia'
    
    export function createApp() {
      const app = createSSRApp(App)
      const router = createRouter()
      const pinia = createPinia()
      app.use(router)
      app.use(pinia)
      return { app, router, pinia }
    }
    
  • router同理

    // src/router/index
    import { createRouter as _createRrouter, createMemoryHistory, createWebHistory, RouteRecordRaw } from 'vue-router'
    
    const routes: RouteRecordRaw[] = [
      ...
    ]
    
    export function createRouter() {
      return _createRrouter({
        history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
        routes,
      })
    }
    
  • 然后修改index.html,增加注释占位和客户端入口文件,在之后的服务端渲染时注入

    <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <link rel="icon" type="image/svg+xml" href="/vite.svg" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Vite + Vue + TS</title>
      <!-- 静态资源占位 .js .css ... -->
      <!--preload-links-->
    </head>
    <body>
      <!-- 应用代码占位 -->
      <div id="app"><!--ssr-outlet--></div>
      <script type="module" src="/src/main.ts"></script>
      <!-- 引用客户端入口文件 -->
      <script type="module" src="/src/entry-client.ts" ></script>
      <script>
        // 服务端获取的数据统一挂载到window上
        window.__INITIAL_STATE__ = '<!--pinia-state-->'
      </script>
    </body>
    </html>
    

2. 服务端启动文件

  • 创建项目后,就开始编写服务端启动文件,也就是项目根路径下的server.js文件

  • 这个文件的功能是启动一个node服务,然后根据请求,读取html文件,处理资源后把注释进行替换,最后把html发送给客户端

    import fs from 'fs'
    import path from 'path'
    import { fileURLToPath } from 'url'
    import express from 'express'
    
    import { createRequire } from 'module';
    const __dirname = path.dirname(fileURLToPath(import.meta.url))
    const require = createRequire(import.meta.url);
    const resolve = (p) => path.resolve(__dirname, p);
    
    const createServer = async () => {
    // 创建node服务
    const app = express()
    
    /**
     * @官方解释
     * 以中间件模式创建vite应用,这将禁用vite自身的HTML服务逻辑
     * 并让上级服务器接管
     */
    const vite = await require('vite').createServer({
      server: {
        middlewareMode: true,
      },
      appType: 'custom'
    });
    app.use(vite.middlewares);
    
    app.use('*', async (req, res, next) => {
      const url = req.originalUrl
      try {
        // 读取index.html
        let template = fs.readFileSync(
          resolve('index.html'),
          'utf-8'
        )
        // 应用vite html转换,会注入vite HMR
        template = await vite.transformIndexHtml(url, template)
    
        // 加载服务端入口
        const render = (await vite.ssrLoadModule('/src/entry-server.js')).render
        const [ appHtml, piniaState ] = await render(url)
    
        // 替换处理过后的模版
        const html = template
          .replace(`<!--ssr-outlet-->`, appHtml)
          .replace(`<!--pinia-state-->`, piniaState)
        res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
      } catch (error) {
        vite?.ssrFixStacktrace(error)
        next(e)
      }
    })
    
    // 监听5100端口
    app.listen(5100)
    }
    
    createServer();
    

3. 服务端入口文件

  • 服务端入口文件主要是调用SSR的renderToString和收集需要发送的资源和数据

    import { renderToString } from 'vue/server-renderer'
    import { createApp } from './main'
    
    export async function render(url, manifest) {
      const { app, router, pinia } = createApp()
    
      router.push(url)
      await router.isReady()
    
      const ctx = {}
      const html = await renderToString(app, ctx)
      return [html, JSON.stringify(pinia.state.value)]
    }
    

4. 客户端入口文件

  • 客户端入口文件主要用于挂载节点和初始化数据

    import { createApp } from './main'
    
    const { app, router, pinia } = createApp()
    
    router.isReady().then(() => {
      if (window.__INITIAL_STATE__) {
        pinia.state.value = JSON.parse(window.__INITIAL_STATE__);
      }
    
      app.mount('#app')
    })
    

5. 组件和页面

  • 组件和页面获取数据主要有两种方式,一种是增加一个asyncData选项,然后在enter-server.js的逻辑中增加遍历当前组件的逻辑,统一触发asyncData,但是现在都是用script setup的方式写业务代码,所以有点麻烦,

    <script>
    export defualt {
      asyncData() {
        // 服务端获取数据逻辑
      }
    }
    </script>
    
    <script setup lang='ts'>
    ...
    </script>
    
  • 另一种就是hook的方式,通过import.meta.env.SSR的方式进行判断

  • 对于数据具体存储方式,大概有三种,一种是存在vuex或者pinia这种全局状态库中,一种是存在context上下文中,还有一种是自定义数据

6. 生产环境

6.1 pacnakge.json
  • 增加构建脚本

    "scripts": {
      "dev": "node server",
      "build": "npm run build:client && npm run build:server",
      "build:client": "vite build --ssrManifest --outDir dist/client",
      "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
      "serve": "cross-env NODE_ENV=production node server"
    },
    
6.2 服务端运行文件
  • 针对生产环境,需要启动静态资源服务,引用路径需要改为dist目录下
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import express from 'express'

import { createRequire } from 'module';
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url);
const resolve = (p) => path.resolve(__dirname, p);

const createServer = async (isProd = process.env.NODE_ENV === 'production') => {
  const app = express()

-  const vite = await require('vite').createServer({
-    server: {
-      middlewareMode: true,
-    },
-    appType: 'custom'
-  });
-  app.use(vite.middlewares);

+  let vite;
+  if (isProd) {
+    app.use(require('compression')());
+    app.use(
+      require('serve-static')(resolve('./dist/client'), {
+        index: false
+      })
+    );
+  } else {
+    vite = await require('vite').createServer({
+      server: {
+        middlewareMode: true,
+      },
+      appType: 'custom'
+    });
+    app.use(vite.middlewares);
+  }
   // 通过bulid --ssrManifest命令生成的静态资源映射需要在生产环境下引用
+  const manifest = isProd ? fs.readFileSync(resolve('./dist/client/ssr-manifest.json'), 'utf-8') :{}
  
  app.use('*', async (req, res, next) => {
    const url = req.originalUrl
    try {
-      let template = fs.readFileSync(
-        resolve('index.html'),
-        'utf-8'
-      )
-      template = await vite.transformIndexHtml(url, template)
-      const render = (await vite.ssrLoadModule('/src/entry-server.js')).render
-      const [ appHtml, piniaState ] = await render(url)

+      let template, render
+      if (isProd) {
+        template = fs.readFileSync(resolve('./dist/client/index.html'), 'utf-8')
+        render = (await import('./dist/server/entry-server.js')).render
+      } else {
+        template = fs.readFileSync(
+          resolve('index.html'),
+          'utf-8'
+        )
+        template = await vite.transformIndexHtml(url, template)
+        render = (await vite.ssrLoadModule('/src/entry-server.js')).render
+      }
+      const [ appHtml, preloadLinks, piniaState ] = await render(url, manifest)
      const html = template
+       .replace(`<!--preload-links-->`, preloadLinks)
        .replace(`<!--ssr-outlet-->`, appHtml)
        .replace(`<!--pinia-state-->`, piniaState)
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (error) {
      vite?.ssrFixStacktrace(error)
      next()
    }
  })

  app.listen(5100)
}

createServer();
6.3 服务端入口文件
  • 服务端入口文件主要是增加了构建时生成的静态资源映射处理的逻辑

    **

    import { basename } from 'path'
    import { renderToString } from 'vue/server-renderer'
    import { createApp } from './main'
    
    export async function render(url, manifest) {
      const { app, router, pinia } = createApp()
    
      router.push(url)
      await router.isReady()
    
      const ctx = {}
      const html = await renderToString(app, ctx)
      const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
      return [html, preloadLinks, JSON.stringify(pinia.state.value)]
    }
    
    function renderPreloadLinks(modules, manifest) {
      let links = ''
      const seen = new Set()
      modules.forEach((id) => {
        const files = manifest[id]
        if (files) {
          files.forEach((file) => {
            if (!seen.has(file)) {
              seen.add(file)
              const filename = basename(file)
              if (manifest[filename]) {
                for (const depFile of manifest[filename]) {
                  links += renderPreloadLink(depFile)
                  seen.add(depFile)
                }
              }
              links += renderPreloadLink(file)
            }
          })
        }
      })
      return links
     }
     
     function renderPreloadLink(file) {
      if (file.endsWith('.js')) {
        return `<link rel="modulepreload" crossorigin href="${file}">`
      } else if (file.endsWith('.css')) {
        return `<link rel="stylesheet" href="${file}">`
      } else if (file.endsWith('.woff')) {
        return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
      } else if (file.endsWith('.woff2')) {
        return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
      } else if (file.endsWith('.gif')) {
        return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
      } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
        return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
      } else if (file.endsWith('.png')) {
        return ` <link rel="preload" href="${file}" as="image" type="image/png">`
      } else {
        return ''
      }
    }
    

React-SSR

image.png node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 propscontext或者store 形式传入组件,然后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件渲染为 html字符串或者 stream 流, 在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器端可以得到数据(脱水),浏览器开始进行渲染和节点对比,然后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束

例子

1、创建项目

mkdir react-ssr
cd react-ssr
npm init -y

2、项目目录结构分析

├── src
│   ├── client
│   │   ├── index.js // 客户端业务入口文件
│   ├── server
│   │   └── index.js // 服务端业务入口文件
│   ├── container    // React 组件
│   │   └── Home
│   │       └── Home.js
│   │
├── config // 配置文件夹
│   ├── webpack.client.js // 客户端配置文件
│   ├── webpack.server.js // 服务端配置文件
│   ├── webpack.common.js // 共有配置文件
├── .babelrc // babel 配置文件
├── package.json

首先我们编写一个简单的 React 组件, container/Home/Home.js

import React from "react";

const Home = ()=>{
  return (
    <div>
      hello world      <br/>
      <button onClick={()=> alert("hello world")}>按钮</button>
    </div>
  )
}

export default Home;

安装客户端渲染的惯例,我们写一个客户端渲染的入口文件, client/index.js

import React from "react";
import ReactDom from "react-dom";
import Home from "../containers/Home";

ReactDom.hydrate(<Home/>,document.getElementById("root"));
// ReactDom.render(<Home/>,document.getElementById("root"));

以前看到的都是调用 render 方法,这里使用 hydrate 方法,它的作用是什么?

ReactDOM.hydrate

render() 相同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。 React 会尝试在已有标记上绑定事件监听器。

我们都知道纯粹的 React 代码放在浏览器上是无法执行的,因此需要打包工具进行处理,这里我们使用 webpack ,下面我们来看看 webpack 客户端的配置:

webpack.common.js

module.exports = {
  module:{
    rules:[
      {
        test: /.js$/,
        exclude: /node_modules/,
        loader: "babel-loader",
      }
    ]
  }
}

.babelrc

    {
      "presets":[
        ["@babel/preset-env"],
        ["@babel/preset-react"]
      ]
    }

webpack.client.js

const path = require("path");
const {merge} = require("webpack-merge");
const commonConfig = require("./webpack.common");

const clientConfig = {
  mode: "development",
  entry:"./src/client/index.js",
  output:{
    filename:"index.js",
    path:path.resolve(__dirname,"../public")
  },
}

module.exports = merge(commonConfig,clientConfig);

代码解析:通过 entry 配置的入口文件,对 React 代码进行打包,最后输出到 public 目录下的 index.js

在以往,直接在 HTML 引入这个打包后的 JS 文件,界面就显示出来了,我们称之为纯客户端渲染。这里我们就不这样使用,因为我们还需要服务端渲染。

接下来,看看服务端渲染文件 server/index.js

import express from "express";
import { renderToString } from "react-dom/server";
import React from "react";
import Home from "../containers/Home";

const app = express(); // {1}
app.use(express.static('public')) // {2}
const content = renderToString(<Home />); //{3}

app.get('/',function (req,res) {
  // {4}
  res.send(`    <!doctype html>    <html lang="en">      <head>          <meta charset="UTF-8">          <meta name="viewport"                content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">          <meta http-equiv="X-UA-Compatible" content="ie=edge">          <title>React SSR</title>      </head>      <body>        <div id="root">${content}</div>        <script src="/index.js"></script>        </body>    </html>  `)
})

app.listen(3000);

代码解析:

  • {1},创建一个 express 实例对象
  • {2},开启一个静态资源服务,监听 public 目录,还记得客户端的打包文件就放到了 public 目录了把,这里通过监听,我们就可以这样 localhost:3000/index.js 访问该静态资源
  • {3},把 React 组件通过 renderToString 方法生成 HTML
  • {4},当用户访问 localhost:3000 时便会返回 res.send 中的 HTML 内容,该 HTML 中把 React 生成的 HTML 片段也插入进去一同返回给用户了,这样就实现了服务端渲染。通过 <script src="/index.js"></script> 这段脚本加载了客户端打包后的 React 代码,这样就实现了客户端渲染,因此一个简单同构项目就这样实现了。

你会发现一个奇怪的现象,为什么写 Node.js 代码使用的却是 ESModule 语法,是的没错,因为我们要在服务端解析 React 代码,作为同构项目,因此统一语法也是非常必要的。所以 Node.js 也需要配置相应的 webpack 编译文件:

webpack.server.js

const path = require("path");
const nodeExternals = require("webpack-node-externals");
const {merge} = require("webpack-merge");
const commonConfig = require("./webpack.common");

const serverConfig = {
  target:"node", //为了不把nodejs内置模块打包进输出文件中,例如: fs net模块等;
  mode: "development",
  entry:"./src/server/index.js",
  output:{
    filename:"bundle.js",
    path:path.resolve(__dirname,"../build")
  },
  externals:[nodeExternals()], //为了不把node_modules目录下的第三方模块打包进输出文件中,因为nodejs默认会去node_modules目录下去寻找和使用第三方模块。
};

module.exports = merge(serverConfig,commonConfig);

到此我们就完成了一个简单的同构项目,这里您应该会有几个疑问?

  1. renderToString 有什么作用?
  2. 为什么服务端加载了一次,客户端还需要再次加载呢?
  3. 服务端加载了 React 输出的代码片段,客户端又执行了一次,这样是不是会加载两次导致资源浪费呢?
ReactDOMServer.renderToString(element)

React 元素渲染为初始 HTMLReact 将返回一个 HTML 字符串。你可以使用此方法在服务端生成 HTML ,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。

为什么服务端加载了一次,客户端还需要再次加载呢?

原因很简单,服务端使用 renderToString 渲染页面,而 react-dom/server 下的 renderToString 并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定,渲染出来的页面只是一个静态的 HTML 页面。只有在客户端渲染 React 组件并初始化 React 实例后,才能更新组件的 stateprops ,初始化 React 的事件系统,让 React 组件真正“ 动” 起来