likes
comments
collection
share

跟着Vue2官文实现一个SSR(SSR新手起步)

作者站长头像
站长
· 阅读数 17

前言

Vue.js 服务器端渲染指南 | Vue SSR 指南 (vuejs.org)

Demo地址:github.com/vuejs/vue-h…

Demo是尤大做得一个博客项目,里面有了官文中大部分提及的Vue SSR必要的一些代码。官文中的代码也可以直接copy到项目中直接使用,都是一些通用的模板代码。唯一的缺陷是这个demo是6年前(2017左右)的产物了,webpack、node、npm的版本都已经是非常旧了,建议使用nvm进行node与npm的管理。

跟着Vue2官文实现一个SSR(SSR新手起步)

在demo的package.json中可以看到node版本最低为7.0,npm版本最低为4.0,我自己在搭建的时候则是使用了node@9 + npm@5,为了避免因为node与npm版本引发的不明问题,都建议减低一下node与npm的版本。

本文的主要内容就是搭建一个最简化的SSR,方便新手快速上手,大部分代码都从官文上copy,而npm依赖的版本严格与demo中的保持一致,demo只做参照作用,当然也可以用demo删除一些代码来做到。

原理

跟着Vue2官文实现一个SSR(SSR新手起步)

这个图一开始看了也是似懂非懂,大概就是原来的vue项目的结构(Universal Application Code)包括Store、Router、Components;入口文件原本为main.js,变为了app.js,并且还分为服务端入口(Server-entry)和客户端入口(Client-entry)。经过webpack的打包之后,生成了一个Server Bundle和一个Client Bundle,Server Bundle负责渲染出HTML,Client Bundle负责Hydrate到HTML(同构)激活成为一个Vue 应用。

大概原理就是这么回事,然后再区分生产环境和开发环境,生产环境自然而然可以生成Server Bundle和Client Bundle放在dist中,但开发环境中并不需要真正的输出到文件系统,我们需要从内存中读取出这两个Bundle,实际的代码接着来看。

目录结构

跟着Vue2官文实现一个SSR(SSR新手起步)

server.js

这个是最主要的文件了,整体的逻辑就是获取Server Bundle和Client Bundle,还有template,其中template是html的模板,Server Bundle和Client Bundle由于还没输出到文件系统,所以分别利用了webpack-dev-middleware和memory-fs的文件系统,直接从内存的webpack compiler对象中读取。最后一个注意的点就是因为是开发环境,热更新这个问题要注意以下 ,比如Client Bundle是使用了webpack-dev-middleware编译的,Server Bundle则是用了webpack Compiler.watch进行文件变更监听,同时,监听到文件的变更之后,对应的renderer也要更新,所以这里切忌不要用readyPromise来传入renderer给到express中间件,否则就没有热更新了。


const fs = require('fs')
const MFS = require('memory-fs')
const path = require('path')
const webpack = require('webpack')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')

const clientConfig = require('./build/webpack.client.config')
const serverConfig = require('./build/webpack.server.config')

let template = fs.readFileSync('./src/index.template.html', 'utf-8')
let serverBundle
let clientManifest
let ready
let renderer
function updated({ serverBundle, clientManifest, template }) {
  // @see https://v2.ssr.vuejs.org/zh/guide/bundle-renderer.html#%E4%BC%A0%E5%85%A5-bundlerenderer
  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, 
    template, 
    clientManifest
  })
}
let readyPromise = new Promise(r => ready = r)

// @see https://www.webpackjs.com/api/node/#compiler-instance
const clientCompiler = webpack(clientConfig)
// 这里找不回webpack3的文档了,webpack5中没找到这个用法,但不难看出是给Compiler对象注册钩子函数。
clientCompiler.plugin('done', () => {
  // 因为clientConfig是交给webpack-dev-middleware进行执行编译的,可从它的输出中读取内存生成的Client Bundle
  clientManifest = JSON.parse(devMiddleware.fileSystem.readFileSync(path.join(clientConfig.output.path, 'vue-ssr-client-manifest.json'), 'utf-8'))
  if (serverBundle) {
    // 这里的因为webpack-dev-middleware会监听文件的变化再次编译,所以可能会被多次updated,用promise resolve不合适
    updated({ serverBundle, clientManifest, template })
    ready()
  }
})
// @see https://www.webpackjs.com/guides/development/#using-webpack-dev-middleware
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
  noInfo: true,
})

// 基本同上
const serverCompiler = webpack(serverConfig)
// @see https://www.npmjs.com/package/memory-fs  A simple in-memory filesystem. 
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, () => {
  serverBundle = JSON.parse(mfs.readFileSync(path.join(clientConfig.output.path, 'vue-ssr-server-bundle.json')))
  if (clientManifest) {
    updated({ serverBundle, clientManifest, template })
    ready()
  }
})


const app = express()

// @see https://www.webpackjs.com/guides/development/#using-webpack-dev-middleware
app.use(devMiddleware)

app.get('*', (req, res) => {
  // 等待编译完成读取到Server Bundle和Client Bundle
  readyPromise.then(() => {
    const context = { url: req.url }
    // @see https://v2.ssr.vuejs.org/zh/api/#renderer-rendertostring
    // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
    // 现在我们的服务器与应用程序已经解耦!
    renderer.renderToString(context, (err, html) => {
      // 处理异常……
      res.end(html)
    })
  })
})

app.listen(8084, () => {
  console.log('http://localhost:8084')
})

entry-server.js

数据预取和状态 | Vue SSR 指南 (vuejs.org) 官文中提到的通用模板代码 直接copy,大致就是等router的挂载,然后首页渲染的页面有异步请求获取的数据,也需要交给asyncData和store处理。

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

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

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

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      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)
    }, reject)
  })
}

entry-client.js

数据预取和状态 | Vue SSR 指南 (vuejs.org) 一样的copy

import Vue from 'vue'
import { createApp } from './app'

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

const { app, router,store  } = createApp()


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

Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

// 这里假定 App.vue 模板中根元素具有 `id="app"`

router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.length) {
      return next()
    }

    // 这里如果有加载指示器 (loading indicator),就触发

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {

      // 停止加载指示器(loading indicator)

      next()
    }).catch(next)
  })

  app.$mount('#app')
})

webpack.base.config.js

只配了一个vue-loader和一个babel-loader


const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].js',
    publicPath: '/dist/',
  },

  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          prettify: false,
        }
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        loader: 'babel-loader',
        options: {
          "presets": [
            ["env", { "modules": false }]
          ],
          "plugins": [
            "syntax-dynamic-import"
          ]
        }
      }
    ]
  },
  plugins: [
    // 请确保引入这个插件!
    new VueLoaderPlugin()
  ]
}

webpack.client.config.js

构建配置 | Vue SSR 指南 (vuejs.org) 直接copy,关键就指定一个入口和VueSSRClientPlugin


const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')

module.exports = merge(baseConfig, {
  entry: './src/entry-client.js',
  plugins: [
    new VueSSRClientPlugin(),
  ]
})

webpack.server.config.js

构建配置 | Vue SSR 指南 (vuejs.org) 直接copy, 关键就指定一个入口和VueSSRServerPlugin,由于是在node端运行的还需添加一些选项

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')

module.exports = merge(baseConfig, {
  entry: './src/entry-server.js',
  plugins: [
    new VueSSRServerPlugin(),
  ],
  // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',

  // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },
})

其余的

在写法上也有一些注意事项

避免状态单例

源码结构 | Vue SSR 指南 (vuejs.org),在app、router、store这些生成一个对象的代码中,由于要在服务端也生成一份相同的,所以要避免单例,影响了其余访问用户的对象状态。

数据预取和状态

数据预取和状态 | Vue SSR 指南 (vuejs.org),这一篇是重中之重,SSR主要解决的就是首屏渲染的问题,这篇讲述的就是如何解决首屏渲染遇到异步请求时的解决方案。

编写通用代码

编写通用代码 | Vue SSR 指南 (vuejs.org),上面的代码基本都是ssr的一个搭建的方案,但使用这个方案还需要注意业务逻辑编写时的一些小细节。比如里面提到的 组件生命周期钩子函数,很明显我们的服务端也有一个new Vue的操作,但new Vue中可会触发beforeCreate和created两个钩子,我们平时会在这两个钩子里写定时器、请求异步数据等,然后再beforeDestory里注销定时器,但在服务端可没有执行到beforeDestory钩子,那么定时器就不会被注销,服务端就出现了内存泄漏了;异步请求上一小节也提到了要写到asyncData中了就不谈了。还有其他的小细节请直接到官文中阅读。

生产环境

生产环境直接加在打包出来的Bundle就好啦,都不需要webpack-dev-middleware或者memory-fs了,然后想想一些分包,缓存的问题,这些在官文中都是又提到的,尤大的Demo中也是大部分都配置了。

源码地址

VueSSRTest2: Vue2 SSR 完整版官文实践(store router 数据预取 开发环境热更新) (gitee.com)

总结

原理:createBundleRenderer,生成Client Bundle与Server Bundle即可,最主要的逻辑都写在server.js中了,需要一点webpack和node的基础,上文代码中基本都用了@see注释注明可以在哪里学习到关键的知识点,非常保姆级别的Vue SSR新手教程了,但需要阅读的文档还是比较多。