Vue SSR學習筆記

365 阅读8分钟

看了Vue SSR的文档,还是搞不太懂SSR要怎样配置,结合拉勾前端训练营所讲的内容,整理一篇笔记。这部分拉勾前端训练营讲得特别清晰,不少内容看文档没有注意到,讲课的老师真的很棒(毕竟是大厂出来的)。

基本使用

先建立一个项目,然后下载vue和vue-server-renderer:

npm install vue vue-server-renderer --save

简单构建一个例子吧:

const Vue = require(‘vue’)
const renderer = require(‘vue-server-renderer’)

const app = new Vue({
  template: `<div>Hello World</div>`
})

renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
})

执行打印的结果为:

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

data-server-rendered="true" 是用来作标记,标示是客户端渲染的入口。

把以上的代码放在web服务器,这里用express示范:

npm install express --save

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
  })
})

server.listen(8080)

我们已经完成了一个简单的SSR应用,然而,有一个问题,就是应用与html模板结合在一起,这样读写代码不方便。我们可以另外新建一个模板。在同目录下新建 index.template.html,把html模板放在这里:

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

vue-ssr-outlet 是之后vue注入的地方。改一下之前的应用:

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer({
  template: require(‘fs’).readFileSync(‘./index.template.html’, ‘utf-8’)
})

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(html)
  })
})

server.listen(8080)

现在vue渲染的模板就会注入至index.template.html。

web_demo

动态渲染

不过现在仅仅是静态渲染,也就是如果vue中加上动态绑定方法等是没有效,服务器只输出html。这时候需要webpack帮忙打包。先看Vue SSR官网的一幅图:

structure

左边是源码,分为服务和客户端两部分,中间是webpack打包,右边是输出的部分,服务器部分打包构成渲染,客户端打包的则之后负责动态渲染。 先看一下官网的项目架构:

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # universal entry
├── entry-client.js # runs in browser only
└── entry-server.js # runs on server only

App.vue是入口模板,内容如下:

<template>
  <div id="app">
    <ul>
      <li>
        <router-link to="/">Home</router-link>
      </li>
      <li>
        <router-link to="/about">About</router-link>
      </li>
      <li>
        <router-link to="/posts">Posts</router-link>
      </li>
    </ul>

    <!-- 路由出口 -->
    <router-view/>
  </div>
</template>

<script>
export default {
  name: 'App',
  data () {
    return {
      message: 'hello'
    }
  },
  methods: {
    onClick () {
      console.log('Hello World!')
    }
  }
}
</script>

<style>

</style>

接下来要写通用入口,可以理解为服务端和客户端共同需要的部分:

/**
 * 通用启动入口
 */
import Vue from 'vue'
import App from './App.vue'

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

最后输出一个对象是因爲之后可能有其他对象要添加,如router和vuex等。 然后是服务端与客户端相关的入口

// entry-client.js 
/**
 * 客户端入口
 */
import { createApp } from './app'

// 客户端特定引导逻辑……

const { app } = createApp()

  app.$mount('#app')

现在客户端要做的是把vue实例挂载到对应的节点。

// entry-server.js
import { createApp } from './app'

export default context => {
  const { app } = createApp()

  return app
}

现在服务端只是创建和返回vue实例。

安装依赖

现在还是不能启动应用,还需要webpack打包。安装以下包:

npm i vue vue-server-renderer express cross-env

npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core
@babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-
loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-
webpack-plugin

cross-env可以设定环境变量,可以通过它设定开发或生产模式。开发依赖的包比较多,用的时候再作说明。

在项目中新建文件夹build,用来存放webpack配置文件,架构如下:

build
├── webpack.base.config.js # 公共配置
├── webpack.client.config.js # 客户端打包配置文件 
└── webpack.server.config.js # 服务端打包配置文件

先看一下公共配置:

/**
 * 公共配置
 */
const VueLoaderPlugin = require('vue-loader/lib/plugin') // 处理vue文件的插件
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')  //友好的 webpack 错误提示 
const resolve = file => path.resolve(__dirname, file) // 封装函数,放入文件名为参数,返回项目文件路径

/**
* process.env.NODE_ENV是由cross-env所设定的
*/
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。注意路径是相对于webpack当前路径,所以要 ..
      '@': resolve('../src/')
    },
    // 可以省略的扩展名
    // 当省略扩展名的时候,按照从前往后的顺序依次解析
    extensions: ['.js', '.vue', '.json']
  },
  devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
  module: {
    rules: [
      // 处理图片资源
      {
      // i表示字符大小写不敏感
        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()
  ]
}
// webpack.server.config.js

const { merge } = require('webpack-merge')  //webpack合并配置文件包
const nodeExternals = require('webpack-node-externals')  //排除 webpack 中的 Node 模块
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') // Vue SSR 渲染插件

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()
 ]
})
// 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()
  ]
})

webpack的配置文件写好后,可以在package.json写脚本,我们需要添加三条脚本:

"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",

**cross-env NODE_ENV=production ** 用来设置环境变量,rimraf可以删除指定文件。

在命令行切换至项目根目录,输入

npm run build

之后会生成dist文件夹,里面存有打包的文件。在根目录创建server.js引用它们:

const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')

const server = express()

server.use('/dist', express.static('./dist'))  // 服务器引用dist的静态文件

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})

const render = async (req, res) => {
  try {
    const html = await renderer.renderToString({
      title: 'Hello World',
      meta: `
        <meta name="description" content="HelloWorld">
      `,
      url: req.url
    })
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  } catch (err) {
    res.status(500).end('Internal Server Error.')
  }
}

server.get('*', render)

server.listen(3000, () => {
  console.log('server running at port 3000.')
})

现在客户端可以动态渲染了。

开发模式

虽然是把应用构建好,但现在还没有热更新,我们希望在开发模式下有热更新。这需要创建build/ setup-dev-server.js。它是用来做什么呢?首先把热更新的插件挂载到server上,如果src或模板有变化,则重新创建渲染实例。由于服务器要等待渲染实例生成,所以setup-dev-server要返回promise,以便服务器等待它。 所以setup-dev-server.js大概是这样:

module.exports = (server, callback) => {
  let ready
  const onReady = new Promise(r => ready = r)

  // 监视构建 -> 更新 Renderer

  let template
  let serverBundle
  let clientManifest

  const update = () => {
    if (template && serverBundle && clientManifest) {
      ready()
      callback(serverBundle, template, clientManifest)
    }
  }

  return onReady
}

所以setup-dev-server监视template, serverBundle和clientManifest,有变动,调用update,更新渲染实例。 监视的话需要安装 ** chokidar **,写下template相关代码调用:

  // 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  // fs.watch、fs.watchFile
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })

chokidar监视参数,如果有变化,根据指定的变化,调用回调函数,这里指的是文件发生变化。 接下来是服务端打包文件监视:

  const webpack = require('webpack')
  const devMiddleware = require('webpack-dev-middleware')
…
  // 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
  const serverConfig = require('./webpack.server.config')
  const serverCompiler = webpack(serverConfig)
  const serverDevMiddleware = devMiddleware(serverCompiler, {
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  serverCompiler.hooks.done.tap('server', () => {
    serverBundle = JSON.parse(
      serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    update()
  })

这里要webpack帮助,devMiddleware把数据存入内存,加快存取速度,logLevel是设定日志,silent表示关闭日志,serverCompiler.hooks.done.tap设定监视的回调函数,第一个参数是函数名。serverDevMiddleware.fileSystem.readFileSync与fs.readFileSync类似,只是它读取内存,resolve是对path.resolve封装的函数:

const resolve = file => path.resolve(__dirname, file)

因为读取出来是字符串,所以要解析。

然后是客户端打包文件监视,基本上差不多:

  // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  const clientConfig = require('./webpack.client.config')
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  clientCompiler.hooks.done.tap('client', () => {
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
  })

区别是要加上publicPath,因为客户端还要加载dist的文件,由于浏览器要访问内存数据,所以要另外加上:

server.use(clientDevMiddleware)

最后把热更新加上,项目中有任何变动,浏览器(客户端)自动更新。先安装 webpack-hot-middleware。之后在客户端配置部分添加代码:

  // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  const clientConfig = require('./webpack.client.config')
  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' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  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 // 关闭它本身的日志输出
  }))

  // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  server.use(clientDevMiddleware)

先添加插件** HotModuleReplacementPlugin **,入口处的值用数组把 ** webpack-hot-middleware/client ** 添加,后面字符串是调参用的,reload是webpack卡住时,自动刷新页面,quiet把浏览器控制台的日志关闭。热更新下output不用加上hash,最后server把hotMiddleware加上。

setup-dev-server完成了,添加到server.js:

const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')

const server = express()

server.use('/dist', express.static('./dist'))

const isProd = process.env.NODE_ENV === 'production'

let renderer
let onReady
if (isProd) {
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest
  })
} else {
  // 开发模式 -> 监视打包构建 -> 重新生成 Renderer 渲染器
  onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
  })
}

const render = async (req, res) => {
  try {
    const html = await renderer.renderToString({
      title: '拉勾教育',
      meta: `
        <meta name="description" content="拉勾教育">
      `,
      url: req.url
    })
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
  } catch (err) {
    res.status(500).end('Internal Server Error.')
  }
}

// 服务端路由设置为 *,意味着所有的路由都会进入这里
server.get('*', isProd
  ? render
  : async (req, res) => {
    // 等待有了 Renderer 渲染器以后,调用 render 进行渲染
    await onReady
    render(req, res)
  }
)

server.listen(3000, () => {
  console.log('server running at port 3000.')
})

onReady是表示已经构建好开发模式的设置,之后异步函数用到。先看路由设置,开发模式用异步函数,await onReady 先看已经构建好没有,好的话返回fulfiled状态下的Promise,执行渲染。

Router

之后比较简单。在router/index.js中,与创建vue实例一样,用createrouter封装:

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/pages/Home'

Vue.use(VueRouter)

export const createRouter = () => {
  const router = new VueRouter({
    mode: 'history', // 兼容前后端
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import('@/pages/About')
      },
      {
        path: '/posts',
        name: 'post-list',
        component: () => import('@/pages/Posts')
      },
      {
        path: '*',
        name: 'error404',
        component: () => import('@/pages/404')
      }
    ]
  })

  return router
}

在app.js导入router:

import { createRouter } from './router/'

...

export function createApp () {
  const router = createRouter()
  ...
  return { app, router, store }
}

只是需要在服务端入口添加代码。

const { app, router } = createApp()

// 设置服务器端 router 的位置
router.push(context.url)

// 等到 router 将可能的异步组件和钩子函数解析完
await new Promise(router.onReady.bind(router))

router.push(context.url) 是因为客户端不一定从首页开始浏覧应用,可以是其他页面,所以服务端需要定位。

await new Promise(router.onReady.bind(router)) 是由于router中可能有异步组件和钩子函数,所以需要等待它解析完。

Vuex

Vuex的配置也是类似的,这里只说重点。在服户端与客户端分别添加以下代码:

服务端:

  context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
  }

客户端:

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

现在SSR基本就配建完成了