【翻译】构建坚不可摧的 React 组件

5 阅读7分钟

原文链接:shud.in/thoughts/bu…

作者:Shu Ding

我滑向冰球即将到达的位置,而非它曾经所在之处。 ——韦恩·格雷茨基

大多数组件都是为理想状态设计的。它们能正常工作——直到出错。现实世界充满挑战:服务器端渲染、数据加载、多实例并发、异步子组件、门户组件……你的组件可能遭遇所有这些情况。关键在于它能否经受住考验。

真正的考验不在于组件能否在当前页面运行,而在于当他人使用时——在未预设的条件下——它能否正常运作。脆弱的组件往往在此刻崩溃。

以下是让组件存活的方法。

  1. 实现服务器兼容性
  2. 实现渲染兼容性
  3. 实现实例兼容性
  4. 实现并发兼容性
  5. 实现组合兼容性
  6. 实现全平台兼容性
  7. 实现过渡兼容性
  8. 实现Activity兼容性
  9. 实现防泄漏兼容性
  10. 实现未来兼容性

实现服务器兼容性

一个简单的主题提供程序,从localStorage中读取用户的偏好设置:

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

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

SSR中的崩溃——从localStorage读取主题。

localStorage 在服务器端并不存在。在 Next.js、Remix 或任何服务器端渲染框架中,这会导致构建失败。请将浏览器 API 移至 useEffect 中:

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

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

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

useEffectlocalStorage 的操作限制在客户端执行。

现在它能在服务器端渲染而不崩溃。

实现渲染兼容性

我称之为渲染处理。服务器安全版本虽能运行,但用户会看到闪烁。服务器端渲染light,客户端进行水化处理,随后特效运行并切换至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。

两个脚本都在争夺同一个元素。请使用 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 为每个实例生成唯一标识符。

现在多个实例可以安全地共存。

具备并发安全性

现在让我们将主题改为服务器驱动。一个用于获取用户偏好的服务器组件

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>
}

React.cache()方法可消除并发调用中的重复操作

相同的查询,无论从何处调用,都只访问数据库一次。

实现组合兼容性

有时需要将数据作为 props 传递给子组件,传统做法是使用 React.cloneElement

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

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

通过 cloneElement 将主题传递给子元素。

但使用 React 服务器组件React.lazy"use cache" 时,子组件可能是 Promise不透明引用——cloneElement 将无法工作。请改用上下文:

const ThemeContext = createContext('light')

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

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

上下文无处不在——服务器端、客户端、异步环境。

子组件通过 useContext 读取主题——无需属性钻取,无需克隆。

实现全平台兼容性

支持快捷键的主题提供商——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渲染时,快捷方式将失效。监听器绑定在父窗口而非组件所在的窗口。请使用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 找到正确的窗口。

现在该快捷键在任何窗口环境中都有效。

实现过渡兼容性

一个可在简易视图与高级视图间切换的设置面板:

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

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

两个面板之间的简单状态切换。

用 React 19 的 <ViewTransition> 包裹它,动画效果就会消失——面板会直接弹出。状态更新必须通过 startTransition 实现:

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

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

启用视图过渡效果。

现在过渡效果流畅地动画显示。

实现Activity兼容性

通过<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',并在取消隐藏时恢复其设置。

现在隐藏的组件将不再应用深色主题。

实现防泄漏兼容性

一个服务器组件,用于将user对象(包括会话令牌)传递给另一个主题组件。合理用例——您需要在服务器端处理数据。您可能知道UserThemeConfig是服务器组件,因此向其传递数据是安全的。

async function Dashboard() {
  const user = await getUser()

  return <UserThemeConfig user={user} />
}

仪表盘将用户(附带令牌)传递给另一个组件。

然而,你并不了解 UserThemeConfig 的具体行为、其渲染内容,也不清楚未来版本可能的变更。你并不负责维护它。

此外,由于 UserThemeConfig 并未创建user,它可能并不知道user拥有敏感的token属性。你无法控制该组件,因此不能假设它不会在组件树的某个节点将该令牌传递给客户端组件。令牌会被序列化并发送至客户端。请使用 React 的实验性 taintUniqueValue 标记令牌为服务器专用。若该值被传递至客户端组件,React 将抛出异常。若需阻止整个对象而非单个值,请使用 taintObjectReference

import { experimental_taintUniqueValue } from 'react'

async function Dashboard() {
  const user = await getUser()

  experimental_taintUniqueValue(
    'Do not pass the user token to the client.',
    user,
    user.token
  )

  return <UserThemeConfig user={user} />
}

taintUniqueValue 阻止 user.token 被发送至客户端。

如果该组件的代码(或团队其他成员未来的重构)试图将 user.token 传递给客户端组件,React 将抛出包含您自定义信息的异常。有效用例得以保留,令牌绝不会泄露。

实现未来兼容性

这是一个需要理解的概念:保持防御性。它并非适用于所有场景的模式。

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

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

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

useMemo 会缓存生成的颜色。

useMemo 仅是性能优化建议,而非语义保证。React 在热模块替换(HMR)过程中会清除缓存值,并对未显示的组件或尚未存在的特性保留此操作权限。若 React 清除缓存,主题颜色将出现闪烁。当正确性依赖于状态持久性时,请使用状态管理:

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而建。