前言
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()<div>{val()}</div>
所以咱们可将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"// 定义 storeclass Store { count = 0 constructor() { makeAutoObservable(this); } add(){ this.count += 1 },}// 实例化 storeconst mySotre = new Store();const App = observer(() => { // 应用 store const store = useObservable(mySotre); return <button onClick={() => ++store.count}>{store.count}</button>});
示例参考自mobx官网react集成
这种应用体验落后于其余不须要显式突出察看,api齐全走函数式格调的signal
实现库。
preact/signals-react
preact 本身实现了 signal
后,也公布了 @preact/signals-react
包来为react
带来signal
能力,然而当我认真体验后,惟一的感触是:这可能只适宜写hello world
.....
咱们依照官网demo写进去一个简略的示例是这样的:
import { signal, computed } from "@preact/signals-react";// 定义 signalconst count = signal(0);// 派生 signalconst comput = computed(() => count.value * 5);// 批改 signalconst increment = () => { count.value++;};export default function App() { return ( <div className="App"> <h1>Count -> {count}</h1> <h1>Commp -> {comput}</h1> <button onClick={increment}>Increment</button> </div> );}
跑起来确实没问题,然而咱们的理论开发我的项目不可能是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的组件也会被重渲染。
代码如下,每个组件能够蕴含了一个 <h3>{Date.now()}</h3>
,如果变动了示意以后组件被触发重渲染了
示例代码见App3.tsx文件
import { signal } from "@preact/signals-react";const person = signal({ firstName: "John", lastName: "Doe",});function FirstName() { return ( <div> <h1>firstName: {person.value.firstName}</h1> <h3>{Date.now()}</h3> </div> );}function LastName() { return ( <div> <h1>lastName: {person.value.lastName}</h1> <h3>{Date.now()}</h3> </div> );}const change = (e) => { person.value.firstName = e.target.value; person.value = { ...person.value };};export default function App() { return ( <div className="App"> <FirstName /> <LastName /> <input onInput={change} /> </div> );}
输出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;++};// 组件应用-- <p>{person.value.firstName}</p>++ <p>{person.value.firstName.value}</p>
写完这样一段代码后,我的心田感触是这样的
如果咱们真的在我的项目铺开来这样应用signal
的话,将陷入signal
拆分的陆地里,以及漫天.value
的暴雨中....
helux 带你进入真正的signal世界
helux
引入signal
设计时,明确了要实现以下几个指标,能力让signal
在react
真正可用、好用。
纯正的函数式
相比mobx-react
应用class
创立 store,helux
彻底拥抱函数式,有更强的可组合性
import { atom, share, derive, watch, watchEffect } from 'helux';// primitive atomconst [ numAtom, setNum ] = atom(1);const result = derive(()=> numAtom.val + 1); // { val: 2 }watch(()=> console.log(`numAtom changed`), ()=>[numAtom]);// orwatchEffect(()=> console.log(`numAtom changed ${numAtom.val}`))setNum(100); // 驱动 derive watch watchEffect// object atomconst [ state, setState ] = share({a:1, b:2});const result = derive(()=> state.a + 1);watch(()=> console.log(`state.a changed`), ()=>[state.a]);// orwatchEffect(()=> 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 ( <div>{num}</div> );}function Demo2() { const [ state ] = useAtom(state); return ( <div>{state.a}</div> );}
依赖是细粒度的,对象逐渐开展依赖就会逐渐放大
// 依赖仅仅是 state.a,只有 state.a 变动才会引起重渲染<div>{state.a}</div>
如果不须要驱动这个组件从新渲染,只须要应用$
符号包含即可,此时组件都不须要useAtom
,实现 0 hook 编码。
import { $ } from 'helux';function Demo(){ // 仅div外部这一小块区域会重渲染 <div>{$(state.a)}</div>}
没有任何心智累赘的应用信号对象
咱们能够把对象构建得足够简单,也能够像应用原生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 ( <MarkUpdate info={info}>name: {state.list[props.idx].name}</MarkUpdate> ); }, () => true);function List() { const [state] = useState(); return ( <div> {state.list.map((item, idx) => ( <ListItem key={idx} idx={idx} /> ))} </div> );
更新成果如下,可察看色块的变动确定是否被更新
结语
其余框架的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)