JavaScript 作用域与闭包(下):提升与闭包

0 阅读13分钟

提升

一、打破直觉:代码执行的 "非顺序性" 现象

先来看两个经典的代码片段:

案例 1:变量声明与赋值的顺序之谜

a = 2;
var a;
console.log(a); // 输出:2

案例 2:变量使用与声明的顺序困惑

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

为何案例 1 中变量声明在后却不影响赋值?为何案例 2 中未声明的变量使用不会报错?这违背直觉的现象背后,正是提升机制在起作用。

二、提升的本质:编译阶段的提前处理

JavaScript 引擎在执行代码前会经历编译阶段,其中一项重要工作是扫描所有声明(变量和函数),并将其提升到所在作用域的顶部。执行阶段仅处理赋值和逻辑操作。

1. 变量声明的提升

案例2 会被拆解为:

  • 编译阶段:var a;(声明提升到作用域顶部,初始值为undefined

  • 执行阶段:(按原代码顺序执行)

    • 第一步:执行 console.log(a);
      此时 a 已被声明(提升后存在),但尚未执行赋值 a = 2,因此输出 undefined
    • 第二步:执行 a = 2;
      对变量 a 赋值为 2,此时 a 的值变为 2

    因此案例 2 的实际执行顺序为:

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

 ------------------------------------------

var a; // 提升
console.log(a); // undefined
a = 2; // 执行

2. 函数声明的提升

函数声明会被整体提升,包括函数体。例如:

foo(); // 正常执行,输出:2
function foo() {
  var a = 2;
  console.log(a);
}

等价于:

function foo() { // 函数声明提升
  var a; // 变量声明在函数内提升
  a = 2;
  console.log(a); // 2
}
foo();

三、提升的规则与特殊场景

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); }; // 执行阶段赋值
  1. 编译阶段:

    • 遇到 var foo; 和 function foo() { console.log(1); },函数声明会被提升到作用域的顶部,变量声明 var foo; 也会被提升,但由于函数声明会覆盖变量声明(在这种情况下,函数声明会被优先处理),所以实际上在编译阶段,foo 被定义为一个函数。
  2. 执行阶段:

    • 执行 foo();,此时 foo 是在编译阶段被提升的函数,所以会输出 1
    • 执行 foo = function() { console.log(2); };,这行代码将 foo 重新赋值为一个新的函数。

2. 函数表达式不会提升

与函数声明不同,函数表达式作为变量赋值,仅有变量名会被提升,函数体不会提前可用:

foo(); // TypeError(foo为undefined,无法调用)
var foo = function bar() { /* ... */ };

等价于:

var foo; // 变量声明提升
foo(); // undefined调用函数,报错
foo = function bar() { /* ... */ }; // 执行阶段赋值

3. 块作用域中的函数声明(谨慎使用)

在 ES6 之前,块级作用域内的函数声明行为不规范,可能被提升到外层作用域:

foo(); // 输出:"b"(非标准行为,可能随版本改变)
var a = true;
if (a) {
  function foo() { console.log("a"); }
} else {
  function foo() { console.log("b"); }
}

建议:避免在块级作用域内声明函数,改用函数表达式或 ES6 的let/const

四、提升的实际应用与避坑指南

1. 合理利用提升

  • 在函数顶部声明所有变量(提升特性使代码更易读)
  • 函数声明提前可用,适合模块化设计前的代码组织。

2. 避免常见陷阱

  • 禁止重复声明:同名变量和函数声明混合会导致逻辑混乱
  • 区分函数声明与表达式
// 正确:函数声明提升
sayHello(); // 输出:"Hello"
function sayHello() { console.log("Hello"); }

// 错误:函数表达式未提升
sayHi(); // TypeError
var sayHi = function() { console.log("Hi"); };

五、总结:提升的核心逻辑

  • 声明提前:变量和函数声明在编译阶段被提升到作用域顶部,赋值留在执行阶段。

  • 优先级规则:函数声明 > 变量声明,同名声明后者覆盖前者(函数声明之间)。

  • 实践原则

    • 变量先声明再使用,避免隐式全局变量。
    • 用函数声明替代表达式,除非需要立即执行函数(IIFE)。
    • 避免在块级作用域内声明函数,优先使用 ES6 的let/const和箭头函数。

垃圾回收机制(GC)

垃圾回收(Garbage Collection,简称 GC):是编程语言自动管理内存的一种机制,目的是清理程序中 “不再使用的内存”,避免内存泄漏,让程序更高效地运行。

一、什么是 “垃圾”?

程序运行时会申请内存存储数据(比如变量、对象、函数等)。当这些数据不再被程序需要时,它们占用的内存就变成了 “垃圾”,需要被回收。

举个例子:

function fn() {
  let a = 10; // 函数内部声明变量a,存储数字10
}
fn(); // 函数执行完后,变量a不再被使用,它占用的内存就是“垃圾”

二、垃圾回收的核心逻辑:找到并清理 “无用数据”

JavaScript 的垃圾回收机制主要通过以下两种方式判断数据是否 “无用”:

1. 引用计数法
  • 核心思想:追踪每个数据被引用的次数。当引用次数为 0 时,数据成为垃圾。

  • 什么是引用

    • 变量赋值:let obj = { name: '小明' };obj引用了这个对象)。
    • 函数参数传递:fn(obj);(函数fn内部也引用了这个对象)。
  • 例子

let obj1 = { age: 20 }; // obj1引用对象,计数为1
let obj2 = obj1; // obj2也引用同一个对象,计数变为2
obj1 = null; // obj1不再引用对象,计数减为1
obj2 = null; // obj2也不再引用对象,计数变为0 → 对象成为垃圾,被回收
  • 缺点:无法处理 “循环引用”(两个对象互相引用,但都不再被外部使用),比如:
// 1. 创建两个对象模拟房间
let roomA = { name: "房间A" }; // 引用计数:1(被roomA变量引用)
let roomB = { name: "房间B" }; // 引用计数:1(被roomB变量引用)

// 2. 循环引用:roomA存储roomB的引用,roomB存储roomA的引用
roomA.key = roomB; // roomA的key属性引用roomB → roomB的计数+1(变为2)
roomB.key = roomA; // roomB的key属性引用roomA → roomA的计数+1(变为2)

// 3. 外部变量不再引用房间(没人“持有”roomA和roomB)
roomA = null; // roomA变量断开引用 → roomA对象的计数-1(变为1)
roomB = null; // roomB变量断开引用 → roomB对象的计数-1(变为1)

引用计数法的问题

  • 当前状态
  • 房间A对象的计数 = 1(来自房间B对象.key的引用)。
  • 房间B对象的计数 = 1(来自房间A对象.key的引用)。
  • 困境
    两个对象的计数都不为 0,但它们已经无法被外部代码访问(就像两个锁死的房间,外人进不去)。
    引用计数法会认为它们 “仍在使用”,不会回收内存,导致内存泄漏。
2. 标记 - 清除法(现代浏览器常用)
  • 核心思想
  1. 标记阶段:从 根对象(Roots) 出发(如全局变量window、函数调用栈中的变量),递归遍历所有可达对象并标记。
  2. 清除阶段:遍历整个堆内存,回收未标记的对象,并释放其占用的内存空间。
  • 例子
// 全局变量obj是根对象的一部分
let obj = { name: '小红' }; 
function fn() {
    let num = 10; // 函数调用时,num在栈中,属于根对象链的一部分
}
fn(); // 函数执行完后,num从栈中移除,不再被根对象引用 → 标记阶段无法访问num,清除其内存
obj = null; // obj不再引用对象,对象脱离根对象链 → 下次标记时被清除
  • 优点:能处理循环引用,因为只要数据不在根对象链中,就会被清除。
  • 缺点:标记清除后会产生内存碎片,导致后续大对象分配困难。

闭包

定义:闭包是指函数在执行时,能记住并访问其词法作用域内的变量,即使该函数在词法作用域外部被调用。

举个栗子🌰:

function foo() {
  var a = 1;
  function bar() {
    console.log(a);
  }
  return bar;
}
var baz = foo();
baz();

我们把这个foo()函数作用域看成一个房间,函数作用域里的变量如a就是一个物品,可能会有许多物品,bar()就是房间里的一个盒子,也就是“闭包”。当foo()使用完毕这个房间就会被销毁(垃圾回收机制),按理来说房间里的所有物品会被一同销毁,而有了这盒子(闭包),将物品a放入盒子里打包带出这个房间,在房间之外(外部作用域)盒子里的物品不会被销毁还可以拿出来使用,这就是闭包的用处。

我们再来看一段循环代码片段:

1. 原始代码的行为
for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

你猜会输出啥呢?1 2 3 4 5?错误!
实际上这段代码在运行时会以每秒一次的频率输出五次 6。

这是为什么?

原因

  • var 的函数作用域var声明的变量会被提升到函数作用域(这里是全局作用域),整个循环中只有一个i变量。
  • 闭包捕获的是变量引用:所有定时器回调函数共享同一个i的引用,而不是每次迭代的副本。

当闭包捕获一个变量时,它捕获的是该变量的引用,而非变量的值。这意味着: 闭包内对变量的修改会影响外部作用域。 外部作用域对变量的修改也会影响闭包内的值。

  • 异步执行时机:当定时器回调执行时(循环结束后),i的值已经变为 6(循环终止条件:i>5时退出)。
2. 首次尝试修复(无效的 IIFE)
for (var i=1; i<=5; i++) {
  (function() {
    setTimeout(function timer() {
      console.log(i);
    }, i*1000);
  })();
}
// 输出结果:仍然是5个6

失败原因

  • IIFE 创建了新的块级作用域,但内部没有声明自己的变量,导致闭包仍然捕获全局作用域中的i
3. 正确修复(IIFE + 参数传递)
for (var i=1; i<=5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j);
    }, j*1000);
  })(i);
}
// 输出结果:1, 2, 3, 4, 5(每秒输出一个)

成功原因

  • 每次迭代时,IIFE 立即执行并接收当前i的值作为参数j
  • 每个 IIFE 创建独立的闭包,将j的值(即当前迭代的i)封闭在内部。
  • 定时器回调捕获的是不同闭包中的j,而非共享的i
4. ES6 的let块级作用域解决方案
for (let i=1; i<=5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i*1000);
}
// 输出结果:1, 2, 3, 4, 5(无需IIFE,直接正确)

原因

  • let声明的变量具有块级作用域,每次迭代都会创建独立的i副本。
  • 每个定时器回调捕获的是当前迭代的i,而非全局变量。

常见误区与最佳实践

1. 误区
  • 认为闭包会 “冻结” 变量的值(实际捕获的是引用)。
  • 依赖循环结构本身创建作用域(var不具备块级作用域)。
2. 最佳实践
  • 优先使用let/const:ES6 的块级作用域自动解决循环闭包问题。
  • 避免在循环内定义回调:如果必须使用,确保每个回调有独立作用域。

模块

概念:模块模式是一种利用闭包来实现数据封装和隐藏的设计模式。它通过一个外部函数来创建一个封闭的作用域,在这个作用域内可以定义私有变量和函数,然后通过返回一个包含公共函数的对象来提供对这些私有成员的访问。

一、模块模式的实现

以下是一个简单的模块模式示例:

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

在这个示例中,CoolModule函数是一个模块创建器,它返回一个包含两个公共函数doSomethingdoAnother的对象。这两个函数可以访问模块内部的私有变量somethinganother,因为它们形成了闭包

模块模式的特点
  1. 数据封装和隐藏:模块内部的变量和函数是私有的,外部无法直接访问,只能通过公共 API ( return{}部分 ) 来操作。
  2. 闭包的使用:通过闭包,公共函数可以访问和修改模块内部的私有状态。
  3. 模块实例的创建:每次调用模块创建器函数都会创建一个新的模块实例,每个实例都有自己独立的私有状态。

二、单例模式

单例模式是模块模式的一种特殊形式,它确保一个模块只有一个实例。可以通过将模块函数转换为立即执行函数表达式(IIFE)并直接调用它来实现单例模式:

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

在这个示例中,CoolModule函数被立即执行,返回的对象直接赋值给foo,从而确保了只有一个模块实例。

三、模块的参数传递

模块函数可以接受参数,这些参数可以在模块内部使用:

function CoolModule(id) {
    function identify() {
        console.log(id);
    }

    return {
        identify: identify
    };
}

var foo1 = CoolModule("foo 1");
var foo2 = CoolModule("foo 2");

foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

在这个示例中,CoolModule函数接受一个参数id,并在内部函数identify中使用它。

四、模块的公共 API 修改

通过在模块内部保留对公共 API 对象的引用,可以从内部对模块的公共 API 进行修改:

var foo = (function CoolModule(id) {
    function change() {
        // 修改公共API
        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 module
foo.change();
foo.identify(); // FOO MODULE

在这个示例中,CoolModule函数返回一个包含changeidentify方法的对象。change方法可以修改identify方法,从而改变模块的公共 API。

五、模块的导入和导出

  1. 导出(export) :使用export关键字可以将当前模块的一个标识符(变量、函数等)导出为公共 API,例如:

    function hello(who) {
        return "Let me introduce: " + who;
    }
    export hello;
    
  2. 导入(import)

  • 使用import关键字可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上,例如:

    import hello from "bar";
    
  • 使用module关键字将整个模块的 API 导入并绑定到一个变量上,例如:

    module foo from "foo";
    

以下是一个完整的示例,展示了如何在 ES6 中定义和使用模块:

  1. bar.js

    function hello(who) {
        return "Let me introduce: " + who;
    }
    export hello;
    
  2. foo.js

    import hello from "bar";
    var hungry = "hippo";
    function awesome() {
        console.log(hello(hungry).toUpperCase());
    }
    export awesome;
    
  3. baz.js

    module foo from "foo";
    module bar from "bar";
    console.log(bar.hello("rhino")); // Let me introduce: rhino
    foo.awesome(); // LET ME INTRODUCE: HIPPO
    

六、总结

模块模式是 JavaScript 中一种强大的设计模式,它通过闭包实现了数据封装和隐藏,提供了一种优雅的方式来组织和管理代码。通过模块模式,可以将相关的代码和数据封装在一起,提高代码的可维护性和可复用性