javascript作用域和闭包之我见

racy 2019-06-21

javascript作用域和闭包之我见

看了《你不知道的JavaScript(上卷)》的第一部分——作用域和闭包,感受颇深,遂写一篇读书笔记加深印象。路过的大牛欢迎指点,对这方面不懂的同学请绕道看书,以免误人子弟... 看过这本书的可以一起交流交流。

编译过程

理解js作用域首先要了解js的编译过程(或者说解析过程)。

  1. 引擎

    从头到尾负责整个 JavaScript 程序的编译及执行过程。
  2. 编译器

    引擎的好朋友之一,负责语法分析及代码生成等脏活累活(详见前一节的内容)。
  3. 作用域

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

都说node是基于chrome的V8引擎开发 的。那么V8是引擎,node是编译器吗?这个理解是错误的!我之前就是这么错误理解的,听说node是用C++实现的,之前我一直以为V8是负责把javascript语言转换成底层的C++,然后node很高级node负责编译,做js的语法检察,ES6的新特性全都是node的开发人员,一点点的开发支持起来的。然而现实是,V8包办了所有js编译的过程,而node只是一个环境。如nodejs.cn首页所说Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。 ,是运行环境!node只是在V8的基础上,做了终端命令行的支持、文件处理的支持、http服务的支持等等,相当于一个给V8提供了各种功能的壳子。

上面说的三点是包含关系,不是并行关系!引擎包含编译器,对js进行编译,然后根据作用域和语句执行不同的代码逻辑。

编译器的查询

我们将 var a = 2; 分解,看看引擎和它的朋友们是如何协同工作的。

编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编 译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。
可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内 存,将其命名为 a,然后将值 2 保存进这个变量。”然而,这并不完全正确。
事实上编译器会进行如下处理。

1.  遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的 集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作 用域的集合中声明一个新的变量,并命名为 a。
2.  接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2这个赋值 操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。

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

在我们的例子中,引擎会为变量 a 进行 LHS 查询。另外一个查找的类型叫作 RHS。

RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图 找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋 值操作的右侧”,更准确地说是“非左侧”。

你可以将RHS理解成retrieve his source value(取到它的源值),这意味着“得到某某的 值”。

怎么理解呢,我的理解是LHS 查询是查询变量的命名空间,然后进行赋值。RHS 查询是在作用域链中,一级级的往上查找该变量的引用

所以:

function foo(a) { 
   var b=a;
   return a + b; 
}
var c=foo(2);
  1. 找到其中所有的LHS查询。(这里有3处!)

  2. 找到其中所有的RHS查询。(这里有4处!)

LHS:var c=的赋值、foo(2)传参给foo(a)时的赋值、var b=的赋值
RHS:foo(2)函数调用时查找foo()方法、var b=a中a查找自己的值、a+b中a和b两个参数查找自己的值。

作用域和作用域链

作用域的概念,应该两张图几句话就能解释吧。

javascript作用域和闭包之我见

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

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

javascript作用域和闭包之我见

① 包含着整个全局作用域,其中只有一个标识符:foo。
② 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b。
③ 包含着 bar 所创建的作用域,其中只有一个标识符:c。
作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的。

我觉得,说一个变量属于哪个作用域,可以顾名思义用该变量生效的区域来解释,所以上图中的b变量,可以说属于bar()的函数作用域内,也可以说是foo()的函数作用域内,也可以说是全局作用域内。
一层嵌一层的作用域形成了作用域链,变量b在作用域链中的foo()函数内得到了自己的定义。

改变作用域

eval(..) 和 with 会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的 词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到 标识符。
但如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断 都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会 如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底 是什么。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认 为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

call()bind()之类的是改变作用域吗?他们只是改变了this的指向并不算改变作用域,是可以在编译阶段进行静态分析,所以不会导致上面说的无法优化的情况。

形成作用域

我们知道函数可以形成作用域,还有哪些方式形成作用域呢?

with

可以指定变量的作用域(选择一个对象),在它的块作用域内,变量就相当于这个对象的属性。

var obj={
    a: 1,
    b: 2,
    c:3
};
// 单调乏味的重复 "obj" 
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式 
with (obj) {
    a=3; 
    b=4; 
    c=5;
}

不被推荐,因为它会影响性能,且不易阅读(代码块内的代码特别多的情况,根本不知道这个是普通的变量还是某个对象的属性,还是某个对象的属性的属性的属性)。

try/catch

try {
    undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
    console.log( err ); // 能够正常执行! 
}
console.log( err ); // ReferenceError: err not found

做错误状态传参的err变量是当前块的局部变量。
但是如果在catch(err){…}内部var其它变量,并没有效果,见下面代码。

try {
    var abc='测试try块中的变量'
}
catch (err) {
    var b=2;    // 没有错误,不会被执行到的。
}
console.log( abc ); // 测试try块中的变量
try {
    throw '55';    // 制造一个异常
}
catch (err) {
    var abc='测试catch块中的变量';
}
console.log(abc);        // 测试catch块中的变量

这是只属于err参数用的伪块作用域。

let、const

ES6新特性,大神器。在{}中形成块作用域,且不会遇到提升 的问题出现。
为变量显式声明块作用域,有助于回收内存垃圾

function process(data) { 
    // 在这里做点有趣的事情
}

// 在这个块中定义的内容可以销毁了! (这里指的是下面let定义的`someReallyBigData`)
{
    let someReallyBigData = { .. }; 
    process( someReallyBigData );
}

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
         console.log("button clicked");
}, /*capturingPhase=*/false );

let有一个很有意思的地方,就是在for循环中。

for (let i=0; i<10; i++) {
    console.log( i );
}
console.log( i ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环 的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
下面通过另一种方式来说明每次迭代时进行重新绑定的行为:

{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // 每个迭代重新绑定!
        console.log( i );
    }
}

提升

编译器在解析作用域时,会对作用域中var声明的变量、函数进行提升

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

相当于

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

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

相当于

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

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。

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

相当于

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

闭包

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

function foo() {
    var a=2;
    function baz() { 
        console.log( a ); // 2
    }
    bar( baz ); 
}
function bar(fn) {
    fn(); // 妈妈快看呀,这就是闭包!
}

相关推荐