Introducing Signals

Introducing Signals
Photo by Roberto Júnior / Unsplash

SolidJS

Magic React

  • 严格限制useState/useEffect的出现时机
  • 繁琐的手动deps配置
  • Virtual DOM带来的渲染时机不确定性及“虚假的状态驱动”
  • (FC)生命周期执行的不确定性


SolidJS 特性

渲染函数仅执行一次

SolidJS 仅支持 FunctionComponent 写法.

无论内容是否拥有状态管理,也无论该组件是否接受来自父组件的 Props 透传,都仅触发一次渲染函数。

所以其状态更新机制与 React 存在根本的不同:

  • React 状态变化后,通过重新执行 Render 函数体响应状态的变化。
  • Solid 状态变化后,通过重新执行用到该状态代码块响应状态的变化。

与 React 整个渲染函数重新执行相对比,Solid 状态响应粒度非常细,甚至一段 JSX 内调用多个变量,都不会重新执行整段 JSX 逻辑,而是仅更新变量部分:

const App = ({ var1, var2 }) => (
  <>
    var1: {console.log("var1", var1)}
    var2: {console.log("var2", var2)}
  </>
);

上面这段代码在 var1 单独变化时,仅打印 var1,而不会打印 var2,在 React 里是不可能做到的。

核心理念:面相状态驱动而不是面向视图驱动(更细粒度的状态驱动)

更完善的 Hooks 实现

SolidJS 用 createSignal 实现类似 React useState 的能力。

const App = () => {
  const [count, setCount] = createSignal(0);
  return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
};

我们要完全以 SolidJS 心智理解这段代码。一个显著的不同是,将状态代码提到外层也完全能 Work:

const [count, setCount] = createSignal(0);
const App = () => {
  return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
};

无需deps的Effect,没有闭包问题:

const App = () => {
  const [count, setCount] = createSignal(0);
  createEffect(() => {
    console.log(count()); // 在 count 变化时重新执行
  });
};


Preact Signals

状态管理的困境

props waterfall造成性能问题和代码冗余

多context造成的混乱



import { signal } from "@preact/signals";

const count = signal(0);

const App = () => {
  return (
    <Fragment>
      <h1 onClick={() => count.value++;}>
        +
        {console.log("++")}
      </h1>
      <span>{count}</span>
    </Fragment>
  );
};

跟 SolidJS 的 createSignal非常相似。Signals 可以在一个应用从小到大,在越来越复杂的逻辑迭代后,依然能保证性能。

Singals 提供了细粒度状态管理的好处,而无需通过 memorize 或者其他 tricks 方式去优化,Signals 跳过了数据在组件树中的传递,而是直接更新所引用的组件。

这样开发者就能降低使用心智,保证性能最佳。


Signals

Mobx

Redux

Recoil

基于依赖收集的细粒度更新/自动deps推导

✅✅

✅✅

❌(手动connect)

集中式/分散式的状态存储(context)

分散式

集中式

集中式

集中式

响应式状态管理三要素

信号: Signals一个基础响应式数据,aka. Observables(Mobx等),Atoms(Recoil,Jotai等),Refs(Vue等)。不过基本意思都一样,表示一个响应式数据的单元。

const [count, setCount] = createSignal(0);

console.log(count()); //0
setCount(5);
console.log(count()); //5

这里取值用的是 function,有些地方用的是 .value,意味着也可以通过 Object 的 getter, setter 或者 Proxy 去进行数据处理

反应: ReactionsEffect ,也就是副作用,当然也有用 actions 的,下方是一个基本例子:

const [count, setCount] = createSignal(0);
createEffect(() => console.log("The count is", count()));
setCount(5);

反应也就是在数据更新时的监听器,作为响应式数据的基础,也是必不可少的一环

衍生: Derivations这里是指数据的衍生状态,本质上也可以认为是 Signals 的变种,常见命令可能有 computed, memo 等。

const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");

const fullName = createMemo(() => {
  return `${firstName()} ${lastName()}`
});

console.log(fullName);

衍生能缓存计算结果,避免重复的计算,并且也能自动追踪依赖以及同步更新。

响应式特点

响应式数据管理会存储不同节点之间的链接关系,当每次节点更新之后,会重新检查链接关系。如果不在关联,就会解绑链接,取消依赖。

另外响应式还有一点就是同步更新,同步更新避免了状态不一致的问题,也提高了更好的预测性和可测试性。

在响应式数据更新的基础上,有些也会加入比如批量更新,大大避免了一些多余额外的执行消耗

如何实现Signal

状态管理的本质是发布订阅

UI = fn(State)


redux采用 dispatch + reducer 进行发布,使用connect进行订阅及视图更新。是low-level的发布订阅抽象。

Signals/Mobx/Recoil使用了Proxy/defineProperty的方式,将发布流程变得更加简单(setter即发布),并且通过自动的依赖收集将「视图更新」和「其他副作用执行」自动订阅,是high-level的发布订阅模式。


实现步骤

  1. 创建一个 Signal 对象
  2. 使用 Signal对象的value 的时候,会通过 get 来获取到依赖的组件和Effect,生成一个 Map 映射关系
  3. 当对状态进行修改的时候,会从映射关系里面取出来对应的组件 forceUpdate 方法及Effect,进行执行和精准更新UI



https://github.com/pmndrs/its-fine

目标

  • 默认惰性求值(lazy evaluate)- 只有被使用到的才会被监听和更新
  • 最佳更新策略
  • 最佳依赖追踪策略 - 不像 hooks 需要指定依赖
  • 直接访问状态值,不需要 selector 或其他 hooks
  • 同步更新所有副作用

使用LinkedList替代Set记录deps

使用Set记录

  • Auto Deduplication
  • 不错的增/删/查性能


  • 需要重复记录(正向/反向),空间占用偏大
  • 需要重复收集依赖
const s1 = signal(0);
const s2 = signal(0);
const s3 = signal(0);
const c = computed(() => {
  if (s1.value) {
    s2.value;
    s3.value;
  } else {
    s3.value;
    s2.value;
  }
});


使用LinkedList记录

  • 同样不错的增/删/查性能
  • 依赖关系的SSOT
  • 采用标记的方式缓存记录结果


const s1 = signal(0);
const s2 = signal(0);
const s3 = signal(0);
const c = computed(() => {
  if (s1.value) {
    s2.value;
    s3.value;
  } else {
    s2.value;
    s4.value;
  }
});

两种情况,使用set需要重复构建:

s1 -> s2 -> s3

s1 -> s2 -> s4


通过标志位,可以只构建一次,变更标志位即可:

s1 -> s2 -> s3 -> s4

Lazy:使用version缓存结果

每个 signal 和 compute 都有自己的版本号

每次值发生变化时,都会增加版本号。

当 compute 函数运行时,它会在节点中存储其依赖项的最后的版本号。

使用了以下算法来确定compute何时可以重用其缓存值:

  1. 如果全局版本号未新增,则退出并返回缓存值。

每次signal更改时,也会增加一个全局版本号,在所有普通signal之间共享。每个compute都会跟踪他们看到的最后一个全局版本号。

如果自上次计算以来全局版本没有更改,则可以提前跳过重新计算。在这种情况下,无论如何都不会对任何计算值进行任何更改。

  1. 如果compute正在侦听通知,并且自上次运行以来尚未收到通知,则退出并返回缓存值。

当计算信号从其依赖项中获得通知时,它将缓存值标记为过时。如前所述,计算信号并不总能收到通知。但是当他们这样做时,我们可以利用它。

  1. 按顺序重新收集依赖关系,并检查依赖项的版本号。如果没有依赖项改变了它的版本号,在重新收集依赖关系之后,退出并返回缓存的值。

这一步是需要保持依赖关系在其使用顺序中的原因。如果依赖项发生了变化,那么不必重新评估列表后面的依赖项。

  1. 运行compute函数。如果返回的值与缓存的值不同,则增加compute的版本号。缓存并返回新值。

如果新值等于缓存的值,那么版本号不会改变,并且下线的依赖者可以使用它来优化他们自己的缓存。


最后两个步骤通常会递归搜集依赖项(可能耗费性能)。前面的步骤旨在尝试使递归短路。

参考文章

精读《SolidJS》 - 掘金

Signal Boosting – Preact