吃透JS闭包:从概念到实战,防抖/节流/缓存全讲透

0 阅读7分钟

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

方法语法执行时机参数传递核心场景
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):用闭包“兜住”定时器

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 都会创建新的 timerclearTimeout 清不掉之前的定时器,防抖直接失效。

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

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

  2. 封装防抖/节流时,必须用 apply 保留原函数的 this 和参数,避免上下文丢失。

8.2 闭包核心用法

场景闭包保存什么核心作用
防抖timer共享定时器,实现“停手后执行”
节流last(时间戳)/ timer(冷却标记)限制执行频率,实现“一段时间只执行一次”
函数工厂配置参数(前缀、baseURL等)定制函数,避免重复代码
简单缓存Map / 对象(缓存数据)复用请求/计算结果,提升性能

8.3 闭包本质

闭包的核心是 函数能访问并保留它创建时的作用域变量。写代码时想清楚“要在多次调用之间保留什么变量”,闭包就用对了。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~