最近我又雙叒叕打算重寫我的主頁了,此次打算嘗試一下 Gatsby,這是背景。html
若是你們不瞭解 Gatsby 是什麼,我這裏簡單介紹一下,它是一個基於 React 的靜態頁面構建工具。開發者經過編寫頁面模板(其實就是 React 組件)和配置文件,Gatsby 就能爲指定的數據文件(能夠是 Markdown 等)建立頁面。react
開發過程當中我一直使用的是 serve 模式,這個模式就相似於 webpack dev server,全部的路由都會 rewrite 到 index.html,徹底由客戶端進行渲染。我在應用裏添加了不少偏好設置,例如多語言和夜間模式之類的。就拿多語言舉例,實現的大體思路就是寫一個 Context 做爲 scope,而後全部 scope 下的組件均可以經過 useContext
拿到有關多語言的上下文數據。webpack
看一下代碼:git
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 }; } 複製代碼
使用的話也很簡單:github
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 中存儲的值,這些都很簡單。web
到這裏一切都沒有問題。正當我寫完一個版本打算 deploy 看一下效果時,我發現設置完語言再刷新頁面,內容既有中文也有英文,英文正是默認語言(也就是 SSR 時輸出的 HTML 的語言)。緩存
有英文的部分是 article
標籤下的文章內容,看上去是 dangerouslySetInnerHTML
屬性在 Hydrate 過程當中沒被處理到。直覺告訴我這是 React 的 bug...dom
我迅速搜了一遍 GitHub 上的 issues,發現沒有和我狀況同樣且與 dangerouslySetInnerHTML
相關的問題。後來我又發現,不只僅是 dangerouslySetInnerHTML
不不一致,連 className
也不一致。因而我修改了關鍵字繼續搜索,終於發現了 #14281 這個 issue,正符合我描述的現象。ide
其實這並非一個 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,問題解決了。
SSR 和第一次客戶端渲染的內容要保持一致,若是必定會有不一致,那就在第二次渲染時再渲染最新內容。
如今的 SSR 主要有兩種目的,一種是爲了減小首屏等待時間,那麼對於這種目的,咱們就能夠在服務端渲染最少許的內容,例如只渲染出 skeleton。
另一種是爲了 SEO,那麼服務端就須要渲染頁面實際的內容,對於上面多語言的 case,其實最佳實踐就是用路由控制顯示的語言版本,這也有利於搜索引擎爬取內容,你必定不但願用戶搜索出來的是中文,點進去倒是英文吧。而主題、字號這類偏好設置,能夠經過二次渲染來同步,不過這又引出了另一個問題:頁面閃爍。頁面會在 JS 加載完的一瞬間從新渲染。即使 JS 被緩存,HTML 加載完成和 JS 加載完成並執行之間仍是會有必定的時間間隔。這裏能夠作一個簡單的優化:先將內容經過 CSS 隱藏起來,並在內聯 script 標籤中啓動定時器,超時後顯示內容以防首次 JS bundle 加載時間過長。後期就能夠經過 Service Worker 等方式緩存 JS bundle 和相關資源,那麼以後在進入頁面時,因爲 JS 資源被緩存,能夠在短期內加載並執行。
最後,來看一下效果吧:cyandevio.unixzii.now.sh