Skip to content

[译] 了解 React 18 中为外部存储引入的新 Hook useSyncExternalStore

在深入了解 useSyncExternalStore API 之前,让我们先熟悉一下术语,这对理解新 Hook 很有帮助。

并发渲染和 startTransition API

并发是一种通过确定任务的优先级来同时执行多个任务的机制。Dan Abramov 通过电话类比轻松地解释了这一概念。

startTransition API 的帮助下,我们可以选择在呈现时保持应用的响应性。换句话说,React 现在可以暂停呈现。这样,浏览器就可以处理中间的事件。

有关 startTransition API 的更多详细信息,我们已在上一篇文章中进行了介绍。

外部存储

外部存储是我们可以订阅的东西。外部存储的例子包括 Redux 存储、Zustand 存储、全局变量、模块作用域变量、DOM 状态等。

内部存储

内部存储包括 propscontextuseStateuseReducer

撕裂(Tearing

撕裂指的是视觉上的不一致。这意味着用户界面会为同一状态显示多个值。

React 18 之前,这个问题不会出现。但在 React 18 中,并发呈现使这一问题成为可能,因为 React 会在呈现过程中暂停。在这些暂停之间,更新会拉入与用于呈现的数据相关的更改。这会导致用户界面为相同的数据显示两个不同的值。

让我们考虑一下 React working groups 讨论撕裂时提到的例子。

在这里,一个组件需要访问一些外部存储来获取颜色。

通过同步呈现,用户界面上呈现的颜色是一致的。

在并发渲染中,最初获取的颜色是蓝色。React 生成,存储更新为红色。React 会使用更新后的红色值继续呈现。这会导致 UI 不一致,也就是所谓的 "撕裂"。

为了解决这个问题,React 团队添加了 useMutableSource Hook,以便安全高效地从可变外部源读取数据。但是,工作组成员报告了现有 API 设计的缺陷,这使得库维护者很难在其实现中采用 useMutableSource。经过反复讨论,useMutableSource Hook 被重新设计并更名为 useSyncExternalStore

理解 useSyncExternalStore Hook

React 18新提供的 useSyncExternalStore Hook 允许正确订阅 store 中的值。

为了帮助简化迁移,React 提供了一个新包 use-sync-external-store。该软件包中的 shim(垫片) 可与任何支持 HookReact 版本配合使用。

js
import {useSyncExternalStore} from 'react';
//  or
// 向后兼容的垫片
import {useSyncExternalStore} from 'use-sync-external-store/shim';

// 基本用法。getSnapshot 必须返回 缓存/模拟结果
useSyncExternalStore(
  subscribe: (callback) => Unsubscribe
  getSnapshot: () => State
) => State

// 使用内联 getSnapshot 选择特定字段
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);

useSyncExternalStore Hook 有两个函数

  • subscribe 函数用于注册回调函数。

  • getSnapshot 用于检查所订阅的值自上次渲染以来是否发生了变化,它要么是一个不可变的值,如字符串或数字,要么是一个 缓存/记忆 对象。Hook 将返回不可变值。

自动支持记忆化 getSnapshot 结果的 API 版本:

js
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'

const selection = useSyncExternalStoreWithSelector(
  store.subscribe,
  store.getSnapshot,
  getServerSnapshot,
  selector,
  isEqual,
)

让我们看看 Daishi KatoReact 18 for External Store Libraries 讲座中讨论的示例。

js
import React, { useState, useEffect, useCallback, startTransition } from 'react'

const createStore = (initialState) => {
  let state = initialState
  const getState = () => state
  const listeners = new Set()
  const setState = (fn) => {
    state = fn(state)
    listeners.forEach((l) => l())
  }
  const subscribe = (listener) => {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }
  return { getState, setState, subscribe }
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()))
  useEffect(() => {
    const callback = () => setState(selector(store.getState()))
    const unsubscribe = store.subscribe(callback)
    callback()
    return unsubscribe
  }, [store, selector])
  return state
}
jsx
const store = createStore({ count: 0, text: 'hello' })

const Counter = () => {
  const count = useStore(
    store,
    useCallback((state) => state.count, []),
  )
  const inc = () => {
    store.setState((prev) => ({ ...prev, count: prev.count + 1 }))
  }
  return (
    <div>
      {count} <button onClick={inc}>+1</button>
    </div>
  )
}

const TextBox = () => {
  const text = useStore(
    store,
    useCallback((state) => state.text, []),
  )
  const setText = (event) => {
    store.setState((prev) => ({ ...prev, text: event.target.value }))
  }
  return (
    <div>
      <input value={text} onChange={setText} className="full-width" />
    </div>
  )
}

const App = () => {
  return (
    <div className="container">
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  )
}

如果我们在代码的某个地方使用 startTransition,可能会导致撕裂。为了解决撕裂问题,我们现在可以使用 useSyncExternalStore API

让我们修改库的 useStore Hook,使用 useSyncExternalStore 代替 useEffectuseState Hook

js
import { useSyncExternalStore } from 'react';

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState(), [store, selector]))
  )
}

使用新 Hook 后,代码看起来更简洁、可维护且安全。在外部存储中迁移到 useSyncExternalStore Hook 很容易,建议使用该 Hook 以避免任何潜在问题。

并发渲染会影响哪些库?

  • 拥有 components 和自定义 Hook 的库在呈现时不会访问外部可变数据,而只会使用 React propsstatecontext 递信息,这些库不会受到影响。

  • 而处理数据获取、状态管理或样式的库(ReduxMobXRelay)则会受到影响。这是因为这些库在 React 之外存储它们的状态。有了并发呈现,这些数据存储可以在呈现过程中更新,而 React 对此一无所知。