本文对应的 SSR demo 项目仓库为: https://github.com/njleonzhang/play-vue-ssr

前文开坑,继续填,😂。

普通的非SSR Vue 项目

先从我们熟悉的普通的 vue 项目出发, 假设我们有一个 vue 项目,只有这个一个 vue 实现的页面:

// app.vue
<template>
  <div>
    hello world
    <div v-if='show'>show me</div>
    <div>
      <button @click='toggle'>toggle</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    }
  },
  methods: {
    toggle() {
      this.show = !this.show
    }
  }
}
</script>

为了让这个页面在浏览器里跑起来,我们要为这个 vue 文件写一个入口 js 文件 main.js, 一个 index.html 模板文件 和 一个 webpack 配置文件。整个过程大致如下图:

default

简单实现一下这几个文件:

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

new Vue({
  render: h => h(App)
}).$mount('#app')
// webpack config file

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
 const isProd = process.env.NODE_ENV === 'production'
 module.exports = {
  entry: {
    app: './src/entry-client.js'
  },
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ],
}

这样 webpack 编译后,就会生成 bundle.js 了, 然后我们粗暴地来手动 inject 一下 js

  <!-- index.html -->
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
    </head>
    <body>
      <script src='/bundle.js'>
    </body>
  </html>

👌,现在把这个 index.html 文件和 bundle.js 发布出去,这个 demo 站就好了。

改造成SSR

请出大神的图:

忽略 StoreRouter, 那么尤大的这个图和我们上面的非SSR版本的图的主要区别在于以下2点:

  1. 生成的 bundle.js 文件有2个

    这点比较好理解,前文中我们就提到过: SSR 的架构中,页面需要在后台被渲染好然后返回给前台,再由前台的 bundle js hydrate 后接管页面。所以我们的源码既需要跑在前台,也需要跑在后台。跑在后台时,就是要跑在 Node 环境,跑在前台时就需要跑在浏览器环境, 那么我们自然需要打包出2份不一样的 bundle js 咯.

    我们改造一下 webpack 文件, 以生成2份 bundle。client 和非 SSR 版本的配置文件基本一致,server 版的配置文件指明目标是 node.

       // webpack.client.config.js
       const config = merge(base, {
         entry: {
           app: './src/entry-client.js'
         },
         output: {
           filename: 'client-bundle.js'
         },
         ....
       )
    
       // webpack.server.config.js
       const config = merge(base, {
         target: 'node',                   // 目标是 node
         entry: './src/entry-server.js',
         output: {
           filename: 'server-bundle.js',
           libraryTarget: 'commonjs2'      // 编译成 commonjs
         },
         ....
       )
    
  2. 和 entry 相关的 js 文件有3个 (app.js, client entry, server entry)

    这个问题类似于 Vue component 的 data 属性为什么需要使用函数。当前的例子里还没有引入 StoreRoute, 所有只从当前的例子来看还不太好理解。我们想象一下,每次有用户来访问我们网站的时候,后台都给他渲染一个页面,如果 Node 服务器始终用一个 Vue app 实例去渲染,则很容易不同用户之间数据串掉的问题,所以 Node 服务器这个 Bundle 需要是一个工厂函数,每次用户来访问,我都生成一个新的 Vue app 实例,并用这个新的,干净的实例去渲染页面。

    尤大的解释可能更抽象一点: 当编写纯客户端(client-only)代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

    如果,你暂时不能理解原因,那也没关系,先记住好了,Node 服务器端的这个 bundle 里的 Vue app 需要是一个工厂方法。后面我们说到 Router 和 State 的时候,我们再回过头来看这个问题。

    我们改造非 SSR版本的 main.js, 我们抽取一个公共函数,用于生成 Vue app 的实例:

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

    对于客户端的 bundle,我们还是只需要一份 vue app,所以直接创建一个 app, 然后 mount 就好. 代码逻辑和原来非 SSR 的版本实际上是一样的。

       // entry-client.js
    
       import { createApp } from './app'
        const { app } = createApp()
        // 这里假定 App.vue 模板中根元素具有 `id="app"`
       app.$mount('#app')
    

    对于服务器版本的 bundle, 我们需要一个工厂方法:

       // entry-server.js
    
       import { createApp } from './app'
        export default context => {
         const { app } = createApp()
         return app
       }
    

至此,这个非 SSR 的 vue 项目到 SSR 版本的改造基本完成,我们还缺个 Node 服务器用于做后台渲染,当然这个是 SSR 架构所特有的。

Node 服务器

这里我只是把尤大的例子稍微改了改:

const Vue = require('vue')
const express = require('express')
const server = express()
const createRenderer = require('vue-server-renderer').createRenderer
const app = require('./dist/server-bundle')

const renderer = createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8'),
})

 server.use(express.static('dist'))  // 为了让 client-bundle.js 能够被加载

 server.get('*', (req, res) => {
  const context = {
    title: 'hello',
    meta: `
      <meta charset="utf8">
    `
  }
   renderer.renderToString(app.default(), context, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
     res.send(html)
  })
})
 server.listen(8080, () => {
  console.log(`server started at localhost:8080`)
})

可以看到用户对我们页面的访问,会最终由 renderer.renderToString 做一次渲染,渲染的结果就是我一直提到的真的完整的壳子html.这个渲染出来的壳子html被返回给前台后,它需要加载 client bundle,进而进行 hydrate。所以在这个壳子里需要注入 client bundle。从简单阐述的目的出发,我也是做了暴力的手动 inject:

// index.template.html
<html>
  <head>
    <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>

    <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
    <script src='/client-bundle.js'></script>  // 暴力 inject。为了让这个请求能成功
                                               // node server 里特别加了一个静态文件的配置
                                               // server.use(express.static('dist'))
  </body>
</html>

效果

首先,后台返回的内容里确实是一个完整的页面,SEO 无忧啦!首屏白屏无忧啦!

再看一下前端 vue 是否接管了页面:

Bingo!!! @clickv-if 可以正常工作。

读者可以自己尝试下:

git clone https://github.com/njleonzhang/play-vue-ssr.git
npm install
git checkout level1
npm run start

或者直接看这个commit的代码。

总结

我们搭架了一个特别简单的 SSR 项目,没有 Router, 没有 State, 没有各种 dev 和 pro 处理。但是有时候简单例子却最能说明问题的本质。后面的几节里,我们会慢慢加上 Router,State,数据预取等功能,关于 dev 和 pro 处理等工程化实践的内容依然不会讨论,如果你需要了解相关内容,可以直接去看 Nuxt.js 的文档,Nuxt.js 可能是 Vue SSR 实际工程使用的最佳实践。

系列文章: