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

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

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

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

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

免责声明

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

Signals 是什么鬼物?

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

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

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

js 复制代码
// 计数器
const counter = new Signal.State(0)

我们可以:

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

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

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

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

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

js 复制代码
console.log(isEven.get()) // false
counter.set(2)
console.log(isEven.get()) // true

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

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

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

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

js 复制代码
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 中的文本节点:

js 复制代码
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,如下所示:

js 复制代码
// 装饰器
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 类的可读性,如下所示:

js 复制代码
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 函数,该函数对微任务队列进行批量更新。

js 复制代码
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 提案吗?欢迎在本文下方自由言论,文明共享。

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

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

相关推荐
zqx_739 分钟前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己1 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称1 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色2 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2342 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河2 小时前
CSS总结
前端·css
NiNg_1_2342 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript
读心悦2 小时前
如何在 Axios 中封装事件中心EventEmitter
javascript·http
BigYe程普2 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
神之王楠3 小时前
如何通过js加载css和html
javascript·css·html