深入javascript计划三:javascript编译器、引擎、作用域介绍

如果有问题,请大家麻烦指出来。

javascript我们通常把它归类为"动态"或"解释执行"语言,但事实上它是一门编译语言。它与传统编译语言(C语言等)不同,它不是提前编译,而且并不会产生可移植的编译结果。

(传统编译语言)编译器工作流程

通常分为三个步骤:

   1.分词/词法分析(Tokennizing/Lexing)

        这个过程会将由字符组成的字符串分解成有意义的代码块(把输入的字符串分解为一些对编程语言有意义的代码块),这些代码块被称为"词法单元"(token)

   2.解析/语法分析(Parsing)

        这个过程是将上一步的词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为"抽象语法树"(Abstract Syntax Tree,AST)

   3.代码生成

      这个过程是将上一步的AST转换为可执行代码(机器指令)的过程被称为代码生成

JavaScript编译器工作流程

在线调试工具jointjs

在线调试工具esprima

1.分词/词法分析(Tokennizing/Lexing)解释如上文一致,例子:

var a = 2;

小提示:空格是否会被当作词法单元,取决于空格在这门语言是否有意义。

2.解析/语法分析(Parsing)解释如上文一致,例子:

var a = 2;

 

3.代码生成 解释如上文一致

简单的描述一下就是有某种方法可以将var a  = 2; 的AST转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存地址等),并将一个值存储在a中。

JavaScript编译器工作流程与(传统编译语言)编译器工作流程有什么区别?

javascript编程过程与(传统编译语言)编译过程都是分三个步骤。但是javascript引擎会在语法分析和代码生成阶段进行优化(例如针对冗余元素进行优化),还会对编译过程进行优化(如JIT,延迟编译或者重编译),目的是缩短编译过程,保证性能最佳。

JIT(Just In Time)是什么?

每当一个程序在运行时创建并运行一些新的可执行代码,而这些代码在存储于磁盘上时不属于该程序的一部分,它就是一个JIT。

JavaScript中的引擎、编译器、作用域是如何配合工作?

引擎:从头到尾负责整个javascript程序的编译及执行过程。浏览器不同,其引擎也不同,比如Chrome采用的是v8,Safari采用的是SquirrelFish Extreme。

编译器:负责词法分析、语法分析及代码生成。

作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套严格的规则,确定当前执行的代码对这些标识符的访问权限。通俗来讲就是在运行时代码中的某些特定部分中变量,函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。在说明白一点:也就是变量的管家用一个事先定义好的规则(词法作用域),管理变量的查询与访问。

先讲讲作用域

作用域有两种模型:

  1. 词法作用域
  2. 动态作用域(javascript中没有)

词法作用域(静态作用域)和动态作用域

词法作用域(书写时定义):

我们将"作用域"定义了一套规则,javascript引擎根据这套规则来管理变量的查找与引用,词法作用域就是其使用的规则,在编译器进行词法化时,会根据你写代码时将,变量和块作用域写在哪里,来决定规则的内容。这其中又包含了块作用域这个概念,只要记住ES6之前没有块作用域,只有函数有作用域,即:函数内部是一个独立的块作用域。(有个特例:catch语句块内也是独立的作用域。) 通俗来讲:词法作用域是定义在词法阶段的作用域,换句话来说就是在执行之前就确定了它可以应用哪些地方的作用域(变量)。

javascript引擎可以通过evalwith来改变词法作用域,但这两种会导致引擎无法在编译时对作用域查找进行优化, 因此不要使用它们。

例子:

function foo(a) {
    var b = a + 2;
    function bar(c) {
        console.log(a, b, c);
    }
    bar(b * 3);
}
foo(2); // 2, 4, 12

看图:

讲解:

1. a 会先在bar()作用域查找有没有a,有就返回,没有就继续往上查找

2. a往foo()作用域查找有没有a,有就返回,没有就继续往上查找直到全局作用域,还没有找到就抛出异常。

.....

动态作用域(运行时定义):

javascript例子(javascript是不支持的):


var value = 1;

function foo() {
    console.log(value); // 打印 1
}

function bar() {
    var value = 2;
    foo();
}

bar();

讲解:

如果javascript支持的话在foo()作用域查找value,查找不到会往bar()作用域查找 然后 打印 2。

但是javascript不支持它会在foo()作用域查找value,查找不到会往在当前函数上边查找(在例子中就是全局作用域) 然后 打印 1。

作用域范围

  1. 全局作用域
  2. 函数级作用域
  3. 块级作用域(es6)

先说一下var、let(es6)、const(es6)!

编译器将var 声明的变量提升至作用域顶部,将let或const声明的变量放到暂时性死区(temporal dead zone, TDZ)。访问暂时性死区(TDZ)中的变量会触发运行时的错误,只有执行过变量声明语句后,变量才会从时暂时性死区(TDZ)中移出,这时才可访问。

全局作用域:

var a = 1; // 全局变量

function foo() { // 全局函数

   b = 2; // 未定义却赋值(LHS)会创建一个全局变量b,然后赋值

   var name = 'an'; // 局部变量

   function bar() { // 局部函数
       console.log(name);
   }
}

console.log(name) // ReferenceError: name is not defined

函数级作用域:

function foo() {
    var name = "an";
    function sayName() {
        console.log(`hello, ${name}`);
    }
    sayName();
}
foo() // hello, an
console.log(name) // ReferenceError: name is not defined
sayName() // ReferenceError: sayName is not defined

值得注意的是, if、switch、while、for 这些条件语句或者循环语句不会创建新的作用域, 虽然它也有一对{}包裹. 能不能访问的到内部变量取决于声明方式(var 还是 let/const),因为var 会提升到当前作用域顶部!

块级作用域

JavaScript 没有块级作用域的情况,在es6 let 和 const 的出现解决了这样的情况,所声明的变量在指定块的作用域外无法被访问。

function test() {
    let name = "an"
    console.log(name) // an
    function bar() {
        console.log(name) // an
    }
    bar()
}
let name = "name" || var name = "name"
test()
console.log(name) // name
{
  let name = ""
}
console.log(name) // ReferenceError: name is not defined

作用域链

通俗来讲:当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。

例子:

function foo(a) {
    var b = a + 2;
    function bar(c) {
        console.log(a, b, c);
    }
    bar(b * 3);
}
foo(2); // 2, 4, 12

讲解:

1. a 会先在bar()作用域查找有没有a,有就返回,没有就继续往上查找

2. a往foo()作用域查找有没有a,有就返回,没有就继续往上查找直到全局作用域,还没有找到就抛出异常。

....

先讲讲引擎的提升

大部分编程语言都是先声明变量再使用,但是在javascript中,并不是这样。

因为javascript引擎拿到代码的时候,编译器已经做了一些转换,编译器为什么要作这个事情?

因为它要在第一步就找到所有的声明,并且用合适的作用域将他们关联起来,这也正是词法作用域的核心。表现为:包括变量和函数在内的所有声明都会在当前块作用域内被首先处理,即类似于提升到最前面声明,但是复制处理操作因为是在执行阶段,因此编译阶段他们原地待命等待执行。换句话说,就是编译阶段的一部分工作就是找到所有的声明,并且使用合适的作用域将它们串联起来,变量和函数在内的所有声明都会在代码执行前被处理。

变量提升

看下面代码:

console.log(a); // undefined
var a = 1;

javascript引擎会拆分成

var a;
console.log(a);
a = 2;

javascript引擎会将var a = 2; 拆分成 var a; a = 2;当作两个单独的声明,第一个是在编译阶段的任务,第二个是在执行阶段的任务

换句话说, 这个过程将会把变量和函数声明放到其作用域的顶部,这个过程就叫做提升。

可能你会有疑问,为什么 let 和 const 不存在变量提升呢?

这是因为在编译阶段, 当遇到变量声明时,编译器要么将它提升至作用域顶部(var 声明),要么将它放到暂时性死区(temporal dead zone, TDZ),也就是用 let 或 const 声明的变量。访问暂时性死区(TDZ)中的变量会触发运行时的错误,只有执行过变量声明语句后,变量才会从时死区(TDZ)中移出,这时才可访问。

例子:

console.log(a) // ReferenceError: Cannot access 'a' before initialization
let a = "a";

// 分割线

console.log(a) // ReferenceError: Cannot access 'a' before initialization
const a = "a";

函数提升

函数声明和变量声明都会被提升,但值得注意的是,函数首先被提升,然后才是变量。

定义一个函数有两种方式:函数声明(function definition/declaration/statement)和函数表达式( function expression)。

foo(); // 1
var foo;
function foo() {
  console.log(1);
}
foo = function() {
  console.log(2);
}

javascript引擎会理解成

function foo() {
   console.log(1);
}
foo(); // 1
foo = function() {
   console.log(2);
}

注意,var foo尽管出现在function foo() 声明之前,但是它是重复的声明(会忽略),因为函数声明会被提升到普通变量之前。

函数表达式不可提升

a();  // TypeError: a is not a function
var a = function f1() {
  console.log("a");
}

先讲讲引擎的LHS、RHS

LHS:赋值操作的目标是谁(对变量进行赋值所执行的查询)

RHS:谁是赋值操作的源头(找到并使用变量所执行的查询)

例子:

var a; // LHS 寻找a,未找到,通知作用域声明一个新变量,命名为a
a = 2; // LHS 找到a并给其赋值为2
console.log(a); // RHS 找到a的值2,并将其输出

例子:

function test(a) {
   // 这里隐式包含了 a = 2 这个赋值,所以对 a 进行了 LHS 查询
   var b = a;
   // 这里对 a 进行了 RHS 查询,找到 a 的值,然后对 b 进行 LHS 查询,把 2 赋值给 b
   return a + b; 
   // 这里包含了对 a 和 b 进行的 RHS 查询
}
var a = test(2)
// 这里首先对 test() 进行 RHS 查询,找到它是一个函数,然后对 a 进行 LHS 查询把 test 赋值给 a 

区分LHS、RHS很重要!

因为在变量还没有声明(在任何作用域中都无法找到该变量)情况下,这两种查询行为是不一样的。

function test(a) {
   console.log(a + b)
   b = a
}
test(2)

以上代码对b进行RHS的时候无法找到该变量的值,则会抛出ReferenceError异常。

如果是LHS找不到变量:

非严格模式下,会在全局作用域中,创建一个具有该名称的变量,

严格模式下,会抛出与RHS类似的异常。

看例子:

function test() {
    a = 100
    console.log(a) // 100
}
test()
console.log(a) // 100
console.log(c) // ReferenceError: c is not defined

好了就讲这里。回到原文!

var a = 2;

这段程序的工作流程:

当你看见 var a = 2; 这段程序时,很可能认为这是一句声明,事实上javascript引擎认为(这个是提升)把它们拆成 var a; a = 2;有两个完全不同的声明,一个由编译器在编译时处理,另一个在引擎运行时处理。

    因为javascript引擎拿到代码的时候,编译器已经做了一些转换,编译器为什么要作这个事情?

    因为它要在第一步就找到所有的声明,并且用合适的作用域将他们关联起来,这也正是词法作用域的核心。

代码执行前会对其进行编译,首先编译器会词法分析,然后词法单元流(数组)解析成语法树,最后进行代码生成,别忘了代码生成就是将语法树转化为一组机器指令。

  1. 生成代码前编译器会询问作用域是否有该名称的变量(LHS),如果有,忽略该声明,如果没有,会要求作用域在当前作用域的集合中声明一个新变量,并命名为a。
  2. 接下来为引擎生成运行时所需要的代码,这些代码用来处理a = 2;这个赋值操作(LHS),运行时引擎首先会问作用域,在当前作用域集合中是否有一个叫作a的变量,如果找到就会对他赋值(如果没有就继续往上级作用域查找,如果到根作用域仍然找不到javascript引擎就会抛出一个异常)。

继续往下看!LHS、RHS版:

var a = 2;
console.log(a);

1 .编译器:作用域,我需要对a进行LHS查找,你见过么?

2 .作用域:我这找到根都没看到啊,要不咱声明一个吧!

3. 编译器:好,建好了,那我生成代码了,引擎,给你你要的代码。

4 .引擎:收到,咦,需要一个a啊,作用域,帮我LHS找一下有没有?

5. 作用域: 找到了,编译器已经帮忙声明了。

6. 引擎:好的,那我对它赋值。

7. 引擎:作用域,不要意思,我碰到一个console,需要RHS引用

8 .作用域: 找到了,是个内置对象,拿走不谢。

9 .引擎: 好的作用域,对了能在帮我确认一下a的RHS么?

10 .作用域:确认好了,没变,拿去用吧,他的值是2

11. 引擎:好咧,我把2传递给log(..)

谈谈闭包

我们上文以及讲了作用域以及作用域链,然后在来看看面试多数问到的闭包。

什么是闭包?

红宝书(p178)上对于闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数。

闭包产生的原因?

首先要明白作用域链的概念(上文也讲过),其实很简单,在ES5中只存在两种作用域————全局作用域和函数作用域,当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中,这就是作用域链,值得注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。 比如:

var a = 1;
function f1() {
  var a = 2
  function f2() {
    var a = 3;
    console.log(a);//3
  }
}
f1();

讲解:f2(),console.log(a)的时候会先查找自身作用域看看有没有a这个变量,如果没有就会查找f1()作用域,如果没有会查找全局作用域。不过f2查找自身的时候就找了 所以就 直接 输出 3。

闭包产生的本质就是,当前环境中存在指向父级作用域的引用。例子:

var name = "an";
function f1() {
    var a = 2
    function f2() {
      a++;
      console.log(a);
      console.log(name);
    }
    return f2;
  }
  var x = f1();
  x(); // 3 an
  x(); // 4 an
  x(); // 5 an
  // console.log(name); // an
  // console.log(a); // ReferenceError: a is not defined

讲解:这里x()会拿到f1()作用域和全局作用域中的变量,输出 3 an ....。因为在当前环境中,含有对f2()作用域的引用,f2()恰恰引用了全局作用域、f1()作用域。因此f2()作用域可以访问到f1()的作用域和全局作用域的变量。

来个难一点的例子:

var f3;
function f1() {
  var a = 2
  console.log("---f1---")
  f3 = function() {
    console.log(a);
  }
}
f1(); // ---f1---
console.log(f3) // [Function: f3]
f3(); // 2

// 如果直接访问
// console.log(f3) // undefined

讲解:让f1()执行,给f3赋值一个函数,f3拥有全局、f1()、本身作用域的访问权限,f3()打印a,会先查找自身作用域,没有就查找f1的作用域(还没有就查找全局作用域),找到之后返回打印 2。

经典的面试题

for ( var i=1; i<=5; i++) {
	setTimeout( function timer() {
		console.log( i );
	}, i*1000 );
}

为什么会全部输出6?如何改进,让它输出1,2,3,4,5?(方法越多越好)

因为setTimeout为宏任务,由于JS中单线程eventLoop机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,但输出i的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了6。因此会全部输出6。

解决方法:

1、利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中

for(var i = 1;i <= 5;i++){
  (function(j){
    setTimeout(function timer(){
      console.log(j)
    }, 0)
  })(i)
}

2、给定时器传入第三个参数, 作为timer函数的第一个函数参数

for(var i=1;i<=5;i++){
  setTimeout(function timer(j){
    console.log(j)
  }, 0, i)
}

3.使用es6的let

for(let i = 1; i <= 5; i++){
  setTimeout(function timer(){
    console.log(i)
  },0)
}

参考:

《你不知道的javascript》上卷

http://47.98.159.95/my_blog/js-base/004.html

https://github.com/creeperyang/blog/issues/16

https://www.jianshu.com/p/5ebf2ad6def2

https://juejin.im/post/5ca995626fb9a05e1a7aabd8#heading-7

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

An_s

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值