Introducing Signals
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一个基础响应式数据,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的发布订阅模式。
实现步骤
- 创建一个 Signal 对象
- 使用 Signal对象的value 的时候,会通过 get 来获取到依赖的组件和Effect,生成一个 Map 映射关系
- 当对状态进行修改的时候,会从映射关系里面取出来对应的组件 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何时可以重用其缓存值:
- 如果全局版本号未新增,则退出并返回缓存值。
每次signal更改时,也会增加一个全局版本号,在所有普通signal之间共享。每个compute都会跟踪他们看到的最后一个全局版本号。
如果自上次计算以来全局版本没有更改,则可以提前跳过重新计算。在这种情况下,无论如何都不会对任何计算值进行任何更改。
- 如果compute正在侦听通知,并且自上次运行以来尚未收到通知,则退出并返回缓存值。
当计算信号从其依赖项中获得通知时,它将缓存值标记为过时。如前所述,计算信号并不总能收到通知。但是当他们这样做时,我们可以利用它。
- 按顺序重新收集依赖关系,并检查依赖项的版本号。如果没有依赖项改变了它的版本号,在重新收集依赖关系之后,退出并返回缓存的值。
这一步是需要保持依赖关系在其使用顺序中的原因。如果依赖项发生了变化,那么不必重新评估列表后面的依赖项。
- 运行compute函数。如果返回的值与缓存的值不同,则增加compute的版本号。缓存并返回新值。
如果新值等于缓存的值,那么版本号不会改变,并且下线的依赖者可以使用它来优化他们自己的缓存。
最后两个步骤通常会递归搜集依赖项(可能耗费性能)。前面的步骤旨在尝试使递归短路。