本文翻译自官方文档 Keeping Components Pure
有些 JS 函数是“纯函数”。纯函数只进行计算。编写组件时,只有严格保证它是纯函数,才能避免一些莫名其妙的问题和意料之外的行为。
你会在本文学到:
- 纯度(purity)是什么,它如何帮我们避免一些问题
- 为了保证组件的纯粹,如何将变化移到渲染之外
- 使用 Strict Mode 发现组件错误
纯度:把组件当作方程式
在计算机科学(尤其在函数式编程的世界),纯函数具备以下特性:
- 只关注自己的逻辑。在它执行前创建的任何对象或变量,它都不会改变。
- 相同输入必然产生相同输出。
我们也许熟悉纯函数的一个例子:数学中的方程式。
比如,当给定方程式 时:
如果 ,那么 。总是如此。
如果 ,那么 。总是如此。
如果 ,那么 绝不会随着时间或股票市场的变动,一会儿变成 9,一会儿变成 -1 或 2.5。
如果用 JS 函数表示,代码如下:
function double(number) {
return 2 * number;
}
在上述例子中,double() 是纯函数。如果传入 3,它的返回值始终是 6。
React 就是围绕这个概念设计的。React 假定你写的每个组件都是纯函数。这意味着,你编写的 React 组件,对于给定输入,必须有相同输出。就像数学方程式一样。
你可以把组件当作菜谱:如果你照着它烹饪,不自作主张添加新配方,每次都能得到相同的菜肴。这里的“菜肴”就是组件产生的 JSX,它会被 React 渲染。
副作用:预期的和非预期的结果
React 的渲染进程必须是纯粹的。组件只能返回他们的 JSX,不可以改变渲染之前存在的对象和变量 --- 这会让他们不纯粹!
下面举例说明:
let guest = 0;
function Cup() {
// 不好:改变了已经存在的变量
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}
guest 变量在组件之外定义,但组件读取且修改了它。这意味着,多次调用该组件将产生不同的 JSX!更糟的是,如果其他组件也读取了 guest,在不同的渲染时刻,他们也会产生不同的 JSX!这是无法预测的。
回到方程式 ,现在即使 ,我们也无法保证 。我们的测试会失败,用户会困惑不解,飞机会坠毁 --- 要知道这将造成巨大的问题!
可以将 guest 当作属性传递,修复这个组件:
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup guest={1} />
<Cup guest={2} />
<Cup guest={3} />
</>
);
}
现在,组件是纯组件,它返回的 JSX 只与 guest 属性有关。
通常,我们不能指望组件以某种特定的顺序执行。 在 前面或后面调用都无所谓:两个方程式都能各自得到正确的结果。同样,每个组件都应该“独立思考”,不要在渲染阶段依赖其他组件。渲染就像学校考试:每个组件都应该独立计算自己的 JSX!
React 提供了一个 "严格模式(Strict Mode)"。严格模式下,开发阶段针对每个组件,它会调用两次组件函数。严格模式可以借此帮助发现破坏规则的组件。严格模式在生产环境下无效,因此不会拖垮线上应用。将根组件放入
<React.StrictMode>标签中,就能开启严格模式。一些框架默认使用严格模式。
局部突变:你组件的小秘密
上述例子中,问题在于组件在渲染阶段改变了已经存在的变量。这通常称作“突变(mutation)”,听上去有些可怕😨。纯函数不会改变函数作用域之外的变量 --- 这让他们不纯粹!
但是,在渲染阶段改变刚刚创建的变量或对象,是完全没问题的。在本例中,创建了 [] 数组,将它赋予 cups 变量,然后添加了多个 cups 变量:
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaGathering() {
let cups = [];
for (let i = 1; i <= 12; i++) {
cups.push(<Cup key={i} guest={i} />);
}
return cups;
}
如果 cups 变量或 [] 数组在 TeaGathering 函数之外创建,这会导致大问题!通过将元素压入数组,你就改变了现存的对象。
然而,你是在 TeaGathering 同一个渲染阶段创建的这些变量,因此没有任何问题。TeaGathering 之外的代码,完全不知道发生了什么。这叫“局部突变(local mutation)” --- 就像是我们组件的小秘密。
在哪里可以产生副作用
尽管函数式编程极其依赖纯度,在某时某地,有些东西不得不变。那才是编程的意义所在!这些改变 --- 更新屏幕,开启动画,改变数据 --- 都叫做副作用(side effects)。它们不是在渲染阶段发生的,而是“在别处”发生。
在 React,副作用通常在事件处理函数中产生。事件处理函数是 React 执行的函数,它在你进行某些操作时调用 --- 比如,当你点击一个按钮。尽管事件处理函数在组件中定义,它们在渲染之外执行!因此,事件处理函数可以不纯!
当你绞尽脑汁,依然无法找到合适的事件处理函数存放副作用时,可以在组件的 JSX 中添加 useEffect 调用,将副作用置于其中。这会告诉 React 在渲染后稍晚些再执行它,此时允许副作用发生。但是,这种方法应该是你的最后的手段。
尽可能坚持只在渲染阶段表现你的逻辑。它能实现的功能之广,会让你吃惊不已。
为什么 React 如此重视纯度?
编写纯函数需要养成遵循一定的习惯和规则。但是它也会释放巨大的机会:
- 你的组件可以在不同的环境中执行 --- 比如,服务端!因为对于同样输入返回同样结果,一个组件可以响应大量的用户请求。
- 如果某些组件的输入没有改变,可以忽略这些组件的渲染,这样可以提高性能。因为纯组件总是返回同样的结果,因此可以安全的缓存。
- 如果在渲染层级很深的组件树时,数据发生变化,React 可以重新渲染,无需在未完成的渲染上浪费时间。纯度可以让我们很安全的随时中止计算。
React 的每个新特性都基于纯度。从数据获取到动画,再到性能优化,保持组件的纯粹可以释放 React 范式的巨大能量。
回顾
- 组件必须纯粹,意味着:
- 关心自身逻辑。它不应改变渲染之前存在的对象或变量
- 相同输入,相同输出。对于相同的输入,组件总是返回相同的 JSX
- 渲染可能随时发生,因此组件不应依赖其他组件的渲染次序
- 你不要改变组件渲染依赖的任何输入变量。这包括 props,state 和 context。若要更新屏幕,"set" state,而不是直接修改现存对象。
- 尽量在返回的 JSX 中表达你的组件逻辑。当你需要“改变一些东西”时,通常可以在事件处理函数中执行。把
useEffect当作你的最后的方法。 - 编写纯函数需要一些训练,但是它将释放 React 范式的巨大能量。