函数执行上下文
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
}
}
- 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执行完毕')
}
执行栈在浏览器中的体现:
虽然真正的函数上下文栈是不可见的,但是很多浏览器经维护了一个函数调用栈,它基本上和上下方栈一样:
再后面就是依次出栈的过程了。
上下文栈、任务队列、异步与事件循环
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. 全局代码执行完毕
在上面的例子中,代码执行顺序与上班文栈的状态如上图所示:
- 全局代码执行完毕后,由于代码中添加了load事件,在网页load后,会将load事件对应的回调函数放入任务队列中(注意不是执行),此时上下文栈为空。
- 由于上下文栈为空,会为load事件回调函数创建对应的上下文,并推入上下文栈中,load事件回调函数开始执行,控制台打印
loaded - 在此时,点击页面上的 btn 按钮,会将其对应的回调函数放入事件队列中,同样的,代码并不会立即执行
- load事件回调函数执行完毕后,控制台打印for循环执行耗时,对应上下文出栈,上下文栈为空
- 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))
宏任务和微任务
事实上,任务队列并不是只有一个,它们之间是有优先级的。在绝大多数浏览器中,存在一个宏任务队列和微任务队列,并且微任务队列的优先级高于宏任务队列,所以可能出现一种情况,就是宏任务队列的任务先注册,但是微任务队列的任务却先执行,比如常见的setTimeout和Promise:
console.log("全局代码开始执行");
setTimeout(() => {
console.log("宏任务======");
});
// 1. setTimeout 微任务队伍
Promise.resolve().then(() => {
console.log("微任务======");
});
console.log("全局代码完毕");
对应的队列表现为:
常见的宏任务和微任务:
宏任务
- setTimeout 和 setInterval
- requestAnimationFrame
- postMessage
- MessageChannel
微任务
- promise.then(包括promise.catch、promsie.finally)
- process.nextTick(nodejs)
- MutationObserver
- Object.observe
nodejs的事件循环
相较于浏览器,nodejs中的事件循环更为复杂,在大致也有宏任务和微任务的区别。很多资料里面说nodejs的setImmediate是宏任务,这个说法并不准确,例如setTimout的delay为0的回调和setImmediate的回调的执行顺序在不同场景下是不一致的,更多关于nodejs事件循环的信息可以参考nodejs.org/zh-cn/docs/…