暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Vue.nextTick(this.$nextTick) 与 响应式数据的原理

胡聊前端 2021-03-20
515

前言

我们知道,在Vue中,修改响应式数据是异步的。即如果修改后想获取到DOM的更新,需要在nextTick回调函数中才能得到。这样做的主要目的是为了节省性能。

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的

当你设置 vm.someData = 'new value',该组件不会立即重新渲染

源码解析

以下是nextTick的源码:

    // 各种错误判断和兼容代码略过了。
    function nextTick (cb, ctx) {
    你传入的回调函数会被依次放入到callbacks数组中。
    callbacks.push(function () {
    cb.call(ctx);
    });
    if (!pending) {
    pending = true;
    timerFunc();
    }
    }
    复制
      // Vue.nextTick 和 this.$nextTick是等价的。
      Vue.prototype.$nextTick = function (fn) {
      return nextTick(fn, this)
      };
      Vue.nextTick = nextTick;
      复制

      可见,nextTick函数传入的回调都被push到一个数组中了,那么这个数组callbacks
      中的回到函数是什么时间执行的呢?

      其实当你第一次调用nextTick
      就已经执行了,但是被放入到微任务队列中

        // 这是上边nextTick中 timerFunc的实现 
        var p = Promise.resolve();
        timerFunc = function () {
        p.then(flushCallbacks);
        };
        复制

        可以看到第一次调用nextTick就会执行timerFunc
        函数,而p
        已经是fullfiled
        的状态,也就是会把flushCallbacks
        回调函数放入到微任务队列中。当执行栈中的代码执行完毕就会立即执行微任务队列中的flushCallbacks
        。由于这个过程是异步的,即callbacks可能还会在此期间被推入更多的回调,届时一起执行。

          // flushCallbacks  就是这么简单,原封不动搬过来了
          // 说白了就是callbacks中的挨个执行。
          function flushCallbacks () {
          pending = false;
          var copies = callbacks.slice(0);
          callbacks.length = 0;
          for (var i = 0; i < copies.length; i++) {
          copies[i]();
          }
          }
          复制

          好了,既然到了这里,那么问题来了。为什么nextTick就能获取到DOM的更新呢?不难想象,那一定是响应式数据已经更新过了。

          响应式数据的原理

          那么再来看下当给响应式数据赋值直到DOM更新都发生了什么。图片来源于官网,setter
          会拦截响应式数据的设置,任何数据获取的地方,包括Watch,Computed,以及模板,都是一个Watcher
          。当数据变更的时候,被通知Notify
          进行相应的重新渲染(re-render
          )或其他逻辑。

          其实不妨先和大家直说了,如上图所示,响应式数据的改变当然也是异步的。思路和nextTick基本是一致的,都是准备一个队列,当第一次进行赋值的时候,将执行的时机延迟到微任务队列中,在此期间,任何对于响应式数据的变更,都会放入到同一队列中,当执行时机到来的时候,一起执行。更有甚者,这个异步实现的方法同样是nextTick
          ,固响应式数据原理[1]也很简单,见如下源码:

            // 当赋值发生的时候会触发每个Watcher的更新,但更新并不是立即执行的,而是放入到队列中,见下面的queueWatcher函数
            Watcher.prototype.update = function update() {
            if (this.lazy) {
            this.dirty = true;
            } else if (this.sync) {
            this.run();
            } else {
            queueWatcher(this);
            }
            };
            复制

            将需要更新的Watcher放入到一个队列中,使用同样的实现即nextTick方法去实现异步,当执行到微任务队列时候一并更新。

              function queueWatcher (watcher) {
              var id = watcher.id;
              if (has[id] == null) {
              has[id] = true;
              if (!flushing) {
              queue.push(watcher);
              } else {
              var i = queue.length - 1;
              while (i > index && queue[i].id > watcher.id) {
              i--;
              }
              queue.splice(i + 1, 0, watcher);
              }
              // 这里异步的实现竟然和nextTick调用同一个方法。
              // 这里可以看到同一个Watcher
              if (!waiting) {
              waiting = true;
              nextTick(flushSchedulerQueue);
              }
              }
              }
              复制

              既然二者是相同的实现,那么这就解释了为什么当为响应式数据赋值之后,虽然他是异步的,但nextTick回调中却可以得到更新的数据。因为待执行的nextTick异步队列在响应式数据赋值Watcher的异步队列之后。固nextTick可以得到DOM更新。

              DOM更新并非渲染

              这里有必要说一句,DOM更新并非是指页面渲染,DOM更新之后,会和渲染引擎(webkit、Blink)进行交互,由后者完成更新,就是通常所说的重排重绘合成的浏览器渲染流程。但我们可以获取到DOM更新的时机就是在变更DOM之后。

              下面这个页面alert可以弹出ok,但页面仍旧是一片空白,即渲染未发生,但已经可以获取到DOM更新了。

                <!DOCTYPE html>
                <html lang="en">
                <head>
                <meta charset="UTF-8">
                <meta http-equiv="X-UA-Compatible" content="IE=edge">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Document</title>
                </head>
                <script>
                window.addEventListener('DOMContentLoaded', () => {
                document.body.append('ok');
                alert(document.body.innerHTML); // ok
                })
                </script>
                <body>
                </body>
                </html>
                复制

                举例印证

                最后,我们来尝试做一个简单的例子来印证我们的理解。我们知道,可以得到DOM更新的原因是响应式数据改变的回调队列在nextTick的回调队列之前被调用。那请思考一下两个场景:

                假设我们有以下的响应式数据

                  <template>
                  <div>
                  <span ref="foo">{{foo}}</span>
                  <span ref="bar">{{bar}}</span>
                  </div>
                  </template>
                  data() {
                  return {
                  foo: 'foo',
                  bar: 'bar'
                  }
                  }
                  mounted() {
                  // 第一种情况
                  this.$nextTick(() => {
                  console.log(this.$refs.foo.innerHTML) // ??
                  })
                  this.foo = 'foo更新了'
                  // 第二种情况
                  this.bar = 'bar 更新了'
                  this.$nextTick(() => {
                  console.log(this.$refs.foo.innerHTML) // ??
                  })
                  this.foo = 'foo更新了'
                  }
                  复制

                  1.先调用nextTick,后改变响应式数据。可以获取到DOM更新么?2.先改变其他的响应式数据,后调用nextTick,再改变响应式数据bar,nextTick中可以得到DOM更新么?

                  稍微思考下不难想象,在第一种情况中,由于nextTick先创建了队列,从而导致foo的更新不会被获取到。而第二种情况由于bar的赋值先创建了队列,固foo的更新虽然发生在$nextTick之后,但仍旧会被其获取到。

                    <template>
                    <div>
                    <span ref="foo">{{ foo }}</span>
                    </div>
                    </template>
                    <script>
                    export default {
                    data() {
                    return {
                    foo: "foo",
                    bar: "bar"
                    };
                    },
                    mounted() {
                    // 第一种情况
                    // this.$nextTick(() => {
                    // console.log(this.$refs.foo.innerHTML); // 打印出foo,并非foo更新了
                    // });
                    // this.foo = "foo更新了";
                    };
                    </script>
                    复制
                      <template>
                      <div>
                      <span ref="foo">{{ foo }}</span>
                      <!-- bar本次虽然用不到但不能删掉,因为他会作为一个watcher,如果没有就不会再第二种情况下先创建队列 -->
                      <span ref="bar">{{ bar }}</span>
                      </div>
                      </template>
                      <script>
                      export default {
                      data() {
                      return {
                      foo: "foo",
                      bar: "bar"
                      };
                      },
                      mounted() {
                      // 第二种情况
                      this.bar = "bar 更新了";
                      this.$nextTick(() => {
                      console.log(this.$refs.foo.innerHTML); //打印出 foo更新了
                      });
                      this.foo = "foo更新了";};
                      </script>


                      复制

                      参考

                      github vue:https://github.com/vuejs/vuevue 文档:https://vuejs.org/

                      备注

                      本文vue源码基于vue 2.6.12


                      文章转载自胡聊前端,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                      评论