在這個系列中,我們一共實作了約 27 種不同的 Atomic Components。但在實際開發上,我們不總是只需要顧好 Client Side Rendering(CSR)。如果我們今天要實作的專案是像:電商平台、新聞網站、企業形象網站等等,這些網站對於 SEO 的要求比較高,也更在意使用者體驗,像是首次渲染的速度,這時我們可能會需要照顧到 Server Side Rendering(SSR,Universal Rendering)。
在這系列文章中,我們有特別提到 Element Plus 的某些元件在 SSR 上的表現就不是太理想,甚至有許多元件在文件上特別標記無法正確執行 SSR,使用時需要 <ClientOnly>
元件來確保這些元件只會在 Client 端被渲染。
📝 NOTE
關於 Element Plus 特別匡列出來需要使用<ClientOnly>
的元件,大多是底層使用到<ElTooltip>
的元件。另外,文件在這個 PR(docs: remove tip about client-only #17852)中已經被刪除,不過如果不是使用 Nuxt 作為 SSR 解決方案的使用者,仍可能需要自行解決相關的問題。
在 Client Side,Vue 初始化後會建立一個 VNode 樹,並使用 document.createElement
方法講畫面渲染出來;在 SSR 的情境下沒有,Server 主要要再做的是動態生成一份 HTML 檔案回傳到 Client Side 讓瀏覽器渲染,因此 Server 會將 VNode 組合成 HTML 「字串」回傳到前端。
說明一下,Server Side 是如何將 VNode 組合成 HTML 字串的:
這是一個 VNode 的範例:
const vnode = {
type: 'div',
props: {
id: 'foo',
},
children: [
{
type: 'p',
children: 'Hello, World!',
},
],
}
第一步,vnode.type
會決定起始標籤的名稱。
<div
第二步,將 props 轉換成 HTML 屬性。
<div id="foo">
接著,會將子元素逐一組合出來。
<div id="foo">
<p>Hello, World!</p>
最後,children
組合染完後,加上結束標籤。
<div id="foo">
<p>Hello, World!</p>
</div>
我們發現,這個組合過程會由上至下,由外至內逐層組合,而且這個過程是一次性的,也就是說在 Server Side 已經生成的 HTML 字串不會隨著資料改變而更新。
舉個例子,我們有兩個元件,<Parent>
與 <Child>
:
<Parent>
const number = ref(0)
provide('number', {
number,
update(value: number) {
number.value = value
},
})
<template>
<div>Parent: {{ number }}</div>
<Child />
<div>Parent: {{ number }}</div>
</template>
<Child>
const { number, update } = inject('number')
update(10)
<template>
<div>Child: {{ number }}</div>
</template>
我們可能預期從 Server Side 取得的 HTML 結果會是這樣:
<div>Parent: 10</div>
<div>Child: 10</div>
<div>Parent: 10</div>
但實際上會拿到下這樣的結果
<div>Parent: 0</div>
<div>Child: 10</div>
<div>Parent: 10</div>
我們可以從開發人員工具的 Network 中找到從 Server 回傳的 HTML,從 Preview 中可以看到在 <Child>
之前的 number
維持在 0,而 <Child>
之後的 number
則是 10。
我們覆盤一下 SSR 的過程:
<Parent>
執行 setup
,初始化 number
為 0。<Parent>
模板的第一行,此時 number
為 0。<Child>
執行 setup
,透過 inject
取得 number
與 update
。update(10)
,number
變成 10。<Child>
,此時 number
為 10。<Parent>
的第三行,此時 number
為 10。有了這上面的認知後,我們可以開始解釋在 [為你自己寫 Vue Component] AtomicTabs 中提到的問題。
<template>
<ElTabs v-model="tab">
<ElTabPane label="User" name="first">User</ElTabPane>
<ElTabPane label="Config" name="second">Config</ElTabPane>
<ElTabPane label="Role" name="third">Role</ElTabPane>
<ElTabPane label="Task" name="fourth">Task</ElTabPane>
</ElTabs>
</template>
一開始會先渲染 <ElTabs>
的 Nav 區塊:
<div class="el-tabs">
<div class="el-tabs__nav">
<!-- 空 -->
</div>
執行到 <ElTabPane label="User">
時,除了 default slot 外,還會試圖往上向 <ElTabs>
的 Nav 區塊新增一個 Tab。
<div class="el-tabs">
<div class="el-tabs__nav">
<!-- Client Side 會在這裡新增下面的結構,但 Server Side 不會 -->
<!-- <div class="el-tabs__item">User</div> -->
</div>
<!-- Content 是 <ElTabs> 的 default slot -->
<div class="el-tabs__content">
User
</div>
不過,就像前面提到的「已經生成的 HTML 字串不會隨著資料改變而更新」,因此我們最後從 Server Side 得到的結構會像是下面這樣:
<div class="el-tabs">
<div class="el-tabs__nav">
</div>
<div class="el-tabs__content">
User
</div>
<div class="el-tabs__content" style="display: none;">
Config
</div>
<div class="el-tabs__content" style="display: none;">
Role
</div>
<div class="el-tabs__content" style="display: none;">
Task
</div>
</div>
這也說明了為什麼在那篇文章中會提到這種「由子層元件決定上層元件渲染」的設計,在 SSR(Server Side Rendering)的場景下可能會遇到一些問題。
有解決方案嗎?有,目前 Element Plus 在 2.8.0 後的版本改變了 <ElTabs>
的渲染順序:
<div class="el-tabs">
<div class="el-tabs__content">
User
</div>
<div class="el-tabs__content" style="display: none;">
Config
</div>
<div class="el-tabs__content" style="display: none;">
Role
</div>
<div class="el-tabs__content" style="display: none;">
Task
</div>
<div class="el-tabs__nav">
<div class="el-tabs__item">User</div>
<div class="el-tabs__item">Config</div>
<div class="el-tabs__item">Role</div>
<div class="el-tabs__item">Task</div>
</div>
</div>
讓 Nav 區塊的內容在 default slot 之後渲染。這時,由於資料在渲染前就已經被更新,就不會有上面提到的問題,最後顯示時再用 CSS 將視覺上的順序調換,看起來就像是解決了上面提到的問題。不過卻也衍生出了無障礙鍵盤操作的問題。
📝 NOTE
上面 Element Plus 的渲染結構,為了方便閱讀,做了很多的簡化,實際上 Element Plus 渲染的結構會更為複雜。
在 SSR 的情境下,由於 Server Side 已經渲染好了 HTML 結構,所以 Vue 不再需要一個一個 DOM 建立起來,而只需要將元件初始化,並且將每個元件的 VNode 與畫面上的 HTML 進行匹配、綁定事件,這個過程稱為 Hydration(水合)。
📝 NOTE
推薦閱讀 從歷史的角度探討多種 SSR(Server-side rendering),我很喜歡胡立使用「脫水」的比喻來形容 Server 端的行為。另外也描述 Client Side 需要「注入水」,讓脫水後扁平乾燥的畫面活起來,非常生動。
既然水合的過程是將元件的 VNode 與畫面上的 HTML 進行匹配、綁定事件,那麼前後的一致性就非常重要。
// Server Side
const vnode = {
type: 'div',
props: {
id: 'foo',
},
children: [
{
type: 'p',
children: 'Hello, World!',
},
],
}
由前一段我們知道,上面的 VNode 會被渲染成下面的 HTML:
<div id="foo">
<p>Hello, World!</p>
</div>
如果這時在 Client Side 初始化後的 VNode 與 Server Side 輸出的 HTML 不一致,就會導致 Hydration Error。
// Client Side
const vnode = {
type: 'div',
props: {
id: 'bar',
},
children: [
{
type: 'p',
children: 'Hello, World!',
},
],
}
因此,我們需要確保 Server Side 與 Client Side 的 VNode 是一致的。在我們前面實作的元件中,有一些元件在 SSR 的情境下會有問題,像是這段:
📝 NOTE
以前在使用 Nuxt 2 開發時曾踩過這個雷。當時嘗試在 Server Side 從pages
往上更新layout
的內容失敗,後來又過了很久才理解有這層限制在。Can't update layout content after component created at server side #10365
const id = `tab-${Math.round(Math.random() * 1e5)}`;
在 <AtomicTabs>
的內部使用了 Math.random()
產生了隨機的 id
,分別在 Server Side 與 Client Side 執行,它們幾乎不會得到相同的結果。因此,如果我們將有使用到隨機亂數 id
的元件放到 SSR 專案中,就會導致 Hydration Error。
我們需要想辦法確保 Server Side 與 Client Side 的 VNode 是一致的。
要解決我們現在遇到的 id
不一致問題,有幾種方式可以解決這個問題:
用 Plugin 的方式管理並產生全站唯一的 id
我們可以考慮新增一個 atomic plugin,來統一管理全站唯一的 id
,這裡要做的事情非常簡單。
plugin
import type { InjectionKey, ObjectPlugin } from 'vue';
export const ATOMIC_INJECT_KEY = Symbol() as InjectionKey<{ id: number }>;
function createAtomic() {
return <ObjectPlugin>{
install(app) {
app.provide(ATOMIC_INJECT_KEY, {
id: 0,
});
},
};
}
composables
import { ATOMIC_INJECT_KEY } from '~/plugins/atomic';
export default function useId(prefix: string = 'atomic') {
const context = inject(ATOMIC_INJECT_KEY, null);
if (!context) throw new Error('error');
return `${prefix}-${context.id++}`;
}
使用
// const id = `tab-${Math.round(Math.random() * 1e5)}`;
const id = useId('tab');
因為在 Server Side 渲染 HTML 跟 Client Side 初始化的順序基本上是一致的,這樣我們可以確保生成的 id
在 Server 與 Client 是一致的。
使用自定義指令(Custom Directives)
大多數的情況下,自定義指令都包含了對 DOM 的操作,因此它們在 SSR 時會被忽略。但如果我們想要控制指令在 SSR 時如何被渲染,我們可以使用 getSSRProps
這個方法。
import { kebabCase } from 'scule';
const BindOncePlugin: ObjectPlugin = {
created: (el, binding) => {
for (const key in binding.value) {
const k = kebabCase(key);
if (!el.hasAttribute(k)) {
el.setAttribute(k, binding.value[key]);
}
}
},
mounted: (el, binding) => {
for (const key in binding.value) {
const k = kebabCase(key);
el.setAttribute(k, binding.value[key]);
}
},
getSSRProps(binding) {
if (!binding.value) return {};
return Object.fromEntries(
Object.entries(binding.value).map(([key, value]) => [
kebabCase(key),
value,
])
);
},
};
在模板中使用指令
<template
v-for="tab in tabs"
:key="tab.value"
>
<button
v-bind-once="{
id: tab.id,
ariaControls: tab.tabpanelId,
}"
class="atomic-tabs__tab"
>
...
</button>
</template>
或者可以考慮直接使用由 Nuxt 核心成員 Daniel Roe 維護的 v-bind-once。
使用 Vue 3.5 的 useId
如果夠幸運,專案能夠升級到 Vue 3.5 以上的版本,Vue 3.5 提供了 useId
這個 API,可以幫我們生成穩定的 id
。
// const id = `tab-${Math.round(Math.random() * 1e5)}`;
const id = useId();
💡 TIP
在 Vue 3.5 後的版本,我們還可以使用data-allow-mismatch
屬性來消除 Hydration Error。<div data-allow-mismatch="text">{{ Date.now() }}</div>
上面三種方法都可以解決 Server Side 與 Client Side 的 id
不一致問題,讓我們避免 Hydration Error 的發生,可以依照專案的性質挑選適合的解決方案。
雖然我們這裡以亂數 id
造成的 Hydration Error 為例,但不論是哪個環節,在開發 SSR 專案時,我們都需要確保 Server Side 與 Client Side 初始化的 VNode 是一致的,這樣才能避免所有 Hydration Error 的發生。
在處理 SSR 專案時,有些不起眼的差異可能會讓我們一不小心就造成跨請求狀態污染與記憶體洩漏。我們以在 <AtomicToast>
中實作的「資料管理中心」為例,看看是什麼不起眼的差異造成了跨請求狀態污染與記憶體洩漏。
在處理資料管理中心時,我們使用了一個陣列來存放 Toast 的資料。
const toasts = shallowReactive<Toast[]>([]);
我們可以選擇將這個陣列放在全域,或是放在建立 plugin 的 function 裡面。
將 toasts
放在全域:
const toasts = shallowReactive<Toast[]>([]);
export function createToastsManager() {
// ...
}
將 toasts
放在建立 plugin 的 function 裡面:
export function createToastsManager() {
const toasts = shallowReactive<Toast[]>([]);
// ...
}
對 CSR 專案來說,這個陣列擺放的位置影響並不大。在 Client Side(瀏覽器環境)沒有多個請求問題,自然不會有跨請求污染的問題。也因為每次網頁重新整理都可以視為一個全新的環境,所以也不會因為 toasts
沒有清空而造成記憶體洩漏。
但在 SSR 專案中,這個陣列擺放的位置就變得相對重要了。
對於 SSR 來說,每個請求都會重新建立一次 Vue instance。這時,第一種寫法存在一些問題。如果我們在 Server Side 對 toasts
新增了資料,因為 toasts
現在是全域變數,它不會在下一個請求時重新初始化,那麼這個資料就有機會影響到下一個請求,造成了跨請求狀態污染。也因為這時 toasts
沒有一個機制可以去清除裡面的資料,隨著時間的推移,資料只會越積越多,進而造成機器的記憶體耗盡,這在流量大的平台上更為明顯。
因此,考量到 SSR 的使用情境,我們應該盡量將 toasts
放在 function 內,這樣每次請求都會有獨立的 toasts
,並且在該請求結束後釋放掉記憶體,避免了跨請求狀態污染,同時也解決了記憶體洩漏的問題。
💡 TIP
因為有非同步問題,所以我們也不能擅自在接收到新的請求時清空toasts
,這樣反而會造成同一個請求資料不一致的問題。關於資料管理中心會遇到的跨請求狀態污染問題,在我之前寫過的:「深入淺出 pinia(一):createPinia、defineStore #跨請求狀態污染(Cross-Request State Pollution)」有更詳細的說明。
📝 NOTE
在 Vue Final Modal 上曾經收到關於 SSR 時記憶體洩漏的 issue 回報,除了在全域變數上存放資料外,還牽涉到了在不正確的生命週期中新增資料,這也是一個需要注意的地方。
今天我們探討了 Server Side Rendering(Universal Rendering)的注意事項,算是解開了前面懸而未決的問題。
在 Server Side Rendering 的開發中,我們需要注意以下幾點:
如果我們只開發過 CSR 的專案,上面的每個問題可能平時不會遇到,也有點難以想像。然而,當我們需要考量 SSR 的開發場景時,這些問題便顯得很重要。其實 SSR 還有很多眉眉角角,這裡只是其中一部分,更多需要注意的細節在 Vue 的 SSR 文件中都有提到,實作前務必閱讀公開說明書。