I heard some points of criticism to how React deals with reactivity and it's focus on "purity". It's interesting because there are really two approaches evolving. There's a mutable + change tracking approach and there's an immutability + referential equality testing approach. It's difficult to mix and match them when you build new features on top. So that's why React has been pushing a bit harder on immutability lately to be able to build on top of it. Both have various tradeoffs but others are doing good research in other areas, so we've decided to focus on this direction and see where it leads us.
我听到了一些关于 React 如何处理响应式以及它过于关注“purity”的批评。这很有趣,其实关于如何实现响应式,业界一致存在两种方案。一种是「可变性 + 更改追踪方法」和一种是「不可变性 + 引用相等测试」方法。当您在上面构建新功能时,很难混合使用这两种方案。也就是说,两者之间只能二选一。所以这就是为什么 React 最近一直在努力推动「不可变性」,以便能够在它之上构建。两者都有不同的权衡取舍,也就是说各有自己的代价。其他人在其他领域(指的是「可变性 + 更改追踪方法」)做了很好的研究,所以我们决定专注于「不可变性 + 引用相等测试」这个方向,看看它会把我们带向何方。
I did want to address a few points that I didn't see get enough consideration around the tradeoffs. So here's a small brain dump.
我确实想解决一些我认为在之前在权衡取舍方面没有给予足够考虑的问题。所以这里有一个小小的脑暴笔记(brain dump)。
__"Compiled output results in smaller apps"__ - E.g. Svelte apps start smaller but the compiler output is 3-4x larger per component than the equivalent VDOM approach. This is mostly due to the code that is usually shared in the VDOM "VM" needs to be inlined into each component. The trajectory is steeper. So apps with many components aren't actually smaller. It's a tradeoff is scalability.
__“通过加入编译层,原始应用程序会能生成更小的应用程序”__ - 例如Svelte 应用程序开始时较小,但每个组件的编译器输出体积比等效的 VDOM 方法大 3-4 倍。这主要是由于通常在 VDOM“VM”(指的 VDOM 运行时)中共享的代码需要内联到每个组件中。随着组件数量的增多,这条曲线会更加陡峭。因此,具有许多组件的应用程序实际上并不小。这是一个在应用规模的可扩展性上的一个权衡取舍。
We hit a similar trajectory with the work we did on Prepack. That also had other problems but this is ultimately the reason we abandoned that particular approach. A lot of the win new frameworks see comes from a leaner ecosystem outside the core library. Similar to how the Preact community's app often are smaller just by virtue of excluding other community libraries and avoiding solving problems small apps don't hit... yet. React is working on how to scale it so we can have rich components but still initialize them lazily and responsibily scale up.
我们在 Prepack 上所做的工作也遇到了类似的曲线问题。当前,还有其他问题,但这是我们最终放弃 Prepack 的主要原因。许多成功的新框架都来自核心库之外的更精简的生态系统。类似于 Preact 社区的应用程序通常更小,只是由于排除了其他社区类库并避免解决小型应用程序没有遇到的问题......但是。 React 正在研究如何扩展它,以便使得我们可以拥有丰富的组件的同时,仍然做到弹性扩展(懒初始化和动态伸缩)。
__"DOM is stateful/imperative, so we should embrace it"__ - Arguably this is an argument against all modern frameworks becaues even when they have a state mutation model their view composition goes a reactive declarative API (e.g. templates), so that mismatch is still there somewhere.
__“DOM 是有状态的/命令的,所以我们应该拥抱它”__ - 可以说这是跟当前所有现代前端框架唱反调的论点。因为即使它们(指所有的现代前端框架?)具有状态突变模型,它们的视图组合也会采用响应式声明性 API(例如模板),因此不一致性还是存在。
Interestingly though, the DOM is imperative today but Jetpack Compose and SwiftUI are seeing wins by creating systems that take advantage of not exposing a fully imperative API in the core platform so maybe we'll see the same from browsers eventually.
有趣的是,今天 DOM 是命令式的,但 Jetpack Compose 和 SwiftUI 通过创建声明式 API 系统取得了胜利(利用未在核心平台中公开完全命令式 API ),所以也许我们最终会从浏览器中看到相同的结果。
__"React leaks implemenation details through useMemo"__ - React relies on referential equality to track changes. `(x1, x2) => if (x1 === x2) update(x2)`. Other libraries tend to model this through a dirty bit. `(x, xChanged) => if (xChanged) update(x)`. This is the fundamental difference. Either way this implementation leaks either through referential equality or through change tracking APIs.
__“React 通过 useMemo 泄漏实现细节”__ - React 依赖于引用相等来追踪更改。 `(x1, x2) => if (x1 !== x2) update(x2)`(原文是:`(x1, x2) => if (x1 === x2) update(x2)`,应该是作者笔误)。其他库倾向于通过脏标志位来对此进行建模。 `(x, xChanged) => if (xChanged) update(x)`。这是根本的区别。无论哪种方式,此实现都会通过引用相等或追踪 API 泄漏。
Referential equality you can mostly express pretty easily in your own code. E.g. if you do something like:
您可以在自己的代码中很容易地表达引用相等。例如。如果您执行以下操作:
```
setUsers([...users.filter(user => user.name !== 'Sebastian'), {name: 'Sebastian'}]);
```
You can just pass these referentially equivalent objects around through arbitrary code and components that can compare them. E.g. if I later on pass any of these users to a component like ``, then that component can still bail out due to referential identity.
您可以把这些引用相等的对象传递给任意的采用引用相等检测的组件或者代码块。例如。如果我稍后将这些用户中的任何一个传递给像``这样的组件,那么由于引用相等,则该组件可以不用 re-render(*can still bail out* 翻译为 「不用 re-render」)。
If you do this in a system that use syntax sugar like Svelte:
如果您在使用 Svelte 等语法糖的系统中执行此操作:
```
users = [...users.filter(user => user.name !== 'Sebastian'), {name: 'Sebastian'}];
```
You've lost that most users were equivalent when they get passed around.
你会在传递他们的使用丢失掉他们的引用相等性。
So you have to ensure that all your data structures that you use and those over your helps are tracked, e.g. with runtime proxies, to preserve the change tracking and do stuff like `users.value.push({name: 'Sebastian'})`.
因此,您必须确保追踪您使用的所有数据结构以及那些不在轻易最终范围的数据结构,例如使用运行时 proxy,以确保对 “users.value.push({name: 'Sebastian'})”之类的操作进行更改追踪。
Change tracking on the reader side is so involved that libraries that employ it usually hide it away behind compilers (a reason they often end up having to rely heavily on templates).
在值读取方面(reader side)的更改追踪非常复杂,以至于使用它的库通常将其隐藏在编译器后面(这是他们最终不得不严重依赖模板的原因)。
However, the recent heavy use of useMemo/useCallback in React has a similar effect. I have some ideas to auto-add useMemo/useCallback using a Babel compiler plugin so that you don't have to think about it same as how templating languages do it. But you always have the ability to do the memoization yourself too rather than being hidden in compiler magic. The nice thing about this approach is that it also use with external helpers that produce immutable values like the `.filter(...)` function above.
然而,最近在 React 中大量使用 useMemo/useCallback 也有类似的效果。我有一些想法可以使用 Babel 编译器插件来自动添加 useMemo/useCallback,这样您就不必像模板语言那样考虑它。但是我们还是保留让你做 memoization 的入口,而不是隐藏在编译器的魔法中。这种方案的好处是它还可以与产生不可变值的外部helper 一起使用,例如上面的 .filter(...) 函数。
Note that this approach might have a small negative file size approach so the trick is finding a good balance between update performance and avoid scaling up at 3-4x file size but I'm optimistic.
请注意,这种方法对文件大小可能具有较小的负向影响,因此诀窍是在更新性能和避免以 3-4 倍文件大小放大之间找到良好的平衡,但我对此很乐观。
__"Stale closures in Hooks are confusing"__ - This is interesting because our approach really stems from making code consistent. This sometimes means making it equally confusing in all cases rather than easy in common ones. The theory is that if you have one way of doing it, you prepare your mental model for dealing with the hard problems. Even if it takes some getting used to up front.
__“Hooks 中的陈旧闭包令人困惑”__ - 这很有趣,因为我们的方案实际上源于使「代码保持一致」(making code consistent)的理念。这有时意味着在所有情况下都同样令人困惑,而不是在常见情况下。理论上讲,如果你有一种方法,你就可以通过准备好改方法的心智模型来解决一些难题。不过在此之前,你需要一些时间来适应这个心智模型。
One way this shows up is batching. Imagine this in a mutable reactive system:
出现这种情况的一种场景是「批处理」。想象一下在一个可变的响应式系统中:
```js
{ if (this.count < 10) this.count++; }} />
```
Is that equivalent to this?
```js
this.count++ : null} />
```
There are many subtle patterns similar to this.
有许多与此类似的微妙模式。
Because of batching, if you invoke this within the same batch `props.onBar(); props.onBar();` they're not equivalent since the render won't rerender. Most declarative systems like CSS layout or updating templates are batched because it's good for performance when multiple mutations overlap. It leads to this kind of quirks in all these libraries. The more you increase batching (e.g. concurrent mode batches more aggressively) the more cases can fall into this category. React addresses this by making the count stale in both scenarios instead of just one, forcing you to address it consistently.
由于批处理,如果您在同一批处理中调用它 `props.onBar(); props.onBar();` 它们不等价,因为渲染不会重新渲染。大多数声明性系统(如 CSS 布局或更新模板)都是批处理的,因为对多个突变进行合并对性能有好处。它导致了所有这些库中的这种怪癖。您增加批处理的次数越多(例如,更激进的并发模式批处理),就越多的案例可以属于这一类。 React 解决了这个问题,方法是在两种情况下都使计数过时(making the count stale),而不是只在一种情况下,迫使您始终如一地解决它。
Another case is referring to a value inside an asynchronous operation like:
另一种情况是指异步操作中的值,例如:
```js
useEffect(async () => {
let data = await fetch(...);
if (count < 10) { // stale count
doStuff(data);
}
}, []);
```
This is an example of a stale closure in React hooks because count could've updated between the mount and when the fetch returns.
这是 React hook 中陈旧闭包的一个示例,因为 count 可能在挂载和 fetch 返回之间更新。
The way to solve this in a mutable system is to make the count read from a mutable closure or object. E.g.
在可变性系统中解决此问题的方法是从可变闭包或对象中读取计数。例如。
```js
onMount(async () => {
let data = await fetch(...);
if (count.currentValue < 10) { // reactive/mutable count
doStuff(data);
}
});
```
However the issue with that is that now you still have to think about everything that might be accidentally closed over. E.g. if you hoist the value out to a named variable for readability:
然而,问题在于,现在您仍然必须考虑可能出现的意外 case 。例如。为了提高可读性,你新声明了一个变量,把值提升上去,并按值来传递给这个新变量(这个时候,新变量就失去了响应性)
```js
onMount(async () => {
let isEligible = count.currentValue < 10; // reactive/mutable count
let data = await fetch(...);
if (isEligible) {
doStuff(data);
}
});
```
So you can easily get into the same situation even with a mutable source value. React just makes you always deal with it so that you don't get too far down the road before you have to refactor you code to deal with these cases anyway. I'm really glad how well the React community has dealt with this since the release of hooks because it really sets us up to predictably deal with more complex scenario and for doing more things in the future.
因此,即使源值可变,您也可以轻易地面临同样的问题。 React 只是更有先见之明,让你总是处理它,以便于你不用在走得很远的时候遇到这种问题需要去重构你的代码。我真的很高兴 React 社区自 hooks 发布以来处理得这么好,因为它确实让我们能够以可预测的方式处理更复杂的场景并且在未来可以做更多的事情。