overreactedby Dan Abramov

setState 是如何知道该做什么的?

December 9, 2018

当你在组件中调用 setState 时,你觉得发生了什么?

import React from 'react';
import ReactDOM from 'react-dom';
 
class Button extends React.Component {
  constructor(props) {
    super(props);
    this.state = { clicked: false };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({ clicked: true });
  }
  render() {
    if (this.state.clicked) {
      return <h1>Thanks</h1>;
    }
    return (
      <button onClick={this.handleClick}>
        Click me!
      </button>
    );
  }
}
 
ReactDOM.render(<Button />, document.getElementById('container'));

没错,React 会用下一个 { clicked: true } 状态重新渲染组件,并将 DOM 更新为 <h1>Thanks</h1> 元素。

看起来很直接。但等等,这真的是 React 做的吗?还是 React DOM

更新 DOM 听起来像是 React DOM 负责的事情。但我们调用的是 this.setState(),而不是 React DOM 里的什么东西。而且我们的 React.Component 基类定义在 React 本身里。

那么,React.Component 里的 setState() 是如何更新 DOM 的呢?

声明:就像本博客上的大多数 其他 文章,你其实不需要了解这些内容也能高效使用 React。本文是为那些喜欢探究幕后原理的人准备的,完全可选!


我们可能会以为 React.Component 类包含了 DOM 更新的逻辑。

但如果真是这样,那 this.setState() 怎么能在其他环境下工作?比如,React Native 应用里的组件同样继承自 React.Component,它们也像上面那样调用 this.setState(),但 React Native 操作的是 Android 和 iOS 的原生视图,而不是 DOM。

你可能还用过 React Test Renderer 或 Shallow Renderer。这两种测试方式都允许你渲染普通组件并在其中调用 this.setState(),但它们都不涉及 DOM。

如果你用过像 React ART 这样的渲染器,你可能还知道页面上可以同时使用多个渲染器。(比如,ART 组件可以嵌在 React DOM 树中。)这就让用全局变量或标志来区分变得不可行。

所以,**React.Component 必须把状态更新的处理委托给特定平台的代码。**在理解它是怎么做到这一点之前,我们先深入了解下包的划分方式以及原因。


有一个常见误区认为 React 的“引擎”就在 react 包里。其实并不是这样。

事实上,从 React 0.14 的包拆分开始,react 包就只负责定义组件的 API。大部分 React 的实现都在“渲染器”里。

react-domreact-dom/serverreact-nativereact-test-rendererreact-art 都是渲染器的例子(你甚至可以自定义渲染器)。

这就是为什么无论你面向哪个平台,react 包都很有用。它导出的内容,比如 React.ComponentReact.createElementReact.Children 工具方法以及(最终的)Hooks,都与目标平台无关。无论你用 React DOM、React DOM Server 还是 React Native,组件的导入和使用方式都是一样的。

相比之下,渲染器包则暴露了平台相关的 API,比如 ReactDOM.render(),让你可以把 React 组件树挂载到 DOM 节点上。每个渲染器都提供类似的 API。理想情况下,大多数组件都不需要从渲染器导入任何东西,这样它们就更具可移植性。

**大多数人以为的 React“引擎”其实就在每个渲染器内部。**很多渲染器都包含了一份相同代码的副本——我们称之为“协调器”(reconciler)。一个构建步骤会把协调器代码和渲染器代码合并成一个高度优化的包以提升性能。(虽然复制代码通常不利于包体积,但绝大多数 React 用户一次只需要一个渲染器,比如 react-dom。)

这里的重点是,react 包只让你使用 React 的特性,但并不知道这些特性是如何实现的。渲染器包(如 react-domreact-native 等)才提供了 React 特性的实现和平台相关逻辑。部分代码是共享的(协调器),但那只是各个渲染器的实现细节。


现在我们知道了,reactreact-dom 包都需要更新才能支持新特性。例如,React 16.3 增加了 Context API,React.createContext() 就是在 React 包上暴露的。

React.createContext() 实际上并没有实现 context 功能。比如在 React DOM 和 React DOM Server 中,具体实现方式就不同。所以 createContext() 返回的是一些普通对象:

// 稍作简化
function createContext(defaultValue) {
  let context = {
    _currentValue: defaultValue,
    Provider: null,
    Consumer: null
  };
  context.Provider = {
    $$typeof: Symbol.for('react.provider'),
    _context: context
  };
  context.Consumer = {
    $$typeof: Symbol.for('react.context'),
    _context: context,
  };
  return context;
}

当你在代码中使用 <MyContext.Provider><MyContext.Consumer> 时,渲染器 决定如何处理它们。React DOM 可能用一种方式追踪 context 值,而 React DOM Server 可能用另一种方式。

**所以如果你把 react 升级到 16.3+,但没有升级 react-dom,你用的渲染器还不知道 ProviderConsumer 这些特殊类型。**这就是为什么老版本的 react-dom报错说这些类型无效

同样的注意事项也适用于 React Native。不过与 React DOM 不同,React 的发布不会立刻“强制” React Native 发布。它们有各自独立的发布节奏。更新后的渲染器代码会定期同步到 React Native 仓库中。这也是为什么新特性在 React Native 上的可用时间和 React DOM 不一样。


好了,现在我们知道 react 包本身没什么“有趣”的实现,具体实现都在 react-domreact-native 等渲染器里。但这还没解答我们的疑问。React.Component 里的 setState() 是怎么“联系”到正确的渲染器的?

答案是每个渲染器会在创建类实例时设置一个特殊字段。 这个字段叫做 updater。这不是要设置的,而是 React DOM、React DOM Server 或 React Native 在创建你的类实例后设置的:

// 在 React DOM 内部
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMUpdater;
 
// 在 React DOM Server 内部
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMServerUpdater;
 
// 在 React Native 内部
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactNativeUpdater;

看看 React.Component 里的 setState 实现,它所做的只是把工作委托给创建该组件实例的渲染器:

// 稍作简化
setState(partialState, callback) {
  // 用 `updater` 字段与渲染器通信!
  this.updater.enqueueSetState(this, partialState, callback);
}

React DOM Server 可能会 忽略状态更新并给出警告,而 React DOM 和 React Native 则会让它们各自的协调器来处理

这就是为什么即使 this.setState() 定义在 React 包里,也能更新 DOM。它读取由 React DOM 设置的 this.updater,并让 React DOM 安排和处理更新。


我们已经了解了类组件的情况,那 Hooks 呢?

当人们第一次看到 Hooks 提案 API 时,常常会疑惑:useState 是怎么“知道该做什么”的?大家以为它比基类的 this.setState() 更“神奇”。

但正如我们今天看到的,基类的 setState() 实现其实一直只是个“幻觉”。它除了把调用转发给当前渲染器外什么都没做。而 useState Hook 做的事情完全一样

**不同的是,Hooks 用的是一个 “dispatcher”(调度器)对象,而不是 updater 字段。**当你调用 React.useState()React.useEffect() 或其他内置 Hook 时,这些调用会被转发给当前的 dispatcher。

// 在 React 内部(稍作简化)
const React = {
  // 实际属性藏得更深一点,有兴趣可以去找找!
  __currentDispatcher: null,
 
  useState(initialState) {
    return React.__currentDispatcher.useState(initialState);
  },
 
  useEffect(initialState) {
    return React.__currentDispatcher.useEffect(initialState);
  },
  // ...
};

各个渲染器会在渲染你的组件前设置 dispatcher:

// 在 React DOM 内部
const prevDispatcher = React.__currentDispatcher;
React.__currentDispatcher = ReactDOMDispatcher;
let result;
try {
  result = YourComponent(props);
} finally {
  // 恢复原 dispatcher
  React.__currentDispatcher = prevDispatcher;
}

比如,React DOM Server 的实现在这里,而 React DOM 和 React Native 共享的协调器实现在这里

这也是为什么像 react-dom 这样的渲染器需要访问和你调用 Hooks 时用的是同一个 react 包。否则你的组件就“看不到” dispatcher!如果你在同一个组件树中有多个 React 副本,这就可能失效。不过,这种情况一直会导致各种隐晦的 bug,所以 Hooks 会强制你在出问题前解决包重复的问题。

虽然我们不推荐,但你其实可以为了高级工具场景手动覆盖 dispatcher。(我刚才关于 __currentDispatcher 名字撒了个小谎,实际名字你可以在 React 仓库里找到。)比如,React DevTools 会用专门构建的 dispatcher 来通过捕获 JavaScript 堆栈追踪分析 Hooks 树。请勿在生产环境模仿。

这也意味着 Hooks 并不天然绑定于 React。如果将来有更多库想复用同样的 Hooks 原语,理论上 dispatcher 可以移到单独的包中,并以更“友好”的名字作为一等 API 暴露出来。但实际上,我们更倾向于等有真正需求时再做抽象,而不是过早设计。

无论是 updater 字段还是 __currentDispatcher 对象,本质上都属于一种叫做依赖注入的通用编程原则。在这两种情况下,渲染器都把诸如 setState 这样的特性实现“注入”到通用的 React 包中,从而让你的组件更具声明性。

你在使用 React 时无需关心这些原理。我们希望 React 用户把更多时间花在应用代码上,而不是诸如依赖注入这样的抽象概念上。但如果你曾经好奇过 this.setState()useState() 是怎么知道该做什么的,希望这篇文章能帮你解惑。

Pay what you like

Edit on GitHub