Vue 之异步更新 DOM 策略

create on in source_code with 0 comment and 369 view

在我们使用 vue 时,有时我们需手动的去获取某个 DOM 节点。

例如:

<template> <div> <div ref="test">{{test}}</div> <button @click="handleClick">button</button> </div> </template>
export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; console.log(this.$refs.test.innerText);// 打印begin } } }

可看到,通过 ref 得到文本内容并不是刚设置的end,这是为什么呢?在 vue 文档中,对此进行了解释 异步更新队列

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

这段解释中说了了一些重要概念,watcher, 队列,tick等等。
对于 在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作 即是使用异步更新的重要原因,举个栗子:

export default { data () { return { test: 0 }; }, mounted () { for(let i = 0; i < 1000; i++) { this.test++; } } }

若使用同步更新视图,每次 test 发生更改,则会执行 setter -> dep -> watcher -> diff -> patch -> render 这样的过程 1000 次,这是非常消耗性能的,若采用异步操作的话,可将这些变化过程看作一次更改, 即 test 从 0 变成 1000, 这样就只用执行一次这个过程。

接下来, 来看看具体的实现原理。

Watcher队列

在上篇vue - 数据绑定中,提及过 render watcher,除此之外还有user watcher,即用户在 vue 中自定义监听 data。例如:

data() { return { test: '123' } }, watch: { test: (newval) => { console.log(newval) } }

监听 test ,发生更改时执行回调。
当 data 发生变更时,会通知其 setter 闭包的 Dep,Dep 通知它所管理的所有 Watcher 对象调用 update。

// watcher update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { /*同步则执行run直接渲染视图*/ this.run() } else { /*异步推送到观察者队列中,下一个tick时调用。*/ queueWatcher(this) } }

vue 默认使用异步更新,调用 queueWatcher, 将 watcher 加入队列。

/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/ export function queueWatcher (watcher: Watcher) { /*获取watcher的id*/ const id = watcher.id /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/ if (has[id] == null) { has[id] = true if (!flushing) { /*如果没有flush掉,直接push到队列中即可*/ 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. // 在 flushing queue 时,产生新的 watcher, 将它加入到队列中合适的位置。 let i = queue.length - 1 while (i >= 0 && queue[i].id > watcher.id) { i-- } queue.splice(Math.max(i, index) + 1, 0, watcher) } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } } }

相同 id 的 watcher 只会加入队列一次,因为,watcher 只关心最终的数据。

在一次事件栈中,无论对 data 内的一个或多个属性更改,对于 watcher 来说,只需拿到最终的 data 进行回调操作即可。

使用 waiting 来表示队列的状态,异步执行调度者 flushSchedulerQueue,此时,若后续还有 watcher, 依然继续加入队列。

nextTick

nextTick 的作用是在 microtask 或者 macrotask 中推入一个function。

/** * Defer a task to execute it asynchronously. */ /* 延迟一个任务使其异步执行,在下一个tick时执行,一个立即执行函数,返回一个function 这个函数的作用是在 macrotask 或者 microtask 中推入一个Function,在当前调用栈执行完以后依次执行直到timerFunc 目的是延迟到当前调用栈执行完以后执行 */ export const nextTick = (function () { /*存放异步执行的回调*/ const callbacks = [] /*一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送*/ let pending = false /*一个函数指针,指向函数将被推送到任务队列中,等到主线程任务执行完时,任务队列中的timerFunc被调用*/ let timerFunc /*下一个tick时的回调*/ function nextTickHandler () { /*一个标记位,标记等待状态(即函数已经被推入任务队列或者主线程,已经在等待当前栈执行完毕去执行),这样就不需要在push多个回调到callbacks时将timerFunc多次推入任务队列或者主线程*/ pending = false /*执行所有callback*/ const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } // the nextTick behavior leverages the microtask queue, which can be accessed // via either native Promise.then or MutationObserver. // MutationObserver has wider support, however it is seriously bugged in // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It // completely stops working after triggering a few times... so, if native // Promise is available, we will use it: /* istanbul ignore if */ /* 这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法 优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法都会在microtask中执行,会比setTimeout更早执行,所以优先使用。 如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。 参考:https://www.zhihu.com/question/55364497 */ if (typeof Promise !== 'undefined' && isNative(Promise)) { /*使用Promise*/ var p = Promise.resolve() var logError = err => { console.error(err) } timerFunc = () => { p.then(nextTickHandler).catch(logError) // in problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop) } } else if (typeof MutationObserver !== 'undefined' && ( isNative(MutationObserver) || // PhantomJS and iOS 7.x MutationObserver.toString() === '[object MutationObserverConstructor]' )) { // use MutationObserver where native Promise is not available, // e.g. PhantomJS IE11, iOS7, Android 4.4 /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/ var counter = 1 var observer = new MutationObserver(nextTickHandler) var textNode = document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true }) timerFunc = () => { counter = (counter + 1) % 2 textNode.data = String(counter) } } else { // fallback to setTimeout /* istanbul ignore next */ /*使用setTimeout将回调推入任务队列尾部*/ timerFunc = () => { setTimeout(nextTickHandler, 0) } } /* 推送到队列中下一个tick时执行 cb 回调函数 ctx 上下文 */ return function queueNextTick (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) } }) if (!pending) { pending = true timerFunc() } if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => { _resolve = resolve }) } } })()

它是一个立即执行函数, 返回一个queueNextTick接口。作用是将 function 加入到 callbacks 队列,并使用 pending 作为 callbacks 的状态标识。
在闭包内,根据执行环境筛选所支持的异步方法,得到一个 timerFunc 函数。

注:一共有Promise、MutationObserver(Vue 中如何使用 MutationObserver 做批量处理) 以及 setTimeout 三种尝试得到 timerFunc 的方法。 优先使用Promise,最坏的情况使用 setTimeout。
使用 setTimeout 的缺点在于:可能导致渲染两次。因为,浏览器的 UI 渲染(I/O操作)属于一种 macrotask,根据 HTML Standard,在每个有 DOM 操作的 macrotask 运行完以后,UI 都会重渲染。所以,若使用 setTimeout 来执行异步队列,可能在此之前已经进行过一次 UI渲染,使用 microtask,可让数据更新在UI渲染之前完成。
setTimeout:
macrotask -> UI渲染(macrotask) -> 执行队列(macrotask) -> UI渲染(macrotask)
promise/MutationObserver:
macrotask -> 执行队列(microtask) -> UI渲染(macrotask)

flushSchedulerQueue

/** * Flush both queues and run the watchers. */ /*nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers*/ 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排序,这样做可以保证: 1.组件更新的顺序是从父组件到子组件的顺序,因为父组件总是比子组件先创建。 2.一个组件的user watchers比render watcher先运行,因为user watchers往往比render watcher更早创建 3.如果一个组件在父组件watcher运行期间被销毁,它的watcher执行将被跳过。 */ queue.sort((a, b) => a.id - b.id) // do not cache length because more watchers might be pushed // as we run existing watchers /*这里不用index = queue.length;index > 0; index--的方式写是因为不要将length进行缓存,因为在执行处理现有watcher对象期间,更多的watcher对象可能会被push进queue*/ for (index = 0; index < queue.length; index++) { watcher = queue[index] id = watcher.id /*将has的标记删除*/ has[id] = null /*执行watcher*/ watcher.run() // in dev build, check and stop circular updates. /* 在测试环境中,检测watch是否在死循环中 比如这样一种情况 watch: { test () { this.test++; } } 持续执行了一百次watch代表可能存在死循环 */ 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 } } } // keep copies of post queues before resetting state /**/ /*得到队列的拷贝*/ const activatedQueue = activatedChildren.slice() const updatedQueue = queue.slice() /*重置调度者的状态*/ resetSchedulerState() // call component updated and activated hooks /*使子组件状态都改编成active同时调用activated钩子*/ callActivatedHooks(activatedQueue) /*调用updated钩子*/ callUpdateHooks(updatedQueue) // devtool hook /* istanbul ignore if */ if (devtools && config.devtools) { devtools.emit('flush') } }

flushSchedulerQueue是下一个tick时的回调函数,主要目的是执行Watcher的run函数,用来更新视图。

总结

再回过头来看看官方文档的这段话:

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

其中,队列指的是 watcher 列表,相同 id 的 watcher 在队列中只能有一个,依此来避免不必要的 UI 渲染。vue 根据环境来获得更好的异步方法,其中,Promise, MessageChannel 属于 microtask, setTimeout 属于 task,选择优先级为 Promise > MessageChannel > setTimeout,当当前栈执行完后,去到 event loop 中找到下一个 tick,进行实际的工作(已去重的 watcher 的回调)。

😁😂😃😄😅😆😇😈😉😐😑😒😓😔😕😖😗😘😙😠😡😢😣😤😥😦😧😨😩😰😱😲😳😴😵😶😷😸😹🙀🙁🙂🙃🙄🙅🙆🙇🙈
🙂