搭建一个简易的Vue SSR

搭建自己的Vue SSR

渲染一个vue实例

首先创建一个目录,使用npm初始化一下package.json文件

npm init -y
复制代码

再安装vue以及vue-server-renderer这两个包:

注意一下版本,不同版本不确定会不会有不同的配置引起意料之外的错误

npm i vue@2.6.11 vue-server-renderer@2.6.11
复制代码

在根目录下创建一个server.js,将在这里创建vue实例:

const Vue = require('vue')
​
const app = new Vue({
    // 模板
  template: `
    <div id="app">
        <h1>{{ message }}</h1>
    </div>
  `,
  data: {
    message: 'hello world'
  }
})
复制代码

现在的目标就是将data里面的message给替换到template内的差值表达式中,再将模板给渲染出来。

这时候可以将vue-server-renderer引入进来,这个包可以用来渲染实例:

const Vue = require('vue')
// 加载的时候可以将createRenderer方法加载并执行,这样就可以得到一个渲染器,可以用来渲染vue实例
const renderer = require('vue-server-renderer').createRenderer()
​
const app = new Vue({
  template: `
    <div id="app">
        <h1>{{ message }}</h1>
    </div>
  `,
  data: {
    message: 'hello world'
  }
})
​
// renderToString方法第一个参数是vue实例,第二个是回调函数,里面可以接收到错误对象以及html模板
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
})
复制代码

使用node来执行一下这份代码看看:

node server.js
复制代码

1630843493848.png

可以看到模板里面的内容已经被渲染出来了,其内部差值表达式的内容也被替换成了真实的数据。

同时div#app上还额外加了一个data-server-renderer属性,他的作用是后续客户端渲染激活的入口。

结合到web服务器

先安装一下express作为本地服务器:

npm i express@4.17.1
复制代码
const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()
​
const express = require('express')
const server = express()
​
// 设置路由
server.get('/', (req, res) => {
  const app = new Vue({
    template: `
    <div id="app">
        <h1>{{ message }}</h1>
    </div>
  `,
    data: {
      message: 'hello world'
    }
  })
​
  renderer.renderToString(app, (err, html) => {
    if (err) {
        // 当出错时返回500状态码
      return res.status(500).end('Internal Server Error.')
    }
      // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
      // 成功时直接返回html内容
    res.end(html)
  })
})
​
// 监听3000端口
server.listen(3000, () => {
  console.log('server is running at port 3000')
})
复制代码

使用nodemon来启动一下服务:

nodemon server.js
复制代码

在浏览器中打开localhost:3000可以看到页面也能正常显示

1630844665265.png

使用HTML模板

在根目录下创建一个HTML的模板,取名index.template.html

<html>
<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">
    <title>Document</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
​
复制代码

body中的注释是一个特殊的标记,将来在渲染时,需要渲染的内容会渲染到此处

同时,在创建渲染器的时候,配置模板选项:

const Vue = require('vue')
// 引入fs模块
const fs = require('fs')
const renderer = require('vue-server-renderer').createRenderer({
    // 用fs读取模板文件
  template: fs.readFileSync('./index.template.html', 'utf-8')
})
​
const express = require('express')
const server = express()
​
server.get('/', (req, res) => {
  const app = new Vue({
    template: `
    <div id="app">
        <h1>{{ message }}</h1>
    </div>
  `,
    data: {
      message: 'hello world'
    }
  })
​
  renderer.renderToString(app, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
})
​
server.listen(3000, () => {
  console.log('server is running at port 3000')
})
复制代码

启动一下服务打开浏览器可以看到服务端返回了完整的页面:

1630847170989.png

在模板中使用外部数据

renderToString方法中可以传入第二个参数,这些参数都是可以在模板中渲染出来的:

// renderToString方法
renderer.renderToString(app, {
    title: 'VueSSR'
  },(err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
复制代码

同时可以在index.template.htmltitle标签里面使用插值表达式绑定传入的数据:

<title>{{ title }}</title>
复制代码

重启服务、刷新页面,可以看到浏览器标签上已经更改成配置的title

1630848453999.png

还可以通过这种方式来配置meta标签,但是在模板里面需要使用{{{ }}}来使内容原样输出:

renderer.renderToString(app, {
    title: 'VueSSR',
    meta: `
      <meta name="description" content="VueSSR"/>
    `
  },(err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
复制代码
<meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    {{{ meta }}}
    <title>{{ title }}</title>
复制代码

重启服务,就能看见手动配置的meta已经被加入进去了:

1630848871397.png

构建配置

以上的服务端渲染只是把vue实例处理成纯静态的html字符串再发送给客户端,当内容中有动态交互操作的需求时,还需要额外的处理。
先来模拟一下动态交互功能:

在创建vue实例时加入额外的功能:

const app = new Vue({
    template: `
    <div id="app">
        <h1>{{ message }}</h1>
        <h2>动态交互</h2>
        <input v-model="message" type="text">
        <button @click="onClick">点击弹窗</button>
    </div>
  `,
    data: {
      message: 'hello world'
    },
    methods: {
      onClick() {
        alert(this.message)
      }
    }
  })
复制代码

现在预想的是:当输入框内容发生变化的时候,h1标签的内容也会随之变化;点击按钮也会弹出弹框输入框的内容。

但是在浏览器中,只有这些东西的视图,而功能却没有:

GIF 2021-9-5 21-56-27.gif

打开网路日志可以看到页面里面并没有所需的js文件,只有页面内容:

1630850758159.png

所以要实现客户端交互的功能还需要额外的处理才能实现。

打开vue官网,可以找到一张关于ssr源码结构的一张图:

1630851023048.png

图的左边是应用的源代码(source)、中间是webpack、右边是服务端(node server)。

对于以上所做的工作来说,只有服务端的入口(server entry),这是用来处理服务端渲染的。若想要服务端渲染的内容拥有客户端动态交互的能力的话,还需要有客户端入口(client entry)用于处理客户端交互。

对于server entry来说,最终要通过webpack打包成一个server bundle,其作用主要是来做服务端渲染;对于client entry来说,它最终要打包成client bundle,它的作用是来接管服务端渲染生成的页面并激活成动态的客户端页面。

所以接下来要先处理一下源码结构,源码的大致结构应是如此:

1630851685992.png

先在根目录下创建一个src目录,里面再创建一个App.vue文件,文件内容可以写成之前创建的vue实例中的option

<template>
  <div id="app">
    <h1>{{ message }}</h1>
    <h2>动态交互</h2>
    <input v-model="message" type="text">
    <button @click="onClick">点击弹窗</button>
  </div>
</template>
​
<script>
export default {
  name: "App",
  data() {
    return {
      message: 'hello world'
    }
  },
  methods: {
    onClick() {
      alert(this.message)
    }
  }
}
</script>
​
<style scoped>
​
</style>
​
复制代码

在src下创建一个app.js,它是该同构应用通用的启动入口

import Vue from 'vue'
import App from './App.vue'
​
// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const app = new Vue({
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app }
}
复制代码

app.js 是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任转移到纯客户端 entry 文件。

——vue ssr指南

在src创建一个entry-client.js

import { createApp } from './app'
​
// 客户端特定引导逻辑……
​
const { app } = createApp()
​
// 这里假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app')
​
复制代码

客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中

在src下创建一个entry-server.js

import { createApp } from './app'
​
export default context => {
  const { app } = createApp()
  return app
}
​
复制代码

服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 – 但是稍后我们将在此执行服务器端路由匹配 (server-side route matching) 和数据预取逻辑 (data pre-fetching logic)。

此时代码还不可以运行

安装依赖

  1. 安装生产依赖

除去之前已经安装好的vuevue-server-rendererexpress以外,还需要cross-env(它的作用就是通过npm scripts设置跨平台环境变量):

npm i cross-env@7.0.2
复制代码
  1. 安装开发依赖
npm i -D webpack@4.43.0 webpack-cli@3.3.12 webpack-merge@5.0.9 webpack-node-externals@2.5.0 @babel/core@7.10.4
@babel/plugin-transform-runtime@7.10.4 @babel/preset-env@7.10.4 babel-loader@8.1.0 css-loader@3.6.0 url-loader@4.1.0 file-loader@6.0.0 rimraf@3.0.2 vue-loader@15.9.3 vue-template-compiler@2.6.11 friendly-errors-webpack-plugin@1.7.0
复制代码

webpack版本要在4.x,不然可能会有意料之外的错误

webpack配置

在根目录下创建build文件夹,在该文件夹里存放打包配置文件:

  1. webpack.base.config.js
/**
 * 公共配置
 */
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
  mode: isProd ? 'production' : 'development',
  output: {
    path: resolve('../dist/'),
    publicPath: '/dist/',
    filename: '[name].[chunkhash].js'
  },
  resolve: {
    alias: {
      // 路径别名,@ 指向 src
      '@': resolve('../src/')
    },
    // 可以省略的扩展名
    // 当省略扩展名的时候,按照从前往后的顺序依次解析
    extensions: ['.js', '.vue', '.json']
  },
  devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
  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'
        ]
      },
      // CSS 预处理器,参考:https://vue-loader.vuejs.org/zh/guide/pre•processors.html
      // 例如处理 Less 资源
      // {
      // test: /.less$/,
      // use: [
      // 'vue-style-loader',
      // 'css-loader',
      // 'less-loader'
      // ]
      // },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new FriendlyErrorsWebpackPlugin()
  ]
}
​
复制代码
  1. webpack.client.config.js
/**
 * 客户端打包配置
 */
const {merge} = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, {
  entry: {
    app: './src/entry-client.js'
  },
  module: {
    rules: [
      // ES6 转 ES5
      {
        test: /.m?js$/,
        exclude: /(node_modules|bower_components)/,
        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()
  ]
})
​
复制代码
  1. webpack.server.config.js
/**
 * 服务端打包配置
 */
const {merge} = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
  // 将 entry 指向应用程序的 server entry 文件
  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()
  ]
})
​
复制代码

配置构建命令

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 && npm run build:client && npm run build:server"
}
复制代码

build:client:客户端打包脚本;

build:server:服务端打包脚本;

build:一起打包客户端与服务端;

试运行一下:

GIF 2021-9-5 23-16-11.gif

一切正常运行~

启动应用

上面的打包生成了以下几个文件:

1630933575615.png

对于server-bundle,官网上也有详细的使用方式:ssr.vuejs.org/zh/guide/bu…

这里使用一下它:

server.js中,将createRenderer方法替换成createBundleRenderer方法:

const fs = require('fs')
// 引入serverBundle
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
​
// 引入clientManifest
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
​
// 将模板的引入提取出来
const template = fs.readFileSync('./index.template.html', 'utf-8')
​
// createBundleRenderer方法第一个参数就是serverBundle
// 第二个参数是个对象,里面可配置模板以及clientManifest
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
  template,
  clientManifest
})
​
// ...其他的代码
复制代码

同时将路由配置中创建的实例给删除掉:

server.get('/', (req, res) => {
  renderer.renderToString({
    title: 'VueSSR',
    meta: `
      <meta name="description" content="VueSSR"/>
    `
  },(err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
})
复制代码

这边是删除前后的diff:

1630934385945.png

此处删除了vue的实例,它会找到entry-server,调用里面的方法得到实例然后再进行渲染。

在命令行里面输入启动服务,打开浏览器可以看到页面还是能像之前那样显示,但是动态交互还不能正常工作:

1630934624066.png

可以发现下面有个app.js的请求报了404,但是dist文件夹里面确有该文件。这是因为当前服务并未将该文件夹里面的资源暴露出来,所以还需要处理一下这些文件的问题:

// 在server.js里设置路由的上方设置静态资源路径
// 当请求以/dist开头的文件时,服务器将会在./dist下尝试查找所需的文件
server.use('/dist', express.static('./dist'))
复制代码

刷新浏览器可以发现app.js已经被加载进来了,同时客户端的动态交互也能正常工作了:

GIF 2021-9-6 21-32-23.gif

构建开发模式

在开发的过程中,仅靠以上过程需要在每次修改源码之后就需要重新打包在通过server启动web服务。所以要先实现一个开发模式:写完代码自动进行构建并且重启web服务,再重新刷新浏览器页面等。

先在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 && npm run build:client && npm run build:server",
    "start": "cross-env NODE_ENV=production node server.js",
    "dev": "node server.js"
}
复制代码

start:生产环境下启动服务

dev:开发环境下启动服务

server.js中,来分别处理开发模式与生产模式的运行方式:

// 定义一个变量来判别环境
const isProd = process.env.NODE_ENV === 'production'
​
// 因为后面需要用到renderer,所以将renderer的定义提前
let renderer
if (isProd) {
  // 生产模式
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
    template,
    clientManifest
  })
} else {
  // 开发模式
}
复制代码

生产环境只需要像之前那样操作就好了,但是对于开发环境还需要监视文件自动打包构建、重新生产renderer渲染器。

同时,在路由处理函数里面,也需要对不同的环境进行处理:

// 生产环境的render给提取出来
const render = (req, res) => {
  renderer.renderToString({
    title: 'VueSSR',
    meta: `
      <meta name="description" content="VueSSR"/>
    `
  },(err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
}
​
// 设置路由
server.get('/', isProd ?
  render :
  (res, req) => {
    // ...一些操作
    // 等待有了renderer之后进行渲染
    render()
  }
)
复制代码

setupDevServer

为了生成开发环境的renderer,还需要一个setupDevServer函数,该函数需要传入两个参数,第一个参数是server实例(给它挂载一些中间件等),第二个参数是一个回调函数(重新生成renderer),回调函数中可以接收到serverBundletemplate以及clientManifest

整理一下代码:

const Vue = require('vue')
const fs = require('fs')
// createBundleRenderer提前
const { createBundleRenderer } = require('vue-server-renderer')
​
const express = require('express')
// server实例提前
const server = express()
​
const isProd = process.env.NODE_ENV === 'production'
​
let renderer
let onReady
if (isProd) {
  // 生产模式
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest
  })
} else {
  // 开发模式
    // 需要的setupDevServer函数,先不去实现
    // setupDevServer会返回一个promise,以便在获取renderer时能在正确的获取到
  onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
  })
}
​
server.use('/dist', express.static('./dist'))
​
const render = (req, res) => {
  renderer.renderToString({
    title: 'VueSSR',
    meta: `
      <meta name="description" content="VueSSR"/>
    `
  },(err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
}
​
server.get('/', isProd ?
  render :
  async (res, req) => {
    // ...一些操作
    // 等待有了renderer之后进行渲染
    await onReady
    render()
  }
)
​
server.listen(3000, () => {
  console.log('server is running at port 3000')
})
​
复制代码

以上就是我们期望的流程,先在要开始实现setupDevServer

build目录下新建一个setup-dev-server.js,文件内导出一个函数,该函数返回一个promise

​
module.exports = (server, callback) => {
  const onReady = new Promise()
​
  // 监视构建 -> 更新renderer
​
  return onReady
}
​
复制代码

同时在server.js中导入该函数

const setupDevServer = require('./build/setup-dev-server')
复制代码

接下来就就开始完善setupDevServer函数。

在这个函数中,需要构建出templateserverBundle以及clientManifest以供回调函数使用,同时还需要个update函数,该函数在被调用时需要判断上面三个存不存在,如果都存在则调用回调函数并传入这三个:

​
module.exports = (server, callback) => {
  const onReady = new Promise()
​
  // 监视构建 -> 更新renderer
​
  let template,
    serverBundle,
    clientManifest
​
  const update = () => {
    if (template && serverBundle && clientManifest) {
      callback(serverBundle, template, clientManifest)
    }
  }
​
  return onReady
}
​
复制代码

在调用回调函数之后,需要将promise的状态变成fullfilled

​
module.exports = (server, callback) => {
    // 定义一个变量保存promise的resolve方法
  let ready
  const onReady = new Promise(r => ready = r)
​
  // 监视构建 -> 更新renderer
​
  let template,
    serverBundle,
    clientManifest
​
  const update = () => {
    if (template && serverBundle && clientManifest) {
        // 调用resolve方法将promise状态变成fullfilled,在外面也能await到这个状态
      ready()
      callback(serverBundle, template, clientManifest)
    }
  }
​
  return onReady
}
​
复制代码

同时,update函数的执行也需要在构建完成template/serverBundle/clientManifest之后,然后再更新renderer渲染器。接下来就需要挨个实现这几个构建流程。

构建template

构建template时需要读取index.template.html文件,所以还需要引入fspath模块。读取到文件后将它保存到template上面再调用update函数:

const path = require('path')
const fs = require('fs')
​
module.exports = (server, callback) => {
  let ready
  const onReady = new Promise(r => ready = r)
​
  // 监视构建 -> 更新renderer
​
  let template,
    serverBundle,
    clientManifest
​
  const update = () => {
    if (template && serverBundle && clientManifest) {
      ready()
      callback(serverBundle, template, clientManifest)
    }
  }
​
  // 构建template -> 调用update -> 更新renderer渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  console.log(template)
​
  // 构建serverBundle -> 调用update -> 更新renderer渲染器
  // 构建clientManifest -> 调用update -> 更新renderer渲染器
​
  return onReady
}
复制代码

运行一下npm run dev命令看下打印:

1631107203825.png

可以看到模板文件已经成功读取到了。

这还不够,当文件发生变化时,还需要再进行这些操作,所以需要chokidar模块来辅助完成监视文件的工作:

npm i chokidar@3.4.0
复制代码

安装完成之后引入chokidar:

const path = require('path')
const fs = require('fs')
const chokidar = require('chokidar')
​
​
module.exports = (server, callback) => {
  let ready
  const onReady = new Promise(r => ready = r)
​
  // 监视构建 -> 更新renderer
​
  let template,
    serverBundle,
    clientManifest
​
  const update = () => {
    if (template && serverBundle && clientManifest) {
      ready()
      callback(serverBundle, template, clientManifest)
    }
  }
​
  // 构建template -> 调用update -> 更新renderer渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })
​
  // 构建serverBundle -> 调用update -> 更新renderer渲染器
  // 构建clientManifest -> 调用update -> 更新renderer渲染器
​
  return onReady
}
复制代码

构建serverBundle

构建serverBundle需要对项目源代码进行构建打包,所以需要引入webpack以及其配置文件:

const path = require('path')
const fs = require('fs')
const chokidar = require('chokidar')
const webpack = require('webpack')
​
module.exports = (server, callback) => {
  // ...省略
​
  // 构建template -> 调用update -> 更新renderer渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })
​
  // 构建serverBundle -> 调用update -> 更新renderer渲染器
  // 直接将配置文件引入进来
  const serverConfig = require('./webpack.server.config')
  // 将配置文件传给webpack然后进行编译,它会返回一个编译器
  const serverCompiler = webpack(serverConfig)
  // 编译器有个watch方法,它会监视文件变化,当文件发送变化后还会自动重新打包
  // watch方法第一个参数是配置对象,第二个参数是回调函数,函数的第一个参数是打包构建时webpack自身发生的错误,第二个参数是打包结果的信息对象
  serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    // stats下有个hasErrors方法,它会返回源代码中的错误
    if(stats.hasErrors()) return
    // 使用readfile读取打包后的文件,由于读取到的是字符串,所以还需要再解析一下
    serverBundle = JSON.parse(
      fs.readFileSync(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    // 调用update方法
    update()
  })
​
  // 构建clientManifest -> 调用update -> 更新renderer渲染器
​
  return onReady
}
​
复制代码

将打包结果写入内存中

webpack打包会将结果生成到指定文件夹下,但是在开发过程中会经常性的修改文件,而文件从磁盘中的读写操作也是很耗时的,所以需要对上面进行优化,将打包结果写入内存之中。

对于将结果读取写入内存可以借助webpack-dev-middleware这个插件:

npm i webpack-dev-middleware@3.7.2
复制代码
const path = require('path')
const fs = require('fs')
const chokidar = require('chokidar')
const webapck = require('webpack')
// 引入
const devMiddleware = require('webpack-dev-middleware')
​
module.exports = (server, callback) => {
  // ...省略
​
  // 构建template -> 调用update -> 更新renderer渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })
​
  // 构建serverBundle -> 调用update -> 更新renderer渲染器
  const serverConfig = require('./webpack.server.config')
  const serverCompiler = webpack(serverConfig)
  // 将编译器传入devMiddleware中,第二个参数是配置选项
  const serverDevMiddleware = devMiddleware(serverCompiler, {
    logLevel: 'silent' // 关闭日志
  })
  // 给编译器的done钩子上注册一个事件,done钩子会在每次编译结束后调用
  serverCompiler.hooks.done.tap('server', () => {
    // 不同于fs,devMiddleware执行后返回的实例serverDevMiddleware的fileSystem是可以读取内存中的文件
    serverBundle = JSON.parse(
      serverDevMiddleware.fileSystem.readFileSync(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    update()
  })
  // 下面的watch可以删除了
  // serverCompiler.watch({}, (err, stats) => {
  //   if (err) throw err
  //   if (stats.hasErrors()) return
  //   serverBundle = JSON.parse(
  //     fs.readFileSync(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'), 'utf-8')
  //   )
  //   update()
  // })
​
  // 构建clientManifest -> 调用update -> 更新renderer渲染器
​
  return onReady
}
复制代码

可以在update函数调用之前打印一下serverBundle

1631113175786.png

成功打印出来也就说明这个东西没问题。

构建clientManifest

clilentManifest的构建与serverBundle类似,只需要将命名部分改造一下就差不多了:

// 构建clientManifest -> 调用update -> 更新renderer渲染器
  const clientConfig = require('./webpack.client.config')
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    // 设置路径,路径最好从配置文件中获取
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent' // 关闭日志
  })
  clientCompiler.hooks.done.tap('client', () => {
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
  })
复制代码

setupDevServer给整理一下:

const path = require('path')
const fs = require('fs')
const chokidar = require('chokidar')
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware')
​
// 将path.resolve方法给提取出来
const resolve = file => path.resolve(__dirname, file)
​
module.exports = (server, callback) => {
  let ready
  const onReady = new Promise(r => ready = r)
​
  // 监视构建 -> 更新renderer
​
  let template,
    serverBundle,
    clientManifest
​
  const update = () => {
    if (template && serverBundle && clientManifest) {
      ready()
      callback(serverBundle, template, clientManifest)
    }
  }
​
  // 构建template -> 调用update -> 更新renderer渲染器
  const templatePath = resolve('../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })
​
  // 构建serverBundle -> 调用update -> 更新renderer渲染器
  const serverConfig = require('./webpack.server.config')
  const serverCompiler = webpack(serverConfig)
  const serverDevMiddleware = devMiddleware(serverCompiler, {
    logLevel: 'silent' // 关闭日志
  })
  serverCompiler.hooks.done.tap('server', () => {
    serverBundle = JSON.parse(
      serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    update()
  })
​
  // 构建clientManifest -> 调用update -> 更新renderer渲染器
  const clientConfig = require('./webpack.client.config')
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent' // 关闭日志
  })
  clientCompiler.hooks.done.tap('client', () => {
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
  })
​
  return onReady
}
​
复制代码

这时候启动一下发现页面并不能打开,控制台也有个报错:

1631193050424.png

是在server.js里面发生的错误,查看一下发现是在设置路由的里面执行render函数时没有将res``req传进去,修改一下:

// 设置路由
server.get('/', isProd ?
  render :
  async (res, req) => {
    // ...一些操作
    // 等待有了renderer之后进行渲染
    await onReady
    render(res, req)
  }
)
复制代码

重启一下dev命令,打开浏览器发现页面已经正常渲染了但是页面的交互都不能正常工作,在网络日志里面也可以看到app.js报了404:

GIF 2021-9-9 21-15-24.gif

回看server.js中使用express对静态文件的处理,这里只是对物理磁盘上的文件进行处理,而在开发模式下,数据都存放在内存之中,所以需要在setupDevServer函数中,将传入的express实例(server)挂载上devMiddleware

module.exports = (server, callback) => {
  // 。。。省略
​
  // 构建template -> 调用update -> 更新renderer渲染器
  // 构建serverBundle -> 调用update -> 更新renderer渲染器
  // 构建clientManifest -> 调用update -> 更新renderer渲染器
  
  // 将clientDevMiddleware挂载到express服务上,提供对其内部内存中的数据的访问
  server.use(clientDevMiddleware)
​
  return onReady
}
复制代码

这样当访问dist的时候,就会尝试从内存中访问。

重启服务,可以发现客户端的交互又能正常实现了:

GIF 2021-9-9 21-33-29.gif

热更新

目前当项目发生更改后还需要手动刷新浏览器,所以还需要再配置一下热跟新模块:

npm i -D webpack-hot-middleware@2.25.0
复制代码

然后在setupDevServer中引入webpack配置文件之后直接给plugins选项push进热更新模块;再将配置文件中的entry选项改变成一个数组,里面还要配置上一个额外的脚本;输出的文件名也不要使用带有hash的;最后将热更新模块挂载到服务上:

// 文件顶部引入热更新模块
const hotMiddleware = require('webpack-hot-middleware')
​
// 构建clientManifest -> 调用update -> 更新renderer渲染器
  const clientConfig = require('./webpack.client.config')
  // 直接push
  clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
  clientConfig.entry.app = [
    'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
    clientConfig.entry.app
  ]
  clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent' // 关闭日志
  })
  clientCompiler.hooks.done.tap('client', () => {
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
  })
​
  // 挂载
  server.use(hotMiddleware(clientCompiler, {
    log: false
  }))
复制代码

重启一下服务,尝试修改跟组件下的静态内容,发现浏览器内也会跟着改变:

GIF 2021-9-9 21-59-21.gif

路由处理

先把vue router安装进来:

npm i vue-router@3.5.2
复制代码

src下创建pages子目录,用于存放页面组件;pages目录下创建几个示例页面:homeabout以及404

1631361140505.png

src下创建一个router子目录,其里面再创建一个index.js,用于存放路由配置:

// src/router/index.js
​
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/pages/home'
​
Vue.use(VueRouter)
​
// 像vue实例那样导出一个函数
export const createRouter = () => {
  const router = new VueRouter({
    mode: 'history',  // hash模式在服务端渲染下不兼容
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import('@/pages/about')
      },
      {
        path: '*',
        name: 'error',
        component: () => import('@/pages/404')
      }
    ]
  })
​
  return router
}
​
复制代码

app.js中引入createRouter函数并执行,将返回的router实例挂载到vue根实例上:

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
​
export function createApp () {
​
  // 创建路由实例
  const router = createRouter()
​
  const app = new Vue({
    router,  // 把路由挂载到vue根实例上
    render: h => h(App)
  })
  // 导出router以便在外部使用
  return { app, router }
}
​
复制代码

适配服务端

entry-server.js中,将路由适配到服务端渲染中(官网示例):

// entry-server.js
import { createApp } from './app'
​
export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
​
    // 设置服务器端 router 的位置
    router.push(context.url)
​
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      // 这里的代码是处理非法路由的,由于已经在路由规则中配置了404组件,所以下面的代码可以删除
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
​
      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}
​
复制代码

asyncawait将其改造一下,能看的更直观一些:

// entry-server.js
import { createApp } from './app'
​
export default async context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  const { app, router } = createApp()
​
  // 设置服务器端 router 的位置
  router.push(context.url)
​
  // 等到 router 将可能的异步组件和钩子函数解析完
  // 这里已经删去了之前无用的代码
  await new Promise(router.onReady.bind(router))
  
  return app
}
​
复制代码

server.js中还要将请求的url给配置进renderToString方法里面,同时将服务端路由的设置给改一下:

// server.js
const render = (req, res) => {
  renderer.renderToString({
    title: 'VueSSR',
    meta: `
      <meta name="description" content="VueSSR"/>
    `,
    url: req.url
  },(err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  })
}
​
// 设置路由, 设为*所有的路由都会进入这里
server.get('*', isProd ?
  render :
  async (req, res) => {
    // ...一些操作
    // 等待有了renderer之后进行渲染
    await onReady
    render(req, res)
  }
)
复制代码

由于renderToString方法支持promise,所以render函数也可以使用asyncawait改造一下:

const render = async (req, res) => {
  // 使用try catch捕获异常
  try {
    const html = await renderer.renderToString({
      title: 'VueSSR',
      meta: `
      <meta name="description" content="VueSSR"/>
    `,
      url: req.url
    })
    // 防止中文乱码
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  } catch (e) {
    res.status(500).end('Internal Server Error.')
  }
}
复制代码

适配客户端

entry-client.js中,需要在router.onReady之后再挂载:

// entry-client.js
import { createApp } from './app'
​
// 客户端特定引导逻辑……
​
const { app, router } = createApp()
​
// 这里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
  app.$mount('#app')
})
​
复制代码

处理页面上路由出口

app.vue的模板中,设置好router-link以及router-view

<template>
  <div id="app">
    <h1>{{ message }}</h1>
    <h2>动态交互111</h2>
    <input v-model="message" type="text">
    <button @click="onClick">点击弹窗</button>
​
    <ul>
      <li><router-link to="/">Home</router-link></li>
      <li><router-link to="/about">about</router-link></li>
    </ul>
​
    <router-view/>
  </div>
</template>
复制代码

路由配置就差不多完成了,使用npm run dev再启动一下项目看看效果叭:

GIF 2021-9-11 21-51-07.gif

哦可了

管理页面head

关于页面head的管理可以使用nuxtvue-meta

npm i vue-meta@2.4.0
复制代码

app.js中引入并注册插件:

// app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import VueMeta from 'vue-meta'
​
// 注册
Vue.use(VueMeta)
​
// 混入
Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - VueSSR'  // %s代表原始标题,现在会将原始标题再加上模板内的内容一起显示到标签上
  }
})
​
// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
​
  // 创建路由实例
  const router = createRouter()
​
  const app = new Vue({
    router,  // 把路由挂载到vue根实例上
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app, router }
}
​
复制代码

entry-server.js中:

// entry-server.js
import { createApp } from './app'
​
export default async context => {
  const { app, router } = createApp()
​
  // 获取meta
  const meta = app.$meta()
​
  router.push(context.url)
​
  // 替换meta
  context.meta = meta
​
  await new Promise(router.onReady.bind(router))
​
  return app
}
​
复制代码

在模板文件中也要预留好位置:

<html>
<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.inject().title.text() }}}
    {{{ meta.inject().meta.text() }}}
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
复制代码

重启一下服务,可以看到页面标题已经变成了所需要的:

1631370092918.png

数据预取和状态管理

在服务端渲染期间,组件的beforeCreatecreated会执行但是并不会等待其中的异步操作,所以当在服务端渲染项目里面继续在created周期使用异步操作获取数据时,并不能达到预期的效果。

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

为了解决这个问题, 获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或”状态容器(state container)”中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。

首先安装vuex以及axios

npm i vuex@3.5.1 axios@0.19.2
复制代码

src目录下创建store子目录,里面再创建index.js用于配置store

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
​
Vue.use(Vuex)
​
export const createStore = () => {
  return new Vuex.Store({
    // state设置为函数防止数据污染
    state: () => ({
      posts: []
    }),
    mutations: {
      setPosts(state, payload) {
        state.posts = payload
      }
    },
    actions: {
      // 服务端渲染期间务必让action返回一个promise
      async getPosts({ commit }) {
        const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
        commit('setPosts', data.data)
      }
    }
  })
}
​
复制代码

about组件内调用action

<template>
  <div>
    <h1>about</h1>
​
    <ol>
      <li v-for="(post, index) in posts" :key="index">{{ post.title }}</li>
    </ol>
  </div>
</template>
​
<script>
import { mapState, mapActions } from 'vuex'
​
export default {
  name: "about",
  metaInfo: {
    title: 'about'
  },
  computed: {
    ...mapState(['posts'])
  },
  // vue ssr特殊为服务端渲染提供的一个生命周期钩子,会在渲染之前进行调用
  serverPrefetch() {
    // 发起action
    return this.getPosts()
  },
  methods: {
    ...mapActions(['getPosts'])
  }
}
</script>
​
<style scoped>
​
</style>
​
复制代码

重启一下项目进入about页面,发现页面内并没有展示列表,打开网络日志查看对当前页面的请求:

1631372597780.png

可以看到页面加载过来时是有内容的,但是由于这些数据还都在服务端,当客户端接管页面时客户端并没有这些数据,导致内容的丢失。

entry-server.js里面,需要在服务端渲染完毕之后拿到数据状态赋值给上下文中:

// entry-server.js
import { createApp } from './app'
​
export default async context => {
  const { app, router, store } = createApp()
​
  const meta = app.$meta()
​
  // 设置服务器端 router 的位置
  router.push(context.url)
​
  context.meta = meta
​
  // 等到 router 将可能的异步组件和钩子函数解析完
  await new Promise(router.onReady.bind(router))
​
  // 会在服务端渲染好之后再调用,所以可以在里面拿到数据状态
  context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
  }
​
  return app
}
​
复制代码

entry-cilent.js中需要判断一下当前window对象下是否有__INITIAL_STATE__这个属性,有的话替换一下:

import { createApp } from './app'
​
const { app, router, store } = createApp()
​
// 替换
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}
​
router.onReady(() => {
  app.$mount('#app')
})
​
复制代码

在元素审查中也可以看到这段脚本:

1631373668841.png

结尾

使用npm run build命令进行打包然后使用npm run start启动生产模式,打开3000端口可以看到页面已经能够正常启动了,但是还是有个bug,就是当第一次进入的页面是home页时,再由此页跳转进入about页,会发现数据不能正常加载。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享