介紹
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-renderer
、vue-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)圖 :
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)境的差異化代碼抹平:
在客戶(hù)端入口中vue實(shí)例化之后執(zhí)行掛載dom的代碼,服務(wù)端入口的vue則只需要生成vue對(duì)象即可 。
一些不兼容ssr的第三方庫(kù)或者代碼片段,我們可以只在客戶(hù)端入口中加載 。
即使通用代碼我們也可以通過(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)境引用不同模塊。
在服務(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腳本引用。async
和modules
將配合檢索出異步組件和異步依賴(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ì)象:render
和templateRenderer
,他們分別負(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)行配置:
-
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à)很高。 -
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)境是單例的 -
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.返回renderToString
和renderToStream
函數(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é):
- 獲取到serverBundle的入口文件代碼并解析為入口函數(shù),每次執(zhí)行實(shí)例化vue對(duì)象
- 實(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的組裝,它的主要原型方法有:bindRenderFns
、render
、renderStyles
、renderResourceHints
、renderState
、renderScripts
其中renderStyles
、renderResourceHints
、renderState
、renderScripts
分別是生成頁(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í)行installSSRHelpers
和normalizeRender
這兩行主要是針對(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$1
、renderComponent
、renderElement
、renderAsyncComponent
這四個(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í)行:renderComponentWithCache
和renderComponentInner
,這兩個(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é):
- 用戶(hù)發(fā)起請(qǐng)求時(shí),通過(guò)執(zhí)行serverBundle后得到的應(yīng)用入口函數(shù),實(shí)例化vue對(duì)象。
- renderer對(duì)象負(fù)責(zé)把vue對(duì)象遞歸轉(zhuǎn)為vnode,并把vnode根據(jù)不同node類(lèi)型調(diào)用不同渲染函數(shù)最終組裝為html。
- 在渲染組件的過(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的renderStyles
、renderResourceHints
、renderState
、renderScripts
這些函數(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中支持自定義渲染,同樣nect
和tail
部分都支持這么做。
接下來(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é):
- 在編譯階段通過(guò)插件生成應(yīng)用的文件和模塊
moduleIdentifier
- 在同一次編譯過(guò)程中通過(guò)vue-loader把
moduleIdentifier
注入到每個(gè)模塊的hook鉤子中 - 在渲染階段創(chuàng)建組件時(shí)調(diào)用hook鉤子,把每個(gè)模塊的
moduleIdentifier
添加到用戶(hù)上下文context的_registeredComponents數(shù)組中 - 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)有意義。