Skip to content
imshengli blog
Go back

深入理解 JavaScript 闭包:原理、陷阱与实战

深入理解闭包:作用域链、形成条件、经典陷阱(循环闭包),以及防抖、柯里化等实际应用。

· 5 min

作用域链回顾

JavaScript 有三种作用域:

作用域链的查找规则很简单:从当前作用域向外逐层查找,直到全局作用域。内层可以访问外层,外层无法访问内层。

var global = 'g';

function outer() {
  var outerVar = 'o';

  function inner() {
    var innerVar = 'i';
    console.log(global);    // 'g' — 找到全局
    console.log(outerVar);  // 'o' — 找到外层函数
  }

  inner();
  console.log(innerVar); // ReferenceError — 外层访问不到内层
}

闭包的定义

闭包(Closure)= 函数 + 它所引用的外部变量的环境。

更直白地说:一个函数在定义时所处的词法环境被保留了下来,即使这个函数在其他地方执行

形成闭包的条件:

  1. 函数嵌套
  2. 内部函数引用了外部函数的变量
  3. 内部函数被返回或传递到外部
function createGreeting(prefix) {
  // prefix 被闭包捕获
  return function(name) {
    return prefix + ', ' + name;
  };
}

const hello = createGreeting('Hello');
const hi = createGreeting('Hi');

hello('Alice'); // 'Hello, Alice'
hi('Bob');      // 'Hi, Bob'

createGreeting 已经执行完毕,但 prefix 变量没有被回收,因为返回的函数仍然引用着它。

经典示例

计数器

function makeCounter(initial) {
  let count = initial || 0;

  return {
    increment() { return ++count; },
    decrement() { return --count; },
    getCount() { return count; }
  };
}

const counter = makeCounter(0);
counter.increment(); // 1
counter.increment(); // 2
counter.decrement(); // 1
counter.getCount();  // 1

// count 变量外部完全无法直接访问

私有变量

function createUser(name) {
  let _password = '';  // 外部无法直接访问

  return {
    getName() { return name; },
    setPassword(pwd) { _password = pwd; },
    checkPassword(pwd) { return _password === pwd; }
  };
}

const user = createUser('张三');
user.setPassword('123456');
user.checkPassword('123456'); // true
user._password; // undefined — 真正的私有

模块模式

ES6 之前,闭包是实现模块化的主要手段:

var EventBus = (function() {
  var listeners = {};  // 私有状态

  return {
    on: function(event, fn) {
      (listeners[event] = listeners[event] || []).push(fn);
    },
    emit: function(event, data) {
      (listeners[event] || []).forEach(fn => fn(data));
    },
    off: function(event, fn) {
      listeners[event] = (listeners[event] || []).filter(f => f !== fn);
    }
  };
})();

循环中的闭包陷阱

经典面试题:

// 用 var — 输出全是 5
for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}
// 输出:5 5 5 5 5
// 原因:var 是函数作用域,循环结束后 i = 5,所有回调共享同一个 i

三种解决方式:

// 方式一:IIFE 创建独立作用域
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() { console.log(j); }, 100);
  })(i);
}

// 方式二:let 块级作用域(推荐)
for (let i = 0; i < 5; i++) {
  setTimeout(function() { console.log(i); }, 100);
}

// 方式三:setTimeout 第三个参数
for (var i = 0; i < 5; i++) {
  setTimeout(function(j) { console.log(j); }, 100, i);
}

闭包与内存

闭包会阻止被引用的变量被垃圾回收(GC)。这是特性,不是 bug,但使用不当会造成内存泄漏。

可能泄漏的场景

function setupHandler() {
  var hugeData = new Array(1000000).fill('x'); // 大数据

  var button = document.getElementById('btn');
  button.addEventListener('click', function() {
    // 这个回调闭包引用了 hugeData 所在的作用域
    // 即使只用了 hugeData.length,整个 hugeData 都不会被回收
    console.log(hugeData.length);
  });
}

如何避免

function setupHandler() {
  var hugeData = new Array(1000000).fill('x');
  var length = hugeData.length; // 只保留需要的值

  // hugeData 失去引用后可被 GC
  var button = document.getElementById('btn');
  button.addEventListener('click', function() {
    console.log(length);
  });
}

原则:闭包只捕获你需要的东西,不要无意间持有大对象的引用

实际应用

防抖(debounce)

function debounce(fn, delay) {
  let timer = null;  // 闭包保持 timer 引用

  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

const handleInput = debounce(function(e) {
  console.log('搜索:', e.target.value);
}, 300);

柯里化(currying)

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return function(...moreArgs) {
      return curried.apply(this, args.concat(moreArgs));
    };
  };
}

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3);    // 6
add(1, 2)(3);   // 6

缓存(memoize)

function memoize(fn) {
  const cache = {};  // 闭包保持缓存

  return function(...args) {
    const key = JSON.stringify(args);
    if (key in cache) {
      return cache[key];
    }
    return (cache[key] = fn.apply(this, args));
  };
}

const factorial = memoize(function(n) {
  return n <= 1 ? 1 : n * factorial(n - 1);
});

小结


Share this post on:

Previous Post
Node.js Stream:四种流类型、pipe 管道与背压机制
Next Post
Underscore 源码解读(一):整体架构与核心设计