深入函数与作用域

理解 JavaScript 最核心的运行机制

🎯 学习目标

学完本章,你将能够:

  • 能够解释词法作用域和块级作用域的概念,预测变量查找路径
  • 能够使用闭包实现柯里化、防抖、节流等高级模式
  • 能够区分 this 的四种绑定规则(默认、隐式、显式、new)
  • 能够使用 call/apply/bind 显式绑定 this 的指向
  • 能够识别闭包导致的内存泄漏,并采取预防措施

一个诡异的现象

为什么这段代码输出三个 3,而不是 0、1、2?

for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// 输出: 3, 3, 3(为什么不是 0, 1, 2?)

要理解这个问题,你需要搞清楚三件事:作用域(变量住在哪里)、闭包(函数怎么"记住"变量)、this(函数被谁调用)。

本章带你从原理层面彻底搞懂它们。

Part 1: 作用域

每个变量都有自己的"领地"

开场那段 for (var i = ...) 输出三个 3 的代码,问题就出在作用域上。var 声明的变量没有块级作用域,导致循环结束后所有回调共享同一个 i。那 let 为什么就能解决?变量到底"住"在哪里?引擎是怎么找到它们的?搞清楚作用域和作用域链,这些疑问就全部解开了。

词法作用域

变量能被访问的范围,在写代码时就确定了

词法作用域(Lexical Scope)是指变量的可访问范围由代码书写时的嵌套结构决定,而非函数调用时的位置。

用人话说:函数能看到哪些变量,取决于它定义在哪里,而不是它在哪里被调用。

这个比喻只是为了帮你建立直观感受。在 JavaScript 引擎内部,作用域是通过词法分析阶段的作用域链来维护的,并不存在什么"领地"。

let value = "global";

function foo() {
    console.log(value); // "global"
}

function bar() {
    let value = "local";
    foo(); // foo 定义在 bar 外面
}
bar(); // 输出 "global",不是 "local"

块级作用域

let/const 让花括号成为一道墙

块级作用域是指由 {} 包裹的代码块内部,用 let 或 const 声明的变量只在该块内有效。

function example() {
    let x = 1;
    if (true) {
        let x = 2; // 不同变量,互不干扰
        console.log(x); // 2
    }
    console.log(x); // 1
}

// var 没有块级作用域
for (var j = 0; j < 3; j++) {
    setTimeout(() => console.log(j), 100);
}
// 输出: 3, 3, 3(j 泄漏到函数作用域)

// let 有块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// 输出: 0, 1, 2(每次循环有独立的 i)

回到开场的问题:用 var 时,三个回调共享同一个 j;循环结束时 j 变成 3,所以都输出 3。换成 let 就好了。

作用域链

从内向外逐层查找变量的链条

作用域链是 JavaScript 引擎查找变量的路径:先在当前作用域找,找不到就向外层找,直到全局作用域。

图:作用域链的变量查找过程
let global = "global";
function outer() {
    let outerVar = "outer";
    function middle() {
        let middleVar = "middle";
        function inner() {
            console.log(innerVar);  // "inner"(自身)
            console.log(middleVar); // "middle"(外层)
            console.log(outerVar);  // "outer"(更外层)
            console.log(global);    // "global"(全局)
        }
        inner();
    }
    middle();
}
outer();

变量遮蔽

内层变量"挡住"了外层同名变量

let name = "全局";
function func() {
    let name = "局部";
    console.log(name); // "局部"
    // 内层 name 遮蔽了外层 name
}
func();
console.log(name); // "全局"
症状:修改了内层变量,外层同名变量却没有变化。
原因:它们根本就是两个不同的变量,只是名字一样。
修复:避免在不同作用域使用相同变量名,用有区分度的命名。

Part 2: 闭包

函数"记住"了它出生的地方

作用域讲的是"变量能被谁看到"。但如果函数跑到了定义它的作用域外面去执行呢?它居然还能"记住"原来的变量。这个现象就是闭包。防抖、节流、柯里化——日常开发中那些精巧的模式,底层全靠闭包撑着。这一部分我们用四个递进案例,把闭包从原理到实战彻底吃透。

什么是闭包

函数 + 它定义时的词法环境

闭包(Closure)是指一个函数能够"记住"并访问它定义时所在的词法作用域,即使该函数在那个作用域之外执行。

用人话说:内部函数被返回到外部后,依然能访问外部函数里的变量。就像你从家里拿了一个笔记本出门,上面记着家里的 Wi-Fi 密码。

这个比喻只是为了帮你建立直观感受。实际机制是引擎在堆内存中保留了外部函数的变量对象(Variable Object),内部函数通过作用域链引用它。

function createCounter() {
    let count = 0; // 自由变量
    return {
        increment() { return ++count; },
        decrement() { return --count; },
        getCount()  { return count; }
    };
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.getCount();  // 2
// createCounter 执行完了,但 count 依然活着

闭包原理图

引擎如何让"已结束"的函数的变量继续存活

图:闭包的内存机制

关键:虽然 createCounter 已经执行完毕,但返回的三个方法仍然持有对 count 的引用。引擎不会回收被引用的变量。

闭包应用:柯里化

把多参数函数拆成一系列单参数函数

// 柯里化(Currying)
function multiply(a) {
    return function(b) {
        return a * b; // a 被闭包捕获
    };
}
const double = multiply(2);
const triple = multiply(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

multiply(2) 返回的函数"记住"了 a = 2。每次调用 double,都用 2 去乘。

闭包应用:防抖

用户疯狂点击按钮,只执行最后一次

function debounce(fn, delay) {
    let timer; // 被闭包捕获
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

// 使用
const debouncedSearch = debounce(query => {
    console.log("搜索:", query);
}, 300);

// 用户连续输入 "a" "ab" "abc"
// 只有 300ms 后才会执行一次搜索 "abc"

每次调用返回的函数,都能访问同一个 timer。前一次的定时器被清除,只有最后一次生效。

闭包应用:节流

不管用户点多快,固定间隔只执行一次

function throttle(fn, interval) {
    let lastTime = 0;
    return function(...args) {
        let now = Date.now();
        if (now - lastTime >= interval) {
            lastTime = now;
            fn.apply(this, args);
        }
    };
}

// 使用:滚动事件每 200ms 最多触发一次
const handleScroll = throttle(() => {
    console.log("处理滚动");
}, 200);

闭包应用:工厂

批量生产带不同规则的验证器

function makeValidator(rules) {
    // rules 被闭包捕获
    return function(value) {
        for (let rule of rules) {
            if (!rule.test(value)) return false;
        }
        return true;
    };
}

const isEmail = makeValidator([
    v => v.length > 0,
    v => v.includes("@"),
    v => v.includes(".")
]);

const isPhone = makeValidator([
    v => /^\d{11}$/.test(v),
]);

console.log(isEmail("test@example.com")); // true
console.log(isPhone("13800138000"));       // true

闭包内存陷阱

循环中的经典坑

function createFunctions() {
    let funcs = [];
    for (var i = 0; i < 3; i++) {
        funcs.push(function() { return i; });
    }
    return funcs;
}
let fns = createFunctions();
console.log(fns[0]()); // 3(不是 0!)
console.log(fns[1]()); // 3
console.log(fns[2]()); // 3
症状:所有函数返回同一个值 3。
原因:var 没有块级作用域,三个闭包共享同一个 i。循环结束后 i = 3。
修复:把 var 改成 let,或用 IIFE 创建独立作用域。
// 修复1:使用 let
for (let i = 0; i < 3; i++) {
    funcs.push(function() { return i; });
}
// 修复2:IIFE
for (var i = 0; i < 3; i++) {
    funcs.push((function(j) {
        return function() { return j; };
    })(i));
}

Part 3: this 绑定

this 的值取决于怎么调用,而非怎么定义

闭包解决了"函数记住变量"的问题,但还有一个让人头疼的关键字:this。同一个函数,用不同方式调用,this 指向完全不同。对象方法、回调、构造函数、箭头函数——每种场景都有各自的规则。接下来我们用一张决策树把五种绑定规则理清楚,以后遇到 this 就不用再猜了。

this 五种规则

判断 this 指向的完整决策树

图:this 绑定的五种规则判断流程

优先级:new > 显式绑定 > 隐式绑定 > 默认绑定。

默认与隐式绑定

独立调用 vs 对象方法调用

// 规则1:默认绑定(独立调用)
function showThis() {
    console.log(this);
}
showThis(); // window(严格模式下 undefined)

// 规则2:隐式绑定(对象方法)
let obj = {
    name: "张三",
    greet() { console.log(this.name); }
};
obj.greet(); // "张三"(this 指向 obj)

// 隐式丢失!
let fn = obj.greet;
fn(); // undefined(脱离了 obj,变成默认绑定)
症状:把对象方法赋值给变量后,this 指向丢失。
原因:fn = obj.greet 只拷贝了函数引用,调用时前面没有对象。
修复:用 bind 绑定,或用箭头函数。

显式、new 与箭头函数

三种强制改变 this 的方式

// 规则3:显式绑定
function greet(greeting) {
    console.log(`${greeting}, ${this.name}!`);
}
greet.call({ name: "张三" }, "Hello");  // "Hello, 张三!"
greet.apply({ name: "李四" }, ["Hi"]); // "Hi, 李四!"
let bound = greet.bind({ name: "王五" });
bound("Hey"); // "Hey, 王五!"

// 规则4:new 绑定
function Person(name) {
    this.name = name; // this 指向新对象
}
let p = new Person("张三");

// 规则5:箭头函数(继承外层 this)
let team = {
    name: "开发组",
    members: ["Alice", "Bob"],
    listMembers() {
        this.members.forEach(member => {
            console.log(`${this.name}: ${member}`);
            // this 继承自 listMembers,指向 team
        });
    }
};
team.listMembers();

call/apply/bind

三个方法,三种改变 this 的方式

方法执行时机传参方式返回值
call立即执行逐个传参函数返回值
apply立即执行数组传参函数返回值
bind返回新函数逐个传参绑定后的函数
// bind 偏函数:预设部分参数
function multiply(a, b) { return a * b; }
let double = multiply.bind(null, 2);
console.log(double(5)); // 10

// 借用方法
let arrayLike = { 0: "a", 1: "b", length: 2 };
let arr = Array.prototype.slice.call(arrayLike);
// ["a", "b"]

回调函数

把函数当参数传来传去

回调函数(Callback)是指作为参数传递给另一个函数,并在特定时机被调用的函数。

// 异步回调
function fetchData(url, callback) {
    setTimeout(() => {
        callback({ url, status: "ok" });
    }, 1000);
}
fetchData("/api/users", (data) => {
    console.log("收到:", data);
});

// 数组方法中的回调
let nums = [1, 2, 3, 4, 5];
nums.filter(n => n % 2 === 0);  // [2, 4]
nums.map(n => n * 2);           // [2, 4, 6, 8, 10]
nums.reduce((acc, n) => acc + n, 0); // 15

常见坑与排错

问题一:闭包在循环中共享变量

症状:for 循环里创建的回调都返回同一个值。

原因:var 声明的变量没有块级作用域。

修复:改用 let。

问题二:this 指向丢失

症状:把对象方法赋给变量后调用,this 变成 undefined。

原因:函数脱离了对象,退化为默认绑定。

修复:用 .bind(obj) 或箭头函数。

问题三:闭包导致内存泄漏

症状:页面越用越卡,内存占用不断增长。

原因:闭包持有的引用未被释放。

修复:不需要时将引用置为 null。

排错技巧:遇到 this 问题,先问自己"这个函数是怎么被调用的?"然后按优先级判断:new > 显式 > 隐式 > 默认。

检查点练习

练习一

任务:写一个 makePassword 函数,接收一个密码,返回一个验证函数。验证函数接收一个输入,返回是否匹配。

// 提示:利用闭包"记住"密码
// 你的代码:

参考答案

function makePassword(password) {
    return function(input) {
        return input === password;
    };
}
const check = makePassword("secret");
console.log(check("secret")); // true
console.log(check("wrong"));  // false

练习二

任务:补全代码,让 timer.start() 能正确输出 "Timer A started"。

let timer = {
    name: "Timer A",
    start: function() {
        // 补全:1秒后输出 this.name + " started"
    }
};
let fn = timer.start;
fn(); // 这里会出问题,为什么?怎么修复?

参考答案

// start 方法
start: function() {
    setTimeout(() => {
        console.log(this.name + " started");
    }, 1000);
}
// fn() 的 this 丢失,修复:
let fn = timer.start.bind(timer);
fn(); // "Timer A started"

综合练习

练习三:事件总线

任务:用闭包实现一个简单的事件总线(EventEmitter)。它支持注册事件、触发事件、取消注册。

const bus = createEventBus();
bus.on("login", (user) => console.log(`${user} 已登录`));
bus.on("login", (user) => console.log(`记录日志: ${user}`));
bus.emit("login", "张三");
// 张三 已登录
// 记录日志: 张三

提示:用闭包保存一个 events 对象,每个事件名对应一个回调函数数组。

参考答案

function createEventBus() {
    let events = {};
    return {
        on(name, fn) {
            (events[name] = events[name] || []).push(fn);
        },
        emit(name, ...args) {
            (events[name] || []).forEach(fn => fn(...args));
        },
        off(name, fn) {
            events[name] = (events[name] || []).filter(f => f !== fn);
        }
    };
}

📝 要点总结

本章核心知识点

词法作用域

作用域在代码书写时确定,而非运行时

闭包应用

柯里化、防抖、节流、工厂函数

this 绑定

四种规则:默认、隐式、显式、new

显式绑定

call/apply/bind 控制 this 指向

内存陷阱

闭包持有外部变量,不当使用导致内存泄漏

下一章

搞懂了作用域和闭包,接下来进入 JavaScript 的面向对象核心:原型与继承。

1 / X