写点什么

重学 JavaScript03——执行

发布于: 2020 年 08 月 08 日
重学JavaScript03——执行

简介



Javascript 代码运行在Javascript 引擎当中,当我们拥有一段JS代码时,宿主,也就是浏览器或者Node首先要将代码传递给Javascript 引擎,并要求其执行。也就是说,JS引擎常驻内存,等待宿主将JS代码或函数传递给它执行。



宿主一方面向JS引擎传递代码执行,一方面也提供API给Javascript, 比如setTimeout,会允许JS在特定的时机执行。



ES3及之前的版本,JS是没有异步执行能力的,JS引擎依次执行宿主传递的代码。ES5 之后引入了Promise,JS引擎也可以自己发起任务了。按照JSC引擎的术语,我们把宿主发起的任务叫做宏观任务,把JS引擎发起的任务叫做微观任务。



宏观任务与微观任务



操作系统中,等待行为通常都是一个事件循环,Node 中也是这么叫的,而事件循环在C/C++中就是一个跑在独立线程中的循环:



while(TRUE) {
r = wait();
execute(r);
}



而整个事件循环就是反复的“等待-执行”。每次执行相当于一个宏观任务,而宏观任务队列,就相当于事件循环。



如前所述,宏观任务当中,Promise 还会产生异步代码,JS必须保证这些异步代码在宏观任务中完成,因此每个宏观任务又包含了一个微观任务队列。





由于又了宏观任务和微观任务队列,就可以实现JS引擎级和宿主级的任务了。比如, Promise 将微观任务添加到队列尾部;setTimeout等宿主API,则会添加宏观任务。



Promise



Promise 的设计思想是标准化异步管理,当需要IO、等待等异步操作时,返回的不是结果,而是一个承诺,调用方等待这个承诺的兑现(then),如:



function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
sleep(1000).then( ()=> console.log("finished"));



很好理解,是等待传入参数的指定时常,打印finished。



我们知道,Promise的then回调是异步的,那么:



var r = new Promise(function(resolve, reject){
console.log("a");
resolve()
});
r.then(() => console.log("c"));
console.log("b")



这段代码的输出,应当是 a b c,因为Promise 的resolve 是异步的,所以b 一定会出现在c之前。



上面都相对好理解,下面我们加入setTimeout。



var r = new Promise(function(resolve, reject){
console.log("a");
resolve()
});
setTimeout(()=>console.log("d"), 0)
r.then(() => console.log("c"));
console.log("b")



结果是a b c d, d 为什么出现在最后了呢?不是等待时间是0 吗?因为Promise 产生的是JS引擎内部的微任务,而 setTimeout 是浏览器API产生的宏任务。



那就是说,微任务始终先于宏任务吗?



我们设计一个实验:



setTimeout(()=>console.log("d"), 0)
var r = new Promise(function(resolve, reject){
resolve()
});
r.then(() => {
var begin = Date.now();
while(Date.now() - begin < 1000);
console.log("c1")
new Promise(function(resolve, reject){
resolve()
}).then(() => console.log("c2"))
});



这里我们强制了等待一秒,可以确保输出 c2 的任务是在 输出d 之后添加到任务队列的。



但是输出仍然是c1 c2 d。即使耗时了1秒的c1执行完毕,也是先执行c2, 再执行d。



结论就是,微任务优先。



也就是说异步的执行顺序为:



  • 首先我们分析有多少个宏任务;

  • 在每个宏任务中,分析有多少个微任务;

  • 根据调用次序,确定宏任务中的微任务执行次序;

  • 根据宏任务的触发规则和调用次序,确定宏任务的执行次序;

  • 确定整个顺序。



练习一下:



function sleep(duration) {
return new Promise(function(resolve, reject) {
console.log("b");
setTimeout(resolve,duration);
})
}
console.log("a");
sleep(5000).then(()=>console.log("c"));



setTimeout 将代码分成了两个宏任务,5秒并不重要,第二个宏任务执行调用了resolve,输出c。因此输出为 a b c。



似乎Promise带来的好处并没有比回调更简单,于是ES6的 async/asait 出场了,它和Promise 配合起来,能有效改善代码结构。



async/await



async/await 提供了用for、if 等代码结构来编写异步的方式。async 函数一定返回Promise,我们把所有返回Promise 的函数都可以认为是异步函数。



用法为在函数前加上async关键字,就可以在其中使用await 来等待一个Promise。



function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
async function foo(){
console.log("a")
await sleep(2000)
console.log("b")
}



async 的优势在于,它是可以嵌套的。我们定义了一批原子操作的情况下,可以利用async函数组合出新的async函数。



function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
async function foo(name){
await sleep(2000)
console.log(name)
}
async function foo2(){
await foo("a");
await foo("b");
}



如此这般,如果我们把sleep这样的异步操作放入到某个框架或者库中,使用者几乎不需要了解Promise 的概念就可以进行异步编程了。



提一下,经常讲到异步就会讲generator/iterator,而它俩仅仅是在缺少async/await时来模拟async/await的行为,并且generator/iterator 的设计初衷并不是来实现异步,因此有了async/await,就不要再考虑generator / iterator 了。



闭包



追本溯源一下,闭包,翻译自closure,计算机领域中,出现过三次:



  • 编译原理中,是处理语法产生式的一个步骤

  • 计算几何中,表示包裹平面点集的多边形(又叫凸包)

  • 编程语言中,表示一种函数



最早出现在1964年《The Computer Journal》,文章是《The mechanical evaluation of expressions》,作者是 P. J. Landin , 概念是applicative expression 和 closure。而那个时代,编程语言的主角是基于lambda 演算的编程语言,因此当时闭包的含义仅仅为“带有一系列信息的lambda 表达式”,而lambda表达式就是函数,翻译一下,就是带有一系列信息的函数。



函数可以理解为表达式,那什么是一系列信息呢?也就是函数所携带的环境部分,即环境和标识符列表,收敛到Javascript:



  • 环境就是函数的词法环境,即执行上下文的一部分

  • 标识符列表,就是函数中用到的未声明的变量

  • 表达式部分,即为函数体



由此可见,Javascript 中的函数定义其实就是闭包的定义。



但是,一种常见的“误解”,当然可能仅仅是为了讲述那个知识点而产生的字面误解。许多人,包括我在内,将Javascript 的执行上下文,或Scope作用域(ES3中规定的执行上下文的一部分)概念当作闭包。是种“误解”,但我并不反对,起名没啥意义,我知道你说的什么就行了,你可以接着说。



执行上下文



执行上下文,就是函数所携带的“环境部分”,它包括了词法环境等(TODO)。JS标准把一段代码(包括函数)执行所需的所有信息定义为执行上下文。



ES3中,执行上下文包括:



  • Scope: 作用域,或常听到的作用域链

  • VO(Variable Object): 变量对象,用于存储变量的对象

  • this value: 就是this 值。



ES5中, 由于我们修改了命名方式,执行上下文包括:



  • lexical environment: 词法环境,获取变量时使用

  • variable environment:变量环境,声明变量时使用

  • this value:还是this 值



ES2018,this 被划分到了lexcial environment 中,但增加了许多,执行上下文变成了:



  • lexical environment: 词法环境,获取变量或者this值时使用

  • variable environment:变量环境,声明变量时使用

  • code evaluation state: 用于恢复代码执行位置

  • Function: 执行的任务是函数时使用,表示正在被执行的函数

  • ScriptOrModule:执行的任务时脚本或模块时使用,表示正在被执行的代码

  • Realm:使用的基础库和内置对象实例

  • Generator:仅仅生成器上下文有这个属性,表示当前的生成器。



这是执行上下文的发展,我们统一使用最新的ES2018中规定的术语即可。



从几行简单的代码说起:



var b = {}
let c = 1
this.a = 2;



平淡无奇,且枯燥,有什么问题呢?执行上下文能解决什么问题呢?



这段代码,出现在不同的位置,甚至每次执行中,会关联到不同的执行上下文,也就是说,同一段代码会产生不一样的行为。



比如:



  • var 把 b 声明到哪了? b 表示的哪个变量? b的原型是哪个对象?

  • let 把 c 声明到那了?

  • this 指向的是谁?



var 的声明与赋值



var b = 1



代码声明了b, 赋值为1,var 的声明作用域为执行作用域,即会穿透for、if 等。



因此没有let 的年代,催生出了IIFE,立即执行函数表达式,为的就是构造一个新的作用域,控制var 的影响范围。



怎么立即执行呢? 加括号!



(function(){
var x;
x = 1;
console.log(x)
//code
}());
(function(){
var y;
y = 2;
console.log(y)
//code
})();





但是加括号有问题,如果上一行代码没写分号,那括号就会被解释成上一行代码的函数调用,显然不符合预期也不好调试。



但的确有不加分号的代码风格规范,那涉及到IIFE,就要自己给行首加上分号,类似:



;(function(){
var a;
//code
}())
;(function(){
var a;
//code
})()



有点丑,推荐void 写法:



void function(){
var a;
//code
}();



void 不仅避免了“分号问题”,同时void 本身就表示忽略后面表达式的值,变成undefined,与IIFE 初衷一样,语义上也更合理,因此更优雅。



var 的穿透特性,加上with,会产生一些灾难:



var b;
void function(){
var env = {b:1};
b = 2;
console.log("In function b:", b); // 2
with(env) {
var b = 3;
console.log("In with b:", b); // 3
}
}();
console.log("Global b:", b); // undefined



导致声明的变量 b 和被赋值的变量 b,是两个b:



with 我们后面还会讲到,是个大坑,建议别用,function 的环境中,并没有var b的声明,也就是说with 内的var b 作用到了function 中。



而 var b = xxx 这样的语句对两个作用域都产生了作用,显然是一个糟糕的设计,也因此很多人反对使用with



let



var 的弊病很多,比如穿透,因此let 做了明确的梳理和规定。



为了实现let,Javascript 终于引入了块级作用域,let 让以下语句拥有了单独的作用域(在let之前,是没有的):



  • for

  • if

  • switch

  • try/catch/finally



Realm



Realm 是9.0标准新引入的概念,它是什么呢?



它包含了一组完整的内置对象,而且是复制关系。怎么理解呢?



先执行:



var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"



再执行:



var b1 = iframe.contentWindow.b;
var b2 = {};
console.log(typeof b1, typeof b2); //object object
console.log(b1 instanceof Object, b2 instanceof Object); //false true



常见的Realm 就是iframe了。



代码展示从浏览器环境中获取来自两个 Realm 的对象,它们跟本土的 Object 做 instanceOf 时会产生差异:



由于 b1、 b2 由同样的代码“ {} ”在不同的 Realm 中执行,所以表现出了不同的行为。



说白了,两个Realm 的Object 并不是同一个Object。



函数



切换上下文最主要的场景是函数调用,那我们先来讲讲函数。



函数简介



ES2018中,函数已经很复杂了,大概分为以下几种:



  1. 普通函数,也就是function关键字定义的函数

  2. 箭头函数,即用 => 运算符定义的函数

  3. 生成器函数,也就是用function*定义的函数

  4. 方法,是指class 中定义的函数

  5. 类,用class 定义的类,实际上也是函数

  6. async 异步函数家族,包括

1. async 普通函数

2. async 箭头函数

3. async 生成器函数



对普通变量来说,这些函数没有本质区别,都是遵循“继承定义时环境”,行为差异在于this关键字。



this 关键字



this 在javascript 中是个关键字,使用方法类似于变量,而且this 是执行上下文中的重要组成。同一个函数调用方式不同,得到的this值也不同。



function showThis(){
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // global
o.showThis(); // o



普通函数的this 值,是由“调用它所使用的引用”决定的,我们获取函数的表达式,它实际上返回的是一个Reference类型,而非函数本身



Reference 类型包含两部分,一个对象和一个属性值。比如上例中的o.showThis 所产生的Reference类型,就是由对象o和属性“showThis”构成的。



总结一下,调用函数的引用,决定了函数执行时刻的this 值。



也就是说,和Java相比,这里的this 完全是在模仿Java 的语法,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



与普通函数一样,方法也是,this 的值与调用它使用的表达式相关。



async 的行为与其不加async 的行为是一致的。(即,异步箭头函数是不影响的,另外两个是影响的)



this 关键字的机制



再深入一些说,实现this 行为的机制是什么样的呢?



Javascript 中,为函数定义了用来保存定义时上下文的私有属性,即[[Environment]]。



当函数执行时,会创建一条新的执行环境的记录,记录的是外层词法环境的[[Environment]],设置成函数的[[Environment]],而这,就是切换上下文的机理。



var a = 1;
foo();
// 假设在另一个文件中定义了foo:
var b = 2;
function foo(){
console.log(b); // 2
console.log(a); // error
}



function 中的foo能够访问b(定义时的词法环境),却访问不了a(执行时的词法环境)



底层使用栈+链表来管理执行上下文的。函数调用,会入栈新的执行上下文,调用结束,执行上下文出栈。





this 要更复杂一点,JavaScript 定义了 [[thisMode]] 私有属性,包括三个取值:



  • lexical:上下文中找this,对应了箭头函数

  • global:this 为undefined 时,取全局变量,即普通函数

  • strict:当严格模式时使用,this 严格按照调用时传入的值,可能为null 也可能为undifeined。



这也解释了上面说到的,方法的行为与普通函数的行为有差异,就是因为class设计成了默认按strict 模式进行。



做个实验:



"use strict"
function showThis(){
console.log(this);
}
var o = {
showThis: showThis
}
showThis(); // undefined
o.showThis(); // o



如果代码执行,遇到了this ,会逐层检查当前词法环境记录中的[[ThisBindingStatus]],找到则获取,如下例所示,调用三个函数,获取到的this 值时一致的。



var o = {}
o.foo = function foo(){
console.log("shallow", this);
return () => {
console.log("middle", this);
return () => console.log("deep", this);
}
}
o.foo()()(); // o, o, o



而,如果都是普通函数:



var o = {}
o.foo = function foo(){
console.log("shallow", this);
return function(){
console.log("middle", this);
return function(){console.log("deep", this);}
}
}
o.foo()()(); // o, global, global



操作this 的内置函数



JS还提供了一系列函数的内置方法来操纵this。



Function.prototype.call 和 Function.prototype.apply 这两个方法可以指定函数调用时传入的this值:



function foo(a, b, c){
console.log(this);
console.log(a, b, c);
}
foo.call("fakeThis", 1, 2, 3);
foo.call({}, 1, 2, 3);
foo.apply({}, [1, 2, 3]);



第一个参数就是this 的值,两者仅在传参方式上不同。



还有一个,叫Function.prototype.bind,可以生成一个绑定过的函数,而这个函数的this 值,也可以通过入参来设置。



function foo(a, b, c){
console.log(this);
console.log(a, b, c);
}
foo.bind({}, 1, 2, 3)();



但是,这都是针对普通函数的,call、bind、apply 对于不接受this 的函数类型,如箭头、class 不会报错,但无法改变this 的能力,只能进行传参。



const foo = (a,b,c) =>{
console.log(this);
console.log(a, b, c);
}
foo.bind({}, 1, 2, 3)();



new 与 this



复习一下前面说过的new 的执行过程:



  • 以构造器的prototype 属性为原型,创建新对象。(注意是prototype 属性,而不是私有字段[[prototype]]

  • 将this 和调用参数传递给构造器,执行

  • 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。



也就是说,通过new 调用函数,与直接调用的this 取值有明显区别:





也就是说,仅普通函数和类可以跟new 搭配。



语句



常见的语句有变量的声明、表达式、条件语句、循环语句等,这些都是我们熟悉的。那我们就来看看不熟悉的。



Completion 类型



下面的代码中,finally块会执行吗?



function foo(){
try{
return 0;
} catch(err) {
} finally {
console.log("a")
}
}
console.log(foo());



发现返回是 a 0。



如果我们在finally中,return呢



function foo(){
try{
return 0;
} catch(err) {
} finally {
return 1;
}
}
console.log(foo());



发现仅返回了1,finally 中的return 覆盖了try 中的return。如果你知道Java ,其实结果也是这样。



这一机制靠的就是JavaScript语句执行的完成状态,我们用一个标准类型表示,叫Completion Record,用于描述异常、跳出等语句执行过程。它有三个字段:



[[type]] 表示完成的类型,如break continue return throw normal



[[value]] 就是语句的返回值,没有则是empty



[[target]]表示语句的目标,通常是一个JavaScript 标签



那Javascript 是怎么使用Completion Record 来控制语句执行过程的呢?



语句大概分为以下几类:





普通的语句



普通的语句即不带控制能力的语句,如上图所示。



普通语句的执行是依次执行(忽略var和函数声明的预处理机制),执行后得到的Completion Record,[[type]] 为normal,JS引擎遇到这种CR,会继续执行下一句。



普通语句中,只有表达式语句会产生[[value]],而且对引擎来说,并没什么用。在Chrome 的控制台也能看到。





控制台显示的,就是Completion Record 的[[value]]



语句块



语句块就是大括号扩起来的一组语句,是语句的复合结构,可以嵌套。



{
var i = 1; // normal, empty, empty
i ++; // normal, 1, empty
console.log(i) //normal, undefined, empty
} // normal, undefined, empty



语句块中的语句,如果Completion Record 的[[type]] 不为 normal, 会打断后续的语句执行。



如return 类型、 throw 类型



{
var i = 1; // normal, empty, empty
return i; // return, 1, empty
i ++;
console.log(i)
} // return, 1, empty



语句块中的语句被return这一非normal的完成类型打断了。这个结构保证了非normal的完成类型产生控制的效果。



控制型语句



控制型语句带有关键字,如if 、 switch 等,会对不同类型的CR产生反应。



控制语句也可以分两种,一种是对内部造成影响,如if、switch、while/for、try



一种是对外部造成影响,如break、continue、return、throw。



两者相配合,可以控制代码的执行顺序和执行逻辑。





『消费』是控制语句里的内容执行完毕。消费就是在这一层就执行了这个break或者continue。

『穿透』是控制语句里的内容没能执行完,被中止了。穿透就是去上一层的作用域或者控制语句找可以消费break,continue的执行环境。



回到之前的例子,就是try 和return 的组合。由于finally 的内容要保证必须执行,所以try/catch执行完毕,即使得到的结果是非normal的,也必须要执行finally。而finally 如果也得到了非normal的记录,则会使finally中的记录作为整个try结果的结果。



带标签的语句



最后一个字段target,涉及了JavaScript 中带标签的语句。



任何JavaScript语句都是可以加标签的,只需要在语句前加冒号。



大部分可以看作注释,唯一用得到的地方是与CR的target 相配合,跳出多层循环,即break/continue 后加关键字,会产生带target 的完成记录,到只拥有对应label的循环语句来消费。



outer: while(true) {
inner: while(true) {
break outer;
}
console.log("Now you are in the infinity loop! Close Now!")
}
console.log("finished")



如上段代码,如果不加outer, 就死循环了。



用户头像

还未添加个人签名 2017.10.17 加入

还未添加个人简介

评论

发布
暂无评论
重学JavaScript03——执行