浅曦 Vue 源码 - 43-patch 阶段 - 异步队列更新 & 性能优化

一、背景

Vue 如何组织队列更新,主要依托于下面几个方法:

1.Watcher.prototype.update,当响应式数据发生变化,其对应的 dep.notify 执行,watcher.update 会调用 queueWatcher;
2.queueWatcher 负责把 watcher 实例加入到待求值的 watcher 队列 queue 中,添加到队列需要根据当前队列是否处于刷新状态做不同的处理;
3.queueWatcher 还会调用 nextTick 方法,传入消耗 queue 队列的 flushSchedulerQueue 方法;
4.nextTick 会把 flushSchedulerQueue 包装然后放到 callbacks 队列,nextTick 另一个重要任务就是把消耗 callbacks 队列的 flushCallback 放入到下一个事件循环(或者下一个事件循环的开头,即微任务);

总结起来就两件事:

响应式数据发生变化,将依赖它的 watcher 放到 queue 队列;
nextTick 把消耗 queue 的 flushSchedulerQueue 放到 callbacks 队列,同时把消耗 callbacks 队列的 flushCallbacks 方法放到下个事件循环(或事件环的开头)
听完这些感觉已经很明白了,但是现在有两个具体的问题需要分析一番:

如果在一个用户 watcher 中修改某一个 渲染 watcher 依赖的响应式数据,这个渲染 watcher 会被多次添加到 queue 吗?

在一个 tick 中多次修改同一个被渲染 watcher 依赖的响应式数据(或者修改多个不同的响应式数据)那么渲染 watcher 会被多次添加到 queue 队列中吗?

很多人在看 Vue 面试题的时候都看到过一句话:Vue 会合并当前事件循环中的所有更新,只触发一次依赖它的 watcher;

所以答案很显然:是不会多次添加的,今天我们就来掰扯掰扯为什么不会?

二、用户 watcher 修改响应式数据

先来看一段示例代码:

这个示例代码是想表达:

渲染 watcher 依赖了 forProp.a 以及条件渲染的 imgFlag,即

{{froProp.a}}

当点击 button 按钮时,更新响应式数据 forProp.a 属性,使之 ++;
forProp.a 的变化就会触发用户 watcher 即 forProp.a(nv, ov) {…},用户 watcher 会在触发时更新 imgFlag;
首先 forProp.a 变化,渲染 watcher 肯定会被 push 到 queue 队列,那么用户 watcher 执行时会不会再次把渲染 watcher push 到 queue 队列,即 queue 中有两个渲染 watcher ?

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>Vue</title>
</head>
<body>
<div id="app">
 <button @click="goForPatch">使 forProp.a++</button>
 <div v-if="imgFlag"> ===> {{forProp.a}}</div>
 <img v-else
    :src="imgSrc"
    onload="console.log('img onload')">
</div>
<script src="./dist1/vue.js"></script>
<script>
 const src = 'https://gift-static.hongyibo.com.cn/static/ad_oss/image-1004-294/6139ce3ed297e/16311783028626.jpg'
 debugger
 new Vue({
    el: '#app',
    forProp: {
      a: 100
    },
   imgFlag: false,
   imgSrc: src
  },  
  methods: {
    goForPatch () {
      this.forProp.a++
    }
  },
  
  watch: {
    // 这个 watch 选项就是 用户 watcher,
    // 有别于 Vue 自己创建的渲染 watcher、计算属性对应的 lazy watcher
    'forProp.a' (nv, ov) {
      this.imgFlag = !this.imgFlag
     }
   }
 })
</script>
</body>
</html>
复制代码

2.1 queueWatcher 的 has [id]、waiting

2.1.1 watch.id

每个 watcher 被创建时,都会获取一个唯一自增的 id,这个值是唯一的,无论是用户 watcher 还是 渲染 watcher 都有;

2.1.2 has[id]

前面的 forProp.a++ 使得 forProp.a 的 setter 被触发,最终调用 dep.notity -> watcher.update -> queueWatcher(this);

queueWatcher 把 this(watcher 实例)添加到 queue,在添加之前会判断缓存对象 has 中是否已经存在该 watcher.id,如果判断出 has[id] 不存在,再 push 到 queue,并且 has[id] = watcher.id;

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id

  // 如果 watcher 已经存在,则跳过,不会重复进入 queue
  if (has[id] == null) {
    // 缓存 watcher.id,用于判断 watcher 是否已经进入队列
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    }
  }
}
复制代码

2.2 用户 watcher 和 渲染 watcher 的顺序

从上一篇讲述 消耗 queue 队列的 flushSchedulerQueue 方法中的得知,在触发 watcher 重新求值前会有一个给 queue 中的 watcher 按照 id 进行升序排序,所以 id 小的 watcher 将会被先执行;

所以现在问题变成了 用户 watcher 和 渲染 watcher 的 id 谁更小的问题。这个问题答案很显然,是用户 watcher id 更小。

在 Vue watcher 的 id 是个自增的值,先被创建的 watcher 的 id 会更小; 用户 watcher 是在初始化时初期进行响应式数据初始化的过程中创建的,而渲染 watcher 是在挂载阶段创建的,所以用户 watcher id 更小;

这里我们假设用户 watcher id 为 4,渲染 watcher 的 id 为 5;

此时缓存 watcher id 的 has 对象:{ 4: true, 5: true };

2.3 消耗 queue 队列

综上,当 flushSchedulerQueue 方法执行时,开始遍历排序后的 queue 队列执行 queue 中每一项 watcher.run() 方法,因为用户 watcher id 较小,所以就会先执行用户 watcher 的回调: forProp.a(nv, ov) { this.imgFlag = !this.imgFlag }。

imgFlag 被重新赋值,就会触发 imgFlag 这个响应式数据的 setter,进而触发 dep.notify(),notify() 执行会触发 watcher.update(),调用流程如下:

this.imgFlag = !this.imgFlag;
  -> imgFlag setter () 
    -> dep.notify()
      -> watcher.update()
        -> queueWatcher(this) this 是渲染 watcher,其 id  5
          -> if (has[id] == null) 不成立,因为 has = { 4: true, 5: true }
复制代码
export function queueWatcher (watcher: Watcher) {
  // 此时 watcher 是渲染 watcher,id  5
  const id = watcher.id

  // 因为 has = { 4: true, 5: true },
  // 由于 imgFlag 变更时,渲染 watcher 已经在 queue 了,
  // 所以不会重复将渲染 watcher 放入 queue
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    }
  }
}
复制代码

2.4 总结

因为 watcher 被放入到 queue 前经过了判重处理,同时因为用户 watcher 的执行时机早于渲染 watcher,所以在用户 watcher 中修改渲染 watcher 依赖的数据时,不会多次将渲染 watcher 放入到 queue;

这么做的好处显而易见了,这就能够避免用户 watcher 中修改响应式数据导致页面刷新多次,这就减少了非常大的性能开销。

这里还有一个隐藏条件:当渲染 watcher 执行时,就能拿到用户 watcher 更新后的响应式数据最新值,这是为啥?因为用户 watcher 和 渲染 watcher 是同步串行的。

三、合并一个 tick 多次修改

3.1 一个 tick 多次修改同一个数据

先看一个例子:

这个例子很简单,当点击 button 按钮时,对 this.forProp++ 两次,此时分析一下会不会向 queue 中添加两次同一个渲染 watcher,同样我们假设渲染 watcher 的 watcher.id 为 5;

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>Vue</title>
</head>
<body>
<div id="app">
 <button @click="goForPatch">使 forProp.a++</button>
 <div> ===> {{forProp.a}}</div>
</div>
<script src="./dist1/vue.js"></script>
<script>
  debugger
  new Vue({
    el: '#app',
    data: {
      forProp: {
        a: 100
      },
    },

    methods: {
      goForPatch () {
        this.forProp.a++
        this.forProp.a++
      }
    }
  })
</script>
</body>
</html>
复制代码

点击事件触发时,this.forProp.a 第一次被 ++ 时,

this.forProp.a++
  -> forProp.a  setter()
   -> dep.notify()
    -> 渲染 watcher.update()
     -> queueWatcher(this)
      -> if (has[id] == null) 成立
      ->  has[5] = true
复制代码
第二次 this.forProp.a  ++ 时,还会走一变和上面类似的步骤,但是 has[5] == null 不成立了:

this.forProp.a++
  -> forProp.a  setter()
   -> dep.notify()
    -> 渲染 watcher.update()
     -> queueWatcher(this)
      -> if (has[id] == null) 不成立,has[5] = true
复制代码

虽然 this.forProps.a 在同一个 tick 中被 ++ 两次,但是最终 queue 中只有一个渲染 watcher;这个也就是常说的 Vue 性能优化的一个重要手段:合并同一个 tick 中对同一个响应式数据的多次更新。

为啥称之为合并呢?当渲染 watcher 真正触发重新求值的时候,已经是在多次更新响应式数据的 tick 之后的下一个 tick 了,此时渲染 watcher 重新求值,获取到的就是上一个 tick 中响应式数据的最新值,至于在最新值之前的值通通被渲染 watcher 忽略掉了,因为渲染 watcher 从来就不知道这个响应式数据有这么多的前任。

3.2 一个 tick 修改多个不同数据

这个原理同样被应用到在一个 tick 中一次性修改多个响应式数据,比如 this.forProp.a++ 然后 this.imgFlag = !this.imgFlag,这两个步骤都触发了各自的 setter,但是因为渲染 watcher 已经存在 queue 的原因,不会被重复添加,渲染 watcher 最后还是只有一个;

四、总结

深入理解 nextTick 精妙设计所在的过程。另外,也解答了何为合并多次修改的性能优化,其核心实现如下:

watcher 被 push 到 queue 之前有一步判断重复处理,即 has[id] == null;
watcher 被成功推入 queue 之后 has[id] = true,可保证下次 push 这个 watcher 时不会通过上一步的判重,因而不会重复加入 queue;
当 queue 中的 watcher 被重新求值前,会按 id 进行升序,用户 wacher 的 id 小于 渲染 watcher 的 id,所以用户 watcher 放心大胆的改响应式数据,同样是由于 has[渲染watcher id] = true 故而不会将渲染 watcher 多次加入 queue;
最后,每个 watcher 从 queue 取出并重新求值后 has[被取出 watcher id] 被置为 null,这就保证下个 tick 中修改响应式数据时,这些 watcher 又可以被重新添加到 queue;
源码附件已经打包好上传到百度云了,大家自行下载即可~

链接: https://pan.baidu.com/s/14G-bpVthImHD4eosZUNSFA?pwd=yu27
提取码: yu27
百度云链接不稳定,随时可能会失效,大家抓紧保存哈。

如果百度云链接失效了的话,请留言告诉我,我看到后会及时更新~

开源地址

码云地址:
http://github.crmeb.net/u/defu

Github 地址:
http://github.crmeb.net/u/defu

开源不易,Star 以表尊重,感兴趣的朋友欢迎 Star,提交 PR,一起维护开源项目,造福更多人!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
`Vue.nextTick`是Vue提供的一个API,它可以让我们在DOM更新之后执行一些操作。在Vue中,当数据发生变化时,Vue会异步执行DOM更新操作。也就是说,当我们修改了Vue实例中的某个属性,Vue并不会立即去更新DOM,而是先将这个更新操作加入到一个队列中,在下一个事件循环时,Vue会清空这个队列,依序执行其中的更新操作。 由于Vue的异步更新机制,有时候我们需要在DOM更新之后执行一些操作,比如获取更新后的DOM节点的尺寸或位置等。此时,我们就可以使用`Vue.nextTick`来确保这些操作是在DOM更新后执行的。 下面是一个使用`Vue.nextTick`的例子: ```javascript new Vue({ el: &#39;#app&#39;, data: { message: &#39;Hello Vue.js!&#39; }, methods: { updateMessage: function () { this.message = &#39;Updated!&#39; this.$nextTick(function () { // DOM已经更新 console.log(this.$el.textContent) // &quot;Updated!&quot; }) } } }) ``` 在`updateMessage`方法中,我们首先修改了`message`属性的值,然后调用了`this.$nextTick`方法,在回调函数中打印了`this.$el.textContent`。由于`this.$nextTick`方法会在DOM更新之后执行回调函数,所以打印出来的是更新后的内容。 需要注意的是,`Vue.nextTick`不是立即执行的,而是在下一个事件循环时执行的。这意味着如果我们在某个方法中多次调用`Vue.nextTick`,那么这些回调函数会被加入到同一个队列中,在下一个事件循环时一起执行。这个特性可以帮助我们避免不必要的DOM操作,从而提高性能。 总的来说,`Vue.nextTick`可以帮助我们更好地掌控Vue的异步更新机制,避免出现一些奇怪的bug。同时,它也为我们提供了一个优化性能的机会,让我们能够更好地利用Vue的异步更新机制。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CRMEB定制开发

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值