關於 SSR 內容一致性的問題

Cyandev發表於2019-03-23

最近我又雙叒叕打算重寫個人主頁了,這次打算嘗試一下 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

相關文章