JavaScript 的词法作用域

105 阅读4分钟

JavaScript 的词法作用域

JavaScript 是一种动态语言,其作用域机制在开发中扮演了重要角色。词法作用域(Lexical Scope)是 JavaScript 中作用域的一种基础规则,了解它可以更好地编写高效、可维护的代码。

什么是词法作用域?

词法作用域(Lexical Scope),也称为静态作用域(Static Scope),是指变量的作用域在代码编译时就确定了,而不是在运行时确定。换句话说,一个变量的作用域是由它在源代码中声明的位置决定的。

词法作用域的规则

  1. 查找变量时,JavaScript 引擎会按照代码的书写结构(词法结构)逐层向上查找。 它首先在当前作用域中查找变量,如果没有找到,就到外层作用域查找,直到找到该变量或到达全局作用域为止。这个查找的过程形成了一个作用域链(Scope Chain)。
  2. 内部函数可以访问其外部函数的作用域,但外部函数不能访问内部函数的作用域。 这就形成了“闭包”的基础。

示例

function outerFunction() {
    const outerVariable = "I am from outer";

    function innerFunction() {
        console.log(outerVariable); // 可以访问 outerVariable
    }

    return innerFunction;
}

const myFunction = outerFunction();
myFunction(); // 输出:I am from outer

在这个例子中,innerFunction 可以访问 outerVariable,是因为它在定义时的词法作用域中包含了 outerVariable,即使 outerFunction 已经执行完毕,outerVariable 依然可以被访问。

为什么词法作用域重要?

  1. 封装性:词法作用域允许开发者封装变量和逻辑,从而避免全局污染。
  2. 闭包的实现基础:闭包依赖于词法作用域,可以让函数记住定义时的上下文。
  3. 代码可预测性:由于作用域在定义时就确定,代码行为更易预测。

词法作用域与动态作用域的区别

与词法作用域相对的是动态作用域(Dynamic Scope)。动态作用域是指变量的作用域在运行时确定,取决于函数在哪里被调用,而不是在哪里被定义。JavaScript 使用的是词法作用域,而不是动态作用域

动态作用域示例

(JavaScript 不支持动态作用域,但我们用伪代码说明)

function outerFunction() {
    const outerVariable = "I am from outer";
    innerFunction();
}

function innerFunction() {
    console.log(outerVariable); // 假如是动态作用域,这会查找调用时的上下文
}

outerFunction(); // 如果是动态作用域,将输出:I am from outer

在 JavaScript 中,以上代码会抛出 ReferenceError,因为 innerFunction 的作用域在定义时已经确定。

如何利用词法作用域优化代码?

  1. 模块化开发: 利用词法作用域,将变量和逻辑封装在函数中。

    function createCounter() {
        let count = 0;
        return function() {
            return ++count;
        };
    }
    
    const counter = createCounter();
    console.log(counter()); // 1
    console.log(counter()); // 2
    
  2. 避免全局污染: 使用 IIFE(立即执行函数)创建局部作用域。

    (function() {
        const localVariable = "I am local";
        console.log(localVariable);
    })();
    // console.log(localVariable); // ReferenceError
    
  3. 使用 constlet 代替 varconstlet 遵循块级作用域,而 var 是函数作用域,容易引发意外行为。

    {
        let blockScoped = "I am block scoped";
        console.log(blockScoped); // 有效
    }
    // console.log(blockScoped); // ReferenceError
    

常见面试题

  1. 下面代码输出什么?为什么?

    for (var i = 0; i < 3; i++) {
        setTimeout(() => console.log(i), 1000);
    }
    

    答案:输出 3, 3, 3。因为 var 是函数作用域,所有的 setTimeout 回调共享同一个 i,循环结束时 i 的值为 3。

  2. 如何修改上面的代码使其输出 0, 1, 2? 答案:使用 let 或 IIFE。

    for (let i = 0; i < 3; i++) {
        setTimeout(() => console.log(i), 1000);
    }
    

    for (var i = 0; i < 3; i++) {
        (function(i) {
            setTimeout(() => console.log(i), 1000);
        })(i);
    }
    
  3. 解释以下代码中 console.log 的输出

    const outer = () => {
        let count = 0;
        return () => {
            count++;
            console.log(count);
        };
    };
    
    const counter1 = outer();
    const counter2 = outer();
    
    counter1(); // ?
    counter1(); // ?
    counter2(); // ?
    

    答案:输出分别是 1, 2, 1。因为每次调用 outer 会创建新的闭包,counter1counter2 拥有各自独立的 count 变量。

  4. 下面代码为什么会报错?如何修复?

    {
        console.log(blockScopedVar); // ReferenceError
        let blockScopedVar = "I am block scoped";
    }
    

    答案:因为 let 存在暂时性死区(TDZ),在变量声明之前访问会报错。将 console.log 放到变量声明之后即可修复。

    {
        let blockScopedVar = "I am block scoped";
        console.log(blockScopedVar); // I am block scoped
    }