学习并总结 JavaScript 执行机制相关知识,内容顺序由大到小,包含了运行时概念、事件循环、函数执行与语句执行
运行时概念
执行栈(Stack)
函数调用形成了一个由若干帧组成的栈
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
- 当调用
bar
时,第一个帧被创建并压入栈中,帧中包含了bar
的参数和局部变量 - 当
bar
调用foo
时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含foo
的参数和局部变量 - 当
foo
执行完毕然后返回时,第二个帧就被弹出栈(剩下bar
函数的调用帧 ) - 当
bar
也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了
堆(Heap)
对象被分配在堆中
任务队列(Queue)
- 一个 JavaScript 运行时包含了一个待处理的任务队列,每个任务都关联着一个回调函数
- 在事件循环期间时,先进入队列的任务会被优先处理,而后被处理的任务会被移出队列,并作为输入参数来调用与之关联的回调函数
- 调用一个回调函数总是会为其创造一个新的栈帧,函数的处理会一直进行到执行栈再次为空为止,然后事件循环将会处理队列中的下一个任务(如果还有的话)
可视化描述
--引自MDN
事件循环
JavaScript 引擎等待宿主环境分配任务是一个 等待
-> 执行
的过程,循环往复,也就是事件循环
while (queue.waitForTask()) {
queue.processNextTask()
}
宏任务(MacroTask)、微任务(MicroTask)
- 由宿主环境发起的任务称为宏任务,常见的有:
script
、I/O
、setTimeout
、setInterval
、setImmediate
、requestAnimationFrame
等 - JavaScript 引擎自身发起的任务称为微任务,常见的有:
Promise.then/catch/finally
、async/await
、MutationObserver
、process.nextTick
等 - 宏任务是从头执行一段程序(比如从一个控制台,或在一个
<script>
元素中运行代码)、执行一个事件回调或一个interval/timeout
被触发之类的标准机制而被调度的 JavaScript 代码,这些都在宏任务队列上被调度 - 在宏任务中还包含了如 Promise 等由 JavaScript 引擎发起的微任务,JavaScript 必须保证这些微任务在一个宏任务中完成,所以每一个宏任务中还包含了一个微任务队列
- 每当一个宏任务存在,事件循环都会检查该任务是否正把控制权交给其他 JavaScript 代码,如果没有,事件循环就会运行微任务队列中的所有微任务
宏任务和微任务的关系应该是这样的
他们的执行顺序应该是这样的
- 执行栈选择最先进入队列的宏任务(一般都是
script
),执行其同步代码至执行栈为空且控制权尚未返还给用来驱动脚本执行环境的事件循环之前 - 检查是否存在微任务(如
Promise
),有则会执行至微任务,直到队列为空 - 开始下一轮,执行下一个宏任务(
setTimeout
等回调) - 所以微任务总是会先于宏任务
Promise、async/await
Promise 是 JavaScript 语言提供的一种标准化的异步管理方式,它的总体思想是,需要进行 io、等待或者其它异步操作的函数,不返回真实结果,而返回一个承诺,函数的调用方可以在合适的时机,选择等待这个承诺兑现
- 看一个可以有效证明微任务先于宏任务的例子
setTimeout(() => console.log('d'), 0)
const r = new Promise((resolve, reject) => {
resolve()
})
r.then(() => {
const begin = Date.now()
while (Date.now() - begin < 1000);
console.log('c1')
new Promise((resolve, reject) => {
resolve()
}).then(() => console.log('c2'))
})
1. 强制 1 秒的执行耗时,确保微任务 c2 是在宏任务 d 之后被添加到任务队列
2. 耗时 1 秒的 c1 执行完毕,再入队的 c2 仍先于 d 执行了
最终输出:c1 c2 d
- 再看一个比较综合的例子
const sleep = duration => {
return new Promise((resolve, reject) => {
console.log('b')
setTimeout(resolve, duration)
// resolve()
})
}
console.log('a')
sleep(1000).then(() => console.log('c'))
foo()
setTimeout(() => {
console.log('e')
}, 3000)
setTimeout(() => {
console.log('d')
}, 2000)
async function foo(){
await sleep(5000)
console.log('f')
}
微任务
1. console.log('a')
2. 调用 sleep(1000) 返回 Promise 中的 console.log('b')
3. 调用 foo() 中 sleep(5000) 返回 Promise 中的 console.log('b')
宏任务
1. 1000ms setTimeout(resolve, duration) console.log('c')
2. 2000ms setTimeout console.log('d')
3. 3000ms setTimeout console.log('e')
4. 5000ms setTimeout(resolve, duration) console.log('f')
最终输出顺序为:a, b, b, c, d, e, f
分析异步执行的顺序:
- 有多少个宏任务
- 在每个宏任务中,有多少个微任务
- 根据调用次序,确定宏任务中的微任务执行次序
- 根据宏任务的触发规则和调用次序,确定宏任务的执行次序
- 确定整个顺序
函数执行
执行上下文与作用域
JavaScript 标准把一段代码(包括函数),执行所需的所有信息定义为执行上下文,任何变量都存在于某个执行上下文中(也称为作用域),这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分
var color = "blue";
function changeColor() {
let anotherColor = "red";
function swapColors() {
let tempColor = anotherColor;
anotherColor = color;
color = tempColor;
// 这里可以访问color、anotherColor和tempColor
}
// 这里可以访问color和anotherColor,但访问不到tempColor
swapColors();
}
// 这里只能访问color
changeColor();
以上代码分析如下:
- 涉及3个上下文,全局上下文、
changeColor()
的局部上下文和swapColors()
的局部上下文 - 全局上下文中有一个变量
color
和一个函数chageColor()
changeColor()
的局部上下文中有一个变量anotherColor
和一个函数swapColors()
,但在这里可以访问全局上下文中的变量color
swapColors()
的局部上下文中有一个变量tempColor
,只能在这个上下文中访问到。- 全局上下文和
changeColor()
的局部上下文都无法访问到tempColor
- 而在
swapColors()
中则可以访问另外两个上下文中的变量,因为它们都是父上下文
总结几点:
- 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数
- 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量
- 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据
几种函数
- 普通函数
function foo(){
// code
}
- 箭头函数
const foo = () => {
// code
}
- 用class定义的类也是函数
class Foo {
constructor(){
//code
}
}
- 方法,例如在class中定义的函数
class C {
foo(){
//code
}
}
- 生成器函数
function* foo(){
// code
}
- 异步函数
async function foo(){
// code
}
const foo = async () => {
// code
}
async function foo*(){
// code
}
this
JavaScript 中的一个关键字,当前执行上下文(global、function 或 eval)的一个属性,在非严格模式下,总是指向一个对象,在严格模式下可以是任意值
行为
同一个函数调用方式不同,得到的this值也会不同,看几个例子:
- 使用普通函数
function showThis(){
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // global
o.showThis(); // o
调用函数时使用的引用,决定了函数执行时刻的
this
值
- 使用箭头函数
const showThis = () => {
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // global
o.showThis(); // global
不论用什么引用来调用它,都不影响它的
this
值,因为箭头函数体内的this
对象,就是定义时所在的对象,而不是调用时所在的对象
- 使用方法
class C {
showThis() {
console.log(this);
}
}
var o = new C();
var showThis = o.showThis;
showThis(); // undefined
o.showThis(); // o
class
内部会默认按照严格模式执行
- 非严格模式和严格模式对比
function fun() { return this; }
console.log(fun()); // window
console.log(fun.call(2)); // Number
console.log(fun.apply(null)); // window
console.log(fun.call(undefined)); // window
console.log(fun.bind(true)()); // Boolean
"use strict";
function fun() { return this; }
console.log(fun()); // undefined
console.log(fun.call(2)); // 2
console.log(fun.apply(null)); // null
console.log(fun.call(undefined)); // undefined
console.log(fun.bind(true)()); // true
**在严格模式下,指定的
this
不再被封装为对象,而且如果没有指定this
的话它值是undefined
**
绑定模式
new的绑定与实现
new
都做了哪些事情:
- 创建新的空对象,指定原型
- 执行构造函数,并且绑定
this
- 判断构造函数是否返回对象,有就返回此对象
- 构造函数无返回值返回创建的新对象
function _new() {
const [constructor, ...args] = [...arguments]
// 创建一个空对象,指定原型为constructor.prototype
const obj = Object.create(constructor.prototype)
// 执行构造函数,绑定this
const result = constructor.apply(obj, args)
// 如果构造函数返回一个对象,那么返回该对象
if (result && (typeof result === 'object' || typeof result === 'function'))
return result
// 如果没有就返回新对象
return obj
}
function Person(name, age) {
this.name = name
this.age = age
}
_new(Person, 'mxin', 18)
// Person {name: "mxin", age: "18"}
// age: "18"
// name: "mxin"
// __proto__:
// constructor: ƒ Person(name, age)
// __proto__: Object
const mxin = _new(Person, 'mxin', 18)
console.log(mxin.name, mxin.age)
// mxin,18
显式绑定
使用 call
、apply
、bind
三种绑定 this
的方式为显式绑定
call
、apply
两个方法参数不同,效果相同,且都会执行传入的函数bind
不会执行函数
隐式绑定
函数的调用是在某个对象上触发的,即调用位置上存在上下文对象或被某个对象包含
const getName = function(){
console.log(`Hello, ${this.name}`);
}
const person = {
name: 'mxin',
getName: getName
}
person.getName();
默认绑定
在没有以上几种绑定模式下,此种为默认绑定模式,非严格模式下,浏览器中 this
默认指向 window
,严格模式下默认为 undefined
var a = 2;
function foo(){
console.log(this.a);
}
foo(); //2
函数与new
new 仅仅能与普通函数及类搭配使用
函数类型 | new |
---|---|
普通函数 | 新对象 |
箭头函数 | 报错 |
方法 | 报错 |
生成器 | 报错 |
类 | 新对象 |
异步普通函数 | 报错 |
异步箭头函数 | 报错 |
生成器函数 | 报错 |
语句执行
Completion 类型
根据 try
catch
finally
语句执行顺序可以看到一种现象,try
中有返回值的情况下依然会在 finally
执行完毕后才返回,测试一下
function foo(){
try{
return 0
} catch(err) {
} finally {
console.log("a")
}
}
console.log(foo());
// a
// 0
try
和 finally
都有返回值的情况,会先返回 try
中的值,然后被 finally
中的返回值覆盖,也就是执行了两次 return
操作
function foo(){
try{
return 0
} catch(err) {
} finally {
return 1
}
}
console.log(foo());
这一机制的基础正是 JavaScript 语句执行的完成状态,我们用一个标准类型来表示:Completion Record,它表示一个语句执行完之后的结果,有三个字段:
- [[type]] 表示完成的类型,有
break
continue
return
throw
和normal
几种类型 - [[value]] 表示语句的返回值,如果语句没有,则是 empty
- [[target]] 表示语句的目标,通常是一个 JavaScript 标签(标签在后文会有介绍
JavaScript 依靠语句的 Completion Record 类型,在语句的复杂嵌套结构中,实现了各种控制
语句大概分为以下几种:
普通语句
在 JavaScript 中,我们把不带控制能力的语句称为普通语句
- 忽略 var 和函数声明的预处理机制,普通语句在执行时,从前到后顺次执行,没有任何分支或者重复执行逻辑
- 普通语句执行后,会得到 [[type]] 为 normal 的 Completion Record,JavaScript 引擎遇到这样的 Completion Record,会继续执行下一条语句
- 只有表达式语句会产生 [[value]],从引擎控制的角度,这个 value 并没有什么用处
Chrome 控制台显示的正是语句的 Completion Record 的 [[value]]
语句块
- 语句块就是拿大括号括起来的一组语句,它是一种语句的复合结构,可以嵌套
- 语句块内部的语句的 Completion Record 的 [[type]] 如果不为 normal,会打断语句块后续的语句执行
先看一个普通语句块,对应了给出了每行的 Completion Record,在这个 block 中,每一个语句都是 normal 类型,那么它会顺次执行
{
var i = 1; // normal, empty, empty
i ++; // normal, 1, empty
console.log(i) //normal, undefined, empty
} // normal, undefined, empty
加入return
{
var i = 1; // normal, empty, empty
return i; // return, 1, empty
i ++;
console.log(i)
} // return, 1, empty
在 block 中插入了一条 return
语句,产生了一个非 normal 记录,整个 block 会成为非 normal;这个结构就保证了非 normal 的完成类型可以穿透复杂的语句嵌套结构,产生控制效果
控制型语句
控制型语句带有 if
、switch
关键字,它们会对不同类型的 Completion Record 产生反应
- 控制类语句分成两部分,一类是对其内部造成影响,如
if
、switch
、while/for
、try/catch/finally
等 - 另一类是对外部造成影响如
break
、continue
、return
、throw
,这两类语句的配合,会产生控制代码执行顺序和执行逻辑的效果
先来看一下控制语句跟 break
、continue
、return
、throw
四种类型与控制语句两两组合产生的效果
break | continue | return | throw | |
---|---|---|---|---|
if | 穿透 | 穿透 | 穿透 | 穿透 |
switch | 有效执行 | 穿透 | 穿透 | 穿透 |
for/while | 有效执行 | 有效执行 | 穿透 | 穿透 |
function | 报错 | 报错 | 有效执行 | 穿透 |
try | 特殊处理 | 特殊处理 | 特殊处理 | 有效执行 |
catch | 特殊处理 | 特殊处理 | 特殊处理 | 穿透 |
finally | 特殊处理 | 特殊处理 | 特殊处理 | 穿透 |
回来看之前例子中的 try
和 return
的组合,根据语句的特点去分析:
finally
中的内容必须保证执行,try/catch
执行完毕,得到的结果是非 normal 型的完成记录,也必须要执行finally
finally
执行也得到了非 normal 记录,使finally
中的记录作为整个try
结构的结果
function foo(){
try{
return 0
} catch(err) {
} finally {
return 1
}
}
console.log(foo());
带标签的语句
语句是可以加标签的,在语句前加冒号即可
firstStatement: var i = 1;
实用场景:与完成记录类型中的 target 相配合,用于跳出多层循环
for(let i=0; i<3; i++){
for(let j=0; j<10; j++){
console.log(i)
}
}
// 10次 0
// 10次 1
// 10次 2
outer: for (let i = 0; i < 3; i++) {
inner: for (let j = 0; j < 10; j++) {
console.log(i)
if (i === 1) break outer
}
}
// 10次 0
// 1
break
、continue
语句如果后跟了关键字,会产生带 target 的完成记录;一旦完成记录带了 target,那么只有拥有对应 label 的循环语句会有效的执行它