React组件防御性编程:应对SSR、水合、并发等场景

我滑向冰球将要出现的地方,而不是它已经出现过的地方。
— 韦恩·格雷茨基

大多数React组件都是在理想条件下构建的。它们在你的开发环境中工作得很好——直到它们被用于你没有预见的场景。现实世界充满了挑战:服务器端渲染、水合、多个实例、并发渲染、异步子组件、门户网站……你的组件可能会面临所有这些情况。关键问题是:它能否存活下来?

真正的考验不在于你的组件是否在你当前的页面上工作。而在于当别人在你没有计划的条件下使用它时,它是否仍然能够正常工作。那时,缺乏防御的脆弱组件就会崩溃

以下是如何通过防御性编程思想来构建能够应对各种挑战的组件的方法。

  1. 应对服务器端渲染
  2. 应对水合过程
  3. 应对多实例场景
  4. 应对并发渲染
  5. 应对组件组合
  6. 应对Portal(传送门)
  7. 应对视图转换
  8. 应对 Activity 组件
  9. 应对未来变化

应对服务器端渲染

考虑一个简单的主题提供者,它从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的访问仅在客户端执行

现在组件可以安全地在服务器上渲染而不会崩溃。

应对水合过程

上面的实现解决了SSR问题,但引入了新的问题:用户会看到主题闪烁。服务器渲染的是light主题,客户端水合后,useEffect才运行并切换到dark主题:

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    setTheme(localStorage.getItem('theme') || 'light')
  }, [])

  return <div className={theme}>{children}</div>
}

问题: 主题闪烁——useEffect在水合后才执行,导致视觉不一致

防御性的解决方案是:在浏览器绘制和React水合之前,通过同步脚本设置正确的值。这样当React接管DOM时,样式已经是正确的:

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) {}
      `}} />
    </>
  )
}

防御策略: 内联脚本在浏览器绘制前同步设置主题,消除闪烁

这样既没有不匹配,也没有视觉闪烁。

应对多实例场景

上面的实现使用了硬编码的id="theme"。但如果应用中存在多个ThemeProvider实例呢?

function App() {
  return (
    <>
      <ThemeProvider><MainContent /></ThemeProvider>
      <AlwaysLightThemeContent />
      <ThemeProvider><Sidebar /></ThemeProvider>
    </>
  )
}

问题: 两个脚本都试图操作同一个ID的元素,导致冲突和不可预测的行为

防御性的方案是:为每个实例生成唯一的、稳定的标识符。使用React的useId钩子:

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,避免冲突

现在即使存在多个实例,它们也能安全地独立工作。

应对并发渲染

现在考虑一个更复杂的场景:主题由服务器驱动,通过服务器组件从数据库获取用户偏好:

async function ThemeProvider({ children }) {
  const prefs = await db.preferences.get(userId)

  return <div className={prefs.theme}>{children}</div>
}

问题: 如果在多个地方渲染这个组件,会导致重复的数据库查询,浪费资源

防御性的做法是:使用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>
}

防御策略: cache()去重并发查询,优化性能和资源使用

现在无论从多少个地方调用,相同的查询都只会命中数据库一次。

应对组件组合

在传统的React模式中,将数据传递给子组件通常使用React.cloneElement

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  return React.Children.map(children, (child) => {
    return React.cloneElement(child, { theme })
  })
}

问题: 这种方法在现代React特性中失效

当使用React服务器组件React.lazy"use cache"时,children可能是Promise或不透明引用,cloneElement无法处理。

防御性的方案是:使用Context API替代prop传递,这种方法在所有场景下都能工作:

const ThemeContext = createContext('light')

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  )
}

防御策略: Context API在服务器、客户端、异步场景中都能正常工作

子组件通过useContext读取主题——无需prop钻取,无需克隆操作。

应对Portal(传送门)

考虑一个带有全局键盘快捷方式的主题提供者——按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>
}

问题: 如果组件通过createPortal渲染到弹出窗口或iframe中,快捷方式会失效

监听器被附加到主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动态获取正确的窗口对象

现在快捷方式在任何窗口上下文中都能正常工作——无论是主窗口、弹出窗口还是iframe。

应对视图转换

考虑一个在简单视图和高级视图之间切换的设置面板:

function ThemeSettings() {
  const [showAdvanced, setShowAdvanced] = useState(false)

  return (
    <>
      {showAdvanced ? <AdvancedPanel /> : <SimplePanel />}
      <button onClick={() => setShowAdvanced(!showAdvanced)}>
        {showAdvanced ? 'Simple' : 'Advanced'}
      </button>
    </>
  )
}

问题: 使用React 19的<ViewTransition>时,状态更新不会触发过渡动画

这是因为<ViewTransition>需要通过startTransition来标记状态更新,以便浏览器能够捕获视图变化并应用过渡效果。

防御性的做法是:将所有会影响视图的状态更新都包装在startTransition

function ThemeSettings() {
  const [showAdvanced, setShowAdvanced] = useState(false)

  return (
    <>
      {showAdvanced ? <AdvancedPanel /> : <SimplePanel />}
      <button onClick={() =>
        startTransition(() => setShowAdvanced(!showAdvanced))
      }>
        {showAdvanced ? 'Simple' : 'Advanced'}
      </button>
    </>
  )
}

防御策略: startTransition标记状态更新,启用视图过渡动画

现在视图变化会通过平滑的过渡动画呈现,提升用户体验。

应对 Activity 组件

考虑一个通过<style>标签注入全局CSS变量的主题组件:

function DarkTheme({ children }) {
  return (
    <>
      <style>{`
        :root {
          --bg: #000;
          --fg: #fff;
        }
      `}</style>
      {children}
    </>
  )
}

问题: 当组件被包装在<Activity>中时,即使组件被隐藏,深色主题样式仍然保持激活

这是因为<Activity>保留DOM以保持状态,而<style>标签的效果是全局的,React无法自动清理这些DOM级别的副作用。

防御性的方案是:使用useLayoutEffect动态管理样式的media属性,在组件隐藏时禁用样式:

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',在显示时恢复为'all'

现在隐藏的组件不会继续应用其样式,避免了样式污染。

应对未来变化*

这是一个需要深刻理解的概念:要有防御性思维。这不是一个到处都要应用的模式,而是一种设计原则。

考虑一个在挂载时生成随机强调色的主题组件:

function ThemeProvider({ baseTheme, children }) {
  const colors = useMemo(
    () => getRandomColors(baseTheme),
    [baseTheme]
  )

  return <div style={colors}>{children}</div>
}

问题: useMemo只是一个性能提示,不是语义保证

React在HMR期间、处理不在屏幕上的组件时,或为了支持未来的功能,都保留丢弃缓存值的权利。如果React丢弃缓存,你的主题会闪烁到完全不同的颜色。

防御性的做法是:当正确性依赖于值的持久性时,使用useState而不是useMemo

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的内部优化如何变化。

总结

这些不是边界情况或罕见的场景。它们是现代React开发的新常态。那些在生产环境中崩溃的组件?它们并不是天生脆弱的。它们是为昨天的React构建的,而我们正在为明天的React构建。

防御性编程不是过度设计,而是一种成熟的工程实践。它要求我们:

  • 预见可能的失败场景,而不仅仅关注理想路径
  • 主动采取措施应对服务器渲染、水合、并发等复杂场景
  • 选择正确的API和模式,确保组件在各种上下文中都能正常工作
  • 为未来的变化做好准备,不过度依赖内部实现细节

通过这些防御性的设计原则,你的React组件不仅能够在今天的应用中工作,还能够适应React框架的演进和应用场景的变化。

感谢Jiachi的校对。

原文链接: https://shud.in/thoughts/build-bulletproof-react-components