[译]React Hook 是一个错误?

1,395 阅读8分钟

这个系列文章主要是用于练习英文文档的读写能力,期待半年后能脱离工具写出通畅的英文文档。原文地址

在过去几周,Web 开发社区一直在讨论 SignalsSignals 是一种响应式语法模式,可以响应 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 新增了两个依赖:countstarted。只有当这两个依赖变化时,我们才会创建一个新的 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 ,并且在访问 countstarted 时是作为一个函数调用。与类和函数组件一样,看起来相似但却完全不同。

关键在于 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 承诺是一种可靠的方式,它们的成功将取决于它们实现这一承诺的能力。

概要:

  1. 首先,我们有类组件,它将数据保存在多次渲染中的单个实例中。
  2. 其次,我们有函数组件和 hooks,它在每次渲染时都拥有独立的实例和状态。
  3. 现在,我们转向 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 的组合性和类的稳定性之间寻找一个甜点。至少,这是一个值得探索的选项。