| 
                             
                              | 
                                   
                                    | 编辑推荐: |   
                                    | 本文着重介绍了Preact的工作流程以及其中各个模块的一些工作细节,希望可以达到抛砖引玉的作用。 本文来自于知乎,由火龙果软件Anna编辑推荐。
 |  |  前一段时间由于React Licence的问题,团队内部积极的探索React的替代方案,同时考虑到之后可能开展的移动端业务,团队目标是希望能够找到一个迁移成本低,体量小的替代产品。经过多方探索,Preact进入了我们的视野。从接触到Preact开始,一路学习下来折损了许多头发,也收获不少思考,这里想和大家介绍一下Preact的实现思路,也分享一下自己的思考所得。 Preact是什么 
                            一句话介绍Preact,它是React的3KB轻量替代方案,拥有同样的ES6 API。如果觉得就这么一句话太模糊的话,我还可以再啰嗦几句。Preact 
                            = performance + react,这是Preact名字的由来,其中一个performance足以窥见作者的用心。下面这张图反映了在长列表初始化的场景下,不同框架的表现,可以看出Preact确实性能出众。 高性能,轻量,即时生产是Preact关注的核心。基于这些主题,Preact关注于React的核心功能,实现了一套简单可预测的diff算法使它成为最快的虚拟 
                            DOM 框架之一,同时preact-compat为兼容性提供了保证,使得Preact可以无缝对接React生态中的大量组件,同时也补充了很多Preact没有实现的功能。 
 
                            长列表初始化时间对比 
                            Preact的工作流程 
                            简单介绍了Preact的前生今世以后,接下来说下Preact的工作流程,主要包含五个模块: component 
                            h函数 
                            render 
                            diff算法 
                            回收机制 
                            流转过程见下图。 首先是我们定义好的组件,在渲染开始的时候,首先会进入h函数生成对应的virtual node(如果是JSX编写,之前还需要一步转码)。每一个vnode中包含自身节点的信息,以及子节点的信息,由此而连结成为一棵virtual 
                            dom树。基于生成的vnode,render模块会结合当前dom树的情况进行流程控制,并为后续的diff操作做一些准备工作。Preact的diff算法实现有别于react基于双virtual 
                            dom树的思路,Preact只维持一棵新的virtual dom树,diff过程中会基于dom树还原出旧的virtual 
                            dom树,再将两者进行比较,并在比较过程中实时对dom树进行patch操作,最终生成新的dom树。与此同时,diff过程中被卸载的组件和节点不会被直接删除,而是被分别放入回收池中缓存,当再次有同类型的组件或节点被构建时,可以在回收池中找到同名元素进行改造,避免从零构建的开销。 
 
                            Preact工作流程图 
                            在了解了Preact的工作流程之后,接下来会对上文提到的五个模块一一解读。 1. Component 
                            关键词:hook,linkState, 批量更新 相信有过react开发经验的同学对component的概念都不会陌生,这里也不做过多解释,只是介绍一些Preact在component层面上的添加的新特性。 hook函数 
                            除了基本的生命周期函数外,Preact还提供三个hook函数,方便用户在指定的时间点执行统一操作。 afterMount 
                            afterUpdate 
                            beforeUnmount 
                            linkState  linkState针对的场景是在render方法中为用户操作的回调绑定this,这样每次渲染都在局部创建一个函数闭包,这样效率十分低下而且会迫使垃圾回收器做许多不必要的工作。linkState理想中的应用场景如下。 
                             
                              | export default 
                                class App extends Component { constructor() {
 super();
 this.state = {
 text: 'initial'
 }
 }
  handleChange = e => {this.setState({
 text: e.target.value
 })
 }
  render({desc}, {text}} {return (
 <div>
 <input value={text} onChange= {this.linkState('text', 
                                  'target.value')}>
 <div>{text}</div>
 </div>
 )
 }
 }
 |   然而linkState的实现方式。。。是在组件初始化的时候为每个回调创建闭包,绑定this,同时创建一个实例属性将绑定后回调函数缓存起来,这样再次render的时候就不需要再次绑定。实际效果等同于在组件的constructor中绑定。尴尬之处在于,linkState内部只实现了setState操作,同时也不支持自定义参数,使用场景比较有限。 
                             
                              | //linkState源码 //缓存回调
 linkState(key, eventPath) {
 let c = this._linkedStates || (this._linkedStates 
                                = {});
 return c[key+eventPath] || (c[key+eventPath] = 
                                createLinkedState(this, key, eventPath));
 }
 //首次注册回调的时候创建闭包export function createLinkedState(component, 
                                  key, eventPath) {
 let path = key.split('.');
 return function(e) {
 let t = e && e.target || this,
 state = {},
 obj = state,
 v = isString(eventPath) ? delve(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 && 
                                  component.state[path[i]] || {});
 }
 obj[path[i]] = v;
 component.setState(state);
 };
 }
 |  
                            批量更新 
                            Preact实现了组件的批量更新,具体实现思路就是每次执行state or props更新之时,对应的属性会被立刻更新,但是基于new 
                            state or props的渲染操作会被push进到一个更新队列中,在当前event loop的最后或者是在下一个event 
                            loop的开始,才会将队列中的操作一一执行。同一个组件状态的多次更新,不会重复进入队列。如下图所示,属性更新之后,组件渲染之前,_dirty值为true,因此,组件渲染之前后续的属性更新操作都不会使组件重复入队。 
                             
                              | //更新队列源码 export function enqueueRender(component) {
 if (!component._dirty && (component._dirty 
                                  = true) && items.push(component)==1) 
                                  {
 (options.debounceRendering || defer)(rerender);
 }
 }
 |  
                            2. h函数 
                            关键词:节点合并 h函数的作用如同React.CreateElement,用于生成virtual 
                            node。其接受的输入格式如下,三个参数分别为节点类型,节点属性,子元素。 
                             
                              | h('a', { href: 
                                  '/', h{'span', null, 'Home'}}) |    
                            节点合并  h函数在生成vnode的过程中,会对相邻的简单节点进行合并操作,目的是为了减少节点数量,减轻diff负担。 
                            请看下面的例子。 
                             
                              | import { h, 
                                  Component } from 'preact'; const innerinnerchildren = [['innerchild2', 
                                  'innerchild3'], 'innerchild4'];
 const innerchildren = [
 <div>
 {innerinnerchildren}
 </div>,
 <span>desc</span>
 ]
 export default class App extends Component 
                                  {render() {
 return (
 <div>
 {innerchildren}
 </div>
 )
 }
 }
 
 |    
 3. Render 
                            关键词:流程控制,diff准备 首先先解释一下,这里的render模块泛指整个流程中将vnode插入到dom树中的操作,然而这类操作中又有一部分工作被diff模块承担,所以实际上render模块的更多承担的是流程控制以及进入diff的前置工作。 流程控制 
                            所谓流程控制,具体的内容分为两部分,节点类型的判断,是自定义的组件还是原生的dom节点,渲染类型的判断,是首次渲染还是更新操作。根据不同情况,指定不同的渲染路线,执行相应的生命周期方法,hook函数和渲染逻辑。 Diff准备  如前所述,Preact在内存中只维持一棵包含更新内容的新的virtual 
                            dom树,另一个代表被更新的旧的virtual dom树实际上是从dom树还原回来的,与此同时,dom树的更新操作也是在比较过程中,一边比较一边patch的。为了确保上述操作不出现混乱,在生成/更新的dom树的之前,需要在dom节点上添加一些自定义的属性记录状态。 
                             
                              | //创建自定义属性记录 export function renderComponent (component, 
                                  opts, mountAll, isChild) {
 if (component._disable) return;
  let skip, rendered,props = component.props,
 state = component.state,
 context = component.context,
 previousProps = component.prevProps || props,
 previousState = component.prevState || state,
 previousContext = component.prevContext || context,
 isUpdate = component.base,
 nextBase = component.nextBase,
 initialBase = isUpdate || nextBase,
 initialChildComponent = component._component,
 inst, cbase;
 
 |  
                            4. Diff算法 
                            关键词:DOM依赖,Disconnected or Not,DocumentFragment diff过程主要分为两个阶段,第一个阶段是建立virual node与dom节点之间的对应关系,第二个阶段便是对两者进行比较并更新dom节点。 在实际执行过程中,diff操作的起点是update组件的根节点与代表其下一个状态的vnode之前的比较。这一步中两者之间的对应关系十分明确,而到了下一步,则需要在两者的子元素中确定对应关系,具体的方法是首先对相同key值的子节点配对,之后将同类型的节点配对,最后没有被配对的vnode视为新添加的节点,而落单的dom节点的命运则是被回收。  进入到更新阶段之后,会根据virtual node的类型和dom树中参照节点的情况分类处理,并在diff的过程中实时的进行patch操作,最终生成新的dom节点,然后对子节点递归。 
 Diff流程图 
                            DOM依赖 
                            经过前面的介绍,相信大家对Preact的virtual dom实现已经有了一定的了解,这里不再赘述。这种实现方式,优点在于总能真实的反映之前virtual 
                            dom树的情况,缺点就是存在内存泄露的风险。 Disconnected or Not 
                            What does Disconnected mean 
                            我们都知道,当我们向dom树中的节点执行appendChild,removeChild操作的时候,每执行一次,就会触发一次页面的reflow,这是一个具有相当开销的行为。因此当我们必须执行一系列这样的操作的时候,可以采取这样的优化手段,首先创建一个节点,在这个节点上执行过所有子节点的append操作之后,再将以这个节点作为根节点的子树一次性的append或者replace到dom树中,只触发一次reflow,就完成了整个子树的更新,这样的更新方式称之为disconnected。 与之相对,在创建节点之后,立刻将节点插入到dom树中,然后继续进行子节点的操作,则称之为connected。 Go ahead to Preact 
                            在阐明了这个前提之后,再来看Preact的实现方式,Disconnected or Connected,是一座围城。尽管作者声称Preact的渲染方式是disconnected,然而事实的真相是,not 
                            always true。 从一个简单的情况说起,textnode的值被修改或者旧的节点被替换成textnode。Preact所做的就是创建一个textnode或者修改之前textnode的nodeValue。虽然纠结这个场景是没有意义的,但是为了完整的介绍diff流程,有必要先说明一下。 
                            进入重点。先看第一个例子。为了说明问题,我们用一个稍微极端点的例子。 在这个例子中可以看到,当输入text之后,有一个div子树向section子树的更新,这里为了描述一个极端情况,更新前后的子节点是一样的。 
                             
                              | //例一 placeholder所在子树只有根节点不同 import { h, Component } from 'preact';
 export default class App extends Component 
                                  {constructor() {
 super();
 this.state = {
 text: ''
 }
 }
  handlechang = e => {this.setState({
 text: e.target.value
 })
 }
  render({desc}, { text }) {return (
 <div>
 <input value={text} onChange={this.handlechang}/>
 {text ? <section key='placeholder'>
 <h2>placeholder</h2>
 </section>: <div key='placeholder'>
 <h2>placeholder</h2>
 </div>}
 </div>
 )
 }
 }
 |   接下来看一下针对这种场景,diff操作的详细流程。 
                             
                              | //原生dom的idiff逻辑 let out = dom, //注释1
 nodeName = String(vnode.nodeName),
 prevSvgMode = isSvgMode,
 vchildren = vnode.children;
 isSvgMode = nodeName==='svg' ? true : nodeName==='foreignObject' 
                                  ? false : isSvgMode;
 if (!dom) { //注释2
 out = createNode(nodeName, isSvgMode);
 }
 else if (!isNamedNode(dom, nodeName)) { //注释3
 out = createNode(nodeName, isSvgMode);
 while (dom.firstChild) out.appendChild(dom.firstChild);
 if (dom.parentNode) dom.parentNode.replaceChild(out, 
                                  dom);
 recollectNodeTree(dom);
 }
 //子节点递归……
 else if (vchildren && vchildren.length 
                                  || fc) {
 innerDiffNode(out, vchildren, context, mountAll);
 }
 ……
 |  
                            无论参与diff的元素是自定义组件还是原生dom,经过层层解构,最终都是以dom的形式进行比较。因此我们只需要关注原生dom的diff逻辑。 首先看注释1的位置,dom表示dom树上的节点,也就是要被更新掉的节点,vnode就是待渲染的虚拟节点。在例一中,diff的起点就是最外层的div,也就是第一轮的dom变量,因此注释2,注释3处的判定均为false。之后会对out节点的子节点和对应的vnode的子节点进行递归的diff操作。 那么这里首先说明了第一处问题,渲染操作的起点始终是connected状态的。 
                             
                              | if (vlen) 
                                  { for (let i=0; i<vlen; i++) {
 vchild = vchildren[i];
 child = null;
  let key = vchild.key;// 相同key值匹配
 if (key!=null) {
 if (keyedLen && key in keyed) {
 child = keyed[key];
 keyed[key] = undefined;
 keyedLen--;
 }
 }
 // 相同nodeName匹配
 else if (!child && min<childrenLen) 
                                  {
 for (j=min; j<childrenLen; j++) {
 c = children[j];
 if (c && isSameNodeType(c, vchild)) 
                                  {
 child = c;
 children[j] = undefined;
 if (j===childrenLen-1) childrenLen--;
 if (j===min) min++;
 break;
 }
 }
 }
 // vnode为section节点时,dom树中既无同key节点,也无同nodeName节点,因此为null
 child = idiff(child, vchild, context, mountAll);
 ……
 |  
                            子节点之间的对应关系的确立依据,要么key值相同,要么nodeName相同,可以知道section和div的关系并不满足上述两种情况。因此当再次进入idiff方法的时候,在注释2的位置,由于dom不存在,会新建一个section节点赋给out,这样再次进行子元素diff的时候,由于out是一个新建节点,不包含任何子元素,section的所有子元素diff的对象都是null,这就意味这section的所有子元素最后都是被新建出来的(不论是否设置了key值),尽管它们和旧的dom上的节点一模一样。。。所以总结一下就是例一这种情况,section所有的子节点都是被新建出来的,而不是被复用的,但是整个操作过程是在disconnected情况下进行的。 
  那么如果给两者加上相同的key值呢? 
                             
                              | // 例二,组件结构相同,唯一的区别是placeholder所在子树添加了相同的key值 import { h, Component } from 'preact';
 export default class App extends Component 
                                  {constructor() {
 super();
 this.state = {
 text: ''
 }
 }
  handlechang = e => {this.setState({
 text: e.target.value
 })
 }
 render({desc}, { text }) {
 return (
 <div>
 <input value={text} onChange={this.handlechang}/>
 {text ? <section key='placeholder'>
 <h2>placeholder</h2>
 </section>: <div key='placeholder'>
 <h2>placeholder</h2>
 </div>}
 </div>
 )
 }
 }
 |   因为两者具有相同的key值,所以在vnode与dom确定对应关系时可以成功的配对,进入diff环节。然而一个replace操作又让后续的所有操作都变成了connected。好消息是相同的子节点被复用了。 
                             
                              | // 原生dom的diff逻辑 // dom节点,即div存在,且与vnode节点类型section不同类型
 else if (!isNamedNode(dom, nodeName)) {
 out = createNode(nodeName, isSvgMode);
 while (dom.firstChild) out.appendChild(dom.firstChild);
 if (dom.parentNode) dom.parentNode.replaceChild(out, 
                                  dom);
 recollectNodeTree(dom);
 }
 |  
                            DocumentFragment 
                            除去上面介绍过的disconnected方法,还可以通过DocumentFragment将一系列节点一次性插入dom。DocumentFragment 
                            节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。这使得 
                            DocumentFragment 成了有用的占位符,暂时存放那些一次插入文档的节点。github上也有人向作者提出了同样的问题,作者表示他曾经也尝试过用DocumentFragment的方式试图减少reflow的次数,然而最终的结果却令人意外。 
 
                            上图为作者编写的测试案例的性能对比图,横坐标为Operation per second,数值越大代表执行效率越高。可以看出无论connected还是disconnected的情况,DocumentFragement的表现都更差。具体原因还有待考究。BenchMark原链接。 5. 回收机制 
                            关键词:回收池&Enhanced Mount 回收池&Enhanced Mount 
                            在将节点从dom中移除时,不会将节点直接删除,而是会根据节点类型(组件 or node),执行一些清理逻辑之后,分别存入到两个回收池中。在每次执行Mount操作的时候,创建方法会在回收池里寻找同类型节点,一旦找到这样的同类节点,它会被作为待更新的参照节点传入diff算法中,这样再后续的比较过程中,来自回收池的节点会被作为原型进行patch改造,产生新的节点。相当于变Mount为Update,从而避免从零构建的额外开销。 现实的结局往往没有童话故事般美好,回收机制最终还是出现了意外。案发现场传送门,回收机制会在某些情况下导致节点被错误的复用……所以,如同发炎的阑尾,可能很快回收机制就会从我们的视线里消失了。   |