本篇用 8 个实战场景说清楚:从类的私有属性到防抖节流,从事件监听 to 函数柯里化。每个场景都有代码,还有我踩过的坑,保证全是大白话。看完就知道,闭包没那么难。
一、类的封装
刚学面向对象时,我想给类加私有属性,发现 JavaScript 里居然没有private关键字。后来才知道,用闭包就能实现类似的效果。
比如我写了个TodoList类,想让todos数组只能通过方法访问,不能被外部直接修改:
js
function createTodoList() {
// 这个变量就是"私有属性",外部访问不到
let todos = [];
// return 返回的是公开方法
return {
add: function(text) {
if (text.trim()) {
todos.push({ id: Date.now(), text, done: false });
}
},
getAll: function() {
return [...todos];
},
toggle: function(id) {
todos = todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
);
}
};
}
// 使用
const todoList = createTodoList();
todoList.add('学习闭包');
console.log(todoList.getAll()); // 能获取到数据
// 尝试直接访问私有属性
console.log(todoList.todos); // undefined,访问不到!
这里的todos变量就是通过闭包保护起来的:
-
外部无法直接修改todos,只能通过返回对象上的add、toggle等方法操作,保证了数据安全
-
这些方法即使在外部调用,也能记住并访问todos变量,这就是闭包的作用
我之前踩过一个坑,一开始在返回的对象里直接暴露了getTodos: () => todos,结果外部通过todoList.getTodos().push(...)就能修改原数组。后来改成返回副本[...todos]才解决,这也算是封装的细节吧。
二、防抖
做搜索框时遇到个问题:用户输入还没完成,搜索请求就发出去了,既浪费资源又影响体验。这时候防抖就能派上用场 ------让函数在事件停止触发后延迟 n 秒执行,如果 n 秒内再次触发,则重新计时。
防抖的核心就是用闭包保存定时器 ID:
js
function debounce(fn, delay) {
// 用闭包保存定时器ID
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
// 搜索框输入
const searchInput = document.getElementById('search');
function handleSearch(value) {
console.log('搜索:', value);
}
// 包装成防抖函数,输入停止300ms后才执行
searchInput.addEventListener('input', debounce(function(e) {
handleSearch(e.target.value);
}, 300));
这里的timer变量被闭包保存着,每次触发事件时:
-
如果 300ms 内再次输入,就会清除上一个定时器,重新计时
-
只有停止输入 300ms 后,才会执行handleSearch
我试过把delay设成 0,结果发现还是有效果 ------ 能让事件在当前同步代码执行完后再执行,解决了一些 DOM 更新时机的问题。
三、节流
和防抖不同,节流是让函数在指定时间内只执行一次,比如滚动事件、 resize 事件,太频繁触发会导致页面卡顿。
同样用闭包保存状态:
js
function throttle(fn, interval) {
// 记录上次执行时间
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
fn.apply(this, args);
lastTime = now;
}
};
}
// 监听滚动
window.addEventListener('scroll', throttle(function() {
console.log('滚动位置:', window.scrollY);
}, 100)); // 每100ms最多执行一次
这里的lastTime被闭包保存,每次滚动时:
-
计算当前时间和上次执行时间的差
-
只有超过 100ms 才会执行,保证了函数不会执行太频繁
从浏览器控制台看打印结果可以看到,不加节流时,滚动一次页面可能触发几十次事件;加了 100ms 的节流后,最多触发 10 次,大大减轻了性能压力。防抖和节流的区别可以简单记:防抖是 "最后一次才执行",节流是 "每隔一段时间必执行一次" 。
四、绑定上下文
刚学事件监听时,我经常遇到this指向错误的问题。比如在类里写了个方法,绑定到按钮点击事件上,结果this变成了按钮元素,不是类实例。
这时候可以用闭包固定上下文,解决this指向问题:
js
class User {
constructor(name) {
this.name = name;
// 绑定this
this.showName = this.bindThis(this.showName);
}
// 用闭包绑定上下文的方法
bindThis(fn) {
const that = this;
return function(...args) {
return fn.apply(that, args);
};
}
showName() {
console.log('姓名:', this.name);
}
}
const user = new User('张三');
const btn = document.getElementById('btn');
btn.addEventListener('click', user.showName); // 张三
这里的bindThis方法返回了一个闭包,保存了that(即类实例),无论在什么上下文调用,fn都会以that作为this执行。
除了这种方式,还可以用箭头函数(本身没有 this,会继承外层 this)或Function.prototype.bind,但本质上都是通过闭包或类似机制保存了上下文。我个人在类里初始化时用bind比较多,代码更清晰。
五、事件监听器
给 DOM 元素绑定事件时,如果用匿名函数,后续想移除监听就麻烦了 ------ 因为找不到函数引用。这时候可以用闭包保存事件处理函数:
js
function createEventListener() {
// 用闭包保存事件处理函数
function handleClick(e) {
console.log('点击了:', e.target);
}
return {
// 绑定事件
add: function(element) {
element.addEventListener('click', handleClick);
},
// 移除事件
remove: function(element) {
element.removeEventListener('click', handleClick);
}
};
}
const listener = createEventListener();
const btn = document.getElementById('btn');
// 绑定事件
listener.add(btn);
// 不需要时移除
setTimeout(() => {
listener.remove(btn);
console.log('已移除点击事件');
}, 5000);
这里的handleClick被闭包保存着,add和remove方法都能访问到它,保证了移除事件时能找到正确的函数引用。
六、记忆函数
如果有一个计算量大的函数,多次调用时可以用闭包缓存结果,避免重复计算。这种函数叫记忆函数(memoization)。
比如计算斐波那契数列:
js
function createMemoFn(fn) {
// 用闭包缓存计算结果
const cache = {};
return function(n) {
if (cache[n] !== undefined) {
console.log(`从缓存获取${n}的结果`);
return cache[n];
}
const result = fn(n);
cache[n] = result;
console.log(`计算并缓存${n}的结果`);
return result;
};
}
// 计算斐波那契数列(递归,计算量大)
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 创建记忆函数
const memoFib = createMemoFn(fibonacci);
// 第一次计算,会缓存
console.log(memoFib(10)); // 55
// 第二次计算,直接用缓存
console.log(memoFib(10)); // 55
// 计算新值,会缓存
console.log(memoFib(11)); // 89
这里的cache对象被闭包保存,每次调用memoFib时:
-
先查缓存,如果有结果直接返回
-
没有就计算并存入缓存,下次再用
我测试过,计算memoFib(30)时,普通fibonacci要算几十万个节点,而记忆函数只需算 30 次,性能提升天差地别。不过这种方法适合参数有限且重复调用的场景,不然缓存会无限增大。
七、函数柯里化
柯里化(Currying)是把接收多个参数的函数,变成一系列接收单个参数的函数。比如add(1,2,3)变成add(1)(2)(3),这也需要闭包来保存中间参数。
举个例子:
js
// 柯里化函数
function curry(fn) {
// 保存已接收的参数
const args = [];
// 返回一个新函数,接收剩余参数
return function temp(...newArgs) {
args.push(...newArgs);
// 如果参数数量够了,就执行原函数
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return temp;
}
};
}
// 原函数计算三个数的和
function add(a, b, c) {
return a + b + c;
}
// 柯里化后
const curriedAdd = curry(add);
// 可以分多次传参
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
这里的args数组被闭包保存,每次调用temp时:
-
收集传入的参数
-
如果参数够了(达到原函数add的参数长度 3),就执行并返回结果
-
不够就返回temp函数,继续接收参数
柯里化的好处是可以 "分步传参",在一些场景下很有用。比如做表单验证时,可以先固定验证规则,再传入不同的输入值。
八、立即执行函数
最后说个简单但常用的 ------ 立即执行函数(IIFE),它能创建一个独立作用域,避免污染全局变量,本质上也是利用了闭包的特性。
js
// 立即执行函数,执行后销毁
(function() {
// 局部变量,不会污染全局
const message = 'Hello';
console.log(message); // Hello
})();
// 全局访问不到内部变量
console.log(typeof message); // undefined
const module = (function() {
const privateData = '秘密';
return {
getPrivateData: function() {
return privateData; // 闭包访问私有变量
}
};
})();
console.log(module.getPrivateData()); // 秘密
在以前IIFE 是避免全局污染的主要方式。现在虽然有了 ES6 Module,但理解 IIFE 有助于理解闭包和作用域。
最后
说了这么多场景,其实闭包的本质很简单:函数在定义的作用域之外执行时,依然能访问定义时的作用域里的变量。
但使用闭包要注意:
-
内存泄漏风险:闭包会让变量一直被引用,不会被垃圾回收。比如在 DOM 元素上绑定事件处理函数,如果元素被移除但事件没解绑,闭包会导致元素无法回收。解决方法是及时移除事件监听,或在不需要时手动解除引用。
-
性能影响:过度使用闭包可能会增加内存占用,复杂场景下要权衡。
-
调试难度:闭包会让变量作用域变得复杂,调试时可能不知道变量被哪个闭包修改了。我一般会在关键地方加console.log,追踪变量变化。
希望这篇文章能帮你像我一样,从 "一脸懵" 到 "熟练用",真正理解闭包的价值。如果有哪里说的不对,欢迎评论区指正,一起进步!