前端技术发展到今天,用户通过终端浏览到应用内容的方式一般包含两种:

  • 客户端渲染(CSR):客户端请求数据及 JS 内容,在客户端渲染成标准的页面呈现出来
  • 服务端渲染(SSR):服务端从本地拿到数据,然后先渲染好页面再发送到客户端呈现给用户

Vue.js 默认在客户端渲染页面,但是我们可以通过 vue-server-renderer 实现服务端渲染。 用流程图的方式描述二则的区别如下:

CSR 与 SSR 是页面渲染的两种方式,二则各有利弊:

客户端渲染(CSR:Client Side Render)
客户端渲染需要先从服务器拿到数据(主要是些 JS 文件),等这些必要的文件全部加载完后才会渲染页面,比如 Vue.js 中会先拿到一个简单的 html 文件,然后获取必要的 js 文件,通过 js 文件生成虚拟 DOM 用来展示页面。因此客户端渲染往往具有如下特点:

  • 初始加载慢,白屏时间长
  • 无 DOM 结构,不利于 SEO
  • 利于前后端分离,前端专注页面实现,后端专注于 API 开发
  • 用户体验好, 方便应用交互
  • 服务器压力小,页面渲染工作在客户端完成

服务端渲染(SSR:Server Side Render)
服务端渲染一般从本地拿到数据,根据数据渲染成前端可用的 html 页面,将页面发送给客户端快速显示,因此服务端渲染往往具有如下特点:

  • 服务器压力大,特别是对 Vue.js 需要根据客户请求创建独立的 vue 实例,防止数据污染
  • 用户发生交互行为后可能会频繁请求服务器
  • 不利于前后端分离,开发效率低,开发受限
  • 有利于 SEO,方便爬虫抓取数据
  • 首屏加载迅速,避免长时间白屏造成用户体验不好
  • 后端可生成静态缓存文件,避免大量数据库查询

先放两张图,对比下客户端渲染与服务端渲染,浏览器请求信息中的差异:

Vue 代码改造

Vue.js 是构建客户端应用程序的框架。默认情况下创建的应用程序是在浏览器中渲染并生成 DOM 和操作 DOM 的。也就是说,默认的 Vue 应用是客户端渲染(CSR)的,如果我们想让其在服务端渲染(SSR)就需要对其进行简单的改造。

为了避免服务端渲染造成的数据污染,我们需要通过工厂函数为每一个发起请求的用户创建独立的路由、状态管理、Vue实例。

createRouter

客户端渲染方式创建路由:

// 直接在客户端 new 出一个 Router 实例
export default new VueRouter({
    linkActiveClass: 'router-link-active',              // 当包含父路由的子路由被激活时,父路由的样式类名
    linkExactActiveClass: 'router-link-exact-active',   // 当前路由的样式类名
    routes                                              // 路由配置项(数组)
})

服务端渲染创建路由:

// 使用工厂函数,防止数据污染,每次客户端请求都需要服务端创建一个路由实例
export function createRouter() {
    return new Router({
        mode: 'history',
        linkActiveClass: 'router-link-active',              // 当包含父路由的子路由被激活时,父路由的样式类名
        linkExactActiveClass: 'router-link-exact-active',   // 当前路由的样式类名
        routes                                              // 路由配置项(数组)
    })
}

createStore

客户端渲染方式创建状态管理器:

// 直接在客户端 new 出一个 Store 实例
export default new Vuex.Store({
	modules:{
		base,
		// 其他模块的状态管理...
	}
})

服务端渲染方式创建状态管理器:

// 使用工厂函数,防止数据污染,每次客户端请求都需要服务端创建一个路由实例
export function createStore() {
    return new Vuex.Store({
        modules:{
            base,
            // 其他模块的状态管理...
        }
    })
}

createApp

创建一个公共的文件 app.js ,方便客户端及服务端分别编译时调用。注意这里为方便区分没有直接改造 main.js。
客户端渲染方式创建 Vue 实例:

// 直接在客户端 new 出一个 Vue 实例
new Vue({
    router,
    store,
    render: h => h(App)
}).$mount('#app')          // 直接绑定 #app 的 DOM

服务端渲染方式创建 Vue 实例:

// 按需加载 createRouter
import { createRouter } from './router/index.js'
// 按需加载 createStore
import { createStore } from './store/index.js'

// 使用工厂函数,客户端请求时创建独立的 Vue 实例,防止数据污染
export function createApp() {
    const router = createRouter();      // 创建路由
    const store = createStore();        // 创建状态管理器
    const app = new Vue({               // 创建 Vue 实例
        router,
        store,
        render: h => h(App),
    });
    return { app, router, store};
}

entry-client.js

客户端编译时的入口文件,引入公共文件 app.js 中的代码后挂载实例。
注意:需要在 router.onReady 后挂载实例。

// 按需加载 createApp
import { createApp } from './app';
// 按需加载 app router
const {app, router} = createApp();
// 挂载真正的实例
router.onReady(() => {
    app.$mount('#app')
})

entry-server.js

服务端编译时的入口文件,引入公共文件 app.js 中的代码后

// 按需加载 createApp
import { createApp } from './app';
// context 请求上下文
export default context => {
    return new Promise((resolve, reject) => {
        const {app, router} = createApp();
        // 进入首屏
        router.push(context.url)
        router.onReady(() => {
            resolve(app);
        }, reject)
    })
};

vue.config.js 改造

服务器端打包 JSON 数据,客户端打包静态文件,用于挂载实例。改造代码如下:

// webpack 插件
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const nodeExrternals = require("webpack-node-externals");
const merge = require("lodash.merge");
const TARGET_NODE = process.env.WEBPACK_TARGET === "node";
const target = TARGET_NODE ? "server" : "client";

module.exports = {
    outputDir: './dist/'+target,
    configureWebpack: () => ({
        // 将 entry 指向应用程序的 server / client 文件
        entry: `./src/entry-${target}.js`,
        // 对 bundle renderer 提供 source map 支持
        devtool: 'source-map',
        // 这允许 webpack 以 Node 使用方式处理动态导入(dynamic import)
        // 并且还会在编译 Vue 组件时告知 'vue-loader' 输送面向服务器代码(server-oriented code)
        target: TARGET_NODE ? "node" : "web",
        node: TARGET_NODE ? undefined : false,
        output: {
            // 此处告知 server bundle 使用 Node 风格导出模块
            libraryTarget: TARGET_NODE ? "commonjs2" : undefined
        },
        // 外置化应用程序依赖模块。可以使用服务器构建速度更快,并生成较小的 bundle 文件
        externals: TARGET_NODE
        ? nodeExrternals({
            // 不要外置化 webpack 需要处理的依赖模块
            // 可以再这里添加更多的文件类型,例如:未处理 *.vue 原始文件
            // 你还应该将修改 'global' (例如 polyfill)的依赖模块列入白名单
            allowlist: [/\.css$/]
        })
        : undefined,
        optimization: {
            // 为vue-cli3配置了webpack4 里面的分包功能
            splitChunks: TARGET_NODE ? false : undefined
        },
        // 这是将服务器的整个输出构建为单个 JSON 文件的插件
        // 服务器默认文件名为 'vue-ssr-server-bundle.json'
        plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
    })
}

package.json 改造

将 package.json 中的 scripts 内容改成如下内容,这样就可以直接运行 npm run build 打包程序了。

"scripts": {
    "build": "npm run build:server && npm run build:client",
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server"
},

服务器端渲染

要使用 vue-server-renderer 在服务器端渲染页面,我们需要先对其进行部署,这里以 node.js 服务器为例,使用 express 快速搭建服务器。

安装 vue-server-renderer

npm install vue vue-server-renderer --save

注意:vue-server-renderer 和 vue 必须匹配版本,且需要 node.js 环境支持

API 参考

因为之后的部署过程中可能用到相关 API ,这里先简单介绍一下。

  • createRenderer:使用(可选的)选项创建一个 Renderer 实例
    const renderer = createRenderer({ /* 选项 */ })
    
  • createBundleRenderer:使用 server bundle 和(可选的)选项创建一个 BundleRenderer 实例
    const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })
    
  • Class: Renderer
    • renderer.renderToString:将 Vue 实例渲染为字符串。上下文对象(context)可选。回调函数是的第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串。2.5.0+中,回调可选,默认返回 Promise 对象。
    renderer.renderToString(vm, context?, callback?): ?Promise<string>
    
    • renderer.renderToStream:将 Vue 实例渲染为一个 Node.js 可读流。上下文对象(context)可选。
    renderer.renderToStream(vm[, context]): stream.Readable
    
  • Class: BundleRenderer
    • bundleRenderer.renderToString:将 bundle 渲染为字符串。上下文对象(context)可选。回调的第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串。2.5.0+中,回调可选,默认返回 Promise 对象。
    bundleRenderer.renderToString([context, callback]): ?Promise<string>
    
    • bundleRenderer.renderToStream:将 bundle 渲染为一个 Node.js 可读流。上下文对象(context)可选。
    bundleRenderer.renderToStream([context]): stream.Readable
    
  • Renderer 选项
    • template:为整个页面的 HTML 提供一个模板。
    • clientManifest:通过此选项提供一个由 vue-server-renderer/client-plugin 生成的客户端构建 manifest 对象。
    • inject:控制使用 template 时是否执行自动注入。
    • shouldPreload:一个函数,用来控制什么文件应该生成 <link rel=”preload”> 资源预加载提示 (resource hints)。
    • shouldPrefetch:一个函数,用来控制对于哪些文件,是需要生成 <link rel=”prefetch”> 资源提示。
    • runInNewContext:只用于 createBundleRenderer,是否每次渲染都创建一个新的 V8 上下文并重新执行整个 bundle。
    • basedir:只用于 createBundleRenderer,显式地声明 server bundle 的运行目录。运行时将会以此目录为基准来解析 node_modules 中的依赖模块。
    • cache:提供组件缓存具体实现。
    • directives:对于自定义指令,允许提供服务器端实现。

server/index.js 代码

npm 安装

// Node.js 服务器 —— Express

// 引入 express 并创建实例
const express = require("express");
const server = express();

// 使用 fs 插件请求静态文件
const fs = require("fs");

// 引入 vue-server-renderer 并创建渲染器
const vueRenderer = require("vue-server-renderer");
const serverBundle = require('../dist/server/vue-ssr-server-bundle.json');
const clientManifest = require('../dist/client/vue-ssr-client-manifest.json');
const renderer = vueRenderer.createBundleRenderer(serverBundle, {
    runInNewContext: false,
    template: fs.readFileSync('../public/index.template.html', 'utf-8'),
    clientManifest
});

// 中间件处理静态文件请求
server.use(express.static('../dist/client', {index: false}))

// 监听服务器请求
server.get("*", async (req, res) => {
    try {
        // 定义请求上下文信息,服务器渲染的时候会用到此处的一些参数
        const context = {
            url: req.url,
            title: 'SSR Advanced Test'
        }
        // 渲染成 HTML 文档
        const html = await renderer.renderToString(context)
        console.log(html)
        // 返回给用户 HTML 结构
        res.send(html)
    } catch (error) {
        console.log(error)
        res.status(500).send("服务器内部错误")
    }
});

server.listen(3000, ()=>{
    console.log("服务器启动成功...")
});