前言
我们都知道vue是数据驱动视图,而vue中视图更新是异步的。在业务开发中,有没有经历过当改变了数据,视图却没有按照我们的期望渲染?而需要将对应的操作放在nextTick中视图才能按照预期的渲染,有的时候nextTick也不能生效,而需要利用setTimeout来解决?
搞清楚这些问题,那么就需要搞明白以下几个问题:
1、vue中到底是如何来实现异步更新视图;
2、vue为什么要异步更新视图;
3、nextTick的原理;
4、nextTick如何来解决数据改变视图不更新的问题的;
5、nextTick的使用场景。
以下分享我的思考过程。
Vue中的异步更新DOM
Vue中的视图渲染思想
vue中每个组件实例都对应一个 watcher实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
如果对vue视图渲染的思想还不是很清楚,可以参考这篇defineProperty实现视图渲染用defineProty模拟的Vue的渲染视图,来了解整个视图渲染的思想。
Vue异步渲染思想和意义
但是Vue的视图渲染是异步的,异步的过程是数据改变不会立即更新视图,当数据全部修改完,最后再统一进行视图渲染。
在渲染的过程中,中间有一个对虚拟dom进行差异化的计算过程(diff算法),大量的修改带来频繁的虚拟dom差异化计算,从而导致渲染性能降低,异步渲染正是对视图渲染性能的优化。
Vue异步渲染视图的原理
- 依赖数据改变就会触发对应的watcher对象中的update
/** * Subscriber interface. * Will be called when a dependency changes. */ update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { this.run() } else { queueWatcher(this) } }
- 默认的调用queueWatcher将watcher对象加入到一个队列中
/** * Push a watcher into the watcher queue. * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. */ export function queueWatcher (watcher: Watcher) { const id = watcher.id 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. let 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) } } }
当第一次依赖有变化就会调用nextTick方法,将更新视图的回调设置成微任务或宏任务,然后后面依赖更新对应的watcher对象都只是被加入到队列中,只有当nextTick回调执行之后,才会遍历调用队列中的watcher对象中的更新方法更新视图。
这个nextTick和我们在业务中调用的this.$nextTick()是同一个函数。
if (!waiting) { waiting = true nextTick(flushSchedulerQueue) }
flushSchedulerQueue刷新队列的函数,用于更新视图
function flushSchedulerQueue () { flushing = true let watcher, id // Sort queue before flush. // This ensures that: // 1. Components are updated from parent to child. (because parent is always // created before the child) // 2. A component's user watchers are run before its render watcher (because // user watchers are created before the render watcher) // 3. If a component is destroyed during a parent component's watcher run, // its watchers can be skipped. queue.sort((a, b) => a.id - b.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. if (process.env.NODE_ENV !== 'production' && has[id] != null) { circular[id] = (circular[id] || 0) + 1 if (circular[id] > MAX_UPDATE_COUNT) { warn( 'You may have an infinite update loop ' + ( watcher.user ? `in watcher with expression "${watcher.expression}"` : `in a component render function.` ), watcher.vm ) break } } }
那么nextTick到底是个什么东西呢?
nextTick的原理
vue 2.5中nextTick的源码如下(也可以跳过源码直接看后面的demo,来理解nextTick的用处):
/** * Defer a task to execute it asynchronously. */ export const nextTick = (function () { const callbacks = [] let pending = false let timerFunc function nextTickHandler () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } // An asynchronous deferring mechanism. // In pre 2.4, we used to use microtasks (Promise/MutationObserver) // but microtasks actually has too high a priority and fires in between // supposedly sequential events (e.g. #4521, #6690) or even between // bubbling of the same event (#6566). Technically setImmediate should be // the ideal choice, but it's not available everywhere; and the only polyfill // that consistently queues the callback after all DOM events triggered in the // same loop is by using MessageChannel. /* istanbul ignore if */ if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { setImmediate(nextTickHandler) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // Phantomjs MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = nextTickHandler timerFunc = () => { port.postMessage(1) } } else /* istanbul ignore next */ if (typeof Promise !== 'undefined' && isNative(Promise)) { // use microtask in non-DOM environments, e.g. Weex const p = Promise.resolve() timerFunc = () => { p.then(nextTickHandler) } } else { // fallback to setTimeout timerFunc = () => { setTimeout(nextTickHandler, 0) } } return function queueNextTick (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, reject) => { _resolve = resolve }) } } })()
用下面这个demo来感受依赖更新时和nextTick的关系以及nextTick的用处:
function isNative(Ctor) { return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) } const nextTick = (function () { let pending = false; let callbacks = [] let timerFunc function nextTickHandler() { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { setImmediate(nextTickHandler) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // Phantomjs MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = nextTickHandler timerFunc = () => { port.postMessage(1) } } else /* istanbul ignore next */ if (typeof Promise !== 'undefined' && isNative(Promise)) { // use microtask in non-DOM environments, e.g. Weex const p = Promise.resolve() timerFunc = () => { p.then(nextTickHandler) } } else { // fallback to setTimeout timerFunc = () => { setTimeout(nextTickHandler, 0) } } console.log('timerFunc:', timerFunc) return function queueNextTick(cb, ctx) { callbacks.push(() => { if (cb) { cb.call(ctx) } }) // console.log('callbacks:', callbacks) if (!pending) { pending = true console.log('pending...', true) timerFunc() } } })() // 模拟异步视图更新 // 第一次先将对应新值添加到一个数组中,然后调用一次nextTick,将读取数据的回调作为nextTick的参数 // 后面的新值直接添加到数组中 console.time() let arr = [] arr.push(99999999) nextTick(() => { console.log('nextTick one:', arr, arr.length) }) function add(len) { for (let i = 0; i < len; i++) { arr.push(i) console.log('i:', i) } } add(4) // console.timeEnd() // add() // add() nextTick(() => { arr.push(888888) console.log('nextTick two:', arr, arr.length) }) add(8)的值之后 console.timeEnd()
在chrome运行结果如下:
可以看到第二个nextTick中push的值最后渲染在add(8)的值之后,这也就是nextTick的作用了,nextTick的作用就是用来处理需要在数据更新(在vue中手动调用nextTick时对应的是dom更新完成后)完才执行的操作。
nextTick的原理:
首先nextTick会将外部传进的函数回调存在内部数组中,nextTick内部有一个用来遍历这个内部数组的函数nextTickHandler,而这个函数的执行是异步的,什么时候执行取决于这个函数是属于什么类型的异步任务:微任务or宏任务。
主线程执行完,就会去任务队列中取任务到主线程中执行,任务队列中包含了微任务和宏任务,首先会取微任务,微任务执行完就会取宏任务执行,依此循环。nextTickHandler设置成微任务或宏任务就能保证其总是在数据修改完或者dom更新完然后再执行。(js执行机制可以看promise时序问题&js执行机制)
为什么vue中对设置函数nextTickHandler的异步任务类型会有如下几种判断?
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { setImmediate(nextTickHandler) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = nextTickHandler timerFunc = () => { port.postMessage(1) } } else /* istanbul ignore next */ if (typeof Promise !== 'undefined' && isNative(Promise)) { // use microtask in non-DOM environments, e.g. Weex const p = Promise.resolve() timerFunc = () => { p.then(nextTickHandler) } } else { // fallback to setTimeout timerFunc = () => { setTimeout(nextTickHandler, 0) } }
浏览器环境中常见的异步任务种类,按照优先级:
- macro task:同步代码、setImmediate、MessageChannel、setTimeout/setInterval
- micro task:Promise.then、MutationObserver
而为什么最后才判断使用setTimeout?
vue中目的就是要尽可能的快地执行回调渲染视图,而setTimeout有最小延迟限制:如果嵌套深度超过5级,setTimeout(回调,0)就会有4ms的延迟。
所以首先选用执行更快的setImmediate,但是setImmediate有兼容性问题,目前只支持Edge、Ie浏览器:
可以用同样执行比setTimeout更快的宏任务MessageChannel来代替setImmediate。MessageChannel兼容性如下:
当以上都不支持的时候,就使用new Promise().then(),将回调设置成微任务,Promise不支持才使用setTimeout。
资源搜索网站大全 https://www.renrenfan.com.cn 广州VI设计公司https://www.houdianzi.com
总结:
nextTick就是利用了js机制执行任务的规则,将nextTick的回调函数设置成宏任务或微任务来达到在主线程的操作执行完,再执行的目的。
在vue中主要提供对依赖Dom更新完成后再做操作的情况的支持
nextTick的使用场景
当改变数据,视图没有按预期渲染时;都应该考虑是否是因为本需要在dom执行完再执行,然而实际却在dom没有执行完就执行了代码,如果是就考虑使用将逻辑放到nextTick中,有的时候业务操作复杂,有些操作可能需要更晚一些执行,放在nextTick中仍然没有达到预期效果,这个时候可以考虑使用setTimeout,将逻辑放到宏任务中。
基于以上分析,可以列举几个nextTick常用到的使用场景:
- 在created、mounted等钩子函数中使用时。
- 对dom进行操作时,例如:使用$ref读取元素时
// input 定位 scrollToInputBottom() { this.$nextTick(() => { this.$refs.accept_buddy_left.scrollTop = this.$refs.accept_buddy_left.scrollTop + 135 this.$refs.accept_buddy_ipt[ this.$refs.accept_buddy_ipt.length - 1 ].$refs.ipt.focus() }) },
- 计算页面元素高度时:
// 监听来自 url 的期数变化,跳到该期数 urlInfoTerm: { immediate: true, handler(val) { if (val !== 0) { this.$nextTick(function() { // 计算期数所在位置的高度 this.setCellsHeight() //设置滚动距离 this.spaceLenght = this.getColumnPositionIndex( this.list, ) setTimeout(() => { this.setScrollPosition(val) }, 800) }) } },