Vue SSR深度剖析

作者: 陈峰163 | 来源:发表于2019-04-03 20:39 被阅读10次

介绍

vue-ssr相信大部分前端开发都听说过,或许自己也尝试搭建过一些小demo,但真正运用到项目中的不多。本文将从什么是ssr、ssr是如何运作的以及ssr项目的优化方向等这几个方面给大家详细介绍下vue-ssr。

阅读此文章需要对vue、vue-ssr有一定基础,并且默认读者使用webpack对vue应用打包。

本文会涉及到vue-server-renderervue-loader的相关源码解析,建议阅读的同时对照库的源码,以便更容易理解。

什么是vue-ssr

ssr是Server-Side Rendering的简写,即由服务端负责渲染页面直出,亦即同构应用。程序的大部分代码都可以在服务端和客户端运行。在服务端vue组件渲染为html字符串,在客户端生成dom和操作dom。

能在服务端渲染为html字符串得益于vue组件结构是基于vnode的。vnode是dom的抽象表达,它不是真实的dom,它是由js对象组成的树,每个节点代表了一个dom。因为vnode所以在服务端vue可以把js对象解析为html字符串。同样在客户端vnode因为是存在内存之中的,操作内存总比操作dom快的多,每次数据变化需要更新dom时,新旧vnode树经过diff算法,计算出最小变化集,大大提高了性能。

ssr的主要优势在于更好的SEO和更快的到达时间,服务端返回的内容是具有信息内容的html文档,这对搜索引擎的爬虫是友好的。用户在弱网情况下也无需等待js加载完成才能开始渲染页面,可以更加快速的看到完整的内容。

当然ssr也有他的问题,开发ssr的项目需要更好的区分哪些代码能在服务端运行,哪些代码只能在客户端运行,比如:window、document这些就不能出现在初始化代码和服务端的一些钩子函数中,我们需要写出更加通用的代码以保证在两端都可以正常的解析和运行。另外ssr项目在node中渲染页面显然要比大部分动态网站要消耗更多的cpu资源,如果项目是需要在高流量环境中使用,则需要准备更多的服务器负载和更好的缓存策略。

祭出官方提供的架构图 :

786a415a-5fee-11e6-9c11-45a2cfdf085c (1).png

SSR是如何运作的

根据应用的触发时机我们分成以下几个步骤详细讲解:

编译阶段

vue-ssr是同构框架,即我们开发的同一份代码会被运行在服务端和客户端两个环境中。所以我们的代码需要更加偏向于通用,但毕竟环境的差异导致很多特定代码无法兼容,比如:vue的dom挂载、一些运行于客户端的第三方库等等。vue-ssr提供的方式是配置两个入口文件(entry-client.js、entry-server.js),通过webpack把你的代码编译成两个bundle。

两个入口的编译方式可以很方便的做两个环境的差异化代码抹平:

  1. 在客户端入口中vue实例化之后执行挂载dom的代码,服务端入口的vue则只需要生成vue对象即可 。

  2. 一些不兼容ssr的第三方库或者代码片段,我们可以只在客户端入口中加载 。

  3. 即使通用代码我们也可以通过打包工具做到两个运行环境的差异化。比如最常见的在应用中发起请求时,在客户端我们经常使用axios来发起请求,在服务端虽然也兼容axios,但是服务端发起的请求并不需要和客户端一样走外网请求,服务端的接口网关或者鉴权方式和客户端也不一定相同。这种情况我们可以通过webpack的resolve.alias配置实现两个环境引用不同模块。

  4. 在服务端的代码我们不需要做code split,甚至我们项目中所有引入的依赖库,也并不需要打包到bundle中。因为在node运行环境中,我们的依赖库都可以通过require在运行时加载进来。

通过webpack打包生成的bundle示例:

Server Bundle

vue-ssr-server-bundle.json:

{ 
  "entry": "static/js/app.80f0e94fe005dfb1b2d7.js", 
  "files": { 
    "static/js/app.80f0e94fe005dfb1b2d7.js": "module.exports=function(t...", 
    "static/js/xxx.29dba471385af57c280c.js": "module.exports=function(t..." 
  } 
} 

Client Bundle

许多静态资源...

vue-ssr-client-manifest.json文件:

{ 
  "publicPath": "//cdn.xxx.cn/xxx/", 
  "all": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js", 
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "initial": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js",
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "async": [ 
    "static/js/xxx.29dba471385af57c280c.js" 
  ], 
  "modules": { 
    "00f0587d": [ 0, 1 ] 
    ... 
    } 
} 

Server Bundle中包含了所有要在服务端运行的代码列表,和一个入口文件名。

Client Bundle包含了所有需要在客户端运行的脚本和静态资源,如:js、css图片、字体等。还有一份clientManifest文件清单,清单中initial数组中的js将会在ssr输出时插入到html字符串中作为preload和script脚本引用。asyncmodules将配合检索出异步组件和异步依赖库的js文件的引入,在输出阶段我们会详细解读。

初始化阶段

ssr应用会在node启动时初始化一个renderer单例对象,renderer对象由vue-server-renderer库的createBundleRenderer函数创建,函数接受两个参数,serverBundle内容和options配置

在options中我们需要传入clientManifest内容,其他的参数我们会在后续阶段讲解。

bundleRenderer = createBundleRenderer(serverBundle, { 
  runInNewContext: false, 
  clientManifest, 
  inject: false 
});

初始化完成,当用户发起请求时,renderer.renderToString或者renderer.renderToStream函数将完成vue组件到html的过程。

bundleRenderer.renderToString(context, (err, html) => { 
    //...
})

createBundleRenderer函数在初始化阶段主要做了3件事情:

1. 创建将vue对象解析为html的渲染函数的单例对象

var renderer = createRenderer(rendererOptions); 

在createRenderer函数中创建了两个对象:rendertemplateRenderer,他们分别负责vue组件的渲染和html的组装,在之后的阶段我们详细讲解。

var render = createRenderFunction(modules, directives, isUnaryTag, cache);
var templateRenderer = new TemplateRenderer({
  template: template,
  inject: inject,
  shouldPreload: shouldPreload,
  shouldPrefetch: shouldPrefetch,
  clientManifest: clientManifest,
  serializer: serializer
});

2. 创建nodejs的vm沙盒,并返回了run函数作为每次实例化vue组件的入口函数

var run = createBundleRunner( 
  entry, 
  files, 
  basedir, 
  rendererOptions.runInNewContext 
);

这里的entry和files参数是vue-ssr-server-bundle.json中的entry和files字段,分别是应用的入口文件名和打包的文件内容集合。

runInNewContext是可选的沙盒运行配置:

  1. true,每次创建vue实例时都创建一个全新的v8上下文环境并重新执行bundle代码,好处是每次渲染的环境状态是隔离的,不存在状态单例问题,也不存在状态污染问题。但是,缺点是每次创建v8上下文的性能代价很高。
  2. false,创建在当前global运行上下文中运行的bundle代码环境,bundle代码将可以获取到当前运行环境的global对象,运行环境是单例的
  3. once ,会在初始化时单例创建与global隔离的运行上下文

当runInNewContext设置为false或者once时,在初始化之后的用户每次请求将会在同一个沙盒环境中运行,所以在实例化vue实例或者一些状态存储必须通过闭包创建独立的作用域才不会被不同请求产生的数据相互污染,举个例子:

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

  return {app};
}

在createBundleRunner函数中有非常重要的两个函数getCompiledScript和evaluateModule

function getCompiledScript (filename) {
  if (compiledScripts[filename]) {
    return compiledScripts[filename]
  }
  var code = files[filename];
  var wrapper = NativeModule.wrap(code);
  var script = new vm.Script(wrapper, {
    filename: filename,
    displayErrors: true
  });
  compiledScripts[filename] = script;
  return script
}

function evaluateModule (filename, sandbox, evaluatedFiles) {
  if ( evaluatedFiles === void 0 ) evaluatedFiles = {};

  if (evaluatedFiles[filename]) {
    return evaluatedFiles[filename]
  }

  var script = getCompiledScript(filename);
  var compiledWrapper = runInNewContext === false
  ? script.runInThisContext()
  : script.runInNewContext(sandbox);
  var m = { exports: {}};
  var r = function (file) {
    file = path$1.posix.join('.', file);
    if (files[file]) {
      return evaluateModule(file, sandbox, evaluatedFiles)
    } else if (basedir) {
      return require(
        resolvedModules[file] ||
        (resolvedModules[file] = resolve.sync(file, { basedir: basedir }))
      )
    } else {
      return require(file)
    }
  };
  compiledWrapper.call(m.exports, m.exports, r, m);

  var res = Object.prototype.hasOwnProperty.call(m.exports, 'default')
  ? m.exports.default
  : m.exports;
  evaluatedFiles[filename] = res;
  return res
}

createBundleRunner执行时调用evaluateModule并传入serverBundle中的应用入口文件名entry和沙盒执行上下文。

之后调用getCompiledScript,通过入口文件名在files文件内容集合中找到入口文件内容的code,code内容大致如下,内容是由webpack打包编译生成:

module.exports = (function(modules) {
  //...
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  return __webpack_require__(__webpack_require__.s = "ry7I");
})({
  "ry7I": (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__.default = function(context) {
      return new Promise((resolve, reject) => {
        const {app} = createApp(context);
        resolve(app);
      });
    }
  }),
  "+ooV": (function (module, __webpack_exports__, __webpack_require__) {
    //...
  })
})

上面代码是把你一个立即执行函数赋值给module.exports,而立即执行函数的结果返回了入口模块:

return __webpack_require__(__webpack_require__.s = "ry7I");

这里的ry7I是webpack打包时模块的moduleId,根据ry7I我们可以找到:

{
  "ry7I": (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__.default = function(context) {
      return new Promise((resolve, reject) => {
        const {app} = createApp(context);
        resolve(app);
      });
    }
  })
}

这里的入口模块就是我们服务端entry-server.js的内容。为了方便理解我们可以把入口文件简单理解为以下内容:

module.exports = {
  default: function(context) {
    return new Promise((resolve, reject) => {
      const {app} = createApp(context);
      //...
      resolve(app);
    });
  }
  ...
} 

这只是一段赋值代码,如果在vm执行它的话并没有任何返回值,我们也拿不到入口函数,所以在vm中执行前,我们需要把这段代码内容用NativeModule.wrap(code)包裹一下,NativeModule就是nodejs的module模块,wrap函数只做了一次简单的包裹。

module.wrap源码:

let wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

包裹之后入口文件的code:

(function (exports, require, module, __filename, __dirname) {
  module.exports = {
    default: function(context) {
      return new Promise((resolve, reject) => {
        const {app} = createApp(context);
        //...
        resolve(app);
      });
    }
  }
});

回到getCompiledScript函数,通过new vm.Script编译wrapper之后并返回给evaluateModule,接下来根据runInNewContext的配置来决定是在当前上下文中执行还是在单独的上下文中执行,并将执行结果返回(我看上面的code,执行结果其实就是返回了一个函数)。

接下来执行compiledWrapper.call(m.exports, m.exports, r, m);,传入的参数分别对应上面函数中:exports、require、module,这样我们就通过传入的m对象引用拿到了入口函数。另外传入r函数是为了替代原生require用来解析bundle中通过require函数引用的其他模块。

这一步通过createBundleRunner函数创建的run,在用户发起请求时每次调用都会通过入口函数实例化一个完整的vue对象。

3.返回renderToStringrenderToStream函数

return {
  renderToString: function (context, cb) {
    var assign;

    if (typeof context === 'function') {
      cb = context;
      context = {};
    }

    var promise;
    if (!cb) {
      ((assign = createPromiseCallback(), promise = assign.promise, cb = assign.cb));
    }

    run(context).catch(function (err) {
      rewriteErrorTrace(err, maps);
      cb(err);
    }).then(function (app) {
      if (app) {
        renderer.renderToString(app, context, function (err, res) {
          rewriteErrorTrace(err, maps);
          cb(err, res);
        });
      }
    });

    return promise
  },
  renderToStream: function (context, cb) {
    //...
    run(context).then(function (app) {
      var renderStream = renderer.renderToStream(app, context);

      renderStream.pipe(res);
    });
    //...
  }
}

虽然vue的文档没有提到,但是根据这部分的代码,renderToString如果在没执行cb回调函数的情况下是返回一个Promise对象的,这里很巧妙的利用createPromiseCallback创建了一个promise并导出了它的resolve和reject实现了和cb回调的兼容逻辑,所以我们同样也可以这样使用renderToString:

try {
    const html = await bundleRenderer.renderToString(context);
    //...
} catch (error) {
    //error handler
}

小结:

  1. 获取到serverBundle的入口文件代码并解析为入口函数,每次执行实例化vue对象
  2. 实例化了render和templateRenderer对象,负责渲染vue组件和组装html

渲染阶段

当用户请求达到node端时,调用bundleRenderer.renderToString函数并传入用户上下文context,context对象可以包含一些服务端的信息,比如:url、ua等等,也可以包含一些用户信息,context对象内容(除了context.state和模板中的占位字段)并不会被输出到前端:

bundleRenderer.renderToString(context, (err, html) => {
  return res.send(html);
});

上一个阶段在createBundleRenderer函数中创建了renderer和run,执行bundleRenderer.renderToString时会先调用run创建vue的对象实例,然后调用把vue实例传给renderer.renderToString函数。

这个时候如果使用了vue-router库,则在创建vue实例时,调用router.push(url)后router开始导航,router负责根据url匹配对应的vue组件并实例化他们,最后在router.onReady回调函数中返回整个vue实例。

我们接下来看下在这个函数中做了哪些事情。

function renderToString (
  component,
  context,
  cb
) {
  var assign;

  if (typeof context === 'function') {
    cb = context;
    context = {};
  }
  if (context) {
    templateRenderer.bindRenderFns(context);
  }

  // no callback, return Promise
  var promise;
  if (!cb) {
    ((assign = createPromiseCallback(), promise = assign.promise, cb = assign.cb));
  }

  var result = '';
  var write = createWriteFunction(function (text) {
    result += text;
    return false
  }, cb);
  try {
    render(component, write, context, function (err) {
      if (err) {
        return cb(err)
      }
      if (context && context.rendered) {
        context.rendered(context);
      }
      if (template) {
        try {
          var res = templateRenderer.render(result, context);
          if (typeof res !== 'string') {
            // function template returning promise
            res
              .then(function (html) { return cb(null, html); })
              .catch(cb);
        } else {
          cb(null, res);
        }
        } catch (e) {
          cb(e);
        }
      } else {
        cb(null, result);
      }
    });
  } catch (e) {
    cb(e);
  }

  return promise
}

在初始化阶段第一步创建的templateRenderer,它负责html的组装,它的主要原型方法有:bindRenderFnsrenderrenderStylesrenderResourceHintsrenderStaterenderScripts

其中renderStylesrenderResourceHintsrenderStaterenderScripts分别是生成页面需要加载的样式、preload和prefetch资源、页面state(比如vuex的状态state,需要在服务端给context.state赋值才能输出)、脚本文件引用的内容。

在上面代码中执行的templateRenderer.bindRenderFns则是把这四个render函数绑定到用户上下文context中,以便用户可以拿到这些内容做自定义的组装或者渲染。

接下来创建了var write = createWriteFunction写函数,主要负责每个组件渲染完成之后返回html内容时的拼接。

之后调用了createRenderFunction 创建的render函数,传入vue对象实例、写函数、用户上下文context和渲染完成之后的done回调。

render函数:

function render (
  component,
  write,
  userContext,
  done
) {
  warned = Object.create(null);
  var context = new RenderContext({
    activeInstance: component,
    userContext: userContext,
    write: write, done: done, renderNode: renderNode,
    isUnaryTag: isUnaryTag, modules: modules, directives: directives,
    cache: cache
  });
  installSSRHelpers(component);
  normalizeRender(component);

  var resolve = function () {
    renderNode(component._render(), true, context);
  };
  waitForServerPrefetch(component, resolve, done);
}

在这个函数中组件将被按照从父到子的递归顺序,把vue组件渲染为html。

第一步,创建RenderContext 渲染上下文对象,这个对象将贯穿整个递归过程,它主要负责在递归过程中闭合组件标签和渲染可缓存组件的存储工作。

第二步,执行installSSRHelpersnormalizeRender这两行主要是针对在组件中使用字符串template模板的组件的编译工作,在执行normalizeRender时vue会将字符串模板解析语法树,然后转成render函数。而installSSRHelpers是在解析之前安装一些在ssr中生成vnode的帮助函数,一个简单的template解析为render的例子:

template:

<span><div>{{value}}</div></span>

render:

with(this){return _c('span',[_ssrNode("<div>"+_ssrEscape(_s(value))+"</div>")])}

虽然vue在解析html时已经做了很多优化,比如:上面的__ssrNode函数,它不再生成vnode而是生成StringNode这样的简单节点,在后续渲染时直接拼接字符串即可。但是毕竟还是要解析一次html的语法树,所以我们通常开发vue项目时使用vue-loader把template解析为render函数或者直接用jsx语法,甚至createElement函数。而在vue-server-renderer库中缺有大量只是针对template字符串模板的解析和优化的代码,所以尽量避免使用template字符串模板。

第三步,执行waitForServerPrefetch,在waitForServerPrefetch函数中,会检查组件是否定义了serverPrefetch钩子(vue@2.6.0+新增api,代替了以前asyncData的兼容方案),如果定义了,则等待钩子执行完毕后才继续resolve回调。

在回调中component._render返回的是该vue组件的vnode,传递给renderNode函数递归解析。(ps.大家可以看到,虽然serverPrefetch这个api在官方文档中说明是一个返回promise的function类型,但根据源码看,它也可以被定义为一个返回promise的function类型的数组)

function waitForServerPrefetch (vm, resolve, reject) {
  var handlers = vm.$options.serverPrefetch;
  if (isDef(handlers)) {
    if (!Array.isArray(handlers)) { handlers = [handlers]; }
    try {
      var promises = [];
      for (var i = 0, j = handlers.length; i < j; i++) {
        var result = handlers[i].call(vm, vm);
        if (result && typeof result.then === 'function') {
          promises.push(result);
        }
      }
      Promise.all(promises).then(resolve).catch(reject);
      return
    } catch (e) {
      reject(e);
    }
  }
  resolve();
}

第四步,这一步开始执行renderNode,根据不同的vnode类型执行不同的render函数,六种不同类型的节点渲染方法,我们主要对renderStringNode$1renderComponentrenderElementrenderAsyncComponent这四个主要渲染函数做个分析:

function renderNode (node, isRoot, context) {
  if (node.isString) {
    renderStringNode$1(node, context);
  } else if (isDef(node.componentOptions)) {
    renderComponent(node, isRoot, context);
  } else if (isDef(node.tag)) {
    renderElement(node, isRoot, context);
  } else if (isTrue(node.isComment)) {
    if (isDef(node.asyncFactory)) {
      // async component
      renderAsyncComponent(node, isRoot, context);
    } else {
      context.write(("<!--" + (node.text) + "-->"), context.next);
    }
  } else {
    console.log(node.tag, ' is text', node.text)
    context.write(
      node.raw ? node.text : escape(String(node.text)),
      context.next
    );
  }
}

renderStringNode$1,负责处理通过vue编译template字符串模板生成的StringNode简单节点的渲染工作,如果没有子节点则直接调用写函数,其中el.open和el.close是节点开始和闭合标签。如果有子节点则把子节点添加到渲染上下文的renderStates数组中,写入开始标签并传入渲染上下文的next函数,写函数在拼接完成后调用next,在渲染上下文的next函数中继续解析该节点的子节点,并且在解析这个节点树之后写入闭合标签:

function renderStringNode$1 (el, context) {
  var write = context.write;
  var next = context.next;
  if (isUndef(el.children) || el.children.length === 0) {
    write(el.open + (el.close || ''), next);
  } else {
    var children = el.children;
    context.renderStates.push({
      type: 'Element',
      children: children,
      rendered: 0,
      total: children.length,
      endTag: el.close
    });
    write(el.open, next);
  }
}

renderComponent,负责处理vue的组件类型的节点,如果组件设置了serverCacheKey并且缓存中存在该key的渲染结果,则直接写入缓存的html结果。在写入html之前我们看到代码中循环调用了res.components并且传入了用户上下文userContext,循环调用的函数其实是在vue-loader注入的一个hook。这个hook会在执行时把当前这个组件的moduleIdentifier(webpack中编译时生成的模块标识)添加到用户上下文userContext的_registeredComponents数组中,vue会通过这个数组查找组件的引用资源文件。

如果没有命中缓存或者根本就没有缓存,则分别执行:renderComponentWithCacherenderComponentInner,这两个函数的区别是renderComponentWithCache会在组件渲染完成时,通过渲染上下文把结果写入缓存。

function renderComponent (node, isRoot, context) {
  var write = context.write;
  var next = context.next;
  var userContext = context.userContext;

  // check cache hit
  var Ctor = node.componentOptions.Ctor;
  var getKey = Ctor.options.serverCacheKey;
  var name = Ctor.options.name;
  var cache = context.cache;
  var registerComponent = registerComponentForCache(Ctor.options, write);

  if (isDef(getKey) && isDef(cache) && isDef(name)) {
    var rawKey = getKey(node.componentOptions.propsData);
    if (rawKey === false) {
      renderComponentInner(node, isRoot, context);
      return
    }
    var key = name + '::' + rawKey;
    var has = context.has;
    var get = context.get;
    if (isDef(has)) {
      has(key, function (hit) {
        if (hit === true && isDef(get)) {
          get(key, function (res) {
            if (isDef(registerComponent)) {
              registerComponent(userContext);
            }
            res.components.forEach(function (register) { return register(userContext); });
            write(res.html, next);
          });
        } else {
          renderComponentWithCache(node, isRoot, key, context);
        }
      });
    } else if (isDef(get)) {
      get(key, function (res) {
        if (isDef(res)) {
          if (isDef(registerComponent)) {
            registerComponent(userContext);
          }
          res.components.forEach(function (register) { return register(userContext); });
          write(res.html, next);
        } else {
          renderComponentWithCache(node, isRoot, key, context);
        }
      });
    }
  } else {
    renderComponentInner(node, isRoot, context);
  }
}

在renderComponentInner函数中通过vnode创建组件对象,等待组件的serverPrefetch钩子执行完成之后,调用组件对象的_render生成子节点的vnode后再渲染。(ps. 这里我们可以看出,serverPrefetch钩子中获取的数据只会被渲染到当前组件或者子组件中,因为在执行这个组件的serverPrefetch之前父组件已经被渲染完成了。)

function renderComponentInner (node, isRoot, context) {
  var prevActive = context.activeInstance;
  // expose userContext on vnode
  node.ssrContext = context.userContext;
  var child = context.activeInstance = createComponentInstanceForVnode(
    node,
    context.activeInstance
  );
  normalizeRender(child);

  var resolve = function () {
    var childNode = child._render();
    childNode.parent = node;
    context.renderStates.push({
      type: 'Component',
      prevActive: prevActive
    });
    renderNode(childNode, isRoot, context);
  };

  var reject = context.done;

  waitForServerPrefetch(child, resolve, reject);
}

renderElement渲染函数,负责渲染dom组件。函数内部调用了renderStartingTag,这个函数处理自定义指令、show指令和组件的scoped CSS ID生成还有给标签加上data-server-rendered属性(表示这是经过服务端渲染的标签),最后组装好dom的开始标签startTag。

如果组件是自闭合标签或者没有子节点,则直接写入标签节点内容。否则通过渲染上下文在渲染子节点后再写入结束标签。

function renderElement (el, isRoot, context) {
  var write = context.write;
  var next = context.next;

  if (isTrue(isRoot)) {
    if (!el.data) { el.data = {}; }
    if (!el.data.attrs) { el.data.attrs = {}; }
    el.data.attrs[SSR_ATTR] = 'true';
  }

  if (el.fnOptions) {
    registerComponentForCache(el.fnOptions, write);
  }

  var startTag = renderStartingTag(el, context);
  var endTag = "</" + (el.tag) + ">";
  if (context.isUnaryTag(el.tag)) {
    write(startTag, next);
  } else if (isUndef(el.children) || el.children.length === 0) {
    write(startTag + endTag, next);
  } else {
    var children = el.children;
    context.renderStates.push({
      type: 'Element',
      children: children,
      rendered: 0,
      total: children.length,
      endTag: endTag
    });
    write(startTag, next);
  }
}

renderAsyncComponent负责针对异步函数的加载和解析,vnode的asyncFactory是加载函数,因为我们的serverBundle已经包含所有脚本包含异步脚本了,所以在这一步的asyncFactory几乎就相当于一次Promise.resolve返回异步模块,不发起任何请求。拿到组件内容后创建vnode节点,调用renderComponent、renderNode。如果函数式组件的话可能返回多个vnode,直接通过渲染上下文渲染。

function renderAsyncComponent (node, isRoot, context) {
  var factory = node.asyncFactory;

  var resolve = function (comp) {
    if (comp.__esModule && comp.default) {
      comp = comp.default;
    }
    var ref = node.asyncMeta;
    var data = ref.data;
    var children = ref.children;
    var tag = ref.tag;
    var nodeContext = node.asyncMeta.context;
    var resolvedNode = createComponent(
      comp,
      data,
      nodeContext,
      children,
      tag
    );
    if (resolvedNode) {
      if (resolvedNode.componentOptions) {
        // normal component
        renderComponent(resolvedNode, isRoot, context);
      } else if (!Array.isArray(resolvedNode)) {
        // single return node from functional component
        renderNode(resolvedNode, isRoot, context);
      } else {
        // multiple return nodes from functional component
        context.renderStates.push({
          type: 'Fragment',
          children: resolvedNode,
          rendered: 0,
          total: resolvedNode.length
        });
        context.next();
      }
    } else {
      // invalid component, but this does not throw on the client
      // so render empty comment node
      context.write("<!---->", context.next);
    }
  };

  if (factory.resolved) {
    resolve(factory.resolved);
    return
  }

  var reject = context.done;
  var res;
  try {
    res = factory(resolve, reject);
  } catch (e) {
    reject(e);
  }
  if (res) {
    if (typeof res.then === 'function') {
      res.then(resolve, reject).catch(reject);
    } else {
      // new syntax in 2.3
      var comp = res.component;
      if (comp && typeof comp.then === 'function') {
        comp.then(resolve, reject).catch(reject);
      }
    }
  }
}

渲染函数已经介绍完毕,所有vnode都要经历这些函数渲染,当最后一个组件调用写函数,并执行渲染上下文的next时结束渲染工作,调用渲染上下文的done函数,也就是回到下面的回调函数。

如果用户上下文context定义了rendered钩子的话,触发这个钩子(这个钩子在vue@2.6.0新增的)。

result变量就是不断通过调用写函数拼接的组件渲染结果。

render(component, write, context, function (err) {
  if (err) {
    return cb(err)
  }
  if (context && context.rendered) {
    context.rendered(context);
  }
  if (template) {
    try {
      var res = templateRenderer.render(result, context);
      if (typeof res !== 'string') {
        // function template returning promise
        res
          .then(function (html) { return cb(null, html); })
          .catch(cb);
      } else {
        cb(null, res);
      }
    } catch (e) {
      cb(e);
    }
  } else {
    cb(null, result);
  }
});

如果没有定义tempate则vue在服务端的工作已经结束了。我们将在下一阶段分析当定义了template时templateRenderer对象在输出阶段如何拼接html和找到组件所依赖的脚本文件。

小结:

  1. 用户发起请求时,通过执行serverBundle后得到的应用入口函数,实例化vue对象。
  2. renderer对象负责把vue对象递归转为vnode,并把vnode根据不同node类型调用不同渲染函数最终组装为html。
  3. 在渲染组件的过程中如果组件定义了serverPrefetch钩子,则等待serverPrefetch执行完成之后再渲染页面(serverPrefetch生成的数据不会应用于父组件)

内容输出阶段

在上一个阶段我们已经拿到了vue组件渲染结果,它是一个html字符串,在浏览器中展示页面我们还需要css、js等依赖资源的引入标签和我们在服务端的渲染数据,这些最终组装成一个完整的html报文输出到浏览器中。

这里vue提供了两种选项:

没有定义template模板,在上面代码中我们看到,如果用户没有配置template的情况下,渲染结果会被直接返回给renderToString的回调函数,而页面所需要的脚本依赖我们通过用户上下文context的renderStylesrenderResourceHintsrenderStaterenderScripts这些函数分别获得(因为context在开始渲染之前就已经被templateRenderer.bindRenderFns(context)注入这些函数了)。

接下来我们可以用我们自己熟悉的模板引擎来渲染出最终的html报文,这里用hbs举个例子:

renderer.renderToString(context, (err, html) => {
  if (err) {
    return handlerError(err, req, res, next);
  }
  const styles = context.renderStyles();
  const scripts = context.renderScripts();
  const resources = context.renderResourceHints();
  const states = context.renderState();

  const result = template({
    html,
    styles,
    scripts,
    resources,
    states
  });

  return res.send(result);
});

handlerbars:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
    {{{resources}}}
    {{{styles}}}
</head>
<body>
    {{{html}}}
    {{{states}}}
    {{{scripts}}}
</body>
</html>



定义了template模板 ,在定义了template情况下,在创建TemplateRenderer实例的构造函数中,会对提供的template字符串做一个解析。解析的规则很简单,把模板分为三个部分:html文档开头到</head>标签是head部分,head之后到内容占位符是neck部分,最后tail部分是内容占位符到最后。

function parseTemplate (
  template,
  contentPlaceholder
) {
  if ( contentPlaceholder === void 0 ) contentPlaceholder = '<!--vue-ssr-outlet-->';

  if (typeof template === 'object') {
    return template
  }

  var i = template.indexOf('</head>');
  var j = template.indexOf(contentPlaceholder);

  if (j < 0) {
    throw new Error("Content placeholder not found in template.")
  }

  if (i < 0) {
    i = template.indexOf('<body>');
    if (i < 0) {
      i = j;
    }
  }

  return {
    head: compile$1(template.slice(0, i), compileOptions),
    neck: compile$1(template.slice(i, j), compileOptions),
    tail: compile$1(template.slice(j + contentPlaceholder.length), compileOptions)
  }
}

compile$1函数是lodash.template的引用,利用lodash.template函数将这三个字符串包装为一个渲染函数,我们可以在template模板中自定义一些占位符,然后通过用户上下文context上面的数据渲染。

var compile$1 = require('lodash.template');
var compileOptions = {
  escape: /{{([^{][\s\S]+?[^}])}}/g,
  interpolate: /{{{([\s\S]+?)}}}/g
};

vue在官方文档中Head管理(https://ssr.vuejs.org/zh/guide/head.html)中介绍了,如何通过将数据绑定到用户上下文context上,然后在模板中将这些数据渲染。其实不仅在head中支持自定义渲染,同样necttail部分都支持这么做。

接下来我们看TemplateRenderer如何帮我们做html组装的,this.parsedTemplate就是在构造函数中通过上面的解析函数得到的包含三个部分的compile对象,接下来只需要把准备好的各个部分按照顺序拼接就好了,如果设置了inject为false,则preload、style、state、script的引用都需要自己在模板中自行渲染。

TemplateRenderer.prototype.render = function render (content, context) {
  var template = this.parsedTemplate;
  if (!template) {
    throw new Error('render cannot be called without a template.')
  }
  context = context || {};

  if (typeof template === 'function') {
    return template(content, context)
  }

  if (this.inject) {
    return (
      template.head(context) +
      (context.head || '') +
      this.renderResourceHints(context) +
      this.renderStyles(context) +
      template.neck(context) +
      content +
      this.renderState(context) +
      this.renderScripts(context) +
      template.tail(context)
    )
  } else {
    return (
      template.head(context) +
      template.neck(context) +
      content +
      template.tail(context)
    )
  }
};

输出html的流程已经讲完,但是还是有很多人疑惑,如果我的项目是做了code splits代码是分割的,甚至还有一些异步组件,vue执行的serverBundle代码是如何通过clientManifest找到页面依赖的js和css呢?

在文档开头的编译阶段我们介绍了clientManifest文件结构,其中:

all 数组是编译工具打包的所有文件的集合

initial 数组是入口文件和在入口文件中引用的其他非异步依赖模块的文件集合

**async **则是所有异步文件的集合。

**modules **对象是moduleIdentifier和和all数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)。

要生成clientManifest文件需要在webpack配置的plugins中加入插件:

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
// ...
plugins: [
  new VueSSRClientPlugin({
    filename: '../../manifest.json'
  })
]
// ...

假设我们现在有一个简单的vue应用,其中有一个app.vue文件,并引用了一个异步组件,生成了下面的clientManifest文件:

{ 
  "publicPath": "//cdn.xxx.cn/xxx/", 
  "all": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js", 
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "initial": [ 
    "static/js/app.80f0e94fe005dfb1b2d7.js",
    "static/css/app.d3f8a9a55d0c0be68be0.css"
  ], 
  "async": [ 
    "static/js/async.29dba471385af57c280c.js" 
  ], 
  "modules": { 
    "00f0587d": [ 0, 1 ] 
    ... 
    } 
} 

通过配置的plugin我们知道clientmanifest是由vue-server-renderer/client-plugin生成的,我们来看下它在编译时做了哪些事情,我们可以下下面的代码:

在webpack中,编译时compilation对象可以获得打包资源模块和文件(关于webpack详细解读可以参考这篇文章: https://segmentfault.com/a/1190000015088834)。

all、initial、async都可以通过stats.assets和stats.entrypoints获得。

modules通过stats.modules获得,modules的key是根据identifier生成的,对应的依赖文件列表则可以通过states.modules.chunks获得。

VueSSRClientPlugin.prototype.apply = function apply(compiler) {
  var this$1 = this;

  onEmit(compiler, 'vue-client-plugin', function (compilation, cb) {
    var stats = compilation.getStats().toJson();

    var allFiles = uniq(stats.assets
      .map(function (a) { return a.name; }));

    var initialFiles = uniq(Object.keys(stats.entrypoints)
      .map(function (name) { return stats.entrypoints[name].assets; })
      .reduce(function (assets, all) { return all.concat(assets); }, [])
      .filter(function (file) { return isJS(file) || isCSS(file); }));

    var asyncFiles = allFiles
      .filter(function (file) { return isJS(file) || isCSS(file); })
      .filter(function (file) { return initialFiles.indexOf(file) < 0; });

    var manifest = {
      publicPath: stats.publicPath,
      all: allFiles,
      initial: initialFiles,
      async: asyncFiles,
      modules: { /* [identifier: string]: Array<index: number> */ }
    };
    var assetModules = stats.modules.filter(function (m) { return m.assets.length; });
    var fileToIndex = function (file) { return manifest.all.indexOf(file); };
    stats.modules.forEach(function (m) {
      // ignore modules duplicated in multiple chunks
      if (m.chunks.length === 1) {
        var cid = m.chunks[0];
        var chunk = stats.chunks.find(function (c) { return c.id === cid; });
        if (!chunk || !chunk.files) {
          return
        }
        var id = m.identifier.replace(/\s\w+$/, ''); // remove appended hash
        var files = manifest.modules[hash(id)] = chunk.files.map(fileToIndex);
        // find all asset modules associated with the same chunk
        assetModules.forEach(function (m) {
          if (m.chunks.some(function (id) { return id === cid; })) {
            files.push.apply(files, m.assets.map(fileToIndex));
          }
        });
      }
    });

    var json = JSON.stringify(manifest, null, 2);
    compilation.assets[this$1.options.filename] = {
      source: function () { return json; },
      size: function () { return json.length; }
    };
    cb();
  });
};

ps. webpack的identifier通常是需要编译的模块路径,比如:

/Users/chenfeng/Documents/source/yoho/yoho-community-web/node_modules/vue-loader/lib/index.js??vue-loader-options!/Users/chenfeng/Documents/source/yoho/yoho-community-web/apps/app.vue

我们通过vue-server-renderer/client-plugin插件生成了clientManifest,接下来我们还需要知道,vue在渲染时是如何和这个数据关联起来的?

我们来看vue文件在经过vue-loader编译过程中做了哪些事情。下面是app.vue文件经过vue-loader处理过后的生成内容,有一串字符串引起了我们的注意:00f0587d。这个好像也出现在了clientManifest文件的modules对象中!那这个字符串是怎么来的呢?

import { render, staticRenderFns } from "./app.vue?vue&type=template&id=3546f492&"
import script from "./app.vue?vue&type=script&lang=js&"
export * from "./app.vue?vue&type=script&lang=js&"
function injectStyles (context) {

  var style0 = require("./app.vue?vue&type=style&index=0&lang=scss&")
if (style0.__inject__) style0.__inject__(context)

}

/* normalize component */
import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  injectStyles,
  null,
  "00f0587d"

)

component.options.__file = "apps/app.vue"
export default component.exports

上面代码内容都是由vue-loader生成的,我们继续来分析生成上面代码的代码:

let code = `
${templateImport}
${scriptImport}
${stylesCode}

/* normalize component */
import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
  script,
  render,
  staticRenderFns,
  ${hasFunctional ? `true` : `false`},
  ${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
  ${hasScoped ? JSON.stringify(id) : `null`},
  ${isServer ? JSON.stringify(hash(request)) : `null`}
  ${isShadow ? `,true` : ``}
)
  `.trim() + `\n`

其中normalizer函数的第七个参数就是我们要找的内容,这里的request是webpack是需要编译的模块路径,比如:

/Users/chenfeng/Documents/source/yoho/yoho-community-web/node_modules/vue-loader/lib/index.js??vue-loader-options!/Users/chenfeng/Documents/source/yoho/yoho-community-web/apps/app.vue

这个字段和上面plugin中得到的identifier字段是相同含义。

我们接下来看normalizer接收到moduleIdentifier(在plugins生成的identifier和上面的request经过hash之后我们都把他们叫做moduleIdentifier)后做了哪些事情:

export default function normalizeComponent (
  scriptExports,
  render,
  staticRenderFns,
  functionalTemplate,
  injectStyles,
  scopeId,
  moduleIdentifier, /* server only */
  shadowMode /* vue-cli only */
) {
  // Vue.extend constructor export interop
  var options = typeof scriptExports === 'function'
    ? scriptExports.options
    : scriptExports

  // render functions
  if (render) {
    options.render = render
    options.staticRenderFns = staticRenderFns
    options._compiled = true
  }

  // functional template
  if (functionalTemplate) {
    options.functional = true
  }

  // scopedId
  if (scopeId) {
    options._scopeId = 'data-v-' + scopeId
  }

  var hook
  if (moduleIdentifier) { // server build
    hook = function (context) {
      // 2.3 injection
      context =
        context || // cached call
        (this.$vnode && this.$vnode.ssrContext) || // stateful
        (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext) // functional
      // 2.2 with runInNewContext: true
      if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') {
        context = __VUE_SSR_CONTEXT__
      }
      // inject component styles
      if (injectStyles) {
        injectStyles.call(this, context)
      }
      // register component module identifier for async chunk inferrence
      if (context && context._registeredComponents) {
        context._registeredComponents.add(moduleIdentifier)
      }
    }
    // used by ssr in case component is cached and beforeCreate
    // never gets called
    options._ssrRegister = hook
  } else if (injectStyles) {
    hook = shadowMode
      ? function () { injectStyles.call(this, this.$root.$options.shadowRoot) }
      : injectStyles
  }

  if (hook) {
    if (options.functional) {
      // for template-only hot-reload because in that case the render fn doesn't
      // go through the normalizer
      options._injectStyles = hook
      // register for functioal component in vue file
      var originalRender = options.render
      options.render = function renderWithStyleInjection (h, context) {
        hook.call(context)
        return originalRender(h, context)
      }
    } else {
      // inject component registration as beforeCreate hook
      var existing = options.beforeCreate
      options.beforeCreate = existing
        ? [].concat(existing, hook)
        : [hook]
    }
  }

  return {
    exports: scriptExports,
    options: options
  }
}

传入moduleIdentifier后,定义了hook函数,hook函数的内容很简单,接收用户上下文context参数,最终把moduleIdentifier添加到用户上下文的_registeredComponents数组中。这个hook我们在上面中也提到过,在渲染缓存组件时需要把组件从缓存中取出来,手动调用一次这个hook,因为缓存组件没有普通组件的生命周期钩子。

之后的代码中判断组件是否是函数式组件,如果是函数式组件同样没有生命周期钩子,所以在这里重写了组件的render函数,执行render时先调用hook钩子。

如果是普通组件,则把hook钩子添加到组件的beforeCreated生命周期钩子中。

小结:

  1. 在编译阶段通过插件生成应用的文件和模块moduleIdentifier
  2. 在同一次编译过程中通过vue-loader把moduleIdentifier注入到每个模块的hook钩子中
  3. 在渲染阶段创建组件时调用hook钩子,把每个模块的moduleIdentifier添加到用户上下文context的_registeredComponents数组中
  4. TemplateRenderer在获取依赖文件时读取_registeredComponents根据moduleIdentifier在clientManifest文件的映射关系找到,页面所需要引入的文件。

客户端阶段

当客户端发起了请求,服务端返回渲染结果和css加载完毕后,用户就已经可以看到页面渲染结果了,不用等待js加载和执行。服务端输出的数据有两种,一个是服务端渲染的页面结果,还有一个在服务端需要输出到浏览器的数据状态。

这里的数据状态可能是在服务端组件serverPrefetch钩子产生的数据,也可能是组件创建过程中产生的数据,这些数据需要同步给浏览器,否则会造成两端组件状态不一致。我们一般会使用vuex来存储这些数据状态,并在渲染完成后把vuex的state复制给用户上下文的context.state。

在组装html阶段可以通过renderState生成输出内容,例子:

<script>window.__INITIAL_STATE__={"data": 'xxx'}</script>

当客户端开始执行js时,我们可以通过window全局变量读取到这里的数据状态,并替换到自己的数据状态,这里我们依然用vuex举例:

store.replaceState(window.__INITIAL_STATE__);

之后在我们调用$mount挂载vue对象时,vue会判断mount的dom是否含有data-server-rendered属性,如果有表示该组件已经经过服务端渲染了,并会跳过客户端的渲染阶段,开始执行之后的组件生命周期钩子函数。

之后所有的交互和vue-router不同页面之间的跳转将全部在浏览器端运行。

SSR的几点优化

我认为ssr最棒的一点就是使用一套前端技术开发的同时又解决纯前端开发页面的首屏时间问题。

很多人担心的一点是ssr在服务端跑vue代码,是不是很慢?我想说vue-ssr很快,但它毕竟不是常规的渲染引擎拼接字符串或者静态页面的输出。所以ssr的页面在访问流量比较大时要好好利用缓存(并且尽量使用外部缓存),我相信即使不是ssr的页面如果页面流量大时是不是依然还是需要做缓存?

所以,对于ssr页面优化程度最大的一种方案就是合理利用缓存

当我们的页面内容比较长时我们建议在服务端只渲染首屏的内容,尽量减少不必要的运算。比如列表的场景,我们一页的内容可能是十条,但是用户在一屏的大小中最多只能看到五条,那我们在服务端只渲染五条内容,剩下的内容可以在浏览器端异步渲染。

不要让ssr在服务端执行一些密集cpu的运算,这条同样适用于任何nodejs应用,任何密集cpu的运算都会拖慢整个应用的响应速度。

在服务端调用后端接口或者查询数据库时,尽量把请求超时时间控制在一个合理的范围,因为一旦后端服务大量出现超时异常,减少我们请求的超时时间,及时断开请求将避免服务资源被快速沾满。

合理利用dns-prefetch、preload和prefetch加速页面资源下载速度 ,preload和prefetch在我们配置了template和inject时vue会帮我们自动插入。页面需要引用的资源我们都可以在head中加入:

<link rel="preload[prefetch|dns-prefetch]" href="xxx.js" as="script[style]">

preload:告知浏览器该资源会在当前页面用到,浏览器会在解析dom树的同时非阻塞的下载该资源,在页面解析完成请求该资源时立即返回,并且通过标签的as属性浏览器会给不同类型的资源标识不同的加载优先级,比如css相关的资源会比js和图片的优先级更高。

prefetch:告知浏览器该资源可能会在页面上用到,浏览器会在空闲时机预下载,并不保证一定能预下载。

dns-prefetch:告知浏览器这些域名请帮我们开始dns的解析工作,待页面解析完成加载这些域名的资源时不用再执行dns解析。

更多详情,可以参考: http://www.alloyteam.com/2015/10/prefetching-preloading-prebrowsing/

非阻塞式的脚本加载 这个在我们配置了template和inject后vue也会自动帮我们的script加载脚本加上defer属性,script有两种属性defer和async:

无属性:在dom解析阶段开始下载,并且阻塞dom解析,下载完成之后再恢复dom解析。

defer:在dom解析阶段开始下载js,不阻塞dom解析,并在dom解析渲染完成之后再执行。

async:在dom解析阶段开始下载js,不阻塞dom解析,在下载完成之后立即执行,如果dom正在解析则阻塞住。

显然defer会让页面更快的呈现。

具体可参考:https://www.growingwiththeweb.com/2014/02/async-vs-defer-attributes.html

合理定义组件边界不要定义不必要的组件,组件的粒度要把控好,太细粒度的组件定义没有意义。

相关文章

网友评论

    本文标题:Vue SSR深度剖析

    本文链接:https://www.haomeiwen.com/subject/tqstiqtx.html