vue方法nextTick源码分析

2,102 阅读11分钟

一、什么是nextTick

nextTick是vue的核心方法之一,使用的不可谓不多。但是只是知道实在Dom更新完之后执行一个回调。对于里面的原理实现还是很好奇的。看了一下源码分析。大概了解了一点原因。肯定有很多的纰漏。但是先分享理解一波。

二、js的事件循环机制 even loops

1. js的单线程

我们知道JavaScript的一大特点就是单线程,而这个线程中拥有唯一的一个事件循环

作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题

2. 什么是事件循环呢?

1.所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)

2.主线程之外,如果有异步任务,还存在一个“任务队列”,只要异步任务有了运行结果,就在“任务队列”之中放置一个事件

3.一旦“执行栈”中的所有同步任务执行完毕,系统就会读取“任务队列”,
看看里面有哪些事件。那些对应的异步任务,于是结束等待,进入执行栈,开始执行

4.主线程不断重复第3步

假定JavaScript同时有两个线程(事件)

  1. 一个线程在某个DOM节点上添加内容,

  2. 另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以js的运行环境决定js本质就只是单线程

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务

那要是前面的代码不走了,后面就不走了。那的等到猴年马月啊。所以不行,

所以js出了个同步和异步的概念

3.同步任务(代码),异步任务(代码)

我们平时说的最多的就是同步代码,异步代码。同步代码先执行。异步代码会延后执行

不管什么都是借助函数调用栈来执行的。都是调用函数做事的。

简单来说

同步代码

如果在函数返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的

异步代码

如果在函数返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到或等待一段时间,那么这个函数就是异步的

但是异步执行的代码都不是马上执行的。怎么排一个先后顺序呢?

一般而言,异步任务有以下三种类型

1、点击事件,如click 点击的时候,代表任务完成,放到主线程之外的任务队列里排队

2、资源数据加载,如load等,当响应到全部数据的时候,任务完成,去任务队列排队去

3、定时器,包括setInterval、setTimeout等,当时间到了的时候。去任务队列排队

这样,当主线程的同步代码完成以后,js开始从任务队列里找异步。谁在第一个就第一个出去。进入到执行栈,执行完。直到完成一个事件。继续回到主线程,执行。找异步,。。。。。在完成一个事件。。。。事件循环。。。。

主线程是栈 先进后出,任务队列是队列 先进先出

但是es6 发展起来了,出了个 promise的这个时候,异步代码就有意思了。当主线程执行完以后,会去任务队列里找异步任务。但是有些异步任务。执行完以后,并不想马上被主线程执行并清空。反而想马上执行另外的小事情。例如.then().then()。。。。这个时候异步任务就复杂了。主线程就不知道先拿你的异步小事情去做,还是拿你的异步大事情去做。。。。

所以这个时候分为了 宏任务 微任务 的概念

4.宏任务(代码),微任务(代码)

同步代码直接执行。异步代码分为宏任务 和 微任务

不同类型的异步任务的会进入不同的任务队列中

宏任务会加入宏任务队列,

微任务会加入微任务队列。

在执行栈中的同步任务执行完成后,主线程会先查看任务队列中的微任务,如果没有,则去宏任务队列中取出最前面的一个事件加入执行栈中执行;如果有,则将所有在微任务队列中的事件依次加入执行栈中执行,直到所有微任务事件执行完成后,再去宏任务中取出最前面的一个事件加入执行栈,如此循环往复。

宏任务:script,setTimeout,setInterval

微任务:Promise,process.nextTick MutationObserver

看下面的一套代码。

console.log('start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('end');

执行结果是 
script start
script end'
promise1
promise2
setTimeout

走一遍事件循环

每个宏任务都是一个事件

第一遍循环--------

执行栈里面有好多的代码段,从头开始执行

  1. 同步代码输出来 start 输出
  2. 宏任务setTimeout放在宏任务队列里挂起来
  3. 微任务代码Promise后面的两个then promise1 promise2 放在微任务队列
  4. 同步代码 end 输出
  5. 根据定义,主线程同步执行完继续找微任务。执行。输出 promise1 promise2
  6. 微任务队列空了,开始第二个宏任务

第二遍循环--------

  1. 执行栈该执行的执行了。空了
  2. 微任务空了,执行完了
  3. 去宏任务找,艾玛。发现一个。setTimeout
  4. 输出 setTimeout

再来一个例子

console.log('1')           

// 这是一个宏任务
setTimeout(function () {   
  console.log('2')                   
});

new Promise(function (resolve) {
  // 这里是同步任务
  console.log('3');         
  resolve();                          
  // then是一个微任务
}).then(function () {      
  console.log('4')          
  setTimeout(function () {
    console.log('5')
  });
});

结果 1 3 4 2 5

第一遍事件循环

  1. 执行栈里面的方法开始执行
  2. 同步任务 输出1
  3. 遇到一个setTimeout 不执行,放在宏任务队列挂起来 标记setTimeout1
  4. 遇到promise构造函数里的是同步 输出3 then里面的是微任务。挂载微任务队列(里面有宏任务嵌套。不用管,直接放微任务)
  5. 同步执行完了。找微任务。 输出4。艾玛里面有个宏任务。再放进宏任务。标记setTimeout2
  6. 同步执行完了。微任务也完了。改下一个宏任务了

第二遍事件循环

  1. 执行栈里面的方法开始执行
  2. 没有同步代码
  3. 微任务队列也没有
  4. 找宏任务setTimeout1。输出2
  5. 走下一个宏任务了

第三遍事件循环

  1. 执行栈里面的方法开始执行
  2. 没有同步代码
  3. 微任务队列也没有
  4. 找宏任务setTimeout2。输出5
  5. 走下一个宏任务了.。。也没有了 执行了三次事件循环

我们只需记住当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。 每个宏任务就开始一次事件循环

三、nextTick的源码分析

了解了事件循环机制和宏任务,微任务。说一下 源码解析,

Vue.js 默认异步更新 DOM。每当观察到数据变化时,Vue 就开始一个队列,将同一事件循环内所有的数据变化缓存起来。

如果一个 watcher 被多次触发,只会推入一次到队列中。等到下一次事件循环,Vue 将清空队列,只进行必要的 DOM 更新。

所以更改了data的数据,DOM 不会立即更新,

而是在下一次事件循环清空队列时更新。 为了在数据变化之后等待 Vue.js 完成更新 DOM,

可以在数据变化之后立即使用 Vue.nextTick(callback) 。回调在 DOM 更新完成后调用。

事件循环==>更新dom==> 事件循环==>更新Dom

源码位置

1. 源码分析

vue关于nextTick的源码在src/core/util/next-tick.js的目录下单独维护。vue不同版本的代码有些许区别。但是大部分都是在vue对于使用宏任务还是微任务的顺序上的区别

其实nextTick源码部分代码不多,满打满算100来行,但是短小精悍。其实主要分为四大部分

1.定义变量,设置一个存放函数数组,循环遍历数组里面的函数,并且执行。

2.判断什么条件环境使用 什么样的异步延迟函数 (宏任务,微任务) (这是核心关键的代码)

3.封装并导出nextTick的函数,设置this指向 (这样组件才能正常使用这个函数)

4.当nextTick 没有传入函数参数的时候,返回一个 Promise 化的调用(算是一个优化吧)

2. 代码分析

1. 定义变量,设置一个存放函数数组

介绍 引入的模块 和 定义的变量。

// noop 空函数,可用作函数占位符
import { noop } from 'shared/util';

// Vue 内部的错误处理函数
import { handleError } from './error';

// 判断是IE/IOS/内置函数
import { isIE, isIOS, isNative } from './env';

// 使用 MicroTask 的标识符
export let isUsingMicroTask = false;

// 以数组形式存储执行的函数
const callbacks = [];

// nextTick 执行状态
let pending = false;

// 遍历函数数组执行每一项函数
function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

2. 不同环境和条件下的异步延迟函数使用


接下来是核心的 异步延迟函数。这里不同的 Vue 版本采用的策略其实并不相同。

// 核心的异步延迟函数,用于异步延迟调用 flushCallbacks 函数
let timerFunc;

// timerFunc 优先使用原生 Promise
// 如果浏览器环境支持promise微任务,优先使用这样进去下个事件循环快
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);

    // IOS 的 UIWebView,Promise.then 回调被推入 microtask 队列但是队列可能不会如期执行。
    // 因此,添加一个空计时器“强制”执行 microtask 队列。
    if (isIOS) setTimeout(noop);
  };
  isUsingMicroTask = true;

  // 当原生 Promise 不可用时,timerFunc 使用原生 MutationObserver
} else if (
  !isIE &&
  typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) ||
    // PhantomJS 和 iOS 7.x
    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;

  // 如果原生 setImmediate 可用,timerFunc 使用原生 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // 没有办法,使用效率最低的宏任务 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

这一块其实很简单,众所周知,Event Loop分为宏任务(macro task)以及微任务( micro task),
执行完微任务后会进入下一个事件循环,并在两个事件循环之间执行UI渲染。

但是,宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,使用宏任务;但是,各种宏任务之间也有效率的不同,需要根据浏览器的支持情况,使用不同的宏任务。


一句话总结优先级:microtask 优先。
Promise > MutationObserver > setImmediate > setTimeout(最后无奈了选择这个)

3. 封装导出nextTick 函数


nextTick 函数。接受两个参数:

cb 回调函数:是要延迟执行的函数;
ctx:指定 cb 回调函数 的 this 指向;

Vue 实例方法 $nextTick 做了进一步封装,把 ctx 设置为当前 Vue 实例。

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve;

  // cb 回调函数会经统一处理压入 callbacks 数组
  callbacks.push(() => {
    if (cb) {
      // 给 cb 回调函数执行加上了 try-catch 错误处理
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, 'nextTick');
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });

  // 执行异步延迟函数 timerFunc
  if (!pending) {
    pending = true;
    timerFunc();
  }

4. 当nextTick 没有传入函数参数的时候,返回一个 Promise

  // 当 nextTick 没有传入函数参数的时候,返回一个 Promise 化的调用
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve;
    });
  }
}

每次调用 Vue.nextTick(cb) 会做些什么:

cb 函数经处理压入 callbacks 数组,

执行 timerFunc 函数,延迟调用 flushCallbacks 函数,

遍历执行 callbacks 数组中的所有函数。

延迟调用优先级如下:

Promise > MutationObserver > setImmediate > setTimeout

5. 写个简易的nextTick

上述四部分中,第三部分是核心也是最复杂的,一块。其实这是vue为了适应各种不同的应用环境做出大量的适配以及兼容考虑。假如我们不考虑这些情况。我们就使用效率最低的setTimeout来进行异步延迟(vue最后的else也是用的setTimeout。就是为了最后没办法的妥协)

其实简易的nextTick方法就是这些喽。。。。。

// 定义空数组
let callbacks = []
let pending = false

// 循环执行函数里面的方法
function flushCallback () {
    pending = false
    let copies = callbacks.slice()
    callbacks.length = 0
    copies.forEach(copy => {
        copy()
    })
}
// 设置导出 nextTick的函数,吧方法塞进去定义的数组,并执行上一步的flushCallback方法。
function nextTick (cb) {
    callbacks.push(cb)

    if (!pending) {
        pending = true
        setTimeout(flushCallback, 0)
    }
}
 // 当 nextTick 没有传入函数参数的时候,返回一个 Promise 化的调用
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve;
    });
  }

肯定还有很多的纰漏,期望大家指正。多谢

参考链接

vue.js源码

vue.js技术解密

浅析vue的nextTick