Gahing's blog Gahing's blog
首页
知识体系
  • 前端基础
  • 应用框架
  • 工程能力
  • 应用基础
  • 专业领域
  • 业务场景
  • 前端晋升 (opens new window)
  • Git
  • 网络基础
  • 算法
  • 数据结构
  • 编程范式
  • 编解码
  • Linux
  • AIGC
  • 其他领域

    • 客户端
    • 服务端
    • 产品设计
软素质
  • 面试经验
  • 人生总结
  • 个人简历
  • 知识卡片
  • 灵感记录
  • 实用技巧
  • 知识科普
  • 友情链接
  • 美食推荐 (opens new window)
  • 收藏夹

    • 优质前端信息源 (opens new window)
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Gahing / francecil

To be best
首页
知识体系
  • 前端基础
  • 应用框架
  • 工程能力
  • 应用基础
  • 专业领域
  • 业务场景
  • 前端晋升 (opens new window)
  • Git
  • 网络基础
  • 算法
  • 数据结构
  • 编程范式
  • 编解码
  • Linux
  • AIGC
  • 其他领域

    • 客户端
    • 服务端
    • 产品设计
软素质
  • 面试经验
  • 人生总结
  • 个人简历
  • 知识卡片
  • 灵感记录
  • 实用技巧
  • 知识科普
  • 友情链接
  • 美食推荐 (opens new window)
  • 收藏夹

    • 优质前端信息源 (opens new window)
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 前端基础

  • 应用框架

    • UI 框架

      • Angular

      • React

      • Solid

      • Svelte

      • Vue

        • Vue 项目中的 data-v-xxx 是怎么生成的
        • Vue之从零编写一个ContextMenu(右键菜单)插件
        • Vue 第一个组件,浏览器后退无法触发beforeRouteLeave的问题与解决
        • Vue问题记录
        • vue 中 updated 的执行时机
        • 源码解析

          • Vue 源码解析-0.前置准备
          • Vue实例化过程
          • Vue响应式原理
          • Vue响应式之data
          • Vue响应式之props
          • Vue响应式之computed
          • Vue响应式之watch
          • Vue响应式之异步更新与nextTick
            • 前言
            • queueWatcher
              • 实例分析
            • flushSchedulerQueue
              • 为什么要重排序
              • 死循环判断
              • activatedQueue 队列 与 activated 钩子
              • updatedQueue 队列与 updated 钩子
            • nextTick
              • 事件循环
              • nextTick 源码
              • microTask 的模拟实现
              • nextTick 中能拿到最新的 dom 值么?
            • 总结
            • 参考文档
          • Vue响应式之组件更新
      • 框架本质

    • 开发框架

    • 组件库

  • 工程能力

  • 应用基础

  • 专业领域

  • 业务场景

  • 大前端
  • 应用框架
  • UI 框架
  • Vue
  • 源码解析
gahing
2019-11-19
目录

Vue响应式之异步更新与nextTick

# 前言

我们在前面文章讲过,当数据更新时,正常情况下 Watcher 不是马上执行更新回调,而是通过 queueWatcher 来进行调度

下来我们先来看看 queueWatcher 的源码

# queueWatcher

// src\core\observer\scheduler.js

/**
 * 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

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

has 用来存放 watcher 的 id, 作用是防止队列重复添加相同 watcher

这里的队列不包括已执行的 watcher ,即队列中 watcher 已执行则 has[id] 会置 null

flushing 用来判断是否开始执行 watcher (flushSchedulerQueue),作用是为了调整新插入 watcher 在队列中的顺序

若开始 flushing ,由于当前 queue 已经按 watcher.id 排序了,所以新的 watcher 需要插入在未执行队列中比新 watcher 的 id 大的元素之前

waiting 用来将 flushSchedulerQueue 操作放入 microTask 队列

flushSchedulerQueue 整个过程是一个 microTask

watchers 按同步代码执行顺序被添加到队列后( macroTask )才开始执行 flushSchedulerQueue 这个 microTask
而 watcher 执行过程中,可能触发新的 watcher 执行,所以需要 flushing 去控制新的 watcher 插入队列的位置

!!同步代码中执行 $nextTick(()=>{}) ,里面的回调 cb 和 flushSchedulerQueue 同属一个 microTask ,且 cb 在 flushSchedulerQueue 之后执行。稍后分析 nextTick 时再细讲

# 实例分析

<script type="text/x-template" id="demo-template">
  <div>
    {{a}}{{b}}
  </div>
</script>

<script>
  var vm = new Vue({
    el: '#el',
    template: '#demo-template',
    data: {
      a: 0,
      b: 0
    },
    // user watcher
    watch: {
      a(){
        this.b = 1
      }
    },
    mounted() {
      this.a = 1
      this.b = 2
    }
  })
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

最终页面上显示的是什么?是 11 还是 12 ?

结果

11

  1. a 发生变更,其依赖对应的 watcher (有 renderWatcher 和 user watcher )执行 queueWatcher,放入 queue 中
  2. b 发生变更,其依赖对应的 watcher (renderWatcher) 预放入 queue ,不过此时已经有 renderWatcher ,故不再放入
  3. 同步代码执行结束,开始执行 flushSchedulerQueue ,先执行 user watcher ,此时 b = 1 ,其依赖对应的 watcher (renderWatcher) 已在 queue 中,故不再放入。
  4. 执行 renderWatcher 将 a=1 和 b=1 的值渲染到页面上

# flushSchedulerQueue

flushSchedulerQueue 代码如下

// src\core\observer\scheduler.js

/**
 * Flush both queues and run the watchers.
 */
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  queue.sort((a, b) => a.id - b.id)

  // queue 会动态增加,所以 queue.length 不能 cache 
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    // before 在 watcher 实例化的 options.before 中指定
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // dev 环境做了个死循环判断
    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
      }
    }
  }

  // 重置状态前的进行备份,后面有用到
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  // 重置 index queue activatedChildren has circular waiting flushing
  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

有几个地方需要注意的:

# 为什么要重排序

watcher 的 id 是根据创建顺序递增的

在执行 watcher 队列前,对队列进行了排序,主要是以下几点原因:

  1. 由于父组件总是在子组件前创建,所以组件更新应该由父到子
  2. 由于用户的 Watcher (通过 watch 选项创建的) 在 render watcher 前创建,因此用户的 Watcher 应该先运行
  3. 如果一个组件在父组件的 watcher 运行中 destroyed ,该组件的 watchers 应该被跳过

也就是将 watcher 队列按 watcher 的创建顺序进行排序

因此 queueWatcher 时还使用了 flushing 变量去控制 watcher 的顺序

# 死循环判断

has[id] = null
watcher.run()
// dev 环境做了个死循环判断
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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

当某个 watcher 连续执行了 100 次以上,说明存在死循环

// user watcher
watch:{
  a(){
    this.a = this.a + 1
  }
}
1
2
3
4
5
6
  • a 发生改变时,其依赖对应的 user watcher 被放入 queue
  • 同步代码执行结束后,开始执行 flushSchedulerQueue
  • user watcher 的 has 值置空,并执行 user watcher ,此时 a 发生改变,user watcher 再次放入 queue
  • user watcher 一直执行,直到达到 MAX_UPDATE_COUNT 上限,出现 warn 警告

注意: vue 中没有处理相互调用的情况

watch:{
  a:function(){
    this.b = this.b + 1
  },
  b:function(){
    this.a = this.a + 1
  }
}
1
2
3
4
5
6
7
8

这种情况下,当 a 或 b 改动时,会不断的执行 watcher 的回调,vue 中没有做检测,导致最后死循环、页面卡住

这种还算比较容易看出来的,要是 watch 的项变多了,相互修改,一不小心就会出现循环,到时候排查还很难

watch 造成死循环的都是用户自定义的 watch 有问题

# activatedQueue 队列 与 activated 钩子

activatedQueue 为 activatedChildren 的备份

// src\core\observer\scheduler.js

/**
 * Queue a kept-alive component that was activated during patch.
 * The queue will be processed after the entire tree has been patched.
 */
export function queueActivatedComponent (vm: Component) {
  // setting _inactive to false here so that a render function can
  // rely on checking whether it's in an inactive tree (e.g. router-view)
  vm._inactive = false
  activatedChildren.push(vm)
}

function callActivatedHooks (queue) {
  for (let i = 0; i < queue.length; i++) {
    queue[i]._inactive = true
    activateChildComponent(queue[i], true /* true */)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src\core\instance\lifecycle.js

export function activateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = false
    if (isInInactiveTree(vm)) {
      return
    }
  } else if (vm._directInactive) {
    return
  }
  if (vm._inactive || vm._inactive === null) {
    vm._inactive = false
    for (let i = 0; i < vm.$children.length; i++) {
      activateChildComponent(vm.$children[i])
    }
    callHook(vm, 'activated')
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

<keep-alive> 用于包裹组件,缓存不活动的组件实例

callUpdatedHooks 执行前, activatedChildren 会添加活动的组件实例

而执行 callUpdatedHooks(updatedQueue) 时, activatedChildren 中所添加的活动的组件实例会调用 activated 钩子,表明这些被 <keep-alive> 包裹的组件当前处于活动状态

其他的细节我们在讲到 <keep-alive> 时细讲

# updatedQueue 队列与 updated 钩子

updatedQueue 为 queue 的备份,按照由后往前的顺序,执行 watcher 所对应的 vm 的 updated 钩子

其中要求 watcher 为 renderWatcher 且 vm._isMounted && !vm._isDestroyed

故效果为按子组件到父组件的顺序执行 updated 钩子

# nextTick

最后再来说说 nextTick

# 事件循环

在讲解 nextTick 之前,我们需要先简要介绍一下浏览器中的事件循环

浏览器中的任务分为 macrotask 和 microtask

macrotask(宏任务): 同步代码, setTimeout 等

microtask(微任务): 原生 Promise, MutationObserver 等

  • 从 macrotask 队列中取一个宏任务执行,执行完后, 执行所有的 microtask.
  • 重复回合

当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。当前宏任务执行栈空后,再次读取微任务队列里的任务,依次类推。

# nextTick 源码

nextTick 可以是 queueWatcher 时内部执行,也可以是用户 vm.$nextTick 或 Vue.nextTick 手动执行,都是调用的同一个方法

// src\core\util\next-tick.js

export function nextTick (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)
    }
  })
  // 用来判断是否应该创建新的 microTask
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

该方法会将回调 cb 放入 callbacks 队列中,这个 callbacks 队列会在一个 microTask 中被执行。

microTask 由 timerFunc 创建,如果当前 microTask 已经开始执行, nextTick 传入的回调会放在下一个 microTask 中执行

例:

  <script type="text/x-template" id="demo-template">
    <div>
      {{a}}
    </div>
  </script>

  <script>
    var vm = new Vue({
      el: '#el',
      template: '#demo-template',
      data: {
        a: 0,
      },
      watch: {
        a(val){
          console.log(`a=${val}`)
        },
      },
      mounted() {
        this.a = 1
        Promise.resolve().then(() => {
          console.log("promise")
          this.a = 3
        })
        this.$nextTick(()=>{
          console.log("nextTick")
          this.a = 2
        })
      }
    })
  </script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

页面上显示什么,输出又是什么?

点击查看

页面上显示 3 ,输出结果为 :

a=1
nextTick
promise
a=3
1
2
3
4

执行顺序:

  1. a 发生变更,其依赖对应的 watcher (有 renderWatcher 和 user watcher )执行 queueWatcher,放入 queue 中
  2. 此时调用 nextTick ,将 flushSchedulerQueue 回调放入 callbacks 中,同时将 flushCallbacks 放入 microTask 队列
  3. 通过 Promise.resolve().then 新建一个 microTask (promiseMicroTask) 并放入 microTask 队列
  4. 执行 $nextTick ,将回调 cb 放入 callbacks 中
  5. 同步代码执行结束,开始执行 microTask 队列
  6. 先执行 flushCallbacks 这个 microTask ,此时 callbacks 中有两个回调,分别是 flushSchedulerQueue 和 cb
    1. 先执行 flushSchedulerQueue: flushSchedulerQueue 中会先执行 user watcher ,此时输出 a=1 ,其后执行 renderWatcher ,此时通知页面更新为 1,通过打断点可以看到页面更新了 ,flushSchedulerQueue 执行完毕进行重置;
    2. 随后执行 cb 回调,输出 promise , a 发生变更,其依赖对应的 watcher 放入 queue 中,并且重复过程 2
  7. 执行 promiseMicroTask ,a 发生变更,其依赖对应的 watcher 已在 watcher 中,因此不再添加
  8. 执行 flushCallbacks 这个 microTask ,此时 callbacks 只有 flushSchedulerQueue 这个回调,执行过程同 6.1 , 输出 a=3 ,同时页面更新为 3

这个也顺便解释了在同步代码中执行 Promise.resolve().then 和 $nextTick 效果是不一样的,后者的回调和 watcher 的执行在同一个 microTask ,会在 Promise.resolve().then 的回调之前执行

同时回答了这个问题:为什么 nextTick 中每个回调不是一个 microTask ?

这是为了保证 nextTick 的回调能在 flushSchedulerQueue 后马上执行

至于为什么要求这样做,源码中给了大量解释,可能是有些平台会存在问题,感兴趣的可以看源码和相关 issue


回到源码,还有一个细节,当 nextTick 传入的 cb 为空时,会返回一个 Promise

if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}
1
2
3
4
5

所以可以这样使用:

// then 中的回调也会放在 callbacks 中,和 flushSchedulerQueue 同一个 microTask
this.$nextTick().then(()=>{})
1
2

# microTask 的模拟实现

上文说到 timerFunc 会创建一个 microTask ,怎么做的呢?

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||

  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {

  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {

  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {

  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

按照 Promise => MutationObserver => setImmediate => setTimeout 的顺序匹配

# nextTick 中能拿到最新的 dom 值么?

还是举个例子

<script type="text/x-template" id="demo-template">
  <div ref='a'>{{ a }}</div>
</script>

<script>
  var vm = new Vue({
    el: '#el',
    template: '#demo-template',
    data: {
      a: 0,
    },
    mounted() {
      const refA = this.$refs.a
      this.a = 1
      console.log(refA.innerHTML)
      this.$nextTick(() => {
        console.log(refA.innerHTML)
      })
    }
  })
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

输出结果是什么?

结果
0
1
1
2

根据上文的描述,$nextTick 中的回调会在 flushSchedulerQueue 的 renderWatcher 后执行

而 renderWatcher 时,会对 dom 进行更新,所以 $nextTick 执行回调时拿到的就是更新后的 dom 节点

说点题外话,为什么 renderWatcher 要在 microTask 中执行而不是在 macroTask ,因为浏览器的执行顺序为:macroTask -> microTask queue -> UI Render ,如果 renderWatcher 放在 macroTask 中,由于 renderWatcher 可能有多次,所以 UI Render 也会进行多次

可能你又有疑问了,刚刚不是说 $nextTick 可以拿到更新后的 dom 节点,这里又说 UI Render 要在 microTask queue 执行之后,是不是矛盾了?

解析

不矛盾

UI Render 包括样式计算(Recalculate Style),生成布局树(Layout),更新图层树(Update Layer Tree),绘制(Paint),图层合成(Composite Layers),通知渲染进程渲染等过程

根据宏任务时进行的操作决定后面会执行哪几个过程,如果还 Schedule Style Recalculation ,则会进行

而在宏任务中操作 dom,设置样式等,只可能进行解析(Parse HTML),计划计算样式(Schedule Style Recalculation)等,还不会开始计算样式,进行布局和绘制等

但是,如果在宏任务中去获取具体布局相关属性,比如宽高位置,会进行布局和样式计算(同步操作),然后将值返回

同样,如果在宏任务中去获取具体非布局相关属性,比如颜色等,会进行样式计算(同步操作),然后将值返回

当然,这个的前提是触发了计划计算样式(Schedule Style Recalculation),如果这个没触发,直接去获取属性并不会造成回流重绘

并且,如果进行了重排重绘,在 UI Render 阶段不会再次进行样式计算(Recalculate Style)和生成布局树(Layout)了

注意一点,设置了某个节点的样式,然后去获取另一个节点的布局,会先进行样式计算,但不一定是会进行 Layout 的

渲染引擎很智能,会根据样式计算结果得知需不需要进行 Layout

比如前面节点设置的样式并没有涉及布局相关属性,那么后者获取布局属性并不需要先进行 Layout ,

原本以为处于绝对定位然后设置了布局相关属性,影响不到我们要获取的节点,不会进行 Layout,但事实会 Layout 说明渲染引擎没有那么智能

后面我会单独写一篇文章讲这个

试试如下例子:

<div id="a">a</div>
<script>
  function test() {
    var a = document.querySelector("#a")
    Promise.resolve().then(()=>{
      a.innerHTML = "test-1"
    })
    Promise.resolve().then(()=>{
      a.innerHTML = "test-2"
      console.log(a.innerHTML)
      alert(1)
    })

  }
  function test2(){
    var a = document.querySelector("#a")
    Promise.resolve().then(()=>{
      a.innerHTML = "test2-1"
    })
    setTimeout(() => {
      a.innerHTML = "test2-2"
      console.log(a.innerHTML)
      alert(1)
    }, 0);
  }
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

注意:测试的时候不能用 debugger ,应该用 alert,见 浏览器debugger为什么没有阻止浏览器渲染界面而alert可以? (opens new window)

执行 test 时,在 microTask queue 中对 a.innerHTML 进行两次修改,由于在 UI Render 前用 alert 阻塞了渲染线程,故页面不会变化,console.log(a.innerHTML) 输出 test-2

执行 test2 时,在 microTask queue 中对 a.innerHTML 进行了修改并执行了 UI Render ,所以页面上会显示 test2-1 ,下一次事件循环,从 setTimeout 宏任务开始,由于被 alert 堵塞,故页面不会变化,console.log(a.innerHTML) 输出 test2-2

总的来说:nextTick 中能拿到当前最新的 dom 值,但此时页面还未进行重渲染

# 总结

Vue 的异步更新,借用了 microTask 的概念,在 nextTick 执行所有 watcher 的 run ,以保证 watcher 能够按照正常顺序执行。同时,由于对 dom 的更新都是处于 microTask 中,可以避免页面渲染多次

# 参考文档

  1. Vue源码阅读 - 批量异步更新与nextTick原理 (opens new window)
  2. 从event loop规范探究javaScript异步及浏览器更新渲染时机 (opens new window)
编辑 (opens new window)
上次更新: 2024/09/01, 23:56:56
Vue响应式之watch
Vue响应式之组件更新

← Vue响应式之watch Vue响应式之组件更新→

最近更新
01
浅谈代码质量与量化指标
08-27
02
快速理解 JS 装饰器
08-26
03
Vue 项目中的 data-v-xxx 是怎么生成的
09-19
更多文章>
Theme by Vdoing | Copyright © 2016-2024 Gahing | 闽ICP备19024221号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式