Step-by-step,打造属于自己的vue ssr

3,124 阅读7分钟

笔者最近在和小伙伴对vue项目进行ssr的升级,本文笔者将根据一个简单拿vue cli构建的客户端渲染的demo一步一步的教大家打造自己的ssr,拙见勿喷哈。

what ? why ?

What ?

在学习一项新技术的时候我们首先要了解一下他是什么。这里引用官网的一句话:

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将静态标记"混合"为客户端上完全交互的应用程序。

Why ?

知道是什么后我们要知道这项技术对我们现有的项目有什么好处,简单总结一下:

  • 利于SEO,浏览器爬虫不会等待我们的ajax回调完成之后再去抓取我们的页面数据;
  • 利于首屏渲染,vue-ssr会把拿到的数据渲染成html,不用等待全部的js资源都完成下载才显示我们的页面;

do ? how to do ?

这里我们用vue-cli去简单的做一个vue客户端渲染的demo,具体过程就不做赘述了。

demo地址: https://github.com/LNoe-lzy/vue-ssr-demo/tree/master

这里我们根据之前写好的客户端渲染的demo来一步一步的改造成服务端渲染。先甩下demo链接:

demo地址: https://github.com/LNoe-lzy/vue-ssr-demo/tree/vue-ssr-server

First step:理解下原理

先附一张镇文之图,官网的构建流程:

构建步骤

这些都是个啥?

  • app.js用来构建我们的vue实例,这个实例会跑在客户端和服务端;
  • server entry是我们的服务端entry,用来导出一个函数在每次请求中调用,也做组件匹配和初始化渲染数据的获取。webpack会将其打包成server bundle;
  • client entry是我们客户端的entry,用来挂载我们的vue实例到指定的dom元素上。webpack会将其打包成client bundle;

这些都做了啥?

  • 首先我们的entry-server会获取到当前router匹配到的组件,调用组件上asyncData方法,将数据存到服务端的vuex中,然后服务端vuex中的这些数据传给我们的context。
  • Node.js服务器通过renderToString将需要首屏渲染的html字符串send道我们的客户端上,这其中混入了window.INITIAL_STATE 用来存储我们服务端vuex的数据。
  • 然后entry-client,此时服务端渲染时候拿到的数据写入客户端的vuex中。
  • 最后就是客户端和服务端的组件做diff了,更新状态更新的组件。

Secound step:main.js的改造

为了避免单例的影响,我们需要在每个请求都创建一个新的vue的实例,从而避免请求状态的污染,我们来封装一个createApp的工厂函数:

import Vue from 'vue'
import App from './App'

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

Third step:组件的改造

跑在服务端的Vue中所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染过程中被调用,而其他的钩子在客户端才会被调用,毕竟我们的服务端是无法执行dom操作的,所以我们要在路由匹配的组件上定义一个静态函数,这个函数要做的也很简单,就是去dispatch我们的action从而异步获取数据:

import { mapActions } from 'vuex'

export default {
  asyncData ({ store }) {
    return store.dispatch('getNav')
  },
  methods: {
    ...mapActions([
      'getList'
    ])
  }
  // ...
}

Fourth step:router和store的改造

同样为了避免单例的影响,我们也需要用工厂函数封装我们的router和store

// router
export function createRouter () {
  return new Router({
    mode: 'history',
    routes: []
  })
}

// store
export function createStore () {
  return new Vuex.Store({
    state: {},
    actions,
    mutations
  })
}

Fifth step:两个entry

根据构建流程图我们还需要webpack去构建两个bundle,服务端根据Server Bundle去做ssr,浏览器根据Client Bundle去混合静态标记。

为此我们在src目录下新建两个文件,entry-server.js 和 entry-client.js。前者在每次渲染中需要重复调用,执行服务端的路有匹配和数据预取逻辑。后者负责挂载DOM节点,以及前后端vuex数据状态的同步。

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

export default context => {
  // 可能为异步组件,返回一个promise
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    const { url } = context
    const { fullPath } = router.resolve(url).route

    if (fullPath !== url) {
      return reject(new Error(`error: ${fullPath}`))
    }
    router.push(url)
    // 需要等到的异步组件和钩子函数解析完
    router.onReady(() => {
	  // 获取匹配到的组件
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute
      }))).then(() => {
        // 将预取的数据从store中取出放到context中
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

这里我们需要注意两点,一个是我们的数据预取是调用组件的asyncData方法,所以需要Promise.all来保证拿到全部的预渲染数据;另一点是context.state = store.state,这时候服务端拿到的预渲染数据会封在**window.INITIAL_STATE**中通过node服务器send到客户端。

import Vue from 'vue'
import { createApp } from './main'
const { app, router, store } = createApp()

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

// 也是处理异步组件
router.onReady(() => {
  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))
    })

    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }

    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
      .then(() => {
        next()
      })
      .catch(next)
  })
  console.log('router ready')
  app.$mount('#app')
})

看到window.INITIAL_STATE我们就可以知道了客户端拿到了预取的数据,然后去存到客户端的vuex中,这也就是大家经常谈论的通过vuex实现前后端的状态共享。

至于vuex是不是必须的,当然不是(尤大issuse有说),题外话,笔者也实现了没有vuex的版本哦。

Sixth step:webpack的改造

webpack的配置上面其实和纯客户端应用类似,为了区分客户端和服务端两个环境我们将配置分为base、client和server三部分,base就是我们的通用基础配置,而client和server分别用来打包我们的客户端和服务端代码。

首先是webpack.server.conf.js,用于生成server bundle来传递给createBundleRenderer函数在node服务器上调用,入口是我们的entry-server:

const webpack = require('webpack')
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')
// 去除打包css的配置
baseConfig.module.rules[1].options = ''

module.exports = merge(baseConfig, {
  entry: './src/entry-server.js',
  // 以 Node 适用方式导入
  target: 'node',
  // 对 bundle renderer 提供 source map 支持
  devtool: '#source-map',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  externals: nodeExternals({
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    // 这是将服务器的整个输出
    // 构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin()
  ]
})

其次是webpack.client.conf.js,这里我们可以根据官方的配置生成clientManifest,自动推断和注入资源预加载,以及 css 链接 / script 标签到所渲染的 HTML。入口是我们的client-server:

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

const config = merge(base, {
  entry: {
    app: './src/entry-client.js'
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"client"'
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: function (module) {
        return (
          /node_modules/.test(module.context) &&
          !/\.css$/.test(module.request)
        )
      }
    }),
    // 这将 webpack 运行时分离到一个引导 chunk 中,
    // 以便可以在之后正确注入异步 chunk。
    // 这也为你的 应用程序/vendor 代码提供了更好的缓存。
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest'
    }),
    new VueSSRClientPlugin()
  ]
})

Seventh step:编写服务端代码

服务端框架我们采用Express(当然Koa2也是可以的):

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

const app = express()
const resolve = file => path.resolve(__dirname, file)
// 生成服务端渲染函数
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
  runInNewContext: false,
  template: fs.readFileSync(resolve('./index.html'), 'utf-8'),
  clientManifest: require('./dist/vue-ssr-client-manifest.json'),
  basedir: resolve('./dist')
})

// 引入静态资源
app.use(express.static(path.join(__dirname, 'dist')))
// 分发路由

app.get('*', (req, res) => {
  res.setHeader('Content-Type', 'text/html')

  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: 'Vue SSR demo', // default title
    url: req.url
  }
  renderer.renderToString(context, (err, html) => {
    console.log('render')
    if (err) {
      return handleError(err)
    }
    res.send(html)
  })
})

app.on('error', err => console.log(err))
app.listen(3000, () => {
  console.log(`vue ssr started at localhost:3000`)
})

通过观察localhost我们可以很清楚的发现,通过服务端send过来的html字符串仅包括我们根据数据预取渲染出来的dom结构以及服务端混入的window.INITIAL_STATE

服务端渲染html

通过Performance我们也可以看出在采用了ssr的应用中,我们的首屏渲染并不依赖于客服端的js文件了,这就大大加快了首屏的渲染速度,毕竟传统的SPA应用时需要拿到客户端js文件后才可以进行虚拟dom的构建以及数据的获取工作才渲染页面的。

ssr

不只是题外话

  • vue-router不是必须的,不用router其实做个vue的preRender就可以了,完全没必要做ssr;
  • vuex不是必须的,vuex是实现我们客户端和服务端的状态共享的关键,我们可以不使用vuex,但是我们得去实现一套数据预取的逻辑;

不使用vuex其实很头疼,但又有了点灵感,平时我们在开发项目的时候是如何处理组件间通信的,一个是vuex,另一个是EventBus,EventBus就是个Vue的实例啊,数据存这里不也行么?

在此笔者的思路是:创建一个Vue的实例充当仓库,那么我们可以用这个实例的data来存储我们的预取数据,而用methods中的方法去做数据的异步获取,这样我们只需要在需要预取数据的组件中去调用这个方法就可以了。demo很简单,戳这里

还有一个思路是在笔者学习的时候看别人博客学到的:只用了vuex的store和一些支持服务端渲染的api,没有走action、mutation那套,而是将数据手动写入state,为了表示对别人博客的尊重,细节就请转到作者的博客吧,戳这里


写在最后

本文通过一个简单的客户端渲染demo来一步一步的交大家如何搭建属于自己的ssr程序,文笔拙略还请大家谅解了。

不过学习虽好,但是细节到使用上,大家还是斟酌是否适合在自己的项目中。

多谢支持!