为了账号安全,请及时绑定邮箱和手机立即绑定

可变派生在响应式编程中的应用探讨

所有这些关于调度和异步的研究让我意识到我们对反应性还有很多不了解的地方。许多研究起源于对电路和其他实时系统的建模。还有在函数式编程范式方面也有大量的探索。我觉得这对我们对反应性的现代理解产生了很大影响。

当我第一次看到 Svelte 3,后来是 React 编译器时,人们质疑这些框架在渲染上的细腻程度。说实话,它们有很多相似之处。如果我们仅以到目前为止看到的信号和推导出的原语来结束这个故事,你可以说它们在某些方面是等价的,除了这些系统不允许响应性超出 UI 组件之外。

但这就是为什么Solid从来不需要编译器来实现这一点的原因。而且直到今天,它仍然更加优化,而且更胜一筹。这不是实现细节的问题,而是架构层面的。它不仅与UI组件的反应独立性有关,还涉及更多内容。

……

可变的 VS: 不可变的

这里指的是可变与不可变的区别。但如果仅限于此,软件就会变得非常单调。在编程中,这指的是一个值是否可变。如果一个值不可变,那么要更改变量的值,唯一的办法就是重新赋值。

从这个角度来看,我们可以说,信号被设计为不可变的。它们唯一知道变化的方式是拦截新值的赋值。如果有人独立修改了它们的值,那么则不会触发任何反应。

    const [signal, setSignal] = 创建信号({ a: 1 });

    创建效果(() => 控制台日志(signal().a)); // 输出 "1"

    // 不会触发效果
    signal().a = 2; 

    设置信号({ a: 3 }); // 效果输出 "3"

点击全屏,点击退出全屏

我们的响应系统是由不可变节点构成的连接图。当我们处理数据时,我们就会返回下一个值。这个值可能是根据前一个值计算得出的。

    const [日志, 设置日志] = createSignal("开始");

    const 所有日志 = createMemo(prev => prev + 前一个日志()); // 衍生值
    createEffect(() => console.log(所有日志() + "")); // 输出到控制台 "开始"

    设置日志("结束"); // 输出到控制台 "开始-结束"

进入全屏 退出全屏

当我们把信号放入信号中,效应放入效应中时,有趣的事情就会发生。

    function User(user) {
      // 将 "name" 变为 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("用户ID:", u.id);
      console.log("姓名:", u.name());
    })

    // 输出 "用户 2", "姓名: Jack"
    setUser(new User({ id: 2, name: "Jack" })); 

    // 输出 "姓名: Janet"
    user().setName("Janet");

全屏模式 退出全屏

现在我们不仅可以更改用户信息,还可以更改用户的名称。更重要的是,如果只是名字变了,我们可以直接跳过那些不重要的步骤。我们不会重新运行外部的Effect。这不是取决于状态声明的位置,而是使用位置的决定。

这非常强大,但要说我们的系统是不可变的确实有点勉强。虽然单个原子是不可变的,但通过嵌套这些原子,我们创建了一个更适合变异的结构。当我们修改任何内容时,只有真正需要执行的部分代码才会运行。

我们也可以不用嵌套就能得到同样的结果,但我们需要通过运行一些额外的代码来进行差异比较。

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

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

      // 设置为前一个值
      prev = u;
    }); // 输出 "User 1", "Name John"

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

    // 输出 "Name Janet"
    setUser({ id: 2, name: "Janet" });

点击全屏按钮,按X退出

这里有一个相当明确的权衡。我们的嵌套实现需要处理传入的数据来生成嵌套的信号对象,然后基本上需要第二次处理以拆分数据访问方式,以便独立处理每个效果。而我们的差异实现可以直接使用原始数据,但在每次变化时,都需要重新运行所有的代码,并比较所有值以确定哪些值发生了变化。

由于比较操作的性能相当不错,而遍历深度嵌套的数据则比较麻烦,人们通常更愿意选择后者。React 基本上就是这个意思。然而,随着数据及其相关操作变得更多,比较操作的成本将越来越高。

一旦接受了无法避免的合并,信息的丢失是不可避免的。这在上面的例子中有体现。在第一个例子中,我们把名字设为 "Janet",程序被要求更新名字 user().setName("Janet")。在第二个例子中,我们替换了一个全新的用户,程序需要找出用户信息在每个位置上的变化。

虽然嵌套更复杂,但它绝不会运行不必要的代码。意识到 Proxies 可以解决嵌套反应性中的主要问题,因此我创建了 Solid。反应式的 Stores 也随之诞生:

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

    createEffect(() => {
      console.log("User", user.id);
      createEffect(() => {
         console.log("Name", user.name);
      })
    }); // 输出 "用户 1", "名字 John"

    // effect 输出 "用户 2", "名字 Jack"
    setUser({ id: 2, name: "Jack" }); 

    // effect 输出 "名字 Janet"
    setUser(user => user.name = "Janet");

进入全屏 退出全屏

好多了。

这个有效的原因是,我们知道当执行 setUser(user => user.name = "Janet") 时,名字会被更新。name 属性的 setter 被调用了。我们实现了这种精细的更新,而无需对数据进行映射或比较差异。

为什么这很重要呢?想象一下,如果你有一个用户名单。考虑一次不可变的修改。

    function changeName(userId, name) {
      // 保持用户列表不变
      setUsers(users.map(user => {
        if (user.id !== userId) return user;
        // 更新用户的名字
        return { ...user, name };
      }));
    }

全屏模式, 退出全屏

我们得到了一个全新的数组,其中包含了所有现有的用户对象,除了带有更新名称的新对象除外。此时框架只知道列表改变了。它需要遍历整个列表来确定是否有任何行需要移动、添加或移除,或者任何行是否发生了变化。如果有变化,它将再运行映射函数并生成输出,该输出将用于替换或与当前DOM中的内容进行差异比较。

考虑一下可变的改变:

function changeName(userId, name) {
  // 可更改
  // 设置用户信息,将指定用户的名称更改为新的名称
  setUsers(users => {
    users.find(user => user.id === userId).name = name;
  });
}

进入或退出全屏模式

我们不会返回任何内容。相反,代表该用户的唯一标识符会被更新,并运行特定效果来更新显示该名字的位置。无需重建列表。无需处理列表差异。无需重建行项目。无需处理DOM差异。

通过将可变的反应性视为一等公民,我们获得了类似于不可变状态的编写体验,但具备了编译器无法达到的能力。但我们今天讨论的重点不是反应式 Stores,这与衍生有什么关系?

(此处略去内容)

再次审视推导

我们知道,衍生的值是不可更改的。我们有一个函数,每次运行时都会返回下一个状态。

将状态传递给fn函数,由fn函数处理状态。

当输入变化时,它们会重新运行,输出下一个值。在我们的反应图里,它们有几种重要的作用。

首先,它们可以作为一个记忆化点。如果我们发现输入没有变化,我们可以避免重复进行昂贵或异步的计算。这样我们就不用再做那些耗时的计算了。我们可以在无需重新计算的情况下多次使用同一个值。

其次,它们充当汇聚节点。它们就像我们图中的“节点”。它们将多个不同的来源连接在一起,定义它们之间关系。这正是它们同步更新的关键,但同样合乎情理的是,随着有限的来源数量和它们之间依赖关系不断增加,最终一切都将变得错综复杂。

这确实很有道理。使用派生的不可变数据结构时,随着复杂性的增加,你会更多地进行合并。有意思的是,反应式的“存储”(Stores)并不具备这种特性。各个部分可以独立地进行更新。那么我们如何将这种思想应用到派生上呢?

……

顺着形状走

图片描述

安德烈·斯塔尔茨(Andre Staltz)几年前发表了一篇精彩的文章,他将所有类型的反应式和可迭代的基本类型统一起来形成了一个连续体。推拉模型被统一在一个单一的框架下。

我一直很欣赏安德烈在这篇文章中展示的系统思维。我一直在为这个系列中讨论的主题感到头疼。有时,仅仅认识到设计空间的存在就足以开启正确的探索。有时,先理解解决方案的大致轮廓就已经足够。

例如,我早就意识到这一点,如果我们想要避免某些状态更新同步,我们需要找到一种派生可写状态的方法。这个问题在我脑海中萦绕了一段时间后,最终我提出了一个可写的派生方案。【我在这个讨论中提出了这个想法:【点击这里查看】(https://github.com/solidjs/solid-workgroup/discussions/2)】

const [field, setField] = createWritable(() => props.field); // 定义一个可写的状态 field,并设置其初始值为 props.field

进入全屏,退出全屏

这个想法是说它总是可以从源头重置,但可以在其上应用短期更新,直到源发生变化之前。为什么不用这个代替效果?

定义了一个信号变量 `field` 和设置该信号的函数 `setField`。
创建了一个依赖于 `props.field` 的副作用。

进入全屏 退出全屏

因为,如本系列第一部分所述,这里的 Signal 一直不知道它依赖于 props.field。它破坏了图的连贯性,因为我们无法追踪其依赖关系。直觉上我知道将读取放在同一原语内部可以解锁这种功能。事实上,createWritable 实际上完全可以在用户空间中实现。

// 简化以不包含之前的值
function createWritable(fn) {
  const computed = createMemo(() => createSignal(fn()))
  return [() => computed()[0](), (v) => computed()[1](v)]
}

点击全屏/退出全屏

这只是一种更高阶的信号。一个信号的信号,或者如 Andre 所说的“Getter-getter”和“Getter-setter”组合。当传入的 fn 函数被执行时,createMemo 会追踪该函数并创建一个信号。每当这些依赖项发生变化时,就会创建一个新的信号。但是,在被替换之前,这个信号一直是活跃的,任何监听返回 getter 函数的订阅都会同时监听衍生和这个信号,保持依赖关系链。

我们在这里着陆是因为我们遵循了解决问题的方法。随着时间的推移,我们发现,这个可变的衍生原始值并不是原来的可写派生,而是如之前所述,是一个衍生信号。

    // 注释: 普通的信号
    const [count, setCount] = createSignal(5);

    // 注释: 派生的信号
    const [field, setField] = createSignal(() => props.field);

进入全屏 退出全屏

但我们仍然在处理那些不可改变的基本数据。没错,这是一个可以写的衍生版本,但值的变更和通知还是批量进行的。

……

文件对比的难题

图片描述 这是一张图片

直觉上,我们能感觉到有一些差距。我找到了一些想解决却找不到合适基本元素的例子。后来我发现,问题的一部分在于形状。

我们可以从一个角度来看,将计算出的值放入存储中:

const [store, setStore] = 创建存储({
  值,
  获取 派生值() {
    this.值 * 2;
  }
});

// 创建一个存储对象,包含一个值和一个获取派生值的函数,该函数返回值的两倍。

切换到全屏模式,退出全屏模式

但如何动态地生成不同的获取器,而不必向Store写入数据?

另一方面,我们还可以从Stores动态生成形状,但生成的形状不是Store类型。

定义了一个名为value的常量,它使用createMemo函数来记忆化getNextValue函数的返回值,该函数接收store.a和store.b作为参数。// 其中createMemo是一个用于创建记忆化函数的工具,帮助优化计算性能。

全屏模式,退出全屏

如果所有的值都是通过传递一个返回下一个值的包装函数来生成的,我们如何才能隔离改变呢?最好的情况是我们可以将新结果与之前的结果进行对比,并进行局部更新。但这假设我们总是需要进行对比。

我读了一篇 Signia 团队关于增量计算(incremental computeds)的文章,实现了一种通用的类似 Solid 的 For 组件的方式。不过,除了发现逻辑并没有变得更简单之外,我还注意到了:

  • 它是一个单一且不可变的信号。嵌套的变化不能独立引发任何反应。

  • 链中的每个节点都需要参与。每个节点都需要应用其源变更来实现更新值,并且除了末端节点外,其他每个节点还需要生成并传递差异。

在处理不可变数据时,这些引用将会丢失。Diffs 可以帮助找回丢失的信息,但你需要为此付出整个链的代价。而在某些情况下,比如从服务器获取的新数据,没有稳定的引用可供使用。需要有一种方式来“键化”这些模型,而这在示例中使用的 Immer 中并没有提供这种功能。React 则具备这种功能。

当我意识到这个库是为React构建的时候。其中的假设已经固定下来,即会有更多的比较。一旦你接受了比较,更多的比较就会随之而来。这就是不可改变的事实。他们创建了一个系统,通过在整个系统中分摊增量成本,来避免繁重的工作。

这是一张图片,大家来看看。这张图片看起来挺有意思的。图片

我觉得我有点太想显得自己聪明了。虽然这种“坏”做法不可持续,但确实更有效。

此处为空

细粒反应性的宏大统一理论

图片描述(点击图片查看)

将东西设计成不可变的并没有什么不对的。但存在一些挑战或不足。

那我们就按照形状来:

    // 不可变原子
    const [todo, setTodo] = createSignal({ id: 1, done: false });

    // 更新完成状态
    setTodo(todo => ({ ...todo, done: true }));

    // 不可变派生:next = fn(prev)
    const todoWithPriority = createMemo(todo => (
      { ...todo, priority: priority() }
    ), 初始待办事项);

点击全屏 点击退出全屏

很明显,派生的函数和信号设置函数形式相同。在这两种情况里,你都需要传入旧值并返回新值。

为什么不试试用商店来做这个呢?

    // 可变代理
    const [todo, setTodo] = createStore({ id: 1, done: false });

    // 改变当前状态
    setTodo(todo => { todo.done = true; });

    // 可变派生 - 改变当前状态
    const 带优先级的todo = createProjection(todo => {
      todo.priority = getPriority();
    }, initialTodo);

进入全屏 退出全屏

我们还可以引入这些衍生的来源,甚至。

    // 不可变
    const [todo, setTodo] = createSignal(() => props.todo);

    // 可变
    const [todo, setTodo] = 创建存储(todo => {
      // Solid 的 diff 函数用于将 `todo` 更新为与 `props.todo` 一致
      协调(props.todo)(todo); 
    });

点击全屏按钮进入全屏 点击退出按钮退出全屏

这里有一种对称性。不可变的变化总是创建下一个状态,而可变的变化则将当前状态转变为下一个状态。两者都不会执行差异计算。如果Memopriority发生变化,那么整个引用将被替换,所有副作用也会随之运行。如果Projectionpriority发生变化,只有对priority感兴趣的监听者才会更新。


一起来探索投影的世界吧

不可变在操作上始终一致,只需构建下一个状态,而不考虑具体的变化。可变会根据变化采取不同的操作。不可变始终基于未修改的前一个状态,而可变则不然。这会影响我们的预期。

我们可以在上一节中提到这一点,在示例中需要使用 reconcile。当使用 Projections 传入整个新对象时,你不满足于完全替换它。你需要逐步应用这些更改。根据不同的更新,可能需要根据不同的更新以不同的方式进行修改。你可以每次都应用所有更改,并利用存储内部的相等性检查:

const todoWithPriority = createProjection(todo => { 
  todo.priority = priority();
  todo.title = title();
  todo.done = done();
});

进入全屏 退出全屏

但是这种方法很快就会变得不切实际,因为它只能进行浅层操作。对比合并(求差)始终是一个可行的选项。但很多时候,我们只想应用真正需要的部分。这会使代码变得复杂,但可以更加高效。

我们可以通过修改Solid的Trello克隆版的部分,使用Projection来单独应用每个临时更新,或者将整个看板与服务器的最新更新来同步。

    let timestamp;
    const board = createAsync(() => fetchBoard());
    const board = createProjection(笔记列表 => {
      const 上一个时间戳 = timestamp || 0;
      timestamp = Date.now();

      // 如果第一次运行或有新的服务器数据
      if (updatedSinceLastRun(board)) {
        reconcile(applyMutations([...board()], mutations()))(笔记列表);
        return;
      }
      // 直接用新的变更修改前一个状态
      applyMutations(
        笔记列表,
        mutations()
          .filter(mut => mut.timestamp > 上一个时间戳)
      );
    });

进入全屏 退出全屏

这很强大,因为它不仅保留了 UI 中的引用链接,使得更新只发生在需要的粒度级别,而且还逐步应用乐观更新,而不需要复制和对比。因此,不仅组件不需要重新运行,而且每次更改时,都不需要重建整个棋盘的状态,确认变化微乎其微。最后,当服务器最终返回新的数据时,它会将更新与更新后的状态进行比较。保留了引用链接,无需重新渲染任何部分。

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

    <For each={rows()}>
      (row, index) => <div>{index() + 1} {row.text}</div>
    </For>

以下代码遍历每一行,并显示索引和文本内容:

全屏,退出全屏

索引会被投影到你列表中不包含索引的那些行上,作为一个响应式的属性值。现在这个基本操作非常基础,简单到我可能不会用createProjection这样的方法来实现它,但重要的是要理解它在分类上也是一种基本操作。

另一个例子是 Solid 的复杂的 createSelector API。它允许你高效地将选择状态映射到行列表上,这样更改所选内容时不会刷新每一行。因此,我们不再需要专门的原语了。

    let previous;
    // 定义一个变量 `previous` 用于保存之前的选择项ID。
    const selected = createProjection(s => {
      const sId = selectedId();
      s[sId] = true;
      if (previous != null) delete s[previous];
      // 更新 `previous` 为当前的选择项ID。
      previous = sId;
    });

    <For each={rows()}>
      (row) => <tr class={selected[row.id] ? "selected" : ""}></tr>
      // 遍历行并为选中的行添加 `selected` 类。
    </For>

全屏模式 退出全屏

这创建了一个映射,其中通过id查找,但只有选定的行存在数据。由于它是订阅生命周期的代理,我们能够追踪不存在的属性,并在它们更新时发出通知。更改selectionId最多只会更新两行:当前选中的行和新的选中行。我们将原本的O(n)操作优化到了O(2)

随着我越来越多地使用这个原始工具,我意识到它不仅能实现直接可变派生,还能动态地处理反应性。

const [store, setStore] = createStore({ user: { id: 1, name: "John", privateValue: "不要分享" }})
const protectedUser = createProjection(state => {
  const user = store.user;
  state.id = user.id;
  Object.defineProperty(state, "name", {
    get() { return user.name },
    configurable: true
  })
});

createEffect(() => console.log(protectedUser.name));

// 这会重新运行投影和副作用
setStore(s => s.user = { id: 2, name: "Jack", privateValue: "Oh No"});

// 这只会更新数据
setStore(s => s.user.name = "Janet");

全屏 退出全屏

此投影仅暴露用户的 idname,但 privateValue 无法访问。有趣的是,它为名称应用了一个 getter。因此,当替换整个用户时,投影会重新运行,但仅更新用户名称时可以触发效果,而不会触发投影重新运行。

这仅仅是其中几个用例,我认为这些确实需要一点时间来掌握。但我觉得,投影才是信号故事中缺失的那一块拼图。


结论部分

图片描述 该图片来自上述链接。

在这几年对 Reactivity 的可变性问题的探索中,我学到了很多。我对 Reactivity 的视角整个发生了变化。可变性不再单纯被视为一种必要的恶,而是逐渐被我接受,变成了 Reactivity 中一个独特的支柱。这是粗粒度的方法或编译器所无法复制的。

这是一个强有力的声明,说明不可变和可变反应性之间存在根本差异。信号(Signal)和存储(Store)是不同的东西。同样,记忆(Memos)和投影(Projections)也不同。虽然我们可以统一它们的API,但也许我们不应该这么做。

我通过遵循Solid的API形状得出了这些认识,但其他解决方案的信号API则有所不同,所以不知道他们能否得出同样的结论。实现投影确实存在挑战,这个话题还没有结束。但我觉得这次思考可以告一段落了。我还有很多工作要做。

谢谢你的加入,和我一起踏上这段旅程。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消