原文:React hooks: not magic, just arrays
by:Rudi Yardley
我是 hooks API 的超级粉丝。不过,它在使用上有一些奇怪的限制。本文我将为那些难以理解这些限制的人提供一个使用 hooks API 的思考模型。
解读 hooks 的工作原理
我听说有些人对 hooks API 提案中的"黑魔法"感到困惑,所以,我想至少从表面上解读一下该提案是如何工作的。
hooks 的规则
React 核心团队在hooks 提案文档中概述了使用 hooks 需要遵循的两条主要规则。
- 不要在循环语句、条件语句或嵌套函数内调用 hooks
- 只在 React 函数中调用 hooks
我认为后者是不言自明的。要在函数组件上附加行为,就得以某种方式将行为与组件联系起来。
不过,我认为前者可能会让人感到困惑,因为得按照这种规则使用 API 似乎不太合乎常理,这正是我今天要探讨的主题。
hooks 中的状态管理与数组有关
为了获得更清晰的思维模型,让我们来看看一个简单得 hooks API 实现。
请注意,这只是一种推测,只是展示你的思考方式的一种可能的实现方式。这并不一定是 API 的内部工作方式。
如何实现 useState()
?
接下来我们将举例说明状态 hook 的实现方式。
首先,让我们从一个组件开始:
js
function RenderFunctionComponent() {
const [firstName, setFirstName] = useState("Rudi");
const [lastName, setLastName] = useState("Yardley");
return (
<Button onClick={() => setFirstName("Fred")}>Fred</Button>
);
}
hooks API 背后的理念是:hook 函数返回一个数组,数组的第二项是一个 setter 函数,你可以使用该函数管理状态。
那么,React 是如何做到的呢?
让我们看一下 React 内部是如何工作的。以下代码运行在特定组件的渲染上下文中。这意味着,这里存储的数据(状态)位于正在渲染的组件外。该状态不会与其他组件共享,但可以在该组件的后续渲染中访问。
1) 初始化
创建两个空数组:setters
和 state
设置索引为 0
初始:两个空数组,索引为 0
2) 首次渲染
首次运行组件函数。
首次运行时,每次调用 useState()
都会将一个 setter 函数压入 setters
(压入索引位置),然后将一些状态压入 state
数组。
首次渲染:状态和 setter 函数被压入数组,同时索引自增
3) 后续渲染
后续的每一次渲染,索引值都将重置,然后从数组中分别读取状态和 setter 函数。
后续渲染:读取状态和 setter 函数,同时索引自增
4) 事件处理
每个 setter 函数都有指向其索引的引用,因此只要调用任意 setter
,就会改变 state 数组中对应索引位置状态的值。
setter 会"记住"它的索引,并根据索引设置状态
简单(naive)的实现
下面是一个简单的代码示例。
注:以下并不代表 hooks 的工作方式,但它应该能让你对 hooks 如何在单个组件中工作有一个很好的概念。这也是我们使用模块级变量的原因。
js
let state = [];
let setters = [];
let firstRun = true;
let cursor = 0;
function createSetter(cursor) {
return function setterWithCursor(newVal) {
state[cursor] = newVal;
};
}
// 这是 useState 的"伪代码"
export function useState(initVal) {
if (firstRun) {
state.push(initVal);
setters.push(createSetter(cursor));
firstRun = false;
}
const setter = setters[cursor];
const value = state[cursor];
cursor++;
return [value, setter];
}
// 使用 hooks 的组件
function RenderFunctionComponent() {
const [firstName, setFirstName] = useState("Rudi"); // cursor: 0
const [lastName, setLastName] = useState("Yardley"); // cursor: 1
return (
<div>
<Button onClick={() => setFirstName("Richard")}>Richard</Button>
<Button onClick={() => setFirstName("Fred")}>Fred</Button>
</div>
);
}
// 这是在模拟 React 渲染周期
function MyComponent() {
cursor = 0; // 重置索引
return <RenderFunctionComponent />; // 渲染
}
console.log(state); // 首次渲染前: []
MyComponent();
console.log(state); // 首次渲染: ['Rudi', 'Yardley']
MyComponent();
console.log(state); // 后续渲染: ['Rudi', 'Yardley']
// 点击 "Fred" 按钮
console.log(state); // 点击之后: ['Fred', 'Yardley']
为什么顺序这么重要
现在,如果我们根据一些外部因素甚至组件的状态改变渲染周期内 hooks 的顺序,会发生什么?
让我们来做 React 团队说你不应该做的事情:
js
let firstRender = true;
function RenderFunctionComponent() {
let initName;
if(firstRender){
[initName] = useState("Rudi");
firstRender = false;
}
const [firstName, setFirstName] = useState(initName);
const [lastName, setLastName] = useState("Yardley");
return (
<Button onClick={() => setFirstName("Fred")}>Fred</Button>
);
}
我们在条件语句中调用了 useState
。让我们看看这会给程序带来多大的破坏。
不良组件的首次渲染
渲染一个"不良" hook,该 hook 在下次渲染就会消失
不良组件的第二次渲染
如果在渲染之间移除 hook,就会出现错误
现在,由于存储的状态变得不一致,firstName
和 lastName
都被设置为 "Rudi"
,这显然是错误的,并且无法工作,但它让我们明白了为什么 hook 要那样规定。
React 团队之所以要设置使用规则,是因为不遵守这些规则会导致数据不一致。
想想 hooks 如何操作数组,你就不会违反规则了
所以,现在你应该清楚为什么不能在条件或循环中调用 hooks 了。因为我们处理的是指向数组的索引,如果你在渲染过程中改变调用的顺序,索引将无法匹配数据,你的 hooks 调用将无法指向正确的状态或 setter。
所以诀窍是把 hooks 看作是需要一致索引的数组。如果你这样了,一切都将正常工作。
结论
希望我已经为 hooks API 的底层工作机制提供了更清晰的思维模式。请记住,这里的真正价值是将关注点组合在一起,因此要小心顺序,使用 hook API 将带来很高的回报。
Hooks 是 React 组件的一个有效的插件 API。人们对它感到兴奋是有原因的,如果你考虑这种状态以数组形式存在的模型,那么你应该不会违反它们的使用规则。