Diff算法及批处理浅析(React15及之前版本)

91 阅读11分钟

虚拟 DOM

  1. 出现背景及意义

创建DOM tree –> 创建Style Rules -> 构建Render tree -> 布局Layout –> 绘制Painting

  1. 传统的原生api或jQuery去操作DOM

    1. 多次操作 DOM,每次都会执行,导致性能浪费

    2. 操作 DOM 的代价昂贵

    3. DOM 引擎JS 引擎 相互独立,JS 代码调用 DOM API 必须 挂起 JS 引擎

  2. 现代框架 react / vue : 每次变动重新渲染整个应用

    1. 性价比低,耗时高

    2. 任意改动导致重新构造整棵DOM树

    3. input&textarea失去原有的焦点

    4. 全局应用状态变更,需要更新的局部页面很多

  3. 什么是虚拟 DOM

虚拟dom

  • JS 对象表示的虚拟DOM
var element = {
  tagName: 'ul', // 节点标签名  
  props: {       // DOM的属性,用一个对象存储键值对    
    id: 'list'
  },
  children: [    // 该节点的子节点    
    {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
  ]
}
  • 对应的HTML:
<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>
  • 根据虚拟 DOM 生成真正的 DOM 节点
Element.prototype.render = function () {
  var el = document.createElement(this.tagName) // 根据tagName构建
  var props = this.props

  for (var propName in props) { // 设置节点的DOM属性
    var propValue = props[propName]
    el.setAttribute(propName, propValue)
  }

  var children = this.children || []
  children.forEach(function (child) {
    var childEl = (child instanceof Element)
      ? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
      : document.createTextNode(child) // 如果字符串,只构建文本节点
    el.appendChild(childEl)
  })
  return el
}
var ulRoot = ul.render()
document.body.appendChild(ulRoot)

3. ### 虚拟 DOM 的使用

  • 步骤1: 用 JavaScript 对象结构表示 DOM 树的结构

  • 步骤2: 当状态变更的时候——>对比新旧树——>记录差异

  • 步骤3: 将差异(patch)更新到真正的 DOM 树上

Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。

虚拟dom与真实dom

Diff算法

  1. React & Vue 的 Diff

  • 算法复杂度

    • 标准Diff算法 复杂度 O(n^3)

    • React / Vue实现了复杂度为O(n) 的Diff算法

假设 两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构;

逐层比较

  1. diff算法的具体实现

深度优先遍历,记录差异

diff流程图

// diff 函数,对比两棵树
function diff (oldTree, newTree) {
  var index = 0 // 当前节点的标志  
  var patches = {} // 用来记录每个节点差异的对象  
  dfsWalk(oldTree, newTree, index, patches)
  return patches
}
// 对两棵树进行深度优先遍历
function dfsWalk (oldNode, newNode, index, patches) {
  // 对比oldNode和newNode的不同,记录下来  
    patches[index] = [...]
    diffChildren(oldNode.children, newNode.children, index, patches)
}  

//遍历子节点
function diffChildren (oldChildren, newChildren, index, patches) {
  var leftNode = null
  var currentNodeIndex = index
  oldChildren.forEach(function (child, i) {
    var newChild = newChildren[i]
    currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识
      ? currentNodeIndex + leftNode.count + 1
      : currentNodeIndex + 1
    dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍历子节点    
    leftNode = child
  })
}

用patches存储新旧节点的差异

patches[0] = [{difference}, {difference}, ...] //key为节点顺序 

3. ### diff算法的差异类型

  • (1)节点类型不同 :销毁此节点,插入新节点
renderA: <div />
renderB: <span />
=====> 
patches[index] = [{
  type: REPALCE,
  node: newNode // el('section', props, children)
}]
  • (2)节点类型相同:React 会对属性或者文本进行重设从而实现节点的转换
renderA: <div id="before">sally is pretty</div>
renderB: <div id="after">sally is cute</div>
=====>
patches[index] = [
{
  type: PROPS,
  props: { id: "container" }
},
{
  type: TEXT,
  content: "sally is cute"
}]
  • (3)列表节点的比较:同一层的节点的比较

    • 每个节点都没有唯一的标识,React 无法识别每一个节点,那么更新过程会很低效
    • 过程:C ---> F,D ---> C,E ---> D,最后插入E 节点
    • 给每个节点唯一的标识(key),那么 React 能够找到正确的位置去插入新的节点
    • 过程:插入F 节点

Tips:不要使用index作为key值

const list = [
    { id: 1, name: 'test1'},
    { id: 2, name: 'test2'},
    { id: 3, name: 'test3'},
]
  1. 情况1: 在最后一条数据后再加一条数据:此时前三条数据直接复用之前的,新渲染最后一条数据,此时用index作为key,没有任何问题
const list = [
    { id: 1, name: 'test1'},
    { id: 2, name: 'test2'},
    { id: 3, name: 'test3'},
    { id: 4, name: '我是插队的那条数据'},
]
之前的数据之后的数据
key: 0 index: 0 name: test1key: 0 index: 0 name: test1
key: 1 index: 1 name: test2key: 1 index: 1 name: test2
key: 2 index: 2 name: test3key: 2 index: 2 name: test3
key: 4 index: 4 name: 我是插队的那条数据
  1. 情况2:在中间插入一条数据
const list = [
    { id: 1, name: '我是插队的那条数据'},
    { id: 2, name: 'test1'},
    { id: 3, name: 'test2'},
    { id: 4, name: 'test3'},
]
  • 通过对比,发现除了第一个数据可以复用之前的之外,另外三条数据都需要重新渲染
之前的数据之后的数据
key: 0 index: 0 name: test1key: 0 index: 0 name: test1
key: 1 index: 1 name: test2key: 1 index: 1 name: 我是插队的那条数据
key: 2 index: 2 name: test3key: 2 index: 2 name: test2
key: 3 index: 3 name: test3
  • 通过对比,只有一条数据变化了,即id为4的那条数据, 因此只需新渲染这一条数据,其他皆可复用之前的
之前的数据之后的数据
key: 1 id: 1 index: 0 name: test1key: 1 id: 1 index: 0 name: test1
key: 2 id: 2 index: 1 name: test2key: 4 id: 4 index: 1 name: 我是插队的那条
key: 3 id: 3 index: 2 name: test3key: 2 id: 2 index: 2 name: test2
key: 3 id: 3 index: 3 name: test3
  1. 列表节点的更新算法—Myers Diff算法

从 ****a = 'ABCABBA'b = 'CBABAC' 有无数种路径,但是最优路径只有一种:

  • 最优路径的标准:

    •   1⃣️ 改动次数最小
    •   2⃣️ 在改动次数固定的情况下,删除后新增,比新增后删除要好
    •   
    •      图1 方法一先减后增比方法二和方法三更加“直观”

暂时无法在飞书文档外展示此内容

  • diff与图搜索:寻找 diff 的过程可以被表示为图搜索

    • 向右表示“删除”,向下表示”新增“,对角线则表示“原内容保持不动“
    • 定义参数 d 和 k ,d代表路径的长度, k 代表当前坐标 x - y 的值
    • d 和 k 固定的情况下,寻找最远路径。

  图1 diff图搜索

  图2: d和k固定,最远路径坐标分布

  1. Diff更新 优于 原生操作 ?

例子:React和原生直接操作速度对比

  • Diff + VDom只是 React / Vue 视图更新的一种策略

  • 精细化更新 DOM 情况下,React / Vue 毫无性能可言

  • 连续操作 DOM 元素,React / Vue优于原生

批处理

Batch Update 即「批量更新」。Batch Update 可以理解为将一段时间内对 model 的修改批量更新到 view 的机制

  1. React批处理的实现

引子:问下列代码中 4 次 console.log 打印出来的 val 分别是多少?

 addState(){
      this.setState({ val: 1 }).
      1. console.log(this.state.val) // 第 1 次 log
      this.setState({ val: 2 })
      2. console.log(this.state.val) // 第 2 次 log
      this.setState({ val: 3 })
      3. console.log(this.state.val) // 第 3 次 log
  }
  componentDidUpdate() {
    console.log('i am updating')
  }
class Example extends React.Component {
  constructor() {
    super();
    this.state = {
      val: 0
    };
  }
  addState() {
    this.setState({val: this.state.val + 1});
    1. console.log(this.state.val);    // 第 1 次 log
    this.setState({val: this.state.val + 1});
    2. console.log(this.state.val);    // 第 2 次 log
    setTimeout ( () => {
      this.setState({val: this.state.val + 1});
      3. console.log(this.state.val);  // 第 3 次 log
      this.setState({val: this.state.val + 1});
      4. console.log(this.state.val);  // 第 4 次 log 
 }
  render() {
    return (
      <div className="add" onClick={this.addState>{this.state.val}</div>
    );
  }
};

setState 干了什么

  • 若 isBatchingUpdates 为 true,则把当前组件(即调用了 setState 的组件)放入 dirtyComponents 数组中;否则 batchUpdate 所有队列中的更新。在源码(ReactUpdates.js)中
//setState之后会进入这个函数,传入待更新组件component
function enqueueUpdate(component) {
  // ...
  //isBatchingUpdates为false 不处于批更新流程中
 if (!batchingStrategy. isBatchingUpdates ) {
batchingStrategy. batchedUpdates (enqueueUpdate, component);
 return ;
}
  //isBatchingUpdates为true 处于批更新流程中
  dirtyComponents.push(component);
}

//batchingStrategy对象
var batchingStrategy = {
 // 是否正处于创建或更新组件阶段 === 是否处于批更新的流程中
  isBatchingUpdates: false,
  batchedUpdates: function(callback, a, b, c, d, e) {
    // ...    batchingStrategy.isBatchingUpdates = true;
    transaction.perform(callback, null, a, b, c, d, e);
  }};

Transaction事务

Transaction creates a black box that is able to wrap any method such that certain invariants are maintained before and after the method is invoked

事务创建了一个能够包装任何方法的“黑盒子”,以便在调用方法之前和之后维护某些不变量

 *                       wrappers (injected at creation time)
 *                                      +        +
 *                                      |        |
 *                    +-----------------|--------|--------------+
 *                    |                 v        |              |
 *                    |      +---------------+   |              |
 *                    |   +--|    wrapper1   |---|----+         |
 *                    |   |  +---------------+   v    |         |
 *                    |   |          +-------------+  |         |
 *                    |   |     +----|   wrapper2  |--------+   |
 *                    |   |     |    +-------------+  |     |   |
 *                    |   |     |                     |     |   |
 *                    |   v     v                     v     v   | wrapper
 *                    | +---+ +---+   +---------+   +---+ +---+ | invariants
 * perform(anyMethod)  | |   | |   |   |         |   |   | |   | | maintained
 * +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | +---+ +---+   +---------+   +---+ +---+ |
 *                    |  initialize                    close   |
 *                    +-----------------------------------------+
  • Transaction 就是将需要执行的 method 使用 wrapper 封装起来,再通过 Transaction 提供的 perform 方法执行
  • 在 perform 之前,先执行所有 wrapper 中的 initialize 方法;perform 完成之后(即 method 执行后)再执行所有的 close 方法
  • 一组 initialize 及 close 方法称为一个 wrapper, Transaction 支持多个 wrapper 叠加。

思考:setState到底是同步还是异步?

  1. Vue批处理的实现

与 React 相比 Vue 实现 Batch Update 的方法就要简单很多:直接借助浏览器的 Event Loop

EventLoop

执行顺序:

  1. 引擎首先从 macrotask queue(宏任务队列)中取出第一个任务,执行完毕后,将microtask queue(微任务队列)中的所有任务取出,按顺序全部执行;

  2. 然后再从 macrotask queue中取下一个,执行完毕后,再次将microtask queue中的全部取出;

  3. 循环往复,直到两个queue中的任务都取完。

  4. 每次执行一个宏任务或微任务叫称做一次/个tick

浏览器环境中常见的异步任务种类,按照优先级:

  1. Macro task同步代码setImmediateMessageChannelsetTimeout/setInterval

  2. Micro taskPromise.thenMutationObserver

Vue批处理的实现流程

Vue批更新流程图

NextTick的实现
// src/core/util/next-tick.js
export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // ...
}
/* 强制使用macrotask的方法 */
export function withMacroTask(fn: Function): Function {
  return fn._withTask || (fn._withTask = function() {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}
  • cb 回调函数用 try-catch 包裹后放在一个匿名函数中推入callbacks数组中,防止单个 cb 执行错误让整个JS线程挂掉。

  • 然后检查 pending 状态,用于判断是否已经处于更新状态

  • 最后检查是否传入了 cb,因为 nextTick 还支持Promise化的调用:nextTick().then(() => {}),所以如果没有传入 cb 就直接return了一个Promise实例,并且把resolve传递给_resolve,这样后者执行的时候就跳到我们调用的时候传递进 then 的方法中。

MacroTimerFunc & MicroTimerFunc

MacroTimerFunc & MicroTimerFunc 对浏览器中宏任务/微任务的API进行了一层包装

// src/core/util/next-tick.js
const callbacks = []     // 存放异步执行的回调
let pending = false      // 一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送
/* 挨个同步执行callbacks中回调 */
function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
let microTimerFunc        // 微任务执行方法
let macroTimerFunc        // 宏任务执行方法
let useMacroTask = false  // 是否强制为宏任务,默认使用微任务
// 宏任务
1.  if ( typeof setImmediate !== 'undefined' && isNative (setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} 
2.  else  if ( typeof  MessageChannel !== 'undefined' && (
 isNative ( MessageChannel ) ||
 MessageChannel . toString () === '[object MessageChannelConstructor]'  // PhantomJS
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} 
3.  else {
macroTimerFunc = () => {
 setTimeout (flushCallbacks, 0 )
}
}
// 微任务
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
1.  microTimerFunc = () => {
p. then (flushCallbacks)
}
} else {
2.  microTimerFunc = macroTimerFunc      // fallback to macro
}
  • 可以看到上面 macroTimerFuncmicroTimerFunc 进行了在不同浏览器兼容性下的平稳退化,或者说降级策略

    • 宏任务 macroTimerFuncsetImmediate -> ``MessageChannel`` -> setTimeout

    • 微任务 microTimerFuncPromise.then -> macroTimerFunc

  • 是因为HTML5规定setTimeout执行的最小延时为4ms,而嵌套的timeout表现为10ms,为了尽可能快的让回调执行,没有最小延时限制的前两者显然要优于 setTimeout

一个例子:Vue之nextTick实例

<div id="app">
  <span id='name' ref='name'>{{ name }}</span>
  <button @click='change'>change name</button>
  <div id='content'></div></div><script>
  new Vue({
    el: '#app',
    data() {
      return {
        name: 'Sally'
      }
    },
    methods: {
      change() {
        const $name = this.$refs.name
        1⃣️ this.$nextTick(() => console.log('setter前:' + $name.innerHTML))
        this.name = ' name改喽 '
        2⃣️ console.log('同步方式:' + this.$refs.name.innerHTML)
        3⃣️ setTimeout(() => this.console("setTimeout:" + this.$refs.name.innerHTML),0);
        4⃣️ this.$nextTick(() => console.log('setter后:' + $name.innerHTML))
        5⃣️ this.$nextTick().then(() => console.log('Promise方式:' + $name.innerHTML))
      }
    }
  })
</script>

在vue2.5之前的版本中,nextTick基本上基于 micro task 来实现的,但是在某些情况下 micro task 具有太高的优先级,并且可能在连续顺序事件之间(例如#4521#6690)或者甚至在同一事件的事件冒泡过程中之间触发(#6566)。但是如果全部都改成 macro task,对一些有重绘和动画的场景也会有性能影响,如 issue #6813。vue2.5之后版本提供的解决办法是默认使用 micro task,但在需要时(例如在v-on附加的事件处理程序中)强制使用 macro task

  1. React & Vue 批处理总结

  • Vue批处理的原理:Vue本轮事件循环中所有触发的watcher添加到一个队列里交由Vue.nextTick(),nextTick会把队列更新操作包装成一个宏任务task或者微任务task的回调函数,当前栈(tick)执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。

  • React批处理原理:transaction事务在 initialize 阶段就会创建一个更新队列,所以在 事务 中调用 setState 方法更新被推入到更新队列中。函数执行结束进入 事务的 close 阶段,把更新队列flush。

  1. 小程序没有 Diff & 批处理

  • 小程序没有diff,每次setData都是重新渲染小程序
  • 小程序是没有批处理的,也就是说小程序的setData是一个同步行为,所以在setData的时候尽量一次性更新完成,不要多次调用setData

  click() {
    this.setData({ count: 1 });
    console.log(this.data.count); // 1
    this.setData({ count: 2 });
    console.log(this.data.count); // 2
    this.setData({ count: 3 });
    console.log(this.data.count); // 3
  }
  render() {
    return (
      <view className="test-page" bindtap={this.click}>
        {this.data.count}
      </view>
    );
  }
}

参考文档

深度剖析:如何实现一个 Virtual DOM 算法

深入浅出 React(四):虚拟 DOM Diff 算法解析

简析Myers

浅入深出setState(下篇)

React 源码剖析系列 - 解密 setState

Vue源码阅读 - 批量异步更新与nextTick原理