前段时间由于React Licence的问题,团队积极探索React的替代产品,考虑到未来可能的移动业务,团队旨在寻找一款迁移成本低、体积小的替代产品。经过多次探索,Preact进入了我们的视野。自从接触Preact,一路学习下来,掉了很多头发,收获了很多思想。这里介绍一下Preact的实现思路,分享一下自己的想法。
什么是Preact
一句话介绍PReact,是3KB的轻量级替代反应,和ES6 API一样。如果我觉得这句话太模糊,我可以多说几句。Preact = performance+react,这是Preact这个名字的由来。其中一场表演足以看出作者的意图。下图反映了不同框架在长列表初始化场景下的性能,可以看出Preact确实有突出的性能。
高性能、轻量化、准时制生产是Preact的核心。基于这些主题,Preact关注react的核心功能,并实现了一个简单且可预测的diff算法,使其成为速度最快的虚拟DOM框架之一。同时,preact-compat为兼容性提供了保障,使得preact可以无缝连接react生态系统中的大量组件,同时补充了Preact没有实现的很多功能。
与预启动工作流相比,列表初始化时间较长
本文简要介绍了Preact的过去和现在,然后讲述了Preact的工作流程,主要包括五个模块:
组成部分
h函数
提供
Diff算法
回收机制
循环过程见下图。
首先,我们定义组件。在渲染开始的时候,我们会先进入H函数生成对应的虚拟节点(如果写了JSX,需要先转码)。每个vnode都包含自己节点的信息及其子节点的信息,这些信息被链接到一个虚拟dom树中。基于生成的vnode,渲染模块将根据当前的dom树控制进程,并为后续的diff操作做一些准备。Preact的diff算法的实现不同于基于双虚拟dom树的react的思想。Preact只维护一个新的虚拟dom树。在diff期间,将基于dom树恢复旧的虚拟dom树,然后将两者进行比较。在比较过程中,实时对dom树进行补丁操作,最终生成新的DOM树。同时,diff过程中卸载的组件和节点不会被直接删除,而是分别放入回收池进行缓存。当再次构建同类型的组件或节点时,可以在回收池中找到同名的元素进行重构,从而避免从零开始构建的开销。
前期工作流程图
在了解了Preact的工作流程之后,我们将对上面提到的五个模块逐一进行解读。
1.组成部分
关键词:钩子,链接状态,批量更新
相信有过react开发经验的同学对组件的概念都比较熟悉,这里就不做过多的解释了,只介绍Preact在组件层面增加的一些新特性。
挂钩功能
Preact除了基本的生命周期功能外,还提供了三个钩子功能,方便用户在指定的时间点进行统一操作。
后安装
更新后
卸载前
linkStateLinkState就是针对render方法中用户操作的回调绑定这个的场景,这样每次render的时候都会在本地创建一个函数闭包,这样效率很低,会迫使垃圾收集器做很多不必要的工作。linkState的理想应用场景如下。
exportDefaultClassappextendsComponent { constructor(){ super();this . state = { text:' initial ' } } HandleChange = e = & gt;{ this . SetState({ text:e . target . value })} render({ desc },{ text } } { return(& lt;div>。& ltinput value = { text } OnChange = { this . LinkState(' text ',' target . value ')} & gt;& ltdiv>。{ text } & lt/div>。& lt/div>。)}}
但是,linkState的实现。。。当组件初始化时,为每个回调创建一个闭包,绑定它,并创建一个实例属性来缓存绑定的回调函数,因此在再次呈现时不需要再次绑定。实际效果相当于组件的构造函数中的绑定。尴尬的是,linkState只实现了setState操作,不支持自定义参数,所以使用场景有限。
//linkState源代码//cache回调linkstate (key,eventpath) {letc = this。_ linkedstates | |(此。_ link ed States = { });returnc[key+event path]| |(c[key+event path]= createLinkedState(this,key,event path));}//创建闭包ExportFunctionCreateLinkedState(组件,键,事件路径){let path = key。拆分('.')第一次注册回调时;return function(e){ lett = e & amp;& ampe.target||this,state={},obj=state,v=isString(eventPath)?delver(e,eventPath):t.nodeName?(t.type.match(/^che|rad/)?t.checked:t.value):e,I = 0;for(;i<。path . length-1;i++){ obj = obj[path[I]]| |(obj[path[I]]=!i & amp& ampcomponent . state[path[I]]| | { });} obj[path[I]]= v;component . SetState(state);};}批量更新
Preact实现了组件的批量更新。具体实现思路是,每次执行状态或道具更新时,都会立即更新相应的属性,但基于新状态或道具的渲染操作会被推入更新队列,在当前事件循环结束时或下一个事件循环开始时,会逐一执行队列中的操作。同一组件状态的多次更新不会重复进入队列。如下图所示,属性更新后,组件渲染前,_dirty的值为true,因此组件渲染前后续的属性更新操作不会使组件重复入队。
//更新队列源代码exportfunctionenqueuerender(组件){if(!组件。_脏& amp& amp(组件。_ dirty = true)& amp;& ampitems . push(component)= = 1){(options . DEBOUNDERATION | | delay)(re render);}}2.h函数
关键词:节点合并
h函数的作用类似于React。并用于生成虚拟节点。接受的输入格式如下。这三个参数是节点类型、节点属性和子元素。
H ('a ',{href:'/',h {'span ',null,' home'}})节点合并
在生成vnode的过程中,h函数会合并相邻的简单节点,以减少节点数量,减轻diff的负担。请看下面的例子。
从“preact”导入{h,Component };constinnerchildren =[[' inner child 2 ',' innerchild3'],' inner child 4 '];constitinearchildren =[& lt;div>。{ innerinnerchildren } & lt/div>。,& ltspan>。desc & lt;/span>。]ExportDefaultClassAppExtendsComponent { render(){ return(& lt;div>。{ innerchildren } & lt/div>。)}}
3.提供
关键词:过程控制,差异准备
首先说明一下,这里的渲染模块一般指的是整个过程中把vnode插入dom树的操作。然而,这种操作的一些工作是由diff模块承担的,所以实际上,渲染模块更负责过程控制和进入diff的准备工作。
过程控制
所谓过程控制分为两部分,即判断节点类型是自定义组件还是原生dom节点,判断渲染类型是第一次渲染还是更新操作。根据不同的情况,指定不同的渲染路线,执行相应的生命周期方法、钩子函数和渲染逻辑。
Diff就绪
如前所述,Preact在内存中只维护一个内容更新的新虚拟dom树,另一个表示更新的旧虚拟dom树实际上是从dom树中恢复的。同时,dom树的更新操作是在比较的同时进行修补的。为了保证以上操作不被混淆,在生成/更新dom树之前,需要给dom节点添加一些自定义属性来记录状态。
//创建自定义属性记录export functionrendercomponent(component,opts,mountall,ischild) {if (component。_ disable)返回;letskip,rendered,props=component.props,state=component.state,context=component.context,previous props = component . previous props | | props,previous state = component . previous context | | state,previous context = component . previous | context,isUpdate=component.base,nextBase=component.nextBase,initialBase=isUpdate||nextBase,initialBase_component,inst,cbase4.Diff算法
关键词:DOM依赖,断开或不断开,文档片段
Diff流程主要分为两个阶段。第一阶段是建立虚拟节点和dom Node之间的对应关系,第二阶段是比较它们并更新dom Node。
在实际执行过程中,diff操作的起点是update组件的根节点和表示其下一个状态的vnode之间的比较。在这一步中,它们之间的对应关系是非常明确的,但是在下一步中,需要确定它们的子元素之间的对应关系。具体方法是先将键值相同的子节点配对,然后再将同类型的节点配对。最后,未配对的vnode被视为新添加的节点,而单个dom节点的命运被回收。
进入更新阶段后,将根据虚拟节点的类型和dom树中的引用节点进行分类处理,并在diff过程中实时进行补丁操作,最终生成新的dom节点,然后递归子节点。
差异流程图的DOM依赖关系
前面介绍过了,相信大家对Preact的虚拟dom实现都有一定的了解,这里就不赘述了。这种实现的优点是总能真实地反映之前的虚拟dom树,缺点是存在内存泄漏的风险。
断开或未断开
断开是什么意思
众所周知,当我们对dom树中的节点执行removeChild操作时,每次执行都会触发一次页面的回流,这是一种代价高昂的行为。所以当我们要执行一系列这样的操作时,可以采取这样的优化方法,先创建一个节点,然后在这个节点上执行所有子节点的追加操作,然后将这个节点作为根节点的子树追加或者替换到dom树中一次,只触发一次回流就完成了整个子树的更新,这就叫做断开连接。
相反,创建一个节点后,立即将该节点插入到dom树中,然后继续子节点的操作,这叫做连通。
继续进行预认证
在明确了这个前提之后,我们再来看看Preact,Disconnected或者connected的实现,这是一个围城。虽然作者声称Preact的渲染方法是脱节的,但事情的真相是,并不总是如此。在一个简单的例子中,textnode的值被修改或者旧节点被textnode替换。Preact做的是创建一个textnode或者修改上一个textnode的nodeValue。虽然纠结这个场景没有意义,但是为了完整的介绍diff过程,还是要先说明一下。言归正传。先看第一个例子。为了说明这个问题,我们用一个稍微极端的例子。
在这个例子中,我们可以看到在输入文本后,有一个从div子树到section子树的更新。为了描述一个极端情况,更新前后的子节点是相同的。
//示例1:占位符所在的子树只有不同的根节点。从“preact”导入{h,Component };exportDefaultClassappextendsComponent { constructor(){ super();this . state = { text:' ' } } handlechang = e = & gt。{ this . SetState({ text:e . target . value })} render({ desc },{ text }){ return(& lt;div>。& ltinput value = { text } OnChange = { this . handlechang }/& gt。{text?& ltsectionkey='placeholder'>。& lth2>。占位符<。/h2>。& lt/section>。:& ltdiv键= '占位符' >;& lth2>。占位符<。/h2>。& lt/div>。} & lt/div>。)}}
接下来,看看这个场景的diff操作的详细流程。
//idiff logic le tout = DOM of native DOM,//annotation 1 nodename = string(vnode . nodename),prevsvgmode = issvgmode,vchildren = vnode.childrenisSvgMode=nodeName==='svg '?true:nodeName==='foreignObject '?false:ISsvg mode;if(!DOM){//Note 2 out = create node(nodename,issvgmode);}elseif(!IsNamedNode(dom,nodeName)){//comment 3 out = create node(nodeName,issvgmode);while(dom.firstChild)out。(DOM . FirstChild);if(DOM . parent node)DOM . parent node . replace child(out,DOM);RecolkeNodeTree(DOM);}//子节点递归...else if(vchildren & amp;vchildren . length | | fc){ innerDiffNode(out,vchildren,context,mount all);}……
不管参与diff的元素是自定义组件还是原生dom,最后都是以解构后的dom形式进行比较。因此,我们只需要关注本机dom的diff逻辑。
首先看一下注1的位置。dom表示dom树上的节点,即要更新的节点,vnode是要呈现的虚拟节点。例1中diff的起点是最外层的div,也就是第一轮的dom变量,所以注2和注3的判断都是假的。之后,out节点的子节点和对应vnode的子节点递归地不同。
然后,这里说明第一个问题。渲染操作的起点始终是连通的。
if(vlen){ for(leti = 0;i<。vleni++){ vchild = vchildren[I];child = nullletkey = vchild.key//相同的键值匹配if(key!= null){ if(KeyEdlen & amp;& ampkeyinked){ child = keyed[key];keyed[key]= undefined;key edlen-;} }//同一个nodeName匹配elseif(!儿童和青少年。& amp最小<。children len){ for(j = min;j<。童装;j++){ c = children[j];if(c & amp;& ampisSameNodeType(c,VC hild)){ child = c;children[j]= undefined;if(j = = = children len-1)children len-;if(j = = = min)min++;打破;} } }//当vnode为节节点时,dom树中既没有相同的键节点,也没有相同的nodeName节点,因此为null child = idiff (child,vchild,context,mount all);……
子节点之间对应关系的建立基础要么是相同的键值,要么是相同的节点名。可以知道,截面与div的关系不满足上述两个条件。因此,当再次输入idiff方法时,将在Note 2的位置创建一个新的节节点,因为dom不存在,它将被分配给out。当再次进行子元素diff时,因为out是一个新节点,不包含任何子元素,所以diff节的所有子元素的对象都为空,这意味着最终会创建该节的所有子元素(不管是否设置了键值),即使它们与旧dom上的节点相同。。。所以综上所述,就是例1的情况,段的所有子节点都是新建的,不是重用的,而是整个操作过程都是在断开的情况下进行的。
如果两者添加相同的键值会怎样?
//例2,组件结构是一样的,唯一的区别是把从‘preact’导入的相同键值{h,Component }添加到占位符所在的子树中;exportDefaultClassappextendsComponent { constructor(){ super();this . state = { text:' ' } } handlechang = e = & gt。{ this . SetState({ text:e . target . value })} render({ desc },{ text }){ return(& lt;div>。& ltinput value = { text } OnChange = { this . handlechang }/& gt。{text?& ltsectionkey='placeholder'>。& lth2>。占位符<。/h2>。& lt/section>。:& ltdiv键= '占位符' >;& lth2>。占位符<。/h2>。& lt/div>。} & lt/div>。)}}
因为它们有相同的键值,在确定了vnode和dom的对应关系后,就可以成功配对,进入diff链接。但是,替换操作会使所有后续操作连接起来。好消息是相同的子节点被重用。
//原生dom // dom节点的diff逻辑,即div存在,与vnode节点类型section type elseif(!isNamedNode(dom,nodeName)){ out = create node(nodeName,isSvgMode);while(dom.firstChild)out。(DOM . FirstChild);if(DOM . parent node)DOM . parent node . replace child(out,DOM);RecolkeNodeTree(DOM);}DocumentFragment
除了上述断开连接的方法,还可以通过DocumentFragment一次将一系列节点插入到dom中。当文档片段节点被插入到文档树中时,它不是文档片段本身,而是它的所有后代节点。这使得DocumentFragment成为一个有用的占位符,临时存储插入文档一次的节点。github上也有人问了作者同样的问题。作者说,他曾试图通过DocumentFragment的方式减少回流的次数,但最终的结果令人惊讶。
上图是作者写的测试用例性能对比图,横坐标是每秒操作数。值越大,执行效率越高。可以看出,无论是连接还是断开,DocumentFragement的性能都比较差。具体原因还有待研究。BenchMark的原始链接。
5.回收机制
关键词:回收池&增强安装
回收池&增强安装
当一个节点从dom中移除时,它不会被直接删除,而是根据节点类型(组件或节点)执行一些清理逻辑后,存储在两个回收池中。每次执行装载操作时,创建方法都会在回收池中查找相同类型的节点。一旦找到这种相同类型的节点,它们将作为要更新的参考节点被传递到diff算法中,以便在随后的比较过程中,来自回收池的节点将作为原型被修补以生成新节点。相当于把Mount改成Update,从而避免了从零开始构建的额外开销。
现实结局往往不如童话,回收机制终于出事。在犯罪现场的传输门口,恢复机制在某些情况下会导致节点的错误重用...因此,就像发炎的阑尾一样,恢复机制可能很快就会从我们的视线中消失。
标签
本文重点介绍了Preact的工作流程以及各个模块的一些工作细节,希望能够吸引更多的人参与到社区交流中来。欢迎对文章内容感兴趣的朋友随时联系我。如果网上交流不畅,你可以把简历发到colaz1667@163.com。我能想到的最浪漫的事,就是一路陪你收集一点点笑点,然后坐在工作站上慢慢聊。
1.《preact Preact:一个备胎的自我修养》援引自互联网,旨在传递更多网络信息知识,仅代表作者本人观点,与本网站无关,侵删请联系页脚下方联系方式。
2.《preact Preact:一个备胎的自我修养》仅供读者参考,本网站未对该内容进行证实,对其原创性、真实性、完整性、及时性不作任何保证。
3.文章转载时请保留本站内容来源地址,https://www.lu-xu.com/shehui/1595435.html