likes
comments
collection
share

JS 最新提案 Signals(信号),stage 0 草案正式发布!

作者站长头像
站长
· 阅读数 4

《前端暴走团》,喜欢请抱走~大家好,我是团长林语冰。

今天,我十分鸡冻地和大家共享,JS Signals(信号)提案的 stage 0 草案、以及符合规范的 polyfill(功能补丁)现已正式公开发布。

大家可能已经在前端生态的某些库和框架中,或多或少听说过 Signals 了,包括但不限于:Vue、Angular、MobX、Preact、Qwik、RxJS、Solid、Svelte 等等。

举个栗子,地表最强响应式框架 —— Vue 的官方文档就专门阐释了响应式和 Signals 的“超友谊关系”:

JS 最新提案 Signals(信号),stage 0 草案正式发布!

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 A TC39 Proposal for Signals

Signals 是什么鬼物?

Signals 是一种数据类型,它对状态单元以及从其他状态或计算值导出的计算建模,从而实现单向数据流。

状态和计算值形成一个非循环图,其中每个节点都拥有其他节点,这些节点是从其值导出状态的 sink(状态槽),或者是将状态贡献给其值的 source(状态源)。节点也可以被跟踪为“clean”(净值)或“dirty”(脏值)。

举个栗子,假设我们有一个想要追踪的计数器,我们可以将其表示为状态:

// 计数器
const counter = new Signal.State(0)

我们可以:

  • 使用 get() 读取当前值
  • 使用 set() 更改当前值
console.log(counter.get()) // 0

counter.set(1)
console.log(counter.get()) // 1

现在,假设我们想要另一个信号,用来表示计数器是否为偶数。

// 是否为偶数
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0)

计算值是不可写的,但我们总是可以读取其最新值:

console.log(isEven.get()) // false
counter.set(2)
console.log(isEven.get()) // true

如上所示,isEvencounter 的状态槽,而 counterisEven 的状态源。

我们可以添加另一个计算值来实现计数器的奇偶校验:

// 奇偶判定
const parity = new Signal.Computed(() => (isEven.get() ? 'even' : 'odd'))

现在,isEvenparity 的状态源,且 parityisEven 的状态槽。我们可以改变最初的 counter,状态会单向流向 parity

counter.set(3)
console.log(parity.get()) // odd,奇数

目前为止我们所做的一切似乎都可以通过普通的函数组合来实现。但为何我们还需要 Signals 呢?

回想一下,上文我们提及的 Signals 可以是净值或脏值。

当我们更改 counter 的值时,它就会变成脏值。因为我们有图形关系,所以我们可以将 counter 的所有状态槽标记为脏值,以及对状态槽的所有状态槽如法炮制。

粉丝请注意,Signals 算法不是推送模型。更改 counter 不会立即推送更新 isEven 的值,然后通过图表推送更新 parity

Signals 也不是纯粹的拉取模型。读取 parity 的值并不总是计算 parityisEven 的值。相反,当 counter 更改时,它只将脏值标志中的更改推送到图表中。任何潜在的重新计算都会被延迟,直到明确提取特定 Signals 的值为止。

我们称之为“先推后拉”模型。脏值标志会被迫切更新(推送),而计算值会被延迟求值(拉动)。

将非循环图数据结构与“先推后拉”算法相结合会产生许多优点。包括但不限于:

  • Signal.Computed 会自动记忆。如果状态源不变,那就无需重新计算。
  • 即使状态源变更,也不会重新计算不需要的值。如果计算值是脏值,但没有任何内容读取其值,那就不会发生重新计算。
  • 错误或“过度更新”可以避免。举个栗子,如果我们将 counter2 改为 4,那么它是脏值。但是当我们拉取 parity 的值时,它的计算不需要重新运行,因为一旦拉取了 isEven,就会为 4 返回与 2 相同的结果。
  • 当 Signals 变脏时,我们会收到通知,并选择如何响应。

事实证明,这些特性在高效更新 UI 时至关重要。为了了解实现过程,我们可以引入一个虚构的 effect 函数,当其中一个状态源变脏时,该函数会调用某些操作。举个栗子,我们可以使用 parity 更新 DOM 中的文本节点:

effect(() => (node.textContent = parity.get()))

counter.set(2)
counter.set(4)

Signals 提案的细节

Signals 提案的具体内容包括但不限于:

  • 背景、动机、设计目标和常见问题解答
  • 用于创建状态信号和计算信号的 API
  • 用于侦听信号的 API
  • 符合规范的polyfill,涵盖所有建议的API。
  • ......

Signals 提案不包括 effect API,因为此类 API 通常与高度依赖框架/库的渲染和批处理策略深度集成。然而,Signals 提案确实试图定义一组原语和工具函数,库作者可以使用它们来自定义专属 effect

Signals 的用户分为两大类:

  • 应用开发者
  • 库/框架/基建开发者

供应用开发者使用的 API 直接从 Signal 的命名空间暴露。其中包括 Signal.State()Signal.Computed()。API 很少在应用程序代码中使用,且更可能涉及微妙的处理,通过 Signal.subtle 命名空间暴露。其中包括 Signal.subtle.WatcherSignal.subtle.untrack() API。

应用开发者如何使用 Signals?

如今许多人气爆棚的组件和渲染框架已经在使用 Signals 了。一旦 Signals 提案成功通过,应用开发者的模式不会改变。

然而,它们的框架会:

  • 更具互操作性,因为 Signals 有官方标准
  • 体积更小,因为 Signals 是内置的,不需要 JS 框架提供
  • 性能更快,因为 Signals 会作为 JS 运行时的原生功能

使用 Signals 的一种最佳选择是和装饰器强强联手。我们可以创建一个 @signal 装饰器,将访问器转换为 Signals,如下所示:

// 装饰器
export function signal(target) {
  const { get } = target

  return {
    get() {
      return get.call(this).get()
    },

    set(value) {
      get.call(this).set(value)
    },

    init(value) {
      return new Signal.State(value)
    }
  }
}

然后我们可以使用该装饰器来减少样板文件,并提高 Counter 类的可读性,如下所示:

export class Counter {
  @signal accessor #value = 0

  get value() {
    return this.#value
  }

  increment() {
    this.#value++
  }

  decrement() {
    if (this.#value > 0) {
      this.#value--
    }
  }
}

库/基建开发者如何集成信号?

我们希望视图和组件库的维护者、以及创建状态管理库的维护者能够尝试集成 Signals 提案。

第一个集成步骤是更新库的信号,这样可以在内部使用 Signal.State()Signal.Computed(),而不是当前特定于库的实现。

常见的下一步是更新任何 effect 或等效基建,因为 Signals 提案没有提供 effect 的实现。我们的研究表明,effect 与渲染和批处理的细节密切相关,目前无法标准化。

相反,Signal.subtle 命名空间提供了框架可以用来构建专属 effect 的原语。

举个栗子,实现一个简单的 effect 函数,该函数对微任务队列进行批量更新。

let needsEnqueue = true

const w = new Signal.subtle.Watcher(() => {
  if (needsEnqueue) {
    needsEnqueue = false
    queueMicrotask(processPending)
  }
})

function processPending() {
  needsEnqueue = true

  for (const s of w.getPending()) {
    s.get()
  }

  w.watch()
}

export function effect(callback) {
  let cleanup

  const computed = new Signal.Computed(() => {
    typeof cleanup === 'function' && cleanup()
    cleanup = callback()
  })

  w.watch(computed)
  computed.get()

  return () => {
    w.unwatch(computed)
    typeof cleanup === 'function' && cleanup()
  }
}

effect 函数首先根据用户提供的回调创建一个 Signal.Computed()。然后它可以使用 Signal.subtle.Watcher 来侦听计算值的状态源。

为了使侦听器能够“看到”状态源,我们需要至少执行一次计算,这可以通过调用 get() 来实现。

我们的 effect 实现支持回调的基本机制,提供清理函数以及通过返回的函数停止侦听的方法。

我们创建的 Signal.subtle.Watcher,构造函数采用一个回调,每当其监视的任何信号变脏时,都会同步调用该回调。

由于 Watcher 可以监视任意数量的 Signals,因此我们安排对微任务队列上的所有脏值进行处理。一些基本的保护逻辑确保调度能且仅能发生一次,直到处理待处理的 Signals 为止。

processPending() 函数中,我们循环侦听器跟踪的所有待处理 Signals,并通过调用 get() 重新求值,然后要求侦听器恢复监视所有跟踪的 Signals。

大多数框架将以与其渲染或组件系统集成的方式处理调度,并且它们可能会进行其他实现更改以支持其系统的工作模型。

高潮总结

接下来的几周内,我们会在 TC39 上提交 Signals 提案,探索 stage 1。stage 1 意味着 Signals 提案正在考虑中。

目前,Signals 提案仍处于并将长期处于处于 stage 0。在 TC39 技术委员会上发言后,我们会根据委员会的反馈,以及我们从 GitHub 参与者的意见继续完善 Signals 提案。

本期话题是 —— 你期待 Signals 提案吗?欢迎在本文下方自由言论,文明共享。

坚持阅读,自律打卡,每天一次,进步一点。

《前端暴走团》,喜欢请抱走!我是团长林语冰。谢谢大家的点赞,掰掰~

JS 最新提案 Signals(信号),stage 0 草案正式发布!

转载自:https://juejin.cn/post/7357382699629559845
评论
请登录