Vue服务端渲染改造

Vue 作为前端框架,除了支持单页面应用的普通客户端渲染外,还提供了服务端渲染能力,这需要借助 Node.js 来实现,并且 Vue 的服务端渲染是代码同构的,即服务端运行的代码和客户端运行的代码可以使用一套,极大地提升了服务端渲染的可维护性。

同构问题

我们知道,服务端渲染只负责首屏内容,首屏之后的用户交互还是需要在客户端进行的,这就涉及是否需要单独为首屏写一套代码,而且这套代码是否和不用服务端渲染的代码兼容,这就涉及代码同构问题。

所谓同构,就是采用一套代码构建客户端和服务端逻辑,最大限度地重用代码,不用维护两套代码,如图 10-4 所示。

image 2024 02 24 07 43 59 845
Figure 1. 图10-4 代码同构

可以设想一个场景,在使用了服务端渲染的项目中,当需要在首屏内容中添加一张图片时,我们需要在服务端渲染逻辑中添加这个图片相关的代码,但是并不是所有情况下都能使用服务端渲染,因为我们必须为服务端渲染失败时预留容错逻辑,即客户端渲染首屏的这部分逻辑还要保留,所以还需要在客户端渲染逻辑中添加图片相关的代码,这就造成了需要维护两套代码。而 Vue 给我们提供的服务端渲染则会避免这种情况发生。

注意,同构并不是一模一样,如果有需要判断平台端的逻辑且有不同的业务表现,还是需要有这部分代码的。

二次渲染

image 2024 02 24 07 50 28 877
Figure 2. 图10-5 二次渲染

基于 Vite 的服务端渲染概述

用一句话来总结 Vue.js 服务端渲染:基于正常的客户端渲染逻辑编写好代码,然后通过构建来生成客户端渲染使用的文件和服务端渲染使用的文件,并结合 Node.js 提供服务。这里的构建可以通过 Vue Cli 实现,也可以通过 Vite 实现。这里主要介绍基于 Vite 的服务端渲染配置。

服务端渲染的主要步骤概括如下:

  • 使用 Vite 创建正常的客户端渲染项目脚手架。

  • 基于服务端渲染逻辑和客户端渲染逻辑改造 main.js

  • 跑通正常的客户端渲染开发和生产构建流程。

  • 创建 Node.js 服务端 server.js 逻辑,结合 Vite 跑通基于服务端渲染的开发流程。

  • 改造 Node.js 服务端 server.js 逻辑,跑通服务端渲染生产构建流程。

  • 配置 package.json 中定义的命令,以完成改造。

其主要流程如图 10-6 所示。

image 2024 02 24 07 53 29 505
Figure 3. 图10-6 服务端渲染构建步骤

下面对这些步骤进行逐一讲解。

创建 Vite 项目

其实不需要把服务端渲染想象成一个很复杂的东西,它只是对一个正常的客户端 Vite 项目进行改造集成而已。

首先利用 Vite 创建一个项目,这里我们可以延用 8.2 节的 myapp 项目,其目录结构如图 10-7 所示。

image 2024 02 24 07 56 31 454
Figure 4. 图10-7 Vite 初始化目录结构

其中 index.html 是单页面项目访问的入口文件,main.js 本身是用来创建项目 Vue 的根实例,而改造客户端渲染和服务端渲染逻辑可以从这个文件作为出入口进行区分。

改造main.js

main.js 同级创建两个文件:entry-client.jsentry-server.js,分别作为客户端渲染逻辑入口文件和服务端渲染逻辑入口文件,然后改造 main.js,使其作为统一的根实例出口,其代码如下:

// src/main.js
import App from './App.vue'
import { createSSRApp } from 'vue'
import { createRouter } from './router'
export function createApp() {
    // 如果使用服务端渲染,则需要将createApp替换为createSSRApp
    const app = createSSRApp(App)
    // 路由(有就引入,Store也一样)
    const router = createRouter()
    app.use(router)
    // 将根实例以及路由暴露给调用者
    return { app, router }
}

修改 entry-client.js 内容,和正常客户端渲染逻辑一样,调用 mount 方法将应用挂载到一个 DOM 元素上,其代码如下:

// src/entry-client.js
import { createApp } from './main'
const { app, router } = createApp()
// 针对有懒加载路由组件的情况,需等待路由解析完
router.isReady().then(() => {
    app.mount('#app')
})

上面的代码中,唯一的区别就是针对所有路由都是异步懒加载的情况,需要等到路由解析完才能进行挂载。然后修改 index.html,将引入的 main.js 改为 entry-client.js,代码如下:

// index.html
<body>
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry-client.js"></script>
</body>

注意,entry-client.js 也包括后续除了首屏之外的前端用户交互逻辑,所以必须引入,vite.config.js 暂不需要修改。为了区分服务端渲染和客户端渲染的构建命令,为其添加上 client 后缀,将 package.jsondev 命令和 build 命令改造如下:

// package.json
"scripts": {
    "dev:client": "vite",
    "build:client": "vite build --outDir dist/client --ssrManifest"
},

对于 build:client 命令,--outDir 参数为其指定了构建后所产生的文件存放的目录地址,--ssrManifest 表示在进行客户端生产构建后,会生成一个 ssr-manifest.json 文件,这个文件标识了静态资源的映射信息,这样在服务端渲染时,它就可以自动推断并向渲染出来的 HTML 中注入需要 preload/prefetch 的资源,并且包括懒加载的组件所对应的资源。

preload/prefetch 两者以 <link rel="preload"><link rel="prefetch"> 作为引入方式,其主要作用和区别如下:

  • preload:基本的用法是提前加载资源,告诉浏览器预先请求当前页需要的资源,从而提高这些资源的请求优先级,加载但是不运行,占用浏览器对同一个域名的并发数。

  • prefetch:基本用法是浏览器会在空闲的时候下载资源并缓存起来。当有页面使用的时候,直接从缓存中读取。其实就是把是否加载和什么时间加载这个资源的决定权交给浏览器。

Vite 主要利用 preload(在 E6 Modules 中改为 modulepreload),其实是一种优化,当访问首屏时,会提前加载其他页面所需要的资源,这样当打开其他页面时,就会减少等待时间,提升用户体验。正常的客户端渲染出来的 HTML 默认情况下都会带有这个优化,服务端渲染的 HTML 则需要上面的 ssr-manifest.json 才能有对应的优化。对应 HTML 的这部分内容如图10-8所示。

image 2024 02 24 08 11 16 695
Figure 5. 图10-8 HTML 中的 preload/prefetch 内容

至此,客户端渲染的逻辑都已基本完成,可以正常使用,而且预留了服务端渲染所需要的文件和配置。

创建 Node.js 服务 server.js

下面进入服务端渲染的改造过程。服务端渲染的核心能力是利用 Node.js 提供渲染首屏 HTML 的服务,所以可以利用 Express 框架来开启一个 Node.js 服务,同时为了在开发模式下也能使用,将 Vite 利用中间件的形式集成到 Express 中,在 index.html 同级创建 server.js,其代码如下:

const fs = require('fs')
const path = require('path')
const express = require('express')

async function createServer() {
    const app = express()

    // 以中间件模式创建 Vite 应用,这将禁用Vite自身的HTML服务逻辑,并让上级服务器接管控制
    //
    // 如果你想使用 Vite 自己的 HTML 服务逻辑(将 Vite 作为一个开发中间件来使用),那么这里请用 'html'
    const vite = await createViteServer({
        server: { middlewareMode: 'ssr' }
    })
    // 使用 vite 的 Connect 实例作为中间件
    app.use(vite.middlewares)

    app.use('*', async (req,res) => {
        // 服务 index.html,下面我们来处理这个问题
    })

    app.listen(8887)
}

createServer()

通过 Express 提供了一个在端口 8887 上的 Node.js 服务,通过浏览器访问 http://localhost:8887 即可得到首屏的 HTML 代码,这里 viteViteDevServer 的一个实例。vite.middlewares 是一个 Connect 实例,它可以在任何一个兼容 connectNode.js 框架中被用作一个中间件。下一步是实现 * 通配符处理程序提供服务端渲染的 HTML

// * 通配符表示所有请求都会经过这里
app.use('*', async (req, res) => {
    const url = req.originalUrl

    try {
        // 1. 读取 index.html
        let template = fs.readFileSync(
            path.resolve(__dirname, 'index.html'),
            'utf-8'
        )

        // 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,同时也会从 Vite 插件应用 HTML 转换
        template = await vite.transformIndexHtml(url, template)

        // 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
        //  你的 ES7 Modules 源码使之可以在 Node.js 中运行,无需打包
        //  并提供类似 HMR (热更新)的机制
        const { render } = await vite.ssrLoadModule('/src/entry-server.js')

        // 4. 渲染应用的 HTML。这里假设 entry-server.js 导出的 `render` 函数调用了适当的 SSR 框架 API
        const appHtml = await render(url)

        // 5. 注入渲染后的应用程序 HTML 到模板中
        const html = template.replace(`<!--app-html-->`, appHtml)

        // 6. 返回渲染后的 HTML
        res.status(200).set({'Content-Type': 'text/html'}).end(html)
    } catch (e) {
      // 如果捕获到一个错误,让 Vite 来修复该堆栈,这样它就可以映射回你的实际源码中
        vite.ssrFixStacktrace(e)
        console.error(e)
        res.status(500).end(e.message)
    }
})

对于服务端渲染来说,其核心就是产出首屏 HTML,上面的代码就是对浏览器请求进行拦截,然后对 HTML 进行处理和加工,主要包括:

  • 获取 index.html 内容,作为初始的 HTML 模板。

  • 在模板基础上添加 Vite 开发模式的支持逻辑,主要是热更新相关的 JavaScript 文件。

  • 调用 entry-server.js 中的方法得到首屏的 HTML 字符串。

  • 将字符串和 HTML 模板进行合并替换,构造出完整的 HTML 内容。

  • 通过 Express 提供的接口返回给浏览器。

上面的步骤中,核心点在于首屏的 HTML 字符串是动态的。还记得之前我们创建的 entry-server.js 文件吗:它就是产生首屏 HTML 的主要逻辑文件,其代码如下:

// src/entry-server.js
import { createApp } from "./main"
import { renderToString } from "@vue/server-renderer"
export async function render(){
    const { app, router } = createApp()
    // 根据路径确定首屏的具体页面
    router.push(url)
    await router.isReady()
    const ctx = {};
    // renderToString将此时的根实例转换成对应的HTML字符串
    const html = await renderToString(app, ctx);
    return { html }
}

通过 vue/server-renderer 这个库提供的 renderToString 方法将当前状态下的 app 根实例转换成了对应的 HTML 代码,这一步很关键,就相当于让浏览器帮忙运行了一下,产生了 HTML 代码。最后,将得到的 HTML 字符串替换到之前 index.html 模板中的 <!--app-html--> 位置上,就得到了最终的 HTML。

通过执行 node server.js 命令,同时可以把这个命令配置在 package.json 中,如下代码所示:

// package.json
"scripts": {
    "dev:ssr": "node ./server.js"
},

这样 Node.js 服务就运行起来了,通过浏览器访问 http://localhost:8887 即可得到首屏的 HTML 代码,这就完成了开发模式下的服务端渲染。

生产模式服务端渲染

生产模式和开发模式的服务端渲染主要区别是去除了 Vite 相关的配置,直接采用 Node.js 服务解析 entry-server.js 并产生首屏 HTML 代码返回给浏览器即可,同时添加了一些资源的 preload 逻辑,首先需要构造出生产模式的 entry-server.js,主要和之前开发模式的 entry-server.js 代码逻辑一样,只需要添加需要 preload 资源的逻辑即可,其代码如下:

// src/entry-server.js

...
// 获得首屏动态HTML字符串
const html = await renderToString(app, ctx)
// 获得首屏动态需要预加载的资源字符串
const proloadLinks = renderPreloadLinks(ctx.modules, manifest)
...
// 获得需要 preload 的资源
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)
                    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 {
    // TODO
    return ''
  }
}

打印出预加载的资源字符串,如图 10-9 所示。

image 2024 02 24 14 47 46 177
Figure 6. 图10-9 preload 部分资源字符串

修改 entry-server.js 后,还需要设置构建生产环境的 entry-server.js,在 package.json 中添加命令,其代码如下:

// package.json
"scripts": {
    "build:ssr": "vite build --outDir dist/server --ssr src/entry-server.js"
}

注意,使用 --ssr 标志表明这将是一个服务端构建,同时需要指定对应文件的入口。构建完成后,entry-server.js 会在 server.js 中被调用,同时传入对应的参数来获得首屏的 HTML。修改 server.js,添加生产模式相关逻辑,其部分代码如下:

// server.js
...
isProd = process.env.NODE_ENV === 'production' // 判断是否是生产模式
...
// 在生产模式下,获取客户端生产模式构建出来的 index.html 作为模板
 const indexProd = isProd
    ? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
    : ''
// 得到客户端生成模式构建出来的 ssr-manifest.json 资源映射表
const manifest = require('./dist/client/ssr-manifest.json')
...
// 在生成模式下,取消 Vite 相关配置,增加一些 Express 相关优化配置
if (isProd) {
    // 开启资源进行压缩
    app.use(require('compression')())
    // 设置静态资源的根目录
    app.use(
        require('serve-static')(resolve('dist/client'), {
            index: false
        })
    )
}

// 在生成模式下,获取客户端生成模式构建的 entry-server.js
if (isProd) {
    render = require('./dist/server/entry-server.js').render
    // 将 manifest 传入得到需要预加载的资源
    const [appHtml, preloadLinks] = await render(url, manifest)
    const html = template
        .replace(`<!--preload-link-->`, preloadLinks)
        .replace(`<!--app-html-->`, appHtml)
}
...

总结下来,生产模式服务端构建主要对 server.js 做了以下事情:

  • Vite 开发服务器的创建和所有使用都移到开发模式条件分支后面,然后添加 Express 静态文件服务中间件来为 dist/client 中的文件提供服务。

  • 使用 dist/client/index.html 作为模板,而不是根目录的 index.html,因为前者包含到客户端构建的正确资源链接。

  • 使用 require('./dist/server/entry-server.js'),而不是 vite.ssrLoadModule('/src/entry-server.js')(前者是 SSR 构建后的最终结果)。

  • preload 对应的字符串替换到 index.html 中的 <!--preload-links--> 位置上。

当执行 npm run build:ssr 时,就可以在生产模式下将服务运行起来,在生产模式下并不会启动端口服务,只是将生产用的资源打包好,当全部准备就绪时,我们就可以访问完整的生产模式下的服务端渲染。

优化 package.json 命令完成改造

结合之前添加的命令修改 package.json,其完整代码如下:

// package.json
"scripts": {
    // 客户端开发模式构建:正常的Vite开发模式
    "dev:client": "vite",
    // 客户端生产模式构建:Vite构建静态资源的生产包
    "build:client": "vite build --outDir dist/client --ssrManifest",
    // 服务端开发模式构建:Node.js服务提供HTML字符串
    "dev:ssr":"node ./server.js",
    // 服务端生产模式构建:Node.js服务构建服务端生产包
    "build:ssr": "vite build --ssr src/entry-server.js --outDir dist/server",
    // 客户端生产模式构建+服务端生产模式构建合并
    "build": "npm run build:client && npm run build:ssr",
    // 服务端渲染环境生产模式整体启动
    "serve": "cross-env NODE_ENV=production node server",
}

执行 npm run serve,然后在浏览器中访问 http://localhost:8887 ,即可打开在生产模式下服务端渲染出来的页面,如果需要部署生产服务器,那么将整个项目部署到服务器即可。

至此,整个服务端渲染完成改造,项目目录结构如图 10-10 所示。

image 2024 02 24 15 10 06 861
Figure 7. 图10-10 Vite 服务端渲染目录结构