服務(wù)端渲染SSR及實(shí)現(xiàn)原理
本文首發(fā)于政采云前端團(tuán)隊(duì)博客:服務(wù)端渲染SSR及實(shí)現(xiàn)原理
https://www.zoo.team/article/web-ssr

前言
在日常前端開發(fā)中,在需要首屏渲染速度優(yōu)化的場(chǎng)景下,大家或多或少都聽到過服務(wù)端渲染( SSR )。本文將結(jié)合 Vue 來對(duì) SSR 的實(shí)現(xiàn)邏輯來進(jìn)行解讀。通過閱讀本文你將了解到:
服務(wù)端渲染的使用場(chǎng)景 Vue SSR 的實(shí)現(xiàn)原理 可開箱即用的 SSR 腳手架
服務(wù)端渲染
服務(wù)端渲染 SSR (Server-Side Rendering),是指在服務(wù)端完成頁面的 html 拼接處理, 然后再發(fā)送給瀏覽器,將不具有交互能力的 html 結(jié)構(gòu)綁定事件和狀態(tài),在客戶端展示為具有完整交互能力的應(yīng)用程序。
適用場(chǎng)景
以下兩種情況 SSR 可以提供很好的場(chǎng)景支持
需更好的支持 SEO
優(yōu)勢(shì)在于同步。搜索引擎爬蟲是不會(huì)等待異步請(qǐng)求數(shù)據(jù)結(jié)束后再抓取信息的,如果 SEO 對(duì)應(yīng)用程序至關(guān)重要,但你的頁面又是異步請(qǐng)求數(shù)據(jù),那 SSR 可以幫助你很好的解決這個(gè)問題。
需更快的到達(dá)時(shí)間
優(yōu)勢(shì)在于慢網(wǎng)絡(luò)和運(yùn)行緩慢的設(shè)備場(chǎng)景。傳統(tǒng) SPA 需完整的 JS 下載完成才可執(zhí)行,而SSR 服務(wù)器渲染標(biāo)記在服務(wù)端渲染 html 后即可顯示,用戶會(huì)更快的看到首屏渲染頁面。如果首屏渲染時(shí)間轉(zhuǎn)化率對(duì)應(yīng)用程序至關(guān)重要,那可以使用 SSR 來優(yōu)化。
不適用場(chǎng)景
以下三種場(chǎng)景 SSR 使用需要慎重
同構(gòu)資源的處理
劣勢(shì)在于程序需要具有通用性。結(jié)合 Vue 的鉤子來說,能在 SSR 中調(diào)用的生命周期只有 beforeCreate 和 created,這就導(dǎo)致在使用三方 API 時(shí)必須保證運(yùn)行不報(bào)錯(cuò)。在三方庫的引用時(shí)需要特殊處理使其支持服務(wù)端和客戶端都可運(yùn)行。
部署構(gòu)建配置資源的支持
劣勢(shì)在于運(yùn)行環(huán)境單一。程序需處于 node.js server 運(yùn)行環(huán)境。
服務(wù)器更多的緩存準(zhǔn)備
劣勢(shì)在于高流量場(chǎng)景需采用緩存策略。應(yīng)用代碼需在雙端運(yùn)行解析,cpu 性能消耗更大,負(fù)載均衡和多場(chǎng)景緩存處理比 SPA 做更多準(zhǔn)備。
我們來結(jié)合 Vue.js 來看看 Vue 是如何實(shí)現(xiàn) SSR 的。
Vue SSR 的實(shí)現(xiàn)原理
先決條件
組件基于 Vnode 來實(shí)現(xiàn)渲染
VNode 本身是 js 對(duì)象,兼容性極強(qiáng),不依賴當(dāng)前的執(zhí)行的環(huán)境,從而可以在服務(wù)端渲染及原生渲染。虛擬 DOM 頻繁修改,最后比較出真實(shí) DOM 需要更改的地方,可以達(dá)到局部渲染的目的,減少性能損耗。
vue-server-renderer
是一個(gè)具有獨(dú)立渲染應(yīng)用程序能力的包,是 Vue 服務(wù)端渲染的核心代碼。
本文下面的源碼也結(jié)合這個(gè)包展開,此處不多冗余介紹。
SSR 渲染架構(gòu)
我們結(jié)合官網(wǎng)圖和項(xiàng)目架構(gòu)兩個(gè)維度來整體了解一下 SSR 全貌
項(xiàng)目架構(gòu)
src
├──?components
├──?App.vue
├──?app.js?----通用?entry
├──?entry-client.js?----僅運(yùn)行于瀏覽器
└──?entry-server.js?----僅運(yùn)行于服務(wù)器
app.js導(dǎo)出 createApp 函數(shù)工廠,此函數(shù)是可以被重復(fù)執(zhí)行的,從根 Vue 實(shí)例注入,用于創(chuàng)建 router,store 以及應(yīng)用程序?qū)嵗?/p>
import?Vue?from?'vue'
import?App?from?'./App.vue'
//?導(dǎo)出一個(gè)工廠函數(shù),用于創(chuàng)建新的應(yīng)用程序、router?和?store?實(shí)例
export?function?createApp?()?{
??const?app?=?new?Vue({
????render:?h?=>?h(App)
??})
??return?{?app?}
}
entry-client.js負(fù)責(zé)創(chuàng)建應(yīng)用程序,掛載實(shí)例 DOM ,僅運(yùn)行于瀏覽器。
import?{?createApp?}?from?'./app'
const?{?app?}?=?createApp()
//?#app?為根元素,名稱可替換
app.$mount('#app')
entry-server.js創(chuàng)建返回應(yīng)用實(shí)例,同時(shí)還會(huì)進(jìn)行路由匹配和數(shù)據(jù)的預(yù)處理,僅運(yùn)行于服務(wù)器。
import?{?createApp?}?from?'./app'
export?default?context?=>?{
??const?{?app?}?=?createApp()
??return?app
}
服務(wù)端和客戶端代碼編寫原則
作為同構(gòu)框架,應(yīng)用代碼編譯過程 Vue SSR 提供了兩個(gè)編譯入口,來作為抹平由于環(huán)境不同的代碼差異。Client entry 和 Server entry 中編寫代碼邏輯的區(qū)分有兩條原則
通用型代碼 可通用性的代碼,由于鑒權(quán)邏輯和網(wǎng)關(guān)配置不同,需要在 webpack resolve.alias 中配置不同的模塊環(huán)境應(yīng)用。
非通用性代碼 Client entry ?負(fù)責(zé)掛載 DOM 節(jié)點(diǎn)代碼,以及三方包引入和具有兼容性庫的加載。
Server entry 只生成 Vue 對(duì)象。
兩個(gè)編譯產(chǎn)物
經(jīng)過 webpack 打包之后會(huì)有兩個(gè) bundle 產(chǎn)物
server bundle 用于生成 vue-ssr-server-bundle.json,我們熟悉的 sourceMap 和需要在服務(wù)端運(yùn)行的代碼列表都在這個(gè)產(chǎn)物中。
vue-SSR-server-bundle.json
{?
??"entry":?,?
??"files":?{
??? A:包含了所有要在服務(wù)端運(yùn)行的代碼列表
??? B:入口文件
??}?
}
client Bundle 用于生成 vue-SSR-client-manifest.json,包含所有的靜態(tài)資源,首次渲染需要加載的 script 標(biāo)簽,以及需要在客戶端運(yùn)行的代碼。
vue-SSR-client-manifest.json
{?
??"publicPath":?公共資源路徑文件地址,?
??"all":?資源列表
??"initial":輸出?html?字符串
??"async":?異步加載組件集合
??"modules":?moduleIdentifier?和?all?數(shù)組中文件的映射關(guān)系
}
在先決條件中我們提到了一個(gè)重要的包 vue-server-renderer,那我們來重點(diǎn)看看這個(gè)包里面的值得我們學(xué)習(xí)關(guān)注的內(nèi)容。
vue-server-renderer
是 Vue SSR 的核心代碼,值得我們關(guān)注的是應(yīng)用初始化和應(yīng)用輸出。兩個(gè)階段提供了完整的應(yīng)用層代碼編譯和組裝邏輯。
應(yīng)用初始化
在應(yīng)用初始化過程中,重點(diǎn)展開介紹實(shí)例化流程和防止交叉污染。
首先我們先來看看一個(gè) Vue SSR 的應(yīng)用是如何被初始化的。
實(shí)例化流程
生成 Vue 對(duì)象
const?Vue?=?require('vue')
const?app?=?new?Vue()
生成 renderer,值得關(guān)注的兩個(gè)對(duì)象 render 和 templateRenderer
const?renderer?=?require('vue-server-renderer').createRenderer()
// createRenderer 函數(shù)中有兩個(gè)重要的對(duì)象:render 和 templateRenderer
function?createRenderer?(ref)?{
??//?render:?渲染?html?組件
??var?render?=?createRenderFunction(modules,?directives,?isUnaryTag,?cache);
??//?templateRenderer:?模版渲染,clientManifest?文件
??var?templateRenderer?=?new?TemplateRenderer({
????template:?template,
????inject:?inject,
????shouldPreload:?shouldPreload,
????shouldPrefetch:?shouldPrefetch,
????clientManifest:?clientManifest,
????serializer:?serializer
??});
經(jīng)過這個(gè)過程的 render 和 templateRenderer 并沒有被調(diào)用,這兩個(gè)函數(shù)真正的調(diào)用是在項(xiàng)目實(shí)例化 createBundleRenderer 函數(shù)的時(shí)候,即第三步創(chuàng)建的函數(shù)。
創(chuàng)建沙盒 vm,實(shí)例化 Vue 的入口文件
var?vm?=?require('vm');
//?調(diào)用?createBundleRunner?函數(shù)實(shí)例對(duì)象,rendererOptions?支持可配置
var?run?=?createBundleRunner(?
??entry,?----入口文件集合
??files,?----打包文件集合
??basedir,?
? rendererOptions.runInNewContext。
);}
在 createBundleRunner 方法的源碼到其實(shí)舉例了一個(gè)叫 compileModule 的一個(gè)方法,這個(gè)方法中有兩個(gè)函數(shù):getCompiledScript 和 evaluateModule
function?createBundleRunner?(entry,?files,?basedir,?runInNewContext)?{
??//觸發(fā)?compileModule?方法,找到?webpack?編譯形成的?code
??var?evaluate?=?compileModule(files,?basedir,?runInNewContext);
}
getCompiledScript:編譯 wrapper ,找到入口文件的 files 文件名及 script 腳本的編譯執(zhí)行
function?getCompiledScript?(filename)?{
????if?(compiledScripts[filename])?{
??????return?compiledScripts[filename]
????}
????//?在入口文件?files?中找到對(duì)應(yīng)的文件名稱
????var?code?=?files[filename];
????var?wrapper?=?NativeModule.wrap(code);
????//?在沙盒上下文中執(zhí)行構(gòu)建?script?腳本
????var?script?=?new?vm.Script(wrapper,?{
??????filename:?filename,
??????displayErrors:?true
????});
????compiledScripts[filename]?=?script;
????return?script
??}
evaluateModule:根據(jù) runInThisContext 中的配置項(xiàng)來決定是在當(dāng)前上下文執(zhí)行還是單獨(dú)上下文執(zhí)行。
function?evaluateModule?(filename,?sandbox,?evaluatedFiles)?{
????if?(?evaluatedFiles?===?void?0?)?evaluatedFiles?=?{};
????if?(evaluatedFiles[filename])?{
??????return?evaluatedFiles[filename]
????}
????var?script?=?getCompiledScript(filename);
????//?用于判斷是在當(dāng)前的那種模式下面執(zhí)行沙盒上下文,此時(shí)存在兩個(gè)函數(shù)的相互調(diào)用
????var?compiledWrapper?=?runInNewContext?===?false
????????script.runInThisContext()
??????:?script.runInNewContext(sandbox);
????//?m:?函數(shù)導(dǎo)出的?exports?數(shù)據(jù)
????var?m?=?{?exports:?{}};
????//?r:?替代原生?require?用來解析?bundle?中通過?require?函數(shù)引用的模塊
????var?r?=?function?(file)?{
??????...
??????return?require(file)
????};
???}
上述的函數(shù)執(zhí)行完成之后會(huì)調(diào)用 compiledWrapper.call,傳參對(duì)應(yīng)上面的 exports、require、module, 我們就能拿到入口函數(shù)。
錯(cuò)誤拋出容錯(cuò)和全局錯(cuò)誤監(jiān)聽 renderToString: 在沒有 cb 函數(shù)時(shí)做了 promise 的返回,那說明我們?cè)谡{(diào)用次函數(shù)的時(shí)候可以直接做 try catch的處理,用于全局錯(cuò)誤的拋出容錯(cuò)。
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));
????}
????...
????return?promise
??},
}
renderToStream:對(duì)拋錯(cuò)做了監(jiān)聽機(jī)制, 拋錯(cuò)的鉤子函數(shù)將在這個(gè)方法中觸發(fā)。
?renderToStream:?function?(context)?{
????var?res?=?new?PassThrough();
????run(context).catch(function?(err)?{
??????rewriteErrorTrace(err,?maps);
??????//?此處做了監(jiān)聽器的容錯(cuò)
??????process.nextTick(function?()?{
????????res.emit('error',?err);
??????});
????}).then(function?(app)?{
??????if?(app)?{
????????var?renderStream?=?renderer.renderToStream(app,?context);
????????...?
??????}
????}
?}
防止交叉污染
Node.js 服務(wù)器是一個(gè)長期運(yùn)行的進(jìn)程,在客戶端編寫的代碼在進(jìn)入進(jìn)程時(shí),變量的上下文將會(huì)被保留,導(dǎo)致交叉請(qǐng)求狀態(tài)污染。因此不可共享一個(gè)實(shí)例,所以說 createApp 是一個(gè)可被重復(fù)執(zhí)行的函數(shù)。其實(shí)在包內(nèi)部,變量之間也存在防止交叉污染的能力。
防止交叉污染的能力是由 rendererOptions.runInNewContext 這個(gè)配置項(xiàng)來提供的,這個(gè)配置支持 true, false,和 once 三種配置項(xiàng)傳入。
//?rendererOptions.runInNewContext?可配置項(xiàng)如下
??true:?
??新上下文模式:創(chuàng)建新上下文并重新評(píng)估捆綁包在每個(gè)渲染上。
??確保每個(gè)應(yīng)用程序的整個(gè)應(yīng)用程序狀態(tài)都是新的渲染,但會(huì)產(chǎn)生額外的評(píng)估成本。
??false:
??直接模式:
??每次渲染時(shí),它只調(diào)用導(dǎo)出的函數(shù)。而不是在上重新評(píng)估整個(gè)捆綁包
??模塊評(píng)估成本較高,但需要結(jié)構(gòu)化源代碼
??once:?
??初始上下文模式
??僅用于收集可能的非組件 vue 樣式加載程序注入的樣式。
特別說明一下 false 和 once 的場(chǎng)景, 為了防止交叉污染,在渲染的過程中對(duì)作用域要求很嚴(yán)格,以此來保證在不同的對(duì)象彼此之間不會(huì)形成污染。
if?(!runner)?{
???var?sandbox?=?runInNewContext?===?'once'
????????createSandbox()
??????:?global;
????initialContext?=?sandbox.__VUE_SSR_CONTEXT__?=?{};
????runner?=?evaluate(entry,?sandbox);
????//在后續(xù)渲染中,_VUE_SSR_CONTEXT_uu?將不可用
????//防止交叉污染
????delete?sandbox.__VUE_SSR_CONTEXT__;
????if?(typeof?runner?!==?'function')?{
??????throw?new?Error(
????????'bundle?export?should?be?a?function?when?using?'?+
????????'{?runInNewContext:?false?}.'
??????)
????}
??}
應(yīng)用輸出
在應(yīng)用輸出這個(gè)階段中,SSR 將更多側(cè)重加載腳本內(nèi)容和模版渲染,在模版渲染時(shí)在代碼中是否定義過模版引擎源碼將提供不同的 html 拼接結(jié)構(gòu)。
加載腳本內(nèi)容
此過程會(huì)將上個(gè)階段構(gòu)造的 reader 和 templateRender 方法實(shí)現(xiàn)數(shù)據(jù)綁定。
templateRenderer:負(fù)責(zé) html 封裝,其原型上會(huì)有如下幾個(gè)方法, 這些函數(shù)的作用如下圖。值得一提的是:bindRenderFns 函數(shù)是將 4 個(gè) render 函數(shù)綁定到用戶上下文的 context 中,用戶在拿到這些內(nèi)容之后就可以做內(nèi)容的自定義組裝和渲染。

render: 函數(shù)會(huì)被遞歸調(diào)用按照從父到子的順序,將組件全部轉(zhuǎn)化為 html。

function?createRenderFunction?(
??modules,
??directives,
??isUnaryTag,
??cache
)?{
??return?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);
????//?渲染?node?節(jié)點(diǎn),綁定用戶作用上下文
????var?resolve?=?function?()?{
??????renderNode(component._render(),?true,?context);
????};
????//?等待組件?serverPrefetch?執(zhí)行完成之后,_render?生成子節(jié)點(diǎn)的?vnode?進(jìn)行渲染
????waitForServerPrefetch(component,?resolve,?done);
??}
}
在經(jīng)過上面的編譯流程之后,我們已經(jīng)拿到了 html 字符串,但如果要在瀏覽器中展示頁面還需js, css 等標(biāo)簽與這個(gè) html 組裝成一個(gè)完整的報(bào)文輸出到瀏覽器中, 因此需要模版渲染階段來將這些元素實(shí)現(xiàn)組裝。
模版渲染
經(jīng)過應(yīng)用初始化階段,代碼被編譯獲取了 html 字符串,context 渲染需要依賴的 templateRenderer.prototype.bindRenderFns 中綁定的 state, script , styles 等資源。
TemplateRenderer.prototype.bindRenderFns?=?function?bindRenderFns?(context)?{
??var?renderer?=?this
??;['ResourceHints',?'State',?'Scripts',?'Styles'].forEach(function?(type)?{
????context[("render"?+?type)]?=?renderer[("render"?+?type)].bind(renderer,?context);
??});
? context.getPreloadFiles = r**erer.ge****:**reloadFiles.bind(renderer, context);
};
在具體渲染模版時(shí),會(huì)有以下兩種情況:
未定義模版引擎 渲染結(jié)果會(huì)被直接返回給 renderToString 的回調(diào)函數(shù),而頁面所需要的腳本依賴我們通過用戶上下文 context 的 renderStyles,renderResourceHints、renderState、renderScripts 這些函數(shù)分別獲得。
定義了模版引擎 templateRender 會(huì)幫助我們進(jìn)行 html 組裝
TemplateRenderer.prototype.render?=?function?render?(content,?context)?{
//?parsedTemplate?用于解析函數(shù)得到的包含三個(gè)部分的?compile?對(duì)象,
//?按照順序進(jìn)行字符串模版的拼接
??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?{
????...
??}
};
至此我們了解了 Vue SSR 的整體架構(gòu)邏輯和 vue-server-renderer 的核心代碼,當(dāng)然 SSR 也是有很多開箱即用的腳手架來供我們選擇的。
開箱即用的SSR腳手架
目前前端流行的三種技術(shù)棧 React, Vue 和 Angula,已經(jīng)孵化出對(duì)應(yīng)的服務(wù)端渲染框架,開箱即用,感興趣的同學(xué)可以自主學(xué)習(xí)使用。
React: Next.js Vue: ?Nuxt.js Angula: Nest.js
總結(jié)
服務(wù)端渲染 ( SSR ) 是一個(gè)同構(gòu)程序,是否使用 SSR 取決于內(nèi)容到達(dá)時(shí)間對(duì)應(yīng)用程序的重要程度。如果對(duì)初始加載的幾百毫秒可接受,SSR 的使用就有點(diǎn)小題大做了。
對(duì)于源碼的學(xué)習(xí)可以幫助更好借鑒優(yōu)秀的程序?qū)懛ê图ぐl(fā)對(duì)日常代碼編程架構(gòu)的思考,如果你更傾向箱即用的解決方案,那可以使用現(xiàn)有的 SSR 腳手架來搭建項(xiàng)目,這些腳手架的模版抽象和額外的功能擴(kuò)展可以提供平滑的開箱體驗(yàn)。
參考文獻(xiàn)
Vue SSR 官網(wǎng) (https://ssr.vuejs.org/zh) Vue 使用指南 (https://www.w3cschool.cn/vuessr/vuessr-jep83epx.html) Vue SSR 源碼解析 (https://juejin.cn/post/6844903812700831757)
看完兩件事
如果你覺得這篇內(nèi)容對(duì)你挺有啟發(fā),我想邀請(qǐng)你幫我兩件小事
1.點(diǎn)個(gè)「在看」,讓更多人也能看到這篇內(nèi)容(點(diǎn)了「在看」,bug -1 ??)
招賢納士
政采云前端團(tuán)隊(duì)(ZooTeam),一個(gè)年輕富有激情和創(chuàng)造力的前端團(tuán)隊(duì),隸屬于政采云產(chǎn)品研發(fā)部,Base 在風(fēng)景如畫的杭州。團(tuán)隊(duì)現(xiàn)有 50 余個(gè)前端小伙伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風(fēng)暴團(tuán)。成員構(gòu)成既有來自于阿里、網(wǎng)易的“老”兵,也有浙大、中科大、杭電等校的應(yīng)屆新人。團(tuán)隊(duì)在日常的業(yè)務(wù)對(duì)接之外,還在物料體系、工程平臺(tái)、搭建平臺(tái)、性能體驗(yàn)、云端應(yīng)用、數(shù)據(jù)分析及可視化等方向進(jìn)行技術(shù)探索和實(shí)戰(zhàn),推動(dòng)并落地了一系列的內(nèi)部技術(shù)產(chǎn)品,持續(xù)探索前端技術(shù)體系的新邊界。
如果你想改變一直被事折騰,希望開始能折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個(gè)結(jié)果,卻不需要你;如果你想改變你想做成的事需要一個(gè)團(tuán)隊(duì)去支撐,但沒你帶人的位置;如果你想改變既定的節(jié)奏,將會(huì)是“5 年工作時(shí)間 3 年工作經(jīng)驗(yàn)”;如果你想改變本來悟性不錯(cuò),但總是有那一層窗戶紙的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨著業(yè)務(wù)騰飛的過程,親手推動(dòng)一個(gè)有著深入的業(yè)務(wù)理解、完善的技術(shù)體系、技術(shù)創(chuàng)造價(jià)值、影響力外溢的前端團(tuán)隊(duì)的成長歷程,我覺得我們?cè)摿牧?。任何時(shí)間,等著你寫點(diǎn)什么,發(fā)給 [email protected]

