同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端正则干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、先搞清楚:什么是闭包、为什么重要
1.1 闭包的本质
闭包 = 函数 + 它能访问到的外部变量(划重点:“外部”是相对视角)。
当函数内部引用了外部的变量,且该函数在外部变量的作用域之外被执行时,就形成了闭包。JS 引擎会把这个变量“兜住”,不会在函数执行完后立刻回收。
先看最经典的例子,一步步拆明白:
function createCounter() {
let count = 0; // 站在返回的匿名函数视角:这是「外部变量」
return function () { // 这个函数被返到外部执行,形成闭包
count++;
return count;
};
}
// 1. 执行createCounter,返回匿名函数并赋值给counter
const counter = createCounter();
// 2. 第一次执行counter:找到闭包中的count,0→1,返回1
console.log(counter()); // 1
// 3. 第二次执行counter:复用同一个count,1→2,返回2
console.log(counter()); // 2
// 4. 第三次执行counter:2→3,返回3
console.log(counter()); // 3
核心逻辑:count 本应在 createCounter 执行完后被销毁,但因为返回的匿名函数还在引用它,所以被闭包“兜住”,多次调用能共享这个变量。
1.2 闭包能干啥
| 用途 | 说明 |
|---|---|
| 保存状态 | 多次调用之间共享变量,如计数器、缓存(调用一次变一次,不是每次都重置) |
| 封装私有变量 | 外部拿不到 count,只能通过返回的函数访问(避免全局变量污染) |
| 延迟执行时保持上下文 | 防抖、节流、定时器回调里,还能用到创建时的作用域变量 |
1.3 容易混的点(避坑!)
-
❌ 不是“只要函数套函数就是闭包”:关键是内部函数是否引用外部变量 + 内部函数是否在外部执行
-
❌ 闭包 ≠ 内存泄漏:合理使用不会出问题,只有大量 DOM 引用长期不释放才需要小心
-
❌ 循环里创建闭包容易踩坑:下文会专门讲
二、前置知识:call/apply/bind 到底怎么用(小白必看)
在讲防抖/节流前,必须先搞懂 call/apply/bind —— 这三个方法是用来手动控制函数 ** this ** 指向的核心工具,日常开发(尤其是封装函数时)离不开。
2.1 先搞懂:this 指向的“坑”
先看一个简单例子,理解为什么需要手动改 this:
const user = {
name: '张三',
sayHi() {
console.log(`你好,我是${this.name}`);
}
};
// 正常调用:this指向user,输出「你好,我是张三」
user.sayHi();
// 把函数抽出来单独调用:this指向全局(浏览器是window,Node是global)
const sayHi = user.sayHi;
sayHi(); // 输出「你好,我是undefined」(坑!)
问题核心:函数的 this 指向不是定义时决定的,而是调用时决定的。封装防抖/节流时,必须把原函数的 this 保留下来,否则会丢失上下文。
2.2 call/apply/bind 核心用法
三者的核心目的:改变函数执行时的 ** this ** 指向,区别仅在于参数传递方式和执行时机。
| 方法 | 语法 | 执行时机 | 参数传递 | 核心场景 |
|---|---|---|---|---|
| call | fn.call(thisArg, arg1, arg2, ...) | 立即执行 | 逐个传参 | 明确知道参数个数时 |
| apply | fn.apply(thisArg, [arg1, arg2, ...]) | 立即执行 | 数组传参 | 不确定参数个数(比如防抖/节流) |
| bind | fn.bind(thisArg, arg1, arg2, ...) | 返回新函数,不立即执行 | 逐个传参(可分批传) | 提前绑定this,后续再执行 |
示例1:call 用法(逐个传参)
const user = { name: '张三' };
function sayHi(age, gender) {
console.log(`你好,我是${this.name},${age}岁,${gender}`);
}
// 手动指定this为user,参数逐个传
sayHi.call(user, 20, '男'); // 输出:你好,我是张三,20岁,男
示例2:apply 用法(数组传参)
const user = { name: '张三' };
function sayHi(age, gender) {
console.log(`你好,我是${this.name},${age}岁,${gender}`);
}
// 参数是数组,适合参数个数不确定的场景
const args = [20, '男'];
sayHi.apply(user, args); // 输出:你好,我是张三,20岁,男
示例3:bind 用法(绑定后不立即执行)
const user = { name: '张三' };
function sayHi(age) {
console.log(`你好,我是${this.name},${age}岁`);
}
// 绑定this为user,传第一个参数20,返回新函数
const sayHiToZhang = sayHi.bind(user, 20);
// 后续执行新函数,可补传剩余参数(如果有)
sayHiToZhang(); // 输出:你好,我是张三,20岁
2.3 小白速记口诀
-
call 是“打电话”:逐个说参数,说完就执行;
-
apply 是“应用”:把参数打包成数组,应用上就执行;
-
bind 是“绑定”:先绑定关系,啥时候执行我说了算。
三、防抖(debounce):用闭包“兜住”定时器
3.1 场景
搜索框输入、按钮快速点击、窗口 resize:每操作一次就触发逻辑(比如发请求),会产生大量无效操作。
期望:用户停手一段时间后,再执行一次(比如输入完300ms不敲了,再发搜索请求)。
3.2 原理
“最后一次操作”触发后,等指定时间再执行;若这段时间内有新操作,就重新计时。核心是:**用闭包保存 ** timer ,让多次调用共享同一个定时器。
先上可直接用的防抖函数,再拆逻辑:
function debounce(fn, delay) {
let timer = null; // 闭包变量:多次调用debounce返回的函数,共用这个timer
return function (...args) {
// 1. 有未执行的定时器?先清掉(重新计时)
if (timer) clearTimeout(timer);
// 2. 重新设置定时器,delay毫秒后执行原函数
timer = setTimeout(() => {
// 关键:用apply保留this指向,args是接收的参数数组
fn.apply(this, args);
timer = null; // 执行完清空,方便下次判断
}, delay);
};
}
// 用法示例:搜索框防抖
const search = debounce(function (keyword) {
console.log('发起搜索:', keyword);
}, 300);
search('a'); // 触发后300ms内又有新操作,被清空,不执行
search('ab'); // 同上,被清空,不执行
search('abc'); // 300ms后执行:发起搜索: abc
核心细节:
-
...args:接收调用时传的所有参数(比如search('abc')里的'abc'); -
fn.apply(this, args):把原函数的this指向和参数完整保留,避免丢失上下文(比如搜索框绑定的this是输入框DOM元素); -
如果没有闭包:每次调用
search都会创建新的timer,clearTimeout清不掉之前的定时器,防抖直接失效。
3.3 进阶:给防抖加“取消能力”
组件卸载时(比如Vue的卸载钩子),若定时器还没执行,会导致内存泄漏或访问已销毁的组件。给防抖函数加取消方法:
function debounce(fn, delay) {
let timer = null;
// 把防抖逻辑抽成函数,方便挂载取消方法
const debounced = function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args); // 保留this和参数
timer = null;
}, delay);
};
// 新增:取消未执行的定时器
debounced.cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
};
return debounced;
}
// 用法:组件卸载时调用
const search = debounce(() => {}, 300);
search.cancel(); // 清空定时器,避免残留
四、节流(throttle):用闭包“兜住”上次执行时间
4.1 场景
滚动加载更多、窗口 resize、按钮防重复提交:希望 在一定时间内最多执行一次,而不是只等最后一次执行(和防抖的核心区别)。
4.2 原理
用闭包保存“上次执行时间”或“是否在冷却中”,判断当前是否能执行原函数。
版本1:时间戳版(首次立刻执行,之后按间隔执行)
function throttle(fn, interval) {
let last = 0; // 闭包变量:保存上次执行时间,初始为0
return function (...args) {
const now = Date.now();
// 现在时间 - 上次执行时间 ≥ 间隔:可以执行
if (now - last >= interval) {
last = now; // 更新上次执行时间
fn.apply(this, args); // 保留this和参数
}
};
}
版本2:定时器版(首次延迟执行,最后一次也会执行)
function throttle(fn, interval) {
let timer = null; // 闭包变量:标记是否在冷却中
return function (...args) {
// 还在冷却中?直接返回,不执行
if (timer) return;
// 不在冷却中:设置定时器,interval后执行
timer = setTimeout(() => {
fn.apply(this, args); // 保留this和参数
timer = null; // 执行完,退出冷却
}, interval);
};
}
4.3 防抖 vs 节流 怎么选(记人话版)
| 场景 | 更合适 | 原因 |
|---|---|---|
| 搜索框输入、输入框验证 | 防抖 | 只在“停手”后执行一次,减少无效请求 |
| 窗口 resize、元素拖拽 | 防抖或节流 | 防抖:停止调整后再计算;节流:调整过程中定期计算 |
| 滚动加载更多、滚动监听 | 节流 | 滚动过程中要持续判断(比如是否滑到页面底部) |
| 按钮防重复提交 | 节流 | 简单直接:1秒内只能点一次,不用等“停手” |
| ✅ 记忆口诀:停一下再动用防抖,一直动要限频用节流。 |
五、函数工厂:用闭包“生产”定制函数
5.1 场景
不同模块有不同前缀的日志、不同 baseURL 的请求、不同 key 的本地存储:需要“同一套逻辑 + 不同配置”,避免写重复代码。
5.2 原理
工厂函数接收配置(比如日志前缀、接口 baseURL),返回一个“记住配置”的函数——配置通过闭包保存,不用每次传。
示例1:定制化日志函数
// 日志工厂:接收前缀,返回带固定前缀的日志函数
function createLogger(prefix) {
// prefix被闭包保存,每次调用都能用
return function (...args) {
console.log(`[${prefix}]`, ...args);
};
}
// 生产2个定制日志函数
const apiLog = createLogger('API'); // 记住prefix=API
const uiLog = createLogger('UI'); // 记住prefix=UI
apiLog('请求成功'); // [API] 请求成功
uiLog('按钮点击'); // [UI] 按钮点击
示例2:实战 - 带 baseURL 的请求封装
日常开发中最常用的场景,避免每次请求都拼完整 URL:
// 请求工厂:接收baseURL,返回拼好地址的请求函数
function createRequest(baseURL) {
return function (path, options = {}) {
const url = `${baseURL}${path}`; // 闭包复用baseURL
return fetch(url, options);
};
}
// 按业务域拆分请求
const userApi = createRequest('/api/user'); // 记住baseURL=/api/user
const orderApi = createRequest('/api/order');// 记住baseURL=/api/order
userApi('/list'); // 实际请求 /api/user/list
orderApi('/list'); // 实际请求 /api/order/list
5.3 注意点
-
命名规范:
createXxx表示“工厂函数”(比如createLogger),Vue 中useXxx多表示 hook/composable -
避免过度嵌套:工厂套工厂会让代码可读性变差,够用就好
六、简单缓存:用闭包“兜住”缓存对象
6.1 场景
接口权限列表、字典数据、地区数据:短时间内多次使用,希望只请求一次,后续直接读缓存(减少接口请求,提升性能)。
6.2 原理
用闭包保存一个 Map 或普通对象,把“key → 结果”存进去,下次调用先查缓存,有就直接返回,没有再请求。
先上基础版缓存函数:
function createCache(fetcher, ttl = 5 * 60 * 1000) {
const cache = new Map(); // 闭包缓存:key → { value, expireAt }
return async function (key) {
// 1. 先查缓存:有且没过期,直接返回缓存值
const cached = cache.get(key);
if (cached && Date.now() < cached.expireAt) {
return cached.value;
}
// 2. 无缓存/已过期:执行请求,存入缓存
const value = await fetcher(key);
cache.set(key, { value, expireAt: Date.now() + ttl });
return value;
};
}
// 用法:权限列表按userId缓存5分钟
const getPermissions = createCache(
async (userId) => {
const res = await fetch(`/api/user/${userId}/permissions`);
return res.json();
},
5 * 60 * 1000
);
// 同一用户多次调用,只会发一次请求
const p1 = await getPermissions('u001'); // 发请求,存入缓存
const p2 = await getPermissions('u001'); // 命中缓存,直接返回
6.3 进阶:带清除能力的权限缓存(生产级)
function createPermissionCache(getPermissionsApi) {
const cache = new Map(); // userId -> { permissions, expireAt }
const TTL = 5 * 60 * 1000; // 缓存5分钟
return {
// 获取权限:优先读缓存
async get(userId) {
const cached = cache.get(userId);
if (cached && Date.now() < cached.expireAt) {
return cached.permissions;
}
// 无缓存:请求并存入
const permissions = await getPermissionsApi(userId);
cache.set(userId, {
permissions,
expireAt: Date.now() + TTL,
});
return permissions;
},
// 清除缓存:用户退出/切换时调用
clear(userId) {
if (userId) {
cache.delete(userId); // 清单个用户
} else {
cache.clear(); // 清所有缓存
}
},
};
}
// 实际使用
const permissionCache = createPermissionCache(
(userId) => fetch(`/api/user/${userId}/permissions`).then(r => r.json())
);
// 页面中获取权限
const perms = await permissionCache.get(currentUserId);
if (perms.includes('admin')) { /* 显示管理员按钮 */ }
// 用户退出时清缓存
permissionCache.clear(currentUserId);
6.4 避坑:防止并发重复请求
同一 key 同时多次调用时,会发多个重复请求?给缓存加“请求中”标记:
function createCache(fetcher, ttl = 5 * 60 * 1000) {
const cache = new Map();
const pending = new Map(); // 闭包:key → 进行中的请求Promise
return async function (key) {
// 1. 查缓存
const cached = cache.get(key);
if (cached && Date.now() < cached.expireAt) {
return cached.value;
}
// 2. 有进行中的请求?复用这个Promise
if (pending.has(key)) {
return pending.get(key);
}
// 3. 无缓存无请求:发起请求
const promise = fetcher(key).then((value) => {
cache.set(key, { value, expireAt: Date.now() + ttl });
pending.delete(key); // 请求完成,移除标记
return value;
});
pending.set(key, promise);
return promise;
};
}
七、循环中的闭包坑(经典面试题)
先看坑:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:3 3 3(不是0 1 2!)
原因:var 声明的 i 是全局变量,整个循环共用一个,定时器回调执行时,循环已经结束,i 变成 3 了。
解决方法(2种)
方法1:用 let(推荐,最简单)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0 1 2
原理:let 在每次循环会创建块级作用域,每个定时器回调拿到的是当前循环的 i(相当于每个回调都有自己的闭包变量)。
方法2:用 IIFE 制造闭包(兼容旧环境)
for (var i = 0; i < 3; i++) {
// 立即执行函数,把当前i传给j,j被闭包保存
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// 输出:0 1 2
八、小结(核心速记)
8.1 call/apply/bind 核心
-
三者都用来改变函数的 this 指向,
call逐个传参、apply数组传参、bind绑定后不立即执行; -
封装防抖/节流时,必须用
apply保留原函数的this和参数,避免上下文丢失。
8.2 闭包核心用法
| 场景 | 闭包保存什么 | 核心作用 |
|---|---|---|
| 防抖 | timer | 共享定时器,实现“停手后执行” |
| 节流 | last(时间戳)/ timer(冷却标记) | 限制执行频率,实现“一段时间只执行一次” |
| 函数工厂 | 配置参数(前缀、baseURL等) | 定制函数,避免重复代码 |
| 简单缓存 | Map / 对象(缓存数据) | 复用请求/计算结果,提升性能 |
8.3 闭包本质
闭包的核心是 函数能访问并保留它创建时的作用域变量。写代码时想清楚“要在多次调用之间保留什么变量”,闭包就用对了。
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~