kaola-fed / blog

kaola blog

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

浅析Vue 中的patch和diff(上)

AnnVoV opened this issue · comments

疑问

1.当我修改了属性值时,vdom立即进行diff,重新渲染视图了吗?

2.如果1是对的,那重复修改,性能岂不是很差?如果不是,1是如何实现的?

3.我们的nextTick 具体的实现是怎样的?什么时候需要用到它?

4.vdom diff的过程是怎样的?

梗概

关于vue数据更新渲染的几个知识点,先列一下:

  • 数据的更新是实时的,但是渲染是异步的。

  • 一旦数据变化,会把在同一个事件循环event loop中的观察到的watcher 推入一个队列(相同watcher实例不会重复推入)

  • DOM并不是马上更新视图的(想想也不可能,改动一次数据更新一次视图,肯定都是批量操作DOM的),vue 中的nextTick 用到了MicroTask和MacroTask,这需要我们去了解event loop(推荐先阅读下:Tasks, microtasks, queues and schedules

  • 整个script是一个主任务, setTimeout 是一个macroTask, promise的回调是microtask, 顺序是

    主script(第一个主任务) —> microTask (全部执行完)—> UI渲染 —> 下一个macroTask

    // 先不看结果,想一下你的输出
    console.log('script start');
    
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    Promise.resolve().then(function() {
      console.log('promise1');
    }).then(function() {
      console.log('promise2');
    });
    
    console.log('script end');
    // result: 
    /**
    * script start
    * script end
    * promise1
    * promise2
    * setTimeout
    */
  • 所以dom diff 这个过程是在microtask中去处理的(也有的是强制走macrotask, 本例子走microtask)

  • 哪些会走macrotask 哪些会走microtask,为啥要区分,会写在拓展那一小节

例子

接下来,所有的讲解都会围绕下面这个例子

<!DOCTYPE html>
<html>
<head>
	<title></title>
    <style>
        .f-error {
            color: red;
        }
    </style>
</head>
<body>
    <div id="test">
    </div>
    <script type="text/javascript" src="./vue.js"></script>
    <script type="x-template" id="temp">
        <section>
            <div :class="{'f-error': a==2}">{{a+b}}</div>
        </section>
    </script>
    <script type="text/javascript">
        var data = {
            a: 1,
            b: 1
        }
        new Vue({
            el: '#test',
            template: temp,
            data: function() {
                return data;
            },
            mounted() {
                setTimeout(() => {
                    data.a = 2;
                    data.b = 3;
                    this.$nextTick(()=> {
                        console.log(document.querySelector('.f-error'));
                    })
                }, 500);
            }
        });
    </script>
</body>
</html>

入口

vm._update(vm._render(), hydrating)

经过上一次分享,我们知道通过vm._render()方法,我们会获得我们的vdom; 接下去我们进入_update方法;我们看下内部的细节。

Vue.prototype._update = function (vnode, hydrating) {
    var vm = this;
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate');
    }
    ...
    // 初始时,我们是没有prevVnode的, 进入了patch方法
    if (!prevVnode) {
      // initial render
	  // 初始化渲染,我们看下细节	
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      );
      // no need for the ref nodes after initial patch
      // this prevents keeping a detached DOM tree in memory (#5851)
      vm.$options._parentElm = vm.$options._refElm = null;
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode);
    }
    activeInstance = prevActiveInstance;
    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null;
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm;
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el;
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  };

初始进入patch

// 当我们初始进入patch时,会进入createElm 根据我们的vnode 创建节点
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); }
      return
    }
    var isInitialPatch = false;
    var insertedVnodeQueue = [];

    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true;
      createElm(vnode, insertedVnodeQueue, parentElm, refElm);
    } else {
        ...
    }
}

数据更新时

假设我们的数据是{a:1, b:1}更新为了{a:2, b:3}, 我们下面看下细节

  • a值发生了变化, 进入它的setter ——> 进入 dep.notify() 依赖通知
  • dep.notify() ——> subs[i].update() subs存放的是watcher 实例,进入watcher的update()方法
  • watcher.update() ——> 将当前watcher 推入一个队列, 并且将flushSchedulerQueue(冲洗队列)这个动作放入nextTick(一个microtask 中),且将flushSchedulerQueue塞入callback数组
  • 继续往下走,b值发生变化,进入它的setter ——> 进入 dep.notify() 依赖通知
  • dep.notify() ——> subs[i].update() subs存放的是watcher 实例,进入watcher的update()方法
  • watcher.update() ——> 当前watcher已经放入队列,不再放入,继续往下
  • 遇到$nextTick(我们的cb) ——> 我们的cb也塞入callback数组
  • 主任务(script)全部走完 ——> 开始执行所有microtask
  • 开始执行flushCallBacks ——> flushSchedulerQueue(这里后面有watcher.run(), dom diff, 视图更新) ——> 我们的cb
  • 结束
/**
mouted() {
    setTimeout(() => {
                    data.a = 2;
                    data.b = 3;
                    this.$nextTick(()=> {
                        console.log(document.querySelector('.f-error'));
                    })
                }, 500);
}
**/

new Vue({
		el: '#test',
		template: temp,
		data: function() {
			return data;
		},
		methods: {
			test() {
				data.a = 2;
				data.b = 3;
			}
		}
	});

// 当我们data.a 的值改变时,会进入它的setter
set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if ("development" !== 'production' && customSetter) {
        customSetter();
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      // 当值更新时,dep会通知watcher
      dep.notify();
    }

Dep.prototype.notify = function notify () {
  // stabilize the subscriber list first
  var subs = this.subs.slice();
  // 我们知道subs里面存放着我们的watcher实例, 进入watcher的update方法	    
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};

Watcher.prototype.update = function update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    // 一般情况下,没有其他配置会进入这里,将我们的watcher推入队列  
    queueWatcher(this);
  }
};

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
function queueWatcher (watcher) {
  var id = watcher.id;
  // 判断这个watcher是否已经放入过队列
  if (has[id] == null) {
    has[id] = true;
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      var i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    // queue the flush
    if (!waiting) {
      waiting = true;
      // 走到了这里, cb 是flushSchedulerQueue
      nextTick(flushSchedulerQueue);
    }
  }
}

// 进入了nextTick 方法,这里涉及到EventLoop相关的内容,后面会简单说一下
function nextTick (cb, ctx) {
  var _resolve;
  // 将flushSchedulerQueue塞入cb
  callbacks.push(function () {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, 'nextTick');
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    // 注意这里,一般情况下使用microTask但某些情境下会强制使用macroTask  
    if (useMacroTask) {
      macroTimerFunc();
    } else {
     // 我们的例子会进入这里, microTimerFunc结果是什么呢?往下看
      microTimerFunc();
    }
  }
  // $flow-disable-line 
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve;
    })
  }
}

// Determine MicroTask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if(typeof Promise !== 'undefined' && isNative(Promise)) {
   var p = Promise.resolve();
    microTimerFunc = function() {
        // 重点注意这里是promise的cb, 是一个microTask, 是在主script执行完才会执行的
        p.then(flushCallbacks);
    } 
}

data.a执行完以后,开始走data.b, 流程都一样,只是当我们遇到watcher的update时有些区别

queueWatcher(this);
function queueWatcher (watcher) {
  var id = watcher.id;
  // 判断这个watcher是否已经放入过队列, 当执行到data.b时已经放入过队列了, 所以不会继续往下走了(这个也很好理解)
  if (has[id] == null) {
    has[id] = true;
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      var i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    // queue the flush
    if (!waiting) {
      waiting = true;
      // 走到了这里
      nextTick(flushSchedulerQueue);
    }
  }
}

然后进入this.$nextTick方法

 Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
  };

function nextTick (cb, ctx) {
  var _resolve;
  // 将我们的cb塞入callbacks	    
  callbacks.push(function () {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, 'nextTick');
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  // 因为上一次pending 已经置为true,所以此时不符合条件
  if (!pending) {
    pending = true;
    if (useMacroTask) {
      macroTimerFunc();
    } else {
      microTimerFunc();
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve;
    })
  }
}

接下去,就到了我们之前讲到的,主script执行完了开始执行microTask, 进入flushSchedulerQueue方法。(tips: 推荐阅读Tasks, microtasks, queues and schedules更好地了解EventLoop)

//  p.then(flushCallbacks);
function flushCallbacks () {
  pending = false;
  var copies = callbacks.slice(0);
  callbacks.length = 0;
  for (var i = 0; i < copies.length; i++) {
    copies[i]();
  }
}
// 开始遍历callbacks 执行其中的cb
// 第一个cb 是 flushSchedulerQueue
// 第二个cb 是 我们的 console.log(document.querySelector('.f-error')); 

// 第一个cb里面,watcher.run 最终会进入vdom的diff, 下一篇具体讲细节
function flushSchedulerQueue () {
  flushing = true;
  var watcher, id;
   ...
  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    id = watcher.id;
    has[id] = null;
    watcher.run();
    // in dev build, check and stop circular updates.
    ...
  }

  // keep copies of post queues before resetting state
  var activatedQueue = activatedChildren.slice();
  var updatedQueue = queue.slice();
  resetSchedulerState();

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue);
  callUpdatedHooks(updatedQueue);

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush');
  }
}
// 然后执行了我们的cb, 此时视图已经更新
// <div class='f-error'>5</div>    

总结

这一篇blog主要是为了让大家清楚

  • 1.当数据更新时会将watcher 推入一个队列
  • 2.当多个数据更新时,更新完不会立即更新视图
  • 3.视图更新发生在nextTick, 利用microTask 实现
  • 4.vdom 如何进行diff的将放在下一篇

拓展

思考1:不用nextTick

如果很好地理解了micoTask 与 macroTask之间的关系,那么也能很清楚的理解假设我们写成下面这样, 为什么不行了,自己试试喽!下一篇,会细致讲解vdom diff 的过程~

mounted() {
                setTimeout(() => {
                    data.a = 2;
                    data.b = 3;
                    console.log(document.querySelector('.f-error'));
                }, 500);
            }

思考2: 如果都用MicroTask有什么问题?

看下这个issue, @click would trigger event other vnode @click event. #6566

贴一下代码,vue的版本是2.4.2,在此版本下当你点击了‘expand is true’以后,expand click 和 off click都打印出来了,countA与countB都变成了1,文案还是expand is true

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>JS Bin</title>
    <!--<script src="https://unpkg.com/vue@2.4.2/dist/vue.js"></script>-->
    <script src="./vue2.4.js"></script>
</head>
<body>
<div class="panel" id="app">
    <div class="header" v-if="expand">
        <i @click="expandClick">Expand is True</i>
    </div>
	<!-- 注意这里@click 绑定到了外层 -->
    <div class="expand" v-if="!expand" @click="offClick">
        <i>Expand is False</i>
    </div>
    <div>
        countA: {{countA}}
    </div>
    <div>
        countB: {{countB}}
    </div>
    <div>
        expand: {{expand}}
    </div>
    Please Click `Expand is Ture`.
</div>
</body>

<script>
    debugger;
    new Vue({
        el: '#app',
        data: {
            expand: true,
            countA: 0,
            countB: 0,
        },
        methods: {
            expandClick() {
                this.expand = false;
                this.countA++;
                console.log('expand click');
            },
            offClick() {
                this.expand = true;
                this.countB++;
                console.log('off click');
            }
        }
    })
</script>
</html>

尤大在这个issue下面给了回答,引一下:

大致原因是:<i>标签的点击动作触发了第一次nextTick(microTask), 然后我们得到了新的vdom并进行了渲染;microTask先于冒泡这个task,在microTask生成新dom的过程中,外层div添加了listener; 渲染完成后,冒泡触发了新的listener,所以又进入了新的cb。所以在Vue2.5版本中你会看到event handler使用了macroTask进行包裹

/** Vue.js v2.5.13 **/
function add$1 (
  event,
  handler,
  once$$1,
  capture,
  passive
) {
  // 看这里	      
  handler = withMacroTask(handler);
  if (once$$1) { handler = createOnceHandler(handler, event, capture); }
  target$1.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture: capture, passive: passive }
      : capture
  );
}

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a Task instead of a MicroTask.
 */
function withMacroTask (fn) {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true;
    var res = fn.apply(null, arguments);
    useMacroTask = false;
    return res
  })
}

参考资料

1.vue2.0 正确理解Vue.nextTick()的用途 http://www.cnblogs.com/minigrasshopper/p/7879545.html
2.从event loop规范探究javaScript异步及浏览器更新渲染时机 aooy/blog#5
3.Promise的队列与setTimeout的队列有何关联?https://www.zhihu.com/question/36972010/answer/71338002
4.JavaScript 运行机制详解:再谈Event Loop http://www.ruanyifeng.com/blog/2014/10/event-loop.html
5.Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!Ma63d/vue-analysis#6
https://chuckliu.me/#!/posts/58bd08a2b5187d2fb51c04f9
6.Tasks, microtasks, queues and schedules https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
7.@click would trigger event other vnode @click event. #6566 vuejs/vue#6566

commented

学习了,多谢