# VUE-SSR

Vue 官方配置SSR的指南:

Vue.js 服务器端渲染指南 (opens new window)

Vue SSR官方Demo (opens new window)

# SSR 配置总结

  • 通过 webpack 分别将 server bundle 和 client bundle 打包成特殊的 JSON 文件 vue-ssr-server-bundle.jsonvue-ssr-server-bundle.json

  • 使用 vue-server-renderer 提供一个名为 createBundleRenderer 的 API,处理上一步中提到的两个 JSON 文件生成 renderer

  • 最后通过 renderer.renderToString 方法生成 HTML

# 热更新

要实现热更新的条件:

1. 在服务中使用 webpack 打包我们的项目

以客户端为例:

const webpack = require('webpack')
const clientCompiler = webpack(clientConfig) // clientConfig 为客户端 webpack 配置

2. 监听文件变化后自动打包,并将 webpack 打包后的文件发送给服务

这一步需要用到 webpack-dev-middlewarewebpack-dev-middleware 执行结果返回一个 express 中间件函数,它能将 webpack 编译后的文件存储到内存中,然后在访问 express 服务时,将内存中对应的资源输出返回

const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
})
app.use(devMiddleware)

3. 模块热更新

实现模块热更新需要使用到 webpack-hot-middleware, 在 webpack 使用时需要添加以下配置:

  • 在 plugins 中增加 HotModuleReplacementPlugin()

  • 在 entry 中新增 webpack-hot-middleware/client,将插件注入到程序中

  • 在 express 中加入中间件 webpack-hot-middleware

  • 在入口文件添加

     // 当前可忽略
     if (module.hot) {
       module.hot.accept();
     }
    

按照上面的步骤添加配置:

// 在 entry 中新增 `webpack-hot-middleware/client`,将插件注入到程序中
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]

// 增加 `HotModuleReplacementPlugin()` 插件
clientConfig.plugins.push(
  new webpack.HotModuleReplacementPlugin(),
)

// 加入中间件webpack-hot-middleware
app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

4. 打包完成后,重新生成 render

重新打包也就重新生成了 vue-ssr-server-bundle.json 文件,所以我们需要监听打包完成事件,生成新的 renderer

  clientCompiler.plugin('done', stats => {
    stats = stats.toJson()
    if (stats.errors.length) return
    clientManifest = JSON.parse(readFile(
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    update() // 生成新的render
  })

TIP

上面的配置在每次更新都会重新生成新的 render ,但是除了第一次渲染需要用到以外,之后的热更新应该用不到 render , 在例子中尝试将后面的都更新都不重新生成 render 好像也没啥影响

至此客户端热更新的步骤就差不多了,接下就是服务端的,在服务端的更新就比较简单,只需要听到文件变化重新生成新的 vue-ssr-server-bundle.json 文件就可以了,直接使用 webpack 中的 watch 配置

  // watch and update server renderer
  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
    // read bundle generated by vue-ssr-webpack-plugin
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })

完整的配置参考,也可以看 官方Demo (opens new window)

// server.js
const path = require('path')
const fs = require('fs')
const express = require('express')
const LRU = require('lru-cache')
const app = express()
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
function createRenderer (bundle, options) {
  return createBundleRenderer(bundle, Object.assign(options, {
    // for component caching
    cache: LRU({
      max: 1000,
      maxAge: 1000 * 60 * 15
    }),
    // this is only needed when vue-server-renderer is npm-linked todo
    basedir: path.resolve('./dist'),
    // recommended for performance
    runInNewContext: false
  }))
}
app.use(express.static('./dist'))
const templatePath = path.resolve(__dirname, './server/index.template.html')
let renderer
let readyPromise
if (isProd) {
  // In production: create server renderer using template and built server bundle.
  // The server bundle is generated by vue-ssr-webpack-plugin.
  const template = fs.readFileSync(templatePath, 'utf-8')
  const serverBundle = require('../../../dist/vue-ssr-server-bundle.json')
  // The client manifests are optional, but it allows the renderer
  // to automatically infer preload/prefetch links and directly add <script>
  // tags for any async chunks used during render, avoiding waterfall requests.
  const clientManifest = require('../../../dist/vue-ssr-client-manifest.json')
  renderer = createRenderer(serverBundle, {
    template,
    clientManifest
  })
} else {
  // In development: setup the dev server with watch and hot-reload,
  // and create a new renderer on bundle / index template update.
  readyPromise = require('./server/setup-dev-server')(
    app,
    templatePath,
    (bundle, options) => {
      renderer = createRenderer(bundle, options)
    }
  )
}

function render(req, res){
  const s = Date.now()
  const handleError = err => {
    if (err.url) {
      res.redirect(err.url)
    } else if(err.code === 404) {
      res.status(404).send('404 | Page Not Found')
    } else {
      // Render Error Page or Redirect
      res.status(500).send('500 | Internal Server Error')
      console.error(`error during render : ${req.url}`)
      console.error(err.stack)
    }
  }
  const context = {
    title: 'hellohello',
    meta: '<meta charset="utf-8">',
    url: req.url
  }
  renderer.renderToString(context, (err, html) => {
    if (err) {
      return handleError(err)
    }
    res.end(html)
    if (!isProd) {
      console.log(`whole request: ${Date.now() - s}ms`)
    }
  })
}
app.get('*', isProd ? render : (req, res) => {
  readyPromise.then(() => {
    return render(req, res)
  })
})

const port =process.env.PORT || 8088
app.listen(port, ()=> {
  console.log('启动啦啦啦啦啦啦啦', port)
})

setup-dev-server.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')

const clientConfig = require('../../../webpack.config/ssr-vue-client')
const serverConfig = require('../../../webpack.config/ssr-vue-server')

const readFile = (fs, file) => {
  try {
    let fsData = fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
    return fsData
  } catch (e) {
    console.log('readFile-e', e)
  }
}

module.exports = function setupDevServer (app, templatePath, cb) {
  let bundle
  let template
  let clientManifest

  let ready
  const readyPromise = new Promise(r => { ready = r })
  const update = () => {
    if (bundle && clientManifest) {
      cb(bundle, {
        template,
        clientManifest
      })
      ready()
    }
  }

  // read template from disk and watch
  template = fs.readFileSync(templatePath, 'utf-8')
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    console.log('index.html template updated.')
    update()
  })

  // modify client config to work with hot middleware
  clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    // new webpack.NoEmitOnErrorsPlugin() // 在编译出现错误时,使用 NoEmitOnErrorsPlugin 来跳过输出阶段。这样可以确保输出资源不会包含错误
  )
  // dev middleware
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
  })
  app.use(devMiddleware)
  clientCompiler.plugin('done', stats => {
    stats = stats.toJson()

    stats.errors.forEach(err => console.error('stats.errors', err))
    stats.warnings.forEach(err => console.warn('stats.warnings',err))
    if (stats.errors.length) return
    clientManifest = JSON.parse(readFile(
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    update()
  })

  // hot middleware
  app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

  // watch and update server renderer
  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
    // read bundle generated by vue-ssr-webpack-plugin
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })
  return readyPromise
}

# Q&R

在配置过程中可能会遇到的问题

运行后会提示Cannot find element: #app

在纯客户端应用程序中我们在模板中将预设好这个 div#app 元素,或者在入口函数中动态创建div#app,但在 SSR 程序中, 需要将 div#app 元素将移到 App.vue 中

<template>
  <div id="app">
    <p>Hello Vue SSR</p>
  </div>
</template>

(node:12612) DeprecationWarning: Tapable.plugin is deprecated. Use new API on .hooks instead

如果参考了官方的配置例子在 setup-dev-server.js 有使用 clientCompiler.plugin('done) 的形式,该提示就是这种钩子监听方式应该使用新的 Api hooks 代替,但直接使用 hooks,又会出现内存系统访问不了目录的报错,还没找到解决文案