最近我又双叒叕打算重写我的主页了,此次打算尝试一下 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