构建“防弹”的 React 组件(Build Bulletproof React Components)

2026-02-07 · 原文链接

我滑向冰球将要出现的位置,而不是它曾经所在的位置。
—— Wayne Gretzky

大多数组件都是为“理想路径(happy path)”构建的。它们能工作——直到某天不工作为止。现实世界是充满敌意的:服务端渲染、hydration、多实例、并发渲染、异步 children、portal……你的组件可能会遇到所有这些情况。问题是:它能撑得住吗?

真正的考验,不是你的组件在你当前页面上能不能用,而是当别人把它拿去用——在你从未预设过的条件下——它还能不能用。脆弱(fragile)的组件就是在这种时候崩掉的。

下面是让它“活下来”的方法。

  1. 让它 Server-Proof(服务端安全)
  2. 让它 Hydration-Proof(hydration 安全)
  3. 让它 Instance-Proof(多实例安全)
  4. 让它 Concurrent-Proof(并发安全)
  5. 让它 Composition-Proof(组合方式安全)
  6. 让它 Portal-Proof(Portal 安全)
  7. 让它 Transition-Proof(过渡动画安全)
  8. 让它 Activity-Proof(Activity 安全)
  9. 让它 Leak-Proof(防泄漏)
  10. 让它 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