完全搞懂Vue SSR

SSR (Server Side Render)

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

优势

  • 更好的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项目(包括一些点击事件,数据绑定), 典型的客户端渲染。php

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

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

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

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

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

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 页面java

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 标记。node

咱们能够提供一个模板页面。例如webpack

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

注意 <!--vue-ssr-outlet--> 注释这里将是应用程序 HTML 标记注入的地方。 这是插件提供的,若是不用 <!--vue-ssr-outlet-->也是能够的,那就要本身去简单处理一下了。好比demo3git

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 的操做在服务端确定是不行的.github

  • 一般 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)
      })
    }
复制代码

后续

  1. 若是使用 server.bundle.js 文件的话,server 端不能使用 commonChunk 或路由懒加载的形式, 由于须要打包成一个bundle
  2. 如今不能实时刷新, 还须要进一步优化
  3. 组件重用的问题尚未解决
  4. 全部的数据都是放在根Store 中,还需拆分
相关文章
相关标签/搜索