深入了解vm.$nextTick和Vue.nextTick

4,880 阅读8分钟

在我的上上篇提到vue是异步渲染页面的。当我们想要获取页面被渲染之后的值的时候,由于宏任务执行的比较慢,所以如果能在微任务中获取会更好。Vue给我们提供了两种函数: vm.\$nextTickVue.nextTick

在页面重新渲染、DOM更新后,VUE会立刻执行$nextTick,我们可以在$nextTick中传递回调函数来获取我们想要的值。

nextTick的使用

基本使用

<div id="app">{{ msg }}</div>
const vm = new Vue({
el: '#app',
data: {
msg: '小饼'
}
})
vm.msg = '快点吃饼';
console.log(vm.msg); // 快点吃饼 说明数据已经更改
// 1. 使用vm.$nextTick 实例方法 
console.log(vm.$el.innerHTML) //小饼 说明dom上的数据还没有更新
vm.$nextTick(() => {
    console.log(vm.$el.innerHTML); // 快点吃饼 说明dom上的数据已经被更新
})
// 可以放多个回调函数 会一起执行
vm.$nextTick(() => {
    vm.msg = vm.$el.innerHTML + 1 // 页面重新渲染为快点吃饼1
    console.log(vm.$el.innerHTML) // 快点吃饼
})
// 2. 使用Vue.nextTick 构造函数方法
Vue.nextTick(() => {
    console.log(vm.$el.innerHTML); // 快点吃饼
})

vm.$nextTick和Vue.nextTick还可以作为Promise使用 在then中获取数据

<div id="app">{{ msg }}</div>
const vm = new Vue({
    el: '#app',
    data: {
        msg: '小饼'
    }
})
vm.msg = '快点吃饼';
// 1. 使用vm.$nextTick
vm.$nextTick().then(() => {
    console.log(vm.$el.innerHTML); // 快点吃饼
})
// 2. 使用Vue.nextTick
Vue.nextTick().then(() => {
    console.log(vm.$el.innerHTML); // 快点吃饼
})

vm.$nextTick 和 Vue.nextTick的区别

Vue.nextTick内部函数的this指向window

Vue.nextTick(function () {
    console.log(this); // window
})

vm.$nextTick内部函数的this指向Vue实例对象 一般都使用这个

vm.$nextTick(function () {
    console.log(this); // vm实例
})

nextTick是怎么实现的

这边大量参考了这篇文章:Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!写得特别好,通俗易懂。我基本上就是用自己的话复述一遍,理清自己的思路而已。

先回顾一下:异步任务分为宏任务(macro)和微任务(micro)但是宏任务比较慢(如setTimeout等) 微任务比较快(如Promise.then()等)

setTimeout(() => {
  console.log('timeout');
}, 0)  
Promise.resolve().then(() => {
  console.log('promise');
})
// 控制台打印顺序:promise > timeout

然后我们来讲讲微任务:

Mutation Events

这一块简单了解一下就好了,他不是主角嘿嘿。

Mutation Events在DOM3中定义,用于监听DOM树结构变化的事件。他的用法特别简单也特别好懂,如下:

//监听list元素的子元素修改
document.getElementById('list').addEventListener("DOMSubtreeModified", function(){
  console.log('列表中子元素被修改');
}, false);

虽然他看起来很不错的样子,但是实际上还是有很多问题的:

  1. 浏览器兼容性问题

    1. IE9之前的版本不支持mutation 事件而且在IE9版本中没有正确实现其中某些事件
    2. Webkit内核不支持Mutation Events提供的DOMAttrModified特性(不能监听元素属性的更改)
    3. DOMElementNameChanged和DOMAttributeNameChanged在Firefox上不被支持 (到 version 11),可能其他浏览器也是这样。
  2. 性能问题

    1. 缓慢

      Mutation Events本身是事件,所以捕获是采用的是事件冒泡的形式(使用的是addEventListener),如果冒泡捕获期间又触发了其他的MutationEvents的话,很有可能就会导致阻塞Javascript线程,拖慢浏览器的运行,甚至导致浏览器崩溃。

    2. 冗余

      因为Mutation Events是同步执行的,然而DOM很有可能会频繁变动。如果文档中连续插入1000个p元素,就会连续触发1000个插入事件,执行每个事件的回调函数,这很可能造成浏览器的卡顿。

    3. 容易导致崩溃

      MutationEvent中的所有事件都被设计成无法取消,如果可以取消MutationEvent事件会导致现有的DOM接口无法对文档进行改变,像appendChild,remove等添加和删除节点的DOM操作都会失效。

      知道了MutationEvent无法取消之后,来看下面这个例子:

      //监听document中所有节点的添加操作
      document.addEventListener('DOMNodeInserted', function() {
          var newEl = document.createElement('div');
          document.body.appendChild(newEl);
      });
      

      document下的所有DOM添加操作都会触发DOMNodeInserted方法,这时就会出现循环调用DOMNodeInserted方法,导致浏览器崩溃。

有兴趣的也可以看一看这个,讲了一下为什么Mutation Events会被代替。主要是按照这里的逻辑总结的,但是我对自己的英语水平还是心里有数的,如果有大佬发现我的总结有错误欢迎提出!DOM Mutation Events Replacement: The Story So Far / Existing Points of Consensus

Mutation Observer

Mutation Observer是HTML5中的新API,使用起来没有那么简单了,毕竟人家比较高级。这个接口能够监听DOM树上的更改。使用如下:

//MO会在监听到DOM发生变化时被调用 然后执行我们传入的回调函数
let MO = new MutationObserver(callback)

上一步只是定义了一个回调函数,它还给我们提供了个observe方法,用来定义要监听的节点和内容。有两个参数:

  1. 需要监听的DOM元素。
  2. 监听该元素哪些地方的更改。

等到该元素指定的内容被更改的时候,就会调用MO,执行回调函数。

var domTarget = 你想要监听的dom节点
mo.observe(domTarget, {
      characterData: true
     //监听文本内容的修改
     //只有在改变节点数据时才会观察到,如果你删除或者增加节点都不会进行观察
})

现在这个domTarget上发生的文本内容修改就会被MO监听到,MO就会触发你在new MutationObserver(callback)中传入的callback。

Mutation Observer的优点如下:

  1. 相比与MutationEvent而言MutationObserver极大地增加了灵活性,可以设置各种各样的选项来满足程序员对目标的观察。

  2. 异步执行,也就是说不管你前面在同步任务里改了多少次DOM,我只在全部DOM操作完成之后才会调用callback。

    要注意!MutationObserver的回调是放在微队列中执行的。

  3. 当你不再想观察目标节点的变化时,可以调用observe.disconnect()方法来取消观察。

nextTick的实现

看看源码,其实真的不多,主要就是注释比较多,忍一忍就看完了:

export const nextTick = (function () {
  // callbacks存放所有的回调函数 也就是dom更新之后我们希望执行的回调函数
  var callbacks = []
  // pending可以理解为上锁 也可以理解为挂起 这里的意思是不上锁
  var pending = false
  // 使用哪种异步函数去执行:MutationObserver,setImmediate还是setTimeout
  var timerFunc
  // 会执行所有的回调函数 
  function nextTickHandler () {
    pending = false
    // 之所以要slice复制一份出来是因为有的cb执行过程中又会往callbacks中加入内容
    // 比如$nextTick的回调函数里又有$nextTick
    // 这些是应该放入到下一个轮次的nextTick去执行的,
    // 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
    var copies = callbacks.slice(0)
    // 清空回调函数 因为全部都拿出来执行了
    callbacks = []
    // 执行所有的回调函数
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }
    
  // ios9.3以上的WebView的MutationObserver有bug,
  // 所以在hasMutationObserverBug中存放了是否是这种情况
  if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) {
    // 随便声明一个变量作为文本的节点
    var counter = 1
    var textNode = document.createTextNode(counter)
    // 创建一个MutationObserver,observer监听到dom改动之后后执行回调nextTickHandler
    var observer = new MutationObserver(nextTickHandler)
    // 调用MutationObserver的接口,监测文本节点的字符改变
    observer.observe(textNode, {
      characterData: true
    })
    // 每次一执行timerFunc,就变化文本节点的字符,这样就会被observer监听到,然后执行nextTickHandler,nextTickHandler就会执行callback中的回调函数。
    timerFunc = function () {
      // 都会让文本节点的内容在0/1之间切换
      counter = (counter + 1) % 2
      // 切换之后将新值赋值到那个我们用observer观测的文本节点上去
      textNode.data = counter
    }
  } else {
    // webpack默认会在代码中插入setImmediate的垫片
    // 没有MutationObserver就优先用setImmediate,不行再用setTimeout
    // setImmediate是一个宏任务,但是他执行的速度比setTimeout快一点,只在IE下有,主要为了兼容IE。
    const context = inBrowser
      ? window
      : typeof global !== 'undefined' ? global : {}
    timerFunc = context.setImmediate || setTimeout
  }
  //这里返回的才是nextTick的内容
  return function (cb, ctx) {
    //有没有传入第二个参数
    var func = ctx
      //有的话就改变回调函数的this为第二个参数
      ? function () { cb.call(ctx) }
      //没有的话就直接把回调函数赋值给func
      : cb
    //把回调函数放到callbacks里面,等待dom更新之后执行
    callbacks.push(func)
    // 如果pending为true,就表明本轮事件循环中已经执行过timerFunc了
    if (pending) return
    // 上锁
    pending = true
    // 执行异步函数,在异步函数中执行所有的回调
    timerFunc(nextTickHandler, 0)
  }
})()

感谢小伙伴的评论,我终于弄明白了这个pending是干啥的了~

首先,当我们第一次在自己的代码中调用nextTick,就执行了把回调函数推入callbacks,然后调用异步函数nextTickHandler的过程,这时候设置了pending = true,也就是已经上锁了。所以后面我们再调用nextTick,都是执行到callbacks.push(func),把异步函数推到callbacks里面就停止了,不再调用异步函数了。毕竟异步函数等到执行时机一到,就会把callbacks里面的函数全部执行完毕,所以没有必要调用多次。

pending一打开(false),就像是在说:我把nextTickHandler放入异步队列啦!你们赶紧把回调函数放进来给他到时候执行!pending一锁上(true),就表示来了来了!正在放入回调函数!最后nextTickHandler在微任务中一执行,就把所有回调函数都执行了。

等到执行完所有的回调函数了,又要把pending给打开,如果不打开的话他就一直锁着,一直傻傻的在存回调函数,那你都没把nextTickHandler再放入异步队列,给他存这么多回调有啥用嘛。 所以说最后打开pending是为了让我们在宏任务(如setTimeout)中调用nextTick的时候能顺利调用到nextTickHandler,才能够执行回调函数。

其实说宏任务不太准确,应该是在nextTickHandler之后执行的函数~说成宏任务只是为了方便理解。

总而言之,nextTick的主要思路就是:我们有可能会在同步任务中多次改变DOM。那么在所有同步任务执行完毕之后,就说明数据修改已经结束了,改变DOM的函数我都执行过了,已经得到了最终要渲染的DOM数据,所以这个时候可放心更新DOM了。因此nextTick的回调函数都是在microtask中执行的。这样就可以尽量避免重复的修改渲染某个DOM元素,另一方面也能够将DOM操作聚集,减少渲染的次数,提升DOM渲染效率。等到所有的微任务都被执行完毕之后,就开始进行页面的渲染。

微任务与宏任务

其实不是必须要用Mutation Observer,重点是微任务,只要是会被放到微队列的异步函数都可以考虑。在最新版的Vue源码里,优先使用的就是Promise.resolve().then(nextTickHandler)来将异步回调放入到microtask中,只有在没有原生Promise才用MO(IE浏览器中不能执行Promise)。

为什么一定要微任务?宏任务可以吗?可以是可以,就是慢了一点。

直接本来想直接拿Vue源码详解之nextTick中的例子,但是发现连接进不去了,我自己试着实现了一下,但是还原不出来那里说的效果,所以就只能靠理解了..要是有理解的不恰当的地方还要麻烦大佬们指出。

场景如下:

HTML中的UI事件、网络事件、HTML Parsing等都是使用宏任务来完成的。假设我们监听了某个元素的scroll事件,在scroll的回调里面使用了nextTick,在nextTick的回调中修改了DOM。

那么一触发元素的scroll事件,就会把scroll的回调放到宏队列中等待执行,执行到scroll的回调时再执行nextTick,nextTick再把修改DOM的任务放到宏队列或微队列里面。

这时候如果nextTick使用的是微任务,那么在宏任务(scroll的回调)执行完毕之后就会立即执行所有微任务,也就及时的修改了dom,然后等到微任务全部执行之后就渲染DOM。 如果nextTick使用的是宏任务,那么会在当前的宏任务和所有微任务执行完毕之后才在之后的某次宏任务执行的时候去修改DOM,有点慢,会错过多次触发重绘、渲染UI的时机。

而且浏览器内部为了更快的响应用户UI,内部可能是有多个宏队列的。UI的宏队列的优先级可能更高,因此如果nextTick使用的是宏任务,有可能已经多次执行了UI的宏任务都没有执行到nextTick的宏任务去修改DOM,也就导致了我们更新DOM操作的延迟。因此,使用宏任务来实现nextTick是不可行的。

每轮event loop之后的UI Render

  1. 从多个宏队列中的一个队列里,挑出一个最老的宏任务。(因为有多个宏队列的存在,使得浏览器可以优先、高频率的执行某些宏队列中的任务,比如UI的宏队列)

  2. 执行这个宏任务。

  3. 执行完微队列中的所有的微任务。如果微任务执行过程中又添加了微任务,那么仍然会执行新添加的微任务。(但是好像有限制?)

  4. 渲染页面

    当前轮次的event loop中关联到的document对象会形成一个列表,等待更新UI。但是并不是所有关联到的document都需要更新UI,浏览器会判断这个document是否会从UI Render中获益。

    如果我们监听了滚动事件,那么每次我们scoll的时候,document或dom就立即scroll,然后这些滚动的元素会被加入到"被触发了scroll事件的对象"中,这些对象会被遍历并触发scroll事件。

    在这之后会以相似的方式去触发resize事件。后续的媒体查询,更新CSS动画并发送事件,判断是否有全屏操作事件的处理也都是相似的,都是触发事件。

    接下来会执行requestAnimationFrame回调(HTML5新增的那个,每秒60帧的那个,有点像setTimeout的那个)和IntersectionObserver回调(能够知道某个元素是否进入了"视口",即用户能不能看到它)。

    最后就是渲染UI。

  5. 继续执行event loop,又去执行宏任务,微任务和界面渲染。

从大佬博客搬过来的图:看起来好像比文字更好理解。

vue的缺点

从上面一大长串乱七八糟的东西中,我们可以明显的看出来,页面渲染的过程中总是在等的。他总是在等主线程的任务执行完毕之后才能渲染,那假如主线程上面的某一个任务或函数出现问题,卡在那里了,那么整个页面就卡死了。只要主线程没执行完毕,他就没机会渲染。

但是由于他的定位是中小企业,所以他也不管这个问题了。

参考文章

  1. MutationObserver监听DOM树变化
  2. 监听Dom节点变化 - Mutation Observer
  3. 了解HTML5中的MutationObserver
  4. Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!
  5. IntersectionObserver API 使用教程
  6. web前端进阶篇(二) 浏览器 Webpack