首页>前端教程>JavaScript教程

吞下作用域和闭包,感觉世界都清晰了!

写程序最开始遇到的可能就是早期的作用域问题了吧,总是命名冲突,访问不到对应的数据,所以理解函数的前提还是理解作用域。

下面的内容会比较枯燥,需要高度的注意力集中……

1、作用域了解

1.1 作用域

几乎所有的编程语言最基本的功能之一,就是能够储存变量当中的值,并且能够在之后对这个值进行访问修改。

正是这种储存和访问变量的值的能力将状态带给了程序。若没有了状态这个概念,程序会受到高度限制,做不到非常复杂的变化。

但是将变量引入程序会引起几个很有意思的问题:这些变量住在哪里?程序需要时如何找到它们?

这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。

程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

1、分词/词法分析

这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元。比如 var a = 2;,会被分解成为下面这些词法单元:var 、a、=、2、;。

2、解析/语法分析

这个过程是将词法单元流(数组)转换成了一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(AST)

3、代码生成

将AST抽象语法树转换为可以执行代码的过程被称为代码生成。简单地说就是把AST转化为一组机器指令。

对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒的时间内。

引擎

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

编译器

引擎的好朋友之一,负责语法分析及代码生成等脏活累活。

作用域

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

这样一条语句:

var a = 2;

编译器会如下处理:

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

2、接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。

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

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

1.2 作用域嵌套

当一个块或函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。

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

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

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

作用域链.jpg

1.3 LHS和RHS
a = b + 3 ;

RHS(righthand side)右侧查询表示查找到某个变量的值,比如查找到b的值。

LHS(lefthand side)左侧查询则是试图找到变量的容器本身,从而可以对其赋值。比如找到a并为其赋值。

在变量没有声明的情况下,这两种查询的行为是不一样的。

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

第一次对b进行RHS查询是无法找到该变量的,如果RHS在所有嵌套的作用域中遍寻不到所需的变量,引擎会抛出ReferenceError异常。

当引擎执行LHS查询时,如果在全局作用域中也无法找到目标变量,全局作用域就会自动创建一个具有该名称的变量,并将其返还给引擎(非严格模式下)。

如果RHS查询找到了一个变量,但是尝试对这个变量的值进行不合理的操作,比如对一个非函数类型的值进行函数调用,或者引用null类型的值中的属性,引擎会抛出TypeError错误。

ReferenceError同作用域判别失败相关,TypeError则表示作用域判别成功,但是对结果的操作非法。

2、词法作用域

词法作用域就是定义在词法阶段的作用域。

词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。因此当词法分析器处理代码时会保持作用域不变。

所以,变量,函数,块作用域都在写代码那一刻就决定了它的作用域范围。就好像可以在别的城市工作,但是出生地是固定的。

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

词法作用域.jpg

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

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

3包含着bar所创建的作用域,其中只有一个标识符:c,这里还形成了闭包。

词法作用域意味着作用域是由书写代码时标识符声明的位置来决定的,比如变量,函数。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

3、作用域闭包

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

//这个示例展示了词法作用域的访问规则,但是还不太能展示闭包的特性。
function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    //函数是在函数声明的地方调用的
    bar();
}
foo();
function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar; //把函数作为一个值返回
}
var baz = foo();  //只是通过不同的标识符引用调用了内部的函数bar()
baz(); //这就形成了闭包

bar()可以被正常执行,但是在这个例子中,bar()是在自己定义的词法作用域以外的地方执行。

在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收机制,用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因为没有被回收。

谁在使用这个内部作用域?其实就是bar()本身在使用。

因为bar()所声明的位置,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫做闭包。

这个函数在定义时的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义时的词法作用域。

打个比喻:老爸让儿子出去工作,儿子在工作中需要获得一些资源时,还可以回到老爸这儿访问,也就是可以回到出生地儿拿到资源。

所以,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用的时候都可以观察到闭包。

function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    //直接把函数作为值传参
    bar(baz);
}
function bar(fn){
    var a = 3;
    fn();
    //fn(a); //传递参数就不一样了
}
foo();
var fn;
function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    fn = baz; //把函数的引用分配给全局变量fn
}
function bar(){
    fn(); //闭包产生
}
foo();
//fn();
bar();

总结:无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

4、使用闭包

4.1定时器

function wait(message){
    setTimeout(function timer(){ //函数timer()具有涵盖wait()作用域的闭包,因此对变量message还保有引用。
        console.log(message);
    },1000);   
}
wait('使用了闭包closure!');
console.log('先执行,再执行异步!');

本质上无论何时何地,如果将(访问它们各自的词法作用域)函数当做第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听、Ajax请求、跨窗口通信或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

//  顺序打印多个数字
function printNum(min, max) {
    // 定义一个倍数
    let num = 2;
    // 定义一个加倍的函数
    function double(val) {
        return val * num;
    }
    // 遍历参数,并加倍打印
    for (let i = min; i <= max; i++) {
        console.log(double(i));
    }
    // 返回加倍函数
    return double;
}
// 把加倍函数返回给一个变量
let fn = printNum(1,3);
// 验证加倍的倍数
console.log(fn(10));//使用了闭包

4.2 循环和闭包

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

延迟函数的回调会在循环结束时才执行,当定时器运行时即使每个迭代中执行的是setTimeout(...,0),所有的回调函数依然是在循环结束后才会被执行,因此会每隔1秒钟输出一个6.

实际情况是尽管循环中的五个函数都是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。

我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

通过声明函数可以产生新的作用域,IIFE是一个不错的选择。

for (var i = 1; i <= 5; i++) {
    (function () {
        //通过声明一个立即执行的函数表达式,把变量i赋值给函数内部的变量,从而实现定时器调用的时候可以访问这个j变量,实现闭包的效果。
        var j = i;
        setTimeout(function timer() {
            console.log(j);
        }, j * 1000)
    })();
}

可以精简上面的代码:

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

我们使用IIFE在每次迭代时都创建一个新的作用域。

换句话说,每次迭代我们都需要一个块作用域。

for (var i = 1; i <= 5; i++) {
    //声明块作用域的变量,形成闭包的作用域块
    let j = i;
    setTimeout(function timer() {
        console.log(j);
    }, j * 1000);  
}

for循环头部的let声明还会有一个特殊的行为,这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。

随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

所以最终被演变成了这样的代码:

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

4.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
    }
}

let foo = CoolModule();

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

这种模式被称为模块。

返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏而且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API。

这个变量类型的返回值最终被赋值给外部的变量foo,就可以通过它来访问API中的属性方法,比如foo.doSomething()

doSomething()和doAnothor()函数具有涵盖模块实例内部作用域的闭包,当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。

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

  • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例),比如CoolModule()。

  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

模块是函数,因此可以接受参数

function CoolModule(id){
    function identify(){
        console.log(id);
    }
    return {
        identify:identify
    };
}
let foo1 = CoolModule('foo1');
let foo2 = CoolModule('foo2');
foo1.identify(); // foo1
foo2.identify(); // foo2

模块模式另一个简单但强大的用法是命名将要作为公共API返回的对象:

//这是一个单例的模块实例,当只需要一个模块实例的时候,可以用IIFE封装一下。
var foo = (function CoolModule(id) {
    function change() {
        publicAPI.identify = identify2;
    }
    function identify1() {
        console.log(id);
    }
    function identify2() {
        console.log(id.toUpperCase());
    }
    var publicAPI = {
        change: change,
        identify: identify1
    }
    return publicAPI;
})('foo module');

foo.identify();
foo.change();
foo.identify();

通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或者删除方法和属性,以及修改它们的值。

当然,ES6提供了模块的规范,后面再说了。

点赞


1
保存到:

相关文章

发表评论:

◎请发表你卖萌撒娇或一针见血的评论,严禁小广告。

Top