函数的作用域

625 阅读18分钟

一、内部原理

JavaScript拥有一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量,这套规则被称为作用域。作用域貌似简单,实则复杂,由于作用域与this机制非常容易混淆,使得理解作用域的原理更为重要

内部原理分成编译执行查询嵌套异常五个部分进行介绍,最后以一个实例过程对原理进行完整说明

1、编译(了解)

var a = 2;为例,说明javascript的内部编译过程,主要包括以下三步

【1】分词(tokenizing)

把由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)

  var a = 2;被分解成为下面这些词法单元:var、a、=、2、;。这些词法单元组成了一个词法单元流数组、

// 词法分析后的结果
[
    'var':"keyword";//关键字
    'a':"indentifier",//标识符
    '=':"assignment",//分配
    '2':"interger",//整数
    ';':"eos"(end of statement)//结束语句
]

【2】解析(parsing)

把词法单元流数组转换成一个由元素逐级嵌套所组成的代表程序语法结构的树,这个树被称为“抽象语法树” (Abstract Syntax Tree, AST)

var a = 2;的抽象语法树中有一个叫VariableDeclaration的顶级节点,接下来是一个叫Identifier(它的值是a)的子节点,以及一个叫AssignmentExpression的子节点,且该节点有一个叫Numeric literal(它的值是2)的子节点

{
  operation: "=",
  left: {
    keyword: "var",
    right: "a"
  }
  right: "2"
}

【3】代码生成

将AST转换为可执行代码的过程被称为代码生成

var a=2;的抽象语法树转为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将值2储存在a中。

实际上,javascript引擎的编译过程要复杂得多,包括大量优化操作,上面的三个步骤是编译过程的基本概述

  任何代码片段在执行前都要进行编译,大部分情况下编译发生在代码执行前的几微秒。javascript编译器首先会对var a=2;这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它

2、执行(重要)

简而言之,编译过程就是编译器把程序分解成词法单元(token),然后把词法单元解析成语法树(AST),再把语法树变成机器指令等待执行的过程。

实际上,代码进行编译,还要执行。下面仍然以var a = 2;为例,深入说明编译和执行过程

【1】编译

  1. 编译器查找作用域是否已经有一个名称为a的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。

  2. 编译器将var a = 2;这个代码片段编译成用于执行的机器指令

注意:依据编译器的编译原理,javascript中的重复声明是合法的

//test在作用域中首次出现,所以声明新变量,并将20赋值给test
var test = 20;
//test在作用域中已经存在,直接使用,将20的赋值替换成30
var test = 30;

【2】执行

  1. 引擎运行时会首先查询作用域,在当前的作用域集合中是否存在一个叫作a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。

  2. 如果引擎最终找到了变量a,就会将2赋值给它。否则引擎会抛出一个异常

3、查询

在引擎执行的第一步操作中,对变量a进行查询,这种查询叫做LHS查询。实际上,引擎查询分为了两种:LHS查询RHS查询

从字面意思去理解,当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询

更准确地讲,RHS查询与简单地查找某个变量的值没什么区别,而LHS查询则是试图找到变量的容器本身,从而可以对其赋值

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

这段代码中,总共包括4个查询,分别是:

  1、foo(…)对foo进行了RHS引用

  2、函数传参a = 2对a进行了LHS引用

  3、console.log(…)对console对象进行了RHS引用,并检查其是否有一个log的方法

  4、console.log(a)对a进行了RHS引用,并把得到的值传给了console.log(…)

4、嵌套

在当前作用域中无法中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或者是抵达最外层的作用域(全局作用域)为止。

function foo(a){
    console.log( a + b ) ;
}
var b = 2;
foo(2);// 4

在代码片段中,作用域foo()函数嵌套在全局作用域中。引擎首先在foo()函数的作用域中查找变量b,并尝试对其进行RHS引用,没有找到;接着,引擎在全局作用域中查找b,成功找到后,对其进行RHS引用,将2赋值给b。

5、异常

为什么区分LHS和RHS是一件重要的事情?因为在变量还没有声明(在任何作用域中都无法找到变量)的情况下,这两种查询的行为不一样

RHS

【1】如果RHS查询失败,引擎会抛出ReferenceError(引用错误)异常

//对b进行RHS查询时,无法找到该变量。也就是说,这是一个“未声明”的变量
function foo(a){
    a = b;  
}
foo();//ReferenceError: b is not defined

【2】如果RHS查询找到了一个变量,但尝试对变量的值进行不合理操作,比如对一个非函数类型值进行函数调用,或者引用null或undefined中的属性,引擎会抛出另外一种类型异常:TypeError(类型错误)异常

function foo(){
    var b = 0;
    b();
}
foo();//TypeError: b is not a function

LHS

【1】当引擎执行LHS查询时,如果无法找到变量,全局作用域会创建一个具有该名称的变量,并将其返还给引擎。

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

【2】如果在严格模式中LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS查询失败时类似的ReferenceError异常

function foo(){
    'use strict';
    a = 1;  
}
foo();
console.log(a);//ReferenceError: a is not defined

6、原理

function foo(a){    
    console.log(a);
    }
foo(2);

以上面这个代码片段来说明作用域的内部原理,分为以下几步:

【1】引擎需要为foo(…)函数进行RHS引用,在全局作用域中查找foo。成功找到并执行

【2】引擎需要进行foo函数的传参a=2,为a进行LHS引用,在foo函数作用域中查找a。成功找到,并把2赋值给a

【3】引擎需要执行console.log(…),为console对象进行RHS引用,在foo函数作用域中查找console对象。由于console是个内置对象,被成功找到

【4】引擎在console对象中查找log(…)方法,成功找到

【5】引擎需要执行console.log(a),对a进行RHS引用,在foo函数作用域中查找a,成功找到并执行

【6】于是,引擎把a的值,也就是2传到console.log(…)中

【7】最终,控制台输出2

二、词法作用域和动态作用域

大多数时候,我们对作用域产生混乱的主要原因是分不清楚应该按照函数位置的嵌套顺序,还是按照函数的调用顺序进行变量查找。再加上this机制的干扰,使得变量查找极易出错。这实际上是由两种作用域工作模型导致的,作用域分为词法作用域和动态作用域,分清这两种作用域模型就能够对变量查找过程有清晰的认识。

1、词法作用域

上章介绍过。编译器的第一个工作阶段叫分词,就是把由字符组成的字符串分解成词法单元。这个概念是理解词法作用域的基础。

简单地说,词法作用域就是定义在词法阶段的作用域,是由写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变

关系

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定

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

在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含的气泡。

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的

  气泡1包含着整个全局作用域,其中只有一个标识符:foo

  气泡2包含着foo所创建的作用域,其中有三个标识符:a、bar和b

  气泡3包含着bar所创建的作用域,其中只有一个标识符:c

查找

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置

在代码片段中,引擎执行console.log(…)声明,并查找a、b和c三个变量的引用。它首先从最内部的作用域,也就是bar(…)函数的作用域开始查找。引擎无法在这里找到a,因此会去上一级到所嵌套的foo(…)的作用域中继续查找。在这里找到了a,因此引擎使用了这个引用。对b来讲也一样。而对c来说,引擎在bar(…)中找到了它

[注意]词法作用域查找只会查找一级标识符,如果代码引用了foo.bar.baz,词法作用域查找只会试图查找foo标识符,找到这个变量后,对象属性访问规则分别接管对bar和baz属性的访问

var foo = {
    bar:{
        baz: 1
    }
};
console.log(foo.bar.baz);//1

遮蔽

作用域查找从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止

在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”,内部的标识符“遮蔽”了外部的标识符

var a = 0;
function test(){
    var a = 1;
    console.log(a);//1
}
test();

全局变量会自动为全局对象的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问

var a = 0;
function test(){
    var a = 1;
    console.log(window.a);//0
}
test();

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到

2、动态作用域

javascript使用的是词法作用域,它最重要的特征是它的定义过程发生在代码的书写阶段

  那为什么要介绍动态作用域呢?实际上动态作用域是javascript另一个重要机制this的表亲。作用域混乱多数是因为词法作用域和this机制相混淆,傻傻分不清楚

  动态作用域并不关心函数和作用域是如何声明以及在任何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套

var a = 2;
function foo() {
    console.log( a );
}
function bar() {
    var a = 3;
    foo();
}
bar();

【1】如果处于词法作用域,也就是现在的javascript环境。变量a首先在foo()函数中查找,没有找到。于是顺着作用域链到全局作用域中查找,找到并赋值为2。所以控制台输出2

  【2】如果处于动态作用域,同样地,变量a首先在foo()中查找,没有找到。这里会顺着调用栈在调用foo()函数的地方,也就是bar()函数中查找,找到并赋值为3。所以控制台输出3

  两种作用域的区别,简而言之,词法作用域是在定义时确定的,而动态作用域是在运行时确定的

三、声明提升

一般认为,javascript代码在执行的时候是由上到下一行一行执行的。但实际上这不完全正确,主要是因为声明提升的存在。

1、变量声明提升

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

直觉上,会认为undefined,因为var a声明在a=2;之后,可能变量被重新赋值了,因为会被赋予默认值的undefined。但是,真正的输出结果是2

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

鉴于上面的特点,可能会认为这个代码也会同样输出2。但是真正的输出结果是undefined

所有这些和观感相违背的原因位于编译器的编译过程

之前介绍过作用域的内部原理。引擎会在解释javascript代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将他们关联起来。

包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

var a = 2;

这个代码实际上包含两个操作:var a和a = 2;

第一个定义声明是在编译阶段由编译器进行的。第二个赋值操作会被留在原地等待引擎在执行阶段执行。

//对变量a的声明提升到最上面后,再执行代码时,控制台输出2
var a;
a = 2 ;
console.log(a);

声明从它们在代码中出现的位置被“移动”到了最上面,这个过程就叫作提升(hoisting)

注意:每个作用域都会进行提升操作

console.log(a);
var a = 0;
function fn(){
    console.log(b);
    var b = 1;
    function test(){
        console.log(c);
        var c = 2;
    }
    test();
}
fn();

//变量声明提升后,变成下面这样
var a ;
console.log(a);
a = 0;
function fn(){
    var b;
    console.log(b);
    b = 1;
    function test(){
        var c ;
        console.log(c);
        c = 2;
    }
    test();
}
fn();

2、函数声明提升

声明包含两种:变量声明和函数声明。不仅变量声明可以提升,函数声明也有提升操作

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

上面这个代码片段之所以能够在控制台输出1,就是因为foo()函数声明进行了提升,如下所示:

function foo(){
    console.log(1);
}
foo();

函数声明会提升,但函数表达式却不会提升

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

上面这段程序中的变量标识符foo被提升并分配给全局作用域,因此foo()不会导致ReferenceError。但是foo此时并没有赋值,foo()由于对undefined值进行函数调用而导致非法操作,因此会抛出TypeError异常

//变量提升后,代码如下所示:
var foo;
foo();
foo = function(){
    console.log(1);
}

即使是具名的函数表达式也无法被提升

foo();//TypeError: foo is not a function
var foo = function bar(){
      console.log(1);
};
**[注意]函数表达式的名称只能在函数体内部使用,而不能在函数体外部使用**
//声明提升后,代码变为:
var foo;
foo();//TypeError: foo is not a function
foo = function bar(){
      console.log(1);
};

var bar;
var foo = function bar(){
    console.log(1);
};
bar();//TypeError: bar is not a function

3、函数覆盖

函数声明和变量声明都会被提升。但是,函数声明会覆盖变量声明

var a;
function a(){};
console.log(a);//'function a(){}'

如果变量存在赋值操作,则最终的值为变量的值

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

var a;
function a(){};
console.log(a);//'function a(){}'
a = 1;
console.log(a);//1
**[注意]变量的重复声明是无用的,但函数的重复声明会覆盖前面的声明(无论是变量还是函数声明)**
【1】变量的重复声明无用
var a = 1;
var a;
console.log(a);//1

【2】由于函数声明提升优先于变量声明提升,所以变量的声明无作用

var a;
function a(){
    console.log(1);
}
a();//1

【3】后面的函数声明会覆盖前面的函数声明

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

总结:所以,应该避免在同一作用域中重复声明

四、理解什么是作用域和执行上下文环境

1、概念

1.1、作用域

作用域是一套规则,用来确定在何处以及如何查找标识符。

在js中作用域分为全局作用域和函数作用域,另外函数作用域可以互相嵌套

在下面的例子中,存在着全局作用域,fn作用域和bar作用域,他们相互嵌套

1.2、作用域链和自由变量

各个作用域的嵌套关系组成了一条作用域链。例子中bar函数的作用域链式bar->fn>全局,fn函数保存的作用域链式fn->全局

使用作用域链主要是进行标识符(变量和函数)的查询,标识符(变量和哈数)解析就是沿着作用域链一级一级地搜索标识符的过程,而作用域链就是保证对变量和函数的有序访问。

【1】如果自身作用域中声明该变量,则无需使用作用域链

在下面的例子中,如果要在bar函数中查询变量a,则直接使用LHS查询,赋值为100即可。

var a = 1;
var b = 2;
function fn(x){
    var a = 10;
    function bar(x){
        var a = 100;
        b = x + a;
        return b;
    }
    bar(20);
    bar(200);
}
fn(0);

【2】如果自身作用域中未声明该变量,则需要使用作用域链进行查找

这时,就引出了另一个概念——自由变量。在当前作用域中存在但未在当前作用域中声明的变量叫自由变量

  在下面的例子中,如果要在bar函数中查询变量b,由于b并没有在当前作用域中声明,所以b是自由变量。bar函数的作用域链是bar -> fn -> 全局。到上一级fn作用域中查找b没有找到,继续到再上一级全局作用域中查找b,找到了b。

var a = 1;
var b = 2;
function fn(x){
    var a = 10;
    function bar(x){
        var a = 100;
        b = x + a;
        return b;
    }
    bar(20);
    bar(200);
}
fn(0);

如果标识符找不到,则抛出ReferenceError(引用错误)异常。

1.3、执行环境

执行环境(execution context) 也叫执行上下文、执行上下文环境。每个执行唤醒都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。

一定要区分执行环境和变量对象。执行环境会随着函数的调用和返回,不断的重建和销毁。但变量对象在有变量引用的情况下,将留在内存中不被销毁。

代码执行到第15行是fn(0)函数的执行环境,执行环境里的变量对象保存了fn()函数作用域内所有的变量和函数的值

![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d5086bef6ec64081a02b0b87b4edf334~tplv-k3u1fbpfcp-zoom-1.image)

1.4、执行流

代码的执行顺序叫做执行流,程序源代码并不是按照代码的书写顺序一行一行往下执行,而是和函数的调用顺序有关

例子中的执行流是:第1行 ->第2行->第3行->第15行 ->第4行->第5行->第10行 ->第6行->第7行->第8行 ->第9行->第11行->第12行 ->第6行->第7行->第8行 ->第9行->第13行->第14行

1.5、执行环境栈

执行环境栈类似于作用域链,有序地保存着当前程序中存在的执行环境。当执行流进入一个函数时,函数的环境会被压入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。javascript程序中的执行流正是由这个机制控制。

注意:处于活动状态的执行上下文环境只有一个(下图绿色部分)

执行环境栈:其实就是一个压栈出栈的过程

在上面例子中,当执行流进入bar(20)函数时,当前程序的执行环境栈如下图所示,其中黄色的bar(20)表示执行上下文环境处于活跃状态

当bar(20)函数执行完成后,当前程序的执行环境栈如下图所示,bar(20)的执行环境被销毁,等待垃圾回收,控制权交给黄色背景的fn(0)执行环境

1.6、整个执行流程

2、总结

  1. 在javascript中,除了全局作用域外,每个函数都会创建自己的作用域,作用域在函数定义的时候就已经确定了,与函数是否被调用无关。通过作用域,可以知道作用域范围内的变量和函数有哪些,却不知道变量的值是什么。所以作用域是静态的。

  2. 对于函数来说,执行上下文环境在函数调用时确定的,执行上下文环境包含了作用域内所有的变量和函数的值。在同一个作用域下,不同的调用(如上面的bar(20)和bar(200)的调用)会产生不同的执行上下文环境,从而产生不同的变量和值。所以执行上下文环境是动态的