• home > webfront > ECMAS > vue >

    Vue.js批量异步更新策略及 nextTick 原理

    Author:zhoulujun Date:

    个人认为angularJS与Vue双向绑定都是基于发布订阅模式,一个是劫持浏览器事件触发更新,一个是劫持数据更新触发的事件(object set get),数据更新都是异步批量更新,Vue的异步更新机制是如何实现的呢?

    之前我们学到了 Vue 更新数据是如何更新视图的

    vue运行机制

    响应式化 observe( )

    Vue作为一个MVVM框架,我们知道它的 Model 层和 View 层之间的桥梁 ViewModel 是做到数据驱动的关键,Vue的响应式是通过 Object.defineProperty 来实现,给被响应式化的对象设置 getter/setter ,当 render 函数被渲染的时候会触发读取响应式化对象的 getter 进行依赖收集,而在修改响应式化对象的时候会触发设置 settersetter 方法会 notify 它之前收集到的每一个 watcher 来告诉他们自己的值更新了,从而触发 watcher 的 update 去 patch 更新视图

    异步更新

    数据更新(setter)-> 通知依赖收集集合(Dep) -> 调用所有观察者(Watcher) -> 比对节点树(patch) -> 视图

    在更新视图这一步,使用异步更新策略

    <template>
      <div>
        <div>{{number}}</div>
        <div @click="handleClick">click</div>
      </div>
    </template>
    <script>
    export default {
        data () {
            return {
                number: 0
            };
        },
        methods: {
            handleClick () {
                for(let i = 0; i < 1000; i++) {
                    this.number++;
                }
            }
        }
    }
    </script>

    在for循环中,我们连续更改了1000次绑定数据 number ,如果使用同步更新,则需要1000次的 patch ,也就是1000次的 Diff ,1000次更新,这就很可怕了。

    所以,在 Vue 里使用异步更新的方法,每次触发某个数据的 setter 方法后,对应的 Watcher 对象其实会被 push 进一个队列 queue 中,在下一个 tick(代表一次异步) 的时候将这个队列 queue 全部拿出来 run( Watcher 对象的一个方法,用来触发 patch 操作)

    在依赖收集原理的响应式化方法 defineReactive 中的 setter 访问器中有派发更新 dep.notify() 方法,这个方法会挨个通知在 dep 的 subs 中收集的订阅自己变动的watchers执行update。一起来看看 update 方法的实现:

    // src/core/observer/watcher.js
    
    /* Subscriber接口,当依赖发生改变的时候进行回调 */
    update() {
      if (this.computed) {
        // 一个computed watcher有两种模式:activated lazy(默认)
        // 只有当它被至少一个订阅者依赖时才置activated,这通常是另一个计算属性或组件的render function
        if (this.dep.subs.length === 0) {       // 如果没人订阅这个计算属性的变化
          // lazy时,我们希望它只在必要时执行计算,所以我们只是简单地将观察者标记为dirty
          // 当计算属性被访问时,实际的计算在this.evaluate()中执行
          this.dirty = true
        } else {
          // activated模式下,我们希望主动执行计算,但只有当值确实发生变化时才通知我们的订阅者
          this.getAndInvoke(() => {
            this.dep.notify()     // 通知渲染watcher重新渲染,通知依赖自己的所有watcher执行update
          })
        }
      } else if (this.sync) {	  // 同步
        this.run()
      } else {
        queueWatcher(this)        // 异步推送到调度者观察者队列中,下一个tick时调用
      }
    }

    如果不是 computed watcher 也非 sync 会把调用update的当前watcher推送到调度者队列中,下一个tick时调用,看看 queueWatcher :

    // src/core/observer/scheduler.js
    
    /* 将一个观察者对象push进观察者队列,在队列中已经存在相同的id则
     * 该watcher将被跳过,除非它是在队列正被flush时推送
     */
    export function queueWatcher (watcher: Watcher) {
      const id = watcher.id
      if (has[id] == null) {     // 检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验
        has[id] = true
        queue.push(watcher)      // 如果没有正在flush,直接push到队列中
        if (!waiting) {          // 标记是否已传给nextTick
          waiting = true
          nextTick(flushSchedulerQueue)
        }
      }
    }
    
    /* 重置调度者状态 */
    function resetSchedulerState () {
      queue.length = 0
      has = {}
      waiting = false
    }

    这里使用了一个 has 的哈希map用来检查是否当前watcher的id是否存在,若已存在则跳过,不存在则就push到 queue 队列中并标记哈希表has,用于下次检验,防止重复添加。这就是一个去重的过程,比每次查重都要去queue中找要文明,在渲染的时候就不会重复 patch 相同watcher的变化,这样就算同步修改了一百次视图中用到的data,异步 patch 的时候也只会更新最后一次修改。

    这里的 waiting 方法是用来标记 flushSchedulerQueue 是否已经传递给 nextTick 的标记位,如果已经传递则只push到队列中不传递 flushSchedulerQueue 给 nextTick,等到 resetSchedulerState 重置调度者状态的时候 waiting 会被置回 false 允许 flushSchedulerQueue 被传递给下一个tick的回调,总之保证了 flushSchedulerQueue 回调在一个tick内只允许被传入一次。来看看被传递给 nextTick 的回调 flushSchedulerQueue 做了什么:

    // src/core/observer/scheduler.js
    
    /* nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers */
    function flushSchedulerQueue () {
      flushing = true
      let watcher, id
    
      queue.sort((a, b) => a.id - b.id)					// 排序
    
      for (index = 0; index < queue.length; index++) {	 // 不要将length进行缓存
        watcher = queue[index]
        if (watcher.before) {         // 如果watcher有before则执行
          watcher.before()
        }
        id = watcher.id
        has[id] = null                // 将has的标记删除
        watcher.run()                 // 执行watcher
        if (process.env.NODE_ENV !== 'production' && has[id] != null) {  // 在dev环境下检查是否进入死循环
          circular[id] = (circular[id] || 0) + 1     // 比如user watcher订阅自己的情况
          if (circular[id] > MAX_UPDATE_COUNT) {     // 持续执行了一百次watch代表可能存在死循环
            warn()								  // 进入死循环的警告
            break
          }
        }
      }
      resetSchedulerState()           // 重置调度者状态
      callActivatedHooks()            // 使子组件状态都置成active同时调用activated钩子
      callUpdatedHooks()              // 调用updated钩子
    }

    在 nextTick 方法中执行 flushSchedulerQueue 方法,这个方法挨个执行 queue 中的watcher的 run 方法。我们看到在首先有个 queue.sort() 方法把队列中的watcher按id从小到大排了个序,这样做可以保证:

    • 组件更新的顺序是从父组件到子组件的顺序,因为父组件总是比子组件先创建。

    • 一个组件的user watchers(侦听器watcher)比render watcher先运行,因为user watchers往往比render watcher更早创建

    • 如果一个组件在父组件watcher运行期间被销毁,它的watcher执行将被跳过

    在挨个执行队列中的for循环中,index < queue.length 这里没有将length进行缓存,因为在执行处理现有watcher对象期间,更多的watcher对象可能会被push进queue。

    那么数据的修改从model层反映到view的过程:数据更改 -> setter -> Dep -> Watcher -> nextTick -> patch -> 更新视图


    nextTick

    在 Vue 里,实现了一个 nextTick 函数,主要用来异步操作,参数为一个 callback 函数,会被存放在 callback 队列中,在下一个 tick 时触发队列中的所有 callback 事件。

    在 Vue 源码中,使用 setImmediate、MessageChannel、setTimeout 来实现 macroTimerFunc(nextTick 中使用的异步方法),使用 Promise 来实现 microTimerFunc ,感兴趣可以看看 next-tick 。

    注意:Vue文档中说明,在DOM更新之后立即执行callback函数,可以使用Vue.$nextTick(),我理解是应该是将这些callback函数,放在dom更新的函数后面

     setImmediate,这个方法只在 IE、Edge 浏览器中原生实现,

    为什么优先使用 setImmediate 与 MessageChannel 而不直接使用 setTimeout 呢,是因为HTML5规定setTimeout执行的最小延时为4ms,而嵌套的timeout表现为10ms,为了尽可能快的让回调执行,没有最小延时限制的前两者显然要优于 setTimeout。


    参考文章:

    Vue源码阅读 - 批量异步更新与nextTick原理 https://juejin.im/post/5b50760f5188251ad06b61be

    Vue源码阅读 - 文件结构与运行机制 https://juejin.im/post/5b38830de51d455888216675



    转载本站文章《Vue.js批量异步更新策略及 nextTick 原理》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/vue/8479.html