Vue服务器端渲染梳理

简介

官方资料:Vue SSR 指南

主要使用的是vue-server-renderer来实现的服务器端渲染

  • 安装:npm i vue vue-server-renderer --save

vue-server-renderer

createRenderer

  • createRenderer():创建一个renderer实列。以vue组件为入口
  • renderer.renderToString(vm, cb):将Vue实列呈现为字符串。该方法的回调函数是一个标准的Node.js回调,它接收错误作为第一个参数
const Vue = require('vue') // 创建渲染器 const renderer = require('vue-server-renderer').createRenderer() const app = new Vue({ template: `<div>Hello World</div>` }) // 生成预渲染的HTML字符串. 如果没有传入回调函数,则会返回 promise renderer.renderToString(app).then(html => { console.log(html) }).catch(err => { console.log(err) }) // 当然我们也可以使用另外一种方式渲染,传入回调函数, // 其实和上面的结果一样,只是两种不同的方式而已 renderer.renderToString(app, (err, html) => { if (err) { throw err } console.log(html) })

输出的结果如下(里面的属性data-server-rendered作用是告诉VUE这是服务器渲染的元素。并且应该以激活的模式进行挂载):

<div data-server-rendered="true">Hello World</div> <div data-server-rendered="true">Hello World</div>

createBundleRenderer

  • createBundleRenderer(code, [rendererOptions]):创建一个renderer实列。以打包后的JS文件或json文件为入口
const { createBundleRenderer } = require('vue-server-renderer').createBundleRenderer let renderer = createBundleRenderer('./package.json') console.log(renderer)

与Koa集成

集成

  • 新建app.js文件
const Vue = require('vue') const Koa = require('koa') const Router = require('koa-router') const renderer = require('vue-server-renderer').createRenderer() const app = new Koa() const router = new Router() router.get('/(.*)', async (ctx, next) => { // 创建vue实例 const app = new Vue({ data() { return { url: ctx.url } }, template: `<div>访问的URL是:{{url}}</div>` }) try { // vue实例转换成字符串 const html = await renderer.renderToString(app) ctx.status = 200 ctx.body = ` <!DOCTYPE html> <html> <head><title>vue服务器渲染组件</title></head> <body>${html}</body> </html> ` } catch(e) { console.log(e) ctx.status = 500 ctx.body = '服务器错误' } }) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })

使用页面模板

html代码抽离出来,写到一个html文件中

  • 新建index.template.html文件(里面的<!--vue-ssr-outlet-->是必需的,是一个占位符。等 renderer.renderToString函数 真正渲染成html后,会把内容插入到该地方来)
<!DOCTYPE html> <html> <head> <!-- 三花括号不会进行html转义 --> {{{ meta }}} <title>{{title}}</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
  • 修改app.js文件,通过fs模块读取 index.template.html 页面代码
const Vue = require('vue') const Koa = require('koa') const Router = require('koa-router') const renderer = require('vue-server-renderer').createRenderer({ // 读取传入的template参数 template: require('fs').readFileSync('./index.template.html', 'utf-8') }) const app = new Koa() const router = new Router() router.get('/(.*)', async (ctx, next) => { // 创建vue实例 const app = new Vue({ data() { return { url: ctx.url } }, template: `<div>访问的URL是:{{url}}</div>` }) const context = { title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } try { // vue实例转换成字符串 const html = await renderer.renderToString(app, context) ctx.status = 200 ctx.body = html } catch(e) { console.log(e) ctx.status = 500 ctx.body = '服务器错误' } }) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })

服务器渲染搭建

基本目录结构如下

demo
├── src
│   ├── app.js               # 为每个请求创建一个新的根vue实列
│   ├── index.template.html
├── server.js                # 服务相关的代码
└── package.json             # 依赖的包文件
  • src/index.template.html
<!DOCTYPE html> <html> <head> <!-- 三花括号不会进行html转义 --> {{{ meta }}} <title>{{title}}</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>

避免单例

服务器端需要为每个请求创建一个新的根 Vue 实例。
如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染

  • 修改src/app.js文件
const Vue = require('vue') // 为每个请求创建一个新的根vue实例 module.exports = function createApp(ctx) { return new Vue({ data() { return { url: ctx.url } }, template: `<div>访问的URL是:{{url}}</div>` }) }
  • 修改server.js文件
const Koa = require('koa') const Router = require('koa-router') const renderer = require('vue-server-renderer').createRenderer({ // 读取传入的template参数 template: require('fs').readFileSync('./src/index.template.html', 'utf-8') }) const app = new Koa() const router = new Router() const createApp = require('./src/app') router.get('/(.*)', async (ctx, next) => { const app = createApp(ctx) const context = { title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } try { // 传入context渲染上下文对象 const html = await renderer.renderToString(app, context) ctx.status = 200 ctx.body = html } catch(e) { ctx.status = 500 ctx.body = '服务器错误' } }) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })

路由实现和代码分割

以上只是使用 node server.js 运行服务器端的启动程序,然后进行服务器端渲染页面,但是我们并没有将相同的vue代码提供给客户端

要实现这一点的话,我们需要在项目中引用我们的webpack来打包我们的应用程序

使用vue-router引入前端路由

目录结构如下

demo
├── build
│   ├── webpack.base.conf.js     # webpack 基本配置
│   ├── webpack.client.conf.js   # 客户端打包配置
│   ├── webpack.server.conf.js   # 服务器端打包配置
├── src 
│   ├── components               # 存放所有的vue页面,当然我们这边也可以新建文件夹分模块
│   |   ├── home.vue 
│   |   ├── item.vue 
│   ├── app.js                   # 创建每一个实列文件
│   ├── App.vue 
│   ├── entry-client.js          # 挂载客户端应用程序
│   ├── entry-server.js          # 挂载服务器端应用程序
│   ├── index.template.html      # 页面模板html文件
│   ├── router.js                # 所有的路由
├── server.js                    # 服务相关的代码
└── package.json                 # 依赖的包文件
  • 添加src/components/home.vue文件
<template> <h1> home </h1> </template>
  • 添加src/components/item.vue文件
<template> <h1> item </h1> </template>
  • 添加src/App.vue文件
<template> <div id="app"> <router-view></router-view> <h1>{{ msg }}</h1> <input type="text" v-model="msg" /> </div> </template>
export default { name: 'app', data() { return { msg: '欢迎光临vue.js App' } } }
  • 添加src/router.js路由文件,导出一个createRouter()函数
import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) export function createRouter() { return new Router({ mode: 'history', routes: [ { path: '/home', component: resolve => require(['./components/home'], resolve) }, { path: '/item', component: resolve => require(['./components/item'], resolve) }, { path: '*', component: resolve => require(['./components/home'], resolve) } ] }) }
  • 修改src/app.js文件,先简单导出一个createApp()函数
import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' export function createApp() { // 创建router实例 const router = createRouter() const app = new Vue({ // 注入router到根vue实例中 router, // 根据实例简单的渲染应用程序组件 render: h => h(App) }) return { app, router } }
  • 添加src/entry-client.js,创建应用程序,并且将其挂载到DOM中
import { createApp } from './app' import Vue from 'vue' const { app, router } = createApp() // 假设App.vue模板中根元素 id = 'app' router.onReady(() => { app.$mount('#app') })
  • 添加src/entry-server.js
import { createApp } from './app' export default context => { // const { app } = createApp() // return app /* 由于 路由钩子函数或组件 有可能是异步的,比如 同步的路由是这样引入 import Foo from './Foo.vue' 但是异步的路由是这样引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require动态加载进来的,因此我们这边需要返回一个promise对象。以便服务器能够等待所有的内容在渲染前 就已经准备好就绪。 */ return new Promise((resolve, reject) => { const { app, router } = createApp() // 设置服务器端 router 的位置 router.push(context.url) /* router.onReady() 等到router将可能的异步组件或异步钩子函数解析完成,在执行,就好比我们js中的 window.onload = function(){} 这样的。 官网的解释:该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和 路由初始化相关联的异步组件。 这可以有效确保服务端渲染时服务端和客户端输出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含义是: 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)。 通常在服务端渲染的数据预加载时使用。 有关 Router的实列方法含义可以看官网:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents() // 如果匹配不到路由的话,执行reject函数,并且返回404 if (!matchedComponents.length) { return reject({ code: 404 }) } // 正常的情况 resolve(app) }, reject) }).catch(new Function()) }
  • 添加build/webpack.base.config.js
const path = require('path') // vue-loader v15版本需要引入此插件 const VueLoaderPlugin = require('vue-loader/lib/plugin') // 用于返回文件相对于根目录的绝对路径 const resolve = dir => path.join(__dirname, '..', dir) // 是否是开发环境 const isDev = process.env.NODE_ENV === 'development' module.exports = { // 入口暂定客户端入口,服务端配置需要更改它 entry: resolve('src/entry-client.js'), // 生成文件路径、名字、引入公共路径 output: { path: resolve('dist'), filename: '[name].js', publicPath: '/' }, // 开发环境devtool 我们可以使用cheap-module-eval-source-map编译会更快,css样式没有必要打包单独文件。 devtool: isDev ? 'cheap-module-eval-source-map' : false, resolve: { // 对于.js、.vue引入不需要写后缀 extensions: ['.js', '.vue'], // 引入components、assets可以简写,可根据需要自行更改 alias: { 'components': resolve('src/components'), 'assets': resolve('src/assets') } }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', options: { // 配置哪些引入路径按照模块方式查找 transformAssetUrls: { video: ['src', 'poster'], source: 'src', img: 'src', image: 'xlink:href' } } }, { test: /\.js$/, // 利用babel-loader编译js,使用更高的特性,排除npm下载的.vue组件 loader: 'babel-loader', exclude: file => ( /node_modules/.test(file) && !/\.vue\.js/.test(file) ) }, { test: /\.(png|jpe?g|gif|svg)$/, // 处理图片 use: [ { loader: 'url-loader', options: { limit: 10000, name: 'static/img/[name].[hash:7].[ext]' } } ] }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 处理字体 loader: 'url-loader', options: { limit: 10000, name: 'static/fonts/[name].[hash:7].[ext]' } } ] }, plugins: [ new VueLoaderPlugin() ] }
  • 添加build/webpack.client.config.js文件,对客户端代码进行打包
const path = require('path') const webpack = require('webpack') // 使用 webpack-merge 对 webpack.base.config.js 代码配置进行合并 const { merge } = require('webpack-merge') const baseWebpackConfig = require('./webpack.base.config.js') // css样式提取单独文件 const MiniCssExtractPlugin = require('mini-css-extract-plugin') // 服务端渲染用到的插件、默认生成JSON文件(vue-ssr-client-manifest.json) const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') module.exports = merge(baseWebpackConfig, { mode: process.env.NODE_ENV || 'development', output: { // chunkhash是根据内容生成的hash, 易于缓存, // 开发环境不需要生成hash,目前先不考虑开发环境 filename: 'static/js/[name].[chunkhash].js', chunkFilename: 'static/js/[id].[chunkhash].js' }, module: { rules: [ { test: /\.less?$/, // 利用mini-css-extract-plugin提取css // 开发环境不需要提取css单独文件 use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] }, ] }, devtool: false, plugins: [ // webpack4.0版本以上采用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin new MiniCssExtractPlugin({ filename: 'static/css/[name].[contenthash].css', chunkFilename: 'static/css/[name].[contenthash].css' }), // 当vendor模块不再改变时, 根据模块的相对路径生成一个四位数的hash作为模块id new webpack.HashedModuleIdsPlugin(), new VueSSRClientPlugin() ] })
  • 修改package.json文件,添加scripts
{ // ... "scripts": { "build:client": "webpack --config ./build/webpack.client.config.js" }, // ... }
  • 执行npm run build:client会进行打包。打包后对多生成一个文件vue-ssr-client-manifest.json。这个文件就是用于客户端渲染的json文件

  • 添加build/webpack.server.config.js文件,打包服务器渲染文件

const path = require('path'); const webpack = require('webpack'); const { merge } = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const baseConfig = require('./webpack.base.config'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); module.exports = merge(baseConfig, { entry: path.resolve(__dirname, '../src/entry-server.js'), /* 允许webpack以Node适用方式(Node-appropriate fashion)处理动态导入(dynamic import), 编译vue组件时,告知 vue-loader 输送面向服务器代码 */ target: 'node', devtool: 'source-map', // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) output: { libraryTarget: 'commonjs2', filename: '[name].server.js' }, /* 服务器端也需要编译样式,不能使用 mini-css-extract-plugin 插件 ,因为该插件会使用document,但是服务器端并没有document, 因此会导致打包报错,我们可以如下的issues: https://github.com/webpack-contrib/mini-css-extract-plugin/issues/48#issuecomment-375288454 注意:css-loader版本:1.0.0 */ module: { rules: [ { test: /\.less?$/, use: ['css-loader/locals', 'less-loader'] } ] }, // https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外置化应用程序依赖模块。可以使服务器构建速度更快, // 并生成较小的 bundle 文件。 externals: nodeExternals({ // 不要外置化 webpack 需要处理的依赖模块。 // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单 allowlist: /\.css$/ }), // 这是将服务器的整个输出 // 构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json` plugins: [ new VueSSRServerPlugin() ] });
  • 修改package.json文件,添加scripts
{ // ... "scripts": { "build:client": "webpack --config ./build/webpack.client.config.js", "build:server": "webpack --config ./build/webpack.server.config.js" }, // ... }
  • 执行npm run build:server会在dist/目录下生成vue-ssr-server-bundle.json。这个文件就是用于服务器端渲染的json文件

vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json 文件生成后,就可以编写server.js来实现整个服务器端渲染流程了

  • 修改server.js文件
const path = require('path') const fs = require('fs') const Koa = require('koa') const Router = require('koa-router') const static = require('koa-static') const { createBundleRenderer } = require('vue-server-renderer') const app = new Koa() const router = new Router() const template = fs.readFileSync('./src/index.template.html', 'utf-8') // 引入客户端,服务端生成的json文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json') const clientManifest = require('./dist/vue-ssr-client-manifest.json') const renderer = createBundleRenderer(serverBundle, { // 推荐 runInNewContext: false, // 页面模板 template: template, // 客户端构建 manifest clientManifest }) // 静态资源目录对于相对入口文件 index.js 的路径 app.use(static( path.join(__dirname, './dist') )) const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html') const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render: ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } try { const html = await renderer.renderToString(context) ctx.status = 200 ctx.body = html } catch (err) { handleError(err) } next() } router.get('/(.*)', render) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })
  • 修改package.json文件,添加scripts
{ // ... "scripts": { "build:client": "webpack --config ./build/webpack.client.config.js", "build:server": "webpack --config ./build/webpack.server.config.js", "start": "node server.js" }, // ... }
  • 执行npm run start。即可启动服务。访问http://localhost:3000/home或者http://localhost:3000/item即可看到效果

每次打包都需要先npm run build:client,然后再npm run build:server。修改下package.json合并成一个命令npm run build

{ // ... "scripts": { "build:client": "webpack --config ./build/webpack.client.config.js", "build:server": "webpack --config ./build/webpack.server.config.js", "build": "npm run build:client && npm run build:server", "start": "node server.js" }, // ... }

开发环境配置

开发环境下,每次修改都需要先npm run build然后再npm run start,比较麻烦。

配置一个开发环境,当修改vue代码时,自动打包客户端和服务器端代码、重新进行BundleRenderr.renderToString()方法、重启server.js代码中的服务

实现

  • 通过process.env.NODE_ENV来区分是开发环境还是线上环境
  • 使用koa-webpack-dev-middleware,通过传入webpack编译好的compiler实现热加载,也就是说可以监听文件的变化,从而进行刷新网页
  • 使用koa-webpack-hot-middleware实现模块热替换操作
  • 修改package.json配置
{ // ... "scripts": { "build:client": "webpack --config ./build/webpack.client.config.js", "build:server": "webpack --config ./build/webpack.server.config.js", "build": "npm run build:client && npm run build:server", "start": "cross-env NODE_ENV=production node server.js", "dev": "cross-env NODE_ENV=development node server.js" }, // ... }
  • 修改server.js文件
const path = require('path') const fs = require('fs') const Koa = require('koa') const Router = require('koa-router') const static = require('koa-static') const { createBundleRenderer } = require('vue-server-renderer') // 动态监听文件发生改变的配置文件 const devConfig = require('./build/dev.config.js') const isDev = process.env.NODE_ENV === 'development' // 设置renderer为全局变量,根据环境变量赋值 let renderer const app = new Koa() const router = new Router() // 静态资源目录对于相对入口文件 index.js 的路径 !isDev && app.use(static( path.join(__dirname, './dist') )) // 下面我们根据环境变量来生成不同的 BundleRenderer 实例 if (!isDev) { // 正式环境 const template = fs.readFileSync('./src/index.template.html', 'utf-8') // 引入客户端,服务端生成的json文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json') const clientManifest = require('./dist/vue-ssr-client-manifest.json') renderer = createBundleRenderer(serverBundle, { // 推荐 runInNewContext: false, // 页面模板 template: template, // 客户端构建 manifest clientManifest }) } else { // 开发环境 const template = path.resolve(__dirname, './src/index.template.html') devConfig(app, template, (bundle, options) => { console.log('开发环境重新打包......') const option = Object.assign({ runInNewContext: false }, options) renderer = createBundleRenderer(bundle, option) }) } const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html') const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render: ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } try { const html = await renderer.renderToString(context) ctx.status = 200 ctx.body = html } catch (err) { handleError(err) } next() } router.get('/(.*)', render) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })
  • 添加build/dev.config.js文件
const path = require('path') const fs = require('fs') // memory-fs 可以是webpack将文件写入到内存中,而不是写入到磁盘 const MFS = require('memory-fs') const webpack = require('webpack') // 监听文件变化,兼容性更好(比fs.watch、fs.watchFile、fsevents) const chokidar = require('chokidar') const clientConfig = require('./webpack.client.config') const serverConfig = require('./webpack.server.config') // webpack热加载需要 const webpackDevMiddleware = require('koa-webpack-dev-middleware') // 配合热加载实现模块热替换 const webpackHotMiddleware = require('koa-webpack-hot-middleware') // 读取vue-ssr-webpack-plugin生成的文件 const readFile = (fs, file) => { try { return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8') } catch (e) { console.log('读取文件错误:', e) } } module.exports = function devConfig(app, templatePath, cb) { let bundle let template let clientManifest // 监听改变后更新函数 const update = () => { if (bundle && clientManifest) { cb(bundle, { template, clientManifest }) } } // 监听html模板改变,需手动刷新 template = fs.readFileSync(templatePath, 'utf-8') chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8') update() }) // 修改webpack入口配合模块热替换使用 clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] // 编译clientWebpack插入Koa中间件 const clientCompiler = webpack(clientConfig) const devMiddleware = webpackDevMiddleware(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true }) app.use(devMiddleware) clientCompiler.plugin('done', stats => { stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return clientManifest = JSON.parse(readFile( devMiddleware.fileSystem, 'vue-ssr-client-manifest.json' )) update() }) // 插入Koa中间件(模块热替换) app.use(webpackHotMiddleware(clientCompiler)) const serverCompiler = webpack(serverConfig) const mfs = new MFS() serverCompiler.outputFileSystem = mfs serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() if (stats.errors.length) return // vue-ssr-webpack-plugin 生成的bundle bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) update() }) }
  • 修改build/webpack.client.config.js文件,开发环境下不使用mini-css-extract-plugin提取css
// ... // 是否是生产环境 const isDev = process.env.NODE_ENV === 'development' module.exports = merge(baseWebpackConfig, { // ... module: { rules: [ { test: /\.less?$/, // 利用mini-css-extract-plugin提取css // 开发环境不需要提取css单独文件 use: !isDev ? [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] : ['vue-style-loader', 'css-loader', 'less-loader'] }, // ... ] }, // ... })
  • 开发环境下使用命令npm run dev

数据预获取和状态

在服务器端渲染(SSR)期间,比如说我们的应用程序有异步请求,在服务器端渲染之前,我们希望先返回异步数据后,我们再进行SSR渲染,因此我们需要的是先预取和解析好这些数据。

并且在客户端,在挂载(mount)到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据。否则的话,客户端应用程序会因为使用与服务器端应用程序不同的状态。会导致混合失败。

使用官方状态管理库Vuex存储这些数据

在路由组件上暴露出一个自定义静态函数 asyncData,用来获取该组件需要的数据

备注:静态函数 asyncData会在组件实例化之前调用,所以它无法访问this。需要将 store 和路由信息作为参数传递进去

  • 新建src/api/index.js文件,来模拟一个异步方法
export function fetchItem(id) { return Promise.resolve({ text: 'wmm66' }) }
  • 新建src/store/index.js文件
import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); // 假定我们有一个可以返回 Promise 的 import { fetchItem } from '../api/index'; export function createStore() { return new Vuex.Store({ state: { items: {} }, actions: { fetchItem({ commit }, id) { // `store.dispatch()` 会返回 Promise, // 以便我们能够知道数据在何时更新 return fetchItem(id).then(item => { commit('setItem', { id, item }); }); } }, mutations: { setItem(state, { id, item }) { state.items[id] = item // Vue.set(state.items, id, item); } } }); }
  • 修改src/app.js文件
import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' import { createStore } from './store/index' // 将 vue-router 和 vuex 结合在一起,实现应用的路由状态管理 import { sync } from 'vuex-router-sync' export function createApp() { // 创建router实例 const router = createRouter() // 创建store实例 const store = createStore() // 同步路由状态(route state)到store sync(store, router) const app = new Vue({ // 注入router到根vue实例中 router, store, // 根据实例简单的渲染应用程序组件 render: h => h(App) }) return { app, router, store } }
  • 修改src/components/item.vue文件,添加asyncData
<template> <div> <h1> item </h1> <p>{{ item.text }}</p> </div> </template>
export default { asyncData({ store, route }) { // 触发action代码,会返回Promise return store.dispatch('fetchItem', 'name') }, computed: { // 从store的state对象中获取item item() { return this.$store.state.items['name'] } } }
  • 修改src/entry-server.js文件
import { createApp } from './app' export default context => { /* 由于 路由钩子函数或组件 有可能是异步的,比如 同步的路由是这样引入 import Foo from './Foo.vue' 但是异步的路由是这样引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require动态加载进来的,因此我们这边需要返回一个promise对象。以便服务器能够等待所有的内容在渲染前 就已经准备好就绪。 */ return new Promise((resolve, reject) => { const { app, router, store } = createApp() // 设置服务器端 router 的位置 router.push(context.url) /* router.onReady() 等到router将可能的异步组件或异步钩子函数解析完成,在执行,就好比我们js中的 window.onload = function(){} 这样的。 官网的解释:该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和 路由初始化相关联的异步组件。 这可以有效确保服务端渲染时服务端和客户端输出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含义是: 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)。 通常在服务端渲染的数据预加载时使用。 有关 Router的实列方法含义可以看官网:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents() // 如果匹配不到路由的话,执行reject函数,并且返回404 if (!matchedComponents.length) { return reject({ code: 404 }) } // 对所有匹配的路由组件,调用 asyncData() Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 在所有预取钩子(preFetch hook) resolve 后, // 我们的 store 现在已经填充入渲染应用程序所需的状态。 // 当我们将状态附加到上下文, // 并且 `template` 选项用于 renderer 时, // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state resolve(app) }).catch(reject) // 正常的情况 // resolve(app) }, reject) }).catch(new Function()) }
  • 修改src/entry-client.js文件
import { createApp } from './app' import Vue from 'vue' // 给客户端加全局的mixin // 实现切换页面之前,执行新页面的asyncData Vue.mixin({ beforeRouteUpdate(to, from, next) { const { asyncData } = this.$options if (asyncData) { asyncData({ store: this.$store, route: to }).then(next).catch(next) } else { next() } } }) const { app, router, store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } // 假设App.vue模板中根元素 id = 'app' router.onReady(() => { // 添加路由钩子,用于处理 asyncData // 在初始路由 resolve 后执行 // 以便我们不会二次预取已有的数据 // 使用 router.beforeResolve(), 确保所有的异步组件都 resolve router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to) const prevMatched = router.getMatchedComponents(from) // 我们只关心非预渲染的组件 // 所有我们需要对比他们,找出两个品牌列表的差异组件 let diffed = false const activited = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!activited.length) { return next() } // 这里如果有加载指示器 (loading indicator),就触发 Promise.all(activited.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 停止加载指示器(loading indicator) next() }).catch(next) }) app.$mount('#app') })

注入不同的Head

服务器端渲染的时候,我们会根据不同的页面会有不同的metatitle

这里实现一个简单的title注入

  • 修改index.template.html模板文件,定义<title>{{ title }}</title>

注意

  1. 使用双花括号进行 HTML 转义插值,以避免 XSS 攻击。
  2. 应该在创建 context 对象时提供一个默认标题,以防在渲染过程中组件没有设置标题。
  • 新建src/mixins/title-mixin.js文件
function getTitle(vm) { // 组件可以提供一个 `title` 选项 // 此选项可以是一个字符串或函数 const { title } = vm.$options if (title) { return typeof title === 'function' ? title.call(vm) : title } else { return 'Vue SSR demo' } } const serverTitleMixin = { created() { const title = getTitle(this) if (title && this.$ssrContext) { this.$ssrContext.title = title } } } const clientTitleMixin = { mounted() { const title = getTitle(this) if (title) { document.title = title } } } // 我们通过 'webpack.DefinePlugin' 注入 'VUE_ENV' export default process.env.VUE_ENV === 'server' ? serverTitleMixin : clientTitleMixin
  • 修改build/webpack.server.config.js文件,添加process.env.VUE_ENV
// ... module.exports = merge(baseConfig, { // ... plugins: [ new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"server"' }), // ... ] });
  • 修改src/components/item.vue文件
import titleMixin from '../mixins/title-mixins' export default { mixins: [titleMixin], title() { return 'item页面' }, // ... }

页面级别缓存

可以使用micro-caching的缓存策略,来大幅提高应用程序处理高流量的能力。

这通常在 Nginx 层完成,但是我们也可以在 Node.js 中实现

  • 修改server.js文件
const path = require('path') const fs = require('fs') const Vue = require('vue') const Koa = require('koa') const Router = require('koa-router') const static = require('koa-static') // 引入缓存相关的模块 const LRU = require('lru-cache') const { createBundleRenderer } = require('vue-server-renderer') // 动态监听文件发生改变的配置文件 const devConfig = require('./build/dev.config.js') const isDev = process.env.NODE_ENV === 'development' // 缓存 const microCache = new LRU({ max: 100, // 在1分钟后过期 maxAge: 1000 * 60 }) const isCacheable = ctx => { // 假如 item 页面进行缓存 if (ctx.url === '/item') { return true } return false } // 设置renderer为全局变量,根据环境变量赋值 let renderer const app = new Koa() const router = new Router() // 静态资源目录对于相对入口文件 index.js 的路径 !isDev && app.use(static( path.join(__dirname, './dist') )) // 下面我们根据环境变量来生成不同的 BundleRenderer 实例 if (!isDev) { // 正式环境 const template = fs.readFileSync('./src/index.template.html', 'utf-8') // 引入客户端,服务端生成的json文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json') const clientManifest = require('./dist/vue-ssr-client-manifest.json') renderer = createBundleRenderer(serverBundle, { // 推荐 runInNewContext: false, // 页面模板 template: template, // 客户端构建 manifest clientManifest }) } else { // 开发环境 const template = path.resolve(__dirname, './src/index.template.html') devConfig(app, template, (bundle, options) => { console.log('开发环境重新打包......') const option = Object.assign({ runInNewContext: false }, options) renderer = createBundleRenderer(bundle, option) }) } const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html') const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render: ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } // 判断是否可缓存,可缓存且缓存中有的话,直接把缓存中返回 const cacheable = isCacheable(ctx) if (cacheable) { const hit = microCache.get(ctx.url) if (hit) { console.log('从缓存中取', hit) return ctx.body = hit } } try { const html = await renderer.renderToString(context) ctx.status = 200 ctx.body = html if (cacheable) { console.log('设置缓存', ctx.url) microCache.set(ctx.url, html) } } catch (err) { handleError(err) } next() } router.get('/(.*)', render) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })

创作不易,若本文对你有帮助,欢迎打赏支持作者!

 分享给好友: