[精读源码系列]Vue中DOM的异步更新和Vue.nextTick()

前言

vue的DOM更新时异步执行的,只要监听到数据变化,Vue将开启一个队列,并缓存在同一事件循环中发生的全部数据变动,若是同一个Watcher被屡次触发,只会被推入到队列中一次,避免了没必要要的重复计算和频繁的DOM操做,而后在下一个事件循环"tick"中(注意下一个tick多是当前的tick微任务执行阶段执行,也可能在下一个tick执行,主要取决于nextTick函数使用的是Promise/MutationObserver仍是setTimeout),Vue刷新队列并执行更新试图等操做.前端

例如, 当你设置vm.somData = 'new value',该组件不会当即从新渲染,当刷新组件时,组件会在下一个事件循环的"tick"中更新.虽然大多数状况下,咱们并不须要关心这个过程,可是若是咱们想在数据改变以后进行获取更新后的DOM,咱们就须要调用Vue.nextTick(callback),这样回调函数会在DOM更新完成后调用.vue

<div id="example">{{message}}</div>
var vm = new Vue({
    el: '#example',
    data: {
        message: '123'
    }
})
vm.message = 'new message' // 更新数据
console.log(vm.$el.textContent) // '123'
Vue.nextTick(function() {
    console.log(vm.$el.textContent) // 'new message'
})
复制代码

这就很像家里有一个熊孩子今天要开party,可想而知会把家里弄得乱七八糟,乌烟瘴气,你要负责过后打扫战场,可是你要是弄乱一点去收拾一点,就很浪费精力,得不偿失.因此正确的方式应该是任由他折腾,等战斗结束彻底结束后,再去清洗和整理.node

异步更新DOM

Watcher队列

阅读过vue源码的都知道,当某个响应式数据发生变化的时候,它的setter函数就会通知闭包中的Dep,Dep则会触发对应的Watcher对象的update方法,咱们来看一下update的实现:git

update() {
    if(this.lazy) {
        this.dirty = true
    } else if(this.sync) {
        /*同步执行则run直接渲染视图*/
        this.run()
    } else {
        /*异步则推送到观察者队列中,下一个tick时调用*/
        queueWatcher(this)
    }
}
// queueWatcher函数
// 将观察者对象push进队列,并记录观察者的id
// 若是对应的观察者已存在,则跳过,避免重复的计算
export function queueWatcher(watcher: Watcher) {
    const id = watcher.id
    if(!has[id]) {
        has[id] = true
        if(!flushing) {
            /*若是没有被flush掉,直接push到队列中便可*/
            queue.push(watcher)
        } else {
            //...
        }
        // queue the flush
        if(!wating) {
            wating = true
            nextTick(flushSchedulerQueue)
        }
    }
}
复制代码

经过查看源码咱们发现,watcher的update操做都被存入一个队列queue了,等到下一个tick运行时,这些watcher会被遍历执行,更新视图.es6

那么, 什么是下一个tick?github

Event Loop

想要知道什么是下一个tick,咱们先要了解下Event Loop(事件循环).js执行时单线程的,它是基于事件循环的,事件循环机制控制着js全部任务的有序执行,js中的任务分为同步任务和异步任务,事件循环大体分为如下步骤:web

  1. 全部同步任务在主线程上执行,造成一个执行栈.
  2. 主线程以外,还有一个任务队列,这个队列用于存放异步任务, 只要异步任务有了运行结果,就在"任务队列"之中放置一个事件.
  3. 执行栈上的同步任务执行完毕后,主线程会读取任务队列中的任务执行,对应的异步任务结束等待状态,进入执行栈,开始执行.
  4. 主线程不断重复以上操做,造成事件循环.

咱们平时用setTimeout来执行异步代码,其实就是在任务队列的末尾加入了一个task,待前面的任务执行完后在执行它,每次事件循环后,就会有一个UI Render步骤,也就是更新dom操做,那么为何要这么设计呢?代码示例:

for(let i =0; i < 100; i++) {
    dom.style.left = i + 'px'
}
复制代码

浏览器会进行100次dom更新吗?显然这样太损耗性能了,事实上这100次for循环同属一个task,浏览器只会在改task执行完后进行一次DOM更新.这也就意味着,只要让nextTick中的回调放在UI Render后执行,就能够访问到更新后的DOM了.这样咱们很天然的想到把这些回调逻辑放入任务队列中去执行.vue-router

主线程的执行过程就是一个tick,全部的异步结果都是经过"任务队列"来调度,可想而知Vue中的DOM的异步更新任务也是存放在任务队列中的,下面咱们就来看看nextTick的具体实现逻辑.segmentfault

JS任务队列

js中的任务队列分为宏任务(macrotask)队列和微任务(microtask )队列,每次事件循环结束后,都会先清空微任务队列中的微任务,而后才会开始执行下一个宏任务,微任务比宏任务有着更高的优先级.(注: 浏览器和NodeJs的事件循环的执行逻辑不同,这里咱们只研究浏览器中事件循环的执行逻辑,想要了解nodejs中的执行逻辑,可参考: segmentfault.com/a/119000001….)api

因此事实上,咱们调用nextTick的时候,就是在更新DOM那个microtask后执行了咱们传入的回调函数,从而确保咱们的代码在DOM更新后执行

Vue.nextTick()

nextTick的源码, 建议你们对照着源码来阅读接下来的内容.

vue是如何监听到DOM更新完毕,并执行咱们传入的回调函数呢? HTML5新增了一个属性MutationObserver,用于监听DOM修改事件,可以监听到节点的属性,文本内容,子节点等的改动,是一个功能强大的利器,基本用法以下:

// MO基本用法
var observer = new MutationObserver(function() {
    // 这里是回调函数
    console.log("DOM 被修改了!");
}); 
var article = document.querySelector('article');
observer.observer(article);  // 监听dom改变后执行回调
复制代码

那么vue是否是用MO来监听DOM更新完毕的呢? 打开vue的源码,确实看到这样的代码:

// MutationObserver 有更普遍的支持,但在iOS上的触摸事件处理程序中存在bug
// 因此咱们优先采用原生的promise.来建立微任务

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Promise是es6新增的api,在不支持原生Promise的浏览器中,咱们采用HTML5的新属性MutationObserver来监听DOM更新
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // setImmediate, 目前只有IE和Node.js支持
  // 技术上它是利用宏任务队列,
  // 可是它还是比setTimeout更好的选择
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // setTimeout是以上方案都不支持的最后的选择
  // 尽管它有执行延迟,可能形成屡次渲染
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
// 暴露出nextTick方法,控制在下一个tick中执行传入的回调
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
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
复制代码

总结

关于Vue中DOM的异步更新以及Vue.nextTick的原理解析就说到这儿了,后续会推出vue-router的源码解析,持续关注奥~若是你有什么建议,困惑或想法,欢迎留言或者加微信lj_de_wei_xin与我交流~

扩展阅读

相关文章
相关标签/搜索