介紹

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資源,如果項目是需要在高流量環境中使用,則需要準備更多的伺服器負載和更好的緩存策略。

祭出官方提供的架構圖 :

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) { ,

});

];

包裹之後入口文件的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鉤子([email protected]+新增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鉤子的話,觸發這個鉤子(這個鉤子在[email protected]新增的)。

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之後到內容佔位符是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: /{{([^{][sS]+?[^}])}}/g,
interpolate: /{{{([sS]+?)}}}/g
};

vue在官方文檔中Head管理(ssr.vuejs.org/zh/guide/)中介紹了,如何通過將數據綁定到用戶上下文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詳細解讀可以參考這篇文章: segmentfault.com/a/1190)。

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(/sw+$/, ); // 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 doesnt
// 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解析。

更多詳情,可以參考: alloyteam.com/2015/10/p

非阻塞式的腳本載入 這個在我們配置了template和inject後vue也會自動幫我們的script載入腳本加上defer屬性,script有兩種屬性defer和async:

無屬性:在dom解析階段開始下載,並且阻塞dom解析,下載完成之後再恢復dom解析。

defer:在dom解析階段開始下載js,不阻塞dom解析,並在dom解析渲染完成之後再執行。

async:在dom解析階段開始下載js,不阻塞dom解析,在下載完成之後立即執行,如果dom正在解析則阻塞住。

顯然defer會讓頁面更快的呈現。

具體可參考:growingwiththeweb.com/2

合理定義組件邊界不要定義不必要的組件,組件的粒度要把控好,太細粒度的組件定義沒有意義。


推薦閱讀:
相关文章