Skip to content

彻底搞懂JavaScript作用域 #3

Open
@prettyEcho

Description

@prettyEcho
Owner

BY 张建成(prettyEcho@github)

除非另行注明,页面上所有内容采用知识共享-署名(CC BY 2.5 AU)协议共享

🐬🐬 欢迎评论和star 🐳🐳

我们常说,万物都有其存在的价值,这话的确不错,但是深思一下,是不是需要有个前提,万物都在某些领域或多或少的存在某些价值

举个例子,汽车,绝对是个非常有价值的stuff,它给我们的日常出行,货物运输等带来了极大的便利;筷子,同样也是个非常有价值的stuff,它给我们吃饭带来了极大的方便。但是,汽车能帮我们把菜送到嘴里吗?筷子能载着我们出行吗?

那么,我上面所说的某些领域,我们是不是可以称其为作用域,我想是可以的。

说到这,那么我就想问了:在JS里,作用域是不是也是类似的概念呢?

首先,我可以肯定的说这是一个在JavaScript中灰常灰常重要的概念,关系着JS里很多核心的机制,理解它,很多问题都迎刃而解了。

那么,问问自己,在JS里,作用域是什么?

心里大概知道是什么,但是细细一想又好像说不太清。

没关系,下面我们就细细品味这个有意思的东东。

先throw概念吧:

作用域负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

通俗来说,作用域相当于一个管理员(有自己的一套规则),他负责管理所有声明的标识符的有序查询。

我们来讲个故事,说说作用域到底干了啥。

三兄弟齐上阵

long long ago,有3个关系很好的基友,老大叫引擎,老二叫编辑器,老三叫作用域。三兄弟眼看年岁已长,可手上还是没有几个银子。个个都很着急,于是三兄弟谋划一同做个事。

求职过程:此粗略去数万个字。。。

最终他们做的工作是:负责JS的编译和运行。

他们的工作内容是这样的:

老板甩给他们一项任务编译并执行下面代码:

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

开始工作:

  • 编译器:作用域,帮我看看你那有没有储存变量a。
  • 作用域:二哥,还没有。
  • 编译器:那好,帮我储存一个。
  • 引擎: 老三,你那有没有一个叫做a的变量。
  • 作用域:大哥,还真有,刚二哥让我存储了一个。
  • 引擎: 真是太好了,帮我拿出来,它的值是几,我需要给它复制。
  • 作用域:大哥,它的值是2。
  • 引擎: 谢谢你,三弟,这样我就能打印它的值了。

上面讲了一个不恰当的小故事,但是三者之间的关系大概就是这样。

词法作用域 VS 动态作用域

  • 词法作用域

原来JavaScript内部是这样运行的里介绍过,大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。回忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。

在JS里,使用的作用域就是词法作用域

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

  • 动态作用域

在JS里,动态作用域和this机制息息相关。它的作用域诗是在运行的过程中确定的

var a = 1;

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

foo(); // 1

从上面的代码,我们可以看出:foo中打印a的值不是由写代码的位置确定的,而是取决于foo执行的位置。

  • 区别

    • 词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)
    • 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

函数作用域

JS里,生成作用域的方式:

  • 函数
  • with、eval (不建议使用,影响性能)

由此,我们知道JS里,绝大多数的作用域都是基于函数生成的

每个函数都会为自身生成一个作用域气泡。这个气泡内所有的标识符都可以在这个气泡中使用。

function bar() {
    var a = 1;

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

    foo();

    console.log(a);
}

bar();

上面代码,bar气泡有标识符a、foo,因此在bar气泡中可以访问到a、foo; foo气泡有标识符b,因此在foo气泡中可以访问到b; 当然还有一个全局气泡,全局气泡中有bar标识符,因此在全局气泡中可以访问到bar。

最小授权原则

最小授权原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作 用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小 特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确 的代码应该是可以阻止对这些变量或函数进行访问的。

例如:

function doSomething(a) {
        
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );

}

function doSomethingElse(a) { 
    return a - 1;
}

var b;
doSomething( 2 ); // 15

在这个代码片段中,变量 b 和函数 doSomethingElse(..) 应该是 doSomething(..) 内部具体 实现的“私有”内容。给予外部作用域对 b 和 doSomethingElse(..) 的“访问权限”不仅 没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用, 从而导致超出了 doSomething(..) 的适用条件。更“合理”的设计会将这些私有的具体内容隐藏在 doSomething(..) 内部,

例如:

function doSomething(a) { 

    function doSomethingElse(a) {
        return a - 1; 
    }

    var b;
    
    b = a + doSomethingElse( a * 2 );

    console.log( b * 3 );
}
doSomething( 2 ); // 15

现在,b 和 doSomethingElse(..) 都无法从外部被访问,而只能被 doSomething(..) 所控制。 功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会 依此进行实现。

规避冲突

当我们的程序代码逐渐多起来,难免会出现变量冲突。那么如何规避冲突就显得额外重要。

函数可以把标识符严谨的"隐藏"起来,外部无法访问到,利用这个特性我们可以很好的规避冲突。

function foo() {
    var a = 1;
}

function bar() {
    var a = 2;
}

foo和bar中定义了相同的变量a,但是却不会相互造成影响。因为函数可以很好的把标识符"隐藏"起来。

  • 全局命名空间

变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它 们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属 性,而不是将自己的标识符暴漏在顶级的词法作用域中。

例如:

var myLibrary = {
    name: 'echo',
    getName: function() {
        console.log( this.name );
    }
}

函数声明 VS 函数表达式

函数声明和函数表达式判别的依据是:函数的声明是否以function关键词开始
以关键词function 开始的声明是函数声明,其余的函数声明全部是函数表达式。

//函数声明
function foo() {

}

//函数表达式
var foo = function () {

};

(function() {

})();

具名函数 VS 匿名函数

  • 具名函数
    拥有名字的函数

    function foo() {
    
    }
    
    var foo = function bar() {
    
    }
    
    setTimeout( function foo() {
    
    } )
    
    +function foo() {
    
    }();
    

需要注意:函数声明一定要是具名函数

  • 匿名函数
    没有名字的函数

    var foo = function () {
    
    }
    
    setTimeout( function foo() {
    
    } )
    
    -function foo() {
    
    }();
    

立即执行函数(IIFE)

vara=2;

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

console.log( a ); // 2

该函数是以()开始,不是以关键词function开始,因此IIFE是函数表达式

函数名对 IIFE 当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式。虽然使 用具名函数的 IIFE 并不常见,但它具有以下优势:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。

因此具名函数的 IIFE 也是一个值得推广的实践。

  • 另一种表达形式
(function() {

}())

这也是IIFE的一种表达方式,功能上和上面那种方式是一致的。选择哪种全凭个人爱好

  • 参数传递

IIFE 也可以和其他形式的函数一样实现参数的传递(多说一句:参数传递是按值传递)。

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

这个模式的另外一个应用场景是解决 undefined 标识符的默认值被错误覆盖导致的异常(虽 然不常见)。将一个参数命名为 undefined,但是在对应的位置不传入任何值,这样就可以 保证在代码块中 undefined 标识符的值真的是 undefined:

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做! 
(function IIFE( undefined ) {
    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }
})();
  • UMD (Universal Module Definition)

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。尽管这种模式略显冗长,但有些人认为它更易理解。

var a=2;

(function IIFE( def ) { 
    //参数的处理
    def( window );
})(function def( global ) {
    //逻辑运算
    var a=3;
    console.log( a ); // 3 
    console.log( global.a ); // 2
});

块作用域

尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计 方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可 以实现维护起来更加优秀、简洁的代码。

  • try...catch
    非常少有人会注意到 JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域, catch 的参数变量仅在 catch 内部有效。
try{
    throw undefined;
}catch(a){
    a = 2;
    console.log(a); // 2
}
console.log(a);  // ReferenceError
  • let

ES6的标准使我们能够简单的创建块作用域,其中一个变量定义方式是let关键词定义。

let定义的变量具有以下的特点:

  1. let隐形的创建块作用域({...})
  2. let声明的变量不能进行变量提升,因此只能先定义,后使用
{
    let a = 1;
    console.log(a); // 1
}
console.log(a);  // ReferenceError

let一个典型的应用就是在for循环里

我们看下面两个例子:

// 每秒输出一个5
for( var i = 0; i < 5 ; i++ ) {
    setTimeout(() => {
        console.log( i );
    }, i *1000)
}

// 依次输出0,1,2,3,4,时间间隔位1秒
for( let i = 0; i < 5 ; i++ ) {
    setTimeout(() => {
        console.log( i );
    }, i *1000)
}

其原因就是let形成了5个块作用域,使每次输出的变量都从本次循环的块作用域中获取。

当然我们还可以有其他方式做到第二种效果,我们将在 闭包,是真的美中说道。

  • const

除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的 (常量)。之后任何试图修改值的操作都会引起错误。

var foo = true;
if (foo) { 
    var a=2;
    const b = 3; // 包含在 if 中的块作用域常量

    a=3;//正常!
    b=4;//错误! 
}

console.log( a ); // 3
console.log( b ); // ReferenceError!

作用域链

作用域链是由当前作用域与上层一系列父级作用域组成,作用域的头部永远是当前作用域,尾部永远是全局作用域。作用域链保证了当前上下文对其有权访问的变量的有序访问。

var a = 2;

function bar() {

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

    foo();
}
bar(); // 2

上面代码是由3层作用域气泡组成,foo气泡中试图打印变量a,引擎在foo气泡中未找到a变量,于是去其父作用域气泡bar中寻找...以此类推直到找到全局作用域气泡,发现有变量a,将其值打印出来。如若没找到,报ReferenceError错误。

Activity

changyuqing

changyuqing commented on Apr 3, 2018

@changyuqing

编译器:作用域,帮我看看你那有没有储存变量a。
作用域:二哥,还没有。
编译器:那好,帮我储存一个。
引擎: 老三,你那有没有一个叫做a的变量。
编译器:大哥,还真有,刚二哥让我存储了一个。
引擎: 真是太好了,帮我拿出来,它的值是几,我需要给它复制。
编译器:大哥,它的值是2。
引擎: 谢谢你,三弟,这样我就能打印它的值了。

这一段后面两个编译器应该是作用域吧

snachx

snachx commented on Apr 3, 2018

@snachx

foo气泡有标识符b,因此在bar气泡中可以访问到b --> foo气泡有标识符b,因此在foo气泡中可以访问到b

prettyEcho

prettyEcho commented on Apr 4, 2018

@prettyEcho
OwnerAuthor

一楼的哥们,多谢😂

zlxbuzz

zlxbuzz commented on Apr 4, 2018

@zlxbuzz

函数的生命是否以function关键词开始 应该是声明

prettyEcho

prettyEcho commented on Apr 4, 2018

@prettyEcho
OwnerAuthor

十分感谢,🙏🙏🙏

zhanwang626

zhanwang626 commented on Aug 9, 2019

@zhanwang626

这不是You Don't Know JS么

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @snachx@zlxbuzz@zhanwang626@changyuqing@prettyEcho

        Issue actions

          彻底搞懂JavaScript作用域 · Issue #3 · prettyEcho/deep-js