JavaScript作用域与闭包:核心概念与实战解析
1. 作用域:变量存取的规则手册
在JavaScript中,作用域就像一套严格的访问控制规则,决定了变量在哪里可以被访问,在哪里会被隐藏。理解作用域是避免变量冲突和内存泄漏的基础。
1.1 全局作用域与局部作用域
全局作用域就像公共场所,任何代码都可以访问这里的变量。在浏览器环境中,全局作用域是window对象:
var globalVar = '我在全局'; console.log(window.globalVar); // 输出"我在全局"函数作用域则是私人空间,只有函数内部才能访问:
function testScope() { var localVar = '我是局部变量'; console.log(localVar); // 正常输出 } console.log(localVar); // ReferenceError: localVar is not defined1.2 let/const带来的块级作用域
ES6引入的let和const带来了块级作用域(由{}界定),这是与var最显著的区别:
if (true) { var varVariable = 'var声明'; let letVariable = 'let声明'; } console.log(varVariable); // 输出"var声明" console.log(letVariable); // ReferenceError块级作用域在循环中特别有用,能解决经典的闭包陷阱:
// 使用var的问题 for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // 输出3,3,3 } // 使用let的解决方案 for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j), 100); // 输出0,1,2 }1.3 作用域链的查找机制
当访问一个变量时,JavaScript会沿着作用域链向上查找:
let outer = '外层'; function parent() { let middle = '中间'; function child() { let inner = '内层'; console.log(inner); // 内层 console.log(middle); // 中间 console.log(outer); // 外层 console.log(global); // ReferenceError } child(); } parent();查找顺序是:当前作用域 → 父作用域 → ... → 全局作用域。如果到全局作用域还没找到,就会抛出ReferenceError。
2. 闭包:跨越作用域的魔法桥梁
闭包是JavaScript中最强大的特性之一,也是面试中最常被深挖的话题。简单说,闭包让函数可以"记住"并访问其词法作用域,即使函数在其作用域外执行。
2.1 闭包的形成条件
一个典型的闭包需要三个要素:
- 外层函数包裹内层函数
- 内层函数引用外层函数的变量
- 内层函数被外部引用
function createCounter() { let count = 0; // 外层变量 return function() { return ++count; // 内层函数引用外层变量 }; } const counter = createCounter(); // 外部引用 console.log(counter()); // 1 console.log(counter()); // 22.2 闭包的实际应用场景
2.2.1 数据封装与私有变量
在没有class私有字段(#)的年代,闭包是实现私有变量的主要方式:
function createPerson(name) { let _age = 0; // "私有"变量 return { getName: () => name, getAge: () => _age, birthday: () => { _age++; } }; } const person = createPerson('Alice'); console.log(person._age); // undefined person.birthday(); console.log(person.getAge()); // 12.2.2 函数工厂与柯里化
闭包可以实现函数工厂和参数预置(柯里化):
function multiplyFactory(x) { return function(y) { return x * y; }; } const double = multiplyFactory(2); const triple = multiplyFactory(3); console.log(double(5)); // 10 console.log(triple(5)); // 152.2.3 事件处理与回调
在事件处理中,闭包可以保存状态:
function setupButtons() { const buttons = document.querySelectorAll('button'); for (let i = 0; i < buttons.length; i++) { buttons[i].addEventListener('click', function() { console.log(`按钮${i}被点击`); }); } }2.3 闭包的内存管理
闭包会导致外层函数的变量无法被垃圾回收,可能引发内存泄漏。常见的内存泄漏场景:
// 意外的全局变量 function leak1() { leakedVar = '我泄漏了'; // 忘记声明,变成全局变量 } // 定时器未清理 function leak2() { const data = getHugeData(); setInterval(() => { console.log(data.length); }, 1000); } // DOM引用未清除 function leak3() { const element = document.getElementById('big-element'); element.addEventListener('click', () => { console.log('点击'); }); // 即使移除DOM,由于事件监听器引用,element不会被回收 }解决方案:
- 使用严格模式避免意外全局变量
- 清除不再需要的定时器、事件监听器
- 对于大型数据,使用后手动解除引用
3. 立即执行函数(IIFE):创建独立作用域的利器
立即执行函数表达式(Immediately Invoked Function Expression)是定义后立即执行的函数,主要用于创建独立作用域。
3.1 IIFE的基本语法
两种常见写法:
// 写法1:括号包裹整个函数 (function() { console.log('IIFE执行'); })(); // 写法2:括号包裹函数体 (function() { console.log('IIFE执行'); }());3.2 IIFE的实际应用
3.2.1 避免变量污染全局
在模块化之前,IIFE是隔离作用域的主要方式:
// 全局命名空间污染 var count = 0; function add() { count++; } // 使用IIFE隔离 (function() { var count = 0; function add() { count++; } window.myModule = { add }; })(); console.log(count); // 0,不受IIFE内部影响3.2.2 捕获瞬间状态
在循环中,IIFE可以捕获每次迭代的状态:
for (var i = 0; i < 3; i++) { (function(currentI) { setTimeout(function() { console.log(currentI); }, 100); })(i); } // 输出0,1,23.2.3 模块化开发的基础
早期模块化方案(如UMD)基于IIFE:
var MyModule = (function() { var privateVar = '私有'; function privateMethod() { /*...*/ } return { publicMethod: function() { privateMethod(); console.log(privateVar); } }; })();3.3 现代替代方案
随着ES6模块和块级作用域的普及,IIFE的使用减少了:
// 使用块级作用域替代 { let count = 0; const add = () => count++; window.myModule = { add }; } // 使用模块化 // module.js let count = 0; export function add() { count++; } // main.js import { add } from './module.js';4. 高频面试题深度解析
4.1 作用域相关题目
题目1:以下代码输出什么?
var a = 1; function foo() { console.log(a); var a = 2; } foo();解析:输出undefined。这是因为变量提升(hoisting),函数内的var a会提升到函数顶部,相当于:
function foo() { var a; console.log(a); a = 2; }题目2:let和var的区别
关键区别:
- var有函数作用域,let有块级作用域
- var会变量提升,let存在暂时性死区(TDZ)
- var可以重复声明,let不可以
- var在全局声明会成为window属性,let不会
4.2 闭包相关题目
题目1:实现一个计数器工厂
function createCounter() { let count = 0; return { increment: () => ++count, decrement: () => --count, getCount: () => count }; } const counter = createCounter();题目2:以下代码的输出顺序是什么?
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 100); }输出:3,3,3。因为setTimeout回调执行时循环已结束,i的值为3。解决方案:
- 使用IIFE捕获i
- 使用let替代var
- 使用setTimeout的第三个参数
4.3 IIFE相关题目
题目1:以下IIFE的输出是什么?
var result = (function(x) { return x * 2; })(5); console.log(result); // 10题目2:如何用IIFE实现模块模式?
var calculator = (function() { let memory = 0; function add(x) { memory += x; } function get() { return memory; } return { add, get }; })();4.4 综合应用题
题目:实现一个once函数,确保传入函数只执行一次
function once(fn) { let called = false; let result; return function(...args) { if (!called) { called = true; result = fn.apply(this, args); } return result; }; } const logOnce = once(console.log); logOnce('hello'); // 输出"hello" logOnce('world'); // 无输出5. 实战中的经验与陷阱
5.1 作用域相关的常见错误
- 意外全局变量:忘记声明变量导致污染全局
function oops() { accidentalGlobal = '糟糕'; // 应该是let/const/var }提示:始终使用严格模式("use strict")可以避免这种错误
- 循环中的闭包陷阱:使用var在循环中创建闭包
for (var i = 0; i < 5; i++) { setTimeout(() => console.log(i), 100); // 输出5个5 }解决方案:
- 使用let替代var
- 使用IIFE捕获i
- 使用Array.prototype.forEach
5.2 闭包性能优化技巧
- 减少闭包捕获的变量数量:只保留必要的变量
// 不推荐:捕获了整个data对象 function process() { const data = getLargeData(); return function() { console.log(data.id); }; } // 推荐:只捕获需要的属性 function process() { const data = getLargeData(); const { id } = data; return function() { console.log(id); }; }- 及时释放闭包引用:
function setup() { const data = getLargeData(); const listener = () => console.log(data); element.addEventListener('click', listener); // 不再需要时移除 return () => element.removeEventListener('click', listener); }5.3 IIFE的现代替代方案
- 使用块级作用域:对于简单的隔离需求
{ let temp = '临时变量'; // 这里使用temp } // temp在这里不可访问- 使用ES模块:对于代码组织
// module.js let privateVar = '私有'; export function publicFn() { /*...*/ } // main.js import { publicFn } from './module.js';- 使用类私有字段:对于面向对象场景
class Counter { #count = 0; // 私有字段 increment() { this.#count++; } }