同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。
一、先搞清楚:什么是闭包、为什么重要
1.1 闭包的本质
闭包其实就是JS里一个“变量不被清理”的特殊现象,核心就看「谁包谁」(内外)和「在哪用」(执行位置),用生活例子讲透:
1.11 先搞懂“内”和“外”(最关键)
不用想复杂的“作用域”,就看函数的包裹关系:
-
把代码里的函数想象成套间:大套间里套着小卧室(一个函数里定义了另一个函数);
-
小卧室(内层函数)是“内”,包裹它的大套间(外层函数)是“外”;
-
大套间里的冰箱(外层函数里的变量),对小卧室来说,就是“外部变量”。
1.12 闭包的完整过程(一句话+代码)
正常情况下,你离开大套间(外层函数执行完),套间里的冰箱会被搬走(变量被JS回收);但如果小卧室里的你(内层函数)拿了冰箱钥匙(引用外部变量),还走出小卧室、甚至走出大套间去用冰箱(内层函数在外部函数的作用域之外执行),JS就会专门把这个冰箱留着不搬走(保留外部变量)——这就是闭包。
代码示例(对应上面的比喻)
// 外层函数 = 大套间
function 大套间() {
// 外层变量 = 大套间里的冰箱(对内层函数来说是“外部变量”)
let 冰箱里的可乐 = "冰可乐";
// 内层函数 = 小卧室
function 小卧室() {
// 内层函数引用了“外部”的变量(拿冰箱钥匙)
console.log("我要喝" + 冰箱里的可乐);
}
// 把内层函数“带出”小卧室(离开自己的作用域)
return 小卧室;
}
// 拿到内层函数(走出大套间)
let 走出套间的我 = 大套间();
// 执行内层函数(在套间外面用冰箱)—— 形成闭包,可乐变量没被回收
走出套间的我(); // 输出:我要喝冰可乐
-
闭包的“内外”只看函数包裹关系:内层函数能访问的、外层函数里的变量,就是内层的“外部变量”;
-
闭包的触发条件:内层函数引用了外层变量,且在内层函数的“老家”(外层函数作用域)之外执行;
-
闭包的核心效果:JS不会按常规回收这个外层变量,而是专门留给内层函数使用。
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 音标 : / di'bauns /):用闭包“兜住”定时器
3.1 场景
搜索框输入、按钮快速点击、窗口 resize:每操作一次就触发逻辑(比如发请求),会产生大量无效操作。
期望:用户停手一段时间后,再执行一次(比如输入完300ms不敲了,再发搜索请求)。
3.2 原理
“最后一次操作”触发后,等指定时间再执行;若这段时间内有新操作,就重新计时。核心是:**用闭包保存 ** timer ,让多次调用共享同一个定时器。
先上可直接用的通用防抖函数,再拆逻辑:
function debounce(fn, delay) {
let timer = null; // 闭包变量:多次调用debounce返回的函数,共用这个timer
return function (...args) {
// 打印闭包函数的this,看清楚指向谁
console.log('闭包函数this:', this);
// 1. 有未执行的定时器?先清掉(重新计时)
if (timer) clearTimeout(timer);
// 2. 重新设置定时器,delay毫秒后执行原函数
timer = setTimeout(() => {
fn.apply(this, args); // 核心:传递this和参数
timer = null; // 执行完清空,方便下次判断
}, delay);
};
}
// 定义一个“搜索框对象”(模拟真实业务中的对象)
const searchBox = {
name: "商品搜索框",
value: "", // 存储输入的内容
// 原函数:依赖this获取当前对象的value
doSearch: function (keyword) {
this.value = keyword; // 给当前对象的value赋值
console.log(`【${this.name}】发起搜索:${this.value}`);
},
};
// 给searchBox绑定防抖版的搜索方法
searchBox.debounceSearch = debounce(searchBox.doSearch, 300);
// 场景1:用对象调用防抖函数(this指向searchBox)
searchBox.debounceSearch('手机');
// 输出1:闭包函数this: {name: '商品搜索框', value: '', doSearch: ƒ, debounceSearch: ƒ}
// 输出2:【商品搜索框】发起搜索:手机
// 场景2:如果去掉apply,直接fn(args)会怎样?
// (修改debounce里的代码为fn(args)后)
// 输出1:闭包函数this: 同上(依然是searchBox)
// 输出2:【undefined】发起搜索:undefined
3.3 核心疑问拆解(新手必看)
疑问1:闭包函数的this已经是searchBox了,为啥不能直接fn()?
核心原因:传给debounce的fn是“孤立的函数”,和原对象解绑了。
-
当执行
searchBox.debounceSearch = debounce(searchBox.doSearch, 300)时,searchBox.doSearch被赋值给fn,此时fn只是一个独立函数体,和searchBox无绑定关系; -
直接
fn(args)属于“裸调用”,非严格模式下this指向全局(window),因此this.name/this.value都是 undefined; -
只有通过
fn.apply(this, args),才能强制把fn的this绑定到闭包函数的this(即searchBox)。
疑问2:那写this.fn()行不行?
不行!会直接报错 Uncaught TypeError: this.fn is not a function。
-
this.fn()里的this确实是闭包函数的this(searchBox); -
但
searchBox上只有name/value/doSearch/debounceSearch,没有fn属性,调用必然报错。
疑问3:3种写法对比(一目了然)
| 写法 | 含义 | 执行结果 |
|---|---|---|
fn(args) | 裸调用孤立函数,this=window | 输出【undefined】发起搜索:undefined |
this.fn() | 调用this(searchBox)上的fn属性 | 报错:this.fn is not a function |
fn.apply(this, args) | 绑定this=searchBox后执行fn | 输出【商品搜索框】发起搜索:手机 |
疑问4:为什么要写timer = null?(不是性能问题!)
哪怕没有 timer = null,clearTimeout 依然能取消定时器,防抖功能本身正常——但会导致「状态混乱」:
-
理想状态:有未执行定时器 → timer=数字(ID);无定时器 → timer=null;
-
不写
timer = null:定时器执行后,timer仍存着已失效的ID,if (timer)永远为true,下次调用会清失效ID(逻辑瑕疵); -
核心作用:让timer变量状态和实际定时器状态一致,避免调试时的逻辑误导,提升代码健壮性。
3.4 专用版vs通用版(新手易踩坑)
很多同学会写出“场景专用版”防抖函数(如下),看似能跑,但复用性极差:
❶ 场景专用版(仅适用于searchBox)
function debounce(fn, delay) {
let timer = null;
return function (...arg) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
console.log(`${arg}`);
console.log(this);
this.doSearch(`${arg}`) // 硬编码doSearch方法名
timer = null;
}, delay);
};
}
-
优点:在
searchBox.debounceSearch()场景下能正常执行; -
缺点:硬编码
doSearch方法名,fn参数形同虚设,无法适配其他函数/其他调用者(比如全局调用、其他对象调用)。
❷ 通用版(推荐,适配所有场景)
function debounce(fn, delay) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args); // 执行传入的任意fn,绑定当前this
timer = null;
}, delay);
};
}
-
优点:
fn参数是真正要执行的函数,this指向由调用者决定,可通过bind灵活修改; -
核心价值:哪怕全局调用,也能通过绑定
this修复上下文:// 全局调用时,提前绑定this为searchBox const globalSearch = debounce(searchBox.doSearch, 500).bind(searchBox); globalSearch("6"); // 输出:【商品搜索框】发起搜索:6
3.5 进阶:给防抖加“取消能力”
新手最容易忽略的问题:Vue 组件卸载/路由跳转时,防抖函数的定时器还没执行,会导致两个坑:
-
内存泄漏:定时器占用的内存没被释放,项目越运行越卡;
-
报错:定时器回调执行时,组件已经销毁(比如访问
this为 undefined、调用this.$axios报错)。
解决办法:给防抖函数加一个 cancel 方法,主动清空未执行的定时器。
3.5.1 完整代码(带 cancel 方法)
function debounce(fn, delay) {
let timer = null; // 闭包变量:保存定时器ID
// 把防抖逻辑抽成独立函数,方便挂载cancel方法
const debounced = function (...args) {
// 有未执行的定时器?先清掉(重新计时)
if (timer) clearTimeout(timer);
// 重新设置定时器
timer = setTimeout(() => {
fn.apply(this, args); // 保留this和参数
timer = null; // 执行完清空,保持状态一致
}, delay);
};
// 新增:取消未执行的定时器(核心新增逻辑)
debounced.cancel = function () {
if (timer) { // 有未执行的定时器才清
clearTimeout(timer); // 清空定时器
timer = null; // 重置timer,避免状态混乱
}
};
// 返回带cancel方法的防抖函数
return debounced;
}
3.5.2 核心逻辑拆解(小白能懂)
❶ 为什么能给函数加 .cancel 方法?
JavaScript 中函数也是对象(重点!),就像给 Vue 实例加属性/方法一样,我们可以给 debounced 这个函数挂载 cancel 方法:
-
debounced是防抖的核心函数(执行防抖逻辑); -
debounced.cancel是附加方法(专门用来取消定时器); -
两者共享同一个
timer变量(闭包特性),所以cancel能精准清空定时器。
❷ Vue 中怎么用?(分版本实战)
不管是 Vue 2 还是 Vue 3,核心逻辑都是「在组件卸载时调用 cancel」,只是钩子函数写法不同:
✨ Vue 3 实战示例(组合式 API,可直接复制)
<template>
<!-- 输入框输入时,把事件对象传给 handleInput -->
<input
type="text"
@input="handleInput"
placeholder="输入搜索关键词(防抖版)"
/>
</template>
<script setup>
import { onUnmounted } from 'vue';
// 假设我们把上面的 debounce 函数放在了工具文件里
// import { debounce } from '@/utils/防抖函数';
// 1. 定义原始的搜索逻辑(这是你真正想做的事)
const doSearch = (keyword) => {
console.log(`[搜索请求] 关键词:${keyword}`);
// 实际项目中这里会是:await axios.get('/api/search', { params: { keyword } });
};
// 2. 创建防抖版的搜索函数(核心步骤!)
// 延迟 500ms 执行,创建后会一直复用这个闭包
const debouncedSearch = debounce(doSearch, 500);
// 3. 定义输入事件处理器
const handleInput = (e) => {
const keyword = e.target.value.trim();
// 调用防抖函数,并传入参数
debouncedSearch(keyword);
};
// 4. 关键操作:组件卸载时取消防抖(防止内存泄漏)
onUnmounted(() => {
console.log('组件卸载,取消防抖定时器');
debouncedSearch.cancel(); // 执行取消操作
});
</script>
Vue 2 兼容写法(选项式 API)
考虑到部分小白读者可能还在维护 Vue 2 项目,这里补充经典的 options API 写法:
<template>
<input type="text" @input="handleInput" placeholder="Vue 2 防抖示例" />
</template>
<script>
// import { debounce } from '@/utils/防抖函数';
export default {
name: 'SearchBox',
methods: {
// 1. 原始搜索逻辑
doSearch(keyword) {
console.log(`[Vue2搜索] 关键词:${keyword}`);
},
// 2. 输入处理
handleInput(e) {
// 直接调用挂载在 this 上的防抖函数
this.debouncedSearch(e.target.value.trim());
}
},
created() {
// 3. 在创建阶段,把防抖函数挂载到实例上
this.debouncedSearch = debounce(this.doSearch, 500);
},
// 4. 在销毁阶段取消
destroyed() {
this.debouncedSearch.cancel();
}
};
</script>
3.5.3 小白必懂:核心疑问解答
❶ 为什么要在 onUnmounted 里调用?
假设你输入了“手机”,还没等 500ms 到,你就快速跳转到了“首页”。此时搜索组件已经被销毁了。
-
不加 cancel:500ms 后,
doSearch依然会执行,如果里面有请求,就会发一个“无意义”的请求。 -
加了 cancel:组件一卸载,定时器就被清空,彻底杜绝了无效代码执行。
❷ 为什么 debouncedSearch 能调用 .cancel?
回顾我们的防抖函数代码,它返回的 debounced 不仅仅是一个函数,更是一个带属性的特殊对象。
-
debouncedSearch():执行防抖逻辑。 -
debouncedSearch.cancel():执行取消逻辑。 -
它们共用同一个
timer变量,所以能精准控制。
四、节流(throttle 音标 : / θrɒtl /):用闭包“兜住”上次执行时间
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和参数
}
};
}
流程讲解:
首次进入的时候last默认是0,走到if 的时候怎么计算都是大于interval的,所以是首次立刻执行。
首次进入if之后last就有值了,此时再次进入程序now - last就不一定>= interval了。只有满足now - last >= interval之后if代码块中的代码才会执行,所以是首次执行之后按间隔执行。
版本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);
};
}
流程讲解:
第一次进入程序的时候timer是null,所有走不到return。进入setTimeout后timer有值。第二次进入程序的时候timer有值,走到if的时候return结束了本次访问。直到首次setTimeout执行完毕之后timer赋值null,再次调用才会进入到setTimeout中。所以是首次延迟执行,最后一次也会知性
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
fetch说明:fetch 是浏览器原生提供的 API(无需引入任何库),基于 Promise 实现(所以支持 async/await 语法),专门用来发送 HTTP 请求(GET/POST/PUT/DELETE 等)、获取远程资源(接口数据、文件、图片等)
5.3 注意点
-
命名规范:
createXxx表示“工厂函数”(比如createLogger),Vue 中useXxx多表示 hook/composable; -
避免过度嵌套:工厂套工厂会让代码可读性变差,够用就好。
六、简单缓存:用闭包“兜住”缓存对象
6.1 场景
接口权限列表、字典数据、地区数据:短时间内多次使用,希望只请求一次,后续直接读缓存(减少接口请求,提升性能)。 这类数据通常变化频率低、查询频率高,缓存后能显著优化页面响应速度,避免重复向服务器发送请求造成的资源浪费。
小白提问:什么数据适合做缓存?普通的用户消息、订单状态能缓存吗? 通俗解答:就记一句话——变化慢、用得多的 data 适合缓存(比如地区列表,好几年不变,每次打开页面都要用到);变化快的(比如实时消息、刚下单的订单状态)不适合,缓存了会显示旧数据,反而出错。
6.2 原理
用闭包保存一个 Map 或普通对象作为缓存容器,把“key → 结果”的对应关系存进去。每次调用缓存函数时,先通过key查询缓存:若缓存存在且未过期,直接返回缓存数据;若缓存不存在或已过期,执行真正的请求/计算逻辑,再将结果存入缓存,供后续调用复用。核心是利用闭包让缓存容器在函数多次调用后依然保留,不被JS垃圾回收机制清理。
小白提问:闭包在这里到底起啥作用?没有闭包不行吗? 通俗解答:没有闭包真不行!如果不用闭包,每次调用缓存函数,都会重新创建一个新的缓存容器(比如新的Map),相当于“每次都从零开始”,根本达不到“一次请求、多次复用”的效果。闭包就像一个“专属储物盒”,把缓存容器装起来,不管你调用多少次函数,这个储物盒都一直在,里面的缓存数据也不会丢。
6.3 基础版缓存函数(逐行拆解,新手小白可直接复制使用)
// 缓存工厂函数:接收2个参数
// 1. fetcher:真正发请求/做计算的函数(比如请求用户权限、字典数据)
// 2. ttl:缓存有效期(默认5分钟,单位毫秒),可根据业务调整
function createCache(fetcher, ttl = 5 * 60 * 1000) {
// 闭包变量1:用Map存缓存数据,结构是 key → { value: 实际数据, expireAt: 过期时间戳 }
// Map的好处:键可以是任意类型(比如对象、数字),比普通对象更灵活,查找效率更高
const cache = new Map();
// 返回一个异步函数(因为fetcher大概率是异步请求,如接口调用),这个函数会被多次调用
return async function (key) {
// 第一步:先查缓存,根据key获取缓存数据
const cached = cache.get(key);
// 判断:缓存存在 且 没过期(当前时间 < 过期时间)
if (cached && Date.now() < cached.expireAt) {
console.log(`✅ 命中缓存:key=${key},直接返回`);
return cached.value; // 有缓存就直接返回,不执行后续请求/计算逻辑
}
// 第二步:无缓存/缓存过期,执行真正的逻辑(比如发接口请求)
console.log(`❌ 未命中缓存:key=${key},执行请求/计算`);
const value = await fetcher(key); // 等待请求/计算完成(异步操作需加await)
// 第三步:把结果存入缓存,同时设置过期时间
cache.set(key, {
value: value, // 存入实际获取到的数据
expireAt: Date.now() + ttl // 计算过期时间(当前时间 + 缓存有效期)
});
return value; // 返回最新获取的数据,供本次调用使用
};
}
// ------------------- 新手友好的用法示例(可直接复制测试) -------------------
// 1. 定义一个“真正发请求”的函数(模拟后端接口,实际项目替换为axios/fetch请求)
async function getPermissionsApi(userId) {
// 模拟网络请求延迟500ms,还原真实接口调用场景
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟返回不同用户的权限数据(实际项目中是后端返回的真实数据)
return userId === 'u001' ? ['admin', 'edit', 'view'] : ['view'];
}
// 2. 用工厂函数创建“带缓存的权限请求函数”,缓存有效期设为5分钟
const getPermissions = createCache(getPermissionsApi, 5 * 60 * 1000);
// 3. 测试:多次调用同一个用户的权限请求,观察缓存效果
async function testCache() {
console.log('第一次调用(无缓存,需发请求):');
const p1 = await getPermissions('u001'); // 会执行getPermissionsApi,存入缓存
console.log('用户u001权限:', p1); // 输出:['admin', 'edit', 'view']
console.log('\n第二次调用(命中缓存,不发请求):');
const p2 = await getPermissions('u001'); // 直接读缓存,瞬间返回
console.log('用户u001权限:', p2); // 输出:['admin', 'edit', 'view']
console.log('\n调用另一个用户(无缓存,需发请求):');
const p3 = await getPermissions('u002'); // 执行请求,存入新缓存
console.log('用户u002权限:', p3); // 输出:['view']
console.log('\n第三次调用u001(仍命中缓存):');
const p4 = await getPermissions('u001'); // 继续读缓存
console.log('用户u001权限:', p4); // 输出:['admin', 'edit', 'view']
}
// 执行测试函数,查看控制台输出,理解缓存工作流程
testCache();
6.3.1 代码执行结果(直观理解缓存效果)
第一次调用(无缓存,需发请求):
❌ 未命中缓存:key=u001,执行请求/计算
用户u001权限:[ 'admin', 'edit', 'view' ]
第二次调用(命中缓存,不发请求):
✅ 命中缓存:key=u001,直接返回
用户u001权限:[ 'admin', 'edit', 'view' ]
调用另一个用户(无缓存,需发请求):
❌ 未命中缓存:key=u002,执行请求/计算
用户u002权限:[ 'view' ]
第三次调用u001(仍命中缓存):
✅ 命中缓存:key=u001,直接返回
用户u001权限:[ 'admin', 'edit', 'view' ]
6.3.2 核心疑问解答(整合问答,小白必看)
-
为什么用
async/await? 答:因为缓存的场景大多是“请求接口”,接口请求是异步的(需要等后端返回数据,就像你发消息给朋友,要等他回复才能继续聊天),所以整个缓存函数要支持异步。用async/await能让异步代码更简单,不用写复杂的回调函数,小白也能看懂。如果你的请求是同步的(比如本地计算),去掉async/await就能用。 -
ttl是什么?怎么调整? 答:TTL 是 Time To Live 的缩写,就是“缓存存活时间”,单位是毫秒。默认是5分钟(5601000ms),简单说就是:缓存存5分钟,5分钟内再用,直接读缓存;超过5分钟,就重新发请求拿新数据。 调整方法:比如想让缓存存10秒,就把ttl改成 101000;想存1小时,就改成 6060*1000,根据数据变化快慢调整就行。 -
为什么用
Map而不是普通对象? 答:普通对象的键只能是字符串或数字(比如只能用 'u001' 当key),但Map的键可以是任意类型(比如直接用用户对象当key)。举个例子:如果你想缓存“用户对象”对应的权限,用Map能直接存,普通对象做不到;而且Map找数据、删数据比普通对象更快,适合当缓存容器。 -
缓存会一直占内存吗?会不会让页面变卡? 答:会占内存,但只要合理设置
ttl就不用担心。缓存过期后,新的请求会覆盖旧缓存,不会一直占用大量内存;如果数据很少(比如地区列表、简单字典),占用的内存可以忽略不计,完全不会让页面变卡。
6.4 进阶:带清除能力的权限缓存(生产级)
// 权限缓存工厂:专门封装“用户权限”的缓存逻辑
// 参数:getPermissionsApi → 真正发请求的函数
function createPermissionCache(getPermissionsApi) {
// 闭包变量1:缓存用户权限,key=userId,value={ permissions: 权限列表, expireAt: 过期时间 }
const cache = new Map();
// 闭包变量2:固定缓存有效期(5分钟),写死在内部更符合“权限缓存”的业务场景
const TTL = 5 * 60 * 1000;
// 返回一个“对象”,包含2个方法:get(获取权限)、clear(清除缓存)
return {
// 方法1:获取权限(核心逻辑和基础版一致)
async get(userId) {
// 1. 查缓存
const cached = cache.get(userId);
if (cached && Date.now() < cached.expireAt) {
console.log(`✅ 命中权限缓存:userId=${userId}`);
return cached.permissions;
}
// 2. 无缓存:发请求
console.log(`❌ 未命中权限缓存:userId=${userId},请求接口`);
const permissions = await getPermissionsApi(userId);
// 3. 存缓存
cache.set(userId, {
permissions: permissions,
expireAt: Date.now() + TTL,
});
return permissions;
},
// 方法2:清除缓存(重点:业务中必须有这个方法!)
clear(userId) {
if (userId) {
// 传了userId:只清这个用户的缓存(比如用户退出登录)
cache.delete(userId);
console.log(`🗑️ 清除单个用户缓存:userId=${userId}`);
} else {
// 没传userId:清空所有缓存(比如系统刷新)
cache.clear();
console.log(`🗑️ 清除所有用户权限缓存`);
}
},
};
}
// ------------------- 用法示例(可直接复制) -------------------
// 1. 模拟接口请求函数
async function getPermissionsApi(userId) {
await new Promise(resolve => setTimeout(resolve, 500));
return userId === 'u001' ? ['admin', 'edit'] : ['view'];
}
// 2. 创建权限缓存实例
const permissionCache = createPermissionCache(getPermissionsApi);
// 3. 业务中使用
async function businessDemo() {
// 获取u001的权限(第一次请求)
const perms1 = await permissionCache.get('u001');
console.log('perms1:', perms1); // ['admin', 'edit']
// 再次获取u001的权限(命中缓存)
const perms2 = await permissionCache.get('u001');
console.log('perms2:', perms2); // ['admin', 'edit']
// 用户退出登录:清除该用户的缓存
permissionCache.clear('u001');
// 清除后再次获取(重新请求)
const perms3 = await permissionCache.get('u001');
console.log('perms3:', perms3); // ['admin', 'edit']
// 清空所有缓存(比如系统维护)
permissionCache.clear();
}
businessDemo();
6.4.1 代码执行结果
❌ 未命中权限缓存:userId=u001,请求接口
perms1: [ 'admin', 'edit' ]
✅ 命中权限缓存:userId=u001
perms2: [ 'admin', 'edit' ]
🗑️ 清除单个用户缓存:userId=u001
❌ 未命中权限缓存:userId=u001,请求接口
perms3: [ 'admin', 'edit' ]
🗑️ 清除所有用户权限缓存
6.4.2 核心疑问解答(整合问答,小白必看)
-
为什么要加
clear方法?不加不行吗? 答:不加肯定不行!业务中一定会有“缓存失效”的场景,比如:用户修改了权限(缓存里的旧权限就错了)、用户退出登录(不能再保留他的权限缓存)、系统刷新(需要重新加载所有数据)。这时候必须手动清除旧缓存,否则会读到错误数据,导致功能异常。 -
为什么返回对象而不是函数?和基础版不一样? 答:基础版只需要“获取缓存”这一个功能,所以返回一个函数就够了;进阶版需要“获取缓存+清除缓存”两个功能,返回对象可以把这两个方法装在一起(就像一个工具包,里面有两个工具),而且两个方法能共享同一个
cache缓存容器(闭包的作用),逻辑更清晰,用起来也更方便。 -
clear方法不传userId,为什么会清空所有缓存? 答:这是故意设计的!方便应对“系统维护、批量更新权限”这类场景——比如系统升级后,所有用户的权限都可能变,一个个清除太麻烦,不传userId,直接清空所有缓存,简单又高效。
6.5 避坑:防止并发重复请求(重点!小白必看)
6.5.1 问题场景(小白能懂的通俗描述)
比如页面加载时,有3个组件同时请求“u001”的权限,用基础版缓存会出问题:3个组件会同时发3次请求,而不是只发1次!这会给后端服务器增加压力,也浪费网络资源——就像3个人同时去超市买同一件东西,明明买1份就够,却非要各买1份,多此一举。
// 同时调用3次u001的权限
Promise.all([
permissionCache.get('u001'),
permissionCache.get('u001'),
permissionCache.get('u001')
]);
// 结果:发3次请求,而不是1次!
6.5.2 解决代码(拆解,小白可直接复制)
// 第一步:定义缓存工厂函数(解决并发问题的版本)
function createCache(fetcher, ttl = 5 * 60 * 1000) {
const cache = new Map(); // 存缓存数据
const pending = new Map(); // 存正在进行的请求Promise(核心防并发)
return async function (key) {
// 1. 查缓存:有缓存且未过期,直接返回
const cached = cache.get(key);
if (cached && Date.now() < cached.expireAt) {
console.log(`✅ 命中缓存:key=${key}`);
return cached.value;
}
// 2. 查是否有正在进行的请求(核心防并发逻辑)
if (pending.has(key)) {
console.log(`⏳ 有正在进行的请求:key=${key},复用Promise`);
return pending.get(key); // 复用正在请求的Promise,不发新请求
}
// 3. 发起新请求(正确的await写法)
console.log(`❌ 发起新请求:key=${key}`);
// 第一步:先创建Promise,并存入pending(核心!必须先存)
const promise = fetcher(key);
pending.set(key, promise); // 这一步要在await之前做
try {
// 第二步:等待Promise完成
const res = await promise;
// 第三步:请求成功,更新缓存
cache.set(key, { value: res, expireAt: Date.now() + ttl });
return res;
} finally {
// 第四步:无论请求成功/失败,都清空pending(关键!避免pending一直有值)
pending.delete(key);
}
};
}
// 第二步:定义模拟接口请求函数(fetcher)
async function getPermissionsApi(userId) {
// 模拟网络请求延迟500ms
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟返回不同用户的权限数据
return userId === 'u001' ? ['admin', 'edit'] : ['view'];
}
// 第三步:创建 getPermissions 方法(关键!补全缺失部分)
// 用缓存工厂包装接口请求函数,得到带缓存+防并发的 getPermissions
const getPermissions = createCache(getPermissionsApi, 5 * 60 * 1000);
// 第四步:测试并发场景
async function testConcurrent() {
// 模拟3个并发请求
const [res1, res2, res3] = await Promise.all([
getPermissions('u001'),
getPermissions('u001'),
getPermissions('u001')
]);
console.log('res1:', res1); // ['admin', 'edit']
console.log('res2:', res2); // ['admin', 'edit']
console.log('res3:', res3); // ['admin', 'edit']
}
// 执行测试
testConcurrent();
6.5.3 代码执行结果
❌ 发起新请求:key=u001
⏳ 有正在进行的请求:key=u001,复用Promise
⏳ 有正在进行的请求:key=u001,复用Promise
res1: [ 'admin', 'edit' ]
res2: [ 'admin', 'edit' ]
res3: [ 'admin', 'edit' ]
6.5.4 核心逻辑讲解(我在学习时候的思路整理成的问答,小白能懂)
-
pending变量的作用?通俗点说是什么? 答:pending就像一个“正在排队的标记本”。第一个请求进来时,我们把它的请求任务(Promise)记在这个本子上;后面再进来同一个key的请求,一看本子上有这个请求正在处理,就不发新请求了,直接等第一个请求完成,拿它的结果——相当于3个人买东西,只派1个人去买,另外2个人等着分,不重复跑腿。 -
用await写法时,如何保证并发复用?小白容易踩什么坑? 答:核心关键是 先把请求的 Promise 存入 pending,再用 await 等待请求完成,这是小白最容易踩的核心坑,一定要记牢!
✅ 为什么存 pending 后只发 1 次请求,而不是 3 次?(小白必懂)
pending 就像 “请求任务登记本”,Promise 是 “待完成的请求任务”:第一个请求把 Promise 存入 pending 后,相当于在登记本上记了 “这个 key 的请求正在做”,后续 2 个并发请求来的时候,看到登记本上有这个任务,就不会再发新请求,而是直接复用这个已存在的 Promise 等结果 —— 就像 3 个人买牛奶,1 个人先登记 “我去买”,另外 2 人只等结果,不会各自跑腿,所以最终只发 1 次请求。
❌ 小白常踩的错误写法:先写 await promise(等待请求完成),再把 promise 存入 pending—— 这样函数会一直卡住,等请求结束后才会存 pending,这期间后续并发请求看不到登记本上的标记,都会各自发请求,最终发 3 次而不是 1 次,完全失去防并发的作用;
✅ 正确写法(全程用 await,直接记这个就好):先创建请求的 promise,立刻把它存入 pending(做好 “正在请求” 的标记),再用 await 等待请求完成,这样后续并发请求能及时看到 pending 里的标记,直接复用同一个 Promise,只发 1 次请求。
小白记:await 的作用就是 “等任务完成再继续”,但一定要先把 “请求任务标记”(Promise)存到 pending 里,再去等 —— 不然别人看不到你在 “排队做任务”,都会各自发请求,既浪费服务器资源,也白做了防并发。
-
这个优化真的有必要吗?平时开发能用到吗? 答:非常有必要!这是生产环境必加的优化。比如页面加载时,多个组件同时请求同一数据(比如用户信息、字典数据),不加这个优化,后端会收到大量重复请求,可能导致服务器卡顿;加了之后,不管多少个并发请求,只发1次请求,既减轻服务器压力,也能让页面加载更快。
简单缓存总结(小白版)
-
缓存的核心是用闭包保存
cache变量,让多次调用共享缓存数据,避免重复请求/计算——闭包就是“缓存的储物盒”,不让缓存数据丢失。 -
基础版缓存:实现“查缓存→无缓存则请求→存缓存”三步,满足简单场景(比如单个组件请求数据)。
-
进阶版缓存:必须加
clear方法,处理“缓存失效”场景(用户退出、权限修改),否则会读错数据。 -
并发场景必加
pending标记:避免同一个key被多次请求,这是生产环境的“避坑关键”,小白也能轻松理解和复制使用。 -
缓存的本质:用内存换时间——用一点点内存存数据,减少网络请求、复杂计算的时间,让页面更快,用户体验更好。
七、循环中的闭包坑(经典面试题)
7.1 先看坑:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:3 3 3(不是0 1 2!)
原因:var 声明的 i 是全局变量,整个循环共用一个 i。
定时器是延迟执行的,等它真正跑的时候,循环早就结束了,i 已经变成 3。
7.2 解决方法(2种)
方法1:用 let(推荐,最简单)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0 1 2
原理:let 在每次循环会创建独立块级作用域,每个定时器回调拿到的是当前循环专属的 i。
方法2:用 IIFE 制造闭包(兼容旧环境)
IIFE = Immediately Invoked Function Expression
立即执行函数表达式
作用:手动给每一轮循环创建独立空间。
for (var i = 0; i < 3; i++) {
// 立即执行,把当前 i 传进去
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// 输出:0 1 2
原理:
每一轮循环都创建一个独立函数作用域,j 保存了当前的 i,形成闭包。
7.3 重点小白讲解:为什么 let 就能输出 0 1 2?
很多同学都会疑惑:
for 里面的 let i = 0 不是只执行一次吗?怎么会每轮都独立?
我用最简单、最真实的话讲清楚:
7.3.1 关键结论(一定要记住)
for (let i = 0; i < 3; i++)
JS 对这里的 let 做了特殊照顾:
-
初始化只执行一次:let i = 0
-
但每一轮循环,都会自动创建一个新的独立 i
-
每一轮循环都有自己的“独立空间”
你可以理解成:
-
第1轮:独立空间 → i = 0
-
第2轮:独立空间 → i = 1
-
第3轮:独立空间 → i = 2
定时器回调,绑的是当前这一轮的 i,不是共用的那个。
7.3.2 执行顺序真相
正常的执行顺序是这样的:
let i=0 → i<3 → 执行体 → i++ → 判断 → 执行体...
但 JS 在背后偷偷多做了一步:
每一次 i++ 之后,都会把当前值复制到一个新块级作用域里。
也就是:
-
第一轮:i = 0 → 生成独立空间 0
-
i++ → 变成 1 → 生成独立空间 1
-
i++ → 变成 2 → 生成独立空间 2
-
i++ → 变成 3 → 循环结束
定时器拿到的,是每一轮独立空间里的 i,不是共用变量!
所以:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0 1 2
7.3.3 一句话总结(最通俗)
-
var:循环共用 1 个 i → 最后全是 3
-
let:循环每一轮 单独一个 i → 输出 0 1 2
let 并不是只创建一次变量,而是每轮都给你一个新的、独立的 i。
八、整体总结(核心速记)
8.1 call/apply/bind 核心
-
三者都用来改变函数的 this 指向,
call逐个传参、apply数组传参、bind绑定后不立即执行; -
封装防抖/节流时,必须用
apply保留原函数的this和参数,避免上下文丢失。
8.2 闭包核心用法
| 场景 | 闭包保存什么 | 核心作用 |
|---|---|---|
| 防抖 | timer | 共享定时器,实现“停手后执行” |
| 节流 | last(时间戳)/ timer(冷却标记) | 限制执行频率,实现“一段时间只执行一次” |
| 函数工厂 | 配置参数(前缀、baseURL等) | 定制函数,避免重复代码 |
| 简单缓存 | Map / 对象(缓存数据) | 复用请求/计算结果,提升性能 |
8.3 闭包本质
闭包的核心是 函数能访问并保留它创建时的作用域变量。写代码时想清楚“要在多次调用之间保留什么变量”,闭包就用对了。
🔍 本系列专栏导航
一、《闭包实战:从原理到防抖・节流・缓存|JS 进阶必会篇》
二、《异步基础:Promise & async/await 实战|JS 进阶必会篇》
三、《常用工具函数:深拷贝・去重・扁平化手写实战|JS 进阶必会篇》
四、《事件循环与宏微任务:log 顺序实战解析|JS 进阶必会篇》
五、《设计模式实战:单例・发布订阅・策略 JS 轻量用法|JS 进阶必会篇》
六、《浏览器存储实战:localStorage/sessionStorage/cookie 用法详解|JS 进阶必会篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~