UI 框架一致性的成本

2025-05-28

UI 框架一致性的成本

有时,有些问题并没有放之四海而皆准的解决方案。需要做出一些权衡。有些观点无法得到维护。有时,甚至难以判断哪种方案更优。

我们最终在日志中得到的内容是:

React 0 0 0
Vue 1 2 0
Svelte 1 0 0
Solid 1 2 2

我第一次发布这篇文章是在一年半前,但从那以后它就一直萦绕在我心头。我不断地重温它,无论是在我的梦里,还是在我的日常工作中。在开发Marko 6 的时候,我们无法做出决定,于是决定如果有人试图读取该周期中已经更新的值,就抛出一个错误,直到我们下定决心。

那么,这些 JavaScript 框架怎么会有不同的行为呢?嗯,每个框架都有各自的理由。有人回复我的推文,说他们的框架做了唯一合理的事。他们说的都对,也可能都错了。


批量一致性

让我们从React开始。当你更新状态时,它会推迟提交这些更改,直到下一个渲染周期。这样做的好处是 React 始终保持一致,count并且doubleCountDOM 始终保持同步。

框架的一致性至关重要。它能建立信任。当你与视图交互时,你确信所见即所得。如果用户看到某些内容,但应用状态不同,则可能导致难以察觉的错误,因为用户驱动的操作虽然看似有意为之,却可能导致意外结果。有时甚至会造成严重后果(财务或其他方面)。

这延伸到了开发领域。如果开发人员能够确保他们处理的所有事情都同步,他们就能相信他们的代码会按预期运行。

然而,这意味着往往是痛苦的:

// updating state in React
count === 0; // true

setCount(count + 1);

console.log(count, doubleCount, el.textContent); // 0, 0, 0
Enter fullscreen mode Exit fullscreen mode

更新状态不会立即生效。如果您正在进行一系列更改,传递的值将保留旧值。积极的一面是,这会促使您一起执行所有状态更改,从而提高性能。但您需要注意,如果您多次设置相同的状态,则最后一次设置的值将生效。

React 的批量更新一致性模型始终是安全的选择。虽然没有人对此感到兴奋,但它确实是一个很好的默认设置。


反应一致性

即使“正确”,批次一致性也常常会导致混乱和错误,因为期望值会更新。因此,Solid所做的就是反其道而行之,下一行代码就能更新所有内容。

// updating state in Solid
count() === 0; // true

setCount(count() + 1);

console.log(count(), doubleCount(), el.textContent); // 1, 2, 2
Enter fullscreen mode Exit fullscreen mode

这是完全一致的,并且符合预期,但正如您所想象的,必须有一个权衡。

如果您进行多次更改,则会触发多次重新渲染并执行大量工作。即使在像 Solid 这样的框架中,这是一种合理的默认设置,它不会重新渲染组件,并且只更新更改的内容,但有时这仍然会导致不必要的工作。然而,独立的更改不会带来性能开销。但像 React 一样,它可能会迫使您一次性应用所有更改。

Solid 的一致性模型还会让您意识到批处理机制,因为它对于优化很重要。


反应式批处理

$mol框架的作者提出了一个相当有力的论据来捍卫他的框架和Vue的地位。在 Vue 中,事物是响应式更新的,但调度方式与 React 类似。然而,它们会立即应用直接状态更改。

// updating state in Vue
count.value === 0; // true

count.value++;

console.log(count.value, doubleCount.value, el.textContent) // 1, 2, 0
Enter fullscreen mode Exit fullscreen mode

这些库的诀窍是,它们将值标记为过时并进行调度,但除非你从派生值中读取,否则不会立即运行更新。只有这样,它们才会主动执行更新,而不是等到通常被调度到的地方。这样做的好处是,在保持性能的同时,可以推迟最繁重的工作,例如渲染副作用。

这是我们讨论过的第一个不一致的方法。纯计算具有部分一致性,但它不会立即反映在 DOM 中。这样做的好处是,在大多数情况下看起来是一致的。但是,如果下游副作用会更新状态,那么这些更改即使在读取之后也会被应用。

Vue 的批量反应性可能是最有效的,可以使得这一切变得“不重要”,但它可能是最不可预测的。


自然执行

与其他产品相比,Svelte的执行效果可能看起来不那么令人满意。它不一致,而且也没有试图表现出一致性。但它对 Svelte 来说也算是完美的。

// updating state in Svelte
let count = 0;

count++;

console.log(count, doubleCount, el.textContent); // 1, 0, 0
Enter fullscreen mode Exit fullscreen mode

在 Svelte 中,一切看起来都像普通的 JavaScript。为什么你会期望doubleCount在设置变量时,派生对象或 DOM 在下一行更新?这毫无道理。

就像 Vue 一样,人们不会考虑太多。然而,他们更有可能更快地遇到派生数据不一致的问题。最初,这不需要任何解释就能上手,这使得这种模型对于那些没有先入为主观念的人来说感觉最自然。但这真的是我们想要的吗?

Svelte 甚至没有尝试保持一致性。这或许是好事,也可能是坏事。


选择最佳模型

这篇文章的重点在于,我本应说“视情况而定”,并给各位留下一些深刻的思考。但这并不是我想要表达的意思。

所有这些背后都存在着可变性与不变性的争论。就像图片中抓取数组中某个索引处的项目并将其放在数组末尾一样。

const array = ["a", "c", "b"];
const index = 1;

// immutable
const newArray = [
  ...array.slice(0, index),
  ...array.slice(index + 1),
  array[index]
];

// or, mutable
const [item] = array.splice(index, 1);
array.push(item);
Enter fullscreen mode Exit fullscreen mode

无论哪种情况,人们都希望最终得到["a", "b", "c"]

如你所见,不可变数组的更改可以通过对 newArray 的一次赋值来实现。然而,在我们的可变数组示例中,我们通过两次操作来更改实际数组。

如果状态在我们像 React 那样的操作之间没有更新(或许可以想象一下 Vue 的代理),我们最终会得到["a", "c", "b", "c"]。虽然我们会从拼接中获取“c”作为我们的项目。第二个数组操作(“push”)会有效地覆盖第一个操作,因此它不会被从列表中删除。

此外,实际情况比这些例子要复杂一些。我特意选择了一个事件处理程序,因为它位于典型的更新/渲染流程之外,但在内部你会发现不同的行为。

使用 React 的函数设置器提供最新值:

// count === 0

setCount(count => count + 1);
setCount(count => count + 1); // results in 2 eventually

console.log(count); // still 0
Enter fullscreen mode Exit fullscreen mode

Vue 可以通过 Effects 模仿 Svelte 的行为:

const count = ref(0);
const doubleCount = ref(0);

// deferred until after
watchEffect(() => doubleCount.value = count.value * 2);

console.log(count.value, doubleCount.value, el.textContent) // 1, 0, 0
Enter fullscreen mode Exit fullscreen mode

Solid 的更新方式与 Vue 的默认方式类似,但会将任何内部变更从响应式系统传播出去。这对于防止无限循环至关重要。然而,它的显式批处理和 Transitions API 却与 React 一样,保留了以往的风格。


所以... ?

说实话,这太糟糕了。我感觉有必要留意一下批处理行为。有了这种意识,我被迫提供一个一致的默认值,因为这感觉是最明智的做法。

对你们很多人来说,这可能并不奇怪。我是 SolidJS 的作者,所以我为什么不这么说呢?Solid 的即时更新与其渲染模型配合得很好,并且可以通过批处理选项进行补充。

但真正让我大开眼界的是,过去几年我的观点发生了巨大的变化。当我第一次在设计 Marko 6 时遇到这个问题时,我完全被 Vue 的批处理响应性所吸引。作为一种编译型语法,显式地选择加入(opt-in)感觉很不合适,而突变(mutation)不更新又很尴尬。然而,我绝对会把 Svelte 的方法列为我最不喜欢的。

但现在我不太确定了。在 Solid 上工作时,它支持显式语法,我拥有所有可用的工具。如果批处理是可选的,如果我要为了“直观行为”(以及支持修改)而放弃一致性,我至少需要可预测性。从这个角度来看,Svelte 过于简单的模型就很有道理了。

因此,在 Solid 1.5 中,我们正在评估一种新的“自然”批处理模型,以补充我们急切一致的默认设置(以及我们过去对 Transition 的批处理)。我不知道这是否能给我们带来一些启发。我不会责怪任何人得出不同的结论。这些棘手的问题正是我如此热爱这项工作的原因。

怀疑论者可能会说,Solid 应该包含所有更新模型,他们这么说也有点道理。我不知道。打不过他们,就加入他们?


如果您对此有意见并希望参与讨论,请加入目前正在讨论此主题的SolidJS discord 。

文章来源:https://dev.to/this-is-learning/the-cost-of-consistency-in-ui-frameworks-4agi
PREV
JavaScript 中信号的演变
NEXT
成为 Flutter 开发者的路线图。