彻底搞懂Vue SSR

7,895 阅读6分钟

SSR (Server Side Render)

服务端将Vue组件渲染为HTML 字符串,并将html字符串直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

优点

  • 更好的SEO, 由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面
  • 更快的内容到达时间

缺点

  • 开发条件受限。 (服务端只执行beforeCreated 和 created 生命周期函数, 并且没有window, DOM, BOM等)。
  • 涉及构建设置和部署的更多要求,需要处于node server的运行环境
  • 更多的服务端负载

SSR精髓

  • 服务端将Vue组件渲染为HTML 字符串,并将html字符串直接发送到浏览器
  • 独立的应用程序实例,以便不会有交叉请求造成的状态污染

分以下几种情况进行demo

  • 直接将Vue 组件渲染为html字符串并返回给浏览器
  • 引入router 的服务端渲染
  • 需要初始化数据的服务端渲染(一个完整的Vue SSR)

先创建一个简单的vue项目 代码地址 01

|—— components  //  子组件
|   |—— Foo.vue   
|   |—— Bar.vue
|   
|—— App.vue    // 根组件
|—— index.js   // 入口文件
|—— webpack.config.js

代码很简单就是一个很普通的vue项目(包括一些点击事件,数据绑定), 典型的客户端渲染。

直接渲染Vue 组件成html 字符串并返回 代码地址 02/demos

刚开始接触web开发,都是以html页面为模板,把后端数据塞到模板中,像.php、.jsp文件。还有与node 结合使用的artTemplate,ejs等。

而Vue 的服务端渲染也分为两步:

  1. 把Vue 文件(模板文件)解析成html, css, js 静态文件
  2. 把静态文件返回给客户端

官方提供一个插件 vue-server-renderer 可以直接将vue 实例渲染成 Dom 标记

demo1

   const Vue = require('vue')
   const app = new Vue({
     template: `<div>Hello World</div>`
   })
   
   // 第 2 步:创建一个 renderer
   const renderer = require('vue-server-renderer').createRenderer()
   
   // 第 3 步:将 Vue 实例渲染为 HTML
   renderer.renderToString(app, (err, html) => {
     if (err) throw err
     console.log(html)
     // => <div data-server-rendered="true">Hello World</div>
   })
   
   // 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
   renderer.renderToString(app).then(html => {
     console.log(html)
   }).catch(err => {
     console.error(err)
   })

demo2

与服务端结合, 通过请求返回html 页面

   const Vue = require('vue')
   const Koa = require('koa');
   const Router = require('koa-router');
   const renderer = require('vue-server-renderer').createRenderer()
   
   const app = new Koa();
   const router = new Router();
   
   router.get('*', async (ctx, next) => {
     const app = new Vue({
       data: {
         url: ctx.request.url
       },
       template: `<div>访问的 URL 是: {{ url }}</div>`
     })
   
     renderer.renderToString(app, (err, html) => {
       if (err) {
         ctx.status(500).end('Internal Server Error')
         return
       }
       ctx.body = `
         <!DOCTYPE html>
         <html lang="en">
           <head><title>Hello</title></head>
           <body>${html}</body>
         </html>
       `
     })
   })
   
   app
     .use(router.routes())
     .use(router.allowedMethods());
   app.listen(8080, () => {
     console.log('listen 8080')
   })

从demo1 可以看出vue-server-renderer 方法返回的是一个html 片段 官方叫标记(markup), 并不是完整的html 页面。 我们必须像demo2中那样用一个额外的 HTML 页面包裹容器,来包裹生成的 HTML 标记。

我们可以提供一个模板页面。例如

<!DOCTYPE html>
<html lang="en">
 <head><title>Hello</title></head>
 <body>
   <!--vue-ssr-outlet-->  
 </body>
</html>

注意 <!--vue-ssr-outlet--> 注释这里将是应用程序 HTML 标记注入的地方。 这是插件提供的,如果不用 <!--vue-ssr-outlet-->也是可以的,那就要自己去简单处理一下了。比如demo3

demo3

 <!DOCTYPE html>
   <html lang="en">
     <head><title>Hello</title></head>
     <body>
       {injectHere}
     </body>
   </html>
demo3.js
const template = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')
ctx.body = template.replace('{injectHere}', html)

需要注意几点:

  • 服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。
  • 在纯客户端应用程序 (client-only app) 中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染 (cross-request state pollution)
对于第一点:
  • 既然在客户端和服务端上都能运行,那应该有两个入口文件。一些 Dom, Bom 的操作在服务端肯定是不行的.

  • 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,并且许多 webpack 特定功能不能直接在 Node.js 中运行(例如通过 file-loader 导入文件,通过 css-loader 导入 CSS)

对于第二点:
  • 需要将其包装为一个工厂函数,每次调用都会生成一个全新的根组件

app.js

    import Vue from 'vue'
    import App from './App.vue'
    
    export function createApp() {
        const app = new Vue({
            render: h => h(App)
        })
        return { app }
    }

enter-client.js

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

enter-server.js

    import { createApp } from './app.js';
    
    export default context => { // koa 的 context
        const { app } = createApp()
        return app
    }
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>服务端渲染</title>
    </head>
    <body>
      <!--vue-ssr-outlet-->
      <!-- 引入客户端打包后的js文件(client.bundle.js) -->
      <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
    </body>
    </html>

webpack.server.config.js

    const path = require('path');
    const merge = require('webpack-merge');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const base = require('./webpack.base.config');
    
    module.exports = merge(base, {
        // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
        // 并且还会在编译 Vue 组件时,
        // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
      target: 'node',
      entry: {
        server: path.resolve(__dirname, '../entry-server.js')
      },
      output: {
          // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
        libraryTarget: 'commonjs2'
      },
      plugins: [
        new HtmlWebpackPlugin({
          template: path.resolve(__dirname, '../../index.ssr.html'),
          filename: 'index.ssr.html',
          files: {
            js: 'client.bundle.js' // index.ssr.html 中引入的js文件是客户端打包出来的client.bundle.js。这是因为 Vue 需要在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM。这个过程官方称为客户端激活
          },
          excludeChunks: ['server']
        })
      ]
    });

webpack.client.config.js

    const path = require('path')
    const merge = require('webpack-merge')
    const HtmlWebpackPlugin = require('html-webpack-plugin')
    const base = require('./webpack.base.config')
    
    module.exports = merge(base, {
        entry: {
            client: path.resolve(__dirname, '../entry-client.js')
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: path.resolve(__dirname, '../../index.html'),
                filename: 'index.html'
            })
        ]
    })

这是比较完整的 客户端接管由服务端渲染Vue 实例发送的静态 HTML,并由 Vue 管理的动态Dom 的例子。完整代码 03

引入路由router 的服务端渲染

Vue 项目的路由管理由vue-router 来负责,和 02 项目一样, 服务端返回渲染后的html, 剩下的就交给Vue了。

router.js

    import Vue from 'vue'
    import Router from 'vue-router'
    import Bar from "./components/Bar.vue";
    import Foo from "./components/Foo.vue";
    const routes = [
      { path: '/foo', component: Foo },
      { path: '/bar', component: Bar }
    ]
    
    Vue.use(Router)
    
    export function createRouter() {
      // 创建 router 实例,然后传 `routes` 配置
      // 你还可以传别的配置参数, 不过先这么简单着吧。
      return new Router({
        mode: 'history',
        routes
      })
    }

app.js 引入router

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

这样就可以了吗, 显然还不够,Vue 优化上,我们一般会选择惰性加载组件,而不是一下子全部加载。那我们就需要简单修改一下entry-server.js 和 router.js 文件了。

router.js

    import Vue from 'vue'
    import Router from 'vue-router'
    
    const routes = [
     // webpack.base.config.js 中需要配置 @babel/plugin-syntax-dynamic-import
      { path: '/foo', component: () => import('./components/Foo.vue') }, 
      { path: '/bar', component: () => import('./components/Bar.vue') }
    ]
    
    Vue.use(Router)
    
    export function createRouter() {
      // 创建 router 实例,然后传 `routes` 配置
      // 你还可以传别的配置参数, 不过先这么简单着吧。
      return new Router({
        mode: 'history',
        routes
      })
    }

由于加入了异步路由钩子函数或组件,所以我们将返回一个 Promise,以便服务器能够等待所有的内容在渲染前,就已经准备就绪。 我们现在的entry-server.js 更新成这样

entry-server.js

    import { createApp } from './app.js';
    
    export default context => {
        // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
        // 以便服务器能够等待所有的内容在渲染前,
        // 就已经准备就绪。
        return new Promise((resolve, reject) => {
            const { app, router } = createApp()
            if (context.url.indexOf('.') === -1) { // 防止匹配 favicon.ico   *.js 文件
                router.push(context.url)
            }
            // 设置服务器端 router 的位置
    
            console.log(context.url, '******')
            // 等到 router 将可能的异步组件和钩子函数解析完
            router.onReady(() => {
                const matchedComponents = router.getMatchedComponents()
                // 匹配不到的路由,执行 reject 函数,并返回 404
                if (!matchedComponents.length) {
                    return reject({ code: 404 })
                }
    
                // Promise 应该 resolve 应用程序实例,以便它可以渲染
                resolve(app)
            }, reject)
        })
    }

entry.client.js

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

由于用到了,异步路由这个时候,打包的bundle.js不包括异步组件的js文件。还按照上面直接引入 server.bundle.js 的话,会报错找不到相关的异步组件的js文件。

所以这里我们用vue-server-renderer下的插件vue-server-renderer/server-plugin把server.entry.js文件打包成一个json 文件, 而json 文件中会把所有的异步组件和相关的js一一map。

需要初始化数据的服务端渲染

从上面几个例子可以看到,在服务器端渲染(SSR)期间,我们本质上是在渲染一个静态文件,后续的交互还是交给了客户端的vue,所以如果应用程序依赖于一些需要初始化的异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

还有一个问题是在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container))"中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。

即在所有预取钩子(preFetch hook) resolve 后,我们的 store 已经填充入渲染应用程序所需的状态。当我们将状态附加到上下文,并且 template 选项用于 renderer 时,状态将自动序列化为 window.__INITIAL_STATE__,并注入 HTML。在客户端我们就可以通过全局变量window.__INITIAL_STATE__拿到数据。

我们用官方的状态管理库 的VueX 。

store.js

    import Vue from 'vue'
    import Vuex from 'vuex'
    
    Vue.use(Vuex)
    
    // 一个可以返回 Promise 的 API
    import { fetchItem } from './api'
    
    export function createStore () {
      return new Vuex.Store({
        state: {
          items: {}
        },
        actions: {
          fetchItem ({ commit }, id) {
            // `store.dispatch()` 会返回 Promise,
            // 以便我们能够知道数据在何时更新
            return fetchItem(id).then(item => {
              commit('setItem', { id, item })
            })
          }
        },
        mutations: {
          setItem (state, { id, item }) {
            Vue.set(state.items, id, item)
          }
        }
      })
    }

app.js

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

那么,我们在哪里放置「dispatch 数据预取 action」的代码?

我们需要通过访问路由,来决定获取哪部分数据 - 这也决定了哪些组件需要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。

我们将在路由组件上暴露出一个自定义静态函数 asyncData。注意,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去, 所以现在我们的 entry-server.js 现在变成这样

entry-server.js

    import { createApp } from './app.js';

    export default context => {
      // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
      // 以便服务器能够等待所有的内容在渲染前,
      // 就已经准备就绪。
      return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()
        if (context.url.indexOf('.') === -1) {
          // 设置服务器端 router 的位置
          router.push(context.url)
        }
        
        console.log(context.url, '******')
        // 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {
          const matchedComponents = router.getMatchedComponents()
          // 匹配不到的路由,执行 reject 函数,并返回 404
          if (!matchedComponents.length) {
            router.push('/foo')   // 可以加个默认页面, 或者是404页面
            // return reject({ code: 404 })
          }
    
          Promise.all(matchedComponents.map(component => {
            if (component.asyncData) {
              return component.asyncData(
                {
                  store,
                  route: router.currentRoute
                })
            }
          })).then(() => {
            // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,
            //自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序之前,store 就应该获取到状态
            context.state = store.state
            // Promise 应该 resolve 应用程序实例,以便它可以渲染
            resolve(app)
          }).catch(reject)
        }, reject)
      })
    }

服务端把数据存放context.state中,客户端将在 window.__INITIAL_STATE__中拿到数据

entry.client.js

    import { createApp } from './app.js'
    
    const { app, router, store } = createApp()
    // 还原状态
    if(window.__INITAL__STATE__) {
        store.replaceState(window.__INITAL__STATE)
    }
    router.onReady(() => {
      // 这里假定 App.vue 模板中根元素具有 `id="app"`
      app.$mount('#app')
    })

asyncData 函数在非首屏页面怎么加载

多个页面中都有 asyncData 函数时,非首屏加载的页面的怎么执行 asyncData 函数呢,当加载完首屏之后,再跳转到别的页面,别的页面的 asyncData 的执行在beforeMount 钩子函数中执行, 通过mixin 混入。

Vue.mixin({
    beforeMount() {
        const { asyncData } = this.$options
        if(asyncData) {
            this.dataPromise = asyncData({
                store: this.$store,
                route: this.$route
            })
        }
    }
})

后续

现在不能实时刷新, 还需要进一步优化