最近無論是在公司還是自己研究的專案,都一直在搞 H5 頁面服務端渲染
方面的探索,因此本文來探討一下服務端渲染的必要性以及其背後的原理。
先來看幾個問題
To C 的 H5 為什麼適合做 SSR
To C
的營銷H5
頁面的典型特點是:
- 流量大
- 互動相對簡單(尤其是由搭建平臺搭建的活動頁面)
- 對於頁面的首屏一般都有比較高的要求
那麼此時作為傳統的CSR
渲染方式為什麼就不合適了呢?
看了下面一小節,也許你就有答案了
為什麼服務端渲染就比客戶端渲染快呢?
我們分別來對比一下兩者的DOM
渲染過程。
圖片來源The Benefits of Server Side Rendering Over Client Side Rendering
客戶端渲染
服務端渲染
客戶端渲染,需要先得到一個空的 HTML 頁面
(這個時候頁面已經進入白屏)之後還需要經歷:
- 請求並解析
JavaScript
和CSS
- 請求後端伺服器獲取資料
- 根據資料渲染頁面
幾個過程才可以看到最後的頁面。
特別是在複雜應用中,由於需要載入 JavaScript
指令碼,越是複雜的應用,需要載入的 JavaScript
指令碼就越多、越大,這就會導致應用的首屏載入時間
非常長,進而影響使用者體驗感。
相對於客戶端渲染,服務端渲染在使用者發出一次頁面 url
請求之後,應用伺服器返回的 html
字串就是完備的計算好的,可以交給瀏覽器直接渲染,使得 DOM
的渲染不再受靜態資源和 ajax
的限制。
服務端渲染限制有哪些?
但服務端渲染真的就那麼好嗎?
其實,也不是。
為了實現服務端渲染,應用程式碼中需要相容服務端和客戶端兩種執行情況,對第三方庫的要求比較高,如果想直接在 Node 渲染過程中呼叫第三方庫,那這個庫必須支援服務端渲染。對應的程式碼複雜度提升了很多。
由於伺服器增加了渲染 HTML
的需求,使得原本只需要輸出靜態資原始檔的 nodejs
服務,新增了資料獲取的 IO
和渲染 HTML
的 CPU
佔用,如果流量陡增,有可能導致伺服器當機,因此需要使用相應的快取策略和準備相應的伺服器負載。
對於構建部署也有了更高的要求,之前的SPA應用
可以直接部署在靜態檔案伺服器上,而伺服器渲染應用,需要處於 Node.js server
執行環境。
Vue SSR 原理
聊了這麼多可能你對於服務端渲染的原理還不是很清楚,下面我就以Vue
服務端渲染為例來簡述一下其原理:
這張圖來自Vue SSR 指南
原理解析參考如何搭建一個高可用的服務端渲染工程
Source
為我們的原始碼區,即工程程式碼。
Universal Appliation Code
和我們平時的客戶端渲染的程式碼組織形式完全一致,因為渲染過程是在Node
端,所以沒有DOM
和BOM
物件,因此不要在beforeCreate
和created
生命週期鉤子裡做涉及DOM
和BOM
的操作。
比客戶端渲染多出來的app.js
、Server entry
、Client entry
的主要作用為:
app.js
分別給Server entry
、Client entry
暴露出createApp()
方法,使得每個請求進來會生成新的app
例項- 而
Server entry
和Client entry
分別會被webpack
打包成vue-ssr-server-bundle.json
和vue-ssr-client-manifest.json
Node
端會根據webpack
打包好的vue-ssr-server-bundle.json
,通過呼叫createBundleRenderer
生成renderer
例項,再通過呼叫renderer.renderToString
生成完備的html字串
。
Node
端將render
好的html
字串返回給Browser
,同時Node
端根據vue-ssr-client-manifest.json
生成的js
會和html
字串hydrate
,完成客戶端啟用html
,使得頁面可互動。
寫一個 demo 來落地 SSR
我們知道市面上實現服務端渲染一般有這幾種方法:
- 使用
next.js
/nuxt.js
的服務端渲染方案 - 使用
node
+vue-server-renderer
實現vue
專案的服務端渲染(也就是上面提到的) - 使用
node
+React renderToStaticMarkup/renderToString
實現react
專案的服務端渲染 - 使用模板引擎來實現
ssr
(比如ejs
,jade
,pug
等)
最近要改造的專案正好是 Vue
開發的,目前也考慮基於vue-server-renderer
將其改造為服務端渲染的。基於上面分析的原理,我從零一步步搭建了一個最小化的vue-ssr,大家有需要的可直接拿去用~
這裡我貼幾點需要注意的:
使用 SSR
不存在單例模式
我們知道Node.js
伺服器是一個長期執行的程式。當我們的程式碼進入該程式時,它將進行一次取值並留存在記憶體中。這意味著如果建立一個單例物件,它將在每個傳入的請求之間共享。所以每次使用者請求都會建立一個新的 Vue
例項,這也是為了避免交叉請求狀態汙染的發生。
因此,我們不應該直接建立一個應用程式例項,而是應該暴露一個可以重複執行的工廠函式,為每個請求建立新的應用程式例項:
// main.js
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
import createStore from "./store";
export default () => {
const router = createRouter();
const store = createStore();
const app = new Vue({
router,
store,
render: (h) => h(App),
});
return { app, router, store };
};
服務端程式碼構建
服務端程式碼與客戶端程式碼構建的區別在於:
- 不需要編譯
CSS
,伺服器端渲染會自動將CSS
內建 - 構建目標為
nodejs
環境 - 不需要程式碼切割,
nodejs
將所有程式碼一次性載入到記憶體中更有利於執行效率
// vue.config.js
// 兩個外掛分別負責打包客戶端和服務端
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const nodeExternals = require("webpack-node-externals");
const merge = require("lodash.merge");
// 根據傳⼊環境變數決定⼊⼝⽂件和相應配置項
const TARGET_NODE = process.env.WEBPACK_TARGET === "node";
const target = TARGET_NODE ? "server" : "client";
module.exports = {
css: {
extract: false,
},
outputDir: "./dist/" + target,
configureWebpack: () => ({
// 將 entry 指向應⽤程式的 server / client ⽂件
entry: `./src/${target}-entry.js`,
// 對 bundle renderer 提供 source map ⽀持
devtool: "source-map",
// target設定為node使webpack以Node適⽤的⽅式處理動態導⼊,
// 並且還會在編譯Vue元件時告知`vue-loader`輸出⾯向伺服器程式碼。
target: TARGET_NODE ? "node" : "web",
// 是否模擬node全域性變數
node: TARGET_NODE ? undefined : false,
output: {
// 此處使⽤Node⻛格匯出模組
libraryTarget: TARGET_NODE ? "commonjs2" : undefined,
},
externals: TARGET_NODE
? nodeExternals({
allowlist: [/\.css$/],
})
: undefined,
optimization: {
splitChunks: undefined,
},
// 這是將伺服器的整個輸出構建為單個 JSON ⽂件的外掛。
// 服務端預設⽂件名為 `vue-ssr-server-bundle.json`
// 客戶端預設⽂件名為 `vue-ssr-client-manifest.json`。
plugins: [
TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin(),
],
}),
chainWebpack: (config) => {
// cli4項⽬新增
if (TARGET_NODE) {
config.optimization.delete("splitChunks");
}
config.module
.rule("vue")
.use("vue-loader")
.tap((options) => {
merge(options, {
optimizeSSR: false,
});
});
},
};
處理 CSS
正常服務端路由我們可能會這樣寫:
router.get("/", async (ctx) => {
ctx.body = await render.renderToString();
});
但這樣打包後,啟動server
你會發現樣式沒生效。這個問題我們需要通過promise
的方式來解決:
pp.use(async (ctx) => {
try {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString({ url: ctx.url }, (err, data) => {
console.log("data", data);
if (err) reject(err);
resolve(data);
});
});
} catch (error) {
ctx.body = "404";
}
});
處理事件
之所以事件沒有生效是因為我們沒有進行客戶端啟用
操作,也就是把客戶端打包出來的clientBundle.js
掛載到HTML
上。
首先我們要在App.vue
的根結點加上app
的id
:
<template>
<!-- 客戶端啟用 -->
<div id="app">
<router-link to="/">foo</router-link>
<router-link to="/bar">bar</router-link>
<router-view></router-view>
</div>
</template>
<script>
import Bar from "./components/Bar.vue";
import Foo from "./components/Foo.vue";
export default {
components: {
Bar,
Foo,
},
};
</script>
然後通過vue-server-renderer
中的server-plugin
和client-plugin
分別生成vue-ssr-server-bundle.json
和vue-ssr-client-manifest.json
檔案,也就是服務端對映和客戶端對映。
最後在node
服務這裡做下關聯:
const ServerBundle = require("./dist/server/vue-ssr-server-bundle.json");
const template = fs.readFileSync("./public/index.html", "utf8");
const clientManifest = require("./dist/client/vue-ssr-client-manifest.json");
const render = VueServerRender.createBundleRenderer(ServerBundle, {
runInNewContext: false, // 推薦
template,
clientManifest,
});
這樣就完成了客戶端啟用操作,也就支援了 css
和事件。
資料模型的共享與狀態同步
在服務端渲染生成 html
前,我們需要預先獲取並解析依賴的資料。同時,在客戶端掛載(mounted)之前,需要獲取和服務端完全一致的資料,否則客戶端會因為資料不一致導致混入失敗。
為了解決這個問題,預獲取的資料要儲存在狀態管理器(store)中,以保證資料一致性。
首先是建立store
例項,同時供客戶端和服務端使用:
// src/store.js
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default () => {
const store = new Vuex.Store({
state: {
name: "",
},
mutations: {
changeName(state) {
state.name = "cosen";
},
},
actions: {
changeName({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit("changeName");
resolve();
}, 1000);
});
},
},
});
return store;
};
將createStore
加入到createApp
中,並將store
注入到vue
例項中,讓所有Vue
元件可以獲取到store
例項:
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
+ import createStore from "./store";
export default () => {
const router = createRouter();
+ const store = createStore();
const app = new Vue({
router,
+ store,
render: (h) => h(App),
});
+ return { app, router, store };
};
在頁面中使用store
:
// src/components/Foo.vue
<template>
<div>
Foo
<button @click="clickMe">點選</button>
{{ this.$store.state.name }}
</div>
</template>
<script>
export default {
mounted() {
this.$store.dispatch("changeName");
},
asyncData({ store, route }) {
return store.dispatch("changeName");
},
methods: {
clickMe() {
alert("測試點選");
},
},
};
</script>
如果用過nuxt
的同學肯定知道在nuxt
中有一個鉤子叫asyncData
,我們可以在這個鉤子發起一些請求,而且這些請求是在服務端發出的。
那我們來看下如何實現 asyncData
吧,在 server-entry.js
中我們通過 const matchs = router.getMatchedComponents()
獲取到匹配當前路由的所有元件,也就是我們可以拿到所有元件的 asyncData
方法:
// src/server-entry.js
// 服務端渲染只需將渲染的例項匯出即可
import createApp from "./main";
export default (context) => {
const { url } = context;
return new Promise((resolve, reject) => {
console.log("url", url);
// if (url.endsWith(".js")) {
// resolve(app);
// return;
// }
const { app, router, store } = createApp();
router.push(url);
router.onReady(() => {
const matchComponents = router.getMatchedComponents();
console.log("matchComponents", matchComponents);
if (!matchComponents.length) {
reject({ code: 404 });
}
// resolve(app);
Promise.all(
matchComponents.map((component) => {
if (component.asyncData) {
return component.asyncData({
store,
route: router.currentRoute,
});
}
})
)
.then(() => {
// Promise.all中方法會改變store中的state
// 把vuex的狀態掛載到上下文中
context.state = store.state;
resolve(app);
})
.catch(reject);
}, reject);
});
};
通過 Promise.all
我們就可以讓所有匹配到的元件中的asyncData
執行,然後修改服務端的store
了。而且也將服務端的最新store
同步到客戶端的store
中。
客戶端啟用狀態資料
上一步將state
存入context
後,在服務端渲染HTML
時,也就是渲染template
的時候,context.state
會被序列化到window.__INITIAL_STATE__
中:
可以看到,狀態已經被序列化到 window.__INITIAL_STATE__
中,我們需要做的就是將這個 window.__INITIAL_STATE__
在客戶端渲染之前,同步到客戶端的 store
中,下面修改 client-entry.js
:
// 客戶端渲染手動掛載到 dom 元素上
import createApp from "./main";
const { app, router, store } = createApp();
// 瀏覽器執行時需要將服務端的最新store狀態替換掉客戶端的store
if (window.__INITIAL_STATE__) {
// 啟用狀態資料
store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
app.$mount("#app", true);
});
通過使用store
的replaceState
函式,將window.__INITIAL_STATE__
同步到store
內部,完成資料模型的狀態同步。