Loading

你不知道的JavaScript——作用域和闭包

原书《你不知道的JavaScript》

解释型 or 编译型

JavaScript早已不是以前的JavaScript了,早期的JS确实仅仅是生成AST(抽象语法树)然后直接解释执行。但随着JS的应用越来越广,不仅仅局限于浏览器端,所以早先的解释执行已经不能满足大家对它的性能需求了,chrome v8的出现已经让JS可以被编译为本地机器码了,所以JS代码的执行过程大概如下:

  • parse:引擎将代码转换为抽象语法树
  • ignition: 生成字节码
  • turbofan: 将字节码转换为机器码
  • orinoco: 垃圾回收

所以,现在说JS是一门编译型语言也不为过,只是它的编译过程通常在执行前几毫秒。

PS:本书中作者所说的“编译”在我看来都是解释的过程,对于作者为什么说成是编译,我还是表示不解,但我还是保留了作者的说法

一行声明的执行过程

var a = 10;
  1. 遇到var a时,编译器会询问当前作用域中是否已经有一个a变量了。如果有,则忽略var a;如果没有,则会在当前作用域中创建一个a变量。
  2. 接下来去解析a = 10,解析这行时,编译器会在当前作用域下寻找a,并把值10传递给a,这次寻找显然是成功的,因为刚刚var a已经定义了这个a,但如果没找到,则会向上级作用域继续寻找,并重复刚才的过程,如果最顶级作用域没有找到a,则抛出异常。

所以我也能理解实训课的时候瓜子老师写的:

var a = 10;
var a = 20;

为啥不会出错了......

LHS和RHS

一言以蔽之,LHS用于做赋值操作,RHS用于做查询变量的值的操作,只不过它们是编译过程中的术语。你可以简单地通过L(左)HS和R(右)HS来记住。

a = 10使用了一次LHS,而console.log(a)则使用了一次RHS。

作用域

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

该代码能执行成功,并且结果是4,但foo中实际并没有b这个变量,但通过上面的的内容也能知道,LHS和RHS都会先搜索当前作用域,之后在去搜索上一级作用域,直到搜索到最顶层。而上面foo中的代码暗含了两个RHS操作,即取ab,前者刚好在当前作用域中能够找到,后者则可以在父级作用域中找到。

异常

在要找寻的变量没有声明时,即作用域链中找不到这个变量,LHS和RHS的行为是有区别的。

当LHS在作用域链中找不到一个变量时,会在作用域链的最顶层创建这个变量(需要程序运行在非严格模式下)。

当严格模式下的LHS在作用域链中找不到一个变量时,会抛出一个ReferenceError。

而RHS在作用域链中找不到一个变量时,会抛出一个ReferenceError。

当对RHS的的变量值进行不合理的操作时,比如对一个int值作为函数进行调用,则会抛出一个TypeError。

二级标识符

二级标识符由对象接管,而不是词法分析阶段处理。

foo.barz,这里的foo由词法分析处理,而barz由对象属性访问规则接管。

欺骗词法分析器——eval

function foo(str, a) {
    eval( str );
    console.log( a, b );
}

这里的代码本应有误,但如果参数str是一段和变量b的定义相关的代码字符串,则该代码就不会出错。

foo("var b=12",2); // 2 12

但在严格模式下,eval并不会改变它运行时所在的作用域,而是有自己单独的作用域。

同时setIntervalsetTimeout的第一个参数都可以是字符串,但好像已经没看有人用了,也不推荐使用。我看瓜子老师用之前都不知道有这个玩法。

new Function也可以通过字符串的形式来声明其中的代码。也应该避免使用。

with

with可以消除一些重复的引用,和python中的with不是一回事,python中的with更像java中的try-with-resource。

如果你有这样的代码

var elem = document.getElementById(...)
var elem2 = document.getElementById(...)

你不想写那么多重复的document.xxx,那你可以用with

with(document){
    var elem = getElementById(...)
    var elem2 = getElementById(...)
}

确实,在对同一个对象疯狂操作的时候使用这个确实挺爽。它也允许对对象中的变量进行修改,但会造成一些问题。

function foo(obj) {
    with (obj) {
        a = 2;
    }
}
var o1 = {
    a: 3
};
var o2 = {
    b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了

问题的来源在于非严格模式下的LHS,之前说了LHS的行为就是当前作用域找不到就一直向上找,如果到了顶层还找不到就创建一个。

所以使用with,如果不小心,很有可能把一个关键的东西泄露到全局作用域上。

严格模式下的with被完全禁止使用,下面是使用严格模式运行上面的代码得到的错误:

➜  js node js01-scope.js
/home/lilpig/js/js01-scope.js:3
    with (obj) {
    ^^^^

SyntaxError: Strict mode code may not include a with statement

性能

如果evalwith等功能在给你带来便利的同时你有能忍受它对作用域带来的污染,或者你足够小心让它们根本不做出污染作用域的举动,那使用它们就没什么问题了吗?

问题还是有的,编译时编译器并不会理会你那些用字符串编写的屌毛代码,所以一切编译器优化对它们都无效,我至今也没遇到什么需求是必须要用evalwith这种东西来完成的,确实,它们有时会让程序更灵活,但也会让程序跑得更慢,所以尽量避免这些写法吧。

隐藏细节

函数的功能除了抽出重复代码复用之外,还有可以对外部可以隐藏函数的实现细节。

编程中有一个好的习惯是“最小特权”,也就是说在你写一个模块时,最好只暴露出最少的,不得不暴露的细节给外部,而其他内容则隐藏。JS中的函数就可以用来隐藏细节。

像如下的代码,根本看都不用看,就是个失败案例...太多公有的变量和函数让整个逻辑显得乱七八糟,而且很可能一个不小心就酿成大错,即使你小心翼翼的写,你怎么确保用户不去修改你的公共资源?

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和调用者又有何关呢?

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

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

    console.log(b * 3);
}

doSomething(2); // 15

虽然也有一些问题,但这样写好多了,至少暴露出来给用户的东西被合理的隐藏了。

规避冲突

开发的时候要引入大量第三方库,其中难免有很多命名都是一样的,这就会造成冲突,JS中规避冲突的主要方式就是利用作用域。

大部分第三方库都会在全局命名空间中提供一个独特的名字,然后它用到的所有属性都挂在这个命名空间下,例如jquery$

如下是一个实例,只有MyReallyCoolLibrary这个名字被放到全局命名空间中了,其他的属性都要通过MyReallyCoolLibrary.xxx访问。

var MyReallyCoolLibrary = {
    awesome: "stuff",
    doSomething: function() {
    },
    doAnotherThing: function() {
    }
};

使用函数也可以让其中的所有变量都不会泄漏到全局命名空间

function foo(){
    var a = 10;
    function doSomething(){}
}

foo();

它的缺点是我们需要在后面显式地通过foo()调用它,而且实际上foo本身也污染了全局命名空间。

JS提供了一种方式解决这两个问题,而且在已经投放到生产环境中的代码里很常见:

(function foo(){
    var a = 10;
    function doSomething(){}
})()

使用()包裹的函数叫做行内函数表达式,这样写这个foo不会污染到全局空间,而且后面的一对括号保证它立即执行。

如果你觉得某个全局作用域中的变量命名不合乎自己的想法,甚至可以这样:

(function foo(global){
    global.xxx();
})(window);

setTimeout也同理,不会污染全局作用域,只不过它会被setTimeout调用。

setTimeout(function timeHandler(){
    doSomething();
},2000);

匿名和具名

上面的写法估计大家都见过,但是很少有人会在那么用的时候还给函数取名,大多都是直接使用匿名函数。但具名函数的函数名会在错误堆栈中显示,而匿名函数会在排错时让人头秃,所以推荐还是都写上。并且没有函数名,引用自身的时候很费劲。

块作用域

function pow2(num){
    i = 3;
    return num * num;
}
for( var i = 0 ; i < 10 ; i++ ){
    pow2(i);
}

上面的代码会陷入死循环...

因为var声明的变量并不在块作用域中,而是在和for同级的作用域中,那也就和pow2同级,而对于pow2中的代码则是父级作用域,所以pow2中的i=3实际上就是修改了父级作用域中的i,也就是循环变量i,它被固定在3了。

ES6中的letconst关键字解决了这个问题,这两个关键字会将变量的作用域限定在块内,这使得JS也有了其他语言类似的块作用域。

function pow2(num){
    i = 3;
    return num * num;
}
for( let i = 0 ; i < 10 ; i++ ){
    pow2(i);
}

这样就好了。

提升

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

这段代码会输出什么呢?显然是2。

但它的执行过程是什么呢?

我之前的错误思路是这样的,我估计也是大部分人的错误思路:

  1. a=2,LHS,但因为没有a,所以顶层作用域中会自动创建一个a,并赋值2
  2. var a,因为已经有a了,忽略
  3. 输出2

其实不是这样的,JS的编译分为两个阶段,一是找出所有定义,包括变量定义和方法定义,再建立作用域,把变量放到自己的作用域中,这个步骤被称为提升 (注意,这里只是定义操作会提升!!赋值操作会原地等待)。 二才是和其他的代码相关的编译和执行操作。

所以正确的步骤应该是:

  1. var a,定义a
  2. a = 2,赋值
  3. 输出2
console.log( a );
var a = 2; 

这段代码的输出就应该是undefined,它的执行过程为:

  1. var a
  2. console.log(a)
  3. a=2

需要注意的是,函数表达式不会被提升:

// 1.js
// 这个函数会被提升
function foo(){

}

// 2.js
foo(); // TypeError
// var foo 会被提升,但这个函数不会,所以上面调用时,foo还没被赋值
var foo = function(){

}

在提升过程中,函数总是优先的,也就是说当有同名的函数定义和变量定义时,函数总是会覆盖变量。如下,无论你如何调整顺序,你能访问到的总是一个function。

var foo;
function foo(){}
console.log(foo);// [Function:foo]



function foo(){}
var foo;
console.log(foo);// [Function:foo]

不过这个理论只是用来学习,这个特性无论何时都不应该使用,不应该定义任何模棱两可的,让人摸不着头脑的东西。

闭包

咦~我的代码到处都是闭包了,可是我不认识......

所谓闭包,就是函数已经脱离它所在的作用域去执行了,但它还依然能访问它之前所在的作用域。

function foo(){
    var name = "Julia";
    function bar(){
        console.log("Hello,"+name);
    }

    return bar;
}

var bar = foo();
bar();

这就是闭包,foo返回了一个函数,调用这个函数时,显然已经不在foo内了,但foo内的变量name依然可以访问,没有被销毁。

这玩意儿能干啥呢?这是我之前零零散散学js的时候对闭包最大的疑问,这么写了,有啥用呢?

我把代码稍微改造一下,改造成你能认识的:

function MyModule(){
    var name = "Julia";
    function bar(){
        console.log("Hello,"+name);
    }

    return {
        sayHello: bar
    };
}

var module = MyModule();
module.sayHello();

这不就是一个简单的模块系统吗???在这里,我们通过return,返回一个对象,通过这个对象对bar函数进行重命名,让它更能被调用者理解。所以return中的东西就是该模块暴露的API,它暴露出去的所有方法都可以使用闭包,也就是都能够访问MyModule中的全部变量。

继续修改

function MyModule(name){
    function sayHello(){
        console.log("Hello,"+name);
    }

    return {
        sayHello
    };
}

var module = MyModule("Julia");
module.sayHello();

这样,让name属性由外部传入,并且修改了语义不明的bar函数。

现在它已经有内味儿了~~~

闭包不一定是通过返回值返回一个内部的函数,任何能够让函数记住它之前的内部作用域的做法都是闭包。

这是闭包

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log( a );
    }
    fn = baz; // 将 baz 分配给全局变量
}
function bar() {
    fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

这也是闭包,timer实际是被setTimeout所执行,但却能访问当前作用域中的循环变量i

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

这是一个经典的JS问题,大家都知道,输出的结果全部都是10,而我们预期的是每隔一秒顺序输出0~9

这个问题的解释也很简单,因为setTimeout有一个延时时间,并且它又不会阻塞,也就是它的延时不影响其他代码执行,所以当它真正等待延时完毕开始执行timer时,循环变量i早已变成10了,循环早就停止了,但是由于闭包,timer仍然能访问到这个变量,所以,它们访问到的都是10

解决的办法很简单,想办法给这十次循环提供单独的作用域即可,每次一个,而函数恰好能提供这个单独的作用域。

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

这个问题在ES6中还有更简单的解决办法:

for(let i=0;i<10;i++){
    setTimeout(function timer(){
        console.log(i);
    },i*1000);
}

只需要把循环变量的var改成let即可。

let可以把作用域绑定在块级,而var不能。也就是说,对于这个let,每次循环都是一个新的作用域。

具体区别就是:

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


{
    let a = 20;
}
console.log(a); // ReferenceError

const也可以达到类似的效果,只不过const用于声明常量。

闭包最佳实践——设计模块管理系统

var ModuleManager = (function(){
    var modules = {};
    function define(name,deps,impl){
        for(var i = 0; i < deps.length; i++){
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl,deps);
    }

    function get(name){
        return modules[name]

    }

    return{
        define,
        get
    }
})();

这是一个简单的模块系统,它内部维护一个modules,管理所有的模块。

define用于定义一个模块,name参数是模块名,每个模块都必须要定义一个唯一的模块名,deps是一个模块名列表,里面包含当前模块所依赖的模块,列表中的模块必须已经定义在modules中了,impl是这个模块的实现,是一个函数,这个函数会返回它的api,就像我们上面的写法。

define方法的第一个循环用于将由模块名字符串组成的依赖列表转换为真正由一个个模块组成的依赖列表,最后一行,apply的功能是调用这个模块(因为模块是一个函数嘛),并将依赖作为参数传入到这个模块中,你可以看成是注入依赖。

下面使用这个模块系统创建一个门禁打卡的简单项目,虽然看起来有点小题大做,但这是理解使用闭包构建模块系统的好例子

我们定义了两个模块,一个是speaker,它是门禁系统上面的喇叭,另一个是checkInDevice,它是门禁打卡的设备,这个设备有一个喇叭,每个员工来打卡,喇叭都会跟他说一句“你好”。

所以checkInDevice依赖speaker模块。

ModuleManager.define('speaker',[],function(){
    function speak(message){
        if(typeof message == 'string'){
            console.log(message);
        }
    }
    return {
        speak
    }
})


ModuleManager.define('checkInDevice',['speaker'],function(speaker){
    function checkIn(employee){
        speaker.speak('Hello, ' + employee);
    }
    return {
        checkIn
    }
});

var checkInDevice = ModuleManager.get('checkInDevice');
checkInDevice.checkIn('Julia');

当前的模块系统

参考CommonJs中的module.exports或ES6中的importexport

它们的模块系统并不使用函数来构建,而是其他方法,更加强大。

参考

posted @ 2021-07-12 12:51  yudoge  阅读(74)  评论(0编辑  收藏  举报