【翻译】开发者不喜欢但无法避免的两个 React 设计选择

0 阅读12分钟

开发者不喜欢但无法避免的两个 React 设计选择

原文:Two React Design Choices Developers Don't Like—But Can't Avoid

作者:Ryan Carniato

开发者从不掩饰对某些 React API 的厌恶。它们感觉笨拙、受限,或者简直违反直觉。但现实是,React 中被抱怨最多的两个设计选择绝非随意为之——它们是每个 UI 模型最终都会遇到的更深层次约束的早期信号。

正如你们许多人所知,过去几年我一直在开发 Solid 2.0。这是一段旅程。我已经使用 Signals 超过十年,我以为我理解了整个设计空间。但走得越深,我越发现自己处于意想不到的领域。

在这个过程中,我意识到了一些令人不安的事情。React 在那些人们绝对无法忍受的设计决策上是对的。不是 React 的模型——我不是来为那个辩护的。但 React 确实正确识别了两个不变量,而生态系统中的其他部分,包括 Solid 1.x,都忽略了。

我说的是延迟状态提交

const [state, setState] = useState(1);
// 稍后
setState(2);
state === 1; // 尚未提交

以及 Effect 的依赖数组

useEffect(() => console.log(state), [state]);

这两件事正是 Signals 应该"修复"的。在某种意义上,它们确实做到了。但不是人们想象的那样。今天,我们来看看为什么这不是全部的真相。

生活在异步世界中

我们在网络上所做的一切都建立在异步性之上。整个平台由被网络边界分隔的客户端和服务器定义。流式传输、数据获取、分布式更新、事务性变更、乐观 UI——所有这些都源于这个简单的真理。

异步将我们推出命令式的舒适区。命令式代码关乎写入:"设置这个,然后读回来。"异步关乎读取:"这个值是可用的、过时的,还是仍在传输中?"这是每个 UI 在渲染任何东西之前必须回答的问题:我可以显示这个吗,还是会暴露不一致的东西?

对于大多数框架来说,异步看起来像是在同步声明式世界中闪烁进出的临时状态。它感觉不可预测,因为我们只看到异步与计算相交的时刻。但异步不是混乱——它只是时间。如果我们想对它进行推理,我们需要语言直接表示它。

它从我们如何表示状态开始。如果一个值还不可用,没有它可以安全替换的占位符。返回 nullundefined 或包装器会破坏确定性。继续执行会产生一个永远不对应任何实际时刻的结果。保持一致的唯一方法是停止。

它还需要尊重声明式模型。使响应式系统(包括 React)引人注目的是它们能够将 UI 表示为特定时刻的状态。所有架构清晰度和执行保证都源于此。确定性是目标:相同的输入产生相同的输出,时机不改变形状,UI 始终一致。

当异步泄漏到用户空间时——通过条件分支或替代值形状——我们强制用户手动管理一致性,声明式模型就崩溃了。

// 被迫在异步状态上分支的派生计算
const firstInitial = user.loading ? "" : user.name[0];

异步的 UI 辅助手段——加载指示器、骨架屏、回退——不是问题。那些是呈现关注点。问题是当异步成为流经状态图的值的一部分时。它强制每个消费者分支。UI 可以显示任何它想显示的东西,但图必须只看到真实值。

1. 异步必须与提交隔离

与其他响应式系统不同,React 的状态和渲染的紧密耦合迫使它很早就面对这个问题。当每次状态变化都触发重新渲染时,你不能在同步推导后面隐藏不一致性。Signals 避免了这一点,因为在你读取时一切都是最新的——没有重新渲染,没有编排,没有浪费的工作。

但这些特性只是隐藏了一个基本事实。你不能让异步工作与同步提交交错。如果计算仍在等待异步,它执行的任何写入都是推测性的。你不能向用户显示基于你还没有的状态的 UI,因为如果他们与之交互,他们期望与他们看到的东西交互——而不是框架持有的某个中间状态。

考虑:

let count = 0;
let doubleCount = count * 2;
function increment() {
  count++;
  console.log(`${count} * 2 = ${doubleCount}`);
}

<button onClick={increment}>{count} * 2 = {doubleCount}</div>

我过去多次使用这个例子,但它捕捉到了问题的本质。看:

The Cost of Consistency in UI Frameworks转存失败,建议直接上传图片文件

在纯 JavaScript 中,countdoubleCount 会漂离。Signals 通过在读取时更新 doubleCount 来修复这个问题。但这仍然留下一个问题。这个更新何时到达 DOM?如果你立即刷新(像 Solid 1.x),连续更新可能很昂贵。如果你不这样做,那就承认了某种程度的调度是系统固有的。

React 是唯一不立即更新 count 的系统,人们讨厌它。但动机是合理的。React 希望事件处理程序看到一致的状态,而在组件重新运行之前,它无法更新派生值。

现在想象处理程序是:

function onClick(event) {
  setBooks([]);
  // 派生值
  if (booksLength) {
    books[booksLength - 1]
  }
}

如果 books 更新但 booksLength 没有,你就越界读取了。

Signals 保持状态和派生状态完美同步,这给开发者强烈的安全感。你写一次代码,它就能工作。但这种信心在派生值变成异步的那一刻就变成了负担,因为无法保证它会保持同步。

回到 countdoubleCount,但让 doubleCount 异步。如果你想让 UI 保持一致——在异步 doubleCount 解析之前继续显示 1 * 2 = 2——那么你也必须延迟更新 count。否则你会陷入奇怪的境地。UI 仍在显示 1 * 2 = 2,但控制台已经记录 2 * 2 = 2,因为底层数据已经前进到 count = 2

一旦你看到这种不匹配——UI 等待一致性而数据已经前进——结论就变得不可避免。同步世界让你感到安全,因为一切都一起更新,但那种安全是建立在所有派生值立即可用的假设之上的幻觉。其中一个变成异步的那一刻,那个假设就崩溃了。如果你想让 UI 保持一致,你必须延迟提交。一旦你在 UI 中延迟提交,你也必须在数据中延迟它,否则两者会以违反你所依赖的保证的方式漂离。异步不只是增加延迟;它强制不同的执行模型。

2. Effect 的依赖必须在计算时已知

React 的重新渲染模型迫使它比其他任何人都更早面对另一个真相。派生和副作用遵守不同的规则。

当组件在每次变化时重新运行时,每次重新计算所有东西会很浪费。所以当引入 Hooks 时,依赖数组也随之而来——一种粗糙但有效的记忆化形式。

与 Signals 相比,Signals 的依赖是动态发现的,只有必要的计算重新运行,这看起来很有限。但它有一个重要后果。React 在运行任何渲染或副作用之前就知道树的所有依赖。

当异步进入画面时,这个细节变得至关重要。如果渲染可以随时被中断——暂停、重放或中止——那么还没有运行任何副作用。在所有依赖已知之前触发的副作用冒着用部分或推测性状态运行的风险。React 的架构立即暴露了这一点。渲染不保证完成,所以 effect 不能与渲染绑定。

Signals 凭借其精确的手术式更新,多年来避免了这个问题。变化传播是同步且隔离的,所以派生和副作用看起来在单一、可预测的流中运行。但那种可预测性在异步进入图的那一刻就蒸发了。

因为如果异步只在副作用期间发现,那就太晚了。如果异步是可中断的——比如通过抛出 promise 并在解析时重新执行——执行变得完全不可预测。

考虑:

const a = asyncSignal(fetchA());
const b = asyncSignal(fetchB());
const c = asyncSignal(fetchC());

effect(() => {
  console.log(a());
  console.log(b());
  console.log(c());
});

effect 记录什么?它运行多少次?在纯同步世界中,这些问题几乎不重要——派生是稳定的,effect 每次提交运行一次。但有了异步,它们变得无法回答。每个异步源可能在不同的时间解析。每次解析可能重新触发 effect。如果其中任何一个暂停或重试,整个执行顺序变得不确定。

这还只是初始加载。如果这些异步源可以随时间独立更新,不可预测性会复合。如果你无法推理 effect 何时运行或它看到什么值,你就无法推理副作用。

解决方案简单且不可避免。Effect 必须只在它们依赖的所有异步源都已稳定后才运行。要做到这一点,你必须在执行任何 effect 之前知道所有依赖。你必须将收集依赖与执行 effect 分开。

const a = asyncSignal(fetchA());
const b = asyncSignal(fetchB());
const c = asyncSignal(fetchC());

effect(
  () => [a(), b(), c()] // 捕获依赖
  ([a, b, c]) => { // 执行副作用
    console.log(a);
    console.log(b);
    console.log(c);
  }
);

这对基于 Signal 的解决方案意味着什么

此时,架构强制做出选择。要么直面异步,要么继续假装同步保证在异步世界中成立。异步是真实的。它会在图的某处出现。一旦出现,你在同步情况下依赖的保证就不再成立,除非系统承认它。

编译器能解决这个问题吗?

不能。编译器不能通过重新排列语法来修复语义问题。早期提交不是机械限制——它是正确性限制。异步进入图的那一刻,系统必须知道一个值何时是真实的,何时是推测性的。没有任何静态分析能改变这一点。

编译器能从单个 effect 函数提取依赖吗?从浅层意义上说,可以——React 的编译器正是这样做的。但基于编译器的提取只看到作用域内的东西。它看不到整个图。如果你的源是调用 signal 的函数而不是 signal 本身,编译器无法知道这些函数是纯的还是隐藏了副作用。

这正是为什么 Svelte 5 转向 Runes(Signals)。编译时依赖捕获遇到了硬限制。它无法追踪语法上不可见的源。

let count = 0;

function getDoubleCount() {
  return count * 2;
}

// 永远不会更新,因为 count 在此作用域中不可见
$: doubled = getDoubleCount();

一旦遇到这些边缘情况,你必须问增加的复杂性、隐藏规则和不完整覆盖是否值得。编译器推断可以掩盖问题,但不能解决它。异步是运行时现象。保证必须在运行时强制。

这意味着我们注定要模仿 React 吗?

完全不。这不是复制 React。这是承认 React 最先遇到的同一个基本真理。异步强制提交隔离。异步强制 effect 分离。Vue 在其 watcher(effect)中有这种分离已经多年了。这些不是 React 主义。它们是任何想要在异步存在时保持一致性的系统的不变量。

采用这些不变量不会抹去 Signals 的优势。更新仍然是精确的细粒度。组件从不重新渲染。依赖深度可发现且动态。

只有 effect 需要分离。纯计算不需要。这将 Signals 的表达能力与函数式编程的正确性纪律结合起来。它承认现实而不是与之对抗。它给异步与 Signals 已经给同步计算的同样的确定性和清晰度。

结论

Solid 一直在推动前端架构的边界,不是通过追逐新奇,而是通过揭示使 UI 可预测、一致和快速的底层规则。React 最先遇到这些规则,因为它的架构迫使它这样做。它没有选择这些约束——它遇到了它们。称它们为"设计决策"几乎夸大了所涉及的主动性。它们是发现。

从强势地位选择拥抱这些相同的不变量是完全不同的事情。我们采用这些约束不是因为被困住了——我们采用它们是因为它们是真的。异步强制提交隔离。异步强制 effect 分离。异步强制一致快照。这些不是 React 主义——它们是 UI 的物理学。

拥抱这一点不是模仿。是成熟。是睁着眼睛选择不可避免的道路,构建一个将异步视为一等公民而非边缘情况的系统。这是让 Solid 不仅快,而且根本上正确的下一步。

清晰不会简化世界,但它确实让方向变得明确。


本文翻译自 DEV Community,作者 Ryan Carniato 是 SolidJS 的创建者。

Ryan Carniato

相关文章The Cost of Consistency in UI Frameworks

📌 核心要点总结

  1. 延迟状态提交:React 的 setState 不立即更新状态是正确的设计,因为在异步世界中,立即提交会导致 UI 和数据不一致。
  2. Effect 依赖数组:依赖必须在执行 effect 之前已知,否则在异步场景下会产生不可预测的行为。
  3. Signals 的局限:Signals 在同步世界中表现完美,但一旦异步进入状态图,同步保证就不再成立。
  4. 这不是 React 主义:这些是 UI 的"物理学"——任何想要在异步存在时保持一致性的系统都必须遵守的不变量。