从零开始实现一个React(三):diff算法

前端之家收集整理的这篇文章主要介绍了从零开始实现一个React(三):diff算法前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。

前言

上一篇文章,我们已经实现了React的组件功能,从功能的角度来说已经实现了React的核心功能了。

但是我们的实现方式有很大的问题:每次更新都重新渲染整个应用或者整个组件,DOM操作十分昂贵,这样性能损耗非常大。

为了减少DOM更新,我们需要找渲染前后真正变化的部分,只更新这一部分DOM。而对比变化,找出需要更新部分的算法我们称之为diff算法

对比策略

在前面两篇文章后,我们实现了一个render方法,它能将虚拟DOM渲染成真正的DOM,我们现在就需要改进它,让它不要再傻乎乎地重新渲染整个DOM树,而是找出真正变化的部分。

这部分很多类React框架实现方式都不太一样,有的框架会选择保存上次渲染的虚拟DOM,然后对比虚拟DOM前后的变化,得到一系列更新的数据,然后再将这些更新应用到真正的DOM上。

但也有一些框架会选择直接对比虚拟DOM和真实DOM,这样就不需要额外保存上一次渲染的虚拟DOM,并且能够一边对比一边更新,这也是我们选择的方式。

不管是DOM还是虚拟DOM,它们的结构都是一棵树,完全对比两棵树变化的算法时间复杂度是O(n^3),但是考虑到我们很少会跨层级移动DOM,所以我们只需要对比同一层级的变化。


只需要对比同一颜色框内的节点

总而言之,我们的diff算法有两个原则:

  • 对比当前真实的DOM和虚拟DOM,在对比过程中直接更新真实DOM
  • 只对比同一层级的变化

实现

我们需要实现一个diff方法,它的作用是对比真实DOM和虚拟DOM,最后返回更新后的DOM

  1. /**
  2. * @param {HTMLElement} dom 真实DOM
  3. * @param {vnode} vnode 虚拟DOM
  4. * @returns {HTMLElement} 更新后的DOM
  5. */
  6. function diff( dom,vnode ) {
  7. // ...
  8. }

接下来就要实现这个方法
在这之前先来回忆一下我们虚拟DOM的结构:
虚拟DOM的结构可以分为三种,分别表示文本、原生DOM节点以及组件。

  1. // 原生DOM节点的vnode
  2. {
  3. tag: 'div',attrs: {
  4. className: 'container'
  5. },children: []
  6. }
  7.  
  8. // 文本节点的vnode
  9. "hello,world"
  10.  
  11. // 组件的vnode
  12. {
  13. tag: ComponentConstrucotr,children: []
  14. }

对比文本节点

首先考虑最简单的文本节点,如果当前的DOM就是文本节点,则直接更新内容,否则就新建一个文本节点,并移除掉原来的DOM。

  1. // diff text node
  2. if ( typeof vnode === 'string' ) {
  3.  
  4. // 如果当前的DOM就是文本节点,则直接更新内容
  5. if ( dom && dom.nodeType === 3 ) { // nodeType: https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
  6. if ( dom.textContent !== vnode ) {
  7. dom.textContent = vnode;
  8. }
  9. // 如果DOM不是文本节点,则新建一个文本节点DOM,并移除掉原来的
  10. } else {
  11. out = document.createTextNode( vnode );
  12. if ( dom && dom.parentNode ) {
  13. dom.parentNode.replaceChild( out,dom );
  14. }
  15. }
  16.  
  17. return out;
  18. }

文本节点十分简单,它没有属性,也没有子元素,所以这一步结束后就可以直接返回结果了。

对比非文本DOM节点

如果vnode表示的是一个非文本的DOM节点,那就要分几种情况了:
如果真实DOM和虚拟DOM的类型不同,例如当前真实DOM是一个div,而vnode的tag的值是'button',那么原来的div就没有利用价值了,直接新建一个button元素,并将div的所有子节点移到button下,然后用replaceChild方法将div替换成button。

  1. if ( !dom || dom.nodeName.toLowerCase() !== vnode.tag.toLowerCase() ) {
  2. out = document.createElement( vnode.tag );
  3.  
  4. if ( dom ) {
  5. [ ...dom.childNodes ].map( out.appendChild ); // 将原来的子节点移到新节点下
  6.  
  7. if ( dom.parentNode ) {
  8. dom.parentNode.replaceChild( out,dom ); // 移除掉原来的DOM对象
  9. }
  10. }
  11. }

如果真实DOM和虚拟DOM是同一类型的,那我们暂时不需要做别的,只需要等待后面对比属性和对比子节点。

对比属性

实际上diff算法不仅仅是找出节点类型的变化,它还要找出来节点的属性以及事件监听的变化。我们将对比属性单独拿出来作为一个方法

  1. function diffAttributes( dom,vnode ) {
  2.  
  3. const old = dom.attributes; // 当前DOM的属性
  4. const attrs = vnode.attrs; // 虚拟DOM的属性
  5.  
  6. // 如果原来的属性不在新的属性当中,则将其移除掉(属性值设为undefined)
  7. for ( let name in old ) {
  8.  
  9. if ( !( name in attrs ) ) {
  10. setAttribute( dom,name,undefined );
  11. }
  12.  
  13. }
  14.  
  15. // 更新新的属性
  16. for ( let name in attrs ) {
  17.  
  18. if ( old[ name ] !== attrs[ name ] ) {
  19. setAttribute( dom,attrs[ name ] );
  20. }
  21.  
  22. }
  23.  
  24. }

setAttribute方法的实现参见第一篇文章

对比子节点

节点本身对比完成了,接下来就是对比它的子节点。
这里会面临一个问题,前面我们实现的不同diff方法,都是明确知道哪一个真实DOM和虚拟DOM对比,但是子节点是一个数组,它们可能改变了顺序,或者数量有所变化,我们很难确定要和虚拟DOM对比的是哪一个。
为了简化逻辑,我们可以让用户提供一些线索:给节点设一个key值,重新渲染时对比key值相同的节点。

  1. // diff方法
  2. if ( vnode.children && vnode.children.length > 0 || ( out.childNodes && out.childNodes.length > 0 ) ) {
  3. diffChildren( out,vnode.children );
  4. }
  1. function diffChildren( dom,vchildren ) {
  2.  
  3. const domChildren = dom.childNodes;
  4. const children = [];
  5.  
  6. const keyed = {};
  7.  
  8. // 将有key的节点和没有key的节点分开
  9. if ( domChildren.length > 0 ) {
  10. for ( let i = 0; i < domChildren.length; i++ ) {
  11. const child = domChildren[ i ];
  12. const key = child.key;
  13. if ( key ) {
  14. keyedLen++;
  15. keyed[ key ] = child;
  16. } else {
  17. children.push( child );
  18. }
  19. }
  20. }
  21.  
  22. if ( vchildren && vchildren.length > 0 ) {
  23.  
  24. let min = 0;
  25. let childrenLen = children.length;
  26.  
  27. for ( let i = 0; i < vchildren.length; i++ ) {
  28.  
  29. const vchild = vchildren[ i ];
  30. const key = vchild.key;
  31. let child;
  32.  
  33. // 如果有key,找到对应key值的节点
  34. if ( key ) {
  35.  
  36. if ( keyed[ key ] ) {
  37. child = keyed[ key ];
  38. keyed[ key ] = undefined;
  39. }
  40.  
  41. // 如果没有key,则优先找类型相同的节点
  42. } else if ( min < childrenLen ) {
  43.  
  44. for ( let j = min; j < childrenLen; j++ ) {
  45.  
  46. let c = children[ j ];
  47.  
  48. if ( c && isSameNodeType( c,vchild ) ) {
  49.  
  50. child = c;
  51. children[ j ] = undefined;
  52.  
  53. if ( j === childrenLen - 1 ) childrenLen--;
  54. if ( j === min ) min++;
  55. break;
  56.  
  57. }
  58.  
  59. }
  60.  
  61. }
  62.  
  63. // 对比
  64. child = diff( child,vchild );
  65.  
  66. // 更新DOM
  67. const f = domChildren[ i ];
  68. if ( child && child !== dom && child !== f ) {
  69. if ( !f ) {
  70. dom.appendChild(child);
  71. } else if ( child === f.nextSibling ) {
  72. removeNode( f );
  73. } else {
  74. dom.insertBefore( child,f );
  75. }
  76. }
  77.  
  78. }
  79. }
  80.  
  81. }

对比组件

如果vnode是一个组件,我们也单独拿出来作为一个方法:

  1. function diffComponent( dom,vnode ) {
  2.  
  3. let c = dom && dom._component;
  4. let oldDom = dom;
  5.  
  6. // 如果组件类型没有变化,则重新set props
  7. if ( c && c.constructor === vnode.tag ) {
  8. setComponentProps( c,vnode.attrs );
  9. dom = c.base;
  10. // 如果组件类型变化,则移除掉原来组件,并渲染新的组件
  11. } else {
  12.  
  13. if ( c ) {
  14. unmountComponent( c );
  15. oldDom = null;
  16. }
  17.  
  18. c = createComponent( vnode.tag,vnode.attrs );
  19.  
  20. setComponentProps( c,vnode.attrs );
  21. dom = c.base;
  22.  
  23. if ( oldDom && dom !== oldDom ) {
  24. oldDom._component = null;
  25. removeNode( oldDom );
  26. }
  27.  
  28. }
  29.  
  30. return dom;
  31.  
  32. }

下面是相关的工具方法的实现,和上一篇文章的实现相比,只需要修改renderComponent方法其中的一行。

  1. function renderComponent( component ) {
  2. // ...
  3.  
  4. // base = base = _render( renderer ); // 将_render改成diff
  5. base = diff( component.base,renderer );
  6.  
  7. // ...
  8. }

完整diff实现看这个文件

渲染

现在我们实现了diff方法,我们尝试渲染上一篇文章中定义的Counter组件,来感受一下有无diff方法的不同。

  1. class Counter extends React.Component {
  2. constructor( props ) {
  3. super( props );
  4. this.state = {
  5. num: 1
  6. }
  7. }
  8.  
  9. onClick() {
  10. this.setState( { num: this.state.num + 1 } );
  11. }
  12.  
  13. render() {
  14. return (
  15. <div>
  16. <h1>count: { this.state.num }</h1>
  17. <button onClick={ () => this.onClick()}>add</button>
  18. </div>
  19. );
  20. }
  21. }

不使用diff

使用上一篇文章的实现,从chrome的调试工具中可以看到,闪烁的部分是每次更新的部分,每次点击按钮,都会重新渲染整个组件。

使用diff

而实现了diff方法后,每次点击按钮,都只会重新渲染变化的部分。

后话

在这篇文章中我们实现了diff算法,通过它做到了每次只更新需要更新的部分,极大地减少了DOM操作。React实现远比这个要复杂,特别是在React 16之后还引入了Fiber架构,但是主要的思想是一致的。

实现diff算法可以说性能有了很大的提升,但是在别的地方仍然后很多改进的空间:每次调用setState后会立即调用renderComponent重新渲染组件,但现实情况是,我们可能会在极短的时间内多次调用setState。
假设我们在上文的Counter组件中写出了这种代码

  1. onClick() {
  2. for ( let i = 0; i < 100; i++ ) {
  3. this.setState( { num: this.state.num + 1 } );
  4. }
  5. }

那以目前的实现,每次点击都会渲染100次组件,对性能肯定有很大的影响。
下一篇文章我们就要来改进setState方法

这篇文章代码https://github.com/hujiulong/...

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有四篇,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/...
关注点star,订阅点watch

上一篇文章

从零开始实现一个React(二):组件和生命周期

猜你在找的React相关文章