React diff算法的实现示例


Posted in Javascript onApril 20, 2018

前言

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

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

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

对比策略

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

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

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

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

React diff算法的实现示例

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

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

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

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

/**
 * @param {HTMLElement} dom 真实DOM
 * @param {vnode} vnode 虚拟DOM
 * @returns {HTMLElement} 更新后的DOM
 */
function diff( dom, vnode ) {
  // ...
}

接下来就要实现这个方法。

在这之前先来回忆一下我们虚拟DOM的结构:

虚拟DOM的结构可以分为三种,分别表示文本、原生DOM节点以及组件。

// 原生DOM节点的vnode
{
  tag: 'div',
  attrs: {
    className: 'container'
  },
  children: []
}

// 文本节点的vnode
"hello,world"

// 组件的vnode
{
  tag: ComponentConstrucotr,
  attrs: {
    className: 'container'
  },
  children: []
}

对比文本节点

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

// diff text node
if ( typeof vnode === 'string' ) {

  // 如果当前的DOM就是文本节点,则直接更新内容
  if ( dom && dom.nodeType === 3 ) {  // nodeType: https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
    if ( dom.textContent !== vnode ) {
      dom.textContent = vnode;
    }
  // 如果DOM不是文本节点,则新建一个文本节点DOM,并移除掉原来的
  } else {
    out = document.createTextNode( vnode );
    if ( dom && dom.parentNode ) {
      dom.parentNode.replaceChild( out, dom );
    }
  }

  return out;
}

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

对比非文本DOM节点

如果vnode表示的是一个非文本的DOM节点,那就要分几种情况了:

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

if ( !dom || dom.nodeName.toLowerCase() !== vnode.tag.toLowerCase() ) {
  out = document.createElement( vnode.tag );

  if ( dom ) {
    [ ...dom.childNodes ].map( out.appendChild );  // 将原来的子节点移到新节点下

    if ( dom.parentNode ) {
      dom.parentNode.replaceChild( out, dom );  // 移除掉原来的DOM对象
    }
  }
}

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

对比属性

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

function diffAttributes( dom, vnode ) {

  const old = dom.attributes;  // 当前DOM的属性
  const attrs = vnode.attrs;   // 虚拟DOM的属性

  // 如果原来的属性不在新的属性当中,则将其移除掉(属性值设为undefined)
  for ( let name in old ) {

    if ( !( name in attrs ) ) {
      setAttribute( dom, name, undefined );
    }

  }

  // 更新新的属性值
  for ( let name in attrs ) {

    if ( old[ name ] !== attrs[ name ] ) {
      setAttribute( dom, name, attrs[ name ] );
    }

  }

}

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

对比子节点

节点本身对比完成了,接下来就是对比它的子节点。

这里会面临一个问题,前面我们实现的不同diff方法,都是明确知道哪一个真实DOM和虚拟DOM对比,但是子节点是一个数组,它们可能改变了顺序,或者数量有所变化,我们很难确定要和虚拟DOM对比的是哪一个。

为了简化逻辑,我们可以让用户提供一些线索:给节点设一个key值,重新渲染时对比key值相同的节点。

// diff方法
if ( vnode.children && vnode.children.length > 0 || ( out.childNodes && out.childNodes.length > 0 ) ) {
  diffChildren( out, vnode.children );
}
function diffChildren( dom, vchildren ) {

  const domChildren = dom.childNodes;
  const children = [];

  const keyed = {};

  // 将有key的节点和没有key的节点分开
  if ( domChildren.length > 0 ) {
    for ( let i = 0; i < domChildren.length; i++ ) {
      const child = domChildren[ i ];
      const key = child.key;
      if ( key ) {
        keyedLen++;
        keyed[ key ] = child;
      } else {
        children.push( child );
      }
    }
  }

  if ( vchildren && vchildren.length > 0 ) {

    let min = 0;
    let childrenLen = children.length;

    for ( let i = 0; i < vchildren.length; i++ ) {

      const vchild = vchildren[ i ];
      const key = vchild.key;
      let child;

      // 如果有key,找到对应key值的节点
      if ( key ) {

        if ( keyed[ key ] ) {
          child = keyed[ key ];
          keyed[ key ] = undefined;
        }

      // 如果没有key,则优先找类型相同的节点
      } else if ( min < childrenLen ) {

        for ( let j = min; j < childrenLen; j++ ) {

          let c = children[ j ];

          if ( c && isSameNodeType( c, vchild ) ) {

            child = c;
            children[ j ] = undefined;

            if ( j === childrenLen - 1 ) childrenLen--;
            if ( j === min ) min++;
            break;

          }

        }

      }

      // 对比
      child = diff( child, vchild );

      // 更新DOM
      const f = domChildren[ i ];
      if ( child && child !== dom && child !== f ) {
        if ( !f ) {
          dom.appendChild(child);
        } else if ( child === f.nextSibling ) {
          removeNode( f );
        } else {
          dom.insertBefore( child, f );
        }
      }

    }
  }

}

对比组件

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

function diffComponent( dom, vnode ) {

  let c = dom && dom._component;
  let oldDom = dom;

  // 如果组件类型没有变化,则重新set props
  if ( c && c.constructor === vnode.tag ) {
    setComponentProps( c, vnode.attrs );
    dom = c.base;
  // 如果组件类型变化,则移除掉原来组件,并渲染新的组件
  } else {

    if ( c ) {
      unmountComponent( c );
      oldDom = null;
    }

    c = createComponent( vnode.tag, vnode.attrs );

    setComponentProps( c, vnode.attrs );
    dom = c.base;

    if ( oldDom && dom !== oldDom ) {
      oldDom._component = null;
      removeNode( oldDom );
    }

  }

  return dom;

}

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

function renderComponent( component ) {
  
  // ...

  // base = base = _render( renderer );     // 将_render改成diff
  base = diff( component.base, renderer );

  // ...
}

完整diff实现看这个文件

渲染

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

class Counter extends React.Component {
  constructor( props ) {
    super( props );
    this.state = {
      num: 1
    }
  }

  onClick() {
    this.setState( { num: this.state.num + 1 } );
  }

  render() {
    return (
      <div>
        <h1>count: { this.state.num }</h1>
        <button onClick={ () => this.onClick()}>add</button>
      </div>
    );
  }
}

不使用diff

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

React diff算法的实现示例

使用diff

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

React diff算法的实现示例

后话

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

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

假设我们在上文的Counter组件中写出了这种代码

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

那以目前的实现,每次点击都会渲染100次组件,对性能肯定有很大的影响。

下一篇文章我们就要来改进setState方法

这篇文章的代码:https://github.com/hujiulong/simple-react/tree/chapter-3

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
CSS+Table图文混排中实现文本自适应图片宽度(超简单+跨所有浏览器)
Feb 14 Javascript
Javascript解决常见浏览器兼容问题的12种方法
Jan 04 Javascript
如何让页面在打开时自动刷新一次让图片全部显示
Dec 17 Javascript
javascript利用apply和arguments复用方法
Nov 25 Javascript
jQuery中Dom的基本操作小结
Jan 23 Javascript
js实现飞入星星特效代码
Oct 17 Javascript
JS打开新窗口防止被浏览器阻止的方法
Jan 03 Javascript
深入理解JavaScript程序中内存泄漏
Mar 17 Javascript
微信小程序 绘图之饼图实现
Oct 24 Javascript
node+vue实现用户注册和头像上传的实例代码
Jul 20 Javascript
微信小程序自定义单项选择器样式
Jul 25 Javascript
element中table高度自适应的实现
Oct 21 Javascript
vue中子组件向父组件传递数据的实例代码(实现加减功能)
Apr 20 #Javascript
node实现登录图片验证码的示例代码
Apr 20 #Javascript
vue项目中api接口管理总结
Apr 20 #Javascript
通过jquery获取上传文件名称、类型和大小的实现代码
Apr 19 #jQuery
js Element Traversal规范中的元素遍历方法
Apr 19 #Javascript
关于vue中的ajax请求和axios包问题
Apr 19 #Javascript
详解vue 数据传递的方法
Apr 19 #Javascript
You might like
调试PHP程序的多种方法介绍
2014/11/06 PHP
用js实现上传图片前的预览(TX的面试题)
2007/08/14 Javascript
ExtJs事件机制基本代码模型和流程解析
2010/10/24 Javascript
跨域请求之jQuery的ajax jsonp的使用解惑
2011/10/09 Javascript
jquery+json实现数据列表分页示例代码
2013/11/15 Javascript
js离开或刷新页面检测(且兼容FF,IE,Chrome)
2014/03/05 Javascript
封装好的js判断操作系统与浏览器代码分享
2015/01/09 Javascript
javascript简单实现等比例缩小图片的方法
2016/07/27 Javascript
总结javascript中的六种迭代器
2016/08/16 Javascript
快速掌握jQuery插件WebUploader文件上传
2016/11/07 Javascript
Javascrip实现文字跳动特效
2016/11/27 Javascript
Canvas实现动态的雪花效果
2017/02/13 Javascript
JS判断微信扫码的方法
2017/08/07 Javascript
详解vue-cli项目中的proxyTable跨域问题小结
2018/02/09 Javascript
Node.JS获取GET,POST数据之queryString模块使用方法详解
2020/02/06 Javascript
Python中的Numpy入门教程
2014/04/26 Python
python实现从一组颜色中找出与给定颜色最接近颜色的方法
2015/03/19 Python
Python使用re模块实现信息筛选的方法
2018/04/29 Python
python 应用之Pycharm 新建模板默认添加编码格式-作者-时间等信息【推荐】
2019/06/17 Python
pyinstaller打包成无控制台程序时运行出错(与popen冲突的解决方法)
2020/04/15 Python
python推导式的使用方法实例
2021/02/28 Python
Omio美国:全欧洲低价大巴、火车和航班搜索和比价
2017/11/08 全球购物
AT&T Wireless:手机、无限数据计划和配件
2018/06/03 全球购物
Pretty Green美国:英式摇滚服饰风格代表品牌之一
2019/01/23 全球购物
澳大利亚最受欢迎的超级商场每日优惠:Catch
2020/11/17 全球购物
C#和SQL Server的面试题
2016/08/12 面试题
个人委托书怎么写
2014/04/04 职场文书
三月学雷锋月活动总结
2014/04/28 职场文书
优秀大学生自荐信
2014/06/09 职场文书
建筑工程造价专业自荐信
2014/07/08 职场文书
2014大学生学生会工作总结
2014/12/19 职场文书
2015年社区妇联工作总结
2015/04/21 职场文书
工伤劳动仲裁代理词
2015/05/25 职场文书
公司庆典主持词
2015/07/04 职场文书
Vue中插槽slot的使用方法与应用场景详析
2021/06/08 Vue.js
vscode远程免密登入Linux服务器的配置方法
2022/06/28 Servers