JavaScript Event Loop 机制详解与 Vue.js 中实践应用

JavaScript Event Loop 机制详解与 Vue.js 中实践应用概括于笔者的现代 JavaScript 开发:语法基础与实践技巧系列文章。本文依次介绍了函数调用栈、MacroTask 与 MicroTask 执行顺序、浅析 Vue.js 中 nextTick 实现等内容;本文中引用的参考资料统一声明在 JavaScript 学习与实践资料索引javascript

1. 事件循环机制详解与实践应用

JavaScript 是典型的单线程单并发语言,即表示在同一时间片内其只能执行单个任务或者部分代码片。换言之,咱们能够认为某个同域浏览器上下中 JavaScript 主线程拥有一个函数调用栈以及一个任务队列(参考 whatwg 规范);主线程会依次执行代码,当遇到函数时,会先将函数入栈,函数运行完毕后再将该函数出栈,直到全部代码执行完毕。当函数调用栈为空时,运行时即会根据事件循环(Event Loop)机制来从任务队列中提取出待执行的回调并执行,执行的过程一样会进行函数帧的入栈出栈操做。每一个线程有本身的事件循环,因此每一个 Web Worker有本身的,因此它才能够独立执行。然而,全部同属一个 origin 的窗体都共享一个事件循环,因此它们能够同步交流。html

Event Loop(事件循环)并非 JavaScript 中独有的,其普遍应用于各个领域的异步编程实现中;所谓的 Event Loop 便是一系列回调函数的集合,在执行某个异步函数时,会将其回调压入队列中,JavaScript 引擎会在异步代码执行完毕后开始处理其关联的回调。java

在 Web 开发中,咱们经常会须要处理网络请求等相对较慢的操做,若是将这些操做所有以同步阻塞方式运行无疑会大大下降用户界面的体验。另外一方面,咱们点击某些按钮以后的响应事件可能会致使界面重渲染,若是由于响应事件的执行而阻塞了界面的渲染,一样会影响总体性能。实际开发中咱们会采用异步回调来处理这些操做,这种调用者与响应之间的解耦保证了 JavaScript 可以在等待异步操做完成以前仍然可以执行其余的代码。Event Loop 正是负责执行队列中的回调而且将其压入到函数调用栈中,其基本的代码逻辑以下所示:git

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

完整的浏览器中 JavaScript 事件循环机制图解以下:github

在 Web 浏览器中,任什么时候刻都有可能会有事件被触发,而仅有那些设置了回调的事件会将其相关的任务压入到任务队列中。回调函数被调用时即会在函数调用栈中建立初始帧,而直到整个函数调用栈清空以前任何产生的任务都会被压入到任务队列中延后执行;顺序的同步函数调用则会建立新的栈帧。总结而言,浏览器中的事件循环机制阐述以下:web

  • 浏览器内核会在其它线程中执行异步操做,当操做完成后,将操做结果以及事先定义的回调函数放入 JavaScript 主线程的任务队列中。面试

  • JavaScript 主线程会在执行栈清空后,读取任务队列,读取到任务队列中的函数后,将该函数入栈,一直运行直到执行栈清空,再次去读取任务队列,不断循环。编程

  • 当主线程阻塞时,任务队列仍然是可以被推入任务的。这也就是为何当页面的 JavaScript 进程阻塞时,咱们触发的点击等事件,会在进程恢复后依次执行。api

2. 函数调用栈与任务队列

变量做用域与提高一节中咱们介绍过所谓执行上下文(Execution Context)的概念,在 JavaScript 代码执行过程当中,咱们可能会拥有一个全局上下文,多个函数上下文或者块上下文;每一个函数调用都会创造新的上下文与局部做用域。而这些执行上下文堆叠就造成了所谓的执行上下文栈(Execution Context Stack),便如上文介绍的 JavaScript 是单线程事件循环机制,同时刻仅会执行单个事件,而其余事件都在所谓的执行栈中排队等待:promise

而从 JavaScript 内存模型的角度,咱们能够将内存划分为调用栈(Call Stack)、堆(Heap)以及队列(Queue)等几个部分:

其中的调用栈会记录全部的函数调用信息,当咱们调用某个函数时,会将其参数与局部变量等压入栈中;在执行完毕后,会弹出栈首的元素。而堆则存放了大量的非结构化数据,譬如程序分配的变量与对象。队列则包含了一系列待处理的信息与相关联的回调函数,每一个 JavaScript 运行时都必须包含一个任务队列。当调用栈为空时,运行时会从队列中取出某个消息而且执行其关联的函数(也就是建立栈帧的过程);运行时会递归调用函数并建立调用栈,直到函数调用栈所有清空再从任务队列中取出消息。换言之,譬如按钮点击或者 HTTP 请求响应都会做为消息存放在任务队列中;须要注意的是,仅当这些事件的回调函数存在时才会被放入任务队列,不然会被直接忽略。

譬如对于以下的代码块:

function fire() {
    const result = sumSqrt(3, 4)
    console.log(result);
}
function sumSqrt(x, y) {
    const s1 = square(x)
    const s2 = square(y)
    const sum = s1 + s2;
    return Math.sqrt(sum)
}
function square(x) {
    return x * x;
}

fire()

其对应的函数调用图(整理自这里)为:

这里还值得一提的是,Promise.then 是异步执行的,而建立 Promise 实例 (executor) 是同步执行的,譬以下述代码:

(function test() {
    setTimeout(function() {console.log(4)}, 0);
    new Promise(function executor(resolve) {
        console.log(1);
        for( var i=0 ; i<10000 ; i++ ) {
            i == 9999 && resolve();
        }
        console.log(2);
    }).then(function() {
        console.log(5);
    });
    console.log(3);
})()

// 输出结果为:
// 1
// 2
// 3
// 5
// 4

咱们能够参考 Promise 规范中有关于 promise.then 的部分:

promise.then(onFulfilled, onRejected)

2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code. [3.1].

Here “platform code” means engine, environment, and promise implementation code. In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code, it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.

规范要求,onFulfilled 必须在执行上下文栈(Execution Context Stack) 只包含 平台代码(platform code) 后才能执行。平台代码指引擎,环境,Promise 实现代码等。实践上来讲,这个要求保证了 onFulfilled 的异步执行(以全新的栈),在 then 被调用的这个事件循环以后。

3. MacroTask(Task) 与 MicroTask(Job)

在面试中咱们经常会碰到以下的代码题,其主要就是考校 JavaScript 不一样任务的执行前后顺序:

// 测试代码
console.log('main1');

// 该函数仅在 Node.js 环境下可使用
process.nextTick(function() {
    console.log('process.nextTick1');
});

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

new Promise(function(resolve, reject) {
    console.log('promise');
    resolve();
}).then(function() {
    console.log('promise then');
});

console.log('main2');

// 执行结果
main1
promise
main2
process.nextTick1
promise then
setTimeout
process.nextTick2

咱们在前文中已经介绍过 JavaScript 的主线程在遇到异步调用时,这些异步调用会马上返回某个值,从而让主线程不会在此处阻塞。而真正的异步操做会由浏览器执行,主线程则会在清空当前调用栈后,按照先入先出的顺序读取任务队列里面的任务。而 JavaScript 中的任务又分为 MacroTask 与 MicroTask 两种,在 ES2015 中 MacroTask 即指 Task,而 MicroTask 则是指代 Job。典型的 MacroTask 包含了 setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering 等,MicroTask 包含了 process.nextTick, Promises, Object.observe, MutationObserver 等。 两者的关系能够图示以下:

参考 whatwg 规范 中的描述:一个事件循环(Event Loop)会有一个或多个任务队列(Task Queue,又称 Task Source),这里的 Task Queue 就是 MacroTask Queue,而 Event Loop 仅有一个 MicroTask Queue。每一个 Task Queue 都保证本身按照回调入队的顺序依次执行,因此浏览器能够从内部到JS/DOM,保证动做按序发生。而在 Task 的执行之间则会清空已有的 MicroTask 队列,在 MacroTask 或者 MicroTask 中产生的 MicroTask 一样会被压入到 MicroTask 队列中并执行。参考以下代码:

function foo() {
  console.log("Start of queue");
  bar();
  setTimeout(function() {
    console.log("Middle of queue");
  }, 0);
  Promise.resolve().then(function() {
    console.log("Promise resolved");
    Promise.resolve().then(function() {
      console.log("Promise resolved again");
    });
  });
  console.log("End of queue");
}

function bar() {
  setTimeout(function() {
    console.log("Start of next queue");
  }, 0);
  setTimeout(function() {
    console.log("End of next queue");
  }, 0);
}

foo();

// 输出
Start of queue
End of queue
Promise resolved
Promise resolved again
Start of next queue
End of next queue
Middle of queue

上述代码中首个 TaskQueue 即为 foo(),foo() 又调用了 bar() 构建了新的 TaskQueue,bar() 调用以后 foo() 又产生了 MicroTask 并被压入了惟一的 MicroTask 队列。咱们最后再总计下 JavaScript MacroTask 与 MicroTask 的执行顺序,当执行栈(call stack)为空的时候,开始依次执行:

  1. 把最先的任务(task A)放入任务队列

  2. 若是 task A 为null (那任务队列就是空),直接跳到第6步

  3. 将 currently running task 设置为 task A

  4. 执行 task A (也就是执行回调函数)

  5. 将 currently running task 设置为 null 并移出 task A

  6. 执行 microtask 队列

    • a: 在 microtask 中选出最先的任务 task X

    • b: 若是 task X 为null (那 microtask 队列就是空),直接跳到 g

    • c: 将 currently running task 设置为 task X

    • d: 执行 task X

    • e: 将 currently running task 设置为 null 并移出 task X

    • f: 在 microtask 中选出最先的任务 , 跳到 b

    • g: 结束 microtask 队列

  7. 跳到第一步

4. 浅析 Vue.js 中 nextTick 的实现

在 Vue.js 中,其会异步执行 DOM 更新;当观察到数据变化时,Vue 将开启一个队列,并缓冲在同一事件循环中发生的全部数据改变。若是同一个 watcher 被屡次触发,只会一次推入到队列中。这种在缓冲时去除重复数据对于避免没必要要的计算和 DOM 操做上很是重要。而后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际(已去重的)工做。Vue 在内部尝试对异步队列使用原生的 Promise.thenMutationObserver,若是执行环境不支持,会采用 setTimeout(fn, 0) 代替。

《由于本人失误,原来此处内容拷贝了 https://www.zhihu.com/questio... 这个回答,形成了侵权,深表歉意,已经删除,后续我会在 github 连接上重写本段》

而当咱们但愿在数据更新以后执行某些 DOM 操做,就须要使用 nextTick 函数来添加回调:

// HTML
<div id="example">{{message}}</div>

// JS
var vm = new Vue({
  el: '#example',
  data: {
    message: '123'
  }
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
  vm.$el.textContent === 'new message' // true
})

在组件内使用 vm.$nextTick() 实例方法特别方便,由于它不须要全局 Vue ,而且回调函数中的 this 将自动绑定到当前的 Vue 实例上:

Vue.component('example', {
  template: '<span>{{ message }}</span>',
  data: function () {
    return {
      message: '没有更新'
    }
  },
  methods: {
    updateMessage: function () {
      this.message = '更新完成'
      console.log(this.$el.textContent) // => '没有更新'
      this.$nextTick(function () {
        console.log(this.$el.textContent) // => '更新完成'
      })
    }
  }
})

src/core/util/env

/**
 * 使用 MicroTask 来异步执行批次任务
 */
export const nextTick = (function() {
  // 须要执行的回调列表
  const callbacks = [];

  // 是否处于挂起状态
  let pending = false;

  // 时间函数句柄
  let timerFunc;

  // 执行而且清空全部的回调列表
  function nextTickHandler() {
    pending = false;
    const copies = callbacks.slice(0);
    callbacks.length = 0;
    for (let i = 0; i < copies.length; i++) {
      copies[i]();
    }
  }

  // nextTick 的回调会被加入到 MicroTask 队列中,这里咱们主要经过原生的 Promise 与 MutationObserver 实现
  /* istanbul ignore if */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    let p = Promise.resolve();
    let logError = err => {
      console.error(err);
    };
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError);

      // 在部分 iOS 系统下的 UIWebViews 中,Promise.then 可能并不会被清空,所以咱们须要添加额外操做以触发
      if (isIOS) setTimeout(noop);
    };
  } else if (
    typeof MutationObserver !== 'undefined' &&
    (isNative(MutationObserver) ||
      // PhantomJS and iOS 7.x
      MutationObserver.toString() === '[object MutationObserverConstructor]')
  ) {
    // 当 Promise 不可用时候使用 MutationObserver
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    let counter = 1;
    let observer = new MutationObserver(nextTickHandler);
    let textNode = document.createTextNode(String(counter));
    observer.observe(textNode, {
      characterData: true
    });
    timerFunc = () => {
      counter = (counter + 1) % 2;
      textNode.data = String(counter);
    };
  } else {
    // 若是都不存在,则回退使用 setTimeout
    /* istanbul ignore next */
    timerFunc = () => {
      setTimeout(nextTickHandler, 0);
    };
  }

  return function queueNextTick(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);
      }
    });
    if (!pending) {
      pending = true;
      timerFunc();
    }

    // 若是没有传入回调,则表示以异步方式调用
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        _resolve = resolve;
      });
    }
  };
})();

5. 延伸阅读

相关文章
相关标签/搜索