学习Vue的SSR,这可能是最好的教程

为了追求更好的用户体验,很多公司的业务页面都会采用SSR进行渲染,将渲染的结果返回给浏览器,浏览器能够不经过实例化Vue,直接解析HTML代码展示,这样可以减少首屏时间,提高用户体验。

0.Server Side Render使用

0.1上手SSR

Vue提供一个npm包叫做vue-server-renderer,它是在vue源码中的server目录中单独打包出来作为服务端渲染的依赖包,包的名称已经讲清楚了它的作用:作为一个服务端渲染器,将Vue实例中所有需要展示的内容进行渲染。

​ 举一个很简单的服务端渲染例子:

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer();

server.get('*', (req, res) => {
     
    // 实例化一个Vue
    const app = new Vue({
     
        template: `
Server Render
`
, }) // 将vue实例进行渲染生成html,并且拼凑为HTML代码返回 renderer.renderToString(app, (err, html) => { if (err) { res.status(500).end('Internal Server Error') return } res.end(` Hello ${ html} `) }) }) server.listen(8080)

使用renderer在普通实例上面进行渲染成为一个字符串并且返回。

ssr就这么简单?肯定不是。

要知道在Server环境下,一个服务会接收很多请求。那么上面例子会导致很多请求都共享一个Vue实例。现在的程序比较复杂,需要借用到store来进行保存状态:

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer();
const store = new Vuex.Store({
     
    state: {
     
        count: 1
    },
    mutations: {
     
        increment(state) {
     
            state.count++;
        }
    }
})

server.get('*', (req, res) => {
     
    // 实例化一个Vue
    const app = new Vue({
     
        store,
        template: `
Server Render{ {$store.state.count}}
`
, created() { this.$store.commit('increment'); } }) // 将vue实例进行渲染生成html,并且拼凑为HTML代码返回 renderer.renderToString(app, (err, html) => { if (err) { res.status(500).end('Internal Server Error') return } res.end(` Hello ${ html} `) }) }) server.listen(8080)

那么此时就要小心了,在这个例子中,启动服务的时候会创建一个store实例,请求的时候,会去更新store的值。由于store实例只有一个,而每次请求的时候都会自增count,这样会导致同样的请求得到不同的结果(同样的请求访问两次的结果不一致)。

那么如何解决以上问题呢?

造成上面问题的原因是全局只有一个store实例,那么解决的办法是在每次实例化Vue的时候,顺带创建一个新的store即可。

// 工厂函数,返回一个新的store实例
function createStore() {
     
    return new Vuex.Store({
     
        state: {
     
            count: 1
        },
        mutations: {
     
            increment(state) {
     
                state.count++;
            }
        }
    });
}

function createApp() {
     
    let store = createStore();
    let vue = new Vue({
     
        store,
        template: `
Server Render{ {$store.state.count}}
`
, created() { this.$store.commit('increment'); } }); return { vue, store } }

上面举了一个简单的例子,主要想要表达的一点是在写SSR的时候,与编写浏览器的应用是不一样的。在编写SSR代码的时候,需要去考虑到编写的代码会不会对环境造成影响、生成的Store、Router是不是单例?如果是,那么要注意会不会互相影响。因为在服务端中会长时间运行程序,避免不了多次访问服务。而每次服务都很有可能进行更改数据。如果多个不同的应用访问同一个共享内存,那么很有可能产生难以预估的后果。

1.SSR从服务端到浏览器的整个流程

先提出几个问题先:

  1. 为什么文档中说服务端渲染的时候,只调用了前beforeCreatecreated生命周期钩子函数呢?
  2. 如果SSR渲染的页面是需要进行请求获得数据,那么在何时进行请求数据?以什么方式来请求数据?
  3. 服务端初始化App并且请求数据后,app的状态改变了。而在浏览器的时候会重新创建app,那么怎么样才能保证在服务端创建的app的状态和客户端创建app的状态保持一致呢?

1.1为什么服务端渲染只会调用beforeCreate和created?

先来一段官方文档:

学习Vue的SSR,这可能是最好的教程_第1张图片

SSR文档中讲到在服务端渲染中只调用这两个生命周期函数。为什么不会调用后面的生命周期呢?是因为Vue对象留出接口来阻止调用还是SSR进行了特殊化处理?

都不是!为什么呢?我们从Vue源码中生命周期看起!

学习Vue的SSR,这可能是最好的教程_第2张图片

这里进行回答问题:

  • 为什么会调用beforeCreatecreated生命周期钩子函数呢?
    callHook函数是调用实例生命周期的函数。上面的代码在调用new Vue()的时候直接执行,那么理所当然会调用beforeCreatecreated生命周期钩子函数。

  • 为什么不会调用到后面的生命周期钩子函数呢?

    看图中的最后一段代码

    if (vm.$options.el) {
        vm.$mount(vm.$options.el)
    }
    

    先进行判断options中的el选项是否定义,从而决定是否执行$mount函数。而beforeMountmounted钩子函数是在调用vm.$mount函数的时候才能被调用。而在Node环境或者其他环境下是没有document对象的,自然也不会在选项中配置el,所以这个判断条件为false,也就是说不会执行到后面两个生命周期钩子函数。下面的代码可以说明:

学习Vue的SSR,这可能是最好的教程_第3张图片

$mount函数中调用了生命周期钩子函数beforeMountmounted。而ssr的vue实例是没有进行设置el属性的,即不会执行到这个函数,那么自然就没有执行后面的生命周期钩子函数。

1.2 SSR的时候,是如何进行数据请求的?

先回答是如何获取数据、以及什么时候获取数据:

  • 如何获取数据?

    Vue的所有组件的配置都容纳在对象中,并且组件配置实例化成Vue实例的时候是可以拿到组件配置的,那么可以在组件的配置中添加一个选项。这样可以在配置中添加一个asyncData选项,路由匹配到组件后先进行请求数据,请求完毕后再进行初始化,代码如下:

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

// 下面是组件配置
export default {
     
    asyncData({
     store}) {
     
        /// code....
    }
}

// 下面是服务端入口文件内容
export default context => {
     
  return new Promise((resolve, reject) => {
     
    const {
      app, store, router, App } = createApp();

    router.push(context.url);

    router.onReady(() => {
     
      const matchedComponents = router.getMatchedComponents();

      if (!matchedComponents.length) {
     
        return reject({
      code: 404 });
      }

      Promise.all(matchedComponents.map(component => {
     
        if (component.asyncData) {
     
          return component.asyncData({
      store });
        }
      })).then(() => {
     
        // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
        context.state = store.state;
        // 返回根组件
        resolve(app);
      });
    }, reject);
  });
}

上面的例子是先在路由层级(即路由配置中直接指向的组件)组件中定义asyncData。当然你也可以定义其他属性。根据路由获取组件配置后,通过获取配置的asyncData属性并且执行代码,这样就可以执行数据的获取。

  • 那么服务端在什么时候进行请求数据?

    请求刚进来,并且vue实例已经初始化后。根据请求的url进行匹配路由,匹配后拿到匹配到的组件配置进行请求数据。相关的代码如下:

学习Vue的SSR,这可能是最好的教程_第4张图片

1.3 那么怎么样才能保证在服务端创建的app的状态和客户端创建app的状态保持一致呢?

有些组件在渲染之前是需要获取数据的,而获取数据之后,应用的状态就改变的,如果在服务端更改状态后,浏览器初始化的时候状态就与服务端渲染的状态不一致了,那么要怎么做到服务端与浏览器状态统一?

  1. 服务端获取数据,保存到服务端的store状态,以便渲染时候使用,并且还会保存到context的state中,而context的内容最终会保存到window中;
  2. 在renderer中会在html代码中添加,在解析页面的时候会进行设置全局变量;
  3. 在浏览器进行初始化Store的时候,通过window对象进行获取数据在服务端的状态,并且将其注入到store.state状态中,这样能够实现状态统一。(window对象作为中间媒介进行传递数据)

可以从ssr渲染出来的html代码看到:

学习Vue的SSR,这可能是最好的教程_第5张图片

以上就是SSR一种保存状态的方式的流程,不过我们还需要初始化store的时候执行以下这段代码,进行数据同步:

学习Vue的SSR,这可能是最好的教程_第6张图片

2.Server Side Render和Client Side Render的区别

2.1SSR相对于CSR的优点

要讲清楚两个端渲染之间的区别,那么就要先从为什么要使用SSR入手:SSR能够在服务端先进行请求渲染,由于服务端进行请求数据的时延较小,能够快速拿到数据并且返回HTML代码。在客户端可以直接渲染数据而不需要花费一些请求数据的时间,这是服务端渲染的好处。

2.2从返回的内容来看

返回内容SSR会比普通的SPA在HTML代码中多出首次渲染的结果,这样在初始化的时候直接将页面进行渲染,无需花费时间去请求数据再次渲染。

2.3从渲染次数来看

SSR并不是说只在服务端进行渲染,而是说SSR会比普通的客户端渲染多一次在服务端渲染。到浏览器这边,SSR还是需要进行再次初始化Vue,并且经过beforeCreate、created、beforeMount、mounted生命周期,但是在客户端VNode进行patch的时候,如果遇到服务端渲染过的节点,那么会跳过,所以在浏览器端渲染的时候可以减少一些工作,从而提高了页面体验。

3.Server Side Render内部实现注意点

Vue的SSR与浏览器端的执行流程有很大相似度,但是实现的时候还需要有一些比较关键的点:

3.1隔离机制

在编写vue应用的时候,难免会在代码中对环境变量(即全局变量)进行修改。在浏览器端,每次刷新页面的时候都会开启一个新的应用,很少会发生全局变量被莫名其妙修改。而在服务端上跑着的不仅有vue应用,还有接收请求的Server,那么要小心会不会发生因为修改全局变量而产生的错误:

而在服务端的node环境下有一个共享的global对象。Vue的SSR的Renderer可以配置是否每次执行代码的时候创建一个新的执行环境sandbox全局环境(也就是global),以达到每次创建vue实例的时候创建出新的状态。

const renderer = createBundleRenderer('', {
     
  runInNewContext: true    // 是否开启每次都创建新的沙箱模式
});

如果你的应用很依赖全局变量进行通信的话,那么建议你开启这个功能。

3.2编译优化

对于没有进行编译操作(不是通过脚手架进行编写代码的应用)的组件渲染,在SSR中会进行编译优化,具体的思想如下:

  1. 对于纯文本、没有内容的节点,以及没有绑定数据源的输入标签,进行打标记,这样后面处理成渲染函数的时候直接转为字符串处理。
  2. 对于动态绑定数据源的节点,那么和普通的模板编译处理方式一致。

这是因为服务端渲染的实质是直接生成HTML字符串,在执行render函数后并不会进行patch操作,而是重写了渲染层,直接转为字符串,所以在编译的时候一些静态的内容,可以跳过转为渲染函数的步骤直接处理成为字符串。但是如果是webpack中使用到vue-template-compiler已经处理成为渲染函数的组件,那么会跳过编译优化。

3.3渲染层

Web端的渲染层是进行dom操作,而服务端的渲染层是进行生成字符串操作。我们来看一下ssr中的渲染层做了什么事情,首先找到渲染层的入口:

export function createRenderFunction (
  modules: Array<(node: VNode) => ?string>,
  directives: Object,
  isUnaryTag: Function,
  cache: any
) {
     
  // 渲染函数
  return function render (
    component: Component,
    write: (text: string, next: Function) => void,
    userContext: ?Object,
    done: Function
  ) {
     
    warned = Object.create(null)
    // 执行上下文,包括活跃的vm实例
    const context = new RenderContext({
     
      activeInstance: component,
      userContext,
      write, done, renderNode,
      isUnaryTag, modules, directives,
      cache
    });
    // 混入一些工具,比如说标签的class注入、style注入
    installSSRHelpers(component)
    // 判断是否具有渲染函数,如果没有的话就进行编译操作
    normalizeRender(component)

    const resolve = () => {
     
      // 执行渲染函数,这是渲染层的开端
      renderNode(component._render(), true, context)
    }
    // 在文档中有讲到在每个组件可以配置serverPrefetch,会先调用这个钩子函数再进行渲染
    waitForServerPrefetch(component, resolve, done)
  }
}

接下来看到renderNode函数:

/**
 * @desc 在这里的时候,访问到组件实例中的变量已经换成值了,所以不需要考虑说遇到变量怎么处理,而是将考虑的重点放到node的种类中
 * @param node
 * @param isRoot
 * @param context
 */
function renderNode (node, isRoot, context) {
     
  if (node.isString) {
     
    renderStringNode(node, context)
  } else if (isDef(node.componentOptions)) {
     
    renderComponent(node, isRoot, context)
  } else if (isDef(node.tag)) {
     
    // 渲染组件或者真实的dom节点
    renderElement(node, isRoot, context)
  } else if (isTrue(node.isComment)) {
     
    if (isDef(node.asyncFactory)) {
     
      // async component
      renderAsyncComponent(node, isRoot, context)
    } else {
     
      // 注释
      context.write(``, context.next)
    }
  } else {
     
    // 进行转义操作
    context.write(
      node.raw ? node.text : escape(String(node.text)),
      context.next
    )
  }
}

到这里进行渲染的过程也比较清晰了,就不再展开。不过我们来讲清楚一下几个常见问题:

  1. 对于事件监听、双向绑定在SSR中是如何处理的?

    ssr是页面在某个条件下的快照,并不会根据数据的改变进行更新操作(因为在执行_render函数的时候没有创建渲染函数观察者,所以更改数据的时候并不会更新视图,所以说是一个快照),并且服务端渲染出字符串,并不存在dom这个概念,那么事件监听无从说起。事件监听是在浏览器再次进行patch的时候挂载上去的。

你可能感兴趣的:(Vue,源码,vue.js,javascript)