这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战
Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。并且当一个watcher被多次触发,只会被推入队列一次,然后在当前事件循环的宏任务结束后,调用刚刚推入的所有异步任务。
为什么渲染更新是异步执行?
为了提升性能,如果在主线程中更新DOM,循环10次就需要更新10次DOM。如果采用异步队列的话,只需要更新一次。
Vue 更新 DOM 原理
Vue官网对数据操作的描述:
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 更新完成后就会调用。
Vue.$nextTick()
语法:Vue.nextTick([callback, context])
参数:
{Function} [callback]:回调函数,不传时提供promise调用{Object} [context]:回调函数执行的上下文环境,不传默认是自动绑定到调用它的实例上。
Vue实例方法vm.$nextTick做了进一步封装,把context参数设置成当前Vue实例。
作用: 把我们放入其中的回调函数放在DOM更新之后执行
在数据更新操作之后,往往需要对更新后的DOM做一些操作,但数据更新之后DOM并不是立即更新的,所以直接定义对DOM的操作很可能不起作用,在数据变化之后使用Vue.$nextTick()函数,将对DOM的操作放到nextTick()函数的回调函数中,就可以在DOM更新完成之后调用定义的回调函数,完成对更新后DOM的操作。
例1:
<template>
<div class="hello">
<h2 class="myh2">{{test}}</h2>
<button @click = "onSubmit">点我</button>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
data() {
return {
test: '哈哈哈哈哈',
}
},
methods: {
onSubmit() {
console.log('before:', document.querySelector('.myh2').innerHTML);//1. before: 哈哈哈哈哈
this.test = '嘿嘿嘿嘿嘿'
console.log( 'after:', document.querySelector('.myh2').innerHTML);//2. after: 哈哈哈哈哈
this.$nextTick(() => {
console.log('nextTick:', document.querySelector('.myh2').innerHTML );//4. nextTick: 嘿嘿嘿嘿嘿
})
console.log('我是同步代码!');//3. 我是同步代码!
}
}
}
</script>
先搭建一个vue项目,在某个vue组建中写上述代码。
可以看出,在初始状态下,test的值是'哈哈哈哈哈',DOM上展示的也是'哈哈哈哈哈';点击button以后执行onSubmit(),第一个console.log输出'before: 哈哈哈哈哈';接下来,改变数据,将test赋值为'嘿嘿嘿嘿嘿',此时数据改变了,但同步代码还没执行完,所以DOM的值还未改变,仍是'哈哈哈哈哈',所以第二个console.log输出'after: 哈哈哈哈哈';接着执行下面的同步代码,this.$nextTick() 将回调函数放在DOM更新后执行;接着执行下面的同步代码,输出'我是同步代码!';此时所有的 同步代码执行完毕 ,也就是第一个宏任务执行完毕,开始执行异步任务的回调函数,在更新DOM后,执行this.$nextTick()的回调函数,输出'nextTick: 嘿嘿嘿嘿嘿'。
从这个例子我们可以看到,this.$nextTick()的作用就是把我们放入其中的回调函数放在DOM更新之后执行。那么,this.$nextTick()是怎么实现的呢?
Vue.$nextTick()原理分析
nextTick 的实现单独有一个JS文件来维护它,在src/core/util/next-tick.js中。
nextTick 源码主要分为两部分:
- 根据
能力检测,决定以哪种方式执行回调队列,并将回调队列的执行函数(flushCallbacks)赋值给timerFunc。 - 调用$nextTick时,向回调队列callbacks中
新增回调函数,执行timerFunc()。
1.根据能力检测,决定以哪种方式执行回调队列,并将回调队列的执行函数(flushCallbacks)赋值给timerFunc。
由于宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,再使用宏任务。
对当前环境进行不断的降级处理,尝试使用原生的Promise.then、MutationObserver和setImmediate,上述三个都不支持最后使用setTimeout;降级处理的目的都是将flushCallbacks函数放入微任务(判断1和判断2)或者宏任务(判断3和判断4),等待下一次事件循环时来执行。
Vue就在更新DOM的那个microtask后追加了我们自己的回调函数,从而确保我们的代码在DOM更新后执行。
// 空函数,可用作函数占位符
import { noop } from 'shared/util'
// 错误处理函数
import { handleError } from './error'
// 是否是IE、IOS、内置函数
import { isIE, isIOS, isNative } from './env'
// 使用 MicroTask 的标识符,这里是因为火狐在<=53时 无法触发微任务,在modules/events.js文件中引用进行安全排除
export let isUsingMicroTask = false
// 用来存储所有需要执行的回调函数
const callbacks = []
// 用来标志是否正在执行回调函数
let pending = false
/**
* 做了三件事:
* 1、将 pending 置为 false
* 2、清空 callbacks 数组
* 3、执行 callbacks 数组中的每一个函数(比如 flushSchedulerQueue、用户调用 nextTick 传递的回调函数)
*/
// 对callbacks进行遍历,然后执行相应的回调函数
function flushCallbacks () {
pending = false
// 这里拷贝的原因是:
// 有的cb 执行过程中又会往callbacks中加入内容
// 比如 $nextTick的回调函数里还有$nextTick
// 后者的应该放到下一轮的nextTick 中执行
// 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
const copies = callbcks.slice(0)
callbacks.length = 0
for(let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let timerFunc // 异步执行函数 用于异步延迟调用 flushCallbacks 函数,将 flushCallbacks 函数放入浏览器的异步任务队列中
// 在2.5中,我们使用(宏)任务(与微任务结合使用)。
// 然而,当状态在重新绘制之前发生变化时,就会出现一些微妙的问题
// (例如#6813,out-in转换)。
// 同样,在事件处理程序中使用(宏)任务会导致一些奇怪的行为
// 因此,我们现在再次在任何地方使用微任务。
// 优先使用 Promise
//判断1:是否原生支持Promise
if(typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// IOS 的UIWebView, Promise.then 回调被推入 microTask 队列,但是队列可能不会如期执行
// 因此,添加一个空计时器强制执行 microTask
if(isIOS) setTimeout(noop)
}
isUsingMicroTask = true
//判断2:是否原生支持MutationObserver
} else if(!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString === '[object MutationObserverConstructor]')) {
// 当 原生Promise 不可用时,使用 原生MutationObserver
// e.g. PhantomJS, iOS7, Android 4.4
let counter = 1
// 创建MO实例,监听到DOM变动后会执行回调flushCallbacks
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true // 设置true 表示观察目标的改变
})
// 每次执行timerFunc 都会让文本节点的内容在 0/1之间切换
// 切换之后将新值复制到 MO 观测的文本节点上
// 节点内容变化会触发回调
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter) // 触发回调
}
isUsingMicroTask = true
//判断3:是否原生支持setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
// 判断4:上面都不支持,直接使用setTimeout
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
2.调用$nextTick时,向回调队列callbacks中新增回调函数,执行timerFunc()。:
/**
* 完成两件事:
* 1、用 try catch 包装 函数,然后将其放入 callbacks 数组
* 2、如果 pending 为 false,表示现在浏览器的任务队列中没有 flushCallbacks 函数
* 如果 pending 为 true,则表示浏览器的任务队列中已经被放入了 flushCallbacks 函数,
* 待执行 flushCallbacks 函数时,pending 会被再次置为 false,表示下一个 flushCallbacks 函数可以进入
* 浏览器的任务队列了
* pending 的作用:保证在同一时刻,浏览器的任务队列中只有一个 flushCallbacks 函数
* @param {*} cb 接收一个回调函数
* @param {*} ctx 上下文
* @returns
*/
————————————————
const callbacks = []
let pending = false
export function nextTick(cb? Function, ctx: Object) {
let _resolve
// cb 回调函数会统一处理压入callbacks数组
callbacks.push(() => {
if(cb) {
try {
cb.call(ctx)
} catch(e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// pending 为false 说明本轮事件循环中没有执行过timerFunc(),pending用来标识同一个时间只能执行一次timerFunc。
if(!pending) {
pending = true
// 执行 timerFunc,在浏览器的任务队列中(首选微任务队列)放入 flushCallbacks 函数
timerFunc()
}
// 当不传入 cb 参数时,提供一个promise化的调用
// 如nextTick().then(() => {})
// 当_resolve执行时,就会跳转到then逻辑中
if(!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
next-tick.js文件对外暴露了nextTick这一函数,所以当我们调用Vue.nextTick或this.$nextTick时会执行:
-
若传入了回调函数cb,把传入的回调函数
cb压入callbacks数组。 -
若没有传入回调函数cb,提供一个promise化的调用,如nextTick().then(() => {}),当_resolve执行时,就会跳转到then逻辑中。
这里的代码,
_resolve = resolve,个人理解:赋值(基本数据类型:赋值;引用数据类型:赋址),resolve是函数,函数也是对象是引用类型,所以这里 _ _resolve引用了resolve函数,当 _ _resolve执行时也就是resolve执行了,所以会跳转到hen逻辑中。 -
执行timerFunc()
timerFunc是存储了
flushCallbacks 函数的变量,所以执行timerFunc()就会执行flushCallbacks 函数。flushCallbacks 函数的作用是对callbacks进行遍历,然后执行相应的回调函数(若没有回调函数,执行 _resolve,进入then逻辑中)。
这里的
callbacks没有直接在nextTick中执行回调函数的原因是保证在同一个tick内多次执行nextTick,不会开启多个异步任务,而是把这些异步任务都压成一个同步任务,在下一个tick执行完毕。
附加分析
MutationObserver
简单介绍下MutationObserver:MO是HTML5中的API,是一个用于监视DOM变动的接口,它可以监听一个DOM对象上发生的子节点删除、属性修改、文本内容修改等。
调用过程是要先给它绑定回调,得到MO实例,这个回调会在MO实例监听到变动时触发。这里MO的回调是微任务,是放在microtask中执行的。
// 创建MO实例
const observer = new MutationObserver(callback)
const textNode = '想要监听的Don节点'
observer.observe(textNode, {
characterData: true // 说明监听文本内容的修改
})
setImmediate(fn, 0)
在循环事件任务完成后马上运行指定代码.该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数.
应用场景
-
数据变化后操作DOM
原因:Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变,如果同一个watcher被多次触发,只会被推入到队列中一次。
-
created()钩子函数中操作DOM
原因:
created()钩子函数执行时DOM并未进行渲染。在Vue生命周期的created()钩子函数进行DOM操作一定要放到Vue.nextTick()的回调函数中。在created()钩子函数执行的时候DOM 其实并未进行任何渲染,而此时进行DOM操作无异于徒劳,所以此处一定要将DOM操作的js代码放进Vue.nextTick()的回调函数中。与之对应的就是mounted()钩子函数,因为该钩子函数执行时所有的DOM挂载和渲染都已完成,此时在该钩子函数中进行任何DOM操作都不会有问题。