执行上下文栈、异步和事件循环

269 阅读4分钟

函数执行上下文

js代码中,要开始执行一个函数时候,js引擎已经为该函数创建了一个执行上下文,并且这个上下文,绑定了一些标识符,可以用来访问一些数据。这些标识符,可以是js中的一些关键字,也可以是用户定义的一些变量。

chrome中的函数上下文

在chrome浏览器里面,可以看到函数执行过程中,上下文绑定的属性:

let person = {
  name: 'lucas',
  age: 18
}
var msg = 'hello fn ctx'
foo()
function foo() {
  const fooVar = 'fooVar'
  bar()
  function bar() {
    const constVar = 'declare via const'
    let letVar = 'declare via let'
    var variable = 'declare via var'
    console.log('bar', person)
    console.log('bar', msg)
    console.log('bar', fooVar)
    debugger
  }
}

image.png

  • Local:当前函数定义的一些属性,以及一些相关的关键字(比如this
  • Closure:闭包,外层函数定义的属性。及内部定义的具名函数。
  • Script:全局环境中,使用let/const定义的属性
  • Global:在浏览器中就是window,包含使用var/function定义的一些属性,还有宿主环境(浏览器、node)添加的一些属性

事实上,查找标识符对应的值时,就是按照 Local → Closure → Script → Global 的顺序查找的,也就是作用域链,只不过闭包可能是多层嵌套的。

<script>
  const msg = 'hello scope'
  window.msg = 'msg in global(window)' // 使用 var 定义会报错
  foo()
  function foo() {
    console.log(msg) // hello scope
    console.log(window.msg) // msg in global(window)
  }
</script>

想了解更加详细和准确的信息可以参考 ecma执行上下文

执行上下文栈

在js中,函数的地位很高,它可以作为参数传递给其它函数,也可以作为另外一个函数的返回值。整个js代码的执行过程,其实就是函数的调用过程。

执行上下文栈是一个栈结构的数据,和其它的栈一样,它遵循LIFO(last in first out,即后进先出)的规则,栈顶的上下文对应当前正在执行的函数。在全局代码开始执行时,全局函数上下文入栈,然后每当有函数被调用时,就会将对应的上下文入栈(栈顶)。当栈顶的函数执行完毕后,对应的上下文就会出栈,同时,js代码也会继续执行新的上下文对应的函数。全局函数出栈后,即代表全局代码执行完毕。

以一段代码和图来表示上下文栈的变化过程:

first()
function first() {
  console.log('开始执行first')
  second()
  console.log('first执行完毕')
}
function second() {
  console.log('开始执行second')
  third()
  console.log('second执行完毕')
}
function third() {
  console.log('开始执行third')
  console.log('third执行完毕')
}

image.png

执行栈在浏览器中的体现:

虽然真正的函数上下文栈是不可见的,但是很多浏览器经维护了一个函数调用栈,它基本上和上下方栈一样:

image.png

image.png

image.png 再后面就是依次出栈的过程了。

上下文栈、任务队列、异步与事件循环

js代码的执行是单线程的,但是有时候又需要用js来做一些需要耗时的操作,比如延时操作、发送网络请求等,这个时候不可能让js代码等着。此时就要到另外一个东西:任务队列(也有人称事件队列)。

上下文栈与任务队列

全局函数上下文进入上下文栈后,每当上下文栈为空时,js引擎就会查看任务队列中是否有要执行的任务。如果有,就将它放入上下文栈中执行,执行完毕、上下文栈为后,会再次查看任务队列,依次循环,这个过程就叫事件循环。

const divEl = document.querySelector("##btn")
    divEl.addEventListener('click', () => {
      console.log('点击了 btn')
    })
    window.addEventListener('load', (event) => {
      // 2. load 事件的回调入栈
      console.log('loaded')
      // 3. 鼠标点击 btn
      const arr = []
      console.time('for loop')
      for (let i = 0; i < 100000000 / 2; i++) {
        arr.push([])
      }
      for (let i = 0; i < 100000000 / 2; i++) {
        arr.pop()
      }
      console.timeEnd('for loop')
      // 4. load 事件的回调出栈
    })
    // 1. 全局代码执行完毕

image.png

在上面的例子中,代码执行顺序与上班文栈的状态如上图所示:

  1. 全局代码执行完毕后,由于代码中添加了load事件,在网页load后,会将load事件对应的回调函数放入任务队列中(注意不是执行),此时上下文栈为空。
  2. 由于上下文栈为空,会为load事件回调函数创建对应的上下文,并推入上下文栈中,load事件回调函数开始执行,控制台打印 loaded
  3. 在此时,点击页面上的 btn 按钮,会将其对应的回调函数放入事件队列中,同样的,代码并不会立即执行
  4. load事件回调函数执行完毕后,控制台打印for循环执行耗时,对应上下文出栈,上下文栈为空
  5. btn点击事件回调函数上下文入栈,函数开始执行,控制台打印点击了btn

对异步的理解

同步和异步,其实是针对两个函数而言的,如果它们是在同一次上下文出/入栈的周期中执行的,那么它们就是同步的,反之就是异步的。这里的出/入栈周期指的是,从栈底的函数入栈开始,到它出栈为止。

也就是说,异步和耗时与否没有必然的联系,例如,其实XHR也可以发送同步网络请求:

Promise.resolve().then(() => {
  console.log('=========')
})
// 发送异步网络请求
// const xhr1 = new XMLHttpRequest();
// xhr1.onreadystatechange = function () {
//   // 4 for completed
//   if (xhr1.readyState == 4) {
//     console.log(JSON.parse(xhr1.responseText))
//   }
// };
// xhr1.open("get", "<http://localhost:3000>", true)
// xhr1.send(null)

// 发送同步网络请求
const xhr2 = new XMLHttpRequest()
xhr2.open('get', '<http://localhost:3000>', false)
xhr2.send(null)
console.log(JSON.parse(xhr2.responseText))

宏任务和微任务

事实上,任务队列并不是只有一个,它们之间是有优先级的。在绝大多数浏览器中,存在一个宏任务队列和微任务队列,并且微任务队列的优先级高于宏任务队列,所以可能出现一种情况,就是宏任务队列的任务先注册,但是微任务队列的任务却先执行,比如常见的setTimeoutPromise:

console.log("全局代码开始执行");
setTimeout(() => {
  console.log("宏任务======");
});
// 1. setTimeout 微任务队伍
Promise.resolve().then(() => {
  console.log("微任务======");
});
console.log("全局代码完毕");

对应的队列表现为:

image.png

常见的宏任务和微任务:

宏任务

  • setTimeout 和 setInterval
  • requestAnimationFrame
  • postMessage
  • MessageChannel

微任务

  • promise.then(包括promise.catch、promsie.finally)
  • process.nextTick(nodejs)
  • MutationObserver
  • Object.observe

nodejs的事件循环

相较于浏览器,nodejs中的事件循环更为复杂,在大致也有宏任务和微任务的区别。很多资料里面说nodejs的setImmediate是宏任务,这个说法并不准确,例如setTimoutdelay为0的回调和setImmediate的回调的执行顺序在不同场景下是不一致的,更多关于nodejs事件循环的信息可以参考nodejs.org/zh-cn/docs/…