翻译:0147-use-mutable-source.md

603 阅读10分钟
  • 开始日期:2020-02-13
  • RFC PR:18000
  • React Issue:N/A

useMutableSource

useMutableSource 使 React 组件在 并发模式下具备安全高效读取可变外部源的能力。API 会检测渲染时的修改避免割裂,并会在源被修改后自动更新。

基本示例

这个 hook 旨在支持各种各样的可变源。下面是一些例子。

浏览器 API

useMutableSource 也能用于读取非传统源,例如,共用的 Location 对象,同时它能被订阅和拥有“版本”:

// 可以在模块作用域创建,就像 context:
const locationSource = createMutableSource(
  window,
  // 尽管没有典型的“版本”,但 href 属性是稳定的,
  // 并且在无论 Location 哪部分变化时都改变,
  // 所以把它用作版本是安全的。
  () => window.location.href
);

// 由于这个方法不需要访问 props,
// 它能在声明在模块作用域里在组件间共用。
const getSnapshot = window => window.location.pathname;

// 这个方法能订阅根级别变化事件,
// 或者更具体快照事件。
//
// 
// 由于这个方法不需要访问 props,
// 它能在声明在模块作用域里在组件间共用。
const subscribe = (window, callback) => {
  window.addEventListener("popstate", callback);
  return () => window.removeEventListener("popstate", callback);
};

function Example() {
  const pathName = useMutableSource(locationSource, getSnapshot, subscribe);

  // ...
}

使用 props 的选择器

有时 state 值是用组件 props 衍生的。这种情况下,useCallback 应该用于保证快照和订阅函数稳定。

// 可以在模块作用域创建,就像 context:
const userDataSource = createMutableSource(userData, () => userData.version);

// 这个方法能订阅根级别变化事件,
// 或者更具体快照事件。
// 这种情况下,由于 Example 仅读取 "friends" 值,
// 我们仅需要订阅那个值的变化
// (例如,"friends" 事件)
//
// 由于这个方法不需要访问 props,
// 它能在声明在模块作用域里在组件间共用。
const subscribe = (userData, callback) => {
  userData.addEventListener("friends", callback);
  return () => userData.removeEventListener("friends", callback);
};

function Example({ onlyShowFamily }) {
  // 由于快照依赖 props,它必须内联创建。
  // useCallback() 记住了该函数,
  // 这使 useMutableSource() 知道何时安全的重用快照值。
  const getSnapshot = useCallback(
    userData =>
      userData.friends
        .filter(
          friend => !onlyShowFamily || friend.relationshipType === "family"
        )
        .map(friend => friend.id),
    [onlyShowFamily]
  );

  const friendIDs = useMutableSource(userDataSource, getSnapshot, subscribe);

  // ...
}

Redux store

Redux 用户看似从不直接使用 useMutableSource。他们使用 Redux 提供的 hook,Redux 内部使用 useMutableSource

模拟 Redux 实现

// 在某处,Redux store 需要被一个可变源对象包裹...
const mutableSource = createMutableSource(
  reduxStore,
  // 因为 state 是不可变得,所以它能被用作“版本”。
  () => reduxStore.getState()
);

// 它可通过 Context API 被共用...
const MutableSourceContext = createContext(mutableSource);

// 由于这个方法不需要访问 props,
// 它能在声明在模块作用域里在 hook 间共用。
const subscribe = (store, callback) => store.subscribe(callback);

// 最简的 Redux 如何使用可变源 hook 的示例:
function useSelector(selector) {
  const mutableSource = useContext(MutableSourceContext);

  const getSnapshot = useCallback(store => selector(store.getState()), [
    selector
  ]);

  return useMutableSource(mutableSource, getSnapshot, subscribe);
}

用户组件代码示例

import { useSelector } from "react-redux";

function Example() {
  // 用户提供的 selector 应该用 useCallback 保持不变。
  // 这能避免每次更新时不必要的重新订阅。
  // selector 如果需要也能使用例如 props 值。
  const memoizedSelector = useCallback(state => state.users, []);
  
  // Redux hook 会连接用户代码和 useMutableSource。
  const users = useSelector(memoizedSelector);

  // ...
}

Observables

Observables 没有固有的版本号所以它们和这个 API 不兼容。给 Observable 添加一个衍生的版本号是可能的,如下所示,但是 在渲染中这样做可能不安全,除非小心潜在的内存泄露。

function createBehaviorSubjectWithVersion(behaviorSubject) {
  let version = 0;

  const subscription = behaviorSubject.subscribe(() => {
    version++;
  });

  return new Proxy(behaviorSubject, {
    get: function(object, prop, receiver) {
      if (prop === "version") {
        return version;
      } else if (prop === "destroy") {
        return () => subscription.unsubscribe();
      } else {
        return object[prop];
      }
    }
  });
}

动机

现在这个 API 最好的“替代品”是 Context APIuseSubscription hook

Context API

Context API 一般不适于整个树中大量组件共用的源,由于 context 变化导致非常频繁的更新(例如,请看 Redux v6 性能挑战)。(当前有改进提案:RFC 118RFC 119。)

useSubcription

这个 Gist 概述了 useMutableSourceuseSubscription 的区别。新 API 主要的优点是:

  • 在渲染期间不会发生割裂(设置在订阅初始化之前)。
  • 订阅可以是“局部的”所以部分可变源的更新仅影响相关的组件(不是所有的组件都读源的值)。这意味着通常情况下,这个钩子性能更好。

详细设计

useMutableSourceuseSubscription 类似。

  • 都需要一个用回调缓存的 "config" 对象一边从外部“源”读取值。
  • 都需要订阅和取消订阅源的方法。

但是也有不同点:

  • useMutableSource 需要源作为显式参数。(React 使用这个值避免“割裂”和保证所有组件从一个特定源读值、用相同版本的数据渲染。)
  • useMutableSource 需要从源读的值是不可变得快照。这使值能在高优渲染中重用,允许更耗性能的重绘能在需要的时候被延迟。

公开 API

type MutableSource<Source> = {|
  /*…*/
|};

function createMutableSource<Source>(
  source: Source,
  getVersion: () => $NonMaybeType<mixed>
): MutableSource<Source> {
  // ...
}

function useMutableSource<Source, Snapshot>(
  source: MutableSource<Source>,
  getSnapshot: (source: Source) => Snapshot,
  subscribe: (source: Source, callback: () => void) => () => void
): Snapshot {
  // ...
}

实现

根或者模块作用域改变

可变源需要追踪模块级别的两类信息:

  1. 执行中版本号(每个源、每次渲染被追踪)
  2. 待更新过期时间(每个根、每个源被追踪)

版本号

追踪源的版本在组件还没有订阅就从源读值的时候让我们避免割裂。

这种情况下,版本需要被检查以保证满足两者之一:

  1. 在当前渲染期间,这是第一次挂载的组件从源读值,或者
  2. 自上次读取版本号就没变过。(变了的版本号表明依赖的数据发生了变化,可能导致割裂。)

就像 Context,这个钩子应该支持多个并发渲染器(例如 ReactDOM 和 ReactART,React Native 和 React Fabric)。为了支持这个,我们将需追踪两个进行中的版本。(一个为“主”渲染器一个为“次”渲染器)。

当渲染器开始或者结束(或者放弃)一次批量任务时,这个值应该被重置。这个信息可被保存在:

  • 在主或次字段的每个可变源自身。
    • Con: 需要一个独立的数组/列表追踪可变源的显著变化。
  • 在模块级别作为可变源和等待的主次版本号映射的 Map
    • Con: 需要至少一个额外的 Map 结构。

⚠️ 决定 在源自身上直接保存版本且用数组追踪待更新。

待更新过期时间

追踪每个源的待更新能使新挂载的组件读值、避免和在上次渲染中从相同源读值的组件的潜在冲突。

在更新中,如果当前渲染器的过期时间 源中保存的过期时间,从源中读新值是安全的。否则缓存快照值应该被临时使用1

当根被提交,全部的 提交时间的待过期时间对于根可以被丢弃。

这个信息可被保存在:

  • 每个 Fiber 根上,作为可变源和待更新过期时间的 Map
  • 每个可变源上,作为 Fiber 根和待更新过期时间的 Map
    • Con: 需要一个独立的数据结构来映射根和显著变化的可变源(由于显著变化在提交时会在每个根上被清除)。

⚠️ 决定 用根 Fiber 上的 Map 保存待更新时间。

1 当配置在渲染器间变化时缓存快照值不能被重用。更多如下……

关于为什么同时需要待过期和版本的说明

尽管对更新有用,待更新过期时间对新挂载组件上的避免避免割裂并不足够,即使源已经被其他组件所使用。由于每个组件可能订阅 store 的不同部分,下面的场景是可能的:

  1. 一些组件挂载和订阅源 A。
  2. React 开始一次新的渲染。
  3. 一个新组件(非之前挂载的)从源 A 读值,然后 React yield。
  4. 源 A 变化不会通知任何当前已经订阅的组件,但是会影响新组件(它们现在没有订阅)。
  5. 另一个新组件(非之前挂载的)从源 A 读值。这时,对于该源没有待更新任务,但是它已经改变并且从它读值可能会导致割裂。

Hook 状态

useMutableSource() 钩子的 memoizedState 将需要追踪下列值:

  • 用户提供的 getSnapshotsubscribe 函数。
  • 最后的(缓存的)快照值。
  • 可变源本身(为了检测是否有新源提供)。
  • (用户返回的)取消订阅的函数。

需处理的场景

在订阅前从源读值

当组件在尚未订阅的时候从可变源读值1,React 首先检测版本号去看在当前渲染中是否有其他值从这个源被读取。

  • 如果存在记录过的版本号(即这不是第一次读)那它和源的当前版本是否匹配?
    • ✓ 如果两个版本匹配,则读取是 安全的
      • 将快照值保存在 memoizedState 上。
    • ✗ 如果版本变了, 则读取是 不安全的
      • 抛出并重启渲染。

如果不存在版本号,那读取 可能是安全的。我们接下来需要检测关于源的待更新以确认它。

  • ✓ 如果没有待更新则读取是 安全的

    • 将快照值保存在 memoizedState 上。
    • 保存版本号以便这次渲染中的后续读取。
  • ✓ 如果当前过期时间 等待时间,则读取是 安全的

    • 将快照值保存在 memoizedState 上。
    • 保存版本号以便这次渲染中的后续读取。
  • ✗ 如果当前过期时间 > 等待时间,则读取是 不安全的

    • 抛出并重启渲染。

在订阅后从源读值

React 终将当源改变时重新渲染,但是也可能因为其他原因重新渲染。甚至在修改事件中,React 可能需要处理该修改之前渲染高优更新。在这种情况下,组件不从变化源读值很重要,因为可能造成割裂。

如果组件在未触发订阅时再次渲染(或者作为不包含订阅改变的高优渲染的一部分),它一般能复用缓存的快照。

一个不可能情况是当 getSnapshot 发生改变。即使在依赖源未改变时,依赖 props(或者其他组件 State)快照选择器可能改变。这种情况下,缓存的快照对复用不安全,useMutableSource 将不得不抛出并重启渲染。

设计约束

  • 对于使用相同 MutableSource 值的组件,仅在根内实施割裂保证。根之间的割裂是可能的:

  • 从 store 读取和返回的值必须是不可变的如同例如类的 state 或者 props 对象。

    • 例如 ✓ getSnapshot: source => Array.from(source.friendIDs)
    • 例如 ✓ getSnapshot: source => source.friendIDs
    • 值不需要字面不可变但是应该只是被克隆,所以它们能和 store 断开联系并且对外部源不会被变化所修改。
  • 可变源必须有某种形式的稳定版本。

    • 版本应该是全局的(对于源的全部,不是源的部分)。
      • 例如 ✓ getVersion: () => source.version
      • 例如 ✗ getVersion: () => source.user.version
    • 版本应该在无论源的任何部分被修改时改变。
    • 版本不必须是数字或者甚至不必须是一个单独属性。
      • 它可以是数据的序列号形式,只要它是稳定唯一的。(例如,读查询参数可能把整个 URL 串视为版本。)
      • 它可以是状态自身,如果值是不可变的(例如 Redux store 是可变的,但是它的 state 是不可变的)。

替代品

参考上面的“动机”章节。

采用策略

这个钩子主要用于像 Redux 的库(和转换器)。和那些库的维护者一起工作以集成该钩子。

我们如何教授这个

新的 reactjs.org 文档和博客。

未解问题

  • 是否存在一些普遍的/重要的该提案将不能支持的可变源类型?