16
本文翻译自:How Does setState Know What to Do?

原作者:Dan Abramov

如果有任何版权问题,请联系shuirong1997@icloud.com

当你在组件中调用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的东西,可不是React DOM的。另外,我们的基类React.Component是被定义在React内部。

所以问题来了:React.Component内部的setState怎么能去更新DOM呢?

事先声明:就像我的其他博客,你不需要熟练掌握React。这篇博客是为那些想要看看面纱之后是什么东西的人准备的。完全可选!


我们或许会认为React.Component类已经包含了DOM更新逻辑。

但如果这是事实,那this.setState是如何工作在其他环境中呢?比如:在React Native App中的组件也能继承React.Component,他们也能像上面一样调用this.setState(),并且React Native工作在Android和iOS的原生视图而不是DOM中。

你可能也对React Test Renderer 或 Shallow Renderer比较熟悉。这两个测试渲染器让你可以渲染一般的组件并且也能在他们中调用this.setState,但他们可都不使用DOM。

如果你之前使用过一些渲染器比如说React ART,你可能知道在页面中使用超过一个渲染器是没什么问题的。(比如:ART组件工作在React DOM 树的内部。)这会产生一个不可维持的全局标志或变量。

所以React.Component以某种方式将state的更新委托为具体的平台(译者注:比如Android, iOS),在我们理解这是如何发生之前,让我们对包是如何被分离和其原因挖得更深一点吧!


这有一个常见的错误理解:React "引擎"在react包的内部。这不是事实。

事实上,从 React 0.14开始对包进行分割时,React包就有意地仅导出关于如何定义组件的API了。React的大部分实现其实在“渲染器”中。

渲染器的其中一些例子包括:react-dom,react-dom/server,react-native,react-test-renderer,react-art(另外,你也可以构建自己的)。

这就是为什么react包帮助很大而不管作用在什么平台上。所有它导出的模块,比如React.ComponentReact.createElementReact.Children[Hooks](https://reactjs.org/docs/hooks-intro.html),都是平台无关的。无论你的代码运行在React DOM、React DOM Server、还是React Native,你的组件都可以以一种相同的方式导入并且使用它们。

与之相对的是,渲染器会暴露出平台相关的接口,比如ReactDOM.render(),它会让你可以把React挂载在DOM节点中。每个渲染器都提供像这样的接口,但理想情况是:大多数组件都不需要从渲染器中导入任何东西。这能使它们更精简。

大多数人都认为React“引擎”是位于每个独立的渲染器中的。许多渲染器都包含一份相同的代码—我们叫它“调节器”,为了表现的更好,遵循这个步骤 可以让调节器的代码和渲染器的代码在打包时归到一处。(拷贝代码通常不是优化“打包后文件”(bundle)体积的好办法,但大多数React的使用者一次只需要一个渲染器,比如:react-dom(译者注:因此可以忽略调节器的存在))

The takeaway here 是react包仅仅让你知道如何使用React的特性而无需了解他们是如何被实现的。渲染器(react-dom,react-native等等)会提供React特性的实现和平台相关的逻辑;一些关于调节器的代码被分享出来了,但那只是单独渲染器的实现细节而已。


现在我们知道了为什么reactreact-dom包需要为新特定更新代码了。比如:当React16.3新增了Context接口时,React.createContext()方法会在React包中被暴露出来。

但是React.createContext()实际上不会实现具体的逻辑(译者注:只定义接口,由其他渲染器来实现逻辑)。并且,在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会用A方法追踪context值,但React DOM Server或许会用另一个不同的方法实现。

所以如果你将react升级到16.3+,但没有升级react-dom,你将使用一个还不知道ProviderConsumer类型的渲染器,这也就旧版的react-dom可能会报错:fail saying these types are invalid的原因。

同样的警告也会出现在React Native中,但是不同于React DOM,一个新的React版本不会立即产生一个对应的React Native版本。他们(React Native)有自己的发布时间表。大概几周后,渲染器代码才会单独更新到React Native库中。这就是为什么新特性在React Native生效的时间会和React DOM不同。


Okay,那么现在我们知道了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;

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)呢?

当人们第一次看到 钩子接口的提案时,他们常回想:useState是怎么知道该做什么呢?这一假设简直比对this.setState()的疑问还要迷人。

但就像我们如今看到的那样,setState()的实现一直以来都是模糊不清的。它除了传递调用给当前的渲染器外什么都不做。所以,useState钩子做的事也是如此。

这次不是updater,钩子(Hooks)使用一个叫做“分配器”(dispatcher)的对象,当你调用React.useState()React.useEffect()或者其他自带的钩子时,这些调用会被推送给当前的分配器。

// In React (simplified a bit)
const React = {
  // Real property is hidden a bit deeper, see if you can find it!
  __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共享的调节器实现在这里

这就是为什么像react-dom这样的渲染器需要访问和你调用的钩子所使用的react一样的包。否则你的组件将找不到分配器!如果你有多个React的拷贝在相同的组件树中,代码可能不会正常工作。然而,这总是造成复杂的Bug,因此钩子会在它耗光你的精力前强制你去解决包的副本问题。

如果你不觉得这有什么,你可以在工具使用它们前精巧地覆盖掉原先的分配器(__currentDispatcher的名字其实我自己编的但你可以在React仓库中找到它真正的名字)。比如:React DevTools会使用一个特殊的内建分配器来通过捕获JavaScript调用栈来反映(introspect)钩子。不要在家里重复这个(Don’t repeat this at home.)(译者注:可能是“不要在家里模仿某项实验”的衍生体。可能是个笑话,但我get到)

这也意味着钩子不是React固有的东西。如果在将来有很多类库想要重用相同的基础钩子,理论上来说分配器可能会被移到分离的包中并且被塑造成优秀的接口—会有更少让人望而生畏的名称—暴露出来。在实际中,我们更偏向去避免过于仓促地将某物抽象,直到我们的确需要这么做。

updater__currentDispatcher都是泛型程序设计(依赖注入/dependency injection)的绝佳实例。渲染器“注入”特性的实现。就像setState可以让你的组件看起来简单明了。

当你使用React时,你不需要考虑它是如何工作的。我们期望React用户去花费更多的时间去考虑它们的应用代码而不是一些抽象的概念比如:依赖注入。但如果你曾好奇this.setState()useState()是怎么知道它们该做什么的,那我希望这篇文章将帮助到你。


林水溶
1.2k 声望82 粉丝

Front End Developer