在上一篇《Riverpod 的注解模和发展方向》里就有很多人提到 signals ,对比 riverpod 部分人更喜欢 signals 的 “简单”和“直接”,那 signals 真的简单吗?再加上前段时间 signals 和 riverpod 的性能对比风波,也让大家更加关注 signals ,那它究竟有什么「魔力」?
signals.dart 有多“简单”?大概就是它的状态管理可以“简单”到甚至和 Flutter 没有关系,如下代码所示:
通过
signal创建一个信号对象通过
computed可以合并多个signal通过
effect可以监听响应数据变化
import 'package:signals/signals.dart'; final name = signal("N"); final surname = signal("M"); final fullName = computed(() => name.value + "-" + surname.value); // Logs: "Jane Doe" effect(() => print(fullName.value)); // Updating one of its dependencies will automatically trigger // the effect above, and will print "John Doe" to the console. name.value = "D";上述代码会先打印N-M,然后会打印D-M,因为在最后执行name.value = "D";时:
effect里的函数会被调用,因为它内部有fullName.value,signals 内部会自动跟踪fullName的状态变化computed会被调用,因为computed的fullName.value在effect内被访问,所以name的数值发生改变,从而让computed需要刷新状态
是不是有点懵?这其实就是 signals 的 「魔法」,它的独特之处在于,它是「自动状态绑定」和「自动依赖跟踪」:
❝ 和其他传统的状态管理模型不同在于,signals 支持开发者精确地跟踪状态变化并仅更新依赖于这些变化的部分 UI,就像上面的代码,「自动化」的实现看起来就像是「魔法」。
但是,事实上当你觉得某个框架是「魔法」时,那其实这个框架并不适合你使用,毕竟当遇到「咒语」失灵时,「魔法师」就很容易成为「脆皮的废物」,所以搞清楚 signals 的「魔法」实现原理尤为重要。
前言
开始解析在聊 signals.dart 之前,需要快速介绍 signals 的前置概念,附带还有 Preact、Preact Signals 、SolidJS 等关键词。
首先需要说明一点,「Signals」 是业内通用的一种状态管理模式,而 signals.dart 项目就是 Preact Signals 的一个 Dart 移植版本,所以在最底层源码里你可以看到 Preact Signals 的核心原语,自然也就是包含了Signal 的细粒度、惰性求值和自动依赖追踪等能力。
那么 Preact、 Preact Signals 又是什么,还有一开始图片提到的「类似 solidjs 状态管理」,它们和 signals.dart 有什么关系?
首先我们说过,「Signals」 是一种概念模式,它并不限制于任何语言还有框架,而在这个基础上:
Preact 是一个轻量级的 React 替代方案
Preact Signals 是 Preact 团队基于 Signals 概念提供的可用于 Preact 和 React 状态管理
SolidJS 是一个围绕 Signals 模式实现的 UI 框架,它是完全基于 Signals 驱动的框架
所以在 signals.dart 的源码和资料里都能看到它们的身影,而事实上signals.dart 的实现就深受 Preact Signals 的影响,比如最底层的基础代码结构上:
而对于 Signals 而言,它的主要优势在于更高校的颗粒度更新、自动化实现依赖跟踪、延迟计算等特点,其中我们最需要理解的,就是自动化实现依赖跟踪的「魔法」。
解析
要搞清楚「魔法」,首先我们需要知道effect是如何工作,如下代码所示,可以看到先打印输出了N,然后在value被改变的时候,又输出了D,那为什么在name.value改变的时候,effect 就会被调用呢?
这就不得不提,在 signals 里.value的 setter 和 getter 方法都是有特殊处理的,简单来说,就是当 value 被调用时,就会触发相应的逻辑,比如:「创建出对应的Node」,其实对于 signals 来说,内部Node是一个很重要的概念,因为它的实现基础,都是基于这个内部Node双链表来完成。
其实,在signals.dart中Node一直扮演着核心角色,它是自动跟踪依赖和管理状态结构的基础模块 ,比如Node类通过将ReadonlySignal(数据源)连接到对应的Computed和Effect等数据「消费者」来完成依赖:
class Node { // 目标依赖的源。 final ReadonlySignal _source; Node? _prevSource; Node? _nextSource; // 依赖源并在源改变时应被通知的目标, 是消费者 final Listenable _target; Node? _prevTarget; Node? _nextTarget; // 目标上次看到的 _source 的版本号,使用版本号而不是存储源值, // 因为源值可能占用任意大小的内存,并且计算可能会因为惰性求值而永远持有它们, // 使用特殊值 -1 来标记可能未使用但可回收的节点。 int _version;抽象概念先聊它的抽象概念,本质上Node就是在Signal、Computed和Effect等对象里被创建,并集成到一个双向链表中,当开始建立依赖关系时,比如在Computed/Effect访问Signal的值时,新的Node对象久会被创建,并添加到依赖项 (_prevSource/_nextSource) 和消费者 (_prevTarget/_nextTarget) 列表里。
也就是当你在Computed/Effect调用.value的 setter 和 getter 时,依赖追踪就会自动完成,从而创建一个新的Node,而后续的更新和触发执行,都是通过这个Node链表的遍历来完成。
❝ 所以 Node不仅仅是一个简单的数据结构,它通过将 Signal(数据源)连接到 Computed/ Effect消费者从而连接形成了一个图谱,其中一个节点的变化可以传播到其他节点,最终确保状态的一致更新。
所以在 signals 里,会利用Node对象来通知存储在targets列表中的所有依赖者 ,当信号的值发生改变时,会遍历依赖者列表,并根据_version对比结果来触发更新。
因为比对详细数据太过费时费力,通过_version来代表数据版本,不一致版本则更新,这样更有效率:
❝ 当 Signal的值被设置时,它的版本号会递增,当依赖的 computed/ effect运行时,它会记录其读取的每个 Signal的版本,在重新评估之前可以检查记录的版本是否更改,如果没有则可以跳过重新评估,从而节省资源。
所以在这些链表遍历时,_version可以在值改变时更高效地通知依赖者。
是不是觉得有些抽象?没事,我们接下来通过源码来理解。
Effect
首先,Effect会使用Node对象来订阅其依赖的Signal,而首次Effect都会被立即运行,并在每次依赖项更改时被运行,那么这里有两个关键流程:
首先
Effect就自己执行一次然后
Effect内的.value的调用就完成了数据的跟踪绑定
那么我们看Effect首先执行的时候经历了什么,通过源码可以知道,Effect每次执行内部都会执行一个start函数,它其中一个关键的作用就是evalContext = this:
这里的evalContext其实就是Computed/Effect的抽象上下文,它代表的是当前的执行环境,它是存在于global.dart里的全局变量,决定当前执行的上下文环境,evalContext = this大概意思就是 :
❝ Signal 现在执行到当前这个 Effect了。
也就是当Effect被执行的时候,evalContext就代表了当前的这个Effect,这就是Effect首次执行时的关键作用。
接下来就是Effect里的.value调用,让你调用Signal里 value 的 getter 时,其实内部就会对应调用addDependency给这个Signal添加依赖:
此时这个Effect就会创建出对应的Node,这个Node的 target 消费者evalContext正是当前Effect,可以看到,这就是自动跟踪的开始:
因为Effect首先被执行时,全局的evalContext会指向当前Effect,然后在Effect调用.value时,就会创建出Effect的对应Node,并添加到链表里。
❝ 所以自动跟踪的「魔法」,就在于 get value 里执行的依赖操作,通过读取当前执行环境 evalContext来判断需要依赖的位置。
那么,当我们执行.value =xxx的时候,同理就会触发 value 的执行 setter ,可以看到,此时相关 target (Effect) 就会被notify并最终执行endBatch:
notify的作用就是把通过batchedEffect,把所有需要触发的Effect形成一个可访问链表,这里的头部batchedEffect也是一个全局对象:
而最终通过endBatch执行批处理,执行就会触发对应的Effect的 callback,进而再次执行到我们需要让他消费的地方,也就是effect里的函数因为 value 改变被再次执行:
这里有个叫needsToRecompute的函数,其实他就是分析数据源里面的所有version是否改变,如果有改变了,才执行Effect的 callback :
那么到这里,应该就可以简单理解Effect如何实现自动跟踪依赖和刷新:
执行时通过全局对象指定当前
evalContextvalue 的 getter 和 setter 方法通过
evalContext实现自动依赖跟踪version 版本号判断是否更新
那么对于Computed来说也类似,不同的是 Computed 也是一个「特殊信号」,在获取它的 value 的时候同样会添加依赖,只是这里会有多一步internalRefresh操作:
internalRefresh其实就是一个判断是否需要更新的过程,比如用到前面的needsToRecompute会分析所有依赖项的 source version ,从而判断是否需要更新,还有evalContext = this切换到当前执行环境:
所以可以看到,对于Computed来说,更新数据其实不是主动的,它是在 value 被 getter 的时候,才会执行刷新计算,也就是它其实是懒加载的。
比如,在下面counter的 value 被调用之前,每次counter变化时,其实并不会主动触发computed, 而是当data.value被调用到时,有数据改变才会触发computed的的执行:
final data = computed(() { return counter.value + 12; });所以到这里,Computed的「魔法」实现你也了解了吧?除了自动依赖跟踪,应该也理解了为什么 signals 可以做到「颗粒度控制」和「性能优化」了吧?那接下来我们继续聊 Flutter Signals 。
Flutter
实际上,通过前面我们可以看出, signals 的状态管理可以说和 Flutter 没有「直接」关系,那它在 Flutter 上又是如何工作的?
首先我们看下方代码,这是一个最简单的 Flutter 使用 signals 的例子,这里的核心就是SignalsMixin:
class _CounterExampleState extends State
with SignalsMixin { late final Signal
counter = createSignal(0); void _incrementCounter() { counter.value++; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Flutter Counter'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'You have pushed the button this many times:', ), Text( '$counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), ); } }
通过SignalsMixin,我们可以看到:
首先是
createSignal(0)创建信号而不是signal(0);直接使用 '$counter' 直接渲染数据
改变
counter.value,进而让 UI 更新
是不是很简单?这里的关键点就是createSignal(0),在SignalsMixin里调用createSignal的时候,内部会执行一个_watch操作,最终会在_setup的时候,在一个 effect 里订阅对应的 signal 的 value :
也就是说,当着value被改变时,它的effect就会被执行,从而触发_rebuild,进而执行setState更新控件。
也就是createSignal是通过effect来让 UI 更新,这就是 signals 在 Flutter 里的最基础用法,类似的还有createEffect、createComputed等,如果你需要实现自动监听和释放的话,那么在 Flutter 里最好就是使用SignalsMixin的各种 createXXX 方法,因为这样就可以做甩手掌柜:
❝ 为什么这么说?如果我们直接用 effect(() {xxx});,其实我们是需要手动执行 dispose ,不然比如页面销毁时, effect还会继续存在并且被执行。
另外 Flutter 还可以用的就是 signals 里的Watch控件,使用Watch就可以直接使用原始signal而不需要 createXXX :
final counter = signal(0); Watch.builder(builder: (context) { return Text('$counter'); });其实Watch内部是利用了createComputed做依赖跟踪,你在widget.builder的使用的 signal 都会被自动依赖到Computed,因为Watch内部是return result.value,所以在每次变化时,Computed都会重新刷新:
late final result = createComputed(() { return widget.builder(context, widget.child); }, debugLabel: widget.debugLabel); @override Widget build(BuildContext context) { return result.value; }另外还有counter.watch(context)方法,这个方法它会判断你是否存在SignalsMixin:
如果是直接监听即可
如果不是,就获取 Flutter 的
BuildContext并将当前的Element注册为Signal的监听器
而实际watch其实就是让value再变化时通过subscribe触发rebuild,另外这里它会使用signal.peek()来避免 value 调用时的 subscribing 监听。
而peek()之所以不会被跟踪依赖,其实就是在返回 value 之前,先临时清空了evalContext,也就是没有执行环境了:
同样道理的还有batch批处理,其实也就是将全局的batchedEffect临时处理为空,并且判断batchDepth等操作:
举个例子,这里通过 signals 自己的SignalProvider实现将一个信号通过InheritedWidget往下共享,当然你可以也创建一个全局的 Signal ,这里展示的是:
因为
listen: false,所以不会主动更新所以此时
counter.value ++并不会触发 Flutter 本身InheritedWidget的更新,自然也就不会更新到 UI但是此时
effect里是可以正常打印
class _CounterExampleState extends State
with SignalsMixin { void _incrementCounter() { final counter = SignalProvider.of (context, listen: false)!; counter.value ++; } @override Widget build(BuildContext context) { final counter = SignalProvider.of (context, listen: false)!; effect(() { /// Register to $id AsyncSignal print('counter id: ${counter.value}'); }); return Scaffold( appBar: AppBar( title: const Text('Flutter Counter'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'You have pushed the button this many times:', ), Text( '$counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), ); } }
从这里你也可以看到 signals 和 Flutter 之间的一个关系,signals 是一种数据跟踪和管理模式,而如何更新 Flutter UI ,就看你的颗粒度和使用需要,最方便的肯定是直接采用前面介绍的 API 。
❝ 毕竟手动销毁还是挺“麻烦”的。
同时,针对 Flutter 上的支持,signals 也提供了SignalProvider用于需要实现往下共享 Signal 的场景,但是本身 Signal 就支持 context 无关定义,所以实际上不用SignalProvider也可以,毕竟 Signal 本身的颗粒度控制会比InheritedWidget更细腻。
另外, signals 也并不强求什么写什么顶层容器,甚至也不需要InheritedWidget的支持,它单纯就是依赖自己内部驱动的概念,不管是局部状态管理,还是全局状态管理,它都可以很灵活。
最后,signals 也提供了 DevTools 上的数据可视化结构,这其实也是现在状态管理框架的标配之一了:
到这里我们就可以做个简单的总结了,在 signals 里最基础就是Signal、Computed和Effect,它们的实现逻辑可以简单总结为:
Computed/Effect运行时会通过全局evalContext标注当前运行环境Signal的 value 对 getter 和 setter 有特殊处理,一般 getter 会根据evalContext自动添加依赖,而 setter 会刷新数据version并更新所有依赖EffectComputed是一种特殊信号,它的懒加载决定了它只有在 value 被调用时才会触发刷新计算peek和batched其实都是对全局环境变量的临时清空操作version作为判断数据版本的主要依据
所以, 当Computed/Effect函数运行时, 可以做到追踪在函数中访问 value 的任何信号变化,对于每个被访问的信号,都会创建一个新的Node对象(或者重用现有的对象),从而将信号链接到当前的Computed/Effect,Node会被添加到Computed/Effect的依赖项列表和信号的依赖者列表中。
这种自动订阅机制就是 signals 的关键「魔法」,通过消除手动声明依赖项的需求,简化了状态管理,甚至在 Flutter 可以一定程度”脱离“ Context 实现状态更新的实现原理。
那么,你会选择 signals.dart 吗?
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.