Vue SSR 初探

前段時間弄了一個先後端分離的 vue-koa-demo,最近爲這個項目提供了 Vue SSR 的支持。項目比較簡單,因此轉成 Vue SSR 成本仍是不太大的,可是其中也踩了幾個坑,在此記錄一下。javascript

一開始接觸 Vue SSR 固然仍是 官方文檔官方案例 了,學習瞭解以後咱們就能夠本身照葫蘆畫瓢了。css

Vue SSR 與 Vue

當咱們使用 Vue 來編寫咱們的單頁面應用的時候,咱們全部的業務代碼最後都會被 webpack 打包到 dist 目錄下,當瀏覽器輸入 URL 來向服務端請求頁面的時候,咱們的服務器都會返回 dist 下的 index.html 這個文件,可是打開這個文件咱們就能發現,這個文件很簡單裏面都是各類文件連接,只有一個 <div id="app"></div> 這麼一個內容。咱們在瀏覽器裏看到的豐富多彩內容,都是加載完 html 文檔裏的腳本文件後執行並渲染出來的。這樣的頁面須要等待腳本文件所有執行才能夠展示給咱們在網絡較差(下載腳本慢)或運行速度慢(運行腳本慢)的設備上顯示很慢;不利於 SEO,固然也不利於咱們爬取數據(好比我以前爬取的 豆瓣的 2017 年的電影總結 爬完發現就返回了這麼一個 div#app )。html

而咱們使用了 Vue SSR 就不同了,服務器返回的 html 立馬變得豐富了起來,服務器直接返回渲染好的 html。前端

ssr原理

上圖來自 官方文檔 。如下是個人理解:vue

在 Vue SSR 中,咱們須要爲 webpack 提供兩個入口,分別打包兩份代碼,一份給服務器使用,一份給瀏覽器使用。服務器端的 bundle 的職責是當用戶敲下一段 URL 後,須要匹配到該路由,找到對應的 Vue 組件(解釋了爲何 Vue SSR 須要與 vue-router 配合使用),若是須要數據的話,還須要預先獲取數據注入到組件中,最後經過 vue-server-renderer 來渲染出要返回給瀏覽器的 html;而瀏覽器端的 bundle 和以前的前端渲染打包相似,在服務器返回 html 後,由前端的 bundle 接管頁面,使頁面在 Vue 的管理之下,以後頁面內的路由跳轉就走前端路由了。java

對項目進行 SSR 支持

對項目進行 SSR 支持一共分爲如下幾步:node

  1. 修改 main.js,修改 router.js
  2. 增長客戶端打包入口 entry-client.js 和服務端打包入口 entry-server.js
  3. 修改 webpack 配置,使其支持客戶端打包 bundle 和服務端打包 bundle,支持開發環境和生產環境
  4. 修改服務端 app.js
  5. 修改 Vue 組件中的剩餘 bug

修改main.jsrouter.js

當咱們編寫前端業務代碼的時候,咱們通常只在 src/main.js 中建立一個新的 Vue 實例就行,由於咱們的代碼在每一個用戶本身的瀏覽器中運行時都會新建一個 Vue 實例。而對 SSR 來講,若是建立一個單例,則這個單例就會在每一個用戶之間共享,那樣就亂套了,因此須要爲每一個請求都建立一個 Vue 實例。同理 vue-router 的實例以及 vuex 的實例也是如此。webpack

修改後,項目的 main.jsrouter.js 大體以下ios

// main.js
import Vue from 'vue'
import App from './App'
import { createRouter } from './router'

export function createApp () {
  const router = createRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return { app, router }
}

// router.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export function createRouter () {
  return new Router({
    mode: 'history',
    routes: [
      {
        path: '/',
        name: 'todo',
        component: resolve => require(['@/components/TodoList'], resolve)
      },
      {
        path: '/login',
        name: 'login',
        component: resolve => require(['@/components/Login'], resolve)
      },
      {
        path: '/register',
        name: 'register',
        component: resolve => require(['@/components/Register'], resolve)
      },
      {
        path: '/todo',
        name: 'todoList',
        component: resolve => require(['@/components/TodoList'], resolve)
      },
      {
        path: '/detail/:todoId',
        name: 'detail',
        component: resolve => require(['@/components/Detail'], resolve)
      }
    ]
  })
}

增長客戶端打包入口 entry-client.js 和服務端打包入口 entry-server.js

客戶端的入口文件比較簡單,只要建立 Vue 實例,並掛載應用程序來使 Vue 在瀏覽器接管應用程序就能夠了。git

// entry-clent.js
import { createApp } from './main'

const { app, router } = createApp()

router.onReady(() => {
  app.$mount('#app')
})

服務端的入口文件稍微複雜點,它頁須要建立 Vue 實例,以後根據 URL 和 vue-router 中定義的路由要尋找須要渲染的組件。若是須要數據預取的話獲取響應數據注入到實例中,因爲 vue-koa-demo 比較簡單,沒有須要這一步,因此只完成了匹配組件。

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

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject(new Error({ code: 404 }))
      }

      resolve(app)
    }, reject)
  })
}

修改 webpack 配置

因爲項目是以前用 vue-cli2 構建的,因此就在這個基礎上進行修改便可。webpack.base.conf.js 不用動,做爲咱們的基本配置。

新增 webpack.client.conf.js 用於打包客戶端 bundle

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.conf')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const utils = require('./utils')
const config = require('../config')

const isProd = process.env.NODE_ENV === 'production'
const resolve = p => path.resolve(__dirname, p)

const plugins = isProd ? [
  new UglifyJsPlugin({
    uglifyOptions: {
      compress: {
        warnings: false
      }
    },
    sourceMap: config.build.productionSourceMap,
    parallel: true
  }),
  new ExtractTextPlugin({
    filename: utils.assetsPath('css/[name].[contenthash].css'),
    allChunks: true
  }),
  new CopyWebpackPlugin([
    {
      from: path.resolve(__dirname, '../static'),
      to: config.build.assetsSubDirectory,
      ignore: ['.*']
    }
  ])
] : [
  new webpack.HotModuleReplacementPlugin()
]

module.exports = merge(baseConfig, {
  entry: resolve('../src/entry-client.js'),
  externals: ['axios'],
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
    }),
    new webpack.NamedModulesPlugin(),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks (module) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
        )
      }
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'app',
      async: 'vendor-async',
      children: true,
      minChunks: 3
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      minChunks: Infinity
    }),
    ...plugins,
    new VueSSRClientPlugin()
  ]
})

新增 webpack.server.conf.js 用來打包服務端 bundle

const path = require('path')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const utils = require('./utils')

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

const config = merge(baseConfig, {
  entry: resolve('../src/entry-server.js'),
  target: 'node',
  devtool: 'source-map',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  externals: [nodeExternals({
    whitelist: /\.css$/
  })],
  plugins: [
    new ExtractTextPlugin({
      filename: utils.assetsPath('css/[name].[hash:8].css')
    }),
    new VueSSRServerPlugin()
  ]
})

module.exports = config

解釋一下,webpack.client.conf.js 和以前的打包文件很是類似,不一樣之處就在於整合了以前 webpack.dev.conf.jswebpack.prod.conf.js 的插件,根據環境不一樣進行添加,此外最重要的是要添加 VueSSRClientPlugin 這個插件用於生成客戶端 json 文件。

webpack.server.conf.js ,須要添加 VueSSRServerPlugin 插件。此外須要注意兩點:

  1. target 因爲 webpack 默認打包是在瀏覽器端運行,這裏須要修改一下默認值
  2. output.libraryTarget 服務端代碼是運行在 node 中的,node 的引用方式仍是 commonjs 因此這裏也須要改一下默認
  3. externals 服務器端不須要像瀏覽器端那樣,把依賴的包全打進 bundle 裏,服務器只須要在運行時獲取就能夠,因此這裏須要把 node_modules 中的模塊從打包 bundle 中排除出去。而服務端又不能處理 CSS 文件,因此 CSS 文件仍是要打包進 bundle 中的。

提供開發模式熱加載

若是在開發模式下,咱們每次修改頁面,都須要打包一次,再重啓服務才能看到改動後的樣子,實在不太方便。咱們須要進一步配置 webpack 來提供開發模式的自動打包。

爲此新建 build/setup-dev-server.js ,這個代碼來源於 官方案例 功能在於提供開發模式下服務端的熱加載。當從新打包完成後,更新服務器端 renderer,從新請求,就能夠獲得新的頁面。咱們原封不動拷貝下來就行。

可是這裏須要注意的是:因爲 webpack-dev-middlewarewebpack-hot-middleware 原來的代碼是創建在 express 基礎上的,並不能直接兼容 koa 因此咱們要封裝一層,我這裏直接用 npm 上已有的 koa-webpack-dev-middlewarekoa-webpack-hot-middleware 而且配合 koa-convert 完成了功能。

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const convert = require('koa-convert')
const clientConfig = require('./webpack.client.conf')
const serverConfig = require('./webpack.server.conf')

const readFile = (fs, file) => {
  try {
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
  } catch (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) {
      ready()
      cb(bundle, {
        template,
        clientManifest
      })
    }
  }

  // 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?noInfo=true&reload=true', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.NoEmitOnErrorsPlugin()
  )

  // dev middleware
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = convert(require('koa-webpack-dev-middleware')(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()
  })

  // hot middleware
  app.use(convert(require('koa-webpack-hot-middleware')(clientCompiler)))

  // 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
}

修改服務端 app.js

app.js 文件中,須要引入 vue-server-renderer 完成服務端返回的 html 的渲染。

這裏須要注意一件事就是項目中使用的 vuevue-server-renderervue-template-compiler 三個模塊的版本須要一致,不然會報錯。

此外還須要引入剛剛的 setup-dev-server 在開發模式下使用。

const fs = require('fs')
const path = require('path')

const Koa = require('koa')
const json = require('koa-json')
const bodyparser = require('koa-bodyparser')
const onerror = require('koa-onerror')
const logger = require('koa-logger')
const KoaRouter = require('koa-router')
const session = require('koa-session')

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

const isProd = process.env.NODE_ENV === 'production'    // 判斷環境
const resolve = file => path.resolve(__dirname, file)

const app = new Koa()
const router = new KoaRouter()
const index = require('./routes/index')                    // api 路由

app.keys = ['vue koa todo demo']

const CONFIG = {                                        // koa-session 配置        
  key: 'koa:todo',
  maxAge: 86400000,
  overwrite: true,
  httpOnly: true,
  signed: true,
  rolling: false,
  renew: false
}

let renderer
let readyPromise
const templatePath = resolve('../index.html')            // 服務端渲染模板

// 生成 server renderer
function createRenderer (bundle, options) {
  return createBundleRenderer(bundle, Object.assign(options, {
    runInNewContext: false
  }))
}

// 生產模式下直接引用打包出來的 bundle,構造 server renderer
// 開發模式下開啓 devServer,在每次修改後,返回新的 server renderer,以返回修改後的正確的 html
if (isProd) {
  const template = fs.readFileSync(templatePath, 'utf-8')
  const bundle = require('../dist/vue-ssr-server-bundle.json')
  const clientManifest = require('../dist/vue-ssr-client-manifest.json')
  renderer = createRenderer(bundle, {
    template,
    clientManifest
  })
} else {
  readyPromise = devServerSetup(
    app,
    templatePath,
    (bundle, options) => {
      renderer = createRenderer(bundle, options)
    }
  )
}

// 渲染函數,調用 server renderer 方法進行渲染
function render (context) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}

// 開發模式下中間件
const devMiddleware = async (ctx, next) => {
  const context = {
    url: ctx.url
  }
  await readyPromise
  try {
    const html = await render(context)
    ctx.body = html
  } catch (err) {
    await next()
  }
}

// 生產模式下中間件
const prodMiddleware = async (ctx, next) => {
  const context = {
    url: ctx.url
  }
  try {
    const html = await render(context)
    ctx.body = html
  } catch (err) {
    await next()
  }
}

// error handler
onerror(app)
app.use(session(CONFIG, app))

// middlewares
app.use(bodyparser({
  enableTypes: ['json', 'form', 'text']
}))
app.use(json())
app.use(logger())

// logger
app.use(async (ctx, next) => {
  const start = new Date()
  await next()
  const ms = new Date() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})


router.get('*', isProd ? prodMiddleware : devMiddleware)

// 先註冊 api 路由
// 其次註冊 SSR 渲染路由
// SSR 渲染路由 404,繼續走 static 路由,若是 static 404 則返回 404
app.use(index.routes(), index.allowedMethods())
app.use(router.routes(), router.allowedMethods())
app.use(require('koa-static')(resolve('../dist')))

// error-handling
app.on('error', (err, ctx) => {
  console.error('server error', err, ctx)
})

module.exports = app

修改一下 npm script

"scripts": {
    "dev": "nodemon server/bin/www",
    "start": "cross-env NODE_ENV=production node server/bin/www",
    "build": "rimraf dist && npm run build:server && npm run build:client",
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.conf.js --progress --hide-modules",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js --progress --hide-modules"
  },
  • dev:開發模式
  • start:打包後啓動
  • build:打包客戶端與服務端
  • build:client:僅打包客戶端
  • build:server:僅打包服務端

運行

咱們執行 npm run build && npm start 打開瀏覽器端口嘗試運行,發現報了錯。

sessionstorage not defined

這個和官方文檔裏的 window not defined 屬於同類錯誤,緣由就是咱們須要編寫通用代碼。sessionStorage 屬於瀏覽器特定平臺的 API 內容,在服務器端跑固然行不通。所以,在 SSR 項目中,若是用到瀏覽器端特定的 API ,咱們須要保證這些 API 只在瀏覽器的生命週期鉤子函數中才調用。而在 Vue SSR 中,beforeCreatecreate 鉤子是在服務端渲染的過程當中被調用的。

直接全局搜索,發現 sessionStorage 出現了兩類地方:

// 1. created 鉤子中
{
  created () {
    if (sessionStorage.username) {
      /* do something */
    }
  }
}

// 2. data 中
{
  data () {
    return {
      username: sessionStorage.username || ''
    }
  }
}

統一轉換:

// 對於 1
{
  mounted () {
    if (sessionStorage.username) {
      /* do something */
    }
  }
}

// 對於 2
{
  mounted () {
    this.username = sessionStorage.username || ''
  }
}

修改後,打包成功後再次訪問,發現頁面 OK。

ssr-preview

成功渲染出 html 頁面,到這裏就成功地爲 vue-koa-demo 添加了 SSR 支持~

最後附上 項目源碼

相關文章
相關標籤/搜索