yygmind / blog

我是木易杨,公众号「高级前端进阶」作者,跟着我每周重点攻克一个前端面试重难点。接下来让我带你走进高级前端的世界,在进阶的路上,共勉!
https://muyiy.cn/blog/
10.51k stars 1.11k forks source link

精华提炼「你不知道的 JavaScript」之作用域和闭包 #30

Open yygmind opened 5 years ago

yygmind commented 5 years ago

第1章 作用域是什么

1.1 编译原理

JavaScript语言是“动态”或“解释执行”语言,但事实上是一门编译语言。但它不是提前编译的,编译结果也不能在分布式系统中移植。

传统编译语言流程中,程序在执行之前会经历三个步骤,统称为“编译”。

1.2 理解作用域

1.2.1 演员表
1.2.2 对话

var a = 2;存在2个不同的声明。

st=>start: Start
e=>end: End
cond=>condition: 当前作用域存在变量a?
cond2=>condition: 全局作用域?
op1=>operation: 引擎使用这个变量a
op2=>operation: 引擎向上一级作用域查找变量a
op3=>operation: 引擎把2赋值给变量a
op4=>operation: 举手示意,抛出异常
st->cond
cond(yes)->op1->op3->e
cond(no)->cond2(no)->op2(right)->cond
cond2(yes)->op4->e
1.2.3 LHS和RHS查询

LR分别代表一个赋值操作的左侧和右侧,当变量出现在赋值操作的左侧时进行LHS查询,出现在赋值操作的非左侧时进行RHS查询。

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

foo(2);

上述代码共有1处LHS查询,3处RHS查询。

1.3 作用域嵌套

遍历嵌套作用域链的规则:引擎从当前的执行作用域开始查找变量,如果找不到就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没有找到,查找过程都会停止。

1.4 异常

ReferenceError和作用域判别失败相关,TypeError表示作用域判别成功了,但是对结果的操作是非法或不合理的。

1.5 小结

var a = 2被分解成2个独立的步骤。

第2章 词法作用域

2.1 词法阶段

词法作用域是定义在词法阶段的作用域,是由写代码时将变量和块作用域写在哪里来决定的,所以在词法分析器处理代码时会保持作用域不变。(不考虑欺骗词法作用域情况下)

2.1.1 查找

2.2 欺骗词法

欺骗词法作用域会导致性能下降。以下两种方法不推荐使用

2.2.1 eval

eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

function foo (str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}

var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

eval('var b = 3')会被当做本来就在那里一样来处理。

function foo (str) {
    "use strict"; 
    eval( str ); 
    console.log( a ); // ReferenceError: a is not defined
}

foo( "var a = 2;" ); 
2.2.2 with

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;
}

with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,这个对象的属性会被处理为定义在这个作用域中的词法标识符。

这个块内部正常的var声明并不会被限制在这个块的作用域中,而是被添加到with所处的函数作用域中。

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被泄露到全局作用域上了!

上面例子中,创建了o1o2两个对象。其中一个有a属性,另一个没有。在with(obj){..}内部是一个LHS引用,并将2赋值给它。

2.2.3 性能

2.3 小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。

编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

有以下两个机制可以“欺骗”词法作用域:

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

第3章 函数作用域和块作用域

3.1 函数中的作用域

属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

function foo(a) {
    var b = 2;

    // 一些代码

    function bar() {
        // ...
    }

    // 更多的代码

    var c = 3;
}

foo(..)作用域中包含了标识符(变量、函数)a、b、c和bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处的作用域。

全局作用域只包含一个标识符:foo

3.2 隐藏内部实现

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

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

    var b;

    b = a + doSomethingElse( a * 2 );

    console.log( b * 3 );
}

doSomething( 2 ); // 15

bdoSomethingElse(..)都无法从外部被访问,而只能被doSomething(..)所控制,设计上将具体内容私有化了。

3.2.1 规避冲突

”隐藏“作用域中的变量和函数带来的另一个好处是可以避免同名标识符之间的冲突。

function foo() {
    function bar(a) {
        i = 3; // 修改for循环所属作用域中的i
        console.log( a + i );
    }

    for (var i = 0; i < 10; i++) {
        bar( i * 2 ); // 糟糕,无限循环了!
    }
}
foo();

bar(..)内部的赋值表达式i = 3意外的覆盖了声明在foo(..)内部for循环中的i。

解决方案:

规避变量冲突的典型例子:

3.3 函数作用域

var a = 2;

function foo() { // <-- 添加这一行

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

} // <-- 以及这一行
foo(); // <-- 以及这一行

console.log( a ); // 2

上述函数作用域虽然可以将内部的变量和函数定义”隐藏“起来,但是会导致以下2个额外问题。

解决方案:

var a = 2;

(function foo(){ // <-- 添加这一行

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

})(); // <-- 以及这一行

console.log( a ); // 2

上述代码包装函数的声明以(function...开始,函数会被当做函数表达式而不是一个标准的函数声明来处理。

3.3.1 匿名和具名
setTimeout( function() {
    console.log("I wait 1 second!");
}, 1000 );

上述是匿名函数表达式,因为function()..没有名称标识符。

函数表达式可以匿名,但函数声明不可以省略函数名。

匿名函数表达式有以下缺点:

解决方案:

行内函数表达式可以解决上述问题,始终给函数表达式命名是一个最佳实践。

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
    console.log( "I waited 1 second!" );
}, 1000 );
3.3.2 立即执行函数表达式

立即执行函数表达式(IIFE,Immediately Invoked Function Expression)

3.4 块作用域

表面上看JavaScript并没有块作用域的相关功能,除非更加深入了解(with、try/catch 、let、const)。

for (var i = 0; i < 10; i++) {
    console.log( i );
}

上述代码中i会被绑定在外部作用域(函数或全局)中。

var foo = true;

if (foo) {
    var bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

上述代码中,当使用var声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。

3.4.1 with

块作用域的一种形式,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

3.4.2 try/catch

ES3规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch中有效。

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

console.log( err ); // ReferenceError: err not found

当同一个作用域中的两个或多个catch分句用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告,实际上这并不是重复定义,因为所有变量都会安全地限制在块作用域内部。

3.4.3 let

ES6引入了let关键字,可以将变量绑定到所在的任意作用域中(通常是{ .. }内部),即let为其声明的变量隐式地劫持了所在的块作用域。

var foo = true;

if (foo) {
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

console.log( bar ); // ReferenceError

存在的问题

let将变量附加在一个已经存在的的块作用域上的行为是隐式的,如果习惯性的移动这些块或者将其包含在其他的块中,可能会导致代码混乱。

解决方案

为块作用域显示地创建块。显式的代码优于隐式或一些精巧但不清晰的代码。

var foo = true;

if (foo) {
    { // <-- 显式的块
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}

console.log( bar ); // ReferenceError

在if声明内部显式地创建了一个块,如果需要对其进行重构,整个块都可以被方便地移动而不会对外部if声明的位置和语义产生任何影响。

3.4.4 const

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!

第4章 提升

a = 2;

var a;

console.log( a ); // 2

---------------------------------------
// 实际按如下形式进行处理
var a; // 编译阶段

a = 2; // 执行阶段

console.log( a ); // 2
console.log( a ); // undefinde

var a = 2;

---------------------------------------
// 实际按如下形式进行处理
var a; // 编译

console.log( a ); // undefinde

a = 2; // 执行
function foo() {
    var a;

    console.log( a ); // undefinde

    a = 2;
}

foo();
foo(); // 不是ReferenceError,而是TypeError!

var foo = function bar() {
    // ...
};

上面这段程序中,变量标识符foo()被提升并分配给所在作用域,因此foo()不会导致ReferenceError。此时foo并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值),foo()由于对undefined值进行函数调用而导致非法操作,因此抛出TypeError异常。

foo(); // TypeError
bar(); // ReferenceError

var foo = function bar() {
    // ...
};

---------------------------------------
// 实际按如下形式进行处理
var foo;

foo(); // TypeError
bar(); // ReferenceError

foo = function() {
    var bar = ...self...
    // ...
};

4.1 函数优先

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 ); 
};
foo(); // 3

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

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

function foo() {
    console.log( 3 ); 
};
foo(); // "b"

var a = true;
if (a) {
    function foo() { console.log( "a" ); };
}
else {
    function foo() { console.log( "b" ); };
}

第5章 作用域闭包

5.1 闭包

function foo() {
    var a = 2;

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

    return bar;
}

var baz = foo();

baz(); // 2 ---- 这就是闭包的效果

bar()在自己定义的词法作用域以外的地方执行。

bar()拥有覆盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用,不会被垃圾回收器回收

// 对函数类型的值进行传递
function foo() {
    var a = 2;

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

    bar( baz );
}

function bar(fn) {
    fn(); // 这就是闭包
}

foo();
// 间接的传递函数
var fn;

function foo() {
    var a = 2;

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

    fn = baz; // 将baz分配给全局变量
}

function bar() {
    fn(); // 这就是闭包
}

foo();
bar(); // 2
function wait(message) {

    setTimeout( function timer() {
        console.log( message );
    }, 1000 );
}

wait( "Hello, closure!" );
// 典型的闭包例子:IIFE
var a = 2;

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

5.2 循环和闭包

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

//输入五次6

尝试方案1:使用IIFE增加更多的闭包作用域

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

//失败,因为IIFE作用域是空的,需要包含一点实质内容才可以使用

尝试方案2:IIFE增加变量

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

// 正常工作

尝试方案3:改进型,将i作为参数传递给IIFE函数

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

// 正常工作
5.2.1 块作用域和闭包
for (var i = 1; i <= 5; i++) {
    let j = i; // 闭包的块作用域!
    setTimeout( function timer() {
        console.log( j );
    }, j * 1000 );
}

// 正常工作

上面这句话参照3.4.3–---2.let循环,即以下

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

循环改进:

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

// 正常工作

5.3 模块

模块模式需要具备两个必要条件:

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() {
        console.log( something );
    }

    function doAnother() {
        console.log( another.join( " ! ") );
    }

    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

// 1、必须通过调用CoolModule()来创建一个模块实例
// 2、CoolModule()返回一个对象字面量语法{ key: value, ... }表示的对象,对象中含有对内部函数而不是内部数据变量的引用。内部数据变量保持隐藏且私有的状态。

立即调用这个函数并将返回值直接赋予给单例的模块标识符foo。

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() {
        console.log( something );
    }

    function doAnother() {
        console.log( another.join( " ! ") );
    }

    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

5.5.1 现代的模块机制

大多数模块依赖加载器/管理器本质上是将这种模块定义封装进一个友好的API。

var MyModules = (function Manager() {
    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 ); // 核心,为了模块的定义引用了包装函数(可以传入任何依赖),并且将返回值(模块的API),储存在一个根据名字来管理的模块列表中。
    }

    function get(name) {
        return modules[name];
    }

    return {
        define: define,
        get: get
    };

})();

使用上面的函数来定义模块:

MyModules.define( "bar", [], function() {
    function hello(who) {
        return "Let me introduct: " + who;
    }

    return {
        hello: hello
    };
} );

MyModules.define( "foo", ["bar"], function(bar) {
    var hungry = "hippo";

    function awesome() {
        console.log( bar.hello( hungry ).toUpperCase() );
    }

    return {
        awesome: awesome
    };
} );

var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );

console.log(
    bar.hello( "hippo" );
) // Let me introduct: hippo

foo.awesome(); // LET ME INTRODUCT: HIPPO

5.5.2 未来的模块机制

在通过模块系统进行加载时,ES6会将文件当做独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样可以导出自己的API成员。

ES6模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)

// bar.js

function hello(who) {
    return "Let me introduct: " + who;
}

export hello;

// foo.js
// 仅从“bar”模块导入hello()
import hello from "bar";

var hungry = "hippo";

function awesome() {
    console.log(
        hello( hungry ).toUpperCase();
    );
}

export awesome;

// baz.js
// 导入完整的“foo”和”bar“模块
module foo from "foo";
module bar from "bar";

console.log(
    bar.hello( "rhino")
); // Let me introduct: rhino

foo.awesome(); // LET ME INTRODUCT: HIPPO

附录A 动态作用域

// 词法作用域,关注函数在何处声明,a通过RHS引用到了全局作用域中的a
function foo() {
    console.log( a ); // 2
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;
bar();

-----------------------------
// 动态作用域,关注函数从何处调用,当foo()无法找到a的变量引用时,会顺着调用栈在调用foo()的地方查找a
function foo() {
    console.log( a ); // 3(不是2!)
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;
bar();

附录B 块作用域的替代方案

ES3开始,JavaScript中就有了块作用域,包括with和catch分句。

// ES6环境
{
    let a = 2;
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

上述代码在ES6环境中可以正常工作,但是在ES6之前的环境中如何实现呢?

答案是使用catch分句,这是ES6中大部分功能迁移的首选方式。

try {
    throw 2;
} catch (a) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

B.1 Traceur

// 代码转换成如下形式
{
    try {
        throw undefined;
    } catch (a) {
        a = 2;
        console.log( a ); // 2
    }
}

console.log( a ); // ReferenceError

B.2 隐式和显式作用域

let声明会创建一个显式的作用域并与其进行绑定,而不是隐式地劫持一个已经存在的作用域(对比前面的let定义)。

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

console.log( a ); // ReferenceError

存在的问题:

let声明不包含在ES6中,Traceur编译器也不接受这种代码

/*let*/ { let a = 2;
    console.log( a );
}

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

console.log( a ); // ReferenceError

B.3 性能

交流

进阶系列文章汇总如下,内有优质前端资料,觉得不错点个star。

https://github.com/yygmind/blog

我是木易杨,网易高级前端工程师,跟着我每周重点攻克一个前端面试重难点。接下来让我带你走进高级前端的世界,在进阶的路上,共勉!

CallmeJay commented 5 years ago

附录A 动态作用域 这个示例里的动态作用域例子和上面重复了吧

yygmind commented 5 years ago

附录A 动态作用域 这个示例里的动态作用域例子和上面重复了吧

没有啊