闭包实战:从原理到防抖・节流・缓存|JS 进阶必会篇

36 阅读21分钟

同学们好,我是 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 ** 指向,区别仅在于参数传递方式和执行时机。

方法语法执行时机参数传递核心场景
callfn.call(thisArg, arg1, arg2, ...)立即执行逐个传参明确知道参数个数时
applyfn.apply(thisArg, [arg1, arg2, ...])立即执行数组传参不确定参数个数(比如防抖/节流)
bindfn.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),才能强制把 fnthis 绑定到闭包函数的 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 = nullclearTimeout 依然能取消定时器,防抖功能本身正常——但会导致「状态混乱」:

  • 理想状态:有未执行定时器 → 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 组件卸载/路由跳转时,防抖函数的定时器还没执行,会导致两个坑:

  1. 内存泄漏:定时器占用的内存没被释放,项目越运行越卡;

  2. 报错:定时器回调执行时,组件已经销毁(比如访问 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);
  };
}

流程讲解: 第一次进入程序的时候timernull,所有走不到return。进入setTimeouttimer有值。第二次进入程序的时候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 核心疑问解答(整合问答,小白必看)

  1. 为什么用 async/await 答:因为缓存的场景大多是“请求接口”,接口请求是异步的(需要等后端返回数据,就像你发消息给朋友,要等他回复才能继续聊天),所以整个缓存函数要支持异步。用 async/await 能让异步代码更简单,不用写复杂的回调函数,小白也能看懂。如果你的请求是同步的(比如本地计算),去掉 async/await 就能用。

  2. ttl 是什么?怎么调整? 答:TTL 是 Time To Live 的缩写,就是“缓存存活时间”,单位是毫秒。默认是5分钟(5601000ms),简单说就是:缓存存5分钟,5分钟内再用,直接读缓存;超过5分钟,就重新发请求拿新数据。 调整方法:比如想让缓存存10秒,就把 ttl 改成 101000;想存1小时,就改成 6060*1000,根据数据变化快慢调整就行。

  3. 为什么用 Map 而不是普通对象? 答:普通对象的键只能是字符串或数字(比如只能用 'u001' 当key),但 Map 的键可以是任意类型(比如直接用用户对象当key)。举个例子:如果你想缓存“用户对象”对应的权限,用 Map 能直接存,普通对象做不到;而且 Map 找数据、删数据比普通对象更快,适合当缓存容器。

  4. 缓存会一直占内存吗?会不会让页面变卡? 答:会占内存,但只要合理设置 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 核心疑问解答(整合问答,小白必看)

  1. 为什么要加 clear 方法?不加不行吗? 答:不加肯定不行!业务中一定会有“缓存失效”的场景,比如:用户修改了权限(缓存里的旧权限就错了)、用户退出登录(不能再保留他的权限缓存)、系统刷新(需要重新加载所有数据)。这时候必须手动清除旧缓存,否则会读到错误数据,导致功能异常。

  2. 为什么返回对象而不是函数?和基础版不一样? 答:基础版只需要“获取缓存”这一个功能,所以返回一个函数就够了;进阶版需要“获取缓存+清除缓存”两个功能,返回对象可以把这两个方法装在一起(就像一个工具包,里面有两个工具),而且两个方法能共享同一个 cache 缓存容器(闭包的作用),逻辑更清晰,用起来也更方便。

  3. 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 核心逻辑讲解(我在学习时候的思路整理成的问答,小白能懂)

  1. pending 变量的作用?通俗点说是什么? 答:pending 就像一个“正在排队的标记本”。第一个请求进来时,我们把它的请求任务(Promise)记在这个本子上;后面再进来同一个key的请求,一看本子上有这个请求正在处理,就不发新请求了,直接等第一个请求完成,拿它的结果——相当于3个人买东西,只派1个人去买,另外2个人等着分,不重复跑腿。

  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 里,再去等 —— 不然别人看不到你在 “排队做任务”,都会各自发请求,既浪费服务器资源,也白做了防并发。

  3. 这个优化真的有必要吗?平时开发能用到吗? 答:非常有必要!这是生产环境必加的优化。比如页面加载时,多个组件同时请求同一数据(比如用户信息、字典数据),不加这个优化,后端会收到大量重复请求,可能导致服务器卡顿;加了之后,不管多少个并发请求,只发1次请求,既减轻服务器压力,也能让页面加载更快。

简单缓存总结(小白版)

  1. 缓存的核心是用闭包保存 cache 变量,让多次调用共享缓存数据,避免重复请求/计算——闭包就是“缓存的储物盒”,不让缓存数据丢失。

  2. 基础版缓存:实现“查缓存→无缓存则请求→存缓存”三步,满足简单场景(比如单个组件请求数据)。

  3. 进阶版缓存:必须加 clear 方法,处理“缓存失效”场景(用户退出、权限修改),否则会读错数据。

  4. 并发场景必加 pending 标记:避免同一个key被多次请求,这是生产环境的“避坑关键”,小白也能轻松理解和复制使用。

  5. 缓存的本质:用内存换时间——用一点点内存存数据,减少网络请求、复杂计算的时间,让页面更快,用户体验更好。

七、循环中的闭包坑(经典面试题)

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++ 之后,都会把当前值复制到一个新块级作用域里。

也就是:

  1. 第一轮:i = 0 → 生成独立空间 0

  2. i++ → 变成 1 → 生成独立空间 1

  3. i++ → 变成 2 → 生成独立空间 2

  4. 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 核心

  1. 三者都用来改变函数的 this 指向call 逐个传参、apply 数组传参、bind 绑定后不立即执行;

  2. 封装防抖/节流时,必须用 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,你的电子学友,我们下一篇干货见~