对 React 的初学者来说,除去 useEffect 这个大坑,React 还有不少看起来有些诡异的规则,比如
刚开始学习的时候,我只是盲目地遵从着 eslint 的报错提示,心里觉得很生硬;等熟悉 React 后,我才明白了它们的原因。接下来我将尝试分别解释这三条规则的必要性,而到最后,大家将会明白它们都是因 React 的渲染机制而起。
解释过程中我并不会引入 Fiber 以及更底层的 React 技术细节概念,因为一是我希望文章总是能被更多人阅读,尽量保持简洁,降低门槛。二是我觉得底层实现细节和这些规则实际上并没有直接关系,引入它们反而会制造噪音。对于想要了解更多(乃至从头实现一个 React)的朋友,我在文章结尾给出了一些我搜集到的拓展阅读。
首先让我们先描述一下 React 的渲染过程。 假设我们有这样一个 Counter 组件,(摘自官方教程)
export default function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return <button onClick={handleClick}>You pressed me {count} times</button>;
}
当这个 组件函数 (function Counter
)第一次执行,即 React 挂载1 Counter 组件时, 组件函数 第一次执行useState
函数,执行后 React 给相对应的组件注册一个useState
hook,并初始化 state;useState 最后返回当前 state 的值,以及一个对 state 进行更改的 setState
函数。函数组件在最后返回的 JSX 将 setState
绑定到 button 的点击事件中。当用户点击 button 时,setState 触发重新渲染(rerender),React 再一次执行了这个 组件函数 ,执行过程中再一次运行useState
函数,更新 state,并返回最新的值,React 将其填充到 JSX 中,最后更新视图。
整个过程最重要的其实就一点:每次用 setState
更改状态的时候,React 都会重新执行整个组件函数。
记住这点后,那首先来思考第一条规则——为什么不要在条件判断中使用 hook?
要回答这个问题,我们首先要明白,hook 的作用是什么?hook 首先是函数,回顾对 React 渲染过程的描述,我们在执行组件函数的过程中,调用了useState
这个 hook 函数;随后在 rerender 的过程中, React 再次执行了组件函数,**并再次调用useState
**拿到了更新后的状态。
所以关键在于,每一次触发 rerender,我们都会 重新执行一遍组件函数,组件函数的执行结果也就是 rerender 的结果。
那如果从函数的执行角度来讲,前一次和后一次函数的执行结果分别是什么呢?React 看到了什么?
假设我们有这样一个表单组件(摘自 React 官方教程)
import {`useState`} from "React";
export default function Form() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const fullName = firstName + " " + lastName;
function handleFirstNameChange(e) {
setFirstName(e.target.value);
}
function handleLastNameChange(e) {
setLastName(e.target.value);
}
return (
<>
<h2>Let’s check you in</h2>
<label>
First name: <input value={firstName} onChange={handleFirstNameChange} />
</label>
<label>
Last name: <input value={lastName} onChange={handleLastNameChange} />
</label>
<p>
Your ticket will be issued to: <b>{fullName}</b>
</p>
</>
);
}
要注意,“firstName”是对useState
返回的状态值的命名,而不是对状态本身命名,“firstName”并没有作为参数参与到对useState
的调用当中。所以从 React 的视角来看,组件函数执行的结果是这样的。
import {`useState`} from 'React';
export default function Form() {
useState('');
useState('');
...
return (
<>
...
</>
);
}
从这个视角来看,能区分这两个 state 的唯一办法,就是他们对应的 useState 的 调用顺序 或者说 书写顺序 。 那如果在条件语句中使用 useState hook,会出现什么情况? 假设原始组件是这样:
import {`useState`} from 'React';
export default function Form() {
if (Math.random() > 0.5) {
const [firstName, setFirstName] = useState('');
}
if (Math.random() > 0.5) {
const [lastName, setLastName] = useState('');
}
...
return (
<>
...
</>
);
}
那执行结果可能是这样
import {`useState`} from 'React';
export default function Form() {
useState('');
...
return (
<>
...
</>
);
}
也有可能是这样
import {`useState`} from 'React';
export default function Form() {
useState('');
...
return (
<>
...
</>
);
}
从这个视角看,在条件判断中使用 hook 带来的问题就呼之欲出了:请问调用这唯一的useState
获取的是代表"firstName"的那个状态,还是代表"lastName"的那个状态?我们没有办法判断,我们只看到了一个 useState。
不仅仅有这个限制,官方文档还列出了诸如不要在循环、嵌套函数、try/catch 代码块中使用 Hooks 的规则,这些规则被归纳为“仅在顶层调用 hooks”。它们的原因都是类似的:在多次函数执行中,区分 Hooks 的唯一办法就是它们的调用顺序,因此要避免一切 有可能 打乱顺序的行为。
这里提一下,我在写这篇文章的时候查询了一些资料,其中很多都有一个大概这样的总结“因为 React 用一个链表(自制 React 则多用数组)来储存 Hooks 的状态,所以必须要保证它的调用顺序与链表/数组中的排序一致”。这个说法不能说错,但我觉得可能过于聚焦于技术细节了。问题不是 React 用什么数据结构去储存 hooks,问题在于,只要 React 每当状态变更就重新执行一遍组件函数,只要每次执行函数都会重新调用一遍 hooks,那在没有 key、id 等标识符的情况下,React 就只能凭借在函数中的调用顺序去辨认不同的 hooks。 这里提到了 key ,这也是另外两个问题的关键。
为什么 React 会保留相同位置相同类型的组件的状态?让我们看看另外一个例子(摘自官方文档),继续观察组件函数的执行结果,但这次关注返回的 JSX 部分。
export default function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
...
</>
);
}
function Counter({ isFancy }) {
...
return (
<div className={isFancy && "fancy"}>
...
</div>
);
}
App 函数的执行结果可以简化为这样(isFancy 为 true),
export default function App() {
useState(false);
return (
<>
<Counter isFancy={true} />
...
</>
);
}
function Counter({ isFancy }) {
...
return (
<div className={isFancy && "fancy"}>
...
</div>
);
}
或这样(isFancy 为 false)
export default function App() {
useState(false);
return (
<>
<Counter isFancy={false} />
...
</>
);
}
function Counter({ isFancy }) {
...
return (
<div className={isFancy && "fancy"}>
...
</div>
);
}
当 React 看到返回的这两个 JSX 时,它同样没法判断这还是不是同一个 counter2。但这个时候 React 多了另一个信息——它们的 组件名 是相同的。出于性能考虑,React 会默认这还是同一个组件,这样就可以仅更新这个组件的属性而非重新挂载这个组件。(在这个案例中, React 计算以及提交虚拟 dom 时,仅需浏览器更新 div 的 class,而非删除掉这个 div 后再重新创建并添加一个 div)
那如何让 React 认识到这并不是同一个组件呢?这就是 key 属性的作用,它作为组件的唯一标识,类似于数据库中的 id。有了它,React 就不用再借助位置、名称类型来判断组件的同一性了,所以我们可以通过设置不同的 key 来重制掉同一类型同一位置组件的状态。
有了前面的铺垫,我们就可以很顺利地解释第三个规则——为什么 React 会要求开发者给 JSX 中的数组项加上 key 属性?
这里首先要定两个个概念:当组件的某种状态/属性会在 react 的多次渲染中改变时,我们可以称它为 受控的,而仅受创建时初次渲染的影响,并在后续渲染中保持稳定的状态/属性,我们则称它为 非受控的。
const Input = (props: Props) => {
const [value, setValue] = useState("value");
const ref = useRef < HTMLInputElement > null;
useEffect(() => {
ref.current?.focus();
}, []);
return (
<input
ref={ref}
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
defaultValue={props.defaultValue}
/>
);
};
例如,在这个组件中,value 就是一个受控的属性,因为它完全受 react 的渲染更新周期控制,而 ref 所指向的 node 节点以及 focus 状态则不是,因为它们在创建完毕之后就不会再变化了。defaultValue 也是一样,即使后续 props 发生变化。本质来说,react 对组件的渲染更新就是在重新设定组件元素的属性,而有些东西(比如 node 节点,以及 focus 状态)不属于属性,有些属性即使重新设定了也不会有效果(比如 defaultValue),所以说他们是非受控的。
解释完概念后再回到列表当中。相比于其它固定的代码,用来 map 的数组在 JSX 中是一个非常不稳定的结构,它随时有可能受 state 和 prop 的影响而增减数组项,因此数组项的顺序或者说位置(index)在数组中并不是一个稳定的标识,一个数组项在两次渲染中很有可能会发生位置改变,然而由于数组项的组件类型相同,React 会错误地仅根据位置去更新数组项,新旧数组项会复用同一位置上的节点元素,这个时候,元素中的受控状态会正常更新,但非受控状态就会发生错乱,因为新的数组项依旧沿用老数组项的元素的非受控状态。React 为了避免混用新旧数组项的 dom 节点,就必须要有一个唯一而稳定的标识去区分它们,将它们与上一次快照中的数组项一一对应。
其实这三个问题最后都可以归结为“React 如何比较重复执行组件函数的不同结果”,如果没有标识符 key,React 就只能根据顺序去识别不同的 Hooks 和组件。无论 React 框架底层细节是如何实现的,只要 React 遵循 每次渲染时都执行一遍组件函数来生成 UI 结果,而非把它当成一种初始化模板 的做法,那就肯定会出现这些问题。