最近我又双叒叕打算重写个人主页了, 这次打算尝试一下 Gatsby https://www.gatsbyjs.org/ , 这是背景.
如果大家不了解 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 https://github.com/facebook/react/issues/14281 这个 issue, 正符合我描述的现象.
其实这并不是一个 bug, 而是 by design. 简单来说 React SSR 以前是会重新渲染整个页面的, 因此上述的问题并不存在, 但是现在的版本中, React 会假设 SSR 的内容与 hydrate 后的内容一致. 也就是说, 我 SSR 出来的 HTML 是什么语言, 运行出来以后就应该是什么语言. 想要做到这一点也很容易, 分别为英文和中文添加路由. 语言还好说, 那主题呢? 如果以后再增加字号设置, 我难道要为每一种组合都添加路由? 显然是不行的.
当然, 方法还是有的, 就像 React 文档 https://reactjs.org/docs/react-dom.html#hydrate 所说的, 二次渲染就好. 因为 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 资源被缓存, 可以在短时间内加载并执行.
最后, 来看一下效果吧: https://cyandevio.unixzii.now.sh
来源: https://juejin.im/post/5c963ef2f265da60d63eb3ae