JavaScript 编译与执行
代码,是前端工程师的“武器”,也是他们的“面包和黄油”。
一、编译与执行流程
Js运动过程:语法分析、预编译、解释执行。
1. 线程和进程
单线程语言:JavaScript是单线程语言,即在浏览器中一个页面只有一个线程在执行js代码。
进程和线程:
- 进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位)
- 线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)
我们先来了解一下以下几个线程:
GUI渲染线程
负责渲染浏览器界面,包括解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
注意 : GUI渲染线程与JS引擎线程是互斥的。
JS 引擎线程
也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)JS引擎线程负责解析Javascript脚本,运行代码。
事件触发线程
归属于浏览器而不是JS引擎,用来控制事件循环。当JS引擎执行代码块如setTimeOut或是来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等会将对应任务添加到事件线程中,当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列(回调队列)的队尾,等待JS引擎的处理。
定时触发器线程
setInterval与setTimeout所在线程,浏览器定时计数器并不是由JS引擎计数的,因为JS引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确,因此通过单独线程来计时,当计时完毕后将其回调函数添加到回调队列中,等待JS引擎空闲后从回调队列队首取出函数执行。
异步http请求线程
通过XMLHttpRequest连接后,通过浏览器新开一个线程请求,将检测到readyState状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再由JS引擎执行。
注:永远只有JS引擎线程在执行JS脚本程序,以上提及的三个线程只负责将满足触发条件的处理函数推进回调队列中,等待JS引擎线程执行。
2. 引擎执行过程
- 解析器:负责将js代码转换为AST抽象语法树
- 解释器:负责将AST抽转换为字节码,并收集编译器需要的优化编译信息
- 编译器:利用解释器收集到的信息,将字节码转换为优化的机器码
3. 作用域
ES6以后,if,while,switch,for语句都可以形成自己的块级作用域(使用非var来声明变量)。
- 全局作用域:
- 局部作用域:局部作用域分为函数作用域和块作用域。
- 函数作用域:在函数内部声明的变量只能在函数内部被访问,外部无法直接访问。
- 块作用域:在 JavaScript 中使用 {} 包裹的代码称为代码块,代码块内部声明的变量外部将无法被访问。
二、语法分析
浏览器首先按顺序加载由标签分割的代码块,加载代码块完毕后,立刻进入以上三个阶段。然后再按顺序查找下一个代码块,再继续执行以上三个阶段,无论是外部脚本文件(不异步加载 即会停止加载后面的内容,停下来解析脚本并对页面进行渲染)还是内部脚本代码块,都是一样的原理,并且都在同一个全局作用域中。
在js代码加载完毕以后,就会进入到语法分析阶段,在这个阶段中js将会检查代码块的语法是否正确。
- 若有错误语法则直接抛出错误,并停止接下来的执行,而后继续向后查找并加载下一个代码块。
- 若语法都没有问题,那么就进入接下来的预编译阶段。
三、预编译阶段
预编译分为全局(GO)预编译和局部(AO)预编译,全局预编译发生在页面加载完成时执行,而局部预编译发生在函数执行的前一刻。
预编译只管变量、形参、函数等,并不会处理return、if等代码逻辑判断。
全局预编译(GO)的3个步骤(页面加载完成时执行)
1.创建GO(Global Object)/window 对象(全局作用域)。
2.寻找变量声明作为GO的属性名,并赋值为undefined。从上到下查找,遇到var声明,先去全局作用域查找是否有同名变量,如有忽略当前声明,没有则添加声明变量为GO对象的属性,值为undefined,并为变量分配内存。
3.寻找函数声明,放入作为GO的属性,并赋值为其函数体。遇到function,如有同名变量,则将值替换为function函数,没有则添加到GO,并分配内存并赋值。
由于全局中没有参数的的概念,所以省去了实参形参相统一这一步。
局部预编译(AO)的4个步骤(函数执行前一刻)
- 创建AO(Activation Object)对象(函数作用域,执行上下文);
- 找函数形参和函数内变量声明,将形参名和变量名作为AO对象的属性名,值为undefined,并分配内存;
- 将实参值和形参统一,实参值赋给形参;
- 在函数体里面找函数声明,值赋予函数体。
注意:
- 函数提升只会提升函数声明,而不会提升函数表达式
- 函数声明:function fun(){}
- 函数表达式:var fun1=function(){}
1. 执行上下文
当函数执行时(执行前一刻预编译环节),会创建一个称为执行期上下文的内部对象。一个执行期上下文定义了一个函数执行时的环境,函数每次被执行时对应的执行期上下文都是独一无二的,所以多次调用一个函数会导致创建多个执行上下文,当函数执行完毕,它所产生的执行上下文即被销毁。
我们在JS学习初期,或者在面试的时候常常会遇到变量提升相关的思考题。
比如先来一个简单一点的。
console.log(a); // 这里会打印出什么?
var a = 20;
暂时先不管这个例子,我们先引入一个JavaScript中最基础,但同时也是最重要的概念:执行上下文(Execution Context)
每次当控制器转到可执行代码的时候,就会进入一个执行上下文。执行上下文可以理解为当前代码的执行环境,它会形成一个作用域。JavaScript中的运行环境大概包括三种情况。
- 全局环境:JavaScript代码运行起来会首先进入该环境
- 函数环境:当函数被调用执行时,会进入当前函数中执行代码
- eval(不建议使用,可忽略)
因此在一个JavaScript程序中,必定会产生多个执行上下文,在我的上一篇文章中也有提到,JavaScript引擎会以栈的方式来处理它们,这个栈,我们称其为函数调用栈(call stack)。栈底永远都是全局上下文,而栈顶就是当前正在执行的上下文。
当代码在执行过程中,遇到以上三种情况,都会生成一个执行上下文,放入栈中,而处于栈顶的上下文执行完毕之后,就会自动出栈。为了更加清晰的理解这个过程,根据下面的例子,结合图示给大家展示。
执行上下文可以理解为函数执行的环境,每一个函数执行时,都会给对应的函数创建这样一个执行环境。
var color = 'blue';
function changeColor() {
var anotherColor = 'red';
function swapColors() {
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;
}
swapColors();
}
changeColor();
我们用ECStack来表示处理执行上下文组的堆栈。我们很容易知道,第一步,首先是全局上下文入栈。
全局上下文入栈之后,其中的可执行代码开始执行,直到遇到了changeColor()
,这一句激活函数changeColor
创建它自己的执行上下文,因此第二步就是changeColor的执行上下文入栈。
changeColor的上下文入栈之后,控制器开始执行其中的可执行代码,遇到swapColors()
之后又激活了一个执行上下文。因此第三步是swapColors的执行上下文入栈。
在swapColors的可执行代码中,再没有遇到其他能生成执行上下文的情况,因此这段代码顺利执行完毕,swapColors的上下文从栈中弹出。
swapColors的执行上下文弹出之后,继续执行changeColor的可执行代码,也没有再遇到其他执行上下文,顺利执行完毕之后弹出。这样,ECStack中就只剩下全局上下文了。
全局上下文在浏览器窗口关闭后出栈。
注意:函数中,遇到 return 能直接终止可执行代码的执行,因此会直接将当前上下文弹出栈。
详细了解了这个过程之后,我们就可以对执行上下文总结一些结论了。
- 单线程
- 同步执行,只有栈顶的上下文处于执行中,其他上下文需要等待
- 全局上下文只有唯一的一个,它在浏览器关闭时出栈
- 函数的执行上下文的个数没有限制
- 每次某个函数被调用,就会有个新的执行上下文为其创建,即使是调用的自身函数,也是如此。
为了巩固一下执行上下文的理解,我们再来绘制一个例子的演变过程,这是一个简单的闭包例子。
function f1() {
var n = 999;
function f2() {
alert(n);
}
return f2;
}
var result = f1();
result(); // 999
因为f1中的函数f2在f1的可执行代码中,并没有被调用执行,因此执行f1时,f2不会创建新的上下文,而直到result执行时,才创建了一个新的。具体演变过程如下:
最后留一个简单的例子,大家可以自己脑补一下这个例子在执行过程中执行上下文的变化情况。
var name = "window";
var p = {
name: 'Perter',
getName: function () {
// 利用变量保存的方式保证其访问的是p对象
var self = this;
return function () {
return self.name;
}
}
}
var getName = p.getName();
var _name = getName();
console.log(_name);
2. 函数调用栈
每进入一个不同的运行环境都会创建一个相应的执行上下文(Execution Context),在一段JS程序中我们会创建很多的执行上下文,而这些执行上下文在JS引擎中会以栈的方式 执行处理。
而这时候形成的栈我们叫它 函数调用栈(Call Stack) ,其中调用栈的 栈底 永远是我们的 全局执行上下文(Global Execution Context) , 栈顶 则永远是 当前的执行上下文 。
我们从一个简单的例子出发:
function bar() {
var B_context = "Bar EC";
function foo() {
var f_context = "foo EC";
}
foo()
}
bar()
- 首先我们进入全局环境,创建全局执行上下文,这时候推入函数调用栈中
- 当我们调用bar时,进入bar的运行环境,创建bar的执行上下文推入函数调用栈中
- 在运行bar的时候,内部调用foo函数,因此我们再进入foo的运行环境,创建foo的函数执行上下文推入函数调用栈栈中
- 此刻栈底是全局执行上下文,栈顶是foo函数执行上下文,由于foo函数内部没有再调用其他函数,因此无需再创建多余的函数执行上下文
- foo函数执行完毕后,栈顶foo函数执行上下文首先出栈
- 接着bar函数接下来也没有别的语句需要执行,因此也是执行完毕的状态,所以bar函数执行上下文出栈
- 最后全局执行上下文则是在浏览器或者该标签页关闭的时候出栈
3. 创建执行上下文
创建流程:
- 创建变量对象(Variable Object)
- 建立作用域链(Scope Chain)
- 确定this指针
创建阶段:(函数被调用,但是还未执行函数中的代码)
- 创建arguments,变量,函数
- 建立作用域链
- 确定this的值
执行阶段:变量赋值,函数引用,执行代码
执行上下文为一个对象,包含VO,作用域链和this
具体过程:
1.找到当前上下文调用函数的代码;
2.执行代码之前,先创建执行上下文;
3. 创建阶段:
3-1. 创建变量对象(VO):
- 创建arguments,检查当前上下文的参数,建立该对象下的属性和属性值
- 扫描上下文的函数申明:
- 每扫描到一个函数就会在VO里面用函数名创建一个属性,为一个指针,指向该函数在内存中的地址;
- 如果函数名在VO中已经存在,对应的属性值会被新的引用覆盖。
- 扫描上下文的变量申明:
- 每扫描到一个变量就会用变量名作为属性名,其值初始化为 undefined;
- 如果该变量名在VO中已经存在,则直接跳过继续扫描。
3-2. 初始化作用域链
3-3. 确定上下文中this的指向
4. 代码执行阶段
4-1. 执行函数体中的代码,给VO中的变量赋值
变量解析流程:当前环境的活动对象查找 -> prototype 原型链查找 -> 作用域链查找
function foo(i) {
var a = 'hello';
var b = function privateB() {};
function c() {}
}
foo(22);
注意:函数申明先于变量申明
0-创建阶段:预编译
- 创建顺序:执行上下文 => 变量对象 => 作用域链 => this 值
- 变量对象创建顺序:arguments 对象申明 => 形参申明 => 函数申明 => 变量申明
fooExecutionContext = {
variableObject: { // 0-变量对象
/* 函数中的arguments对象, 参数, 内部的变量以及函数声明 */
arguments: {
0: undefined,
length: 1
},
i: undefined, // 形参--
c: pointer to function c(), // 函数申明
a: undefined, // 变量申明
b: undefined // 变量申明
},
scopeChain: { ... }, // 1-作用域链
/* variableObject 以及所有父执行上下文中的variableObject */
this: { ... }
}
1-代码执行阶段:解释执行
赋值 + 执行
fooExecutionContext = {
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c(),
a: 'hello',
b: pointer to function privateB()
},
scopeChain: { ... },
this: { ... }
}
创建变量对象
示例:
function fun(a, b) {
var num = 1;
function test() {
console.log(num)
}
}
fun(2, 3)
当执行fun函数并传入参数2和3时:(暂时不讲解作用域链以及this指向)
// 0-fun的执行上下文
funEC = {
// 1-变量对象
VO: {
// arguments对象
arguments: {
a: undefined,
b: undefined,
length: 2
},
// test函数,在堆内存中地址的引用
test: <test reference>,
// num变量
num: undefined
},
// 2-作用域链
scopeChain:[],
// 3-this指向
this: window
}
解析说明:
创建变量对象是发生在“预编译”阶段,还并没有进入执行阶段,因此这里的变量对象是不可访问的,此时值还是undefined。
当我们进入执行阶段开始对其变量属性赋值,变量对象转变为活动对象,此时才可以进行访问,而这个过程就是VO->AO的过程,其中AO就是Active Object活动对象。
注:函数声明提前和变量声明提升是在创建变量对象中进行的,且函数声明优先级高于变量声明。
通过例子来说明:
var a = 1;
function b() {
a = 10;
return;
}
b();
console.log(a); // 10
同名变量和函数申明
var a = 1;
function b() {
a = 10;
return;
function a() {}
}
b();
console.log(a); // 1
解析说明:
输出: 1,在函数b中优先声明函数a,此后给a赋值为10,这里的a是函数b内的声明变量a,和全局作用域下的a不是同一个变量,因此最后输出a不变。
同名函数申明
function foo(){
function bar() {
return 3;
}
return bar();
function bar() {
return 8;
}
}
alert(foo()); // 8
解析说明:
输出: 8,执行foo函数,先声明返回3的bar函数,再覆盖了一个返回8的bar函数,执行时即输出被覆盖后的值。
同名变量申明
alert(foo());
function foo() {
var bar = function() {
return 3;
};
return bar();
var bar = function() {
return 8;
};
}
解析说明:
输出: 3,优先声明函数foo,因此可以alert出foo函数,此时再foo函数内,先声明变量bar,其次,给bar赋值返回值为3的匿名函数,此时执行return语句直接执行输出3,在return后的赋值语句不会再执行。
注:不同的运行环境执行都会进入代码预编译和执行两个阶段,而语法分析则在代码块加载完毕时统一检验语法。
建立作用域链
当一个函数被创建时,它的[[Scope]]属性就被初始化为当前执行上下文的作用域链。当函数执行时,会创建一个新的执行上下文,并且把该函数的[[Scope]]属性赋值给新执行上下文的作用域链。这就是函数作用域链的创建过程。
函数的作用域在定义函数的时候就决定了。这是因为函数有一个内部属性[[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解[[scope]]就是所有父变量对象的层级连,但是注意[[scope]]并不代表完整的作用域链!
每个JS函数都是一个对象,对象中有些属性我们可以访问,但有些不可以,这些属性仅供JS引擎存取,[[scope]]就是其中一个。[[scope]]指的就是我们所说的作用域,其中储存了运行期上下文的几何。
- 作用域定义:作用域是一套规则(词法环境–根据ECMAScript代码的词法嵌套结构来定义标识符与特定变量和函数的关联)。
- 作用域链定义:作用域链是由当前执行环境的变量对象(也就是未进入执行阶段前)与上层环境的一系列活动对象(AO)组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。([[scope]]中所存储的执行期上下文对象的集合,这个集合呈链式连接,我们把这种链式连接叫做作用域链。)
JS引擎在解释过程中,是严格按照作用域机制来执行的, JS的变量及函数在定义时就已经决定了它们的作用域范围,所以解释JS只要通过静态分析就可以确认每个变量,函数的作用域,因此这些作用域也叫作 静态作用域。
那么举个具体的例子:
var num = 30;
function test() {
var a = 10;
function innerTest() {
var b = 20;
return a + b
}
innerTest()
}
test()
此时:
innerTestEC = {
VO: { b: undefined }, // 变量对象
scopeChain: [VO(innerTest), AO(test), AO(global)],
// 作用域链:注意顺序,链头是当前作用域,链尾一定是全局作用域
this: window // this指向
}
作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样,就能访问到上一层作用域中的变量。
查找变量顺序:从作用域的顶端依次向下查找。
举个具体的例子:
function a() {
function b() {
var b = 234;
}
b();
}
var glob = 100;
a();
函数a被定义时,发生如下过程(继承全局的环境)
函数a被执行时,发生如下过程(生成自己的AO)
当函数a执行完毕后,产生的AO被销毁,可理解为箭头被砍断了。
函数b被创建时,发生如下过程(站在函数a的肩膀上,继承a的环境,与aAO是同一个AO,只是引用方式不同)。
b的作用域链中的AO与aAO是同一个AO,只是引用方式不同
function a() {
function b() {
var bb = 234;
aa = 0;
}
var aa = 123;
b();
console.log(aa);
}
var glob = 100;
a();
以上代码执行后输出的aa值为0,说明了b的作用域链中的AO与aAO是同一个AO,只是引用方式不同。
函数b被执行时,发生如下过程(生成自己的AO)
b执行完毕后砍断了自己生成的AO,原来a生成的aAO,不会销毁,只有a执行完毕才能销毁。
闭包
function a() {
function b() {
var bbb = 234;
document.write(aaa);
}
var aaa = 123;
return b;
}
var glob = 100;
var demo = a();
demo();
如图所示,闭包的产生过程即是函数内部函数被抛出到全局中。我们称当内部函数被保存到外部时,就生成了闭包。闭包形成后将会导致原有的作用域链不释放,造成内存泄漏。
结合图示理解即为a执行完毕后砍断了自己与AO的联系,但是此时b函数已经到了全局中,a砍断自己与AO的联系不会影响到b与AO的联系。而考虑当b执行完毕后,b只会砍掉自己生成的AO联系,那么a生成的AO就被永久的保存到了b的作用域中。
闭包的作用
- 实现公有变量
- 可以用作储存结构
- 可以实现封装,属性私有化
- 模块化开发,防止污染全局变量
立即执行函数
对于某些特殊的初始化功能的函数,我们希望在它被创建时就执行,而不是等到被调用时才执行,此时立即执行函数可以派上用场。
立即执行函数的格式
(function () {} ());//W3C建议使用
(function () {} )();
立即执行函数的原理
理解立即执行函数只需记住:只有表达式才能被执行符号()执行。最外层的括号相当于将内部的函数转变为一个表达式,接下来即可被执行符号执行(最后一个括号)。我们使用-、+都可以达到同样的效果:+ function () {} ()。
立即执行函数的应用举例
首先看如下代码,需求是实现使用闭包向页面打印0-9这10个数字。但是执行结果向页面最终输出的却是十个10.
function test () {
var arr = [];
for (var i = 0; i < 10; i ++) {
arr[i] = funtion () {
document.write(i + " ");
}
}
return arr;
}
var myArr = test();
造成这种结果的原因是:arr对应的内部函数被储存到arr数组中时,函数内容并没有被调用执行,因此打印语句中的i还是i而不是对应的i的值。当arr被抛出到全局中时,调用arr函数时数组中每个函数形成的闭包对应的GO中的i都已经变成了10,因此最后向页面输出的全是10.
使用立即执行函数进行优化:
function test () {
var arr = [];
for (var i = 0; i < 10; i ++) {
(function (j) {
arr[j] = function () {
document.write(j + " ");
}
}(i));
}
return arr;
}
var myArr = test();
for (var j = 0; j < 10; j ++) {
myArr[j]();
}
经过立即执行函数,每一个函数在被保存到数组arr中的同时,for循环的函数语句要被执行,函数执行完毕那么将切断作用域中AO的联系,那么在全局中调用这个数组中的函数时,内部函数访问的就是对应的j的值了,也就是每个函数都对应的是自己的AO,而不是十对一。
确定this的指向
在全局执行上下文中,this是始终指向全局对象的。而在执行函数的过程中,就需要考虑函数的调用方式,而函数共有4中调用方式:
- 当它被作为对象的方法调用时,函数的指向为该对象;
- 当它直接作为函数被调用时,this通常指向的就是全局对象;
- 当它被以构造函数的方式调用时,会将新建的对象作为该函数的this值;
- 当它被间接调用也就是被call/bind/apply这样的函数绑定时,this值就是他们要绑定的
那个对象。
let/const
ES6中,新增了使用let/const来声明变量。我想他们的使用肯定难不倒大家。可是有一个问题不知道大家思考过没有,let/const声明的变量,是否还会变量提升?
是的,这个刁钻的问题也成为了各大面试官爱问的细节。很贱!可也没办法,还是要弄明白怎么回事!
我们来做个试验,验证一下这个问题:
第一步,我们直接使用一个未定义的变量
console.log(a);
//Uncaught ReferenceError: a is not defined at test.html:9
第二步,我们在let之前调用变量
console.log(a);
let a = 10;
//Uncaught ReferenceError: Cannot access 'a' before
initialization at test.html:9
不能在初始化之前访问a。
这个报错说明了什么问题呢?变量定义了,但是没有初始化。
所以在这里我们就可以得出结论:let/const声明的变量,仍然会提前被收集到变量对象中,但和var不同的是,let/const定义的变量,不会在这个时候给他赋值undefined。
因为完全没有赋值,即使变量提升了,我们也不能在赋值之前调用他。这就是我们常说的暂时性死区。
4. 总结
js执行过程:词法分析 -> 预编译 -> 执行
预编译:js引擎会首先把整个文件进行预处理,以消除一些歧义,这个预处理的过程就叫做预编译
全局预编译:
- 产生windows对象
- 查找变量的声明,把变量做为GO对象的属性名,属性值为undefined
- 查找函数的声明,把函数名作为GO对象的属性名,属性值是function
函数预编译:
- 在函数被调用时,为当前函数产生AO对象
- 找到形参和变量声明,并且将它们赋值为undefined;
- 找到形参对应的实参,并且将实参的值赋予形参;
- 找函数声明并且将函数体赋给函数声明;
⚠️:优先级:局部函数 > 实参 > 形参和局部变量
立即执行函数会预编译吗?
预编译过程无视 if( ) { } 之类的判断语句,因为此时根本就还没执行函数,若if语句中有变量声明/函数声明是完全没问题的,是起作用的。
用var定义的变量和function定义的方法在预编译的过程区别
console.log(mm); // function mm(){}
function mm(){}
console.log(mm); // undefined
var mm = 123;
console.log(nn); // undefined
console.log(aa); // undefined
if (true) {
function mm() {}
var aa = 1;
} else {
function nn() {}
var aa = 2;
}
同名变量和函数申明
函数提升的优先级高于基本类型数据提升,因此函数提升会覆盖掉所有同名的基本类型数据提升。
console.log(func); // func(){console.log("hello!");}
var func = "this is a variable";
function func() {
console.log("hello!");
}
console.log(func); // this is a variable
console.log(func()); // 报错
console.log(a); //undefined
a(); // 出错
var a = function () {
console.log("aa");
};
a(); // aa
console.log(tt); // function tt(){console.log('tt');}
tt(); // tt
function tt(){console.log('tt');}
四、执行阶段
浏览器的事件循环分为同步任务和异步任务;所有同步任务都在主线程上执行,形成一个函数调用栈(执行栈),而异步则先放到任务队列(task queue)里,任务队列又分为宏任务(macro-task)与微任务(micro-task)。
1. 同步任务(synchronous)
又叫做非耗时任务,指的是在主线程上排队执行的那些任务,只有前一个任务执行完毕,才能执行后一个任务;
2. 异步任务(asynchronous)
又叫做耗时任务,异步任务由JavaScript 委托给宿主环境进行执行,当异步任务执行完成后,会通知JavaScript 主线程执行异步任务的回调函数。
异步任务又分为:宏任务和微任务(优先级:微>宏)
宏任务
(macro)task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
// 宏任务((macro)task)包括
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境,优先级高于 setTimeout )
微任务
microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
// 微任务(microtask)包括
Promise.then catch finally
Object.observe
MutationObserver
process.nextTick(Node.js 环境,优先级高于promise 的异步方法)
3. 事件循环
是一个在 JavaScript 引擎等待任务、执行任务、进入休眠状态、等待更多任务这几个状态之间转换的无限循环。事件循环机制可以完成异步操作。
事件循环的执行机制涉及到的概念:
- JS引擎线程、定时器线程、事件触发线程、异步http请求线程
- 调用栈 Call Stack
- 任务队列 task queue
- 宏任务队列 macrotask
- 微任务队列 microtask
- 回调队列 Callback Queue
JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。
任务队列
一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task )的集合,分别有宏任务队列和微任务队列每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。
只要异步任务有了运行结果,就在任务队列之中放置一个事件(一个callback)。
运行机制
先从script(整块代码)开始第一次循环执行,接着对同步任务进行执行,直到调用栈被清空,然后去执行所有的微任务,当所有微任务执行完毕之后。再次从宏任务开始循环执行,直到执行完毕,然后再执行所有的微任务,就这样一直循环下去。如果在执行微队列任务的过程中,又产生了微任务,那么会加入整个队列的队尾,也会在当前的周期中执行。
任务执行流程:
详细步骤:
- 代码开始执行,创建一个全局调用栈,从script(整块代码)开始第一次循环执行。
- 执行过程中同步任务立即执行,异步任务根据异步任务类型分别注册到微任务队列和宏任务队列。
- 同步任务执行完毕,调用栈被清空,然后查看微任务队列。
① 若存在微任务,将微任务队列全部执行(包括执行微任务过程中产生的新微任务)。
② 若无微任务,查看宏任务队列,执行第一个宏任务,宏任务执行完毕,查看微任务队列,重复上述操作,直至宏任务队列为空。
注意:当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染,渲染完毕后,JS线程继续接管,开始新的宏任务,反复如此直到所有任务执行完毕。
Event Loop只做一件事情,那就是负责监听Call Stack和Callback Queue。当Call Stack里面的内容运行完变成空了, Event Loop就把Callback Queue里面的第一条事件(回调函数)放到调用栈中并执行它,后续不断循环执行这个操作。
4. 异步函数的执行
Promise和async中的立即执行,写在Promise中的代码是被当做同步任务立即执行的。而在async/await中,在出现await出现之前,其中的代码也是立即执行的。
实际上await是一个让出线程的标志。await后面的表达式会先执行一遍,将await后面的代码加入到microtask中,然后就会跳出整个async函数来执行后面的代码。由于因为async await 本身就是promise+generator的语法糖。所以await后面的代码是microtask。
Promise 是异步的,是指他的then() 和 catch() 方法,Promise 本身还是同步的(new Promise())
示例:
const p = new Promise(resolve => {
console.log('a')
resolve()
console.log('b')
})
p.then(() => {
console.log('c')
})
console.log('d')
// a b d c
执行分析:
- 整段script进入宏任务队列开始执行;
- promise 创建立即执行,打印 a b;
- 遇到 promise.then 进入微任务队列;
- 遇到 console.log(‘d’) 打印 d;
- 整段代码作为宏任务执行完毕,有可执行的微任务,开始执行微任务,打印 c 。
console.log('a');
new Promise(resolve => {
console.log('b')
resolve()
}).then(() => {
console.log('c')
setTimeout(() => {
console.log('d')
}, 0)
})
setTimeout(() => {
console.log('e')
new Promise(resolve => {
console.log('f')
resolve()
}).then(() => {
console.log('g')
})
}, 100)
setTimeout(() => {
console.log('h')
new Promise(resolve => {
resolve()
}).then(() => {
console.log('i')
})
console.log('j')
}, 0)
// a b c h j i d e f g
执行分析:
- 打印 a
- promise 立即执行,打印 b
- promise.then 推入微任务队列
- setTimeout 推入宏任务队列
- 整段代码执行完毕,开始执行微任务,打印 c ,遇到 setTimeout 推入宏任务队列排队等待执行
- 没有可执行的微任务开始执行宏任务,定时器按照延迟时间排队执行
- 打印 h j ,promise.then 推入微任务队列
- 有可执行的微任务,打印 i ,继续执行宏任务,打印 d
- 执行延迟为100的宏任务,打印 e f,执行微任务打印 g,所有任务执行完毕
案例分析一
setTimeout(function() {
console.log('timeout1');
})
new Promise(function(resolve) {
console.log('promise1');
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log('promise2');
}).then(function() {
console.log('then1');
})
console.log('global1');
首先,事件循环从宏任务队列开始,这个时候,宏任务队列中,只有一个script(整体代码)任务。每一个任务的执行顺序,都依靠函数调用栈来搞定,而当遇到任务源时,则会先分发任务到对应的队列中去,所以,上面例子的第一步执行如下图所示。
第二步:script任务执行时首先遇到了setTimeout,setTimeout为一个宏任务源,那么他的作用就是将任务分发到它对应的队列中。
setTimeout(function() {
console.log('timeout1');
})
第三步:script执行时遇到Promise实例。Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,而后续的.then则会被分发到micro-task的Promise队列中去。
因此,构造函数执行时,里面的参数进入函数调用栈执行。for循环不会进入任何队列,因此代码会依次执行,所以这里的promise1和promise2会依次输出。
promise1入栈执行,这时promise1被最先输出
resolve在for循环中入栈执行
构造函数执行完毕的过程中,resolve执行完毕出栈,promise2输出,promise1页出栈,then执行时,Promise任务then1进入对应队列
script任务继续往下执行,最后只有一句输出了globa1,然后,全局任务就执行完毕了。
第四步:第一个宏任务script执行完毕之后,就开始执行所有的可执行的微任务。这个时候,微任务中,只有Promise队列中的一个任务then1,因此直接执行就行了,执行结果输出then1,当然,他的执行,也是进入函数调用栈中执行的。
第五步:当所有的micro-tast执行完毕之后,表示第一轮的循环就结束了。这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务macro-task开始。
这个时候,我们发现宏任务中,只有在setTimeout队列中还有一个timeout1的任务等待执行。因此就直接执行即可。
这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了。
// promise1 promise2 global1 then1 timeout1
这个例子比较简答,涉及到的队列任务并不多,因此读懂了它还不能全面的了解到事件循环机制的全貌。所以我下面弄了一个复杂一点的例子,再给大家解析一番,相信读懂之后,事件循环这个问题,再面试中再次被问到就难不倒大家了。
案例分析二
// demo02
console.log('glob1');
setTimeout(function() {
console.log('timeout1');
process.nextTick(function() {
console.log('timeout1_nextTick');
})
new Promise(function(resolve) {
console.log('timeout1_promise');
resolve();
}).then(function() {
console.log('timeout1_then')
})
})
setImmediate(function() {
console.log('immediate1');
process.nextTick(function() {
console.log('immediate1_nextTick');
})
new Promise(function(resolve) {
console.log('immediate1_promise');
resolve();
}).then(function() {
console.log('immediate1_then')
})
})
process.nextTick(function() {
console.log('glob1_nextTick');
})
new Promise(function(resolve) {
console.log('glob1_promise');
resolve();
}).then(function() {
console.log('glob1_then')
})
setTimeout(function() {
console.log('timeout2');
process.nextTick(function() {
console.log('timeout2_nextTick');
})
new Promise(function(resolve) {
console.log('timeout2_promise');
resolve();
}).then(function() {
console.log('timeout2_then')
})
})
process.nextTick(function() {
console.log('glob2_nextTick');
})
new Promise(function(resolve) {
console.log('glob2_promise');
resolve();
}).then(function() {
console.log('glob2_then')
})
setImmediate(function() {
console.log('immediate2');
process.nextTick(function() {
console.log('immediate2_nextTick');
})
new Promise(function(resolve) {
console.log('immediate2_promise');
resolve();
}).then(function() {
console.log('immediate2_then')
})
})
这个例子看上去有点复杂,乱七八糟的代码一大堆,不过不用担心,我们一步一步来分析一下。
第一步:宏任务script首先执行。全局入栈。glob1输出。
第二步,执行过程遇到setTimeout。setTimeout作为任务分发器,将任务分发到对应的宏任务队列中。
setTimeout(function() {
console.log('timeout1');
process.nextTick(function() {
console.log('timeout1_nextTick');
})
new Promise(function(resolve) {
console.log('timeout1_promise');
resolve();
}).then(function() {
console.log('timeout1_then')
})
})
第三步:执行过程遇到setImmediate。setImmediate也是一个宏任务分发器,将任务分发到对应的任务队列中。setImmediate的任务队列会在setTimeout队列的后面执行。
setImmediate(function() {
console.log('immediate1');
process.nextTick(function() {
console.log('immediate1_nextTick');
})
new Promise(function(resolve) {
console.log('immediate1_promise');
resolve();
}).then(function() {
console.log('immediate1_then')
})
})
第四步:执行遇到nextTick,process.nextTick是一个微任务分发器,它会将任务分发到对应的微任务队列中去。
process.nextTick(function() {
console.log('glob1_nextTick');
})
第五步:执行遇到Promise。Promise的then方法会将任务分发到对应的微任务队列中,但是它构造函数中的方法会直接执行。因此,glob1_promise会第二个输出。
new Promise(function(resolve) {
console.log('glob1_promise');
resolve();
}).then(function() {
console.log('glob1_then')
})
先是函数调用栈的变化
然后glob1_then任务进入队列
第六步:执行遇到第二个setTimeout。
setTimeout(function() {
console.log('timeout2');
process.nextTick(function() {
console.log('timeout2_nextTick');
})
new Promise(function(resolve) {
console.log('timeout2_promise');
resolve();
}).then(function() {
console.log('timeout2_then')
})
})
timeout2进入对应队列
第七步:先后遇到nextTick与Promise
process.nextTick(function() {
console.log('glob2_nextTick');
})
new Promise(function(resolve) {
console.log('glob2_promise');
resolve();
}).then(function() {
console.log('glob2_then')
})
glob2_nextTick与Promise任务分别进入各自的队列
第八步:再次遇到setImmediate。
setImmediate(function() {
console.log('immediate2');
process.nextTick(function() {
console.log('immediate2_nextTick');
})
new Promise(function(resolve) {
console.log('immediate2_promise');
resolve();
}).then(function() {
console.log('immediate2_then')
})
})
nextTick
这个时候,script中的代码就执行完毕了,执行过程中,遇到不同的任务分发器,就将任务分发到各自对应的队列中去。接下来,将会执行所有的微任务队列中的任务。
其中,nextTick队列会比Promie先执行。nextTick中的可执行任务执行完毕之后,才会开始执行Promise队列中的任务。
当所有可执行的微任务执行完毕之后,这一轮循环就表示结束了。下一轮循环继续从宏任务队列开始执行。
这个时候,script已经执行完毕,所以就从setTimeout队列开始执行。
第二轮循环初始状态
setTimeout任务的执行,也依然是借助函数调用栈来完成,并且遇到任务分发器的时候也会将任务分发到对应的队列中去。
只有当setTimeout中所有的任务执行完毕之后,才会再次开始执行微任务队列。并且清空所有的可执行微任务。
setTiemout队列产生的微任务执行完毕之后,循环则回过头来开始执行setImmediate队列。仍然是先将setImmediate队列中的任务执行完毕,再执行所产生的微任务。
当setImmediate队列执行产生的微任务全部执行之后,第二轮循环也就结束了。
大家需要注意这里的循环结束的时间节点。
当我们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了。例子中没有涉及到这么复杂的嵌套,大家可以动手添加或者修改他们的位置来感受一下循环的变化。
OK,到这里,事件循环我想我已经表述得很清楚了,能不能理解就看读者老爷们有没有耐心了。我估计很多人会理解不了循环结束的节点。
当然,这些顺序都是v8的一些实现。我们也可以根据上面的规则,来尝试实现一下事件循环的机制。
// 用数组模拟一个队列
var tasks = [];
// 模拟一个事件分发器
var addFn1 = function(task) {
tasks.push(task);
}
// 执行所有的任务
var flush = function() {
tasks.map(function(task) {
task();
})
}
// 最后利用setTimeout/或者其他你认为合适的方式丢入事件循环中
setTimeout(function() {
flush();
})
// 当然,也可以不用丢进事件循环,而是我们自己手动在适当的时机去执行对应的某一个方法
var dispatch = function(name) {
tasks.map(function(item) {
if(item.name == name) {
item.handler();
}
})
}
// 当然,我们把任务丢进去的时候,多保存一个name即可。
// 这时候,task的格式就如下
demoTask = {
name: 'demo',
handler: function() {}
}
// 于是,一个订阅-通知的设计模式就这样轻松的被实现了
这样,我们就模拟了一个任务队列。我们还可以定义另外一个队列,利用上面的各种方式来规定他们的优先级。
需要注意的是,这里的执行顺序,或者执行的优先级在不同的场景里由于实现的不同会导致不同的结果,包括node的不同版本,不同浏览器等都有不同的结果。
5. 函数执行
执行阶段:变量赋值,函数引用,执行代码。
执行函数体中的代码,给VO中的变量赋值
变量解析流程:当前环境的活动对象查找 -> prototype 原型链查找 -> 作用域链查找
function foo(i) {
var a = 'hello';
var b = function privateB() {};
function c() {}
}
foo(22);
注意:函数申明先于变量申明
五、测试
1. 执行上下文
foo的执行结果是什么?
var a = 20;
function foo() {
if (!a) {
a = 100;
}
var a = 10;
return a;
}
console.log(foo());
2. 事件循环机制
练习一:
setTimeout(() => {
console.log('1')
}, 0)
console.log('2');
new Promise((resolve) => {
console.log('3');
resolve()
}).then(() => {
console.log('4');
}).then(()=>{
console.log('5')
})
console.log('6')
//结果顺序是:2 3 6 4 5 1
练习二:
setTimeout(() => {
// #1
new Promise(resolve => {
resolve();
}).then(() => {
// #7
console.log('test');
});
// #8
console.log(4);
});
new Promise(resolve => {
resolve();
console.log(1)
}).then(() => {
// #2
console.log(3);
Promise.resolve().then(() => {
// #4
console.log('before timeout');
// return Promise.resolve(undefined); // 默认会返回这么一句话
}).then(() => {
// #5
Promise.resolve().then(() => {
// #6
console.log('also before timeout')
})
})
})
console.log(2);
// #3
/*
result: 2 3 before timeout also before timeout 4 test
同
3 √
8 √
微
2 √
4 √
5 √
6 √
7 √
宏
1 √
*/
练习三:
setTimeout(function() {
// #1
console.log(0);
});
new Promise((resolve, reject) => {
// #2
console.log(1);
resolve();
}).then(() => {
// #3
// 执行此微的时候又会产生两个微
console.log(2);
new Promise((resolve, reject) => {
// #6
console.log(3);
resolve();
}).then(() => {
// #7
console.log(4);
}).then(() => {
// #9
console.log(5);
});
}).then(() => {
// #8
console.log(6);
});
new Promise((resolve, reject) => {
// #4
console.log(7);
resolve();
}).then(() => {
// #5
console.log(8);
});
/* result:1 7 2 3 8 4 6 5 0
同
2 √
4 √
6 √
微
3 √
5 √
7 √
8 √
9 √
宏
1 √
*/
练习四:
function func(num) {
return function () {
console.log(num)
};
}
// #1
setTimeout(func(1));
async function async3() {
await async4();
// #3
console.log(8);
}
async function async4() {
// #2
console.log(5)
}
async3();
function func2() {
// #10
console.log(2);
async function async1() {
await async2();
// #12
console.log(9)
}
async function async2() {
// #11
console.log(5)
}
async1();
// #13
setTimeout(func(4))
}
// #4
setTimeout(func2);
// #5
setTimeout(func(3));
new Promise(resolve => {
// #6
console.log('Promise');
resolve()
})
.then(
// #7
() => console.log(6))
.then(
// #9
() => console.log(7));
// #8
console.log(0);
/* result:5 Promise 0 8 6 7 1 2 5 9 3 4
同
2 √
6 √
8 √
10 √
11 √
微
3 √
7 √
9 √
12 √
宏
1 √
4 √
5 √
13 √
*/
练习五:
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1()
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')
输出:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
练习六:
async function async1() {
console.log('async1 start')
await async2()
// 更改如下:
setTimeout(function () {
console.log('setTimeout1')
}, 0)
}
async function async2() {
// 更改如下:
setTimeout(function () {
console.log('setTimeout2')
}, 0)
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout3')
}, 0)
async1()
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')
输出:
script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1
练习七:
async function a1() {
console.log('a1 start')
await a2()
console.log('a1 end')
}
async function a2() {
console.log('a2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise1')
})
a1()
let promise2 = new Promise((resolve) => {
resolve('promise2.then')
console.log('promise2')
})
promise2.then((res) => {
console.log(res)
Promise.resolve().then(() => {
console.log('promise3')
})
})
console.log('script end')
输出:
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
练习八:
setTimeout(function () {
console.log(a);
}, 0);
var a = 10;
console.log(b);
console.log(fn);
var b = 20;
function fn() {
setTimeout(function () {
console.log('setTImeout 10ms.');
}, 10);
}
fn.toString = function () {
return 30;
}
console.log(fn);
setTimeout(function () {
console.log('setTimeout 20ms.');
}, 20);
fn();
其他示例:
https://blog.youkuaiyun.com/a1056244734/article/details/106720640
https://www.cnblogs.com/longbensong/category/1101835.html
尾语:点赞是一种鼓励,关注是一种陪伴。让我们在彼此的分享中,感受世界的温暖和力量!