React Hooks是一个错误吗?

Web开发社区在过去几周一直在讨论signals,这是一种响应式编程模式,可以实现非常高效的UI更新。有很多人对React编程模型并不感到满意。为什么会这样呢?

我认为问题在于,人们对组件的心智模型与React中的使用钩子的函数组件的工作方式不匹配。我要提出一个大胆的说法:人们喜欢signals,因为基于signals的组件更类似于类组件,而不是使用钩子的函数组件。

React组件以前是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Game extends React.Component {
state = { count: 0, started: false };

increment() {
this.setState({ count: this.state.count + 1 });
}

start() {
if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000);
this.setState({ started: true });
}

render() {
return (
<button
onClick={() => {
this.increment();
this.start();
}}
>
{this.state.started ? "Current score: " + this.state.count : "Start"}
</button>
);
}
}

每个组件都是React.Component类的一个实例。状态保存在state属性中,回调函数只是实例的方法。当React需要渲染一个组件时,它会调用render方法。

你仍然可以像这样编写组件。语法并没有被移除。但是在2015年,React引入了一些新东西:无状态函数组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function CounterButton({ started, count, onClick }) {
return <button onClick={onClick}>{started ? "Current score: " + count : "Start"}</button>;
}

class Game extends React.Component {
state = { count: 0, started: false };

increment() {
this.setState({ count: this.state.count + 1 });
}

start() {
if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000);
this.setState({ started: true });
}

render() {
return (
<CounterButton
started={this.state.started}
count={this.state.count}
onClick={() => {
this.increment();
this.start();
}}
/>
);
}
}

当时,还没有办法向这些组件添加状态 —— 必须将状态保留在类组件中,并作为props传递。这个想法是,大多数组件都是无状态的,由树顶部附近的一些有状态组件提供动力。

但是,当写类组件时,情况有点……尴尬。组合有状态逻辑特别棘手。假设您需要多个不同的类来监听窗口调整事件。如果没有在每个类中重写相同的实例方法,您将如何做到这一点?如果您需要它们与组件状态进行交互怎么办?React尝试通过混合解决这个问题,但一旦团队意识到它们的缺点,混合就很快被弃用了。

此外,人们真的很喜欢函数组件!甚至有一些库可以为它们添加状态。所以也许不足为奇,React提出了一个内置解决方案:Hooks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Game() {
const [count, setCount] = useState(0);
const [started, setStarted] = useState(false);

function increment() {
setCount(count + 1);
}

function start() {
if (!started) setTimeout(() => alert(`Your score was ${count}!`), 5000);
setStarted

(true);
}

return (
<button
onClick={() => {
increment();
start();
}}
>
{started ? "Current score: " + count : "Start"}
</button>
);
}

当我第一次尝试它们时,钩子是一个启示。它们确实使得封装行为和重用有状态逻辑变得容易。

乍一看,这个组件与上面的类组件工作方式相同,但存在一个重要的区别。也许你已经注意到了:UI中的分数将被更新,但是当警报出现时,它总是显示0。因为setTimeout只在第一次调用start时发生,它会封闭初始count值,这就是它将永远看到的所有内容。

您可能认为可以使用useEffect来解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function Game() {
const [count, setCount] = useState(0);
const [started, setStarted] = useState(false);

function increment() {
setCount(count + 1);
}

function start() {
setStarted(true);
}

useEffect(() => {
if (started) {
const timeout = setTimeout(() => alert(`Your score is ${count}!`), 5000);
return () => clearTimeout(timeout);
}
}, [count, started]);

return (
<button
onClick={() => {
increment();
start();
}}
>
{started ? "Current score: " + count : "Start"}
</button>
);
}

这个警报将显示正确的计数。但是又出现了一个新问题:如果您继续点击,游戏永远不会结束!为了防止effect函数封闭的内容变得“过时”,我们将countstarted添加到依赖数组中。每当它们发生变化时,我们都会得到一个新的effect函数,它会看到更新后的值。但是新的effect也会设置一个新的超时。每次点击按钮时,您都会获得五秒的新时间,然后警报就会出现。
在类组件中,方法始终可以访问到最新的状态,因为它们具有对类实例的稳定引用。但在函数组件中,每次渲染都会创建新的回调函数,它们封闭了自己的状态。每次函数被调用时,都会得到自己的闭包。未来的渲染无法改变过去渲染的状态。

换个角度来说:类组件每个挂载的组件只有一个实例,但函数组件每次渲染都有多个“实例” —— 每个渲染一个。Hooks 只是进一步巩固了这种限制。这是你在使用它们时遇到问题的根源:

  • 每次渲染都会创建自己的回调函数,这意味着在运行副作用之前检查引用相等性的任何东西 —— 如useEffect及其兄弟们 —— 都会被触发得太频繁。
  • 回调函数封闭了它们渲染时的状态和 props,这意味着因为 useCallback、异步操作、超时等原因而持久存在于渲染之间的回调函数将访问到过时的数据。

React 给了你一个解决这个问题的方法:useRef,一个可变对象,在渲染之间保持稳定的身份标识。我把它看作是在同一个挂载的组件的不同实例之间传送值的一种方式。考虑到这一点,下面是我们的游戏使用 Hooks 的一个工作版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Game() {
const [count, setCount] = useState(0);
const [started, setStarted] = useState(false);
const countRef = useRef(count);

function increment() {
setCount(count + 1);
countRef.current = count + 1;
}

function start() {
if (!started) setTimeout(() => alert(`你的得分是 ${countRef.current} 分!`), 5000);
setStarted(true);
}

return (
<button
onClick={() => {
increment();
start();
}}
>
{started ? "当前分数: " + count : "开始"}
</button>
);
}

这看起来相当笨拙!我们现在要在两个不同的地方追踪计数,而且我们的增量函数必须同时更新它们。它之所以能工作是因为每个开始闭包都可以访问相同的 countRef;当我们在一个闭包中修改它时,所有其他闭包也可以看到修改后的值。但我们不能完全摆脱 useState,仅依赖于 useRef,因为改变引用不会导致 React 重新渲染。我们陷入了两个不同世界之间的困境 —— 我们用来更新 UI 的不可变状态,以及具有当前状态的可变引用。

类组件没有这个缺点。每个挂载的组件都是类的实例的事实给了我们一种内置的引用。Hooks 给了我们一个更好的原语来组合有状态逻辑,但是它付出了代价。

尽管signals并不新鲜,但近来它们的流行度急剧上升,并且似乎被大多数除了 React 以外的主要框架所使用。

通常的说法是它们实现了“精细的反应性”。这意味着当状态改变时,它们只更新依赖于它的特定 DOM 部分。目前,这通常比 React 更快,因为 React 会在丢弃不变的部分之前重新创建完整的虚拟 DOM 树。但对我来说,这些都是实现细节。人们不是仅仅为了性能而转向这些框架。他们转向是因为这些框架提供了一种基本不同的编程模型。

如果我们用 Solid 重写我们的小计数游戏,我们会得到类似于这样的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Game() {
const [count, setCount] = createSignal(0);
const [started, setStarted] = createSignal(false);

function increment() {
setCount(count() + 1);
}

function start() {
if (!started()) setTimeout(() => alert(`你的得分是 ${count()} 分!`), 5000);
setStarted(true);
}

return (
<button
onClick={() => {
increment();
start();
}}
>
{started() ? "当前分数: " + count() : "开始"}
</button>
);
}

它看起来几乎和第一个 Hooks 版本一样!唯一的可见区别是我们调用 createSignal 而不是 useState,并且 count started 是我们想要访问值时调用的函数。与类和函数组件一样,尽管表面上看起来相似,但实际上存在着重要的区别。

惯用的 Solid 实际上使用Show组件来实现条件 UISolid 和其他基于signals的框架的关键是组件只运行一次,并且框架设置了一个数据结构,当信号变化时自动更新 DOM。只运行组件一次意味着我们只有一个闭包。只有一个闭包给我们再次实现了每个挂载的组件一个稳定的实例的能力,因为闭包相当于类。
没错!从根本上讲,它们两者都只是数据和行为的捆绑。闭包主要是行为(函数调用),带有关联数据(闭包变量),而类主要是数据(实例属性),带有关联行为(方法)。如果你真的想的话,你可以用其中之一来写另一个。

从技术上讲,它们等同于类实例——也就是对象。有些人说“闭包是穷人的对象”,反之亦然。想想看,用类组件……

构造函数设置了组件渲染所需的一切(设置初始状态、绑定实例方法等)。
当你更新状态时,React改变了类实例,调用了render方法,并对DOM进行了必要的更改。
所有函数都可以访问类实例上存储的最新状态。
而使用signals组件……

函数体设置了组件渲染所需的一切(设置数据流、创建DOM节点等)。
当你更新一个信号时,框架会改变存储的值,运行任何依赖信号,并对DOM进行必要的更改。
所有函数都可以访问函数闭包中存储的最新状态。
从这个角度来看,更容易看到权衡。就像类一样,信号是可变的。这可能看起来有点奇怪。毕竟,Solid组件并没有分配任何东西——它调用了setCount,就像React一样!但请记住,count本身不是一个值——它是一个返回信号当前状态的函数。当调用setCount时,它会改变信号,而进一步调用count()将返回新值。

虽然SolidcreateSignal看起来像ReactuseState,但signals更像是引用:对可变对象的稳定引用。不同之处在于,在以不可变性为基础的React中,引用是一个逃生通道,对渲染没有影响。但是像Solid这样的框架将信号置于前台。它们不会被忽视,而是在它们变化时做出反应,仅更新使用它们值的DOM的特定部分。

这的一个重要结果是UI不再是状态的纯函数。这就是为什么React拥抱不可变性的原因:它保证了状态和UI的一致性。当引入突变时,您还需要一种方法来保持UI同步。signals是一种可靠的方法来做到这一点,它们的成功将取决于它们能否实现这一承诺。

回顾一下:

首先是类组件,它们将状态保留在共享渲染之间的单个实例中。
然后是带有钩子的函数组件,在这些组件中,每个渲染都有自己的隔离实例和状态。
现在我们正在转向signals,再次将状态保留在单个实例中。
那么React Hooks是一个错误吗?它们确实使分解组件和重用有状态逻辑变得更容易。甚至在我打字的同时,如果你问我是否会放弃钩子并回到类组件,我会告诉你不会。

尽管我确实想知道如果React更倾向于类范例,并实现类似于游戏开发中的组件模式,它会是什么样子。
与此同时,我也意识到signals的吸引力在于重新获得我们已经拥有的类组件。React对不可变性做出了重大赌注,但人们已经在寻找同时具有不可变数据和便利性的方法了一段时间。这就是为什么存在像immerMobX这样的库:事实证明,使用可变数据的人性化是非常方便的。

尽管人们似乎喜欢函数组件和钩子的美学,但你可以在较新的框架中看到它们的影响。SolidcreateSignal几乎与ReactuseState相同。PreactuseSignal也类似。很难想象如果没有React引领先驱,这些API会是什么样子。

signalsHooks更好吗?一切都有权衡,我们对signals所做的比较相当确定:它们放弃了不可变性和UI作为状态纯函数的特性,以换取更好的更新性能和每个已挂载组件的稳定、可变实例。

时间将告诉我们,signals是否会带回React创建的问题。但就目前而言,框架似乎正在努力在钩子的可组合性和类的稳定性之间取得平衡。至少,这是一个值得探索的选项。