Vue SSR深度剖析

介紹

vue-ssr相信大部分前端開(kāi)發(fā)都聽(tīng)說(shuō)過(guò),或許自己也嘗試搭建過(guò)一些小demo,但真正運(yùn)用到項(xiàng)目中的不多。本文將從什么是ssr、ssr是如何運(yùn)作的以及ssr項(xiàng)目的優(yōu)化方向等這幾個(gè)方面給大家詳細(xì)介紹下vue-ssr。

閱讀此文章需要對(duì)vue、vue-ssr有一定基礎(chǔ),并且默認(rèn)讀者使用webpack對(duì)vue應(yīng)用打包。

本文會(huì)涉及到vue-server-renderervue-loader的相關(guān)源碼解析,建議閱讀的同時(shí)對(duì)照庫(kù)的源碼,以便更容易理解。

什么是vue-ssr

ssr是Server-Side Rendering的簡(jiǎn)寫(xiě),即由服務(wù)端負(fù)責(zé)渲染頁(yè)面直出,亦即同構(gòu)應(yīng)用。程序的大部分代碼都可以在服務(wù)端和客戶(hù)端運(yùn)行。在服務(wù)端vue組件渲染為html字符串,在客戶(hù)端生成dom和操作dom。

能在服務(wù)端渲染為html字符串得益于vue組件結(jié)構(gòu)是基于vnode的。vnode是dom的抽象表達(dá),它不是真實(shí)的dom,它是由js對(duì)象組成的樹(shù),每個(gè)節(jié)點(diǎn)代表了一個(gè)dom。因?yàn)関node所以在服務(wù)端vue可以把js對(duì)象解析為html字符串。同樣在客戶(hù)端vnode因?yàn)槭谴嬖趦?nèi)存之中的,操作內(nèi)存總比操作dom快的多,每次數(shù)據(jù)變化需要更新dom時(shí),新舊vnode樹(shù)經(jīng)過(guò)diff算法,計(jì)算出最小變化集,大大提高了性能。

ssr的主要優(yōu)勢(shì)在于更好的SEO和更快的到達(dá)時(shí)間,服務(wù)端返回的內(nèi)容是具有信息內(nèi)容的html文檔,這對(duì)搜索引擎的爬蟲(chóng)是友好的。用戶(hù)在弱網(wǎng)情況下也無(wú)需等待js加載完成才能開(kāi)始渲染頁(yè)面,可以更加快速的看到完整的內(nèi)容。

當(dāng)然ssr也有他的問(wèn)題,開(kāi)發(fā)ssr的項(xiàng)目需要更好的區(qū)分哪些代碼能在服務(wù)端運(yùn)行,哪些代碼只能在客戶(hù)端運(yùn)行,比如:window、document這些就不能出現(xiàn)在初始化代碼和服務(wù)端的一些鉤子函數(shù)中,我們需要寫(xiě)出更加通用的代碼以保證在兩端都可以正常的解析和運(yùn)行。另外ssr項(xiàng)目在node中渲染頁(yè)面顯然要比大部分動(dòng)態(tài)網(wǎng)站要消耗更多的cpu資源,如果項(xiàng)目是需要在高流量環(huán)境中使用,則需要準(zhǔn)備更多的服務(wù)器負(fù)載和更好的緩存策略。

祭出官方提供的架構(gòu)圖 :

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

SSR是如何運(yùn)作的

根據(jù)應(yīng)用的觸發(fā)時(shí)機(jī)我們分成以下幾個(gè)步驟詳細(xì)講解:

編譯階段

vue-ssr是同構(gòu)框架,即我們開(kāi)發(fā)的同一份代碼會(huì)被運(yùn)行在服務(wù)端和客戶(hù)端兩個(gè)環(huán)境中。所以我們的代碼需要更加偏向于通用,但畢竟環(huán)境的差異導(dǎo)致很多特定代碼無(wú)法兼容,比如:vue的dom掛載、一些運(yùn)行于客戶(hù)端的第三方庫(kù)等等。vue-ssr提供的方式是配置兩個(gè)入口文件(entry-client.js、entry-server.js),通過(guò)webpack把你的代碼編譯成兩個(gè)bundle。

兩個(gè)入口的編譯方式可以很方便的做兩個(gè)環(huán)境的差異化代碼抹平:

  1. 在客戶(hù)端入口中vue實(shí)例化之后執(zhí)行掛載dom的代碼,服務(wù)端入口的vue則只需要生成vue對(duì)象即可 。

  2. 一些不兼容ssr的第三方庫(kù)或者代碼片段,我們可以只在客戶(hù)端入口中加載 。

  3. 即使通用代碼我們也可以通過(guò)打包工具做到兩個(gè)運(yùn)行環(huán)境的差異化。比如最常見(jiàn)的在應(yīng)用中發(fā)起請(qǐng)求時(shí),在客戶(hù)端我們經(jīng)常使用axios來(lái)發(fā)起請(qǐng)求,在服務(wù)端雖然也兼容axios,但是服務(wù)端發(fā)起的請(qǐng)求并不需要和客戶(hù)端一樣走外網(wǎng)請(qǐng)求,服務(wù)端的接口網(wǎng)關(guān)或者鑒權(quán)方式和客戶(hù)端也不一定相同。這種情況我們可以通過(guò)webpack的resolve.alias配置實(shí)現(xiàn)兩個(gè)環(huán)境引用不同模塊。

  4. 在服務(wù)端的代碼我們不需要做code split,甚至我們項(xiàng)目中所有引入的依賴(lài)庫(kù),也并不需要打包到bundle中。因?yàn)樵趎ode運(yùn)行環(huán)境中,我們的依賴(lài)庫(kù)都可以通過(guò)require在運(yùn)行時(shí)加載進(jìn)來(lái)。

通過(guò)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

許多靜態(tài)資源...

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

{ 
  "publicPath": "http://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中包含了所有要在服務(wù)端運(yùn)行的代碼列表,和一個(gè)入口文件名。

Client Bundle包含了所有需要在客戶(hù)端運(yùn)行的腳本和靜態(tài)資源,如:js、css圖片、字體等。還有一份clientManifest文件清單,清單中initial數(shù)組中的js將會(huì)在ssr輸出時(shí)插入到html字符串中作為preload和script腳本引用。asyncmodules將配合檢索出異步組件和異步依賴(lài)庫(kù)的js文件的引入,在輸出階段我們會(huì)詳細(xì)解讀。

初始化階段

ssr應(yīng)用會(huì)在node啟動(dòng)時(shí)初始化一個(gè)renderer單例對(duì)象,renderer對(duì)象由vue-server-renderer庫(kù)的createBundleRenderer函數(shù)創(chuàng)建,函數(shù)接受兩個(gè)參數(shù),serverBundle內(nèi)容和options配置

在options中我們需要傳入clientManifest內(nèi)容,其他的參數(shù)我們會(huì)在后續(xù)階段講解。

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

初始化完成,當(dāng)用戶(hù)發(fā)起請(qǐng)求時(shí),renderer.renderToString或者renderer.renderToStream函數(shù)將完成vue組件到html的過(guò)程。

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

createBundleRenderer函數(shù)在初始化階段主要做了3件事情:

1. 創(chuàng)建將vue對(duì)象解析為html的渲染函數(shù)的單例對(duì)象

var renderer = createRenderer(rendererOptions); 

在createRenderer函數(shù)中創(chuàng)建了兩個(gè)對(duì)象:rendertemplateRenderer,他們分別負(fù)責(zé)vue組件的渲染和html的組裝,在之后的階段我們?cè)敿?xì)講解。

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

2. 創(chuàng)建nodejs的vm沙盒,并返回了run函數(shù)作為每次實(shí)例化vue組件的入口函數(shù)

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

這里的entry和files參數(shù)是vue-ssr-server-bundle.json中的entry和files字段,分別是應(yīng)用的入口文件名和打包的文件內(nèi)容集合。

runInNewContext是可選的沙盒運(yùn)行配置:

  1. true,每次創(chuàng)建vue實(shí)例時(shí)都創(chuàng)建一個(gè)全新的v8上下文環(huán)境并重新執(zhí)行bundle代碼,好處是每次渲染的環(huán)境狀態(tài)是隔離的,不存在狀態(tài)單例問(wèn)題,也不存在狀態(tài)污染問(wèn)題。但是,缺點(diǎn)是每次創(chuàng)建v8上下文的性能代價(jià)很高。
  2. false,創(chuàng)建在當(dāng)前global運(yùn)行上下文中運(yùn)行的bundle代碼環(huán)境,bundle代碼將可以獲取到當(dāng)前運(yùn)行環(huán)境的global對(duì)象,運(yùn)行環(huán)境是單例的
  3. once ,會(huì)在初始化時(shí)單例創(chuàng)建與global隔離的運(yùn)行上下文

當(dāng)runInNewContext設(shè)置為false或者once時(shí),在初始化之后的用戶(hù)每次請(qǐng)求將會(huì)在同一個(gè)沙盒環(huán)境中運(yùn)行,所以在實(shí)例化vue實(shí)例或者一些狀態(tài)存儲(chǔ)必須通過(guò)閉包創(chuàng)建獨(dú)立的作用域才不會(huì)被不同請(qǐng)求產(chǎn)生的數(shù)據(jù)相互污染,舉個(gè)例子:

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

  return {app};
}

在createBundleRunner函數(shù)中有非常重要的兩個(gè)函數(shù)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執(zhí)行時(shí)調(diào)用evaluateModule并傳入serverBundle中的應(yīng)用入口文件名entry和沙盒執(zhí)行上下文。

之后調(diào)用getCompiledScript,通過(guò)入口文件名在files文件內(nèi)容集合中找到入口文件內(nèi)容的code,code內(nèi)容大致如下,內(nèi)容是由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__) {
    //...
  })
})

上面代碼是把你一個(gè)立即執(zhí)行函數(shù)賦值給module.exports,而立即執(zhí)行函數(shù)的結(jié)果返回了入口模塊:

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

這里的ry7I是webpack打包時(shí)模塊的moduleId,根據(jù)ry7I我們可以找到:

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

這里的入口模塊就是我們服務(wù)端entry-server.js的內(nèi)容。為了方便理解我們可以把入口文件簡(jiǎn)單理解為以下內(nèi)容:

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

這只是一段賦值代碼,如果在vm執(zhí)行它的話(huà)并沒(méi)有任何返回值,我們也拿不到入口函數(shù),所以在vm中執(zhí)行前,我們需要把這段代碼內(nèi)容用NativeModule.wrap(code)包裹一下,NativeModule就是nodejs的module模塊,wrap函數(shù)只做了一次簡(jiǎn)單的包裹。

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函數(shù),通過(guò)new vm.Script編譯wrapper之后并返回給evaluateModule,接下來(lái)根據(jù)runInNewContext的配置來(lái)決定是在當(dāng)前上下文中執(zhí)行還是在單獨(dú)的上下文中執(zhí)行,并將執(zhí)行結(jié)果返回(我看上面的code,執(zhí)行結(jié)果其實(shí)就是返回了一個(gè)函數(shù))。

接下來(lái)執(zhí)行compiledWrapper.call(m.exports, m.exports, r, m);,傳入的參數(shù)分別對(duì)應(yīng)上面函數(shù)中:exports、require、module,這樣我們就通過(guò)傳入的m對(duì)象引用拿到了入口函數(shù)。另外傳入r函數(shù)是為了替代原生require用來(lái)解析bundle中通過(guò)require函數(shù)引用的其他模塊。

這一步通過(guò)createBundleRunner函數(shù)創(chuàng)建的run,在用戶(hù)發(fā)起請(qǐng)求時(shí)每次調(diào)用都會(huì)通過(guò)入口函數(shù)實(shí)例化一個(gè)完整的vue對(duì)象。

3.返回renderToStringrenderToStream函數(shù)

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的文檔沒(méi)有提到,但是根據(jù)這部分的代碼,renderToString如果在沒(méi)執(zhí)行cb回調(diào)函數(shù)的情況下是返回一個(gè)Promise對(duì)象的,這里很巧妙的利用createPromiseCallback創(chuàng)建了一個(gè)promise并導(dǎo)出了它的resolve和reject實(shí)現(xiàn)了和cb回調(diào)的兼容邏輯,所以我們同樣也可以這樣使用renderToString:

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

小結(jié):

  1. 獲取到serverBundle的入口文件代碼并解析為入口函數(shù),每次執(zhí)行實(shí)例化vue對(duì)象
  2. 實(shí)例化了render和templateRenderer對(duì)象,負(fù)責(zé)渲染vue組件和組裝html

渲染階段

當(dāng)用戶(hù)請(qǐng)求達(dá)到node端時(shí),調(diào)用bundleRenderer.renderToString函數(shù)并傳入用戶(hù)上下文context,context對(duì)象可以包含一些服務(wù)端的信息,比如:url、ua等等,也可以包含一些用戶(hù)信息,context對(duì)象內(nèi)容(除了context.state和模板中的占位字段)并不會(huì)被輸出到前端:

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

上一個(gè)階段在createBundleRenderer函數(shù)中創(chuàng)建了renderer和run,執(zhí)行bundleRenderer.renderToString時(shí)會(huì)先調(diào)用run創(chuàng)建vue的對(duì)象實(shí)例,然后調(diào)用把vue實(shí)例傳給renderer.renderToString函數(shù)。

這個(gè)時(shí)候如果使用了vue-router庫(kù),則在創(chuàng)建vue實(shí)例時(shí),調(diào)用router.push(url)后router開(kāi)始導(dǎo)航,router負(fù)責(zé)根據(jù)url匹配對(duì)應(yīng)的vue組件并實(shí)例化他們,最后在router.onReady回調(diào)函數(shù)中返回整個(gè)vue實(shí)例。

我們接下來(lái)看下在這個(gè)函數(shù)中做了哪些事情。

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
}

在初始化階段第一步創(chuàng)建的templateRenderer,它負(fù)責(zé)html的組裝,它的主要原型方法有:bindRenderFnsrenderrenderStylesrenderResourceHintsrenderStaterenderScripts

其中renderStylesrenderResourceHintsrenderStaterenderScripts分別是生成頁(yè)面需要加載的樣式、preload和prefetch資源、頁(yè)面state(比如vuex的狀態(tài)state,需要在服務(wù)端給context.state賦值才能輸出)、腳本文件引用的內(nèi)容。

在上面代碼中執(zhí)行的templateRenderer.bindRenderFns則是把這四個(gè)render函數(shù)綁定到用戶(hù)上下文context中,以便用戶(hù)可以拿到這些內(nèi)容做自定義的組裝或者渲染。

接下來(lái)創(chuàng)建了var write = createWriteFunction寫(xiě)函數(shù),主要負(fù)責(zé)每個(gè)組件渲染完成之后返回html內(nèi)容時(shí)的拼接。

之后調(diào)用了createRenderFunction 創(chuàng)建的render函數(shù),傳入vue對(duì)象實(shí)例、寫(xiě)函數(shù)、用戶(hù)上下文context和渲染完成之后的done回調(diào)。

render函數(shù):

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);
}

在這個(gè)函數(shù)中組件將被按照從父到子的遞歸順序,把vue組件渲染為html。

第一步,創(chuàng)建RenderContext 渲染上下文對(duì)象,這個(gè)對(duì)象將貫穿整個(gè)遞歸過(guò)程,它主要負(fù)責(zé)在遞歸過(guò)程中閉合組件標(biāo)簽和渲染可緩存組件的存儲(chǔ)工作。

第二步,執(zhí)行installSSRHelpersnormalizeRender這兩行主要是針對(duì)在組件中使用字符串template模板的組件的編譯工作,在執(zhí)行normalizeRender時(shí)vue會(huì)將字符串模板解析語(yǔ)法樹(shù),然后轉(zhuǎn)成render函數(shù)。而installSSRHelpers是在解析之前安裝一些在ssr中生成vnode的幫助函數(shù),一個(gè)簡(jiǎn)單的template解析為render的例子:

template:

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

render:

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

雖然vue在解析html時(shí)已經(jīng)做了很多優(yōu)化,比如:上面的__ssrNode函數(shù),它不再生成vnode而是生成StringNode這樣的簡(jiǎn)單節(jié)點(diǎn),在后續(xù)渲染時(shí)直接拼接字符串即可。但是畢竟還是要解析一次html的語(yǔ)法樹(shù),所以我們通常開(kāi)發(fā)vue項(xiàng)目時(shí)使用vue-loader把template解析為render函數(shù)或者直接用jsx語(yǔ)法,甚至createElement函數(shù)。而在vue-server-renderer庫(kù)中缺有大量只是針對(duì)template字符串模板的解析和優(yōu)化的代碼,所以盡量避免使用template字符串模板。

第三步,執(zhí)行waitForServerPrefetch,在waitForServerPrefetch函數(shù)中,會(huì)檢查組件是否定義了serverPrefetch鉤子(vue@2.6.0+新增api,代替了以前asyncData的兼容方案),如果定義了,則等待鉤子執(zhí)行完畢后才繼續(xù)resolve回調(diào)。

在回調(diào)中component._render返回的是該vue組件的vnode,傳遞給renderNode函數(shù)遞歸解析。(ps.大家可以看到,雖然serverPrefetch這個(gè)api在官方文檔中說(shuō)明是一個(gè)返回promise的function類(lèi)型,但根據(jù)源碼看,它也可以被定義為一個(gè)返回promise的function類(lèi)型的數(shù)組)

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();
}

第四步,這一步開(kāi)始執(zhí)行renderNode,根據(jù)不同的vnode類(lèi)型執(zhí)行不同的render函數(shù),六種不同類(lèi)型的節(jié)點(diǎn)渲染方法,我們主要對(duì)renderStringNode$1renderComponentrenderElementrenderAsyncComponent這四個(gè)主要渲染函數(shù)做個(gè)分析:

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,負(fù)責(zé)處理通過(guò)vue編譯template字符串模板生成的StringNode簡(jiǎn)單節(jié)點(diǎn)的渲染工作,如果沒(méi)有子節(jié)點(diǎn)則直接調(diào)用寫(xiě)函數(shù),其中el.open和el.close是節(jié)點(diǎn)開(kāi)始和閉合標(biāo)簽。如果有子節(jié)點(diǎn)則把子節(jié)點(diǎn)添加到渲染上下文的renderStates數(shù)組中,寫(xiě)入開(kāi)始標(biāo)簽并傳入渲染上下文的next函數(shù),寫(xiě)函數(shù)在拼接完成后調(diào)用next,在渲染上下文的next函數(shù)中繼續(xù)解析該節(jié)點(diǎn)的子節(jié)點(diǎn),并且在解析這個(gè)節(jié)點(diǎn)樹(shù)之后寫(xiě)入閉合標(biāo)簽:

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,負(fù)責(zé)處理vue的組件類(lèi)型的節(jié)點(diǎn),如果組件設(shè)置了serverCacheKey并且緩存中存在該key的渲染結(jié)果,則直接寫(xiě)入緩存的html結(jié)果。在寫(xiě)入html之前我們看到代碼中循環(huán)調(diào)用了res.components并且傳入了用戶(hù)上下文userContext,循環(huán)調(diào)用的函數(shù)其實(shí)是在vue-loader注入的一個(gè)hook。這個(gè)hook會(huì)在執(zhí)行時(shí)把當(dāng)前這個(gè)組件的moduleIdentifier(webpack中編譯時(shí)生成的模塊標(biāo)識(shí))添加到用戶(hù)上下文userContext的_registeredComponents數(shù)組中,vue會(huì)通過(guò)這個(gè)數(shù)組查找組件的引用資源文件。

如果沒(méi)有命中緩存或者根本就沒(méi)有緩存,則分別執(zhí)行:renderComponentWithCacherenderComponentInner,這兩個(gè)函數(shù)的區(qū)別是renderComponentWithCache會(huì)在組件渲染完成時(shí),通過(guò)渲染上下文把結(jié)果寫(xiě)入緩存。

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函數(shù)中通過(guò)vnode創(chuàng)建組件對(duì)象,等待組件的serverPrefetch鉤子執(zhí)行完成之后,調(diào)用組件對(duì)象的_render生成子節(jié)點(diǎn)的vnode后再渲染。(ps. 這里我們可以看出,serverPrefetch鉤子中獲取的數(shù)據(jù)只會(huì)被渲染到當(dāng)前組件或者子組件中,因?yàn)樵趫?zhí)行這個(gè)組件的serverPrefetch之前父組件已經(jīng)被渲染完成了。)

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渲染函數(shù),負(fù)責(zé)渲染dom組件。函數(shù)內(nèi)部調(diào)用了renderStartingTag,這個(gè)函數(shù)處理自定義指令、show指令和組件的scoped CSS ID生成還有給標(biāo)簽加上data-server-rendered屬性(表示這是經(jīng)過(guò)服務(wù)端渲染的標(biāo)簽),最后組裝好dom的開(kāi)始標(biāo)簽startTag。

如果組件是自閉合標(biāo)簽或者沒(méi)有子節(jié)點(diǎn),則直接寫(xiě)入標(biāo)簽節(jié)點(diǎn)內(nèi)容。否則通過(guò)渲染上下文在渲染子節(jié)點(diǎn)后再寫(xiě)入結(jié)束標(biāo)簽。

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負(fù)責(zé)針對(duì)異步函數(shù)的加載和解析,vnode的asyncFactory是加載函數(shù),因?yàn)槲覀兊膕erverBundle已經(jīng)包含所有腳本包含異步腳本了,所以在這一步的asyncFactory幾乎就相當(dāng)于一次Promise.resolve返回異步模塊,不發(fā)起任何請(qǐng)求。拿到組件內(nèi)容后創(chuàng)建vnode節(jié)點(diǎn),調(diào)用renderComponent、renderNode。如果函數(shù)式組件的話(huà)可能返回多個(gè)vnode,直接通過(guò)渲染上下文渲染。

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);
      }
    }
  }
}

渲染函數(shù)已經(jīng)介紹完畢,所有vnode都要經(jīng)歷這些函數(shù)渲染,當(dāng)最后一個(gè)組件調(diào)用寫(xiě)函數(shù),并執(zhí)行渲染上下文的next時(shí)結(jié)束渲染工作,調(diào)用渲染上下文的done函數(shù),也就是回到下面的回調(diào)函數(shù)。

如果用戶(hù)上下文context定義了rendered鉤子的話(huà),觸發(fā)這個(gè)鉤子(這個(gè)鉤子在vue@2.6.0新增的)。

result變量就是不斷通過(guò)調(diào)用寫(xiě)函數(shù)拼接的組件渲染結(jié)果。

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);
  }
});

如果沒(méi)有定義tempate則vue在服務(wù)端的工作已經(jīng)結(jié)束了。我們將在下一階段分析當(dāng)定義了template時(shí)templateRenderer對(duì)象在輸出階段如何拼接html和找到組件所依賴(lài)的腳本文件。

小結(jié):

  1. 用戶(hù)發(fā)起請(qǐng)求時(shí),通過(guò)執(zhí)行serverBundle后得到的應(yīng)用入口函數(shù),實(shí)例化vue對(duì)象。
  2. renderer對(duì)象負(fù)責(zé)把vue對(duì)象遞歸轉(zhuǎn)為vnode,并把vnode根據(jù)不同node類(lèi)型調(diào)用不同渲染函數(shù)最終組裝為html。
  3. 在渲染組件的過(guò)程中如果組件定義了serverPrefetch鉤子,則等待serverPrefetch執(zhí)行完成之后再渲染頁(yè)面(serverPrefetch生成的數(shù)據(jù)不會(huì)應(yīng)用于父組件)

內(nèi)容輸出階段

在上一個(gè)階段我們已經(jīng)拿到了vue組件渲染結(jié)果,它是一個(gè)html字符串,在瀏覽器中展示頁(yè)面我們還需要css、js等依賴(lài)資源的引入標(biāo)簽和我們?cè)诜?wù)端的渲染數(shù)據(jù),這些最終組裝成一個(gè)完整的html報(bào)文輸出到瀏覽器中。

這里vue提供了兩種選項(xiàng):

沒(méi)有定義template模板,在上面代碼中我們看到,如果用戶(hù)沒(méi)有配置template的情況下,渲染結(jié)果會(huì)被直接返回給renderToString的回調(diào)函數(shù),而頁(yè)面所需要的腳本依賴(lài)我們通過(guò)用戶(hù)上下文context的renderStylesrenderResourceHintsrenderStaterenderScripts這些函數(shù)分別獲得(因?yàn)閏ontext在開(kāi)始渲染之前就已經(jīng)被templateRenderer.bindRenderFns(context)注入這些函數(shù)了)。

接下來(lái)我們可以用我們自己熟悉的模板引擎來(lái)渲染出最終的html報(bào)文,這里用hbs舉個(gè)例子:

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情況下,在創(chuàng)建TemplateRenderer實(shí)例的構(gòu)造函數(shù)中,會(huì)對(duì)提供的template字符串做一個(gè)解析。解析的規(guī)則很簡(jiǎn)單,把模板分為三個(gè)部分:html文檔開(kāi)頭到</head>標(biāo)簽是head部分,head之后到內(nèi)容占位符是neck部分,最后tail部分是內(nèi)容占位符到最后。

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函數(shù)是lodash.template的引用,利用lodash.template函數(shù)將這三個(gè)字符串包裝為一個(gè)渲染函數(shù),我們可以在template模板中自定義一些占位符,然后通過(guò)用戶(hù)上下文context上面的數(shù)據(jù)渲染。

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)中介紹了,如何通過(guò)將數(shù)據(jù)綁定到用戶(hù)上下文context上,然后在模板中將這些數(shù)據(jù)渲染。其實(shí)不僅在head中支持自定義渲染,同樣necttail部分都支持這么做。

接下來(lái)我們看TemplateRenderer如何幫我們做html組裝的,this.parsedTemplate就是在構(gòu)造函數(shù)中通過(guò)上面的解析函數(shù)得到的包含三個(gè)部分的compile對(duì)象,接下來(lái)只需要把準(zhǔn)備好的各個(gè)部分按照順序拼接就好了,如果設(shè)置了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的流程已經(jīng)講完,但是還是有很多人疑惑,如果我的項(xiàng)目是做了code splits代碼是分割的,甚至還有一些異步組件,vue執(zhí)行的serverBundle代碼是如何通過(guò)clientManifest找到頁(yè)面依賴(lài)的js和css呢?

在文檔開(kāi)頭的編譯階段我們介紹了clientManifest文件結(jié)構(gòu),其中:

all 數(shù)組是編譯工具打包的所有文件的集合

initial 數(shù)組是入口文件和在入口文件中引用的其他非異步依賴(lài)模塊的文件集合

**async **則是所有異步文件的集合。

**modules **對(duì)象是moduleIdentifier和和all數(shù)組中文件的映射關(guān)系(modules對(duì)象是我們查找文件引用的重要數(shù)據(jù))。

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

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

假設(shè)我們現(xiàn)在有一個(gè)簡(jiǎn)單的vue應(yīng)用,其中有一個(gè)app.vue文件,并引用了一個(gè)異步組件,生成了下面的clientManifest文件:

{ 
  "publicPath": "http://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 ] 
    ... 
    } 
} 

通過(guò)配置的plugin我們知道clientmanifest是由vue-server-renderer/client-plugin生成的,我們來(lái)看下它在編譯時(shí)做了哪些事情,我們可以下下面的代碼:

在webpack中,編譯時(shí)compilation對(duì)象可以獲得打包資源模塊和文件(關(guān)于webpack詳細(xì)解讀可以參考這篇文章: https://segmentfault.com/a/1190000015088834)。

all、initial、async都可以通過(guò)stats.assets和stats.entrypoints獲得。

modules通過(guò)stats.modules獲得,modules的key是根據(jù)identifier生成的,對(duì)應(yīng)的依賴(lài)文件列表則可以通過(guò)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

我們通過(guò)vue-server-renderer/client-plugin插件生成了clientManifest,接下來(lái)我們還需要知道,vue在渲染時(shí)是如何和這個(gè)數(shù)據(jù)關(guān)聯(lián)起來(lái)的?

我們來(lái)看vue文件在經(jīng)過(guò)vue-loader編譯過(guò)程中做了哪些事情。下面是app.vue文件經(jīng)過(guò)vue-loader處理過(guò)后的生成內(nèi)容,有一串字符串引起了我們的注意:00f0587d。這個(gè)好像也出現(xiàn)在了clientManifest文件的modules對(duì)象中!那這個(gè)字符串是怎么來(lái)的呢?

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

上面代碼內(nèi)容都是由vue-loader生成的,我們繼續(xù)來(lái)分析生成上面代碼的代碼:

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函數(shù)的第七個(gè)參數(shù)就是我們要找的內(nèi)容,這里的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

這個(gè)字段和上面plugin中得到的identifier字段是相同含義。

我們接下來(lái)看normalizer接收到moduleIdentifier(在plugins生成的identifier和上面的request經(jīng)過(guò)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函數(shù),hook函數(shù)的內(nèi)容很簡(jiǎn)單,接收用戶(hù)上下文context參數(shù),最終把moduleIdentifier添加到用戶(hù)上下文的_registeredComponents數(shù)組中。這個(gè)hook我們?cè)谏厦嬷幸蔡岬竭^(guò),在渲染緩存組件時(shí)需要把組件從緩存中取出來(lái),手動(dòng)調(diào)用一次這個(gè)hook,因?yàn)榫彺娼M件沒(méi)有普通組件的生命周期鉤子。

之后的代碼中判斷組件是否是函數(shù)式組件,如果是函數(shù)式組件同樣沒(méi)有生命周期鉤子,所以在這里重寫(xiě)了組件的render函數(shù),執(zhí)行render時(shí)先調(diào)用hook鉤子。

如果是普通組件,則把hook鉤子添加到組件的beforeCreated生命周期鉤子中。

小結(jié):

  1. 在編譯階段通過(guò)插件生成應(yīng)用的文件和模塊moduleIdentifier
  2. 在同一次編譯過(guò)程中通過(guò)vue-loader把moduleIdentifier注入到每個(gè)模塊的hook鉤子中
  3. 在渲染階段創(chuàng)建組件時(shí)調(diào)用hook鉤子,把每個(gè)模塊的moduleIdentifier添加到用戶(hù)上下文context的_registeredComponents數(shù)組中
  4. TemplateRenderer在獲取依賴(lài)文件時(shí)讀取_registeredComponents根據(jù)moduleIdentifier在clientManifest文件的映射關(guān)系找到,頁(yè)面所需要引入的文件。

客戶(hù)端階段

當(dāng)客戶(hù)端發(fā)起了請(qǐng)求,服務(wù)端返回渲染結(jié)果和css加載完畢后,用戶(hù)就已經(jīng)可以看到頁(yè)面渲染結(jié)果了,不用等待js加載和執(zhí)行。服務(wù)端輸出的數(shù)據(jù)有兩種,一個(gè)是服務(wù)端渲染的頁(yè)面結(jié)果,還有一個(gè)在服務(wù)端需要輸出到瀏覽器的數(shù)據(jù)狀態(tài)。

這里的數(shù)據(jù)狀態(tài)可能是在服務(wù)端組件serverPrefetch鉤子產(chǎn)生的數(shù)據(jù),也可能是組件創(chuàng)建過(guò)程中產(chǎn)生的數(shù)據(jù),這些數(shù)據(jù)需要同步給瀏覽器,否則會(huì)造成兩端組件狀態(tài)不一致。我們一般會(huì)使用vuex來(lái)存儲(chǔ)這些數(shù)據(jù)狀態(tài),并在渲染完成后把vuex的state復(fù)制給用戶(hù)上下文的context.state。

在組裝html階段可以通過(guò)renderState生成輸出內(nèi)容,例子:

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

當(dāng)客戶(hù)端開(kāi)始執(zhí)行js時(shí),我們可以通過(guò)window全局變量讀取到這里的數(shù)據(jù)狀態(tài),并替換到自己的數(shù)據(jù)狀態(tài),這里我們依然用vuex舉例:

store.replaceState(window.__INITIAL_STATE__);

之后在我們調(diào)用$mount掛載vue對(duì)象時(shí),vue會(huì)判斷mount的dom是否含有data-server-rendered屬性,如果有表示該組件已經(jīng)經(jīng)過(guò)服務(wù)端渲染了,并會(huì)跳過(guò)客戶(hù)端的渲染階段,開(kāi)始執(zhí)行之后的組件生命周期鉤子函數(shù)。

之后所有的交互和vue-router不同頁(yè)面之間的跳轉(zhuǎn)將全部在瀏覽器端運(yùn)行。

SSR的幾點(diǎn)優(yōu)化

我認(rèn)為ssr最棒的一點(diǎn)就是使用一套前端技術(shù)開(kāi)發(fā)的同時(shí)又解決純前端開(kāi)發(fā)頁(yè)面的首屏?xí)r間問(wèn)題。

很多人擔(dān)心的一點(diǎn)是ssr在服務(wù)端跑vue代碼,是不是很慢?我想說(shuō)vue-ssr很快,但它畢竟不是常規(guī)的渲染引擎拼接字符串或者靜態(tài)頁(yè)面的輸出。所以ssr的頁(yè)面在訪(fǎng)問(wèn)流量比較大時(shí)要好好利用緩存(并且盡量使用外部緩存),我相信即使不是ssr的頁(yè)面如果頁(yè)面流量大時(shí)是不是依然還是需要做緩存?

所以,對(duì)于ssr頁(yè)面優(yōu)化程度最大的一種方案就是合理利用緩存

當(dāng)我們的頁(yè)面內(nèi)容比較長(zhǎng)時(shí)我們建議在服務(wù)端只渲染首屏的內(nèi)容,盡量減少不必要的運(yùn)算。比如列表的場(chǎng)景,我們一頁(yè)的內(nèi)容可能是十條,但是用戶(hù)在一屏的大小中最多只能看到五條,那我們?cè)诜?wù)端只渲染五條內(nèi)容,剩下的內(nèi)容可以在瀏覽器端異步渲染。

不要讓ssr在服務(wù)端執(zhí)行一些密集cpu的運(yùn)算,這條同樣適用于任何nodejs應(yīng)用,任何密集cpu的運(yùn)算都會(huì)拖慢整個(gè)應(yīng)用的響應(yīng)速度。

在服務(wù)端調(diào)用后端接口或者查詢(xún)數(shù)據(jù)庫(kù)時(shí),盡量把請(qǐng)求超時(shí)時(shí)間控制在一個(gè)合理的范圍,因?yàn)橐坏┖蠖朔?wù)大量出現(xiàn)超時(shí)異常,減少我們請(qǐng)求的超時(shí)時(shí)間,及時(shí)斷開(kāi)請(qǐng)求將避免服務(wù)資源被快速沾滿(mǎn)。

合理利用dns-prefetch、preload和prefetch加速頁(yè)面資源下載速度 ,preload和prefetch在我們配置了template和inject時(shí)vue會(huì)幫我們自動(dòng)插入。頁(yè)面需要引用的資源我們都可以在head中加入:

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

preload:告知瀏覽器該資源會(huì)在當(dāng)前頁(yè)面用到,瀏覽器會(huì)在解析dom樹(shù)的同時(shí)非阻塞的下載該資源,在頁(yè)面解析完成請(qǐng)求該資源時(shí)立即返回,并且通過(guò)標(biāo)簽的as屬性瀏覽器會(huì)給不同類(lèi)型的資源標(biāo)識(shí)不同的加載優(yōu)先級(jí),比如css相關(guān)的資源會(huì)比js和圖片的優(yōu)先級(jí)更高。

prefetch:告知瀏覽器該資源可能會(huì)在頁(yè)面上用到,瀏覽器會(huì)在空閑時(shí)機(jī)預(yù)下載,并不保證一定能預(yù)下載。

dns-prefetch:告知瀏覽器這些域名請(qǐng)幫我們開(kāi)始dns的解析工作,待頁(yè)面解析完成加載這些域名的資源時(shí)不用再執(zhí)行dns解析。

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

非阻塞式的腳本加載 這個(gè)在我們配置了template和inject后vue也會(huì)自動(dòng)幫我們的script加載腳本加上defer屬性,script有兩種屬性defer和async:

無(wú)屬性:在dom解析階段開(kāi)始下載,并且阻塞dom解析,下載完成之后再恢復(fù)dom解析。

defer:在dom解析階段開(kāi)始下載js,不阻塞dom解析,并在dom解析渲染完成之后再執(zhí)行。

async:在dom解析階段開(kāi)始下載js,不阻塞dom解析,在下載完成之后立即執(zhí)行,如果dom正在解析則阻塞住。

顯然defer會(huì)讓頁(yè)面更快的呈現(xiàn)。

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

合理定義組件邊界不要定義不必要的組件,組件的粒度要把控好,太細(xì)粒度的組件定義沒(méi)有意義。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 回憶 首先,render函數(shù)中手寫(xiě)h=>h(app),new Vue()實(shí)例初始化init()和原來(lái)一樣。$mou...
    LoveBugs_King閱讀 2,303評(píng)論 1 2
  • 這篇筆記主要包含 Vue 2 不同于 Vue 1 或者特有的內(nèi)容,還有我對(duì)于 Vue 1.0 印象不深的內(nèi)容。關(guān)于...
    云之外閱讀 5,075評(píng)論 0 29
  • vue2.0和1.0模板渲染的區(qū)別 Vue 2.0 中模板渲染與 Vue 1.0 完全不同,1.0 中采用的 Do...
    我是上帝可愛(ài)多閱讀 1,314評(píng)論 0 4
  • ## 框架和庫(kù)的區(qū)別?> 框架(framework):一套完整的軟件設(shè)計(jì)架構(gòu)和**解決方案**。> > 庫(kù)(lib...
    Rui_bdad閱讀 2,971評(píng)論 1 4
  • 2017年9月15日,今天終于到了周五,上完今天就到周末了,就可以去看長(zhǎng)發(fā)公主的話(huà)劇了喲!可是起床時(shí)我還是有點(diǎn)不想...
    茶語(yǔ)心林閱讀 247評(píng)論 1 0