我滑向冰球将要出现的位置,而不是它曾经所在的位置。
—— Wayne Gretzky
大多数组件都是为“理想路径(happy path)”构建的。它们能工作——直到某天不工作为止。现实世界是充满敌意的:服务端渲染、hydration、多实例、并发渲染、异步 children、portal……你的组件可能会遇到所有这些情况。问题是:它能撑得住吗?
真正的考验,不是你的组件在你当前页面上能不能用,而是当别人把它拿去用——在你从未预设过的条件下——它还能不能用。脆弱(fragile)的组件就是在这种时候崩掉的。
下面是让它“活下来”的方法。
- 让它 Server-Proof(服务端安全)
- 让它 Hydration-Proof(hydration 安全)
- 让它 Instance-Proof(多实例安全)
- 让它 Concurrent-Proof(并发安全)
- 让它 Composition-Proof(组合方式安全)
- 让它 Portal-Proof(Portal 安全)
- 让它 Transition-Proof(过渡动画安全)
- 让它 Activity-Proof(Activity 安全)
- 让它 Leak-Proof(防泄漏)
- 让它 Future-Proof(面向未来)*
Make It Server-Proof
一个简单的主题 provider,从 localStorage 里读取用户偏好:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(
localStorage.getItem('theme') || 'light'
)
return <div className={theme}>{children}</div>
}
旁注:SSR 会崩——因为从 localStorage 读主题
但服务端并不存在 localStorage。在 Next.js、Remix 或任何 SSR 框架中,这会导致构建崩溃。把浏览器 API 放进 useEffect:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
setTheme(localStorage.getItem('theme') || 'light')
}, [])
return <div className={theme}>{children}</div>
}
旁注:useEffect 把 localStorage 推迟到只在客户端运行
这样它就可以在服务端渲染而不崩溃。
Make It Hydration-Proof
我也把这称为“防水”。服务端安全版本能工作,但用户会看到一次闪烁:服务端先渲染 light,客户端 hydration 后 effect 才运行,然后切到 dark:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
setTheme(localStorage.getItem('theme') || 'light')
}, [])
return <div className={theme}>{children}</div>
}
旁注:错误主题闪烁——useEffect 在 hydration 之后才运行
解决办法:注入一段同步脚本,在浏览器绘制与 React hydration 之前就把正确值设置好。这样 React 接管时 DOM 已经带着正确 class:
function ThemeProvider({ children }) {
return (
<>
<div id="theme">{children}</div>
<script dangerouslySetInnerHTML={{ __html: `
try {
const theme = localStorage.getItem('theme') || 'light'
document.getElementById('theme').className = theme
} catch (e) {}
`}} />
</>
)
}
旁注:内联脚本在浏览器绘制前设置主题
不再 mismatch,也不会闪。
Make It Instance-Proof
Hydration-safe 版本用了硬编码的 id="theme"。但如果有人用两个 ThemeProvider 呢?
function App() {
return (
<>
<ThemeProvider><MainContent /></ThemeProvider>
<AlwaysLightThemeContent />
<ThemeProvider><Sidebar /></ThemeProvider>
</>
)
}
旁注:多实例——两个脚本都在操作同一个 ID
两个脚本会争抢同一个元素。用 useId 为每个实例生成稳定且唯一的 ID:
function ThemeProvider({ children }) {
const id = useId()
return (
<>
<div id={id}>{children}</div>
<script dangerouslySetInnerHTML={{ __html: `
try {
const theme = localStorage.getItem('theme') || 'light'
document.getElementById('${id}').className = theme
} catch (e) {}
`}} />
</>
)
}
旁注:useId 为每个实例生成唯一 ID
现在多个实例可以安全共存。
Make It Concurrent-Proof
接下来把主题改成由服务端驱动。一个从数据库读取用户偏好的 Server Component:
async function ThemeProvider({ children }) {
const prefs = await db.preferences.get(userId)
return <div className={prefs.theme}>{children}</div>
}
旁注:Server Component 从数据库拉取偏好
类似之前的例子,如果你在两个地方渲染它,可能会触发两次相同的数据库查询。用 React.cache 把查询包起来,在单次请求里去重:
import { cache } from 'react'
const getPreferences = cache(
userId => db.preferences.get(userId)
)
async function ThemeProvider({ children }) {
const prefs = await getPreferences(userId)
return <div className={prefs.theme}>{children}</div>
}
旁注:React cache() 会对并发调用去重
同一个查询,无论从哪里调用,在一次请求里只会 hit 数据库一次。
Make It Composition-Proof
有时候你想把数据以 props 传给 children,传统方式会用 React.cloneElement:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return React.Children.map(children, (child) => {
return React.cloneElement(child, { theme })
})
}
旁注:通过 cloneElement 把 theme 传给 children
但在 React Server Components、React.lazy 或 "use cache" 等场景里,children 可能是 Promise,或者是一个不透明引用——cloneElement 就不工作了。改用 context:
const ThemeContext = createContext('light')
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
)
}
旁注:Context 适用于任何地方——server、client、async
children 通过 useContext 读取主题——不用 prop drilling,也不用 clone。
Make It Portal-Proof
一个带快捷键的主题 provider——Cmd+D 切换深色模式:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
const toggle = (e) => {
if (e.metaKey && e.key === 'd') {
e.preventDefault()
setTheme(t => t === 'dark' ? 'light' : 'dark')
}
}
window.addEventListener('keydown', toggle)
return () => window.removeEventListener('keydown', toggle)
}, [])
return <div className={theme}>{children}</div>
}
旁注:全局键盘快捷键切换主题
但如果有人把应用渲染在弹出窗口、iframe,或者通过 createPortal 渲染,快捷键就失效了。因为 listener 挂在父 window 上,而不是组件所在的 window。用 ownerDocument.defaultView:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
const ref = useRef(null)
useEffect(() => {
const win = ref.current?.ownerDocument.defaultView || window
const toggle = (e) => {
if (e.metaKey && e.key === 'd') {
e.preventDefault()
setTheme(t => t === 'dark' ? 'light' : 'dark')
}
}
win.addEventListener('keydown', toggle)
return () => win.removeEventListener('keydown', toggle)
}, [])
return <div ref={ref} className={theme}>{children}</div>
}
旁注:ownerDocument.defaultView 找到正确的 window
现在这个快捷键在任何 window 上下文里都能工作。
Make It Transition-Proof
一个设置面板,用于在简单/高级视图之间切换:
function ThemeSettings() {
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<>
{showAdvanced ? <AdvancedPanel /> : <SimplePanel />}
<button onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? 'Simple' : 'Advanced'}
</button>
</>
)
}
旁注:在两个面板间用简单 state 切换
把它包在 React 19 的 <ViewTransition> 里之后,你会发现什么都不动画——面板只是“啪”地切过去。因为 state 更新必须走 startTransition:
function ThemeSettings() {
const [showAdvanced, setShowAdvanced] = useState(false)
return (
<>
{showAdvanced ? <AdvancedPanel /> : <SimplePanel />}
<button onClick={() =>
startTransition(() => setShowAdvanced(!showAdvanced))
}>
{showAdvanced ? 'Simple' : 'Advanced'}
</button>
</>
)
}
旁注:startTransition 启用 view transition
现在过渡会顺滑地动画起来。
Make It Activity-Proof
一个主题组件,通过 <style> 注入 CSS 变量:
function DarkTheme({ children }) {
return (
<>
<style>{`
:root {
--bg: #000;
--fg: #fff;
}
`}</style>
{children}
</>
)
}
旁注:通过 style 标签注入全局 CSS 变量
但如果你把它包在 <Activity> 里,即便组件隐藏了,深色主题仍会持续生效。因为 <Activity> 会保留 DOM,而 <style> 有 DOM 级别的副作用——它会全局修改 :root 变量。React 无法自动清理这些副作用。可以通过 media="not all" 在隐藏时禁用这些样式:
function DarkTheme({ children }) {
const ref = useRef(null)
useLayoutEffect(() => {
if (!ref.current) return
ref.current.media = 'all'
return () => ref.current.media = 'not all'
}, [])
return (
<>
<style ref={ref}>{`
:root {
--bg: #000;
--fg: #fff;
}
`}</style>
{children}
</>
)
}
旁注:useLayoutEffect 在隐藏时设 media='not all',显示时恢复
这样隐藏的组件就不会把深色主题应用在页面上。
Make It Leak-Proof
一个 Server Component 把一个 user 对象(包含 session token)传给另一个主题组件。这个需求是合理的——你在服务端需要数据。你可能知道 UserThemeConfig 是 Server Component,并且把数据传给它是安全的。
async function Dashboard() {
const user = await getUser()
return <UserThemeConfig user={user} />
}
旁注:Dashboard 把 user(带 token)传给另一个组件
但你并不知道 UserThemeConfig 的确切行为:它会渲染什么、未来版本可能会做什么;你也并不维护它。
另外,因为 UserThemeConfig 并不是创建 user 的那个组件,它可能并不知道 user 里有个敏感字段 token。你无法控制这个组件,所以不能假设它不会在树的某处把 token 传给 Client Component。这样 token 就会被序列化并发送到客户端。
用 React 的实验特性 taintUniqueValue 把 token 标记为“仅服务端”。如果这个值被传进任何 Client Component,React 会直接抛错。若要阻止整个对象而不是单个值,则使用 taintObjectReference。
import { experimental_taintUniqueValue } from 'react'
async function Dashboard() {
const user = await getUser()
experimental_taintUniqueValue(
'不要把 user token 传到客户端。',
user,
user.token
)
return <UserThemeConfig user={user} />
}
旁注:taintUniqueValue 阻止 user.token 被发送到客户端
如果这个组件的代码(或未来团队里别人重构)试图把 user.token 传给 Client Component,React 会带着你的错误信息抛异常。需求仍能满足,而 token 也不会泄漏。
Make It Future-Proof*
这是一个需要理解的概念:要有防御意识。并不是一个要到处套用的模式。
一个主题在 mount 时生成随机的强调色:
function ThemeProvider({ baseTheme, children }) {
const colors = useMemo(
() => getRandomColors(baseTheme),
[baseTheme]
)
return <div style={colors}>{children}</div>
}
旁注:useMemo 缓存生成的颜色
但 useMemo 是一个性能提示,而不是语义保证。React 在 HMR 时会丢弃缓存值,并且未来也保留在 offscreen 组件或尚不存在的特性上丢弃缓存的权利。如果缓存被丢弃,你的主题就会闪到另一组颜色。当正确性依赖于“持久性”时,用 state。
function ThemeProvider({ baseTheme, children }) {
const [colors, setColors] = useState(() => generateAccentColors(baseTheme))
const [prevTheme, setPrevTheme] = useState(baseTheme)
if (baseTheme !== prevTheme) {
setPrevTheme(baseTheme)
setColors(generateAccentColors(baseTheme))
}
return <div style={colors}>{children}</div>
}
旁注:useState 提供语义上的持久性保证
现在无论 React 内部如何优化,颜色都能保持稳定。
这些都不是边缘案例,而是新的常态。那些会崩的组件?它们并不“脆弱”,它们只是为昨天的 React 而写。我们要为明天构建。
感谢 Jiachi proof-reading。