最近我又雙叒叕打算重寫個人主頁了,這次打算嘗試一下 Gatsby,這是背景。
如果大家不瞭解 Gatsby 是什麼,我這裡簡單介紹一下,它是一個基於 React 的靜態頁面構建工具。開發者通過編寫頁面模板(其實就是 React 元件)和配置檔案,Gatsby 就能為指定的資料檔案(可以是 Markdown 等)建立頁面。
開發過程中我一直使用的是 serve 模式,這個模式就類似於 webpack dev server,所有的路由都會 rewrite 到 index.html,完全由客戶端進行渲染。我在應用裡新增了很多偏好設定,例如多語言和夜間模式之類的。就拿多語言舉例,實現的大致思路就是寫一個 Context 作為 scope,然後所有 scope 下的元件都可以通過 useContext
拿到有關多語言的上下文資料。
看一下程式碼:
import React, { createContext, useState, useContext } from 'react';
import { setPref, getPref } from './globalPrefs';
const ctx = createContext({});
export function I18NScope(props) {
const [currentLang, setCurrentLang] = useState(getPref('lang') || 'en');
function _setCurrentLang(lang) {
setPref('lang', lang);
setCurrentLang(lang);
}
return (
<ctx.Provider
value={{
currentLang,
setCurrentLang: _setCurrentLang,
stringMap: props.stringMap }}>
{props.children}
</ctx.Provider>
);
}
export function useI18N(key) {
const { currentLang, setCurrentLang, stringMap } = useContext(ctx);
if (key) {
return ((stringMap || {})[currentLang] || {})[key] || key;
}
return { currentLang, setCurrentLang };
}
複製程式碼
使用的話也很簡單:
function Post(props) {
const { currentLang } = useI18N();
const { currentStyle } = useTheme();
const data = props.data;
return (
<>
<div style={{ position: 'relative', paddingRight: '40px' }}>
<Title text={data[currentLang].frontmatter.title} />
<Paragraph>{data[currentLang].frontmatter.subtitle}</Paragraph>
<Settings />
</div>
<div className={currentStyle.divider} />
<div style={{ marginTop: '20px' }}>
<article dangerouslySetInnerHTML={{ __html: data[currentLang].html }} />
</div>
<Links links={props.links} />
<Footer />
</>
);
}
複製程式碼
使用者設定語言後會同步到 LocalStorage 中,下一次應用啟動時 context 的預設值就是 LocalStorage 中儲存的值,這些都很簡單。
到這裡一切都沒有問題。正當我寫完一個版本打算 deploy 看一下效果時,我發現設定完語言再重新整理頁面,內容既有中文也有英文,英文正是預設語言(也就是 SSR 時輸出的 HTML 的語言)。
有英文的部分是 article
標籤下的文章內容,看上去是 dangerouslySetInnerHTML
屬性在 Hydrate 過程中沒被處理到。直覺告訴我這是 React 的 bug...
我迅速搜了一遍 GitHub 上的 issues,發現沒有和我情況一樣且與 dangerouslySetInnerHTML
相關的問題。後來我又發現,不僅僅是 dangerouslySetInnerHTML
不不一致,連 className
也不一致。於是我修改了關鍵字繼續搜尋,終於發現了 #14281 這個 issue,正符合我描述的現象。
其實這並不是一個 bug,而是 by design。簡單來說 React SSR 以前是會重新渲染整個頁面的,因此上述的問題並不存在,但是現在的版本中,React 會假設 SSR 的內容與 hydrate 後的內容一致。也就是說,我 SSR 出來的 HTML 是什麼語言,執行出來以後就應該是什麼語言。想要做到這一點也很容易,分別為英文和中文新增路由。語言還好說,那主題呢?如果以後再增加字號設定,我難道要為每一種組合都新增路由?顯然是不行的。
當然,方法還是有的,就像 React 文件所說的,二次渲染就好。因為 SSR 過程是不會觸發 componentDidMount()
和 useEffect
的 effect 的。所以我們可以通過一個狀態來識別當前的環境。一旦 componentDidMount()
或者 effect 被呼叫,就說明現在是客戶端渲染,這時再應用 LocalStorage 裡的設定重新渲染就可以了。
既然方法有了,剩下的事情就很簡單了,直接修改我們的 context 元件就行了:
export function I18NScope(props) {
const isClient = useClientEnv(); // 新增這個狀態
const [currentLang, setCurrentLang] = useState(getPref('lang') || 'en');
function _setCurrentLang(lang) {
setPref('lang', lang);
setCurrentLang(lang);
}
return (
<ctx.Provider
value={{
currentLang: isClient ? currentLang : 'en',
setCurrentLang: _setCurrentLang,
stringMap: props.stringMap }}>
{props.children}
</ctx.Provider>
);
}
複製程式碼
其中 useClientEnv
就是一個自定義 hook:
import { useState, useEffect } from 'react';
export function useClientEnv() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
}
複製程式碼
重新 deploy,問題解決了。
TL;DR
SSR 和第一次客戶端渲染的內容要保持一致,如果一定會有不一致,那就在第二次渲染時再渲染最新內容。
現在的 SSR 主要有兩種目的,一種是為了減少首屏等待時間,那麼對於這種目的,我們就可以在服務端渲染最少量的內容,例如只渲染出 skeleton。
另外一種是為了 SEO,那麼服務端就需要渲染頁面實際的內容,對於上面多語言的 case,其實最佳實踐就是用路由控制顯示的語言版本,這也有利於搜尋引擎爬取內容,你一定不希望使用者搜尋出來的是中文,點進去卻是英文吧。而主題、字號這類偏好設定,可以通過二次渲染來同步,不過這又引出了另外一個問題:頁面閃爍。頁面會在 JS 載入完的一瞬間重新渲染。即便 JS 被快取,HTML 載入完成和 JS 載入完成並執行之間還是會有一定的時間間隔。這裡可以做一個簡單的優化:先將內容通過 CSS 隱藏起來,並在內聯 script 標籤中啟動定時器,超時後顯示內容以防首次 JS bundle 載入時間過長。後期就可以通過 Service Worker 等方式快取 JS bundle 和相關資源,那麼之後在進入頁面時,由於 JS 資源被快取,可以在短時間內載入並執行。
最後,來看一下效果吧:cyandevio.unixzii.now.sh