在nextTick回调中一定可以拿到DOM的最新值吗

359 阅读1分钟

一般情况下,大家都只知道通过$nextTick函数来从DOM中获取到状态更新之后的值,但是通过这种方式真的一定可以获取到DOM的最新状态值吗?

$nextTick实现原理

Vue 2不同版本中对$nextTick函数的实现略有差异,但是基本原理还是一致的,这里以2.6.14版本源码进行分析。把相关代码汇聚如下,其中为简单起见异步实现以setTimeout为例

var callbacks = [];
var pending = false;

var timerFunc = function () {
    setTimeout(flushCallbacks, 0);
};

function flushCallbacks () {
    pending = false;
    var copies = callbacks.slice(0);
    callbacks.length = 0;
    for (var i = 0; i < copies.length; i++) {
      copies[i]();
    }
}

function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
      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(function (resolve) {
        _resolve = resolve;
      })
    }
}

源码中逻辑实现相当简洁清晰:

  • 如果没传入回调函数,可以把nextTick函数当作异步函数来使用,这时候调用nextTick返回一个Promise
  • 可以通过多次调用nextTick函数传入不同的回调函数,但是这些函数是以同步的方式执行

State更新到DOM更新原理

对于Vue框架,大家一般比较熟悉其响应式特性,即在state更新后会主动更新相应的DOM,这个流程并不需要开发者进行操作,通过自动化方式进行执行。

vm._watcher = new Watcher(vm, function () {
  vm._update(vm._render(), hydrating)
}, noop)

上述这段代码来源于Vue 2.0.0版本,这个版本的render watcher定义比较清晰易于理解,其实2.6.14版本中也比较类似,只是考虑的情况会多一些。

将整个DOM的渲染抽象成一个watcher来响应相关state的变化,当所依赖的state有所变化后,就会触发render watcher的执行,因此也就更新了DOM

频繁的DOM更新比较耗时,那在Vue中对Watcher这块做了哪些方面的优化呢

Watcher执行优化

Vue 2版本中通过defineProperty来实现响应式,那么就从defineProperty开始看一下状态变更后,都经过哪些流程触发了Watcher执行。

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    var value = getter ? getter.call(obj) : val;
    if (Dep.target) {
      dep.depend();
      if (childOb) {
        childOb.dep.depend();
        if (Array.isArray(value)) {
          dependArray(value);
        }
      }
    }
    return value
  },
  set: function reactiveSetter (newVal) {
    var value = getter ? getter.call(obj) : val;
    if (newVal === value || (newVal !== newVal && value !== value)) {
      return
    }
    if (customSetter) {
      customSetter();
    }
    if (getter && !setter) { return }
    if (setter) {
      setter.call(obj, newVal);
    } else {
      val = newVal;
    }
    childOb = !shallow && observe(newVal);
    dep.notify();
  }
});

当我们对一个属性赋值时就会调用属性对应的set方法,赋值之后会调用依赖对象的notify方法,也就是所谓的观察者模式中的通知方法

Dep.prototype.notify = function notify () {
    //...
    for (var i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
};

每个subs[i]就是一个Watcher对象,通过调用调用Watcher对象的update方法触发Watcher的更新

Watcher.prototype.update = function update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true;
    } else if (this.sync) {
      this.run();
    } else {
      queueWatcher(this);
    }
};

针对不同的Watcher类型,有三种更新方式:

  • lazy为true的情况,主要是针对computed属性,在下次需要更新computed属性时才会进行Watcher的更新
  • sync为true时表示同步更新,也就是在对state赋值后,相应的Watcher会同步进行执行
  • 把当前的Watcher对象放置到一个队列中,等待统一执行的时机,Render Watcher也属于这种Watcher类型

那么queueWatcher又做了哪些操作呢?

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);
      }
      if (!waiting) {
        waiting = true;

        if (!config.async) {
          flushSchedulerQueue();
          return
        }
        nextTick(flushSchedulerQueue);
      }
    }
}

function flushSchedulerQueue () {
    // ...
    for (index = 0; index < queue.length; index++) {
      // ...
      watcher.run();
      // ...
    }
    // ...
}

从源码中可以看出,queueWatcher主要做了三件事情:

  • 对相同Watcher进行过滤,避免同一个Watcher重复执行
  • 将Watcher放入一个全局queue对象中
  • 在nextTick方法中放入一个flushSchefulerQueue方法,作为异步执行Watcher的回调函数

Watcher执行优化小结

通过以上源码分析可以看出,主要通过以下方式进行优化:

  • 异步执行Watcher,避免阻塞正在执行的同步任务
  • 过滤相同Watcher,在一个事件循环中一个Watcher只执行一次

Render Watcher与$nextTick异步顺序问题

由于render watcher与$nextTick都是异步执行的,因此两者的执行顺序就成了是否能够在$nextTick回调中获取到最新DOM状态的关键,相关demo可以参考jsfiddle.net/flyingbirdh…

其中主要逻辑应该就是这段简单的代码:

onAdd() {
  this.$nextTick(() => {
    console.log('current state:', this.txt);
    const dom = document.getElementById('count');
    console.log('dom value:', dom.innerText);
  });
  this.txt += 1;
},

期望效果是当点击按钮后,txt值会加1,输出的dom value值于最新的txt值保持一致; 实际是txt值加1,但是输出的dom value值却是上一个txt状态值。

这就是因为在书写事件响应函数时没有注意$nextTick的调用顺序时,造成$nextTick中的回调其实并没有能获取到最新的DOM状态。因为此时异步执行顺序变为: 先读取DOM状态,之后执行flushQueueWatcher函数,读取DOM状态在更新DOM状态(状态已经更新但是未进行render watcher更新)之前执行,因此也就无法获取到更新后的值。

总结

其实这种场景出现概率还是很低的,一般的开发习惯都是先更新状态,然后调用$nextTick进行DOM状态的读取,但是随着业务代码的迭代,还是有可能出现类似上述逻辑的代码,因此既然有出现的可能性是不是可以从框架层面避免呢?比如在nextTick(flushQueueWatcher)时将flushQueueWatcher放在callbacks头部,在每次执行异步队列时都先执行Watcher更新,是不是能更好的避免这个问题呢?