什么是服务端渲染
服务端渲染(Server Side Render)简称ssr,是一种直接从服务端返回渲染内容到客户端的页面渲染方式。
服务端渲染的好处
- 首屏渲染速度快
- 有利于SEO
服务端渲染的过程
- 客户端发起请求时,服务端通过读取文件模板以及获取异步数据,结合生成客户端的首屏渲染内容,返回到客户端。
- 客户端收到返回内容后,直接渲染返回结果,然后再激活客户端渲染。以Vue为例,即重新$mount渲染Vue实例,由于Vue实例已经挂载,所以不用重新渲染DOM,只会在客户端生成Vue实例,并将数据变为响应式的数据。
服务端渲染的构建过程
按照服务端渲染的过程,服务端渲染的构建会有两个入口,客户端渲染的入口以及服务端渲染的入口。打包之后也会对应生成客户端和服务端的入口bundle.js,触发渲染时,后执行对应的bundle.js。具体构建过程如下图:
从零开始搭建
服务端渲染的基本结构
-
在一个空的文件夹中初始化package.json,创建src文件目录用于存放源码文件。
-
安装依赖
- 生产依赖:
- vue:vue.js
- vue-server-renderer:vue服务端渲染器
- express:帮助生成web服务器
- cross-env:跨平台设置全局环境
- 开发依赖:
- webpack打包依赖:webpack、webpack-cli、webpack-merge(合并webpack配置)、webpack-node-externals(排除wbepack中的node模块)、friendly-errors-webpack-plugin(打包日志友好输出)。
- babel转换相关:babel-loader、@babel/core、@babel/plugin-transform-runtime、@babel/preset-env。
- vue转换相关:vue-loader、vue-template-compiler
- css及文件转换:css-loader、filer-loader、url-loader。
- 生产依赖:
-
在src中创建Vue实例模板App.vue以及入口模板文件app.js。
/* App.vue */ <template> <div id="app"> </div> </template> <script> export default { } </script> <style> </style>
/* app.js */ /** * 生成Vue实例 */ import Vue from 'vue' import App from './App' export function createApp() { const app = new Vue({ render: h => h(App) }) return { app } }
-
根目录下创建server.js,用于启动web服务,并将服务端渲染的内容发送到客户端。
// server.js // 创建Vue实例 const Vue = require('vue') const app = new Vue({ template: `<div id="app">{{ title }}</div>`, data() { return { title: '吴绍清' } } }) // 创建渲染器 const renderer = require('vue-server-renderer').createRenderer() // 创建服务器对象 const server = require('express')() server.get('/', (req, res) => { res.setHeader('Content-Type', 'text/html;charset=utf-8') // 设置返回内容的编码格式,反之乱码 // 将Vue实例渲染为 HTML 字符串 renderer.renderToString(app, (err, html) => { if (err) return res.status(500).end(err) console.log(html); res.end(html) }) }) server.listen(8000)
-
通过node server.js来启动web服务器,访问
http://localhost:8000/
即可看到服务端渲染的内容。此时是直接返回模板的html字符串内容。
-
为渲染器设置模板。需要创建一个模板文件index.template.html。然后在创建渲染器时读取模板,并传入。并在renderToString时将数据对象作为第二个参数传入。
<!-- index.template.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <!-- 三花括号进行 HTML 不转义差值 --> {{{ meta }}} <!-- 双花括号进行 HTML 转义差值 --> <title>{{ title }}</title> </head> <body> <!-- 固定写法,服务度渲染的出口,Vue实例将替换下面的注释标签 --> <!--vue-ssr-outlet--> </body> </html>
//server.js修改 // 创建渲染器 const renderer = require('vue-server-renderer').createRenderer({ template: require('fs').readFileSync('./index.template.html', 'utf-8') }) // 创建服务器对象 const server = require('express')() server.get('/', (req, res) => { res.setHeader('Content-Type', 'text/html;charset=utf-8') // 设置返回内容的编码格式,反之乱码 // 将Vue实例渲染为 HTML 字符串 renderer.renderToString(app, { title: '模板插值', meta: `<meta http-equiv="X-UA-Compatible" content="IE=edge" />` }, (err, html) => { if (err) return res.status(500).end(err) res.end(html) }) })
-
启动服务器之后,访问
http://localhost:8000/
,将返回模板与数据的结合结果
打包构建设置
-
src目录下创建entry-client.js和entry-server.js文件,作为客户端和服务端打包的入口文件。
// entry-client.js /** * 客户端渲染入口 */ import createApp from './app' const { app } = createApp() // 在id为app的div中渲染Vue实例 app.$mount('#app')
// entry-server.js import createApp from './app' export default context => { const { app } = createApp() return app }
-
创建打包配置文件webpack.base.config.js作为公共打包配置,webpack.client.config.js作为客户端打包配置,webpack.server.config.js作为服务端打包配置。
// webpack.base.config.js const path = require('path') const VueLoaderPlugin = require('vue-loader/lib/plugin') const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin') const resolve = file => path.resolve(__dirname, file) const isProd = process.env.NODE_ENV === 'production' const devToolOption = {} if (!isProd) { devToolOption.devtool = 'cheap-module-eval-source-map' } module.exports = { mode: isProd ? 'production' : 'development', output: { path: resolve('../dist/'), publicPath: '/dist/', filename: '[name].[chunkhash].js' }, resolve: { alias: { '@': resolve('../src/') }, extensions: ['.js', '.vue', '.json'] }, ...devToolOption, module: { rules: [ // 处理图片资源 { test: /\.(png|jpg|gif)$/i, use: { loader: 'url-loader', options: { limit: 8192 } } }, // 处理字体资源 { test: /\.(woff|woff2|eot|ttf|otf)$/, use: [ 'file-loader', ], }, // 处理 .vue 资源 { test: /\.vue$/, loader: 'vue-loader' }, // 处理 CSS 资源 // 它会应用到普通的 `.css` 文件 // 以及 `.vue` 文件中的 `<style>` 块 { test: /\.css$/, use: [ 'vue-style-loader', 'css-loader' ] } ] }, plugins: [ new VueLoaderPlugin(), new FriendlyErrorsWebpackPlugin() ] }
// webpack.client.config.js const { merge } = require('webpack-merge') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const common = require('./webpack.base.config') module.exports = merge(common, { entry: { app: './src/entry-client.js' // 客户端打包入口 }, module: { rules: [ { test: /\.m?js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'], cacheDirectory: true, plugins: ['@babel/plugin-transform-runtime'] } } } ] }, // 打包优化:将 webpack 运行时分离到一个引导 chunk 中, // 以便可以在之后正确注入异步 chunk。 optimization: { splitChunks: { name: 'manifest', minChunks: Infinity } }, plugins: [ // 在输出目录中生成 vue-ssr-client-manifest.json new VueSSRClientPlugin() ] })
// webpack.server.config.js const { merge } = require('webpack-merge') const nodeExternals = require('webpack-node-externals') const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') const common = require('./webpack.base.config') const serverConfig = merge(common, { entry: './src/entry-server.js', // 服务端打包入口 // 这允许 webpack 以 Node 适用方式处理模块加载 // 并且还会在编译 Vue 组件时, // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。 target: 'node', output: { filename: 'server-bundle.js', // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) libraryTarget: 'commonjs2' }, // 不打包 node_modules 第三方包,而是保留 require 方式直接加载 externals: [ nodeExternals({ // 白名单中的资源依然正常打包 allowlist: [/\.css$/] }) ], plugins: [ // 这是将服务器的整个输出构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json` new VueSSRServerPlugin() ] }) module.exports = serverConfig
-
修改server.js,通过createBundleRenderer来生成渲染器,该渲染器负责完成输出服务器渲染结果,并在客户端激活客户端渲染。
// 服务端入口打包之后的文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json') // 模板文件 const template = require('fs').readFileSync('./index.template.html', 'utf-8') // 客户端入口打包之后的文件 const clientManifest = require('./dist/vue-ssr-client-manifest.json') // 创建渲染器 const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, { template, clientManifest })
-
package.json中配置命令
"scripts": { "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js", "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js", "build": "rimraf dist && yarn build:client && yarn build:server", "start": "cross-env NODE_ENV=production node server.js", "dev": "node server.js" },
开发模式构建优化
开发模式的构建目的是为了开发时方便的一些功能,比如热更新、SourceMap等。ssr要实现热更新需要在文件更新之后,重新执行ssr的渲染流程。
-
首先要区分开发模式还是生产模式,对开发模式下的服务进行特殊处理。
-
开发模式下需要监视文件修改,然后重新打包生成template、serverBundle、clientMannifest,之后再调用createBundleRenderer生成新的renderer。然后在通过renderer返回服务端渲染结果时,需要等待renderer生成之后才能使用。
- 考虑是将重新打包到生成renderer的过程封装为一个Promise,通过Promise的状态来确定整个过程是否完成。
// server.js修改部分 // 创建服务器对象 const express = require('express') const server = express() const { createBundleRenderer } = require('vue-server-renderer') const setupDevServer = require('./build/setup-dev-server') const isProd = process.env.NODE_ENV === 'production' let devServerReady let renderer if (isProd) { // 服务端入口打包之后的文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json') // 模板文件 const template = require('fs').readFileSync('./index.template.html', 'utf-8') // 客户端入口打包之后的文件 const clientManifest = require('./dist/vue-ssr-client-manifest.json') // 创建渲染器 renderer = createBundleRenderer(serverBundle, { template, clientManifest }) } else { // 开发模式下 监视文件修改 -> 重新打包生成文件 -> 再读取文件生成renderer // setupDevServer 返回一个Promise,这样在外部就能通过Promise拿到其状态 devServerReady = setupDevServer(server, (serverBundle, template, clientManifest) => { // 创建渲染器 renderer = createBundleRenderer(serverBundle, { template, clientManifest }) }) } const render = async (req, res) => { // 处理物理磁盘的静态资源访问 server.use('/dist', express.static('./dist')) try { const html = await renderer.renderToString({ title: '伍绍清', meta: `<meta http-equiv="X-UA-Compatible" content="IE=edge" />`, }) res.setHeader('Content-Type', 'text/html;charset=utf-8') // 设置返回内容的编码格式,反之乱码 res.end(html) } catch(e) { return res.status(500).end(e) } } server.get('*', isProd ? render : async (req, res) => { await devServerReady // 开发环境需要等待renderer生成之后再调用render render(req, res) })
// setupDevServer const fs = require('fs') const path = require('path') const chokidar = require('chokidar') const webpack = require('webpack') const webpackDevMiddleware = require('webpack-dev-middleware') const hotMiddleware = require('webpack-hot-middleware') const resolve = file => path.resolve(__dirname, file) module.exports = function setupDevServer(server, cb) { let ready const p = new Promise(res => ready = res) let serverBundle let template let clientManifest const update = () => { // 三个文件都生成好之后才能重新生成renderer if (serverBundle && template && clientManifest) { ready() // 调用resolve方法改变Promise状态 cb(serverBundle, template, clientManifest) } } // 监视template 文件变化,并重新构建template -> 调用update更新renderer // 读取template文件,构建template,文件更新之后重新读取template文件,构建template const templatePath = resolve('../index.template.html') template = fs.readFileSync(templatePath, 'utf-8') // 监视资源变化,使用chokidar,基于fs.watch fs.watchFile chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8') update() }) // 监视serverBundle 文件变化,并重新构建serverBundle -> 调用update更新renderer // 构建serverBundle需要使用webpack打包 const serverConfig = require('./webpack.server.config') const serverCompiler = webpack(serverConfig) // webpackDevMiddleware会将打包生成的文件存放在内存中,自动以监视模式运行,不用手动通过watch监视文件变化 const serverDevMiddlerware = webpackDevMiddleware(serverCompiler) // 注册一个插件,在每次执行完构建之后,读取内存中的构建结果,生成serverBundle serverCompiler.hooks.done.tap('server', () => { // 从内存中读取打包之后的结果需要借助webpackDevMiddleware的返回值.serverDevMiddleware.context.outputFileSystem就和node文件系统的fs模块是一样的,读取的文件地址都不用改变 const serverCompilerResult = serverDevMiddlerware.context.outputFileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8') // 读取的结果时字符串,需要转换从js代码 serverBundle = JSON.parse(serverCompilerResult) update() }) // 监视clientManifest 文件变化,并重新构建clientManifest -> 调用update更新renderer // clientManifest的打包和serverBundle的打包类似,都需要经过webpack打包 const clientConfig = require('./webpack.client.config') // 增加热更新配置\ // 1.添加HotModuleReplacementPlugin插件 clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin()) // 2.修改入口,将 webpack-hot-middleware/client 添加到入口数组最前面 clientConfig.entry.app = [ 'webpack-hot-middleware/client?quiet=true&reload=true', clientConfig.entry.app ] // 3.去掉output.filename中的chunk,webpack-hot-middleware需要保证每次输出的文件名字一致。 clientConfig.output.filename = '[name].js' const clientComparer = webpack(clientConfig) // 使用webpack-dev-middleware监视clientManifest打包 const clientDevMiddleware = webpackDevMiddleware(clientComparer, { publicPath: clientConfig.output.publicPath }) // 注册插件,在client文件打包之后执行回调 clientComparer.hooks.done.tap('client', () => { // 读取clientManifest打包之后的结果并重新构建,然后调用update clientManifest = JSON.parse( clientDevMiddleware.context.outputFileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8') ) update() }) // 3.在server中挂载插件 server.use(hotMiddleware(clientComparer, { log: false ,// 关闭hotMiddleware本身的日志输出 })) // 将clientDevMiddleware挂载到Express服务中,提供对其内部内存中数据的访问 server.use(clientDevMiddleware) return p }
-
运行命令行yarn dev即可进入开发模式,文件修改之后将重新打包编译。
SSR路由处理
使用vue-router进行路由处理
-
安装vue-router依赖,创建router/index.js文件,并导出一个创建router的函数。
// router/index.js import Vue from 'vue' import Router from 'vue-router' import Home from '@/pages/home.vue' Vue.use(Router) export default function createRouter () { return new Router({ mode: 'history', // 服务端大多不支持hash模式的路由,history模式的路由前后端支持更好 routes: [ { name: 'home', path: '/', component: Home }, { name: 'about', path: '/about', component: () => import('@/pages/about.vue') }, { name: 'post', path: '/post', component: () => import('@/pages/post.vue') }, { name: '404', path: '*', component: () => import('@/pages/404.vue') } ] }) }
-
在src/app.js中通过函数创建router实例,并将router实例注入到根Vue实例中。
// src/app.js export default function createApp() { const router = createRouter() const app = new Vue({ router, render: h => h(App) }) return { router, app } }
-
修改server.js,在执行render.renderToString时将req.url传入,后续server端入口文件需要根据请求的url来设置router的路由。
const html = await renderer.renderToString({ title: '伍绍清', meta: `<meta http-equiv="X-UA-Compatible" content="IE=edge" />`, url: req.url })
-
修改entry-server.js,等待router将可能的异步组件和钩子解析执行完,再返回vue实例
// entry-server.js import createApp from './app' export default async context => { const { app, router } = createApp() // 设置服务器端的 router 位置 router.push(context.url) // 等待 router 将可能的异步组件和钩子解析完 await new Promise(router.onReady.bind(router)) return app }
-
修改entry-client.js,等待路由准备完之后渲染 Vue实例到 id为 app 的元素内
// entry-client.js /** * 客户端渲染入口 */ import createApp from './app' const { app, router } = createApp() // 等待路由准备完之后渲染 Vue实例到 id为 app 的元素内 router.onReady(() => { // 在id为app的div中渲染Vue实例 app.$mount('#app') })
SSR页面Head 管理
页面Head管理即管理页面中head标签中的内容。在服务端可以通过this.$ssrContext来访问或者设置。在客户端则通过document来设置。先来看一种官方推荐做法,利用mixin来实现。
-
首先创建src/mixins/title-mixin.js文件,导出一个titleMixin,根据渲染端的不同利用不同的方法设置title。
// title-mixin.js function getTitle(vm) { // 组件可以提供一个 `title` 选项 // 此选项可以是一个字符串或函数 const { title } = vm.$options if (title) { return typeof title === 'function' ? title.call(vm) : title } } const serverTitleMixin = { created() { const title = getTitle(this) if (title) { this.$ssrContext.title = title } } } const clientTitleMixin = { mounted() { const title = getTitle(this) if (title) { document.title = title } } } export default process.env.VUE_ENV === 'server' ? serverTitleMixin : clientTitleMixin
-
修改package.json中的命令行,设置全局的VUE_ENV环境变量。
"scripts": { "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js", "build:server": "cross-env NODE_ENV=production VUE_ENV=server webpack --config build/webpack.server.config.js", "build": "rimraf dist && yarn build:client && yarn build:server", "start": "cross-env NODE_ENV=production node server.js", "dev": "nodemon server.js" },
-
在具体的页面组件内添加title属性。
<script> export default { title: 'Home', name: 'HomePage' } </script>
-
启动项目,访问页面,浏览器标签栏将出现页面组件设置的title。
处理上面的方法外还可以使用第三方插件vue-meta来实现。
-
安装vue-meta,并在src/app.js中注册插件,然后定义一个全局的mixin,为所有的Vue实例设置一个metaInfo.titleTemplate属性来设置title的模板。
import VueMeta from 'vue-meta'; // 注册插件 Vue.use(VueMeta) Vue.mixin({ metaInfo: { titleTemplate: '%s - wsq' } })
-
在页面组件中设置metaInfo.title属性,title属性的值将替换模板中的%s,最终显示在标签栏中。
export default { // title: 'About', metaInfo: { title: 'About' }, name: 'AboutPage' }
SSR数据预取和状态管理
服务端和客户端的数据状态要一致,不然会混合失败,导致渲染结果错误。
通过Vuex来实现服务端和客户端数据状态的一致。
-
安装Vuex,并创建store/index.js文件,注册插件,并导出一个创建Store实例的函数。
// store/index.js import Vuex from 'vuex' import axios from 'axios' import Vue from 'vue' Vue.use(Vuex) export default function createStore() { return new Vuex.Store({ state: { posts: [] }, mutations: { savePosts(state, payload) { state.posts = payload } }, actions: { async getPosts({ commit }) { const { data } = await axios({ url: 'https://cnodejs.org/api/v1/topics', method: 'GET' }) commit('savePosts', data.data) } } }) }
-
修改src/app.js,在根Vue实例中注入Store实例
// src/app.js export default function createApp() { const router = createRouter() const store = createStore() const app = new Vue({ router, store, render: h => h(App) }) return { store, router, app } }
-
修改entry-server.js,定义context.rendered方法,该方法会在务端渲染完毕之后调用,在方法中将store.state保存到context.state。context.state 数据将被内联到页面模板中。最终发送给客户端的页面中会包含一段脚本:
window.__INITIAL_STATE__ = context.state
。客户端将window.__INITIAL_STATE__
拿出来填充到客户端 store 容器中。// 会在服务端渲染完毕之后被调用,可以在函数内部拿到服务端渲染好的状态数据 context.rendered = () => { // rendered 会把 context.state 数据对象内联到页面模板中 // 最终发送给客户端的页面中会包含一段脚本: window.__INITIAL_STATE__ = context.state // 客户端将 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中 context.state = store.state }
-
修改entry-client.js,将
window.__INITIAL_STATE__
填充到store容器中。// 同步从entry-server中设置的数据,保持两次渲染的状态一致 if(window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) }
-
创建src/pages/post.vue来使用store中的数据。Vue提供为服务端渲染提供了一个特殊的声明周期钩子serverPrefetch,会在服务端渲染之前调用,必须返回Promise。可以在该生命周期中调用action中异步获取数据。
<template> <div> <ul> <li v-for="p in posts" :key="p.id">{{ p.title }}</li> </ul> </div> </template> <script> import { mapState, mapActions } from 'vuex' export default { metaInfo: { title: 'Post' }, name: 'PostPage', data() { return {} }, computed: { ...mapState(['posts']) }, methods: { ...mapActions(['getPosts']) }, // 服务端渲染特有的生命周期钩子, 会在渲染之前调用,必须返回Promise serverPrefetch() { return this.getPosts() } } </script> <style> </style>
总结
服务端渲染基本构成就是服务端负责处理并返回首屏渲染的内容,之后客户端在接管页面的渲染,最后通过第三方的插件来管理路由以及数据状态,保证两端的路由和数据状态的同步。