前言

helux 是一个集atomsignal依赖追踪为一体,反对细粒度响应式更新的状态引擎,兼容所有类 react 库,包含 react18。

本文将重点介绍react生态里各方signal的具体实现,并探讨背地暗藏的坑导致的可用性问题,同时也会给出heluxsignal实现的劣势,并教会你应用signal 写出0 hookreact 状态组件。

signal 热潮

signal在2023 年掀起了一股热潮,其实诸多框架很早就开始为其布局,Vue、Solid.js、Preact、Svelte 都在不同时的引入了signal,某种程度来说 vue 甚至能够算是 signal 的鼻祖,Angular作者发文Signal是前端框架的将来后,在2023年末的Angular 17里引入了signal实现,可见大家对signal是如许的渴望。

咱们察看如下Angularsignal代码示例

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这里的批改基于setupdate,获取通过get函数。

接下来咱们将只针对react生态里的signal揭示可能存在的应用问题。

不知angularsveltesolid是否存在如下问题,欢送浏览本文的读者提出对应观点或给出示例。

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,后果FirstNameLastName组件均触发重渲染

坑3: 蹩脚的细粒度更新开发体验

可是官网明明声称反对细粒度更新的啊,为什么坑2的示例触发了全副应用方渲染呢,通过仔细阅读官网,发现上述例子改为真正的细粒度渲染还须要进一步拆分signal,所以代码须要优化为signalsignal的模式

// 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设计时,明确了要实现以下几个指标,能力让signalreact真正可用、好用。

纯正的函数式

相比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对象应用信号对象,不须要signalsignal的诡异操作。

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创立进去共享转态时,处处都在收集读取行为产生的信号(derivewatchuseAtom), 这些都是内置的,你只管像操作一般json一样操作携带信号性能的atom共享状态即可,这或者才是咱们再react世界里须要的面向开发者编码敌对,面向用户运行高效的signal实现。

友链:

❤️ 你的小星星是咱们开源最大的精力能源,欢送关注以下我的项目:

helux 集atom、signal、依赖追踪为一体,反对细粒度响应更新的状态引擎

limu 最快的不可变数据js操作库.

hel-micro 工具链无关的运行时模块联邦sdk.

理解更多

欢送入群理解更多,因为微信探讨群号 200 人已满,需加作者微信号或 qq 群号,再邀请你如helux & hel探讨群(加号时记得备注 helux 或 hel)