前言
helux 是一个集atom
、signal
、依赖追踪
为一体,支持细粒度响应式更新的状态引擎,兼容所有类 react 库,包括 react18。
本文将重点介绍react生态里各方signal
的具体实现,并探讨背后隐藏的坑导致的可用性问题,同时也会给出helux
中signal
实现的优势,并教会你使用signal
写出0 hook的react
状态组件。
signal 热潮
signal在2023 年掀起了一股热潮,其实诸多框架很早就开始为其布局,Vue、Solid.js、Preact、Svelte 都在不同时的引入了signal,某种程度来说 vue 甚至可以算是 signal 的鼻祖,Angular作者发文Signal是前端框架的未来后,在2023年末的Angular 17
里引入了signal
实现,可见大家对signal
是多么的盼望。
我们观察如下Angular
的signal
代码示例
import { signal, computed, effect } from '@angular/core';
export class SignalExample {
count = signal(1);
// Get (Same in Template || Typescript)
getCount = () => this.count();
// Setters
reset = () => this.count.set(1);
increment = () => this.count.update((c) => c + 1);
// Computed
doubled = computed(() => this.count() * 2);
// Effects
logCount = effect(() => console.log(this.doubled()));
}
发现保持了signal
里通用概念,即状态
、修改
、派生
、副作用
都存在,大家能很快上手,只是细节实现不一样,例如angular
这里的修改基于set
、update
,获取通过get
函数。
接下来我们将只针对react
生态里的signal
揭示可能存在的使用问题。
不知angular
、svelte
、solid
是否存在如下问题,欢迎阅读本文的读者提出对应观点或给出示例。
signal 本质
为何前端对signal
念念不忘,究其原因就是它可让视图的数据依赖可锁定到最小范围,以便实现点对点更新,即dom粒度的更新
既然要感知到视图的数据依赖,那么就存在依赖收集前置行为,而不是依赖比较后置行为。
上图里的浅灰色部分的compare deps
即react.memo的比较函数
react.memo(Comp, ( prevProps, currProps )=> boolean)
白色部分的compare deps
即现在signal
里读取行为产生的值比较(读取行为让框架感知到了当前视图的依赖,下一刻可以用来做比较)
// 类似 solid 的设计
const [ val, setVal ] = createSignal(someVal);
// 视图调用 val()
{val()}
所以我们可将signal
看做是一种支持依赖收集的包装数据,数据的读依赖可以和各种副作用绑定起来(例如render函数,派生函数,观察函数等),写行为则是找到这些副作用函数再去执行一遍。
哎喂?这不就是mobx
或者vue
一直都在做的事么,换了个名词signal
再来一遍.....
react 里的 signal 三方实现
既然signal
这么美好,为什么react
不原生支持,这也是很多人质问的点,react
核心团队的 Andrew Clark对此做了答复
我们可能会在 React 中添加一个类似 Signals 的基元,但我并不认为这是一个编写 UI 代码的好方法。它对性能来说是很好的。但我更喜欢 React 的模式,在这种模式下,你每次都会假装重新创建所有的内容。我们的计划是使用一个编译器来实现与之相当的性能
这里面还存在一个问题就是react
内部接受的不可变数据,以便做高效的diff
比较,而signal
强调的数据数据可变,以便精确知道变化的细节,但其实这不是阻碍在react
里实现signal
的核心因素,因为我们只需要在可变mutable
和不可变immutable
之间搭一个桥梁,基于mutable
库改写数据,生成快照数据喂给react
就解决此问题了。
但react
保持克制只做自己核心的部分,是社区生态强大的重要原因,所以很多三方库开始为react带来signal
体验,这里我们重点列举mobx-react
和@preact/signals-react
两个库,来看看他们存在的问题。
mobx-react
注意,如果你认可上面总结的signal
本质的话,mobx-react
也算是一种signal
的实现
是不是很意外,原来很早很早react生态里就有了 signal 实现,只是当时大家还没吹热 signal 这个概念。
mobx-react
和现有signal
库的差异点在于,在函数组件大行其道的当下,任然需要显式地突出观察节点,即包装普通组件为可观察组件,再配合钩子函数才能感知变化
import { makeAutoObservable } from "mobx"
import { observer, useObservable } from "mobx-react-lite"
// 定义 store
class Store {
count = 0
constructor() {
makeAutoObservable(this);
}
add(){
this.count += 1
},
}
// 实例化 store
const mySotre = new Store();
const App = observer(() => {
// 使用 store
const store = useObservable(mySotre);
return
});
示例参考自mobx官网react集成
这种使用体验落后于其他不需要显式突出观察,api完全走函数式风格的signal
实现库。
preact/signals-react
preact 自身实现了 signal
后,也发布了 @preact/signals-react
包来为react
带来signal
能力,但是当我认真体验后,唯一的感受是:这可能只适合写hello world
.....
我们按照官网demo写出来一个简单的示例是这样的:
import { signal, computed } from "@preact/signals-react";
// 定义 signal
const count = signal(0);
// 派生 signal
const comput = computed(() => count.value * 5);
// 修改 signal
const increment = () => {
count.value++;
};
export default function App() {
return (
Count -> {count}
Commp -> {comput}
);
}
跑起来的确没问题,但是我们的实际开发项目不可能是count
这种简单结构数据,以及加和减这种逻辑。
坑1:没有可变修改
我稍微把数据调复杂一点,代码变为如下:
const user = signal({a:{b:1}, name: 'test'});
const comput = computed(() => user.v.a.b + 1);
const increment = () => {
user.value.a.b++;
};
这样的代码就不能正常驱动视图重新渲染,所以我必须改为
user.value.a.b++;
+ user.value = { ...user.value };
坑2: 渲染粒度是信号本身
signals-react
的渲染粒度是信号本身,即信号自身改变后,任何引用信号的组件都会被重写渲染,对于复杂对象也是一样的结果,就导致可能只改了对象的部分数据节点A,而只使用了部分数据节点B的组件也会被重渲染。
代码如下,每个组件可以包含了一个
,如果变化了表示当前组件被触发重渲染了{Date.now()}
示例代码见App3.tsx文件
import { signal } from "@preact/signals-react";
const person = signal({
firstName: "John",
lastName: "Doe",
});
function FirstName() {
return (
firstName: {person.value.firstName}
{Date.now()}
);
}
function LastName() {
return (
lastName: {person.value.lastName}
{Date.now()}
);
}
const change = (e) => {
person.value.firstName = e.target.value;
person.value = { ...person.value };
};
export default function App() {
return (
);
}
输入input,渲染结果如下,我们只修改了firstName
,结果FirstName
和LastName
组件均触发重渲染
坑3: 糟糕的细粒度更新开发体验
可是官网明明宣称支持细粒度更新的啊,为什么坑2的示例触发了全部使用方渲染呢,经过仔细阅读官网,发现上述例子改为真正的细粒度渲染还需要进一步拆分signal
,所以代码需要优化为signal
套signal
的模式
// signal 定义
const person = signal({
++ firstName: "John",
-- firstName: signal("John"),
++ lastName: "Doe",
-- lastName: signal("Doe"),
});
// 修改方式
-- const change = (e) => {
-- person.value.firstName = e.target.value;
-- person.value = { ...person.value };
--};
++ const change = (e) => {
++ person.value.firstName.value = e.target.value;
++};
// 组件使用
-- {person.value.firstName}
++ {person.value.firstName.value}
写完这样一段代码后,我的内心感受是这样的
如果我们真的在项目铺开来这样使用signal
的话,将陷入signal
拆分的海洋里,以及漫天.value
的暴雨中....
helux 带你进入真正的signal世界
helux
引入signal
设计时,明确了要完成以下几个目标,才能让signal
在react
真正可用、好用。
纯粹的函数式
相比mobx-react
使用class
创建 store,helux
彻底拥抱函数式,有更强的可组合性
import { atom, share, derive, watch, watchEffect } from 'helux';
// primitive atom
const [ numAtom, setNum ] = atom(1);
const result = derive(()=> numAtom.val + 1); // { val: 2 }
watch(()=> console.log(`numAtom changed`), ()=>[numAtom]);
// or
watchEffect(()=> console.log(`numAtom changed ${numAtom.val}`))
setNum(100); // 驱动 derive watch watchEffect
// object atom
const [ state, setState ] = share({a:1, b:2});
const result = derive(()=> state.a + 1);
watch(()=> console.log(`state.a changed`), ()=>[state.a]);
// or
watchEffect(()=> console.log(`state.a ${state.a}`))
setState(draft=>{draft.a=100}); // 驱动 derive watch watchEffect
足够简单的api
组件里使用 observer
配合 useObservable
才能驱动函数组件,只需要useAtom
即可
function Demo1() {
cosnt [ num ] = useAtom(numAtom); // 自动拆箱 { val: T }
// or useAtom(state);
return (
{num}
);
}
function Demo2() {
const [ state ] = useAtom(state);
return (
{state.a}
);
}
依赖是细粒度的,对象逐步展开依赖就会逐步缩小
// 依赖仅仅是 state.a,只有 state.a 变化才会引起重渲染
{state.a}
如果不需要驱动这个组件重新渲染,只需要使用$
符号包括即可,此时组件都不需要useAtom
,实现 0 hook 编码。
import { $ } from 'helux';
function Demo(){
// 仅div内部这一小块区域会重渲染
{$(state.a)}
}
没有任何心智负担的使用信号对象
我们可以把对象构建得足够复杂,也可以像使用原生json对象使用信号对象,不需要signal
叠signal
的诡异操作。
atom(()=>({
a:1,
b: { .... }
c : { e: { list: [ ... ] } }
}));
强悍的依赖追踪性能
基于limu的强悍性能,可以无所畏惧的跟踪到任意深度节点的依赖,helux
默认对数组只跟踪到下标位置,下标里的对象属性继续展开后的依赖就不再收集,避免超大数组产生过多的跟踪,但如果你愿意花费额外的内存空间收集所有子节点依赖,调整收集策略即可完成。
例如下面这个例子里,修改数组任意一个子节点的某个子属性,只会触发目标节点更新,甚至我们完全克隆了一份新的set回去也没有触发更新,因为helux
内部在比较新值和上一刻的快照是否相等,相等则跳过更新。
示例见list依赖,本链接里还包含了其他简单示例
import React from "react";
import { sharex } from "helux";
import { MarkUpdate, Entry } from "./comps";
const stateFn = () => ({
list: [
{ name: "one1", age: 1 },
{ name: "one2", age: 2 },
{ name: "one3", age: 3 },
],
});
// stopArrDep=false,支持数组子项依赖收集
const { useState, reactive } = sharex(stateFn, { stopArrDep: false });
// 更新其中一个子项,触发更新
function updateListItem() {
reactive.list[1].name = `${Date.now()}`;
}
// 新克隆一个更新回去,不触发更新,因为具体值没变
function updateAllListItem() {
reactive.list = JSON.parse(JSON.stringify(reactive.list));
}
// 新克隆一个并修改其中一个,仅触发list[0]更新
function updateAllListItemAndChangeOne() {
const list = JSON.parse(JSON.stringify(reactive.list));
list[1].name = `${Date.now()}`;
reactive.list = list;
}
const ListItem = React.memo(
function (props: any) {
// arrDep = false 数组自身依赖忽略
const [state, , info] = useState({ arrDep: false });
return (
name: {state.list[props.idx].name}
);
},
() => true
);
function List() {
const [state] = useState();
return (
{state.list.map((item, idx) => (
))}
);
更新效果如下,可观察色块的变化确定是否被更新
结语
其他框架的signal
体验效果暂无验证,本文只针对react
生态中的signal相关作品做对比,helux
处处未体现signal
,但当你使用atom
创建出来共享转态时,处处都在收集读取行为产生的信号(derive
,watch
,useAtom
), 这些都是内置的,你只管像操作普通json一样操作携带信号功能的atom
共享状态即可,这或许才是我们再react
世界里需要的面向开发者编码友好,面向用户运行高效的signal
实现。
友链:
❤️ 你的小星星是我们开源最大的精神动力,欢迎关注以下项目:
helux 集atom、signal、依赖追踪为一体,支持细粒度响应更新的状态引擎
limu 最快的不可变数据js操作库.
hel-micro 工具链无关的运行时模块联邦sdk.
了解更多
欢迎入群了解更多,由于微信讨论群号 200 人已满,需加作者微信号或 qq 群号,再邀请你如helux & hel
讨论群(加号时记得备注 helux 或 hel)