首页 / 文章

用于细致调整 React 和 React Native 更新的图例状态

将热状态移至 Legend 状态的可观察对象中,这样播放列表的行和计时器只需重新渲染发生变化的单元格,而无需重新渲染整个 React Native 子树。

发布者:REACTAPP.TOP阅读约 5 分钟1202 词

来源: Legend State: The Fastest React State Library You’re Probably Not Using

优化 React Native 列表是常见的性能优化话题,像 Legend List 这样的库能让滚动性能接近 FlashList 的水平。然而经过足够的实际应用检测后,列表虚拟化已不再是主要的性能瓶颈。更为隐蔽的成本往往是状态管理:状态在数据结构中的位置,以及单个字段变化时需要重新渲染的组件数量。

内置的钩子(useState、useContext)虽然易于使用,但往往与“减少渲染次数、缩小渲染范围”的目标相冲突。许多外部状态管理方案则能改善这一状况。目前处于 3.0 测试版的 Legend State 采用了更为精细的设计方式——将状态创建与订阅功能分开,并在 Chain React 2026 上进行了深入演示。

useState 的问题

该库背后的性能理念很直接:让应用运行得更快,就能减少 React 和 React Native 需要处理的工作量——树结构更小,渲染次数更少。但内置的 useState 和 useContext 却与这一目标相悖,因为当某个值发生变化时,拥有该值的组件(或所有使用该上下文的组件)都必须进行更新。

以一个在本地状态中存储 replyId 的聊天界面为例:

function ChatScreen() {
  const [replyId, setReplyId] = useState('')

  return (
    <View>
      <ChatMessages replyId={replyId} />
      <ChatComposer replyId={replyId} />
    </View>
  )
}

function ChatMessage({ id, replyId }) {
  const isReply = id === replyId

  // ...
}

当 replyId 发生变化时,ChatScreen 本身可能无需重新渲染——只有读取该 ID 的子组件需要更新。但如果在父组件上使用 useState,React 仍会因为钩子值的变化而重新渲染 ChatScreen。由于状态更新是真实的,即使大多数属性没有变化,组件也会重新渲染,随后 React 会继续处理子组件的同步工作。

useContext看似是一种解决途径,但实际上它往往只是让问题变得更广泛而非真正深入解决:只要上下文的任何部分发生变化,所有使用该上下文的地方都会重新渲染,即便某个使用者从未读取过被修改的字段。将上下文拆分成许多小型提供者能起到一定作用,直到结构呈现出这种常见的嵌套形式:

function App() {
  return (
    <SomeProvider>
      <SomeOtherProvider>
        <YetAnotherProvider>
          <ThisWillBeTheLastOneProvider>
            <OkJustOneMoreProvider>
              <FinalFinalProvider>
                <SendHelpProvider>
                  <ActualApp />
                </SendHelpProvider>
              </FinalFinalProvider>
            </OkJustOneMoreProvider>
          </ThisWillBeTheLastOneProvider>
        </YetAnotherProvider>
      </SomeOtherProvider>
    </SomeProvider>
  )
}

这到底是个多严重的问题?

严重到需要用数据来衡量。有一项基准测试对比了音乐应用计时器的三种放置方式:放在应用根节点、树结构中间位置,以及仅放在最底层的文本组件中,该计时器每秒触发四次:

// App root
function App() {
  const [time, setTime] = useState(0)

  return (
    <Window>
      <PlaybackArea time={time} />
      <Playlist />
      <BottomToolbar />
    </Window>
  )
}

// Playback area:
function PlaybackArea() {
  const [time, setTime] = useState(0)

  return (
    <View>
      <PlaybackTime time={time} />
      <TheRestOfThePlaybackArea />
    </View>
  )
}

// Leaf text:
function ElapsedText() {
  const [time, setTime] = useState(0)

  return (
    <Text>{time}</Text>
  )
}

在测试中,位于根节点的计时器速度比位于最底层的版本慢约十倍。对于那些频繁更新的状态而言,其放置位置绝非微不足道的优化细节;当更新频率很高时,这一点至关重要。

是什么让 Legend State 不同?

通过播放列表的示例可以更直观地看出差异:当某首曲目成为当前播放曲目时,其所在行背景应发生变化。使用 useState 的实现方式通常如下:

function MusicApp() {
  const [playback, setPlayback] = useState(initialPlayback);

  return <TrackList activeTrackId={playback.activeTrackId} />;
}

function TrackList({ activeTrackId }) {
  return tracks.map((track) => (
    <TrackRowWrapper
      key={track.id}
      track={track}
      activeTrackId={activeTrackId}
    />
  ));
}

function TrackRowWrapper({ track, activeTrackId }) {
  const isActive = activeTrackId === track.id;

  return <TrackRowView track={track} isActive={isActive} />;
}

团队们试图通过在包装组件中计算 isActive 布尔值并将其传递下去,让已缓存过的 TrackRowView 在该标志未改变时跳过渲染,从而降低开销。虽然这有一定帮助,但每次调用 setPlayback 仍会重新渲染 MusicApp、TrackList 以及列表中的所有包装组件——尽管实际上只有两个行视图(旧的当前播放项和新的当前播放项)需要更新。

Legend State 的解决方案是这类状态不应存在于 React 中。你应该创建一个 observable(有时也称为信号)。创建存储对象与订阅该对象是两个独立的步骤:

// creates the state
const playback$ = observable({
  tracks: [],
  activeTrackId: null,
});

function TrackRow({ track }) {
  const isActive = useValue(
    // subscribes *only* to playback$.activeTrackId
    () => playback$.activeTrackId.get() === track.id,
  );

  return (
    <View style={[styles.row, isActive && styles.activeRow]}>
      <Text>{track.title}</Text>
    </View>
  );
}

末尾的 $ 只是一种命名习惯,意为“可观察对象”;它并不具备任何运行时特殊功能。playback$ 对象的引用始终稳定。嵌套字段虽可通过 set() 或 assign() 进行修改,但该可观察对象的标识不会改变。

// ...

const isActive = useValue(
    () => playback$.activeTrackId.get() === track.id,
  );

// ...

当 activeTrackId 发生变化时,选择器会再次执行。只有当布尔值结果发生变化时才会安排重新渲染。在包含500首曲目的播放列表中,所有500个选择器都可能通过React之外的简单字符串比较来重新评估;而对于498首曲目的情况,结果保持不变,因此React不会收到任何通知。只有之前和之后的活跃行会重新渲染。

更精细的控制

即便如此,其工作量也可能超过热路径所期望的。更改背景颜色仍需重新渲染整个TrackRow。Legend State提供了响应式原语(名称以$开头),这些原语可以接收响应式属性:

function TrackRow({ track }) {
  return (
    <$View
      $style={() => [
        styles.row,
        playback$.activeTrackId.get() === track.id && styles.activeRow,
      ]}
    />
  );
}

TrackRow仅会挂载一次,后续更新时不会重新执行其内部代码。当activeTrackId发生变化时,只有库中插入的微型包装器内的$style函数会被执行。该组件不再是一个“在数据变化时重新运行的函数”,而变成了“一次性设置的结构,通过小型响应式模块实现自我更新”。

这种高性能语法可能看起来不太熟悉。日常使用中仍可采用前一节介绍的可观察对象加useValue的模式,该模式本身就已经足够快速;$开头的组件则是针对那些性能瓶颈的解决方案。

进一步学习

同样的架构不仅能实现渲染优化:

  • useObserveEffect会响应状态变化而非渲染周期,从而避免了useEffect容易导致的诸多脆弱依赖关系。
  • 更新信息会包含新值、旧值以及发生变化的路径——这些详细信息足以在状态管理库的基础上实现撤销/重做功能。
  • 这些详细的变更记录还为基于离线优先的同步引擎提供了支持,该引擎可通过后端插件实现,且库中已包含相关功能,几乎无需额外配置代码(这一主题本身就值得专门讨论)。

对于那些已经在考虑使用除 useState 之外的状态管理方案的团队而言,如果精细化的订阅机制和可选的反应式原语比遵循最流行的 API 形式更为重要,那么 Legend State 值得认真考虑。首先从可观察对象和选择性钩子开始使用;只有当性能分析显示某些行或单元仍频繁重绘时,才考虑使用以 $ 开头的组件。这样的逐步过渡既能保持思维模型的清晰性,又能为 React Native UI 中最常用的路径预留优化空间。