这个系列文章主要是用于练习英文文档的读写能力,期待半年后能脱离工具写出通畅的英文文档。原文地址
在过去几周,Web 开发社区一直在讨论 Signals
。Signals
是一种响应式语法模式,可以响应 UI 变化。Devon Govett 发了一条有趣的推文,与 Signals 和可变数据有关。Ryan Carniato 回复了一篇极好的文章:React VS Signals,所有人都持有相同的看法,评论区开始变得非常有趣。
讨论中一个明显的问题是,很多人并不适应 React 的编程模型。这是为什么呢?
我认为问题在于人们对组件的心智模型和 hooks 如何在函数组件中生效没有很好地联系起来。我有一个大胆的假设:人们喜欢 Signals 是因为基于 Signals 的组件比 Class 组件和函数组件中的 hooks 简单得多。
让我们回想一下,React 组件以前是这样的:
class Game extends React.Component {
state = { count: 0, started: false }
increment() {
this.setState({ count: this.state.count + 1 })
}
start() {
if (!this.stage.started) {
setTimeout(() => {
alert(`you soce was ${this.stage.count!}`)
}, 5000)
}
this.setStage({ started: true })
}
render() {
return (
<button onClick={() => {
this.increment()
this.start()
}}>
{this.state.started ? `Current socre ${this.state.count}` : 'Start'}
</button>
)
}
}
每个组件都是 React.Component
类的一个实例。状态保存在 state
属性中,且回调函数仅仅是实例的方法。当 React 需要渲染一个组件时,将会调用 render
方法。
你可以一直使用这种方式来声明组件,语法一直没有被移除。但是回到2015年,React 引入了一些新事物:无状态函数组件。
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 尝试使用mixins解决这个问题,但一旦团队意识到这些缺点,他们就会很快启用 mixins。
同时,人们开始喜欢使用函数组件,并出现了一些库来为函数组件添加状态,因此不出所料,React 提供了一种解决方案:hooks。
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>
);
}
当我第一次尝试使用 hooks 时,我受到了启发。它们真的使封装行为和复用状态逻辑变得简单了。我开始深度使用它们,唯一需要编写类组件的地方就是错误边界的处理。
这说明了一个事实,即尽管函数组件和类组件能够实现相同的效果,但它们之间存在一些非常重要的区别。你可能已经发现了其中的一个问题:得分会随着 UI 更新而更新,但在警报中,它会一直显示为 0。原因是 setTimeout
仅在第一次执行 start
时发生,并且它会与初始的 count
值形成闭包。
你可能想通过使用 useEffect
来解决这个问题:
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>
);
}
这次 alert
会显示正确的 count
了,但是又出现了一个新的问题:如果你一直点击按钮,这个游戏永远不会结束!为了阻止 effect
函数从闭包中获取过时的数据,我们给 useEffect
新增了两个依赖:count
、started
。只有当这两个依赖变化时,我们才会创建一个新的 effect
函数来获取更新后的值。但是这个新的 effect
函数也会创建一个新的定时器,每次你点击按钮,都会刷新让 alert
显示的定时器的延时时间。
在类组件中,方法总是能够访问最新状态,因为它们和类实例之间有一个稳定的引用。但是在函数组件中,每次渲染后都会创建一个新的回调函数从而和状态形成闭包。回调函数在调用时总是会从闭包中取值,将来的渲染不能改变过去的状态。
另一方面:类组件在渲染时拥有唯一的实例,函数组件每次渲染都有一个“实例”。Hooks 进一步加强了这种限制。这是所有问题的根本原因:
- 每次渲染都会创建独有回调,这意味着会在执行副作用
useEffect
前检查依赖是否变化,这些检查会导致副作用触发过于频繁 - 回调函数会跟状态和 props 形成闭包,这意味着在多次渲染中回调函数会保持不变,因为
useCallback
、异步操作等会访问到过时的数据
React 对于这种情况内置了处理方式:useRef
,一个在每次渲染后都持有一个固定引用的可变对象。我认为这是一个能在同一组件的不同实例中来回传递数据的方法。考虑到这一点,下面是一个使用 useRef
来实现的版本:
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(`Your score was ${countRef.current}!`), 5000)
setStarted(true)
}
return (
<button
onClick={() => {
increment()
start()
}}
>
{started ? "Current score: " + count : "Start"}
</button>
);
}
这太愚蠢了。现在我们需要在两个不同的地方跟踪 count
并且 increment
方法需要同时更新它们。这是因为每次 start
闭包访问到的都是同一个 countRef
;当在一个 start
中改变 countRef
时,其他地方就能够看到最新的值。
我们不能摆脱 useState
而仅仅使用 useRef
,这是因为改变一个 ref 的值不会使 React 重新渲染。我们现在被困在两个不同的世界中:用于更新 UI 的不可变数据和具有最新状态的可变 ref。
类组件没有这些缺点,每次渲染后都是同一个实例,这给了我们一种内置的 ref。Hooks 让我们能够更好地组合私有状态逻辑,但同时它也是有代价的。
尽管“Signals”不是什么新概念,但最近它突然变得很流行,在除 React 以外的各大主流框架中都有实现。
通常认为它可以提供细粒度响应式,这意味着当状态改变时,它们仅会更新依赖于该状态的 DOM 片段。目前来说,这通常比 React 更快,因为它在创建完整的 VDOM 树之前就丢弃了不需要改变的部分。但对于我来说,这些都是无关紧要的,人们不会仅仅因为这些性能而更换整个框架。人们只会因为这些框架提供了完全不同的编程模型。
如果我们使用 Solid 来重写我们的统计小游戏,可能会看起来像下面这样:
function Game() {
const [count, setCount] = createSignal(0)
const [started, setStarted] = createSignal(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>
);
}
这看起来像是第一个 hooks 版本,唯一的区别就是使用 createSignal
替换了 useState
,并且在访问 count
和 started
时是作为一个函数调用。与类和函数组件一样,看起来相似但却完全不同。
关键在于 Solid 和其他基于 Signal 的框架仅仅执行组件一次。框架设置了一个数据结构,当 signal 更新时自动更新 DOM。仅执行组件一次意味着只有一个闭包。只有一个闭包给了我们一个在多次渲染中稳定的实例,因为闭包等效于类。
什么?
这是正确的!从根本上来说,它们都只是数据和行为的捆绑。闭包主要是具有关联数据(对变量闭合)的行为(函数调用),而类主要是具有关联行为(方法)的数据(实例属性)。如果你真的想这么做,你可以用其中一个来重写另外一个。
思考一下。关于类组件:
- 构造函数设置组件渲染需要的所有内容(设置初始状态、绑定实例方法等)。
- 当你需要更新状态时,React 更新类的实例,调用
render
方法并更新必要的 DOM。 - 所有方法都能访问存储在类实例中的最新数据。
而对于 signal 组件:
- 函数体设置组件渲染需要的所有内容(设置数据流,创建 DOM 节点等)。
- 当你更新 signal 时,框架更新存储的值,执行相关的 signal 并更新必要的 DOM。
- 所有函数都能访问存储在函数闭包中的最新数据。
从这个角度来看,更容易看出两者之间的权衡。就像类一样,signals 是可变的,这看起来很荒谬。毕竟 Solid 组件没有分配任何东西——就像 React 一样,它调用了 setCount
!但是需要记住的是,count
不是值本身——它是一个能够返回 signal 当前值的函数。当调用 setCount
时,它会更改这个 signal,并进一步调用 count()
来返回最新的值。
虽然 Solid 的 createSignal
看起来像 React 的 useState
,但 signal 更像是 refs:一个拥有稳定引用的可变对象。不同之处在于,React 是建立在不可变数据上的,refs 是一个对渲染没有影响的逃生口。但是类似于 Solid 的框架将 signal 放在前面和中心。框架不是忽略它们,而是在它们发生变化时做出反应,只更新DOM中使用其值的特定部分。
这样做的最大后果是,用户界面不再是状态的纯函数。这就是为什么 React 拥抱不变性:它保证了状态和用户界面的一致性。当突变被引入时,你还需要一种方法来保持 UI 的同步性。Signal 承诺是一种可靠的方式,它们的成功将取决于它们实现这一承诺的能力。
概要:
- 首先,我们有类组件,它将数据保存在多次渲染中的单个实例中。
- 其次,我们有函数组件和 hooks,它在每次渲染时都拥有独立的实例和状态。
- 现在,我们转向 Signal,它再次将状态保存在单个实例中。
那么,React Hooks 是一个错误吗?它无疑地使组件拆分和重用状态逻辑变得简单。即使在我写这篇文章时,如果你问我是否会放弃 hooks 而回到类组件,我会告诉你 no。
同时,我也知道,Signal 的吸引力在于重新获得我们在类组件中拥有的东西。React 在不可变性上下了很大的赌注,但人们一直在寻找方法来获得他们的蛋糕,并吃下它,现在已经有一段时间了。这就是像 immer 和 MobX 这样的库存在的原因:事实证明,使用可变数据的工效学是非常方便的。
人们似乎喜欢函数组件和 hooks 的美观性,你可以在新的框架中看到它们的影响。Solid 的 createSignal
几乎与 React 的 useState
相同,Preact 的 useSignal
也类似。很难想象这些 API 如果没有 React 的引领,会看起来像现在这样。
Signal 比 hooks 更好吗?我认为这不是正确的问题。一切都有权衡,我们对 Signal 所做的权衡是相当确定的:它们放弃了不可变性和 UI 作为状态的纯函数,以换取更好的更新性能和每个挂载组件的稳定的可变实例。
时间将会告诉我们,Signal 是否会带回 React 创建的问题。但是,现在的框架似乎正在尝试在 hooks 的组合性和类的稳定性之间寻找一个甜点。至少,这是一个值得探索的选项。