作用域是什么

作用域是你的代码在运行时,某些特定部分中的变量,函数和对象的可访问性。换句话说,作用域决定了变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期

理解作用域

在 JavaScript 中有两种作用域

全局作用域

如果一个变量在函数外面或者大括号{}外声明,那么就定义了一个全局作用域
拥有全局作用域的对象可以在代码的任何地方访问到, 在js中一般有以下几种情形拥有全局作用域:
最外层的函数以及最外层变量:

var globleVariable= 'global';  // 最外层变量
function globalFunc(){ // 最外层函数
var childVariable = 'global_child'; //函数内变量
function childFunc(){ // 内层函数
console.log(childVariable);
}
console.log(globleVariable)
}
console.log(globleVariable); // global
globalFunc(); // global
console.log(childVariable) // childVariable is not defined
console.log(childFunc) // childFunc is not defined

从上面代码中可以看到globleVariableglobalFunc在任何地方都可以访问到, 反之不具有全局作用域特性的变量只能在其作用域内使用。

未定义直接赋值的变量(由于变量提升使之成为全局变量)

function func1(){
special = 'special_variable';
var normal = 'normal_variable';
}
func1();
console.log(special); //special_variable
console.log(normal) // normal is not defined

虽然我们可以在全局作用域中声明函数以及变量, 使之成为全局变量, 但是不建议这么做,因为这可能会和其他的变量名冲突,一方面如果我们再使用const或者let声明变量, 当命名发生冲突时会报错。

// 变量冲突
var globleVariable = "person";
let globleVariable = "animal"; // Error, thing has already been declared

另一方面如果你使用var申明变量,第二个申明的同样的变量将覆盖前面的,这样会使你的代码很难调试。

var name = 'koala'
var name = 'xiaoxiao'
console.log(name); // xiaoxiao

局部作用域

在ES6之前局部作用域只包含了函数作用域,ES6为我们提供的块级作用域,也属于局部作用域

函数作用域

定义在函数中的变量就在函数作用域中。并且函数在每次调用时都有一个不同的作用域。这意味着同名变量可以用在不同的函数中。因为这些变量绑定在不同的函数中,拥有不同作用域,彼此之间不能访问。
    //全局作用域
function test(){
var num = 9;
// 内部可以访问
console.log("test中:"+num);
}
//test外部不能访问
console.log("test外部:"+num);
注意点: 如果在函数中定义变量时,如果不添加var关键字,造成变量提升,这个变量成为一个全局变量。

任何一对花括号{...}中的语句集都属于一个块, 在es6之前,在块语句中定义的变量将保留在它已经存在的作用域中:

var name = 'www.zeze.info';
for(var i=0; i<5; i++){
console.log(i)
}
console.log('{}外部:'+i);
// 0 1 2 3 4 {}外部:5

我们可以看到变量name和变量i是同级作用域。

在ES6块级作用域未讲解之前注意点

变量提升英文名字hoisting,MDN中对它的解释是变量申明是在任意代码执行前处理的,在代码区中任意地方申明变量和在最开始(最上面)的地方申明是一样的。也就是说,看起来一个变量可以在申明之前被使用!这种行为就是所谓的“hoisting”,也就是变量提升,看起来就像变量的申明被自动移动到了函数或全局代码的最顶上。 看一段代码:
var tmp = new Date();
function f() {
console.log(tmp);
if(false) {
var tmp='hello';
}
}

这道题应该很多小伙伴在面试中遇到过,有人会认为输出的是当前日期。但是正确的结果是undefined。这就是由于变量提升造成的,在这里申明提升了,定义的内容并不会提升,提升后对应的代码如下:

var tmp = new Date();
function f() {
var tmp;
console.log(tmp);
if(false) {
tmp='hello';
}
}
f();

console在输出的时候,tmp变量仅仅申明了但未定义。所以输出undefined。虽然能够输出,但是并不推荐这种写法推荐的做法是在申明变量的时候,将所用的变量都写在作用域(全局作用域或函数作用域)的最顶上,这样代码看起来就会更清晰,更容易看出来哪个变量是来自函数作用域的,哪个又是来自作用域链

// var
var name = 'koloa';
console.log(name); // koala
if(true){
var name = 'www.zeze.info';
console.log(name); // 程序员成长指北
}
console.log(name); // 程序员成长指北

虽然看起来里面name申明了两次,但上面说了,js的var变量只有全局作用域和函数作用域两种,且申明会被提升,因此实际上name只会在最顶上开始的地方申明一次,var name=’程序员成长指北’的申明会被忽略,仅用于赋值。也就是说上面的代码实际上跟下面是一致的。

// var
var name = 'koloa';
console.log(name); // koala
if(true){
name = '程序员成长指北';
console.log(name); // 程序员成长指北
}
console.log(name); // 程序员成长指北

变量和函数同时出现的提升:如果有函数和变量同时声明了,会出现什么情况呢?看下面但代码

console.log(foo);
var foo ='i am koala';
function foo(){}

输出结果是undefined
对两种结果进行分析说明:
第一种:函数申明。就是上面第一种,function foo(){}这种形式
另一种:函数表达式。就是上面第二种,var foo=function(){}这种形式
第二种形式其实就是var变量的声明定义,因此上面的第二种输出结果为undefined应该就能理解了。

var foo=function (){}
console.log(foo);
var foo ='i am koala';

原因是:
函数声明被提升到最顶上;
申明只进行一次,因此后面var foo=’i am koala’的申明会被忽略。
函数申明的优先级优于变量申明,且函数声明会连带定义一起被提升(这里与变量不同)

块级作用域

ES6新增了let和const命令,可以用来创建块级作用域变量,使用let命令声明的变量只在let命令所在代码块内有效。
let 声明的语法与 var 的语法一致。你基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:
变量不会提升到代码块顶部且不允许从外部访问块级作用域内部变量

console.log(bar);//抛出`ReferenceErro`异常: 某变量 `is not defined`
let bar=2;
for (let i =0; i<10;i++){
console.log(i)
}
console.log(i);//抛出`ReferenceErro`异常: 某变量 `is not defined`

其实这个特点带来了许多好处,开发者需要检查代码时候,可以避免在作用域外意外但使用某些变量,而且保证了变量不会被混乱但复用,提升代码的可维护性。就像代码中的例子,一个只在for循环内部使用的变量i不会再去污染整个作用域。

不允许反复声明
ES6的let和const不允许反复声明,与var不同

// var
function test(){
var name = 'koloa';
var name = 'www.zeze.info';
console.log(name); // 程序员成长指北
}
// let || const
function test2(){
var name ='koloa';
let name= 'www.zeze.info';
// Uncaught SyntaxError: Identifier 'count' has already been declared
}

看到这里是不是感觉到了块级作用域的出现还是很有必要的。

作用域链

JavaScript是如何执行的?

作用域链

JavaScript代码执行分为两个阶段:

分析阶段

  • 分析函数参数
  • 分析变量声明
  • 分析函数声明

执行阶段

分析阶段分析成功后,会把给AO(Active Object 活动对象)给执行阶段

  • 引擎询问作用域,作用域中是否有这个叫X的变量
  • 如果作用域有X变量,引擎会使用这个变量
  • 如果作用域中没有,引擎会继续寻找(向上层作用域),如果到了最后都没有找到这个变量,引擎会抛出错误。

执行阶段的核心就是找,具体怎么找,后面会讲解LHS查询与RHS查询。

JavaScript执行举例说明

function a(age) {
console.log(age);
var age = 20
console.log(age);
function age() {
}
console.log(age);
}
a(18);

首先进入分析阶段
前面已经提到了,函数运行的瞬间,创建一个AO (Active Object 活动对象)

AO = {}

第一步:分析函数参数:

形式参数:AO.age = undefined
实参:AO.age = 18

第二步,分析变量声明:

// 第3行代码有var age
// 但此前第一步中已有AO.age = 18, 有同名属性,不做任何事
AO.age = 18

第三步,分析函数声明:

// 第5行代码有函数age
// 则将function age(){}付给AO.age
AO.age = function age() {}

函数声明注意点:AO上如果有与函数名同名的属性,则会被此函数覆盖。但是一下面这种情况

var age = function () {
console.log('25');
}

声明的函数并不会覆盖AO链中同名的属性
进入执行阶段
分析阶段分析成功后,会把给AO(Active Object 活动对象)给执行阶段,引擎会询问作用域,找的过程。所以上面那段代码AO链中最初应该是

function age(){
}
20
20

作用域链概念

看了前面一个完整的javascript函数执行过程,让我们来说下作用域链的概念吧。JavaScript上每一个函数执行时,会先在自己创建的AO上找对应属性值。若找不到则往父函数的AO上找,再找不到则再上一层的AO,直到找到大boss:window(全局作用域)。 而这一条形成的“AO链” 就是JavaScript中的作用域链。

JavaScript中的LHS和RHS查询

简述编译原理

通常,把 JavaScript 归类为 “ 动态 ” 或 “ 解释执行 ” 的语言,但是事实上它是一门 编译语言,不提前编译,编译结果也不在分布式系统中进行移植。JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节比它要复杂。传统编译语言,在执行之前的三个步骤,统称为 “ 编译 ” 。

分词/词法分析( Tokenizing/Lexing )

将有字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元( token )。var a = 2;复制代码被分解成词法单元:var 、a、 =、2、;。空格在该语言中有意义,则会被当做词法单元,否则不是。

解析/语法分析( Parsing )

将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的 “ 抽象语法树 ”( Abstract Syntax Tree , AST )。var a = 2;复制代码以上代码的抽象语法树如下所示:

  • VariableDeclaration 顶级节点
    • Identifer 子节点,值为 a
    • AssignmentExpression 子节点
      • NumericLiteral 子节点,值为 2

代码生成

AST转换为可执代码的过程被称为代码生成。这个过程与语言、目标平台等相关。即通过某种方法,将 var a = 2 ; 的 AST 转化为一组机器指令,用来创建一个变量 a ,并将值存储在 a 中。引擎,可以根据需要创建并存储变量。

与其他语言不同,JavaScript的编译过程不是发生在构建之前的。对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短)的时间内。

举个栗子,var a = 2; JavaScript引擎会将它分为几步完成呢?
答案是两步,JavaScript 会将其看成两句声明:var a; 和 a = 2;。第一个定义声明在编译阶段进行,第二个赋值声明会被留在原地等待执行阶段。
变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在引用域中查找该变量,如果能够找到就会对它赋值。

JavaScript中的LHS和RHS查询

LHS 和 RHS

LHS(Left-hand Side)引用和RHS(Right-hand Side)引用。通常是指等号(赋值运算)的左右边的引用。

console.log(a);

这里对a的引用是一个RHS引用,因为这里a并没有赋予任何值,我们只是想查找并取得a的值,然后将它打印出来。

a=2

这里对a的引用是一个LHS引用,因为我们并不关心当前的值是什么,只是想要为赋值操作找到目标。

### 注 LHS和RHS的含义是“赋值操作的左侧和右侧”并不一定意味这就是"="的左侧和右侧。赋值操作还有其他几种形式,因此在概念上最好将其理解为“`赋值操作的目标是谁(LHS)`”以及“`谁是赋值操作的源头(RHS)`”

这里再举一个较复杂的例子:(找出所有的LHS查询和所有的RHS查询)

function foo(a) {
var b = a;
return a + b;
}
var c = foo(2)

这里一共有3个LHS查询和4个RHS查询,这里我们都来做个分析。

LHS

  1. 第6行的 c = ...,c 在赋值操作的左边,所以对 c 需要 LHS 查询。
  2. 隐藏着的 a = 2(隐式变量分配),在调用 foo(2) 时,需要将实参2赋值给形参a,所以对 a 需要 LHS 查询。
  3. 第2行的b = ..., 解释同 1。

RHS

  1. 第6行的c = foo(2),foo(2) 在赋值操作的右边,需要知道 foo(2)的值,对 foo(2) 需要 RHS 查询。
  2. 第2行的b = a, a 在赋值操作的右边,需要知道 a的值,对 a 需要 RHS 查询。
  3. 第3行的reutrn a + b;, 需要知道 a 和 b 的值, 分别对 a 和 b 都进行 RHS 查询。
### 小结: 如果查找的目的是对变量进行赋值,那么就会使用LHS查询; 如果目的是获取变量的值,就会使用RHS查询。

区分 LHS 和 RHS 的重要性

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

LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说他们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一次作用域,最后抵达全局作用域,无论找到或没找到都将停止。

借用一张图,将作用域链比喻成一个建筑,在对上面的论述进行一次转换。
作用域链

这个建筑代表储蓄中的嵌套作用域链。第一层楼代表当前的执行作用域,也就是你所在的位置。建筑的顶层代表全局作用域。

LHS 和 RHS 引用都会在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层楼,如果还是没有找到就继续向上,以此类推。一旦抵达顶层(全局作用域),可能找到了你所需的变量,也可能没找到,但无论如何查找过程都将停止。

### 总结: 不成功的RHS引用会导致抛出 ReferenceError 异常。不成功的LHS引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用LHS引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。