不知大家有没有听过Preact这个框架,就算没听过Preact那也应该听过React吧?
一字之差,preact比react多了个p!(听起来咋不像啥好话呢)
这个P代表的是 Performance,高性能版React的意思。Preact一开始是CodePen上的一个小项目,有点类似于咱们国内常见的《三百行代码带你实现个React》这类文章,用最少的代码实现了React的最基本功能,然后放到CodePen上供大家学习。
当然这是很多年前的事了,那时候这种东西很容易火,想想N年前你看过的第一篇《三百行实现个Vue》、《三百行实现个React》之类的文章是不是竞争对手很少、很容易引发大量的关注度。
CORPORATE CULTURE
不过现在不行了,太卷!这类文章隔三差五的就能看到一篇,同质化严重,导致大家都有点审美疲劳了。
但在那个年代Preact就是这么火起来的,三百行实现了个React引发大量关注度之后,作者觉得自己做的这玩意好像还挺不错的哈!于是开始继续完善,完善后拿去一测试:性能简直完爆React呀!我这玩意不仅体积比你小、性能还比你高。就这样作者开始有些膨胀了、开始飘了!
那我就给这个框架起个名叫Preact吧!
Performance版的React!
Preact 简介
打开Preact官网,映入眼帘的便是它的最大卖点:
只有3KB大小、并且与React拥有相同的API。真的只有3KB么?虚拟DOM、Diff算法、类组件、Hooks… 这些就算实现的再怎么巧妙也需要很多代码才行吧?我们直接用Vite来创建一个Preact项目来试下:
npm create vite
如果屏幕前的你用的是VSCode这个编辑器的话,可以安装一下Import Cost这个插件:
安装好之后我们来看一下主文件(main.jsx):
卧槽?gizpped真的只有3.几K!不过这算法有点鸡贼啊,来了个向下取整:
这让我想起了最近非常火的Turbopack比Vite快十倍的宣传口号,遭尤大怒怼:1k 组件的案例下有数字的四舍五入问题,Turbopack 的 15ms 被向下取整为 0.01s,而到了 Vite 这里 87ms 被向上取整为 0.09s。这把本来接近 6 倍的差距扩大到了 10 倍。
不过即使这样,3.8K依然是一个很惊人的成就。是不是只是render这个函数就占了3.8K啊?我们再引点东西试试:
难以置信!引了这么多hooks居然只多加了0.1K!我还是不太相信用0.1K的代码就能实现出React Hooks来,肯定是用了什么特殊的算法专门针对了这一场景做了优化,我们按照官网的写法来重新引一下:
这回体积明显增大了不少:
不过咋感觉自己跟个杠精似的呢😂 人家说了3KB我却非要以各种方式证明肯定不止3KB,这样不好。Preact真的已经很轻量了,一般人想要实现这么多功能还真做不到只用这么少的代码,Preact的P肯定还是名不虚传的👍
不过刚刚试了下Vue,Vue好像就没有针对这种场景做专门的优化,不仅没优化反而还劣化了:
实际上只引入某几个函数的话Vue没有这么大,这是把Vue全量引入的大小,尤大还不快跟人学学。
Preact Signals
说到"学",Preact原本一直都是React的忠实粉丝,可最近它却开发了一个叫做@preact/signals的东西,这是干嘛的?Preact的创始人Jason Miller以及Preact DevTools的创始人Marvin Hagemeister共同写了篇博客:《Introducing Signals》
点开文章,首先映入眼帘的便是这样一个案例:
等等!这个.value、这个computed、以及这个在jsx的大括号{}中不用写.value的语法…
怎么这么似曾相识呢?好像在哪里见过类似的写法:
与hooks不同,signals可以在组件内部或外部使用。signals在类组件也可以很好的运行,因此您可以按照自己的节奏引入它们,并根据现状,在几个组件中试用它们,并随着时间的推移逐渐采用它们。—— Preact团队
那这样不是越写越Vue了吗?还叫什么Preact啊,叫Vreact多好!
尤雨溪:这还真特娘的是个好主意!我这就把拉你进 Vue 核心群里来!
我们来看看Preact团队为何要实现个P版的Composition API:
- 易冲突的全局状态
- 混乱的上下文
- 寻求更好的状态管理
- 卓越的性能
听说最近尤大被骂了,为啥被骂呢?因为好像有次字节邀请了尤大直播,那尤大肯定得借此机会好好宣传一下Vue啊!不过你光说Vue有多好,观众可能无法感受到。就像如果七龙珠直接让超级赛亚人出场,并且用那个战斗力探测仪显示一个战力:
虽说凭这个确实能让人感受到超级赛亚人很强,但如果要是能有个对比的话那才是最完美的剧情,所以才有了大反派弗利萨的出场机会:
同理,尤大如果光在那罗列数据那肯定不如有个对比来的直观,那就把React拉来对比一番呗!既然是为了宣传Vue,那必须得拿Vue的优点跟React的缺点比啦!这样的对比难免会有失偏颇,让React的粉丝们怒不可遏,在群里疯狂批判尤大。
在一捧一踩(黑React)的宣传过程中呢,尤大花费最多时间宣传的就是以下两点:
- 避免了React Hooks的一些心智负担
- 性能比React强
其实这两点多少还是有点有失偏颇,因为Vue在解决了一种心智负担的同时又带来了另一种心智负担,而且性能也要看场景的,尤大只强调了对Vue有利的场景来宣传…
不过React在某些层面来讲确实有些剑走偏锋了哈,导致性能不是特别理想。Preact也是这么认为的,他们还特意搞了张火焰图:
左边用的是Preact Hooks,右边用的是Composition API… 哦不,是Preact Signals。可以看到Signals的表现完胜Hooks!
那Preact的老师React有在React里实现Vue的计划吗?答案是否定的,自从Preact Signals发布后大家就疯狂@Dan,Dan看完后直接来了句:这与React的发展理念不是很吻合。(潜台词:我们才不会在React里实现Vue呢)
其实我觉得也是,React的发展理念本来就跟Vue走的是完全不同的两种路线,夸张点说就是道不同不相为谋。
那肯定有人说:不对呀,Vue3的Composition API不是抄袭的React么?
这么说吧:大佬们借鉴的是思路,菜鸟们借鉴的才是代码。了解过Vue、React他俩底层实现的朋友们应该都清楚他俩的差距有多大。
尤雨溪在某次采访时说过Vue3一开始本打算实现成类组件,既然是类那就离不开装饰器的话题,尤大说他们甚至都已经实现出来了一版类组件写法的Vue3。只不过他觉得这样相对于Vue2而言除了对TS的支持度之外几乎没有其他什么特别明显的优势。
而且装饰器提案发展了N年却迟迟未能落地,尤大觉得这样遥遥无期,而且就算真的在将来的某一天落地了,是不是也已经与现在TS实现的那版装饰器天差地别了?
Angular用装饰器用的好好的那是因为人家强制要求使用TS,但Vue显然不可能这样做。而且为了防止未来装饰器有变动(其实最近已经Stage3的装饰器已经和TS装饰器不一样了),许多曾经使用装饰器语法的库为了规避这个风险也已经改用了别的写法,如:MobX、React DnD等…
正当尤雨溪为此抓耳挠腮、夜不能寐之时,React Hooks横空出世了!这种函数式组件瞬间就让尤大眼前一亮,他脑袋里的灯泡在那一刹那间被点亮了:
这不就是自己一直苦苦寻找、对TS友好、方便代码复用、语法简洁、低耦合的解决方案么!
但实际上吧,尤大只是参考了这种函数式的设计,如今的Composition API原理与React Hooks相去甚远。真要借鉴的话,尤雨溪已经大大方方承认了是受到了React Hooks的启发,代码层面借鉴的是Meteor Tracker、nx-js/observer-util、salesforce/observable-membrane这三个库。响应式库其实早已不新鲜了,只是之前尤大没能跳出Vue2的思维限制,直到看到了React Hooks才想到可以这样写,然后再一调研发现市面上早就有了函数式的响应式库,Composition API就是这么来的。
不过他在Composition API之前确实模仿了React的原理设计出来了vue-hooks,以用来探索这种函数式组件的可行性。
不过好在后来他发现了Meteor Tracker、nx-js/observer-util、salesforce/observable-membrane这几个库并及时悬崖勒马,没有在这个方向上继续深挖,不然的话Vue3可能就要变成套壳React了。
那究竟为什么没有在此方向继续深挖呢?难道说那仨库的解决方案比React Hooks还要好吗?对此我只想说:
抛开了场景谈好坏都是在耍流氓
这两种方案各有优缺点,巧合的是:双方彼此间的优点恰恰好好就是对方身上的缺点。典型的性格互补么这不是:
有人喜欢内向的、有人喜欢外向的、但也有人想当一个缝合怪:为啥不能内外双向呢?该内向的时候就内向,该外向的时候就外向呗!Preact就是这样想的,他们单独提供了一个叫@preact/signals的包,你要是更在意性能呢,那就用@preact/signals、你要是更在意类似React的开发体验呢,那就不用呗!
用法
Preact版的composition api主要分为三个部分:
- @preact/signals-core
- @preact/signals
- @preact/signals-react
从命名上来看,@preact/signals-core应该是与框架无关的核心实现、@preact/signals是给Preact的特供产品、而@preact/signals-react则是给React提供的特供产品。
我们先来看一下核心实现的用法,这是他们README文件里给出的第一个例子:
非常好理解,就是把原来composition api里的ref换成了signal,这里就不过多赘述了,来看下一个案例:
这个effect也和composition api里的effect如出一辙,不过有同学可能会问了:composition api里没有effect呀?你说的是watchEffect吗?我这里表述的可能不是特别准确,准确来讲的话应该是和@vue/reactivity里的effect如出一辙。
那么问题来了:@vue/reactivity不就是composition api吗?其实他俩确实非常的…容易混淆,准确来讲@vue/reactivity是可以运行在完全脱离vue的环境之下的,而composition api是根据vue的环境进行的进一步更好用的封装。composition api包含了@vue/reactivity。
那composition api和@vue/composition-api又有啥区别呢?区别就是composition api只是一个概念,而@vue/composition-api是一个实现了composition api的项目。当初尤雨溪提出composition api的时候(那时候还不叫composition api,好像叫什么functional base api)遭到了大量质疑的声音,于是有个大佬就用Vue2现有的API实现了一版尤雨溪的提案,尤雨溪觉得这玩意非常不错!你们老喷我是因为你们没有体验过函数式的好,你们先用用试试,试完了保证你们直呼真香!于是联系该作者把Vue2版的composition api合并到Vue的仓库中并发布为@vue/composition-api。
但谁也不会用爱发电对不,刚开始当个娱乐项目给你宣传了,时间一长也没啥收益,该作者也就不维护了。此时另一位大佬出现了,他说既然没人维护了那就交给我吧!他就是肝帝AntFu:
一整年就两三天是灭着的,剩下的时间无论刮风还是下雨,都无法阻挡大佬提交代码的脚步。
甚至那两三天我都怀疑是有什么不可抗力导致的,比方说来台风断电啦或者在飞机上没法提交,下了飞机直接就进入另一个时区(第二天)啦之类的原因,他甚至比尤雨溪都勤快:
不过拿他俩比有点不太公平哈,尤大有家有孩子,而且还要带领两个团队(Vue、Vite),写代码的时间自然会少很多。
而傅佬年轻没结婚没孩子、也无需带领团队啥的,自然就会有很多时间做自己喜欢做的事情。不过我翻了一下尤大迭代最疯狂的2016年,也依然没我傅哥勤快:
这就是我傅哥为何能如此高产的原因。
有点扯远了哈,没接触过@vue/reactivity的effect同学暂且先把它理解为composition api的watchEffect,在这里开始出现了一个与composition api不太一样的api了哈,.peek()是什么鬼?为了帮助大家快速理解这玩意,我们需要对比一下composition api里两个相似功能的api:watch和watchEffect。
这俩api功能相似但各有优缺点,我们只说watchEffect不如watch的其中一个缺点:无法精确控制到底监听了哪个响应式变量。
比方说我们写了这样一段逻辑:
每当我们改动a.value的值时,b.value就会++。这是我们希望的逻辑,但不幸的是,每当我们改动b.value的值时,b.value还是会++。这在watch里还是很好实现的:
但在watchEffect那段代码里就相当于在watch中写了这样一段代码:
Vue的方案是既提供一个自动收集依赖的watchEffect,同时也提供一个手动收集依赖的watch。
而Preact的方案则是只提供一个effect(类似Vue的watchEffect),如果你写出类似上面那样的代码:
那就直接报错给你看:
为什么会报错呢?了解过响应式原理的同学应该不难理解,就是触发getter的时候又会触发setter,而触发了setter又会导致重新运行effect函数导致死循环。
那为啥Vue那边的代码没死循环呢?这是因为Vue做了这样一层判断:如果你在effect / watchEffect里触发了setter,那便不会触发对应的effect / watchEffect函数,这样就可以避免死循环了。
那Preact没做这样的处理怎么办呢?那我们就避免在effect里既对signal进行取值操作同时又对它进行赋值操作呗!
不过这样做肯定是不行的哈,你这太不专业了,所有成熟的响应式库没有哪个会放着这个问题不去解决的。比方说Solid.js,他就有一个叫untrack的函数,假如我们在effect里想要获取到一个响应式的值但却并不想它被收集到依赖里面去就可以写成这样:
这样只有a改变时会触发effect函数,b则不会。如果你能理解上面这段代码的话,那相信你肯定能理解下面这段代码:
我还专门去查了一下peek是啥意思,是偷窥的意思。有时候觉得老外起的api名翻译过来还蛮有意思的,就是说我在effect里需要获取到某个响应式变量的值,但直接获取会被追踪到,所以我不直接获取,我要“偷窥”一眼它的值,这样就不会被追踪到啦!(这个api虽然很调皮,但有些略显猥琐)
接下来看下一个api:computed。就我不说你们都能猜到这是干啥的,这就是Vue的那个computed,直接看例子就不解释了:
下一个api是effect,其实在.peek()那个“偷窥”案例中就已经用过effect了,它就是@vue/reactivity里的effect,也不过多解释了,直接上案例:
接下来这个api可能会有些令大家陌生了,叫batch,分批处理的意思,来看如下案例:
有一定开发经验的同学应该一下子就能看出这段代码想表达什么意思了(如果看不懂的话去反思一下),就是当我们修改值的时候是同步出发对应的effect函数的,所以我们如果连着改两次就会连续运行两次,我写了一个简化版的案例给大家看一下:
控制台打印结果:
就挺让人无语的…… 这也能水个API出来?人家Vue默认就是分批处理的,我们在Vue里写一段同样的代码来看看Vue是怎么运行的:
控制台打印结果:
不过之前咱们不是说Vue的响应式依赖是@vue/reactivity么?Composition API是Vue在@vue/reactivity的基础上再次封装,让它变得更好用更适合Vue项目。那会不会是它封装了批处理才导致这样的结果的呢?我们先不用import xxx from 'vue'这种形式了,这样的话用的是Composition API,我们这次用@vue/reactivity再来试一把:
果不其然,这次的结果终于和Preact保持一致了:
误会了哈!我还寻思@preact/signals-core也太不专业了,人家@vue/reactivity默认就支持的东西……
不过既然@vue/reactivity默认也是同步的,那怎么分批处理呢?想让它像@preact/signals-core这样:
在@vue/reactivity中要想要达到同样效果的话… 关键是这个@vue/reactivity连个文档都没有!Vue官网上的Composition API是又封装了一层,用法已经不一样了。比方说Composition API里的watch在@vue/reactivity里就没有,而且watchEffect和effect表现也不太一致,@vue/reactivity的README写的也特别简陋:
机翻一下:
就很无奈,我想知道这个库怎么用就只能去看看它的TS定义,看看都有哪些API以及都有哪些用法。哪怕不像Vue那样有个专门的官网,那你在README里写几个简单的事例也行啊!就像@preact/signals-core那样,能耽误你几小时?
吐槽归吐槽,想知道咋用还是得去看代码,在了src/effect.ts后我发现这样一段代码:
果然还是和Composition API里的watchEffect参数不一致,我们能看到有个lazy字段,从名字上来看应该就是它了吧。我还特意去Vue官网看了一下watchEffect的第二个参数都有哪些字段,watchEffect就没有lazy这个字段,取而代之的是flush字段:
用法这么大差异,连个文档都不写。尤大,你是想让每个用@vue/reactivity的人都去从源码里找答案么?算了不吐槽了,咱们继续来看例子:
加了{ lazy: true }以后控制台啥都不打印了!尤大你是要气死我呀!那这个lazy到底是用来干啥的?可能是用来代替Composition API里的watch的吧?wacth会自动执行一次,effect则不会这样。
那也不对啊,watch只是刚开始的时候不会自动执行一次,但当依赖变化时还是会运行啊,这怎么连运行都不运行了?不是你别让我猜呀!想知道你这库咋用就两种方式:要么看源码要么就靠猜…… 那如果不是lazy的话那就是scheduler字段?想看看你这咋用,结果你给我来个这:
文档不写就算了,你还定义了一堆any类型… 这特么到底咋用啊?好像以前看过的《Vue.js设计与实现》里有写过,不过那本书搬家放在哪里想不起来了,等我找到后再把例子给补上。
之前还想吐槽@preact/signals-core不专业,Vue早就支持的功能它还要专门出一个API。现在看来还是我太年轻,与框架无关的@vue/reactivity连个文档都没有,都不知道怎么支持这个批量更新,不专业的反而是@vue/reactivity。
咱们继续来看下一个案例:
这是啥意思呢?就是我们在batch函数里访问了一个计算属性,按理说要等batch函数运行完了才会去更新,但这个计算属性依赖的值在batch里刚刚被改过,为了让我们能拿到正确的值,不等batch执行完就直接更新这个计算属性。但也不是所有依赖counter的计算属性都会被更新,没在batch函数里被访问到的tripple就会等batch函数运行完毕后再去进行更新。
batch函数还可以嵌套着写:
当最外层的batch函数运行完成时才会更新对应的值。
React 及 Preact
core的核心部分讲完了,那就继续看看@preact/signals以及@preact/signals-react吧!它俩用法都一样:
就有点类似于在React里写Vue的那种感觉。
后续
晚上回家一顿翻,终于找着了《Vue.js设计与实现》这本书,声明一下本文真不是这本书的软广告,多卖出去一本我也不会得到什么分成。真就是我写那个例子的时候找不到文档又不知道咋用,README让去看TS声明结果看了个any…
我是真没耐心去特别仔细的研究@vue/reactivity的源码,我觉得理解了大概的原理就行不必那么死抠细节,毕竟咱们一不靠卖源码课赚钱、二也不负责维护Vue、三也不像一些大佬似的没事就以钻研为乐、四也不至于研究完源码就能升职加薪什么的…
不过好在我之前看过那本书里面写的挺详细的好像有scheduler、lazy之类的字段是用来干嘛的并且还给出了实现以及用例。我又看了一遍响应式那章,之前靠猜以为lazy是用来模仿watch的,结果写了{ lazy: true }之后直接不运行了,这是因为写了{ lazy: true }就从自动挡变手动挡了!返回一个函数让你自己去决定啥时候运行:
打印结果:
那这样写有什么意义呢?这样写确实没什么意义,本来能自动运行的函数非要让你手动运行。这样做的意义主要是为了实现computed的,咱们想要的是@preact/signals-core里的batch批处理功能,书中的scheduler选项接收一个参数,但实测当前最新版本的@vue/reactvity没有任何参数:
打印结果:
盲猜可能是版本变化导致的用法不一致行为,我们把@vue/reactivity的版本改成3.0后再来打印一下:
这回有值了,那为什么会把这个参数删掉呢?我们只能从CHANGELOG里找答案了:
从有限的信息我们可以得知大概是从3.2及后续版本删掉了的,3.0.x及3.1.x与书中用法保持一致。
在书中scheduler的参数十分重要,书中就是基于这个参数来实现的批处理能力。想知道新用法么?我不告诉你!就是不写文档嘿嘿!看源码去吧!
这让我突然想起尤大在某纪录片中吐槽有些人就是不看文档,我也想吐个槽:你特么倒是写呀!
没办法了,先钻研一下源码吧!经过我一段时间的钻研呢,大概得出来了一个结论:在3.2之后effect的返回值其实就相当于3.2之前scheduler的参数:
那我们就可以根据这一变化来重写书中给出的调度执行的案例了:
这次的打印结果就与@preact/signals-core保持一致了:
为什么会在3.2以后去掉这个这个参数呢?我觉得是因为这个参数与effect的返回值一致,相当于重复了,不信的话我们来拿3.0来做个实验:
打印结果:
吐槽:重复了你就在CHANGELOG里写一句因与返回值重复故删之类的话呗!啥也不写就非得让人去看源码