Vue.nextTick源码解析

675 阅读1分钟

用法

Vue.nextTick( [callback, context] )

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

// 修改数据
vm.msg = 'Hello'
// DOM 还没有更新
Vue.nextTick(function () {
  // DOM 更新了
})

// 作为一个 Promise 使用
Vue.nextTick()
  .then(function () {
    // DOM 更新了
  })

2.1.0 起新增:如果没有提供回调且在支持 Promise 的环境中,则返回一个 Promise。请注意 Vue 不自带 Promise 的 polyfill,所以如果你的目标浏览器不原生支持 Promise (IE:你们都看我干嘛),你得自己提供 polyfill。

Vue.nextTick与vm.$nextTick方法作用是一样的,区别是后者回调的this会自动绑定到调用它的实例上。

为什么Vue.js使用异步更新队列

Vue 实现响应式并不是数据发生变化之后 DOM 立即变化,而是按一定的策略进行 DOM 的更新。

在Vue.js中,当状态发生变化时,watcher会得到通知,然后触发虚拟DOM的渲染流程。要注意的是,watcher触发渲染这个操作并不是同步的,而是异步的。

如果在同一轮事件循环中有两个数据发生了变化,那么组件的watcher会收到两份通知,从而进行两次渲染。但是,没必要渲染两次,虚拟DOM会对整个组件进行渲染。我们只需要等所有状态都修改完之后,一次性将整个组件的DOM渲染到最新就好了。

Vue.js解决这个问题的方法是,将收到通知的watcher实例添加到队列中缓存起来,并且在添加到队列之前检查其中是否已经存在相同的watcher,只有不存在时,才将watcher添加到队列中。在下一次事件循环的时候,Vue.js会让队列中的watcher触发渲染流程并清空队列

简单来说,Vue在修改数据之后,视图并不会立刻更新,而是等同一事件循环中的所有数据变化完成之后,再统一进行视图更新。

事件循环

推荐阅读: juejin.cn/post/693211…

执行栈中的所有任务执行完成之后,会去检查微任务队列中是否有事件存在,如果存在,就会一次调用微任务队列中事件对应的回调,直到为空。然后再去宏任务队列中取出一个事件,把对应的回调加入到执行栈,执行栈中所有任务执行完毕之后,又会继续检查微任务队列是否有事件存在。重复此过程就形成了一个无限循环,叫做事件循环

宏任务(macrotask):setTimeout、setInterval、setImmediate、I/O、UI rendering

微任务(microtask):promise.then、process.nextTick、MutationObserver、queneMicrotask(开启一个微任务)

执行栈

在执行一个方法的时候,js会生成一个与这个方法对应的执行环境,又叫执行上下文。这个执行环境中有这个方法的私有作用域、上层作用域的指向、方法的参数、私有作用域中定义的变量以及this对象。而这个执行环境会被添加到一个栈中,这个栈就是执行栈。

函数多了,就有多个函数执行上下文,每次调用函数就会创建一个新的执行上下文。js引擎创建了执行上下文栈来管理执行上下文。可以把执行上下文栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。

如果对以上两个概念还是很模糊的话建议先再去看一遍基础,我就假装你们都懂啦,接下来看~

实现原理

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。

<div id="example">{{message}}</div>
var vm = new Vue({
  el: '#example',
  data: {
    message: '123'
  }
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
  vm.$el.textContent === 'new message' // true
})

完整代码

注:2021.02.23从github上下载下来的源码,不同版本实现方式有些许差异。

// isUsingMicroTask : 是否将回调添加到宏任务队列
export let isUsingMicroTask = false

// 存储用户注册的回调
const callbacks = []
// 标记是否已向任务队列中添加了一个任务
let pending = false

// 将callbacks中的所有函数依次执行,然后情况列表
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 封装好的将任务添加到任务队列的函数
let timerFunc

// 支持promise就使用promise,微任务中优先级最高
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
	
    // noop是vue内部封装好的空函数
    if (isIOS) setTimeout(noop)
  }
  
  isUsingMicroTask = true
  
  // 如果不支持promise就用 MutationObserver,它会在指定的DOM发生变化时被调用
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  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
  // 如果不支持 MutationObserver 的话就用 setImmediate,但是这个特性只有最新版IE和node支持,此时降级为宏任务
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 如果上面这些都不支持的话就用setTimeout,此时降级为宏任务
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

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)		// 没有回调但支持promise时,执行promise的resolve
      // 这样做的原因:官方文档中说过,如果没有提供回调且在支持Promise的环境中,则返回一个Promise
    }
  })
  // 判断任务队列中没有任务
  if (!pending) {
    pending = true
    timerFunc()		// 将任务添加到任务队列
  }
  
  // 没有回调但支持promise时,_resolve为resolve函数
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

小疑问

为什么先考虑用setImmediate,最后再是setTimeout呢?

在每次轮训检查中,各观察者的优先级分别是:idle观察者 > I/O观察者 > check观察者。

idle观察者:process.nextTick

I/O观察者:一般性的I/O回调,如网络,文件,数据库I/O等

check观察者:setTimeout>setImmediate

那不应该是setTimeout优先级高吗?!

在HTML5规定setTimeout的最小间隔时间是4ms,也就是说0实际上也会别默认设置为最小值4ms。我们把这个延迟加大到一定程度,setImmediate就会早于setTimeout执行了,因为进入macro-task 循环的时候,setTimeout的定时器还没到。

在新版的Node中,process.nextTick执行完后,会循环遍历setImmediate,将setImmediate都执行完毕后再跳出循环。 ——《深入浅出Node.js》

🌰

来个例子巩固一下!(要是错了就尴尬了= =)

<template>
    <div>
        <ul>
            <li class="example" v-for="item in list1">{{item}}</li>
        </ul>
        <ul>
            <li class="example" v-for="item in list2">{{item}}</li>
        </ul>
        <ol>
            <li class="example" v-for="item in list3">{{item}}</li>
        </ol>
        <ol>
            <li class="example" v-for="item in list4">{{item}}</li>
        </ol>
        <ol>
            <li class="example" v-for="item in list5">{{item}}</li>
        </ol>
    </div>
</template>
<script type="text/javascript">
export default {
    data() {
        return {
            list1: [],
            list2: [],
            list3: [],
            list4: [],
            list5: []
        }
    },
    created() {
        this.composeList12()
        this.composeList34()
        this.composeList5()
        this.$nextTick(function() {
            // DOM 更新了
            console.log('finished test ' + new Date().toString(),document.querySelectorAll('.example').length)
        })
    },
    methods: {
        composeList12() {
            let me = this
            let count = 10000

            for (let i = 0; i < count; i++) {
                this.$set(me.list1, i, 'I am a 测试信息~~啦啦啦' + i)
            }
            console.log('finished list1 ' + new Date().toString(),document.querySelectorAll('.example').length)

            for (let i = 0; i < count; i++) {
                this.$set(me.list2, i, 'I am a 测试信息~~啦啦啦' + i)
            }
            console.log('finished list2 ' + new Date().toString(),document.querySelectorAll('.example').length)

            this.$nextTick(function() {
                // DOM 更新了
                console.log('finished tick1&2 ' + new Date().toString(),document.querySelectorAll('.example').length)
            })
        },
        composeList34() {
            let me = this
            let count = 10000

            for (let i = 0; i < count; i++) {
                this.$set(me.list3, i, 'I am a 测试信息~~啦啦啦' + i)
            }
            console.log('finished list3 ' + new Date().toString(),document.querySelectorAll('.example').length)

            this.$nextTick(function() {
                // DOM 更新了
                console.log('finished tick3 ' + new Date().toString(),document.querySelectorAll('.example').length)
            })

            setTimeout(me.setTimeout1, 0)
        },
        setTimeout1() {
            let me = this
            let count = 10000

            for (let i = 0; i < count; i++) {
                this.$set(me.list4, i, 'I am a 测试信息~~啦啦啦' + i)
            }
            console.log('finished list4 ' + new Date().toString(),document.querySelectorAll('.example').length)

            me.$nextTick(function() {
                // DOM 更新了
                console.log('finished tick4 ' + new Date().toString(),document.querySelectorAll('.example').length)
            })
        },
        composeList5() {
            let me = this
            let count = 10000

            this.$nextTick(function() {
                // DOM 更新了
                console.log('finished tick5-1 ' + new Date().toString(),document.querySelectorAll('.example').length)
            })

            setTimeout(me.setTimeout2, 0)
        },
        setTimeout2() {
            let me = this
            let count = 10000

            for (let i = 0; i < count; i++) {
                this.$set(me.list5, i, 'I am a 测试信息~~啦啦啦' + i)
            }
            console.log('finished list5 ' + new Date().toString(),document.querySelectorAll('.example').length)

            me.$nextTick(function() {
                // DOM 更新了
                console.log('finished tick5 ' + new Date().toString(),document.querySelectorAll('.example').length)
            })
        }
    }
}
</script>

参考

vue官方文档: cn.vuejs.org/v2/api/#Vue…

Vue.nextTick 的原理和用途: segmentfault.com/a/119000001…