Skip to content

词法作用域:JavaScript 作用域的查找规则

在一座大型图书馆中,书籍被分门别类地放置在不同的区域和书架上。当你需要查找一本书时,你会先在最靠近你的书架上寻找,如果找不到,就会扩大搜索范围到相邻的区域,最后可能需要查遍整个图书馆。JavaScript 中的词法作用域(Lexical Scope)就是这样一套"查书规则",它决定了代码在运行时如何查找变量。

词法作用域也被称为静态作用域(Static Scope),因为它在代码编写阶段就已经确定了,不会在运行时改变。理解词法作用域是掌握 JavaScript 闭包、模块化等高级特性的关键。

什么是词法作用域

词法作用域意味着作用域是由代码中函数声明的位置来决定的,而不是由函数调用的位置决定。换句话说,变量的可访问性在你写代码的时候就已经确定了,JavaScript 引擎在解析代码时就能知道每个变量属于哪个作用域。

词法作用域 vs 动态作用域

JavaScript 采用词法作用域(大多数编程语言也是如此),而有些语言(如 Bash、Perl 的某些模式)使用动态作用域。两者的区别在于变量查找的依据:

词法作用域:根据代码的书写位置查找变量

javascript
let name = "Global";

function outer() {
  let name = "Outer";

  function inner() {
    console.log(name); // 查找变量时,看 inner 在哪里定义
  }

  return inner;
}

const fn = outer();
fn(); // "Outer" - 词法作用域根据定义位置查找

在这个例子中,inner 函数是在 outer 函数内部定义的,所以当 inner 访问 name 变量时,它会从定义它的位置(outer 函数)开始查找,而不管 inner 在哪里被调用。

动态作用域(JavaScript 不支持,仅作对比):
如果 JavaScript 使用动态作用域,变量查找会基于函数的调用位置:

javascript
// 假设 JavaScript 使用动态作用域(实际不是)
let name = "Global";

function showName() {
  console.log(name);
}

function context1() {
  let name = "Context 1";
  showName(); // 动态作用域会输出 "Context 1"
}

function context2() {
  let name = "Context 2";
  showName(); // 动态作用域会输出 "Context 2"
}

// 但实际上 JavaScript 使用词法作用域
context1(); // "Global" - 因为 showName 定义在全局,所以查找全局的 name
context2(); // "Global"

幸运的是,JavaScript 使用词法作用域,这让代码的行为更可预测,更容易理解和维护。

词法作用域的工作原理

词法作用域的核心是作用域链(Scope Chain)。当代码需要访问一个变量时,JavaScript 引擎会沿着作用域链逐层向上查找,直到找到该变量或到达全局作用域为止。

作用域链的形成

每个函数在创建时,都会保存一个内部属性 [[Scope]],它包含了该函数被定义时所在的作用域链。当函数执行时,会创建一个新的执行上下文(Execution Context),并将自己的作用域添加到作用域链的前端。

javascript
let global = "Global Variable";

function outer() {
  let outerVar = "Outer Variable";

  function middle() {
    let middleVar = "Middle Variable";

    function inner() {
      let innerVar = "Inner Variable";

      // 作用域链: inner → middle → outer → global
      console.log(innerVar); // 在 inner 作用域中找到
      console.log(middleVar); // 在 middle 作用域中找到
      console.log(outerVar); // 在 outer 作用域中找到
      console.log(global); // 在全局作用域中找到
    }

    inner();
  }

  middle();
}

outer();

在这个嵌套结构中,当 inner 函数执行时,它的作用域链为:

  1. inner 的局部作用域
  2. middle 的作用域
  3. outer 的作用域
  4. 全局作用域

查找变量时,引擎会按照这个顺序依次查找,直到找到为止。

变量遮蔽(Variable Shadowing)

当内层作用域定义了与外层作用域同名的变量时,内层变量会"遮蔽"(shadow)外层变量:

javascript
let value = "outer";

function test() {
  let value = "inner"; // 遮蔽了外层的 value
  console.log(value); // "inner"

  function nested() {
    let value = "nested"; // 又遮蔽了上一层的 value
    console.log(value); // "nested"
  }

  nested();
  console.log(value); // "inner" - 仍然是 test 内的 value
}

test();
console.log(value); // "outer" - 全局的 value 没有被修改

变量遮蔽就像在书架前放了一本同名的书,你会先看到这本,而看不到后面那本。只有移开前面的书,才能看到后面的。

访问外层变量而不遮蔽

如果想在内层作用域访问外层变量,只需不重新声明即可:

javascript
let counter = 0;

function increment() {
  counter++; // 访问并修改外层的 counter
  console.log(counter);
}

increment(); // 1
increment(); // 2
increment(); // 3
console.log(counter); // 3 - 外层变量被修改了

这里没有使用 letconstvar 重新声明 counter,所以 increment 函数操作的是外层的变量。

词法作用域与闭包

词法作用域是闭包的基础。闭包(Closure)是指函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行。

闭包的形成

javascript
function createGreeting(greeting) {
  // greeting 是 createGreeting 的参数

  return function (name) {
    // 这个内部函数可以访问外部的 greeting 参数
    console.log(greeting + ", " + name + "!");
  };
}

const sayHello = createGreeting("Hello");
const sayHi = createGreeting("Hi");

sayHello("Alice"); // "Hello, Alice!"
sayHi("Bob"); // "Hi, Bob!"

在这个例子中,虽然 createGreeting 函数已经执行完毕,但返回的内部函数仍然可以访问 greeting 参数。这是因为内部函数在定义时就捕获了它的词法作用域,包括 greeting 变量。

每次调用 createGreeting 都会创建一个新的作用域和新的 greeting 变量,所以 sayHellosayHi 分别记住了各自的 greeting 值。

实用的闭包示例:数据私有化

利用词法作用域和闭包,我们可以创建真正的私有变量:

javascript
function createBankAccount(initialBalance) {
  let balance = initialBalance; // 私有变量

  return {
    deposit(amount) {
      if (amount > 0) {
        balance += amount;
        return balance;
      }
    },
    withdraw(amount) {
      if (amount > 0 && amount <= balance) {
        balance -= amount;
        return balance;
      } else {
        console.log("Insufficient funds or invalid amount");
      }
    },
    getBalance() {
      return balance;
    },
  };
}

const myAccount = createBankAccount(1000);
console.log(myAccount.getBalance()); // 1000
myAccount.deposit(500); // 1500
myAccount.withdraw(300); // 1200
console.log(myAccount.balance); // undefined - 无法直接访问私有变量

外部代码无法直接访问或修改 balance 变量,只能通过提供的方法来操作。这种模式在没有类私有字段的情况下非常有用。

词法作用域的实际应用

1. 模块模式

词法作用域是 JavaScript 模块模式的基础:

javascript
const UserModule = (function () {
  // 私有变量和函数
  let users = [];
  let currentId = 0;

  function generateId() {
    return ++currentId;
  }

  // 公共 API
  return {
    addUser(name, email) {
      const user = {
        id: generateId(),
        name: name,
        email: email,
      };
      users.push(user);
      return user;
    },

    getUser(id) {
      return users.find((user) => user.id === id);
    },

    getAllUsers() {
      // 返回副本,防止外部修改
      return [...users];
    },

    removeUser(id) {
      const index = users.findIndex((user) => user.id === id);
      if (index !== -1) {
        users.splice(index, 1);
        return true;
      }
      return false;
    },
  };
})();

// 使用模块
UserModule.addUser("Alice", "[email protected]");
UserModule.addUser("Bob", "[email protected]");
console.log(UserModule.getAllUsers());
// [
//   { id: 1, name: "Alice", email: "[email protected]" },
//   { id: 2, name: "Bob", email: "[email protected]" }
// ]

console.log(UserModule.users); // undefined - 私有变量不可访问
console.log(UserModule.currentId); // undefined

这个模块使用立即执行函数表达式(IIFE)创建了一个私有作用域,外部只能访问返回对象中的公共方法。

2. 回调函数和事件处理

词法作用域让回调函数能够访问定义时的上下文:

javascript
function setupButtonHandlers() {
  const buttons = document.querySelectorAll(".action-button");

  buttons.forEach(function (button, index) {
    // 每个回调函数都能访问自己的 index 变量
    button.addEventListener("click", function () {
      console.log("Button " + index + " clicked");
      console.log("Button text: " + button.textContent);
    });
  });
}

每个事件处理函数都能记住自己对应的 buttonindex,即使它们在稍后才被调用。

3. 函数工厂

利用词法作用域可以创建定制化的函数:

javascript
function createMultiplier(factor) {
  return function (number) {
    return number * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
const quadruple = createMultiplier(4);

console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(quadruple(5)); // 20

每个返回的函数都记住了自己的 factor 值,形成了专用的乘法函数。

4. 柯里化(Currying)

词法作用域使得函数柯里化成为可能:

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

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

每个中间函数都能记住之前传入的参数,这完全依赖于词法作用域。

常见问题与误区

误区 1: 函数可以访问调用位置的变量

javascript
let message = "Global message";

function showMessage() {
  console.log(message);
}

function wrapper() {
  let message = "Wrapper message";
  showMessage(); // 会输出什么?
}

wrapper(); // "Global message" - 不是 "Wrapper message"

showMessage 函数访问的是它定义时所在作用域的 message(全局的),而不是调用时所在作用域的 messagewrapper 中的)。

误区 2: 循环中的闭包都共享同一个变量

使用 var 时确实如此,但使用 let 可以避免:

javascript
// 问题代码
function createFunctions() {
  var funcs = [];
  for (var i = 0; i < 3; i++) {
    funcs.push(function () {
      console.log(i);
    });
  }
  return funcs;
}

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

// 解决方案 1: 使用 let
function createFunctionsFixed() {
  var funcs = [];
  for (let i = 0; i < 3; i++) {
    // 使用 let
    funcs.push(function () {
      console.log(i);
    });
  }
  return funcs;
}

const fixedFunctions = createFunctionsFixed();
fixedFunctions[0](); // 0
fixedFunctions[1](); // 1
fixedFunctions[2](); // 2

// 解决方案 2: 使用 IIFE 创建新作用域
function createFunctionsIIFE() {
  var funcs = [];
  for (var i = 0; i < 3; i++) {
    funcs.push(
      (function (index) {
        return function () {
          console.log(index);
        };
      })(i)
    );
  }
  return funcs;
}

const iifeFunctions = createFunctionsIIFE();
iifeFunctions[0](); // 0
iifeFunctions[1](); // 1
iifeFunctions[2](); // 2

误区 3: 闭包会导致内存泄漏

闭包本身不会导致内存泄漏,但如果使用不当可能会:

javascript
// 潜在的内存问题
function createHeavyObject() {
  const hugeData = new Array(1000000).fill("data");

  return {
    // 这个函数保持了对整个 hugeData 的引用
    getFirstItem() {
      return hugeData[0];
    },
  };
}

const obj = createHeavyObject(); // hugeData 会一直保留在内存中

解决方法是只保留需要的数据:

javascript
function createLightObject() {
  const hugeData = new Array(1000000).fill("data");
  const firstItem = hugeData[0]; // 只保留需要的数据

  return {
    getFirstItem() {
      return firstItem; // 不引用整个数组
    },
  };
}

const obj = createLightObject(); // 更好:hugeData 可以被垃圾回收

词法作用域的优势

1. 可预测性

因为作用域在编写代码时就确定了,我们可以通过阅读代码来理解变量的来源,不需要追踪运行时的调用链。

javascript
let x = 10;

function outer() {
  let x = 20;

  function inner() {
    console.log(x); // 一眼就能看出会输出 20
  }

  inner();
}

outer();

2. 性能优化

JavaScript 引擎可以在编译时就确定变量的作用域,进行优化,而不需要在运行时动态查找。

3. 封装性

词法作用域提供了天然的封装机制,我们可以创建私有变量和函数,控制外部的访问。

最佳实践

1. 利用词法作用域创建私有状态

javascript
function createTimer() {
  let seconds = 0;
  let intervalId = null;

  return {
    start() {
      if (intervalId === null) {
        intervalId = setInterval(() => {
          seconds++;
          console.log("Elapsed: " + seconds + "s");
        }, 1000);
      }
    },

    stop() {
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
    },

    reset() {
      seconds = 0;
      console.log("Timer reset");
    },

    getTime() {
      return seconds;
    },
  };
}

const timer = createTimer();
timer.start();
// 几秒后...
timer.stop();
console.log("Total time: " + timer.getTime() + "s");

2. 避免意外的全局变量

javascript
// 不好 - 容易创建全局变量
function badExample() {
  result = 10; // 忘记声明,创建了全局变量
}

badExample();
console.log(result); // 10 - 意外的全局变量

// 好 - 始终使用声明
function goodExample() {
  const result = 10; // 明确声明为局部变量
  return result;
}

goodExample();
console.log(typeof result); // "undefined"

3. 使用 IIFE 创建临时作用域

当需要执行一些初始化代码而不污染外部作用域时:

javascript
// 使用 IIFE 创建临时作用域
(function () {
  const tempData = fetchData();
  const processed = processData(tempData);
  const config = generateConfig(processed);

  initializeApp(config);

  // tempData, processed, config 在这里执行完就被销毁
})();

小结

词法作用域是 JavaScript 中变量查找的基本规则。它的核心特点是:

  • 作用域由代码的书写位置决定,而非调用位置
  • 变量查找沿着作用域链从内向外进行
  • 内层作用域可以访问外层变量,但外层无法访问内层
  • 同名变量会形成遮蔽效果

词法作用域让 JavaScript 的行为更可预测,也是闭包、模块化等重要特性的基础。通过合理利用词法作用域,我们可以:

  • 创建真正的私有变量和方法
  • 实现数据封装和信息隐藏
  • 编写更安全、更易维护的代码

在接下来的章节中,我们将详细探讨块级作用域、函数作用域等具体的作用域类型,以及如何在实际开发中充分利用这些特性。理解词法作用域是掌握这些高级主题的关键第一步。