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

1,527 阅读7分钟

当你在组件里调用 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} 状态重渲染组件(component),更新DOM,匹配返回 <h1>Thanks</h1> 元素(element)。

似乎很简单。不过问题来了,是 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这样的渲染器(renderer),你可能也知道页面有可能使用多个渲染器(例如,ART组件运行于React DOM树中),这使得全局标志或变量不再可靠。

所以,针对不同平台代码,React.Component以某种委托方式处理state更新。在我们弄清楚怎么回事前,先深入探讨下如何及为什么要分离包(packages)。


有一种常见的误解,即React的“引擎”在 react 依赖包中,这不是真的。

实际上,自从React 0.14拆分依赖包以来,react依赖包特意地只暴露 定义 组件(components)的APIs,React绝大多数 实现 都放在 “渲染器”,

react-domreact-dom/serverreact-nativereact-test-rendererreact-art都是渲染器样例(你可以搭建自己的)。

这也是为什么react依赖包不管面向哪个平台都可行,它所有的导出,例如React.ComponentReact.createElementReact.Children和最近的Hooks,都独立于目标平台,无论你运行 React DOM、React DOM Server或者React Native,你都可以用同一种方式导入使用组件。

相比之下,渲染器依赖包暴露特定平台的APIs,如ReactDOM.render(),可以将React组件插入DOM节点中。每个渲染器都会提供一个类似的API,理想情况下,大多数 组件 不需要从渲染器导入任何内容,这使它们更灵活。

大多数人认为React的“引擎”在每个渲染器中。不过许多渲染器确实包含了同一份副本代码 —— 我们称为"reconciler"。有个构建步骤将 reconciler 代码与渲染器代码融合成一份高度优化过的代码,以获得更好的性能。(复制代码通常不利于依赖包大小,但绝大多数用户一次只需要一个渲染器,例如react-dom)

这里要说的是,react依赖包只让你知道React有哪些功能,但不知道功能是如何实现的。渲染器依赖包(react-domreact-native等)提供了React功能的实现和平台特性的逻辑。其中一些代码是共享的("reconciler"),但更多的是各个渲染器的具体实现。


现在我们知道为什么有功能时,reactreact-dom依赖包需要同时更新了,比如说,在React 16.3添加 Context API 时,React依赖包会暴露React.createContext()

React.createContext()实际上并没有 实现 context功能,React DOM 与 React DOM Server 的实现是不同的。例如,createContext返回一些 plain objects:

// A bit simplified
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()是如何与对应的渲染器“交流的”。

答案是每个渲染器在创建的class上设置一个特殊字段。这个字段叫做updater。这不是由你设置的,而是React DOM、React DOM Server、React Native在你实例class后给你加上的:

// Inside React DOM
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMUpdater;

// Inside React DOM Server
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMServerUpdater;

// Inside 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 也许打算 忽略state更新并警告你,而React DOM和React Native会用复制来的"reconciler"去处理它

这也是为什么即使this.setState()定义在React依赖包中,依然可以更新DOM。它会获取由React DOM设置的this.updater,并让React DOM调度和处理更新。


我们现在知道class了,那Hooks是怎么做的?

当大家第一次看到Hooks API,很可能会想:useState怎么“知道该怎么做”?猜想是它的this.setState()比基于React.Component的更“神奇”。

但正如我们今天看到的,基于class的setState()实现一直是一种错觉,除了调用指向当前的渲染器之外,它不参与任何操作。useState Hook也同样如此

Hooks使用dispatcher对象而不是updater字段。在你调用 React.useState()React.useEffect()、或者其他内置Hook时,这些都会转发给当前的dispatcher。

// In React (简化)
const React = {
  // 真正的属性隐藏得有点深,你可以尝试去找找看!
  __currentDispatcher: null,

  useState(initialState) {
    return React.__currentDispatcher.useState(initialState);
  },

  useEffect(initialState) {
    return React.__currentDispatcher.useEffect(initialState);
  },
  // ...
};

而每种渲染器在组件渲染之前会设置dispatcher:

// In React DOM
const prevDispatcher = React.__currentDispatcher;
React.__currentDispatcher = ReactDOMDispatcher;
let result;
try {
  result = YourComponent(props);
} finally {
  // Restore it back
  React.__currentDispatcher = prevDispatcher;
}

例如,React DOM Server的实现在这儿,React DOM和React Native共享的 reconciler 实现在这儿

这就是像react-dom这样的渲染器需要获取同一个react依赖包的原因,否则,你的组件不会“看到”这个dispatcher!如果在同一棵组件树中存在多个React副本,就有可能发生问题。不过这样容易出现隐蔽bug,所以Hooks会强迫你在发生前就解决依赖包重复问题。

虽然我们不鼓励这样做,但为了更适用于某些情景,你可以在技术上自行覆盖dispatcher(__currentDispatcher是我编造的,不过你可以在代码库中找到真实的名称),例如,React DevTools会用一个专门定制的dispatcher通过捕获JavaScript堆栈轨迹来描绘反馈Hooks树。不要在家重复这样做了

这也意味着Hooks本身并不依赖于React。如果将来有更多的类库想复用React里的Hooks理念,理论上dispatcher可以挪过去用并且作为一个更少“可怕”名称的一流API展现出来。在开发过程中,我们应该避免过早抽象概念,直到我们不得不这么做了。

updater字段和__currentDispatcher对象都形成于一个叫 依赖注入 的通用编程原理。这两种情况里,渲染器将诸如setState之类的功能实现“注入”到通用的React依赖包中,组件因此以声明为主。

在使用React时,你不需要思考这些是怎么跑起来的。我们希望React开发者花更多的时间在应用程序代码上,而不是像依赖注入这些抽象概念上。但如果你想知道this.setState()或者useState是如何知道怎么做的,我希望这会有所帮助。


翻译原文How Does setState Know What to Do?(2018-12-09)