编程语言
首页 > 编程语言> > react 发展过程——react diff算法闲谈

react 发展过程——react diff算法闲谈

作者:互联网

背景

 

tree diff

在页面的每一层节点,都需要进行对比,整颗DOM树从上倒下,对比一遍以后,所有需要被替换,需要更新的元素 必然会被找出来!

component diff

在对比DOM树的每一层的时候,对每个组件,进行比较: 1、如果对比前后,组件的类型相同,则暂时认为组件不需要去被更新 2、如果对比前后,组件的类型不同,则移除旧组件,创建新组件,并把组件替换到原来的位置;

element diff

组件中,元素级别 传统Diff和React Diff对比 传统 diff 算法的复杂度为 O(n^3),显然这是无法满足性能要求的。React 通过制定大胆的策略,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。  

原理概述

1、找到相同的前置元素和后置元素 A: -> [a b c d] B: -> [a b d]   A 和 B 的不同就是多了一个 c 元素,把 c 元素移除这一步可以完成 diff,相反地,如果这步操作 A 为空,B 有多余元素,那么我们就将多余元素插入即可。   更为复杂的一种情况: A: -> [b c d e f] 旧数组 B: -> [c b h f e] 新数组   这个时候我们需要去创建一个位置数组P去记录新数组应该被插入的位置 P: [. . . . .] // . 代表 -1 当数组足够小时,直接去遍历数组,如果数组太大,需要去建个索引 I: { c: 0, b: 1, h: 2, f: 3, e: 4, } 在这过程中,也需要去维护一个变量 last,它代表访问过的节点在新集合中最右的位置(即最大的位置)。如果新集合中当前访问的节点比 last 大,说明当前访问节点在旧集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不必执行移动操作。只有当访问的节点比 last 小时,才需要进行移动操作 >> >> A: [b c d e f] >> ^ >> B: [c b h f e] >> P: [1 0 . . .] // . == -1 >> I: { >> c: 0, <- >> b: 1, >> h: 2, >> f: 3, >> e: 4, >> } >> last = 1 // last > 0; moved = true >> 当c在新数组的位置比last小,这个时候我们需要进行移动操作 这个方案其实也是stack reconciler的核心方案:   当type是class or func时, 在思考react首次装载的过程中,ReactDOM.render(<App />, root),ReactDOM会将<App />传递给协调器,<App />可以是一个react元素,其实也是一个普通的原型对象(type: App, props: {} ),协调器(reconciler)会检查 App是类还是函数。如果 App 是函数,协调器会调用App(props)来获取所渲染的元素。如果App是类,协调器则会使用new App(props)创建一个App实例,调用 componentWillMount() 生命周期方法,进而调用 render() 方法来获取所渲染的元素。无论如何,协调器都会学习App元素的渲染方式. 在这个渲染的过程中,不断通过递归的方式向下钻取去查找,<App /> 可能被渲染成<List />,进而 渲染成<Item /> ... <Button />等.   // 判断react元素是否是一个class function isClass(type) { return ( Boolean(type.prototype) && Boolean(type.prototype.isReactComponent) ); } // 定义一个func mounting 不断地去递归装载 function mount(element) { let type = element.type; let props = element.props; let renderEle; if(isClass(type)) { let instance =new type(props); instance.props = props; // 调用react 的lift 周期,如果存在这个周期中 if(instance.componentWillMount) { instance.componentWillMount(); } //进而调用render函数去渲染 renderEle = instance.render(); }else{ // 当然这是一个组件函数 renderEle=type(props); } return mount(renderEle); //注这里不断通过递归去返回相应的ele,当然这里只是通过伪代码,没有处理到div和p这一层级的元素,同时这里会不断循环 } “装载”(Mounting)是一个递归过程 当type是string 时 说明该元素是一个主机元素 function mountHost(element) { var type = element.type; var props = element.props; var children = props.children || []; if (!Array.isArray(children)) { children = [children]; } children = children.filter(Boolean);   // 该代码块不可出现在协调器(reconciler)中。 // 不同渲染器(renderers)可能会以不同方式初始化节点。 // 例如,React Native会生成iOS或Android视图。 var node = document.createElement(type); Object.keys(props).forEach(propName => { if (propName !== 'children') { node.setAttribute(propName, props[propName]); } });   // 装载子节点 children.forEach(childElement => { // 子节点有可能是主机元素(如<div />)或复合元素(如<Button />). // 所以我们应该递归的装载 var childNode = mount(childElement);   // 此行代码仍是特定于渲染器的。不同的渲染器则会使用不同的方法 node.appendChild(childNode); });   // 返回DOM节点作为装载结果 // 此处即为递归结束. return node; }   function mount(element) { var type = element.type; if (typeof type === 'function') { // 用户定义的组件 return mountComposite(element); } else if (typeof type === 'string') { // 平台相关的组件,比如说浏览器中的div,ios和安卓中的视图 return mountHost(element); } }   var rootEl = document.getElementById('root'); var node = mount(<App />); rootEl.appendChild(node); class CompositeComponent {   卸载组件   unmount() { // Call the lifecycle hook if necessary var publicInstance = this.publicInstance; if (publicInstance) { if (publicInstance.componentWillUnmount) { publicInstance.componentWillUnmount(); } }   // Unmount the single rendered component var renderedComponent = this.renderedComponent; renderedComponent.unmount(); } } 在这过程中会涉及到一些事件侦听器,同时会清除旧树上的钩子,例如componentWillUnmount   更新组件 在更新组件的时候,会去判断element type是否一样,如果一样则适当更新,否则销毁后重建,也就是卸载现有的内部实例并挂载对应于渲染的元素类型的新实例 if (prevRenderedElement.type === nextRenderedElement.type) { prevRenderedComponent.receive(nextRenderedElement); return; } 当旧的组件和新的组件的type不一致时,对旧的组件销毁然后插入 //模拟 var prevNode = prevRenderedComponent.getHostNode(); // 之前的旧组件进行销毁 prevRenderedComponent.unmount(); //创建新的组件 var nextRenderedComponent = instantiateComponent(nextRenderedElement); var nextNode = nextRenderedComponent.mount();   // Replace the reference to the child this.renderedComponent = nextRenderedComponent; // 组件替换 prevNode.parentNode.replaceChild(nextNode, prevNode); } }   组件对于内部实例的数组相关操作 // // 这些是React元素(element)数组: var prevChildren = prevProps.children || []; if (!Array.isArray(prevChildren)) { prevChildren = [prevChildren]; } var nextChildren = nextProps.children || []; if (!Array.isArray(nextChildren)) { nextChildren = [nextChildren]; } // 这些是内部实例(internal instances)数组: var prevRenderedChildren = this.renderedChildren; var nextRenderedChildren = [];   // 当我们遍历children时,我们将向数组中添加操作。 var operationQueue = [];   // 注意:以下章节大大减化! // 它不处理reorders,空children,或者keys。 // 它只是用来解释整个流程,而不是具体的细节。   for (var i = 0; i < nextChildren.length; i++) { // 尝试为这个子级获取现存内部实例。 var prevChild = prevRenderedChildren[i];   // 如果在这个索引下没有内部实例,那说明是一个child被添加了末尾。 // 这时应该去创建一个内部实例,挂载它,并使用它的节点。 if (!prevChild) { var nextChild = instantiateComponent(nextChildren[i]); var node = nextChild.mount();   // 记录一下我们将来需要append一个节点(node) operationQueue.push({type: 'ADD', node}); nextRenderedChildren.push(nextChild); continue; }   // 如果它的元素类型匹配,我们只需要更新该实例即可 // 例如, <Button size="small" /> 可以更新为 // <Button size="large" /> 但是不能被更新为 <App />. var canUpdate = prevChildren[i].type === nextChildren[i].type;   // 如果我们不能更新现有的实例,我们就必须卸载它。然后装一个新的替代它。 if (!canUpdate) { var prevNode = prevChild.getHostNode(); prevChild.unmount();   var nextChild = instantiateComponent(nextChildren[i]); var nextNode = nextChild.mount();   // 记录一下我们将来需要替换这些nodes operationQueue.push({type: 'REPLACE', prevNode, nextNode}); nextRenderedChildren.push(nextChild); continue; }   // 如果我们可以更新现存的内部实例(internal instance), // 我们仅仅把下一个元素传入其receive即可,让其receive函数处理它的更新即可 prevChild.receive(nextChildren[i]); nextRenderedChildren.push(prevChild); }   // 最后,卸载(unmount)哪些不存在的children for (var j = nextChildren.length; j < prevChildren.length; j++) { var prevChild = prevRenderedChildren[j];     var node = prevChild.getHostNode(); prevChild.unmount();   // 记录一下我们将来需要remove这些node operationQueue.push({type: 'REMOVE', node}); }   // Point the list of rendered children to the updated version. this.renderedChildren = nextRenderedChildren;   // ...   当组件不是通过挂载,而是通过接收一个元素,并且这发生在元素的key变化时,当然这是一种新的情况   Key分析 针对于下面代码块 //old tree <ul> <li>first</li> <li>second</li> </ul> //new tree <ul> <li>first</li> <li>second</li> <li>third</li> </ul> React在迭代遍历<li>first</li>树和<li>second</li>树时,因为前面两项是相同的,在插入<li>third</li>树时,React表现出来的性能不会太差,如果是下面这种情况下: //old tree <ul> <li>Duke</li> <li>Villanova</li> </ul> //new tree <ul> <li>Connecticut</li> <li>Duke</li> <li>Villanova</li> </ul> React就会去不断去替换更新组件 <ul> <li key="1">Duke</li> <li key="2">Villanova</li> </ul> //new tree <ul> <li key="1">Connecticut</li> <li key="2">Duke</li> <li key="3">Villanova</li> </ul> 通过key值React比较<li key="2">Duke</li>与<li key="1">Connecticut</li>时,会发现key值是不同,表示<li key="1">Connecticut</li>是新插入的项,因此会在开始出插入<li key="1">Connecticut</li>,随后分别比较<li key="2">Duke</li>与<li key="3">Villanova</li>,发现li项没有发生改变,仅仅只是被移动而已。这种情况下,性能的提升是非常可观的。因此,从上面看key值必须要稳定、可预测的并且是唯一的 如果是相同的key值,会出现: [1, 1, 2, 2].map((val, index) => { return ( <Demo key={val} value={val + '-' + index} /> ) }) } 渲染后 <ul> { [ <li key={1}>1</li>, <li key={2}>2</li> ] } </ul> 因为React会认为key值相同的元素是同一个元素 思考: 子元素的传入以数组的形式传入第三个参数,但是在第二个场景中,子元素是以参数的形式依次传入的。在第二种场景中,每个元素出现在固定的参数位置上,React就是通过这个位置作为天然的key值去判别的,所以你就不用传入key值的,但是第一种场景下,以数组的类型将全部子元素传入,React就不能通过参数位置的方法去判别,所以就必须你手动地方式去传入key值 当然这启发式算法,使之时间复杂度由O(n3次方)降低为O(n) 下面是JSX模版编译成JS对象后的结果 //第一种情况 function App() { return React.createElement('ul',null,[ React.createElement('li',{key: 1}, "1"), React.createElement('li',{key: 2}, "2") ]) } //第二种情况 function App() { return React.createElement('ul', null, React.createElement('li',{key: 1}, "1"), React.createElement('li',{key: 2}, "2") ) }   上面表述是关于stack协调器,但在react 16后已经使用了fiber协调器代替stack协调器 Fiber协调器 Fiber是其最新实现。归功于它的底层架构,它提供能力去实现许多有趣的特性,比如执行非阻塞渲染,根据优先级执行更新,在后台预渲染内容.这也是时间分片. 我们可以去假设一种情况:如果React要同步遍历整个组件树并为每个组件执行任务,它可能会运行超过16毫秒,以便应用程序代码执行其逻辑。这将导致帧丢失,导致不顺畅的视觉效果. 因为有时候我们不希望JS不受控制地长时间执行(想要手动调度),那么,为什么JS长时间执行会影响交互响应、动画? 这个时我们就会去想,是否可以闲时才去渲染,在保证既能执行其逻辑,又能有很好的交互体验.   为了解决这个问题,React必须重新实现遍历树的算法,从依赖于内置堆栈的同步递归模型,变为具有链表和指针的异步模型 堆栈在自然科学中,调用堆栈是一种堆栈数据结构,每一个活动子程序在其执行完成时应该返回其自己特定的位置,使其有迹可循. 循环遍历树会打印b2, c1, d1, d2, b3, c2 这种方式非常不适合中断,它有局限性。最大的一点就是我们无法分解工作为增量单元。我们不能暂停特定组件的工作并在稍后恢复.   Fiber 是一种单链表树遍历算法 自定义child(子节点)、sibling(兄弟节点)、return class Node { constructor(instance) { this.instance = instance; this.child = null; this.sibling = null; this.return = null; } }   function link(parent, elements) { if (elements === null) elements = [];   parent.child = elements.reduceRight((previous, current) => { const node = new Node(current); node.return = parent; node.sibling = previous; return node; }, null);   return parent.child; } //打印节点名字 function doWork(node) { console.log(node.instance.name); const children = node.instance.render(); return link(node, children); } //深度优先遍历 function walk(o) { let root = o; let current = o;   while (true) { // 为节点执行工作,获取并连接它的children let child = doWork(current); // 如果child不为空, 将它设置为当前活跃节点 if (child) { current = child; continue; } // 如果我们回到了根节点,退出函数 if (current === root) { return; } // 遍历直到我们发现兄弟节点 while (!current.sibling) { // 如果我们回到了根节点,退出函数 if (!current.return || current.return === root) { return; } // 设置父节点为当前活跃节点 current = current.return; } // 如果发现兄弟节点,设置兄弟节点为当前活跃节点 current = current.sibling; } } 因此,在instance相关扩展上新增了这些实例: DOM 真实DOM节点 ------- effect 每个workInProgress tree节点上都有一个effect list 用来存放diff结果 当前节点更新完毕会向上merge effect list(queue收集diff结果) - - - - workInProgress workInProgress tree是reconcile过程中从fiber tree建立的当前进度快照,用于断点恢复 - - - - fiber fiber tree与vDOM tree类似,用来描述增量更新所需的上下文信息 ------- Elements 描述UI长什么样子(type, props)   如果以组件为例: 1、如果当前节点不需要更新,直接把子节点clone过来,跳到5;要更新的话打个tag 2、更新当前节点状态(props, state, context等) 3、调用shouldComponentUpdate(),false的话,跳到5 4、调用render()获得新的子节点,并为子节点创建fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里) 5、如果没有产生child fiber,该工作单元结束,把effect list归并到return,并把当前节点的sibling作为下一个工作单元;否 则把child作为下一个工作单元 6、如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做 7、如果没有下一个工作单元了(回到了workInProgress tree的根节点),第1阶段结束,进入pendingCommit状态 其中current的赋值重新得到需要操作的节点,使其能够终止,值之前便可以'暂停'来执行其它逻辑.构建workInProgress tree的过程就是diff的过程,通过requestIdleCallback来调度执行一组任务,每完成一个任务后回来看看有没有插队的(更紧急的),每完成一组任务,把时间控制权交还给主线程,直到下一次requestIdleCallback回调再继续构建workInProgress tree 增量渲染其实是解决帧(在栈协调器在遍历每一个树节点都会产生相应的栈)问题,,渲染任务拆分之后,每次只做一小段,做完一段就把时间控制权交还给主线程,而不像之前长时间占用。这种策略也是一种cooperative scheduling(合作式调度)   在组件的生命周期, 主要分为两个阶段,第一个阶段由requestIdleCallback不断反复,默认设置为最低优先级 // 第1阶段 render/reconciliation componentWillMount componentWillReceiveProps shouldComponentUpdate componentWillUpdate // 第2阶段 commit componentDidMount componentDidUpdate componentWillUnmount   fiber tree与workInProgress tree fiber tree 和workinprogress tree 共存其实就是一种双缓存的一种策略以,fiber tree为主,workInProgress tree为辅,workInProgress tree构造完后就是会得到新的fiber tree,fiber tree 的指针指向workInProgress tree,复用内部对象(fiber),减少GC的时间开销   优先级策略 每个工作单元运行时有6种优先级: synchronous首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程。animation通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程;后3个都是由requestIdleCallback回调执行的;offscreen指的是当前隐藏的、屏幕外的(看不见的)元素 高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等。另外,紧急的事件允许插队 这样的优先级机制存在2个问题:   LIS算法    

标签:return,闲谈,tree,react,组件,var,diff,type,节点
来源: https://www.cnblogs.com/fuGuy/p/10618288.html