首页 >web前端 >js教程 >反应性的可变派生

反应性的可变派生

Linda Hamilton
Linda Hamilton原创
2024-10-24 08:22:02908浏览

所有这些对调度和异步的探索让我意识到我们对反应性仍然不了解。许多研究源于对电路和其他实时系统的建模。函数式编程范式也进行了大量的探索。我觉得这在很大程度上塑造了我们对反应性的现代看法。

当我第一次看到 Svelte 3 和后来的 React 编译器时,人们质疑这些框架的渲染是细粒度的。老实说,他们有很多相同的特征。如果我们用信号和到目前为止我们所看到的派生原语来结束这个故事,你可能会认为它们是等价的,除了这些系统不允许它们的反应性存在于其 UI 组件之外。

但是 Solid 从来不需要编译器来完成这个任务,这是有原因的。为什么直到今天它仍然是更优化的。这不是一个实现细节。这是建筑性的。它与 UI 组件的反应独立性有关,但还不止于此。


可变与不可变

在定义中,改变的能力与不改变的能力。但这不是我们的意思。如果什么都不改变的话,我们的软件就会非常无聊。在编程中,它指的是一个值是否可以改变。如果一个值不能被改变,那么改变变量值的唯一方法就是重新分配它。

从这个角度来看,信号在设计上是不可变的。他们知道某些内容是否发生变化的唯一方法是在分配新值时进行拦截。如果有人独立地改变他们的价值,就不会发生任何反应。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

我们的反应式系统是一个不可变节点的连通图。当我们导出数据时,我们返回下一个值。它甚至可以使用之前的值来计算。

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

但是当我们将信号放入信号中并将效果放入效果中时,会发生一些有趣的事情。

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

现在我们不仅可以更改用户,还可以更改用户名。更重要的是,当仅更改名称时,我们可以跳过不必要的工作。我们不会重新运行外部 Effect。此行为不是声明状态的结果,而是使用状态的结果。

这非常强大,但很难说我们的系统是不可变的。是的,单个原子是,但通过嵌套它们,我们创建了一个针对突变优化的结构。当我们更改任何内容时,只有需要执行的代码的确切部分才会运行。

我们可以在不嵌套的情况下获得相同的结果,但我们可以通过运行额外的代码来比较:

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

这里有一个相当明显的权衡。我们的嵌套版本需要映射传入的数据以生成嵌套信号,然后再次映射它以分解数据在独立效果中的访问方式。我们的 diff 版本可以使用纯数据,但需要针对任何更改重新运行所有代码,并比较所有值以确定更改的内容。

考虑到比较的性能相当好,并且映射特别是深度嵌套的数据很麻烦,人们通常选择后者。 React 基本上就是这样。然而,随着我们的数据和与该数据相关的工作的增加,差异只会变得更加昂贵。

一旦放弃 diff 就不可避免了。我们失去信息。您可以在上面的示例中看到它。当我们在第一个示例中将名称设置为“Janet”时,我们告诉程序更新名称 user().setName(“Janet”)。在第二次更新中,我们设置了一个全新的用户,程序需要弄清楚用户使用的所有地方发生了什么变化。

虽然嵌套比较麻烦,但它永远不会运行不必要的代码。启发我创建 Solid 的是我意识到映射嵌套反应性的最大问题可以通过代理来解决。反应式商店诞生了:

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

好多了。

这样做的原因是我们仍然知道当我们 setUser(user => user.name = "Janet") 时名称会被更新。 name 属性的 setter 被命中。我们实现了这种精细的更新,无需映射我们的数据或比较。

为什么这很重要?想象一下如果您有一个用户列表。考虑一个不可变的变化:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

我们得到一个新的数组,其中包含除具有更新名称的新用户对象之外的所有现有用户对象。此时框架所知道的只是列表已更改。它将需要迭代整个列表以确定是否有任何行需要移动、添加或删除,或者是否有任何行已更改。 如果它发生了变化,它将重新运行映射函数并生成将被替换/与 DOM 中当前内容不同的输出。

考虑可变的改变:

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

我们不会退回任何东西。相反,作为该用户名称的一个信号会更新并运行更新我们显示名称的位置的特定效果。没有列表娱乐。没有列表差异。没有划船休闲。没有 DOM 差异。

通过将可变反应性视为一等公民,我们获得了类似于不可变状态的创作体验,但具有即使是最聪明的编译器也无法实现的功能。但我们今天来这里并不是要确切地讨论反应式商店。这和推导有什么关系?


重温推导

我们所知的派生值是不可变的。我们有一个函数,每当它运行时它都会返回下一个状态。

状态 = fn(状态)

当输入更改时,它们会重新运行,您将获得下一个值。它们在我们的反应图中扮演着几个重要的角色。

首先,它们充当记忆点。如果我们认识到输入没有改变,我们就可以节省昂贵的或异步计算的工作。我们可以多次使用一个值而无需重新计算它。

其次,它们充当汇聚节点。它们是我们图中的“连接”。他们将多个不同的来源联系在一起定义他们的关系。这是事物一起更新的关键,但这也说明了随着源数量有限以及它们之间的依赖关系不断增加,一切最终都会变得纠缠在一起。

这很有道理。对于派生的不可变数据结构,您只有“连接”而不是“分叉”。随着复杂性的增加,你注定要合并。有趣的是,反应性“商店”没有这个属性。各个部分独立更新。那么我们如何将这种思维应用到推导中呢?

跟随形状

Mutable Derivations in Reactivity

Andre Staltz 几年前发表了一篇令人惊叹的文章,他将所有类型的反应/可迭代原语连接到一个单一的连续体中。推/拉全部统一在一个模型下。

我长期以来一直受到安德烈在本文中应用的系统思维的启发。长期以来,我一直在努力解决本系列中涵盖的主题。有时,了解设计空间的存在就足以开启正确的探索。有时,您首先需要了解解决方案的形状。


例如,我很久以前就意识到,如果我们想避免某些状态更新的同步,我们需要一种派生可写状态的方法。它在我的脑海里萦绕了好几年,但最终我提出了一个可写的推导。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"


这个想法是它总是可以从源重置,但是可以在顶部应用短暂的更新,直到下一次源更改为止。为什么要使用它而不是效果器?

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"


因为,正如本系列第一部分中深入介绍的那样,这里的 Signal 永远不会知道它依赖于 props.field。它破坏了图的一致性,因为我们无法追踪它的依赖关系。直觉上我知道将读取放入同一个原语中可以解锁该功能。事实上,createWritable 如今已完全可以在用户空间中实现。

<script> // Detect dark theme var iframe = document.getElementById('tweet-1252839841630314497-983'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1252839841630314497&theme=dark" } </script>
const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

这只是一个高阶信号。信号的信号,或者安德烈称之为“Getter-getter”和“Getter-setter”的组合。当传入的 fn 执行外推导 (createMemo) 时,会跟踪它并创建一个 Signal。每当这些依赖项发生变化时,就会创建一个新信号。然而,在被替换之前,该 Signal 处于活动状态,任何监听返回的 getter 函数的东西都会订阅派生和保持依赖链的 Signal。

我们降落在这里是因为我们遵循了解决方案的形状。随着时间的推移,遵循这种形状,我现在相信,正如上一篇文章末尾所示,这个可变的派生基元不是可写派生,而是派生信号。

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

但是我们仍在研究不可变原语。是的,这是一个可写的派生,但值的更改和通知仍然会大量发生。


差异化的问题

Mutable Derivations in Reactivity

直观上我们可以看到有一个差距。我可以找到我想要解决的问题的示例,但无法找到一个单一的原语来处理该空间。我意识到部分问题在于形状。

一方面,我们可以将派生值放入 Stores 中:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

但是如何动态改变形状,即在不写入 Store 的情况下生成不同的 getter?

另一方面,我们可以从 Store 导出动态形状,但它们的输出不会是 Store。

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

如果所有派生值都是通过传入返回下一个值的包装函数生成的,那么我们如何隔离更改?最好的情况是,我们可以将新结果与之前的结果进行比较,并向外应用细粒度的更新。但这是假设我们一直想要差异。

我阅读了 Signia 团队的一篇文章,内容是关于增量计算以通用方式实现 Solid 的 For 组件之类的内容。然而,除了逻辑并不简单之外,我注意到:

  • 它是一个单一的不可变信号。嵌套更改无法独立触发。

  • 链上的每个节点都需要参与。每个节点都需要应用其源差异来实现更新的值,并生成其差异以向下传递(最终节点除外)。

处理不可变数据时。参考文献将会丢失。差异有助于取回这些信息,但随后您需要支付整个链条的成本。在某些情况下,例如来自服务器的新鲜数据,没有稳定的引用。需要一些东西来“键入”模型,而示例中使用的 Immer 中不存在这种东西。 React有这个能力。

就在那时我想到这个库是为 React 构建的。假设已经存在,将会有更多的差异。一旦你屈服于差异,差异就会产生更多的差异。这是无法回避的事实。他们创建了一个系统,通过降低整个系统的增量成本来避免繁重的工作。

Mutable Derivations in Reactivity

我觉得我太聪明了。 “坏”方法虽然不可持续,但无可否认效果更好。


(细粒度)反应性的大统一理论

Mutable Derivations in Reactivity

对事物进行一成不变的建模并没有什么问题。但还是有差距。

所以让我们跟随形状:

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

显而易见的是,派生函数与信号设置器函数具有相同的形状。在这两种情况下,您都会传入先前的值并返回新值。

为什么我们不在商店中这样做?

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

我们甚至可以引入派生来源:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

这里有一个对称性。不可变的变化总是构造下一个状态,可变的变化将当前状态转变为下一个状态。差异也不行。如果不可变派生 (Memo) 的优先级发生变化,则整个引用将被替换,并且所有副作用都会运行。如果可变派生(投影)上的优先级发生变化,则只有专门侦听优先级的内容才会更新。


探索预测

不可变的变化在其操作上是一致的,因为无论发生什么变化,它只需要构建下一个状态。 Mutable 根据变化可能有不同的操作。不可变更改始终具有未修改的先前状态可供使用,而可变更改则不然。这会影响期望。

我们可以在上一节的示例中看到这一点,需要使用 reconcile。当一个全新的对象通过投影传入时,您并不满足于替换所有内容。您需要逐步应用更改。根据更新内容,它可能需要以不同的方式进行变异。您可以每次应用所有更改并利用商店的内部平等检查:

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

但这很快就变得令人望而却步,因为它只能浅层发挥作用。协调(差异)始终是一种选择。但通常我们想做的只是应用我们需要的东西。这会导致代码更复杂,但效率更高。

修改 Solid 的 Trello 克隆的摘录,我们可以使用投影来单独应用每个乐观更新,或使面板与服务器的最新更新保持一致。

const [signal, setSignal] = createSignal({ a: 1 });

createEffect(() => console.log(signal().a)); // logs "1"

// does not trigger the effect
signal().a = 2; 

setSignal({ a: 3 }); // the effect logs "3"

这很强大,因为它不仅保留 UI 中的引用,因此只发生粒度更新,而且它增量应用突变(乐观更新),而无需克隆和比较。因此,组件不仅不需要重新运行,而且当您进行每次更改时,它不需要重复重建电路板的整个状态来再次意识到几乎没有什么变化。最后,当它确实需要比较时,当服务器最终返回我们的新数据时,它会根据更新的投影进行比较。保留引用,无需重新渲染任何内容。

虽然我相信这种方法将在未来为实时和本地优先系统带来巨大的胜利,但我们今天可能已经在没有意识到的情况下使用了投影。考虑包含索引信号的反应式映射函数:

const [log, setLog] = createSignal("start");

const allLogs = createMemo(prev => prev + log()); // derived value
createEffect(() => console.log(allLogs())); // logs "start"

setLog("-end"); // effect logs "start-end"

索引将作为反应性属性投影到不包含索引的行列表上。现在这个原语是如此的基线,我可能不会用 createProjection 来实现它,但重要的是要理解它绝对是一个。

另一个例子是 Solid 晦涩难懂的 createSelector API。它允许您以高效的方式将选择状态投影到行列表上,以便更改所选内容不会更新每一行。感谢形式化的投影基元,我们不再需要特殊的基元:

function User(user) {
  // make "name" a Signal
  const [name, setName] = createSignal(user.name);
  return { ...user, name, setName };
}

const [user, setUser] = createSignal(
  new User({ id: 1, name: "John" })
);

createEffect(() => {
  const u = user();
  console.log("User", u.id);
  createEffect(() => {
     console.log("Name", u.name());
  })
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser(new User({ id: 2, name: "Jack" })); 

// effect logs "Name Janet"
user().setName("Janet");

这将创建一个地图,您可以在其中通过 id 查找,但仅存在选定的行。由于它是订阅生命周期的代理,我们可以跟踪不存在的属性,并在更新时仍然通知它们。更改 SelectionId 最多会更新 2 行:已选择的行和正在选择的新行。我们将 O(n) 运算变为 O(2)。

当我更多地使用这个原语时,我意识到它不仅完成了直接可变派生,而且可以用来动态地传递反应性。

const [user, setUser] = createSignal({ id: 1, name: "John" });

let prev;
createEffect(() => {
  const u = user();
  // diff values
  if (u.id !== prev?.id) console.log("User", u.id);
  if (u.name !== prev?.name) console.log("Name", u.name);

  // set previous
  prev = u;
}); // logs "User 1", "Name John"

// effect logs "User 2", "Name Jack"
setUser({ id: 2, name: "Jack" }); 

// effect logs "Name Janet"
setUser({ id: 2, name: "Janet" });

此投影仅公开用户的 id 和名称,但保持 privateValue 不可访问。它做了一些有趣的事情,因为它对名称应用了 getter。因此,当我们替换整个用户时,投影会重新运行,而仅更新用户名就可以运行效果,而无需重新运行投影。

这些用例只是少数,我承认它们需要更多的时间来理解。但我觉得预测是信号故事中缺失的一环。


结论

Mutable Derivations in Reactivity

通过过去几年对反应性推导的探索,我学到了很多东西。我对反应性的整体看法已经改变。可变性不再被视为一种必要的罪恶,而是在我身上成长为反应性的一个独特支柱。一些粗粒度的方法或编译器无法模拟的东西。

这是一个强有力的陈述,表明了不可变反应性和可变反应性之间的根本区别。信号和存储不是同一件事。备忘录和预测也不是。虽然我们也许能够统一他们的 API,但也许我们不应该这样做。

我通过遵循 Solid 的 API 形状实现了这些实现,但其他解决方案对其信号具有不同的 API。所以我想知道他们是否会得出相同的结论。公平地说,实施预测存在挑战,而且故事还没有结束。但我想我们的思想探索就暂时结束了。我还有很多工作要做。

感谢您加入我的旅程。

以上是反应性的可变派生的详细内容。更多信息请关注PHP中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn