解密作用域与闭包:从变量访问到闭包实战一网打尽

今天我们来聊聊JavaScript中最核心也最让人困惑的概念:作用域、作用域链、执行上下文和闭包。
这些都是面试中的高频考点,更是写出高质量代码的基石!

系列文章目录

解密JavaScript面向对象(一):从新手到高手,手写call/bind实战
解密JavaScript面向对象(二):深入原型链,彻底搞懂面向对象精髓

引子

从一个经典面试题开始
先来看这段代码,你能准确说出输出结果吗?

for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 100);
}

如果你的答案是0, 1, 2,那么这篇文章正是你需要的!
让我们从基础开始,彻底搞懂这些问题。

一、作用域:变量访问的规则体系

什么是作用域?

简单来说,作用域就是变量的可访问范围。就像公司的部门权限:财务部的文件,技术部不能随便看。

JavaScript有三种作用域:全局作用域、函数作用域、块级作用域。下面代码中进行说明。

// 1. 全局作用域
var globalVar = "我是全局变量";
function test() {
    // 2. 函数作用域
    var functionVar = "我是函数内变量";
    if (true) {
        // 3. 块级作用域 (ES6+)
        let blockVar = "我是块级变量";
        const constVar = "我是常量";
    }
    console.log(globalVar);   // 可访问
    console.log(functionVar); // 可访问
    console.log(blockVar);    // 报错:blockVar is not defined
}
test();
console.log(functionVar);    // 报错:functionVar is not defined

关键区别:var、let、const

// var 的变量提升
console.log(a); // undefined,不会报错
var a = 10;

// let/const 存在暂时性死区
console.log(b); // 报错:Cannot access 'b' before initialization
let b = 20;

// 循环中的差异
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log('var:', i)); // 输出3次 3
}
for (let j = 0; j < 3; j++) {
    setTimeout(() => console.log('let:', j)); // 输出0,1,2
}

二、作用域链:变量查找的"GPS导航"

当我们访问一个变量时,JavaScript会沿着作用域链逐级向上查找。

var global = "全局";
function outer() {
    var outerVar = "外部";
    function inner() {
        var innerVar = "内部";
        console.log(innerVar);  // 当前作用域找到
        console.log(outerVar);  // 上级作用域找到  
        console.log(global);    // 全局作用域找到
        console.log(notExist);  // 报错:沿链查找不到
    }
    inner();
}
outer();

查找顺序:inner作用域 → outer作用域 → 全局作用域

三、执行上下文:代码执行的"舞台环境"

JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。

var globalVar = "全局";
function first() {
    console.log("first开始");
    second();
    console.log("first结束");
}
function second() {
    var secondVar = "second变量";
    console.log("second执行");
}
first();
console.log("全局结束");

我们来观察执行栈的变化
1)全局上下文入栈
2)first()调用,first上下文入栈
3)second()调用,second上下文入栈
4)second()执行完,second上下文出栈
5)first()执行完,first上下文出栈
6)全局上下文出栈

当 JavaScript 代码执行一段可执行代码时,会创建对应的执行上下文。

对于每个执行上下文,都有三个重要核心(ES6):
1)变量环境:var声明的变量和函数声明
2)词法环境:let/const声明的变量
3)this绑定

PS:ES3规范提出的三个核心是:变量对象、作用域链、this绑定。

协助理解:
作用域链 ≈ 词法环境通过函数调用引用连成的链。
变量对象 ≈ 词法环境 + 变量环境 所管理的存储空间。

ES6的说法更符合其描述底层机制。
变量环境跟词法环境的拆分是为了实现 let/const 的暂时性死区等特性。

console.log(a);     // undefined
console.log(b);     // 报错
console.log(sayHi); // 函数体
var a = 10;
let b = 20;
function sayHi() {
    console.log("Hi");
}

四、闭包(作用域的高级应用)

闭包就是能够访问其他函数作用域的函数
简单说:函数嵌套函数,内部函数可以访问外部函数的变量

function createCounter() {
    let count = 0; // 私有变量
    // 返回的函数形成了闭包
    return function() {
        count++;
        console.log(count);
    };
}
// 通过返回函数形成闭包后,可以访问createCounter中的变量count
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

闭包的实战应用场景:防抖

防抖的核心思想是:“等你讲完”。在事件被触发后,会开启一个定时器。如果在定时器到期之前,事件又被再次触发,则定时器会重置。只有当事件停止触发,并且定时器到期后,目标函数才会被执行。

延伸:****节流的核心思想是:“按规定节奏来”。它保证在一个固定的时间间隔内,函数最多只会被执行一次。无论在这段时间内事件触发了多少次,它都会按照既定的节奏去执行。

// 防抖
function debounce(fn, delay) {
    let timer = null;
    // 返回的函数形成了闭包
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}
// 文字结束输入结束后等待300ms再进行检索
const searchInput = document.getElementById('search');
const debouncedSearch = debounce(function(keyword) {
    console.log('搜索:', keyword);
}, 300);
searchInput.addEventListener('input', (e) => {
    debouncedSearch(e.target.value);
});

五、作用域与面向对象的关联

作用域决定变量的可见性和生命周期,而面向对象通过封装、继承和多态来组织代码。
在面向对象编程中,作用域规则帮助我们实现信息隐藏和数据保护

实现私有属性和方法

function Person(name) {
    // 私有变量
    // ES2022(ES13)引入了原生的私有变量支持,通过#前缀定义,仅支持类(class)中的字段和方法‌。
    let _age = 0;
    let _secret = name + "'s secret";
    // (闭包)公有方法(特权方法) 获取私有变量
    this.getName = function() {
        return name;
    };
    this.getAge = function() {
        return _age;
    };
    this.birthday = function() {
        _age++;
        console.log(`${name} 过生日,现在 ${_age}`);
    };
}

const person = new Person("小明");
console.log(person.getName()); // "小明"
person.birthday(); // 小明 过生日,现在 1 岁
console.log(person._age); // undefined (无法直接访问私有变量)
console.log(person._secret); // undefined

模块模式

// 计数期
const MyModule = (function() {
    // 私有变量
    let privateCounter = 0;
    // 私有方法
    function privateFunction() {
        privateCounter++;
    }
    // 公有API
    return {
        increment: function() {
            privateFunction();
        },
        getCount: function() {
            return privateCounter;
        },
        reset: function() {
            privateCounter = 0;
            console.log('计数器已重置');
        }
    };
})();
// 使用
MyModule.increment();
MyModule.increment();
console.log(MyModule.getCount()); // 2
MyModule.reset(); // 计数器已重置

六、面试常见问题解析

面试题1:循环中的闭包

题目:以下代码输出什么?如何修改为期望的输出?

for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 100);
}

输出
// 期望输出:0, 1, 2
// 实际输出:3, 3, 3

原因解析:
var 的作用域为函数作用域,此代码中i 被提升到该代码所在函数作用域或全局作用域中;
随之在事件for循环中,i值更新到3;
最后是三个 setTimeout 回调函数都形成了闭包,调用的是同一个i,即3。

解决方案:
方案1:使用let块级作用域

for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2
    }, 100);
}

方案2:使用闭包捕获每次循环的i

for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2
        }, 100);
    })(i);
}

方案3:setTimeout的第三个参数

for (var i = 0; i < 3; i++) {
    setTimeout(function(j) {
        console.log(j); // 0, 1, 2
    }, 100, i);
}

面试题2:闭包的内存泄漏

题目:以下代码有什么问题?如何优化?

function createHeavyObject() {
    const largeData = new Array(1000000).fill('data');
    return function() {
        console.log('数据长度:', largeData.length);
    };
}
const heavyFunction = createHeavyObject();

存在问题:即使不再需要,largeData仍无法被垃圾回收
原因解析:JavaScript 的垃圾回收器会标记"不再被引用"的变量进行回收;而闭包中所调用变量,仍被间接引用,不会被回收
优化方案

function createHeavyObject() {
    const largeData = new Array(1000000).fill('data');
    return {
        process: function() {
            console.log('处理数据:', largeData.length);
        },
        // 提供清理方法
        cleanup: function() {
            largeData.length = 0;
            console.log('内存已释放');
        }
    };
}
const obj = createHeavyObject();
obj.process();
obj.cleanup(); // 手动释放内存

七、总结与最佳实践

核心要点回顾
1)作用域决定变量的可见性
2)作用域链决定变量的查找路径
3)执行上下文是代码执行的环境
4)闭包是函数+其创建时的作用域

最佳实践建议
✅ 推荐使用:
1)使用let / const 代替var
2)合理使用闭包进行数据封装
3)及时释放不再需要的闭包引用

❌ 避免使用:
I. 避免创建不必要的闭包
II. 避免闭包中持有DOM引用导致内存泄漏
III. 避免过深的嵌套作用域

下期预告

作用域和闭包都是JavaScript的同步编程概念。下一篇将深入探讨异步编程,这是JavaScript最核心也是最复杂的部分。

如果觉得有帮助,请关注+点赞+ 收藏,这是对我最大的鼓励!如有问题,可以评论区留言哟

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序媛小王ouc

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值