本人主要從個人角度介紹了對服務端渲染的理解,讀完本文后,你將了解到:
- 什么是服務端渲染,與客戶端渲染的區別是什么?
- 為什么需要服務端渲染,服務端渲染的利弊是什么?
- 如何對VUE項目進行同構?
服務端渲染的定義
在講服務度渲染之前,我們先回顧一下頁面的渲染流程:
- 瀏覽器通過請求得到一個html文本
- 渲染進程解析HTML文本,構建DOM樹
- 解析HTML的同時,如果遇到內聯樣式或者樣式腳本,則下載并構建樣式規則(stytle rules),若遇到JAVAScript腳本,則會下載執行腳本。
- DOM樹和樣式規則構建完成之后,渲染進程將兩者合并成渲染樹(render tree)
- 渲染進程開始對渲染樹進行布局,生成布局樹(layout tree)
- 渲染進程對布局樹進行繪制,生成繪制記錄
- 渲染進程的對布局樹進行分層,分別柵格化每一層,并得到合成幀
- 渲染進程將合成幀信息發送給GPU進程顯示到頁面中
可以看到,頁面的渲染其實就是瀏覽器將HTML文本轉化為頁面幀的過程。而如今我們大部分WEB應用都是使用 JavaScript 框架(Vue、React、Angular)進行頁面渲染的,也就是說,在執行 JavaScript 腳本的時候,HTML頁面已經開始解析并且構建DOM樹了,JavaScript 腳本只是動態的改變 DOM 樹的結構,使得頁面成為希望成為的樣子,這種渲染方式叫動態渲染,也可以叫客戶端渲染(client side rende)。
那么什么是服務端渲染(server side render)?顧名思義,服務端渲染就是在瀏覽器請求頁面URL的時候,服務端將我們需要的HTML文本組裝好,并返回給瀏覽器,這個HTML文本被瀏覽器解析之后,不需要經過 JavaScript 腳本的執行,即可直接構建出希望的 DOM 樹并展示到頁面中。這個服務端組裝HTML的過程,叫做服務端渲染。
服務端渲染的由來
Web1.0
在沒有AJAX的時候,也就是web1.0時代,幾乎所有應用都是服務端渲染(此時服務器渲染非現在的服務器渲染),那個時候的頁面渲染大概是這樣的,瀏覽器請求頁面URL,然后服務器接收到請求之后,到數據庫查詢數據,將數據丟到后端的組件模板(php、asp、jsp等)中,并渲染成HTML片段,接著服務器在組裝這些HTML片段,組成一個完整的HTML,最后返回給瀏覽器,這個時候,瀏覽器已經拿到了一個完整的被服務器動態組裝出來的HTML文本,然后將HTML渲染到頁面中,過程沒有任何JavaScript代碼的參與。
客戶端渲染
在WEB1.0時代,服務端渲染看起來是一個當時的最好的渲染方式,但是隨著業務的日益復雜和后續AJAX的出現,也漸漸開始暴露出了WEB1.0服務器渲染的缺點。
- 每次更新頁面的一小的模塊,都需要重新請求一次頁面,重新查一次數據庫,重新組裝一次HTML
- 前端JavaScript代碼和后端(jsp、php、jsp)代碼混雜在一起,使得日益復雜的WEB應用難以維護
而且那個時候,根本就沒有前端工程師這一職位,前端js的活一般都由后端同學 jQuery 一把梭。但是隨著前端頁面漸漸地復雜了之后,后端開始發現js好麻煩,雖然很簡單,但是坑太多了,于是讓公司招聘了一些專門寫js的人,也就是前端,這個時候,前后端的鄙視鏈就出現了,后端鄙視前端,因為后端覺得js太簡單,無非就是寫寫頁面的特效(JS),切切圖(css),根本算不上是真正的程序員。
隨之 nodejs 的出現,前端看到了翻身的契機,為了擺脫后端的指指點點,前端開啟了一場前后端分離的運動,希望可以脫離后端獨立發展。前后端分離,表面上看上去是代碼分離,實際上是為了前后端人員分離,也就是前后端分家,前端不再歸屬于后端團隊。
前后端分離之后,網頁開始被當成了獨立的應用程序(SPA,Single Page Application),前端團隊接管了所有頁面渲染的事,后端團隊只負責提供所有數據查詢與處理的API,大體流程是這樣的:首先瀏覽器請求URL,前端服務器直接返回一個空的靜態HTML文件(不需要任何查數據庫和模板組裝),這個HTML文件中加載了很多渲染頁面需要的 JavaScript 腳本和 CSS 樣式表,瀏覽器拿到 HTML 文件后開始加載腳本和樣式表,并且執行腳本,這個時候腳本請求后端服務提供的API,獲取數據,獲取完成后將數據通過JavaScript腳本動態的將數據渲染到頁面中,完成頁面顯示。
這一個前后端分離的渲染模式,也就是客戶端渲染(CSR)。
服務端渲染
隨著單頁應用(SPA)的發展,程序員們漸漸發現 seo(Search Engine Optimazition,即搜索引擎優化)出了問題,而且隨著應用的復雜化,JavaScript 腳本也不斷的臃腫起來,使得首屏渲染相比于 Web1.0時候的服務端渲染,也慢了不少。
自己選的路,跪著也要走下去。于是前端團隊選擇了使用 nodejs 在服務器進行頁面的渲染,進而再次出現了服務端渲染。大體流程與客戶端渲染有些相似,首先是瀏覽器請求URL,前端服務器接收到URL請求之后,根據不同的URL,前端服務器向后端服務器請求數據,請求完成后,前端服務器會組裝一個攜帶了具體數據的HTML文本,并且返回給瀏覽器,瀏覽器得到HTML之后開始渲染頁面,同時,瀏覽器加載并執行 JavaScript 腳本,給頁面上的元素綁定事件,讓頁面變得可交互,當用戶與瀏覽器頁面進行交互,如跳轉到下一個頁面時,瀏覽器會執行 JavaScript 腳本,向后端服務器請求數據,獲取完數據之后再次執行 JavaScript 代碼動態渲染頁面。
服務端渲染的利弊
相比于客戶端渲染,服務端渲染有什么優勢?
利于SEO
有利于SEO,其實就是有利于爬蟲來爬你的頁面,然后在別人使用搜索引擎搜索相關的內容時,你的網頁排行能靠得更前,這樣你的流量就有越高。那為什么服務端渲染更利于爬蟲爬你的頁面呢?其實,爬蟲也分低級爬蟲和高級爬蟲。
- 低級爬蟲:只請求URL,URL返回的HTML是什么內容就爬什么內容。
- 高級爬蟲:請求URL,加載并執行JavaScript腳本渲染頁面,爬JavaScript渲染后的內容。
也就是說,低級爬蟲對客戶端渲染的頁面來說,簡直無能為力,因為返回的HTML是一個空殼,它需要執行 JavaScript 腳本之后才會渲染真正的頁面。而目前像百度、谷歌、微軟等公司,有一部分年代老舊的爬蟲還屬于低級爬蟲,使用服務端渲染,對這些低級爬蟲更加友好一些。
白屏時間更短
相對于客戶端渲染,服務端渲染在瀏覽器請求URL之后已經得到了一個帶有數據的HTML文本,瀏覽器只需要解析HTML,直接構建DOM樹就可以。而客戶端渲染,需要先得到一個空的HTML頁面,這個時候頁面已經進入白屏,之后還需要經過加載并執行 JavaScript、請求后端服務器獲取數據、JavaScript 渲染頁面幾個過程才可以看到最后的頁面。特別是在復雜應用中,由于需要加載 JavaScript 腳本,越是復雜的應用,需要加載的 JavaScript 腳本就越多、越大,這會導致應用的首屏加載時間非常長,進而降低了體驗感。
服務端渲染缺點
并不是所有的WEB應用都必須使用SSR,這需要開發者自己來權衡,因為服務端渲染會帶來以下問題:
- 代碼復雜度增加。為了實現服務端渲染,應用代碼中需要兼容服務端和客戶端兩種運行情況,而一部分依賴的外部擴展庫卻只能在客戶端運行,需要對其進行特殊處理,才能在服務器渲染應用程序中運行。
- 需要更多的服務器負載均衡。由于服務器增加了渲染HTML的需求,使得原本只需要輸出靜態資源文件的nodejs服務,新增了數據獲取的IO和渲染HTML的CPU占用,如果流量突然暴增,有可能導致服務器down機,因此需要使用響應的緩存策略和準備相應的服務器負載。
- 涉及構建設置和部署的更多要求。與可以部署在任何靜態文件服務器上的完全靜態單頁面應用程序 (SPA) 不同,服務器渲染應用程序,需要處于 Node.js server 運行環境。
所以在使用服務端渲染SSR之前,需要開發者考慮投入產出比,比如大部分應用系統都不需要SEO,而且首屏時間并沒有非常的慢,如果使用SSR反而小題大做了。
同構
知道了服務器渲染的利弊后,假如我們需要在項目中使用服務端渲染,我們需要做什么呢?那就是同構我們的項目。
同構的定義
在服務端渲染中,有兩種頁面渲染的方式:
- 前端服務器通過請求后端服務器獲取數據并組裝HTML返回給瀏覽器,瀏覽器直接解析HTML后渲染頁面
- 瀏覽器在交互過程中,請求新的數據并動態更新渲染頁面
這兩種渲染方式有一個不同點就是,一個是在服務端中組裝html的,一個是在客戶端中組裝html的,運行環境是不一樣的。所謂同構,就是讓一份代碼,既可以在服務端中執行,也可以在客戶端中執行,并且執行的效果都是一樣的,都是完成這個html的組裝,正確的顯示頁面。也就是說,一份代碼,既可以客戶端渲染,也可以服務端渲染。
同構的條件
為了實現同構,我們需要滿足什么條件呢?首先,我們思考一個應用中一個頁面的組成,假如我們使用的是Vue.js,當我們打開一個頁面時,首先是打開這個頁面的URL,這個URL,可以通過應用的路由匹配,找到具體的頁面,不同的頁面有不同的視圖,那么,視圖是什么?從應用的角度來看,視圖 = 模板 + 數據,那么在 Vue.js 中, 模板可以理解成組件,數據可以理解為數據模型,即響應式數據。所以,對于同構應用來說,我們必須實現客戶端與服務端的路由、模型組件、數據模型的共享。
實踐
知道了服務端渲染、同構的原理之后,下面從頭開始,一步一步完成一次同構,通過實踐來了解SSR。
實現基礎的NODEJS服務端渲染
首先,模擬一個最簡單的服務器渲染,只需要向頁面返回我們需要的html文件。
const express = require('express');
const app = express();
app.get('/', function(req, res) {
res.send(`
<html>
<head>
<title>SSR</title>
</head>
<body>
<p>hello world</p>
</body>
</html>
`);
});
app.listen(3001, function() {
console.log('listen:3001');
});
啟動之后打開localhost:3001可以看到頁面顯示了hello world。而且打開網頁源代碼:
也就是說,當瀏覽器拿到服務器返回的這一段HTML源代碼的時候,不需要加載任何JavaScript腳本,就可以直接將hello world顯示出來。
實現基礎的VUE客戶端渲染
我們用 vue-cli新建一個vue項目,修改一個App.vue組件:
<template>
<div>
<p>hello world</p>
<button @click="sayHello">say hello</button>
</div>
</template>
<script>
export default {
methods: {
sayHello() {
alert('hello ssr');
}
}
}
</script>
然后運行npm run serve啟動項目,打開瀏覽器,一樣可以看到頁面顯示了 hello world,但是打開我們開網頁源代碼:
除了簡單的兼容性處理 noscript 標簽以外,只有一個簡單的id為app的div標簽,沒有關于hello world的任何字眼,可以說這是一個空的頁面(白屏),而當加載了下面的 script 標簽的 JavaScript 腳本之后,頁面開始這行這些腳本,執行結束,hello world 正常顯示。也就是說真正渲染 hello world 的是 JavaScript 腳本。
同構VUE項目
構建配置
模板組件的共享,其實就是使用同一套組件代碼,為了實現 Vue 組件可以在服務端中運行,首先我們需要解決代碼編譯問題。一般情況,vue項目使用的是webpack進行代碼構建,同樣,服務端代碼的構建,也可以使用webpack,借用官方的一張。
第一步:構建服務端代碼
由前面的圖可以看到,在服務端代碼構建結束后,需要將構建結果運行在nodejs服務器上,但是,對于服務端代碼的構建,有一下內容需要注意:
- 不需要編譯CSS,樣式表只有在瀏覽器(客戶端)運行時需要。
- 構建的目標的運行環境是commonjs,nodejs的模塊化模式為commonjs
- 不需要代碼切割,nodejs將所有代碼一次性加載到內存中更有利于運行效率
于是,我們得到一個服務端的 webpack 構建配置文件 vue.server.config.js
const nodeExternals = require("webpack-node-externals");
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = {
css: {
extract: false // 不提取 CSS
},
configureWebpack: () => ({
entry: `./src/server-entry.js`, // 服務器入口文件
devtool: 'source-map',
target: 'node', // 構建目標為nodejs環境
output: {
libraryTarget: 'commonjs2' // 構建目標加載模式 commonjs
},
// 跳過 node_mdoules,運行時會自動加載,不需要編譯
externals: nodeExternals({
allowlist: [/.css$/] // 允許css文件,方便css module
}),
optimization: {
splitChunks: false // 關閉代碼切割
},
plugins: [
new VueSSRServerPlugin()
]
})
};
使用 vue-server-renderer提供的server-plugin,這個插件主要配合下面講到的client-plugin使用,作用主要是用來實現nodejs在開發過程中的熱加載、source-map、生成html文件。
第二步:構建客戶端代碼
在構建客戶端代碼時,使用的是客戶端的執行入口文件,構建結束后,將構建結果在瀏覽器運行即可,但是在服務端渲染中,HTML是由服務端渲染的,也就是說,我們要加載那些JavaScript腳本,是服務端決定的,因為HTML中的script標簽是由服務端拼接的,所以在客戶端代碼構建的時候,我們需要使用插件,生成一個構建結果清單,這個清單是用來告訴服務端,當前頁面需要加載哪些JS腳本和CSS樣式表。
于是我們得到了客戶端的構建配置,vue.client.config.js
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = {
configureWebpack: () => ({
entry: `./src/client-entry.js`,
devtool: 'source-map',
target: 'web',
plugins: [
new VueSSRClientPlugin()
]
}),
chainWebpack: config => {
// 去除所有關于客戶端生成的html配置,因為已經交給后端生成
config.plugins.delete('html');
config.plugins.delete('preload');
config.plugins.delete('prefetch');
}
};
使用vue-server-renderer提供的client-server,主要作用是生成構建加過清單
vue-ssr-client-manifest.json,服務端在渲染頁面時,根據這個清單來渲染HTML中的script標簽(JavaScript)和link標簽(CSS)。
接下來,我們需要將vue.client.config.js和vue.server.config.js都交給vue-cli內置的構建配置文件vue.config.js,根據環境變量使用不同的配置
// vue.config.js
const TARGET_NODE = process.env.WEBPACK_TARGET === 'node';
const serverConfig = require('./vue.server.config');
const clientConfig = require('./vue.client.config');
if (TARGET_NODE) {
module.exports = serverConfig;
} else {
module.exports = clientConfig;
}
使用cross-env區分環境
{
"scripts": {
"server": "babel-node src/server.js",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server"
}
}
模板組件共享
第一步:創建VUE實例
為了實現模板組件共享,我們需要將獲取 Vue 渲染實例寫成通用代碼,如下 createApp:
import Vue from 'vue';
import App from './App';
export default function createApp (context) {
const app = new Vue({
render: h => h(App)
});
return {
app
};
};
第二步:客戶端實例化VUE
新建客戶端項目的入口文件,client-entry.js
import Vue from 'vue'
import createApp from './createApp';
const {app} = createApp();
app.$mount('#app');
client-entry.js是瀏覽器渲染的入口文件,在瀏覽器加載了客戶端編譯后的代碼后,組件會被渲染到id為app的元素節點上。
第三步:服務端實例化VUE
新建服務端代碼的入口文件,server-entry.js
import createApp from './createApp'
export default context => {
const { app } = createApp(context);
return app;
}
server-entry.js是提供給服務器渲染vue組件的入口文件,在瀏覽器通過URL訪問到服務器后,服務器需要使用server-entry.js提供的函數,將組件渲染成html。
第四步:HTTP服務
所有東西的準備好之后,我們需要修改nodejs的HTTP服務器的啟動文件。首先,加載服務端代碼server-entry.js的webpack構建結果
const path = require('path');
const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');
const {createBundleRenderer} = require('vue-server-renderer');
const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');
加載客戶端代碼client-entry.js的webpack構建結果
const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json');
const clientManifest = require(clientManifestPath);
使用 vue-server-renderer 的createBundleRenderer創建一個html渲染器:
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
template, // 使用HTML模板
clientManifest // 將客戶端的構建結果清單傳入
});
創建HTML模板,index.html
<html>
<head>
<title>SSR</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
在HTML模板中,通過傳入的客戶端渲染結果clientManifest,將自動注入所有link樣式表標簽,而占位符將會被替換成模板組件被渲染后的具體的HTML片段和script腳本標簽。
HTML準備完成后,我們在server中掛起所有路由請求
const express = require('express');
const app = express();
/* code todo 實例化渲染器renderer */
app.get('*', function(req, res) {
renderer.renderToString({}, (err, html) => {
if (err) {
res.send('500 server error');
return;
}
res.send(html);
})
});
接下來,我們構建客戶端、服務端項目,然后執行 node server.js,打開頁面源代碼,
看起來是符合預期的,但是發現控制臺有報錯,加載不到客戶端構建css和js,報404,原因很明確,我們沒有把客戶端的構建結果文件掛載到服務器的靜態資源目錄,在掛載路由前加入下面代碼:
app.use(express.static(path.resolve(process.cwd(), 'dist')));
看起來大功告成,點擊say hello也彈出了消息,細心的同學會發現根節點有一個data-server-rendered屬性,這個屬性有什么作用呢?
由于服務器已經渲染好了 HTML,我們顯然無需將其丟棄再重新創建所有的 DOM 元素。相反,我們需要"激活"這些靜態的 HTML,然后使他們成為動態的(能夠響應后續的數據變化)。
如果檢查服務器渲染的輸出結果,應用程序的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
data-server-rendered是特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,并且應該以激活模式進行掛載。
路由的共享和同步
完成了模板組件的共享之后,下面完成路由的共享,我們前面服務器使用的路由是*,接受任意URL,這允許所有URL請求交給Vue路由處理,進而完成客戶端路由與服務端路由的復用。
第一步:創建ROUTER實例
為了實現復用,與createApp一樣,我們創建一個createRouter.js
import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home';
import About from './views/About';
Vue.use(Router)
const routes = [{
path: '/',
name: 'Home',
component: Home
}, {
path: '/about',
name: 'About',
component: About
}];
export default function createRouter() {
return new Router({
mode: 'history',
routes
})
}
在createApp.js中創建router
import Vue from 'vue';
import App from './App';
import createRouter from './createRouter';
export default function createApp(context) {
const router = createRouter(); // 創建 router 實例
const app = new Vue({
router, // 注入 router 到根 Vue 實例
render: h => h(App)
});
return { router, app };
};
第二步:路由匹配
router準備好了之后,修改server-entry.js,將請求的URL傳遞給router,使得在創建app的時候可以根據URL匹配到對應的路由,進而可知道需要渲染哪些組件
import createApp from './createApp';
export default context => {
// 因為有可能會是異步路由鉤子函數或組件,所以我們將返回一個 Promise,
// 以便服務器能夠等待所有的內容在渲染前就已經準備就緒。
return new Promise((resolve, reject) => {
const { app, router } = createApp();
// 設置服務器端 router 的位置
router.push(context.url)
// onReady 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,執行 reject 函數,并返回 404
if (!matchedComponents.length) {
return reject({
code: 404
});
}
// Promise 應該 resolve 應用程序實例,以便它可以渲染
resolve(app)
}, reject)
})
}
修改server.js的路由,把url傳遞給renderer
app.get('*', function(req, res) {
const context = {
url: req.url
};
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
res.send('500 server error');
return;
}
res.send(html);
})
});
為了測試,我們將App.vue修改為router-view
<template>
<div id="app">
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
<router-view />
</div>
</template>
Home.vue
<template>
<div>Home Page</div>
</template>
About.vue
<template>
<div>About Page</div>
</template>
編譯,運行,查看源代碼
點擊路由并沒有刷新頁面,而是客戶端路由跳轉的,一切符合預期。
數據模型的共享與狀態同步
前面我們簡單的實現了服務端渲染,但是實際情況下,我們在訪問頁面的時候,還需要獲取需要渲染的數據,并且渲染成HTML,也就是說,在渲染HTML之前,我們需要將所有數據都準備好,然后傳遞給renderer。
一般情況下,在Vue中,我們將狀態數據交給Vuex進行管理,當然,狀態也可以保存在組件內部,只不過需要組件實例化的時候自己去同步數據。
第一步:創建STORE實例
首先第一步,與createApp類似,創建一個createStore.js,用來實例化store,同時提供給客戶端和服務端使用
import Vue from 'vue';
import Vuex from 'vuex';
import {fetchItem} from './api';
Vue.use(Vuex);
export default function createStore() {
return new Vuex.Store({
state: {
item: {}
},
actions: {
fetchItem({ commit }, id) {
return fetchItem(id).then(item => {
commit('setItem', item);
})
}
},
mutations: {
setItem(state, item) {
Vue.set(state.item, item);
}
}
})
}
actions封裝了請求數據的函數,mutations用來設置狀態。
將createStore加入到createApp中,并將store注入到vue實例中,讓所有Vue組件可以獲取到store實例
export default function createApp(context) {
const router = createRouter();
const store = createStore();
const app = new Vue({
router,
store, // 注入 store 到根 Vue 實例
render: h => h(App)
});
return { router, store, app };
};
為了方便測試,我們mock一個遠程服務函數fetchItem,用于查詢對應item
export function fetchItem(id) {
const items = [
{ name: 'item1', id: 1 },
{ name: 'item2', id: 2 },
{ name: 'item3', id: 3 }
];
const item = items.find(i => i.id == id);
return Promise.resolve(item);
}
第二步:STORE連接組件
一般情況下,我們需要通過訪問路由,來決定獲取哪部分數據,這也決定了哪些組件需要渲染。事實上,給定路由所需的數據,也是在該路由上渲染組件時所需的數據。所以,我們需要在路由的組件中放置數據預取邏輯函數。
在Home組件中自定義一個靜態函數asyncData,需要注意的是,由于此函數會在組件實例化之前調用,所以它無法訪問 this。需要將 store 和路由信息作為參數傳遞進去
<template>
<div>
<div>id: {{item.id}}</div>
<div>name: {{item.name}}</div>
</div>
</template>
<script>
export default {
asyncData({ store, route }) {
// 觸發 action 后,會返回 Promise
return store.dispatch('fetchItems', route.params.id)
},
computed: {
// 從 store 的 state 對象中的獲取 item。
item() {
return this.$store.state.item;
}
}
}
</script>
第三步:服務端獲取數據
在服務器的入口文件server-entry.js中,我們通過URL路由匹配
router.getMatchedComponents()得到了需要渲染的組件,這個時候我們可以調用組件內部的asyncData方法,將所需要的所有數據都獲取完后,傳遞給渲染器renderer上下文。
修改createApp,在路由組件匹配到了之后,調用asyncData方法,獲取數據后傳遞給renderer
import createApp from './createApp';
export default context => {
// 因為有可能會是異步路由鉤子函數或組件,所以我們將返回一個 Promise,
// 以便服務器能夠等待所有的內容在渲染前就已經準備就緒。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();
// 設置服務器端 router 的位置
router.push(context.url)
// onReady 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,執行 reject 函數,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 對所有匹配的路由組件調用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
});
}
})).then(() => {
// 狀態傳遞給renderer的上下文,方便后面客戶端激活數據
context.state = store.state
resolve(app)
}).catch(reject);
}, reject);
})
}
將state存入context后,在服務端渲染HTML時候,也就是渲染template的時候,context.state會被序列化到window.__INITIAL_STATE__中,方便客戶端激活數據。
第四步:客戶端激活狀態數據
服務端預請求數據之后,通過將數據注入到組件中,渲染組件并轉化成HTML,然后吐給客戶端,那么客戶端為了激活后端返回的HTML被解析后的DOM節點,需要將后端渲染組件時用的store的state也同步到瀏覽器的store中,保證在頁面渲染的時候保持與服務器渲染時的數據是一致的,才能完成DOM的激活,也就是我們前面說到的data-server-rendered標記。
在服務端的渲染中,state已經被序列化到了window.__INITIAL_STATE__,比如我們訪問http://localhost:3001?id=1,查看頁面源代碼
可以看到,狀態已經被序列化到window.__INITIAL_STATE__中,我們需要做的就是將這個window.__INITIAL_STATE__在客戶端渲染之前,同步到客戶端的store中,下面修改client-entry.js
const { app, router, store } = createApp();
if (window.__INITIAL_STATE__) {
// 激活狀態數據
store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
app.$mount('#app', true);
});
通過使用store的replaceState函數,將window.__INITIAL_STATE__同步到store內部,完成數據模型的狀態同步。
總結
當瀏覽器訪問服務端渲染項目時,服務端將URL傳給到預選構建好的VUE應用渲染器,渲染器匹配到對應的路由的組件之后,執行我們預先在組件內定義的asyncData方法獲取數據,并將獲取完的數據傳遞給渲染器的上下文,利用template組裝成HTML,并將HTML和狀態state一并吐給前端瀏覽器,瀏覽器加載了構建好的客戶端VUE應用后,將state數據同步到前端的store中,并根據數據激活后端返回的被瀏覽器解析為DOM元素的HTML文本,完成了數據狀態、路由、組件的同步,同時使得頁面得到直出,較少了白屏時間,有了更好的加載體驗,同時更有利于SEO。
個人覺得了解服務端渲染,有助于提升前端工程師的綜合能力,因為它的內容除了前端框架,還有前端構建和后端內容,是一個性價比還挺高的知識,不學白不學,加油!