要有心里准备,这篇文章抽象又拗口,希望有人可以将它视觉化!
当你在组件里调用 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 Thanks ;
}
return (
Click me!
);
}
}
ReactDOM.render( , document.getElementById('container'));
很明显,React会随着新的 { clicked: true}
状态重渲染组件(component),更新DOM,匹配返回 Thanks
元素(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-dom
、 react-dom/server
、 react-native
、 react-test-renderer
、 react-art
都是渲染器样例(你可以 搭建自己的
)。
这也是为什么 react
依赖包不管面向哪个平台都可行,它所有的导出,例如 React.Component
、 React.createElement
、 React.Children
和最近的Hooks,都独立于目标平台,无论你运行 React DOM、React DOM Server或者React Native,你都可以用同一种方式导入使用组件。
相比之下,渲染器依赖包暴露特定平台的APIs,如 ReactDOM.render()
,可以将React组件插入DOM节点中。每个渲染器都会提供一个类似的API,理想情况下,大多数 组件
不需要从渲染器导入任何内容,这使它们更灵活。
大多数人认为React的“引擎”在每个渲染器中。不过许多渲染器确实包含了同一份副本代码 —— 我们称为 "reconciler"
。有个构建步骤将 reconciler 代码与渲染器代码融合成一份高度优化过的代码,以获得更好的性能。(通常不利于依赖包大小,但绝大多数用户一次只需要一个渲染器,例如 react-dom
)
这里要说的是, react
依赖包只让你知道React有哪些功能,但不知道功能是如何实现的。渲染器依赖包( react-dom
、 react-native
等)提供了React功能的实现和平台特性的逻辑。其中一些代码是共享的("reconciler"),但更多的是各个渲染器的具体实现。
现在我们知道为什么有功能时, react
和 react-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;
}
当你在代码里使用
或者
时, 渲染器
决定如何处理它们。React DOM可能以一种方式跟踪context,而React DOM Server可能会采用另一种方式。
如果你更新 react
到16.3+而没更新 react-dom
,你将使用的渲染器便不知道什么是 Provider
和 Consumer
。
这也是旧的 react-dom
会引发类型无效错误的原因
。
React Native同样有这警告。不过不同于 React DOM,一次React更新发布不会“迫使”React Native也立即发布新版本,它有自己一套发行时间表。更新的渲染器代码将 单独同步
到React Native代码库中。所以React Native和React DOM同一个功能,可以用上的时间是不同的。
好了,我们现在知道 react
依赖包不包含任何有趣的内容,因为具体实现放到 react-dom
、 react-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)