从事件循环到异步机制

643 阅读9分钟

概述

我曾多次去了解事件循环,想看清楚它的全貌,但直至如今我掌握的只是其中的一点皮毛。写这篇文章主要是再次学习与记录它。
文章主要介绍部分面试题、事件循环概述、primise中异步机制asap、vue中nextTick、Mutation observer等。

浏览器事件循环

由于js是一门单线程的语言,运行时所有的程序都在会一个执行队列中等待被执行。在js中将程序分为同步任务和异步任务,在运行时同步任务会直接进入主线程中执行形成一个执行栈,当栈中程序执行完,系统就会去读取异步任务队列(事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放一个事件回调),比如ajax、settimeout、click事件等。而上述过程的不断重复运作就成了事件循环。具体的可见下图:

image.png

我们来看一段经典的例子吧

    //进入主线程
    console.log('script start');
    //直接输出script start

    //挂起
    setTimeout(function () {
        console.log('setTimeout');
    }, 0);
    //等待被执行 primose执行完后 输出setTimeout

    //挂起
    Promise.resolve()
        .then(function () {
            console.log('promise1');
        }).then(function () {
            console.log('promise2');
        });
    //等待被执行script end 输出后 输出 promise1 promise2
    
    //进入主线程
    console.log('script end');
    //直接输出script end

程序开始执行,如果不是异步直接执行,如果是异步代码进入异步队列挂起,当同步任务执行完,开始执行异步任务,当异步的回调结束返回值时,开始执行返回内容。
这段代码执行的结果为:script start script end promise1 promise2 setTimeout。很奇怪的是settimeout为什么会在promise后面呢?这就涉及到宏任务和微任务了。

宏任务&微任务

宏任务(macrotask)

在ECMAScript中,macrotask也被称为task

我们可以将每次执行栈执行的代码当做是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行), 每一个宏任务会从头到尾执行完毕,不会执行其他。 常见的宏任务有:

  • 主代码块
  • setTimeout
  • setInterval
  • requestAnimationFrame
  • /O、UI 交互事件
  • setImmediate(Node.js 环境)

微任务

微任务是在es6 Promise出现时,浏览器新规定的一个概念。在ECMAScript中,microtask也被称为jobs。 我们已经知道宏任务结束后,会执行渲染,然后执行下一个宏任务, 而微任务可以理解成在当前宏任务执行后立即执行的任务。常见的微任务有:

  • Promise
  • MutaionObserver
  • process.nextTick(Node.js 环境)
  • Object.observe

异步任务运行机制

在事件循环中,每一次的循环我们称为tick,而其中tick的关键点为:

  1. 在 tick 中选择最先进入队列的任务( oldest task ),如果有则执行(一次)
  2. 检查是否存在 jobs ,如果存在则不停地执行,直至清空jobs Queue
  3. 更新 render
  4. 主线程重复执行上述步骤 图解如下

image.png

相关的例子以及常见面试题

监听div的点击

<div class="outer">
  <div class="inner"></div>
</div>
<script>
  let outer = document.querySelector('.outer');
  let inner = document.querySelector('.inner');


  new MutationObserver(function () {
    console.log('mutate');
  }).observe(outer, {
    attributes: true,
  });

  function onClick() {
    console.log('click');

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

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

    outer.setAttribute('data-random', Math.random());
  }

  inner.addEventListener('click', onClick);
  outer.addEventListener('click', onClick);
</script>

<style>
  .outer {
    height: 200px;
    width: 200px;
    background: lightgray;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  .inner {
    height: 100px;
    width: 100px;
    background: gray;
  }
</style>
  1. 如果我们点击inner会发生什么呢?
  2. 触发事件onclike输出:click
  3. settimeout入宏任务队列挂起
  4. promise进入微任务队里挂起
  5. dom监听器监听到事件,进入微任务队列挂起
  6. primise出栈输入:promise
  7. 监听器出栈输出:mutate
  8. 父div触发onclick事件输出:click
  9. promise进入微任务队里挂起
  10. dom监听器监听到事件,进入微任务队列挂起
  11. primise出栈输入:promise
  12. 监听器出栈输出:mutate
  13. setimeout出栈输入:timeout 但是在不同浏览器中,可能结果不一样哦,因为浏览器内核,对异步机制有不一样的处理。

image.png 下面在举几个例子我就不一一解析了,大家自己分析判断吧 例子一:

 async function one() {
    console.log('1')
    await two()
    console.log('1 end')
}

function two() {
    setTimeout(() => {
      console.log('2') 
    })
}

one()

setTimeout(() => {
  console.log('setTimeout')
})

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

console.log('end')

运行结果:

image.png 例子二:

async function async1() {
    console.log('async1 start');
    await async2();
    setTimeout(() => {
      console.log('setTimeout1')
    });
  }
  async function async2() {
    setTimeout(() => {
      console.log('setTimeout2')
    });
  }
  console.log('script start');
  setTimeout(() => {
    console.log('setTimeout3')
  });
  async1();
  new Promise((resolve) => {
    console.log('promise1');
    resolve();
  }).then(function () {
    console.log('promise2');
  });
  console.log('script end');

答案:

image.png 我想看完这二个面试题,对基本的事件循环机制应该能了解清晰了,但是更核心的问题是,js的异步机制到底是这么运行的呢?promise底层原理以及异步机制如何实现是用settimeout类似的方法吗?

promise原理以及异步机制

promise是一个解决异步的对象,其行为需要满足promiseA+规范。其中a+规范部分如下所述
术语

  • henable是一个定义了 then 方法的对象或函数,文中译作“拥有 then 方法”;

  • 值(value)指任何 JavaScript 的合法值(包括 undefined , thenable 和 promise);

  • 异常(exception)是使用 throw 语句抛出的一个值。

  • 据因(reason)表示一个 promise 的拒绝原因。

  • Promise 的状态

一个 Promise 的当前状态必须为以下三种状态中的一种:等待态(Pending)执行态(Fulfilled)拒绝态(Rejected)

  1. 等待态(Pending)处于等待态时,promise 需满足以下条件:可以迁移至执行态或拒绝态

  2. 执行态(Fulfilled)处于执行态时,promise 需满足以下条件: 不能迁移至其他任何状态,必须拥有一个不可变的终值

  3. 拒绝态(Rejected)处于拒绝态时,promise 需满足以下条件:不能迁移至其他任何状态,必须拥有一个不可变的据因

这里的不可变指的是恒等(即可用 === 判断相等),而不是意味着更深层次的不可变(**译者注:**盖指当 value 或 reason 不是基本值时,只要求其引用地址相等,但属性值可被更改)。

Then 方法

一个 promise 必须提供一个 then 方法以访问其当前值、终值和据因。

promise 的 then 方法接受两个参数:

promise.then(onFulfilled, onRejected)

大家可以参考下primise a+的规范

简单实现(来自于掘金作者‘’ssh_晨曦时梦见兮”的实现代码)

function Promise(fn) {
  this.cbs = [];

  const resolve = (value) => {
    setTimeout(() => {
      this.data = value;
      this.cbs.forEach((cb) => cb(value));
    });
  }

  fn(resolve);
}

Promise.prototype.then = function (onResolved) {
  return new Promise((resolve) => {
    this.cbs.push(() => {
      const res = onResolved(this.data);
      if (res instanceof Promise) {
        res.then(resolve);
      } else {
        resolve(res);
      }
    });
  });
};

这是比较经典的实现链式调用的方法,我也是看了很久才看懂的。

官方核心源码

'use strict';

var asap = require('asap/raw');

function noop() {}

// States:
//
// 0 - pending
// 1 - fulfilled with _value
// 2 - rejected with _value
// 3 - adopted the state of another promise, _value
//
// once the state is no longer pending (0) it is immutable

// All `_` prefixed properties will be reduced to `_{random number}`
// at build time to obfuscate them and discourage their use.
// We don't use symbols or Object.defineProperty to fully hide them
// because the performance isn't good enough.


// to avoid using try/catch inside critical functions, we
// extract them to here.
var LAST_ERROR = null;
var IS_ERROR = {};
function getThen(obj) {
  try {
    return obj.then;
  } catch (ex) {
    LAST_ERROR = ex;
    return IS_ERROR;
  }
}

function tryCallOne(fn, a) {
  try {
    return fn(a);
  } catch (ex) {
    LAST_ERROR = ex;
    return IS_ERROR;
  }
}
function tryCallTwo(fn, a, b) {
  try {
    fn(a, b);
  } catch (ex) {
    LAST_ERROR = ex;
    return IS_ERROR;
  }
}

module.exports = Promise;

function Promise(fn) {
  if (typeof this !== 'object') {
    throw new TypeError('Promises must be constructed via new');
  }
  if (typeof fn !== 'function') {
    throw new TypeError('Promise constructor's argument is not a function');
  }
  this._deferredState = 0;
  this._state = 0;
  this._value = null;
  this._deferreds = null;
  if (fn === noop) return;
  doResolve(fn, this);
}
Promise._onHandle = null;
Promise._onReject = null;
Promise._noop = noop;

Promise.prototype.then = function(onFulfilled, onRejected) {
  if (this.constructor !== Promise) {
    return safeThen(this, onFulfilled, onRejected);
  }
  var res = new Promise(noop);
  handle(this, new Handler(onFulfilled, onRejected, res));
  return res;
};

function safeThen(self, onFulfilled, onRejected) {
  return new self.constructor(function (resolve, reject) {
    var res = new Promise(noop);
    res.then(resolve, reject);
    handle(self, new Handler(onFulfilled, onRejected, res));
  });
}
function handle(self, deferred) {
  while (self._state === 3) {
    self = self._value;
  }
  if (Promise._onHandle) {
    Promise._onHandle(self);
  }
  if (self._state === 0) {
    if (self._deferredState === 0) {
      self._deferredState = 1;
      self._deferreds = deferred;
      return;
    }
    if (self._deferredState === 1) {
      self._deferredState = 2;
      self._deferreds = [self._deferreds, deferred];
      return;
    }
    self._deferreds.push(deferred);
    return;
  }
  handleResolved(self, deferred);
}

function handleResolved(self, deferred) {
  asap(function() {
    var cb = self._state === 1 ? deferred.onFulfilled : deferred.onRejected;
    if (cb === null) {
      if (self._state === 1) {
        resolve(deferred.promise, self._value);
      } else {
        reject(deferred.promise, self._value);
      }
      return;
    }
    var ret = tryCallOne(cb, self._value);
    if (ret === IS_ERROR) {
      reject(deferred.promise, LAST_ERROR);
    } else {
      resolve(deferred.promise, ret);
    }
  });
}
function resolve(self, newValue) {
  // Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure
  if (newValue === self) {
    return reject(
      self,
      new TypeError('A promise cannot be resolved with itself.')
    );
  }
  if (
    newValue &&
    (typeof newValue === 'object' || typeof newValue === 'function')
  ) {
    var then = getThen(newValue);
    if (then === IS_ERROR) {
      return reject(self, LAST_ERROR);
    }
    if (
      then === self.then &&
      newValue instanceof Promise
    ) {
      self._state = 3;
      self._value = newValue;
      finale(self);
      return;
    } else if (typeof then === 'function') {
      doResolve(then.bind(newValue), self);
      return;
    }
  }
  self._state = 1;
  self._value = newValue;
  finale(self);
}

function reject(self, newValue) {
  self._state = 2;
  self._value = newValue;
  if (Promise._onReject) {
    Promise._onReject(self, newValue);
  }
  finale(self);
}
function finale(self) {
  if (self._deferredState === 1) {
    handle(self, self._deferreds);
    self._deferreds = null;
  }
  if (self._deferredState === 2) {
    for (var i = 0; i < self._deferreds.length; i++) {
      handle(self, self._deferreds[i]);
    }
    self._deferreds = null;
  }
}

function Handler(onFulfilled, onRejected, promise){
  this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null;
  this.onRejected = typeof onRejected === 'function' ? onRejected : null;
  this.promise = promise;
}

/**
 * Take a potentially misbehaving resolver function and make sure
 * onFulfilled and onRejected are only called once.
 *
 * Makes no guarantees about asynchrony.
 */
function doResolve(fn, promise) {
  var done = false;
  var res = tryCallTwo(fn, function (value) {
    if (done) return;
    done = true;
    resolve(promise, value);
  }, function (reason) {
    if (done) return;
    done = true;
    reject(promise, reason);
  });
  if (!done && res === IS_ERROR) {
    done = true;
    reject(promise, LAST_ERROR);
  }
}

这里有时间我在给大家解析官方的代码(最近比较忙 凑个时间把重点的写写,有时间在回头改改)

关于primise的异步机制asap

asapasap 是 as soon as possible 的简称,在 Node 和浏览器环境下,能将回调函数以高优先级任务来执行(下一个事件循环之前),即把任务放在微任务队列中执行。
其中的核心为
异步方法是通过 setImmediate 或 process.nextTick 来实现异步执行的任务栈,而 asap 方法是对 rawAsap 方法的进一步封装,通过缓存的 domain 和 try/finally 实现了即使某个任务抛出异常也可以恢复任务栈的继续执行(再次调用rawAsap.requestFlush)。其中主要包含两个源码文件:asap.js和raw.js。而重点都在raw.js的代码中,所以我们只有分析raw.js的代码即可。分析请看注释。

"use strict";

var domain; // The domain module is executed on demand
var hasSetImmediate = typeof setImmediate === "function";

//给外界导出的方法
module.exports = rawAsap;
//如果任务栈为空,则触发requestFlush方法,并始终把将要执行的task添加在任务栈queue的末尾
function rawAsap(task) {
    if (!queue.length) {
        requestFlush();
        flushing = true;
    }
    // Avoids a function call
    queue[queue.length] = task;
}

//任务队列
var queue = [];

var flushing = false;

// 下一个任务在任务队列中执行的位置
var index = 0;

var capacity = 1024;

//通过 while 循环依次去执行任务栈 queue 中的每一个任务
//如果遇到异常直接跳过开始下一个任务的执行
// 并处理内存泄漏的
function flush() {
    while (index < queue.length) {
        var currentIndex = index;
        // 在调用任务之前先设置下一个任务的索引,可以确保再次触发 flush 方法时,跳过异常任务
        index = index + 1;
        queue[currentIndex].call()
        // 防止内存泄露
        if (index > capacity) {
            for (var scan = 0, newLength = queue.length - index; scan < newLength; scan++) {
                queue[scan] = queue[scan + index];
            }
            queue.length -= index;
            index = 0;
        }
    }
    queue.length = 0;
    index = 0;
    flushing = false;
}

// 设置为 rawAsap 的属性,方便在任务执行异常时再次触发 requestFlush
rawAsap.requestFlush = requestFlush;

//心代码其实就一句:setImmediate(flush),通过 setImmediate 异步执行 flush 方法。
// 而判断 parentDomain 以及设置和恢复 domain 都只是为了当前的 flush 方法不绑定任何域执行。
// 而这里还有一个 hasSetImmediate 判断,是为了做兼容降级处理,如果不存在 setImmediate 方法,
// 则使用 process.nextTick 方法触发异步执行。但使用 process.nextTick 方法有一个缺陷,就是它不能够处理递归。
function requestFlush() {
    // 确保 flushing 未绑定到任何域
    var parentDomain = process.domain;
    if (parentDomain) {
        if (!domain) {
            // 惰性加载执行 domain 模块
            domain = require("domain");
        }
        domain.active = process.domain = null;
    }
    if (flushing && hasSetImmediate) {
        setImmediate(flush);
    } else {
        process.nextTick(flush);
    }
    if (parentDomain) {
        domain.active = process.domain = parentDomain;
    }
}

其他内容

vue中nextTick与事件循环

在 Vue.js 里是数据驱动视图变化,由于 JS 执行是单线程的,在一个 tick 的过程中,它可能会多次修改数据,但 Vue.js 并不会傻到每修改一次数据就去驱动一次视图变化,它会把这些数据的修改全部 push 到一个队列里,然后内部调用 一次 nextTick 去更新视图,所以数据到 DOM 视图的变化是需要在下一个 tick 才能完成。这便是我们为什么需要vue.nextTick。
但是在使用的过程中我发现:

  1. 如果代码不涉及异步能正常渲染界面
  2. 如果页面渲染的数据,在异步中执行(页面已渲染,但异步未返回),nextTick不能更新数据
  3. 而nextTick中的实现部分使用微任务部分使用宏任务,导致渲染时机不同。 解决方法: 一般使用settimeout将任务变为宏任务即可,但是在某些情况下面。如异步响应时间较长的时候,我们就需要使用监听器去监听数据的变量了,如果数据改变,我们在做相应的事情即可。

DOM 变动观察器(Mutation observer)

MutationObserver是一个内建对象,它观察 DOM 元素,并在检测到更改时触发回调。 MutationObserver 使用简单。

首先,我们创建一个带有回调函数的观察器:

let observer = new MutationObserver(callback);

然后将其附加到一个 DOM 节点:

observer.observe(node, config);

例如,这里有一个 <div>,它具有 contentEditable 特性。该特性使我们可以聚焦和编辑元素。

<div contentEditable id="elem">Click and <b>edit</b>, please</div>

<script>
let observer = new MutationObserver(mutationRecords => {
  console.log(mutationRecords); // console.log(the changes)
});

// 观察除了特性之外的所有变动
observer.observe(elem, {
  childList: true, // 观察直接子节点
  subtree: true, // 及其更低的后代节点
  characterDataOldValue: true // 将旧的数据传递给回调
});
</script>

如果我们在浏览器中运行上面这段代码,并聚焦到给定的 <div> 上,然后更改 <b>edit</b> 中的文本,console.log 将显示一个变动:

mutationRecords = [{
  type: "characterData",
  oldValue: "edit",
  target: <text node>,
  // 其他属性为空
}];

总结

哈哈哈 感觉自己的水平还是有限啊,整篇文档读下来,更像一个第三方文档。虽然也有不少自己的思考,但是都很浅。希望以后的文章精益求精吧,尽量多一点自己的想法。如果文章中有不当的地方欢迎大家指出错误哦,后期也会去不断维护和更新的。

引用

npm promise包
juejin.cn/post/684490…
jakearchibald.com/2015/tasks-…
juejin.cn/post/684490…
zhuanlan.zhihu.com/p/87684858
github.com/kriskowal/a…
www.ituring.com.cn/article/665…
juejin.cn/post/684490…