Skip to content

JavaScript闭包与作用域

JavaScript中的闭包和作用域是该语言最强大也是最容易被误解的特性之一。本文将深入探讨这两个概念,帮助你更好地理解和应用它们。

作用域基础

什么是作用域?

作用域是指程序中定义变量的区域,它决定了变量的可访问性和生命周期。JavaScript中主要有以下几种作用域:

  1. 全局作用域:在代码的任何地方都能访问的变量
  2. 函数作用域:在函数内部定义的变量
  3. 块级作用域:使用letconst声明的变量所在的代码块(ES6引入)
javascript
// 全局作用域
const globalVar = "我是全局变量";

function exampleFunction() {
  // 函数作用域
  const functionVar = "我是函数作用域变量";
  console.log(globalVar);      // 可以访问全局变量
  console.log(functionVar);    // 可以访问函数作用域变量
  
  if (true) {
    // 块级作用域
    const blockVar = "我是块级作用域变量";
    console.log(blockVar);     // 可以访问块级作用域变量
  }
  
  // console.log(blockVar);    // 错误:blockVar未定义
}

exampleFunction();
console.log(globalVar);        // 可以访问全局变量
// console.log(functionVar);   // 错误:functionVar未定义

作用域链

当JavaScript引擎查找变量时,它会从最内层的作用域开始查找,如果找不到,就会向外层作用域继续查找,直到找到该变量或到达全局作用域。这个查找过程形成了作用域链。

javascript
const global = "全局变量";

function outer() {
  const outerVar = "外层函数变量";
  
  function inner() {
    const innerVar = "内层函数变量";
    console.log(innerVar);   // 内层函数变量
    console.log(outerVar);   // 外层函数变量
    console.log(global);     // 全局变量
  }
  
  inner();
  console.log(outerVar);     // 外层函数变量
  // console.log(innerVar);  // 错误:innerVar未定义
}

outer();
console.log(global);         // 全局变量
// console.log(outerVar);    // 错误:outerVar未定义

变量提升

在JavaScript中,使用var声明的变量和函数声明会被"提升"到其所在作用域的顶部。

javascript
console.log(hoistedVar);   // undefined(不会报错)
var hoistedVar = "我被提升了";

hoistedFunction();         // "我是被提升的函数"
function hoistedFunction() {
  console.log("我是被提升的函数");
}

// console.log(notHoisted);  // 错误:notHoisted未定义
let notHoisted = "我没有被提升";

// notHoistedFunction();     // 错误:notHoistedFunction不是一个函数
const notHoistedFunction = function() {
  console.log("我没有被提升");
};

var、let和const的区别

  • var:函数作用域,会被提升,可以重复声明,可以在声明前使用(值为undefined)
  • let:块级作用域,不会被提升,不能重复声明,不能在声明前使用(暂时性死区)
  • const:与let类似,但声明时必须初始化,且不能重新赋值(但对象和数组的内容可以修改)
javascript
// var示例
var x = 1;
var x = 2;       // 允许重复声明
console.log(x);  // 2

// let示例
let y = 1;
// let y = 2;    // 错误:不能重复声明
y = 2;           // 可以重新赋值
console.log(y);  // 2

// const示例
const z = 1;
// z = 2;        // 错误:不能重新赋值

// 但const声明的对象内容可以修改
const obj = { name: "张三" };
obj.name = "李四";     // 允许
console.log(obj.name); // "李四"
// obj = { name: "王五" }; // 错误:不能重新赋值

暂时性死区(Temporal Dead Zone)

使用letconst声明的变量从块的开始到声明处这段区域被称为"暂时性死区",在这个区域内,变量不能被访问。

javascript
{
  // 从这里开始是age的暂时性死区
  // console.log(age); // 错误:不能在声明前访问'age'
  
  let age = 25;        // 暂时性死区结束
  console.log(age);    // 25
}

闭包详解

什么是闭包?

闭包是指一个函数能够访问其词法作用域之外的变量的能力。更具体地说,当一个函数在其父函数作用域之外被调用时,它仍然能够访问其父函数作用域中的变量。

javascript
function createCounter() {
  let count = 0;  // 私有变量
  
  return function() {
    count++;      // 访问外部函数的变量
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

在这个例子中,createCounter函数返回了一个内部函数,这个内部函数形成了一个闭包,它可以访问和修改createCounter函数中的count变量,即使createCounter函数已经执行完毕。

闭包的实际应用

1. 数据封装和私有变量

javascript
function createPerson(name) {
  // 私有变量
  let _age = 0;
  
  return {
    getName: function() {
      return name;
    },
    getAge: function() {
      return _age;
    },
    setAge: function(age) {
      if (age > 0) {
        _age = age;
      }
    },
    incrementAge: function() {
      _age++;
    }
  };
}

const person = createPerson("张三");
console.log(person.getName());  // "张三"
person.setAge(25);
console.log(person.getAge());   // 25
person.incrementAge();
console.log(person.getAge());   // 26
// console.log(person._age);    // undefined,无法直接访问私有变量

2. 函数工厂和柯里化

javascript
// 函数工厂
function multiply(x) {
  return function(y) {
    return x * y;
  };
}

const multiplyByTwo = multiply(2);
const multiplyByTen = multiply(10);

console.log(multiplyByTwo(3));  // 2 * 3 = 6
console.log(multiplyByTen(5));  // 10 * 5 = 50

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

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3));  // 6
console.log(curriedAdd(1, 2)(3));  // 6
console.log(curriedAdd(1)(2, 3));  // 6

3. 事件处理和回调

javascript
function setupButton(buttonId, message) {
  const button = document.getElementById(buttonId);
  
  button.addEventListener("click", function() {
    // 闭包捕获了message变量
    console.log(message);
  });
}

setupButton("button1", "按钮1被点击了");
setupButton("button2", "按钮2被点击了");

4. 模块模式

javascript
const calculator = (function() {
  // 私有变量和函数
  let result = 0;
  
  function add(x) {
    result += x;
  }
  
  function subtract(x) {
    result -= x;
  }
  
  // 公共API
  return {
    add: function(x) {
      add(x);
      return this;
    },
    subtract: function(x) {
      subtract(x);
      return this;
    },
    getResult: function() {
      return result;
    },
    reset: function() {
      result = 0;
      return this;
    }
  };
})();

calculator.add(5).subtract(2).add(10);
console.log(calculator.getResult()); // 13
calculator.reset();
console.log(calculator.getResult()); // 0

闭包的注意事项

1. 内存管理

闭包可能导致内存泄漏,因为它们会保持对外部变量的引用,阻止垃圾回收机制回收这些变量。

javascript
function createLargeArray() {
  // 一个大数组,占用大量内存
  const largeArray = new Array(1000000).fill("大量数据");
  
  return function() {
    // 引用了外部的largeArray
    console.log(largeArray.length);
  };
}

let printArrayLength = createLargeArray(); // largeArray在内存中
printArrayLength(); // 1000000

// 当不再需要时,应该解除引用
printArrayLength = null; // 现在largeArray可以被垃圾回收

2. 循环中的闭包

在循环中创建闭包时,需要注意变量捕获问题。

javascript
// 错误的方式
function createFunctions() {
  const functions = [];
  
  for (var i = 0; i < 3; i++) {
    functions.push(function() {
      console.log(i);
    });
  }
  
  return functions;
}

const fns1 = createFunctions();
fns1[0](); // 3
fns1[1](); // 3
fns1[2](); // 3

// 正确的方式 - 使用IIFE
function createFunctionsWithIIFE() {
  const functions = [];
  
  for (var i = 0; i < 3; i++) {
    functions.push((function(value) {
      return function() {
        console.log(value);
      };
    })(i));
  }
  
  return functions;
}

const fns2 = createFunctionsWithIIFE();
fns2[0](); // 0
fns2[1](); // 1
fns2[2](); // 2

// 正确的方式 - 使用let
function createFunctionsWithLet() {
  const functions = [];
  
  for (let i = 0; i < 3; i++) {
    functions.push(function() {
      console.log(i);
    });
  }
  
  return functions;
}

const fns3 = createFunctionsWithLet();
fns3[0](); // 0
fns3[1](); // 1
fns3[2](); // 2

作用域和闭包的高级概念

执行上下文

每当JavaScript代码执行时,都会创建一个执行上下文。执行上下文包含三个主要部分:

  1. 变量对象:包含函数的参数、内部变量和函数声明
  2. 作用域链:当前上下文和所有父级上下文的变量对象列表
  3. this值:函数调用时的上下文对象
javascript
function outer() {
  console.log(this); // 取决于调用方式
  
  const outerVar = "外部变量";
  
  function inner() {
    console.log(this); // 取决于调用方式
    console.log(outerVar); // 通过作用域链访问
  }
  
  inner(); // 常规函数调用,this指向全局对象(非严格模式)或undefined(严格模式)
}

outer(); // 常规函数调用
const obj = { method: outer };
obj.method(); // 方法调用,this指向obj

词法作用域与动态作用域

JavaScript使用词法作用域(也称为静态作用域),这意味着函数的作用域在函数定义时确定,而不是在函数调用时确定。

javascript
const x = "全局";

function first() {
  const x = "first函数";
  second(); // 调用second函数
}

function second() {
  console.log(x); // 输出什么?
}

first(); // 输出:"全局"(因为词法作用域)

如果JavaScript使用动态作用域,上面的代码会输出"first函数",但由于JavaScript使用词法作用域,所以输出"全局"。

this关键字与箭头函数

在JavaScript中,this关键字的值取决于函数的调用方式,而不是函数的定义位置。

javascript
const person = {
  name: "张三",
  regularFunction: function() {
    console.log(this.name); // "张三"
    
    function innerFunction() {
      console.log(this.name); // undefined(在浏览器中是全局对象的name属性)
    }
    
    innerFunction();
  },
  arrowFunction: function() {
    console.log(this.name); // "张三"
    
    const inner = () => {
      console.log(this.name); // "张三"(箭头函数继承外层函数的this)
    };
    
    inner();
  }
};

person.regularFunction();
person.arrowFunction();

箭头函数不绑定自己的this,而是继承外层作用域的this值,这使得它们非常适合在需要保持外层this上下文的场景中使用。

模块作用域

ES6引入了模块系统,每个模块都有自己的作用域。模块中的变量、函数和类默认是私有的,除非显式导出。

javascript
// math.js
const PI = 3.14159;
export function calculateCircleArea(radius) {
  return PI * radius * radius;
}
export function calculateCircleCircumference(radius) {
  return 2 * PI * radius;
}

// app.js
import { calculateCircleArea, calculateCircleCircumference } from './math.js';

console.log(calculateCircleArea(5)); // 78.53975
console.log(calculateCircleCircumference(5)); // 31.4159
// console.log(PI); // 错误:PI是math.js模块的私有变量

闭包与异步编程

闭包在异步编程中非常有用,特别是在处理回调函数和Promise时。

javascript
function fetchData(url) {
  const data = { timestamp: Date.now() };
  
  return new Promise((resolve) => {
    setTimeout(() => {
      // 闭包捕获了url和data变量
      resolve({
        url: url,
        result: `来自${url}的数据`,
        timestamp: data.timestamp
      });
    }, 1000);
  });
}

fetchData('https://example.com/api')
  .then(response => {
    console.log(response);
    // {url: "https://example.com/api", result: "来自https://example.com/api的数据", timestamp: 1234567890}
  });

闭包与高阶函数

高阶函数是指接受函数作为参数或返回函数的函数。闭包经常与高阶函数一起使用,创建更强大的抽象。

javascript
// 函数组合
function compose(...functions) {
  return function(x) {
    return functions.reduceRight((acc, fn) => fn(acc), x);
  };
}

function addOne(x) { return x + 1; }
function double(x) { return x * 2; }
function square(x) { return x * x; }

const calculate = compose(square, double, addOne);
console.log(calculate(3)); // square(double(addOne(3))) = square(double(4)) = square(8) = 64

// 函数记忆(缓存)
function memoize(fn) {
  const cache = {};
  
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache[key] === undefined) {
      cache[key] = fn.apply(this, args);
    }
    return cache[key];
  };
}

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const memoizedFibonacci = memoize(function(n) {
  if (n <= 1) return n;
  return memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2);
});

console.time('普通fibonacci');
console.log(fibonacci(35)); // 9227465,计算很慢
console.timeEnd('普通fibonacci');

console.time('记忆化fibonacci');
console.log(memoizedFibonacci(35)); // 9227465,计算很快
console.timeEnd('记忆化fibonacci');

实际应用示例

1. 实现一个简单的状态管理器

javascript
function createStore(initialState = {}) {
  let state = initialState;
  const listeners = [];
  
  function getState() {
    // 返回状态的副本,防止直接修改
    return JSON.parse(JSON.stringify(state));
  }
  
  function setState(newState) {
    state = { ...state, ...newState };
    // 通知所有监听器
    listeners.forEach(listener => listener(state));
  }
  
  function subscribe(listener) {
    listeners.push(listener);
    // 返回取消订阅的函数
    return function unsubscribe() {
      const index = listeners.indexOf(listener);
      if (index !== -1) {
        listeners.splice(index, 1);
      }
    };
  }
  
  return {
    getState,
    setState,
    subscribe
  };
}

// 使用示例
const store = createStore({ count: 0, user: null });

// 订阅状态变化
const unsubscribe = store.subscribe(state => {
  console.log('状态已更新:', state);
});

// 修改状态
store.setState({ count: 1 });
// 输出: 状态已更新: { count: 1, user: null }

store.setState({ user: { name: '张三', age: 30 } });
// 输出: 状态已更新: { count: 1, user: { name: '张三', age: 30 } }

// 取消订阅
unsubscribe();

// 再次修改状态,不会触发已取消的订阅
store.setState({ count: 2 });

2. 实现防抖和节流函数

javascript
// 防抖函数 - 延迟执行,重置计时器
function debounce(fn, delay) {
  let timer = null;
  
  return function(...args) {
    const context = this;
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      fn.apply(context, args);
    }, delay);
  };
}

// 节流函数 - 按时间间隔执行
function throttle(fn, interval) {
  let lastTime = 0;
  
  return function(...args) {
    const context = this;
    const now = Date.now();
    
    if (now - lastTime >= interval) {
      fn.apply(context, args);
      lastTime = now;
    }
  };
}

// 使用示例
const expensiveCalculation = () => {
  console.log('执行昂贵计算...');
  // 假设这是一个耗时的操作
};

// 防抖版本 - 只有在用户停止输入500ms后才执行
const debouncedCalculation = debounce(expensiveCalculation, 500);

// 节流版本 - 最多每1000ms执行一次
const throttledCalculation = throttle(expensiveCalculation, 1000);

// 在实际应用中
// window.addEventListener('resize', debouncedCalculation);
// document.getElementById('search').addEventListener('input', debouncedCalculation);
// window.addEventListener('scroll', throttledCalculation);

3. 实现一个简单的观察者模式

javascript
class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  // 订阅事件
  on(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    
    this.events[eventName].push(callback);
    
    return () => this.off(eventName, callback);
  }
  
  // 取消订阅
  off(eventName, callback) {
    if (!this.events[eventName]) return;
    
    this.events[eventName] = this.events[eventName]
      .filter(cb => cb !== callback);
      
    if (this.events[eventName].length === 0) {
      delete this.events[eventName];
    }
  }
  
  // 触发一次后自动取消订阅
  once(eventName, callback) {
    const onceWrapper = (...args) => {
      callback.apply(this, args);
      this.off(eventName, onceWrapper);
    };
    
    return this.on(eventName, onceWrapper);
  }
  
  // 触发事件
  emit(eventName, ...args) {
    if (!this.events[eventName]) return;
    
    this.events[eventName].forEach(callback => {
      callback.apply(this, args);
    });
  }
}

// 使用示例
const emitter = new EventEmitter();

// 订阅事件
const unsubscribe = emitter.on('userLogin', user => {
  console.log(`用户 ${user.name} 已登录`);
});

// 订阅一次性事件
emitter.once('firstVisit', () => {
  console.log('欢迎首次访问!');
});

// 触发事件
emitter.emit('userLogin', { name: '张三', id: 1 });
// 输出: 用户 张三 已登录

emitter.emit('firstVisit');
// 输出: 欢迎首次访问!

// 再次触发一次性事件,不会有输出
emitter.emit('firstVisit');

// 取消订阅
unsubscribe();

// 再次触发事件,不会有输出
emitter.emit('userLogin', { name: '李四', id: 2 });

4. 实现一个简单的单例模式

javascript
function Singleton(className) {
  let instance;
  
  return function(...args) {
    if (!instance) {
      instance = new className(...args);
    }
    return instance;
  };
}

// 使用示例
class Database {
  constructor(host, port) {
    this.host = host;
    this.port = port;
    this.connected = false;
    console.log(`创建数据库连接: ${host}:${port}`);
  }
  
  connect() {
    if (this.connected) {
      console.log('已经连接到数据库');
      return;
    }
    
    console.log(`连接到数据库: ${this.host}:${this.port}`);
    this.connected = true;
  }
  
  query(sql) {
    if (!this.connected) {
      throw new Error('请先连接数据库');
    }
    console.log(`执行查询: ${sql}`);
    return `查询结果: ${sql}`;
  }
}

// 创建单例构造函数
const SingletonDatabase = Singleton(Database);

// 创建实例
const db1 = new SingletonDatabase('localhost', 3306);
db1.connect();
// 输出:
// 创建数据库连接: localhost:3306
// 连接到数据库: localhost:3306

// 尝试创建另一个实例
const db2 = new SingletonDatabase('127.0.0.1', 5432);
// 不会创建新实例,不会输出创建信息

console.log(db1 === db2); // true,它们是同一个实例

db2.query('SELECT * FROM users');
// 输出: 执行查询: SELECT * FROM users

总结

JavaScript中的作用域和闭包是该语言最基础也是最强大的特性之一。理解这些概念对于编写高效、可维护的JavaScript代码至关重要。

主要要点:

  1. 作用域决定了变量的可访问性和生命周期,JavaScript中有全局作用域、函数作用域和块级作用域。
  2. 作用域链使得内部作用域可以访问外部作用域的变量。
  3. 闭包是函数能够访问其词法作用域之外变量的能力,即使函数在其定义的作用域之外执行。
  4. 闭包的主要应用包括数据封装函数工厂回调函数模块模式等。
  5. 使用闭包时需要注意内存管理变量捕获问题。
  6. JavaScript使用词法作用域,函数的作用域在定义时确定,而不是在调用时。
  7. this关键字的值取决于函数的调用方式,而箭头函数继承外层作用域的this值。
  8. ES6引入的模块系统为每个模块提供了独立的作用域。

通过深入理解作用域和闭包,你可以更好地利用JavaScript的特性,编写更清晰、更高效的代码。

用知识点燃技术的火山