關於 SSR 內容一致性的問題
最近我又雙叒叕打算重寫個人主頁了,這次打算嘗試一下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