MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript函数作用域与块级作用域的区别

2023-04-124.3k 阅读

JavaScript 函数作用域

在 JavaScript 的早期版本中,函数作用域是主要的作用域类型。理解函数作用域对于掌握 JavaScript 的变量访问规则和代码执行逻辑至关重要。

函数作用域的定义

函数作用域是指在函数内部声明的变量和函数,在函数外部是不可访问的。也就是说,变量和函数的作用域被限制在它们被声明的函数内部。例如:

function myFunction() {
    var localVar = 'This is a local variable';
    console.log(localVar);
}
myFunction();
// 这里尝试访问 localVar 会报错
console.log(localVar); 

在上述代码中,localVar 是在 myFunction 函数内部声明的变量,当在函数外部尝试访问 localVar 时,JavaScript 引擎会抛出一个 ReferenceError,因为 localVar 只在 myFunction 的函数作用域内有效。

函数作用域的形成机制

函数作用域的形成是在函数定义阶段。当 JavaScript 引擎解析函数定义时,会为该函数创建一个作用域链。这个作用域链包含了函数内部的局部变量对象以及外部作用域的变量对象(如果有外部作用域的话)。例如:

var globalVar = 'This is a global variable';
function outerFunction() {
    var outerVar = 'This is an outer variable';
    function innerFunction() {
        var innerVar = 'This is an inner variable';
        console.log(globalVar); 
        console.log(outerVar); 
        console.log(innerVar); 
    }
    innerFunction();
}
outerFunction();

在这个例子中,innerFunction 的作用域链包含了它自己的局部变量对象(包含 innerVar)、outerFunction 的变量对象(包含 outerVar)以及全局变量对象(包含 globalVar)。当 innerFunction 尝试访问变量时,它会首先在自己的局部变量对象中查找,如果找不到,就会沿着作用域链向上查找,直到找到该变量或者到达全局作用域。

函数作用域的特性 - 变量提升

在函数作用域中,变量声明会被提升到函数的顶部,尽管变量的赋值并不会被提升。这意味着你可以在变量声明之前使用它,但是值是 undefined。例如:

function hoistingExample() {
    console.log(x); 
    var x = 10;
    console.log(x); 
}
hoistingExample();

在上述代码中,var x 的声明被提升到了函数的顶部,所以第一个 console.log(x) 输出 undefined。然后变量 x 被赋值为 10,第二个 console.log(x) 输出 10。这种变量提升特性有时会导致一些不易察觉的错误,尤其是在大型代码库中。

JavaScript 块级作用域

随着 JavaScript 的发展,块级作用域被引入,这在一定程度上解决了函数作用域带来的一些问题,使得代码的逻辑更加清晰和可控。

块级作用域的定义

块级作用域是由一对花括号 {} 包裹的一段代码区域。在 ES6 之前,JavaScript 并没有真正的块级作用域,但是在 ES6 引入了 letconst 关键字后,块级作用域成为了现实。例如:

{
    let blockVar = 'This is a block - level variable';
    console.log(blockVar); 
}
// 这里尝试访问 blockVar 会报错
console.log(blockVar); 

在上述代码中,使用 let 声明的 blockVar 具有块级作用域,它只在花括号 {} 内部有效。在块外部尝试访问 blockVar 会导致 ReferenceError

块级作用域的形成机制

当 JavaScript 引擎遇到使用 letconst 声明变量的块时,会为该块创建一个新的作用域。这个作用域与外部作用域是相互独立的,并且变量的作用域仅限于该块内部。例如:

let outerLet = 'Outer let variable';
{
    let innerLet = 'Inner let variable';
    console.log(outerLet); 
    console.log(innerLet); 
}
// 这里尝试访问 innerLet 会报错
console.log(innerLet); 

在这个例子中,外部作用域中的 outerLet 可以在内部块中访问,因为内部块的作用域链包含了外部作用域。但是 innerLet 只在内部块的作用域内有效,在块外部无法访问。

块级作用域与函数作用域的对比

  1. 作用域范围
    • 函数作用域:函数作用域的范围是整个函数体,从函数声明开始到函数结束。这意味着在函数内部任何位置声明的变量,在整个函数内都是可见的(由于变量提升)。
    • 块级作用域:块级作用域的范围仅限于花括号 {} 包裹的区域。在块外部,块内声明的变量是不可见的。
  2. 变量提升
    • 函数作用域:如前文所述,函数作用域存在变量提升,var 声明的变量会被提升到函数顶部。
    • 块级作用域:使用 letconst 声明的变量不存在变量提升。在变量声明之前访问它们会导致 ReferenceError,这被称为“暂时性死区”(TDZ)。例如:
function compareHoisting() {
    console.log(varVar); 
    var varVar = 'var variable';
    // 这里会报错
    console.log(letVar); 
    let letVar = 'let variable';
}
compareHoisting();

在上述代码中,varVar 因为变量提升,在声明前访问输出 undefined。而 letVar 在声明前访问会导致 ReferenceError,因为 let 不存在变量提升,并且存在暂时性死区。 3. 循环中的作用域 - 函数作用域(使用 var:在循环中使用 var 声明变量时,变量的作用域是整个函数,而不是循环块。这可能会导致一些意外的结果。例如:

function varInLoop() {
    var arr = [];
    for (var i = 0; i < 5; i++) {
        arr.push(function () {
            return i;
        });
    }
    return arr.map(func => func());
}
console.log(varInLoop()); 

在上述代码中,由于 var 的函数作用域特性,当函数被调用时,i 的值已经变为 5,所以数组中的每个函数都返回 5。 - 块级作用域(使用 let:在循环中使用 let 声明变量时,let 会为每次循环迭代创建一个新的块级作用域,每个作用域都有自己独立的变量副本。例如:

function letInLoop() {
    var arr = [];
    for (let i = 0; i < 5; i++) {
        arr.push(function () {
            return i;
        });
    }
    return arr.map(func => func());
}
console.log(letInLoop()); 

在这个例子中,由于 let 的块级作用域特性,每次循环迭代都有自己独立的 i 变量副本,所以数组中的函数会返回预期的 0, 1, 2, 3, 4

函数作用域与块级作用域的实际应用场景

  1. 函数作用域的应用场景
    • 封装代码逻辑:函数作用域可以将相关的代码逻辑封装在一个函数内部,避免全局变量的污染。例如,在一个大型的 JavaScript 应用中,我们可能会有许多功能模块,每个模块可以通过函数封装起来,函数内部的变量和函数不会影响到全局作用域。
function module1() {
    var module1Var = 'Module 1 variable';
    function module1InnerFunction() {
        return module1Var;
    }
    return module1InnerFunction();
}
function module2() {
    var module2Var = 'Module 2 variable';
    function module2InnerFunction() {
        return module2Var;
    }
    return module2InnerFunction();
}
console.log(module1()); 
console.log(module2()); 
- **闭包的实现**:函数作用域是实现闭包的基础。闭包是指一个函数能够访问并操作其外部函数作用域中的变量,即使外部函数已经执行完毕。例如:
function outer() {
    var outerVar = 'Outer variable';
    function inner() {
        return outerVar;
    }
    return inner;
}
var closureFunc = outer();
console.log(closureFunc()); 

在上述代码中,inner 函数形成了一个闭包,它可以访问并返回 outer 函数作用域中的 outerVar,即使 outer 函数已经执行完毕。

  1. 块级作用域的应用场景
    • 控制变量的生命周期:在一些情况下,我们只需要在特定的代码块内使用某个变量,使用块级作用域可以更好地控制变量的生命周期,避免变量在不必要的地方被访问或修改。例如,在处理一些临时计算时:
{
    let tempResult = 10 + 20;
    console.log(tempResult); 
}
// 这里 tempResult 已经超出作用域,无法访问
- **避免命名冲突**:在复杂的代码结构中,块级作用域可以有效避免命名冲突。比如在循环中,使用 `let` 声明循环变量可以避免与其他同名变量产生冲突。
let count = 10;
{
    for (let i = 0; i < 5; i++) {
        console.log(i); 
    }
    // 这里 i 超出作用域,不会与外部的 count 产生混淆
}

深入理解函数作用域与块级作用域的本质差异

  1. 作用域链的构建
    • 函数作用域:函数作用域链的构建是在函数定义时,它包含了函数内部的局部变量对象以及外部作用域的变量对象(从内到外形成链式结构)。函数执行时,会基于这个预定义的作用域链来查找变量。例如:
var globalValue = 'Global';
function outer() {
    var outerValue = 'Outer';
    function inner() {
        var innerValue = 'Inner';
        console.log(globalValue); 
        console.log(outerValue); 
        console.log(innerValue); 
    }
    inner();
}
outer();

在这个例子中,inner 函数的作用域链依次为:inner 函数的局部变量对象(包含 innerValue)、outer 函数的变量对象(包含 outerValue)、全局变量对象(包含 globalValue)。 - 块级作用域:块级作用域链的构建相对简单,它是在块被执行时创建的。块级作用域链包含了块内声明的变量对象以及外部作用域的变量对象。与函数作用域不同的是,块级作用域通常是为了限制变量的可见性,而不是像函数作用域那样用于封装代码逻辑和构建复杂的作用域链关系。例如:

let outerValue = 'Outer';
{
    let blockValue = 'Block';
    console.log(outerValue); 
    console.log(blockValue); 
}

在这个例子中,块级作用域链包含了块内的变量对象(包含 blockValue)和外部作用域的变量对象(包含 outerValue)。

  1. 变量绑定的机制
    • 函数作用域(varvar 声明的变量采用的是函数作用域绑定,并且存在变量提升。这意味着变量在函数内的任何位置都可以被访问(虽然在声明前访问值为 undefined)。变量的绑定是在函数创建时就确定的,并且在函数执行期间保持不变。例如:
function varBinding() {
    console.log(x); 
    var x = 5;
    console.log(x); 
    function inner() {
        console.log(x); 
    }
    inner();
}
varBinding();

在上述代码中,x 的绑定在函数 varBinding 创建时就确定了,函数内部和内部函数 inner 都访问到相同的 x 变量。 - 块级作用域(letconstletconst 声明的变量采用块级作用域绑定,不存在变量提升,并且存在暂时性死区。变量的绑定是在块执行到声明语句时才发生。例如:

function letBinding() {
    // 这里访问 y 会报错
    console.log(y); 
    {
        let y = 10;
        console.log(y); 
    }
    // 这里访问 y 会报错
    console.log(y); 
}
letBinding();

在这个例子中,y 的绑定是在块内的 let 声明语句执行时才发生,在声明之前访问 y 会导致 ReferenceError

  1. 内存管理的影响
    • 函数作用域:由于函数作用域的变量生命周期与函数的执行周期相关,函数内部的变量在函数执行完毕后才会被垃圾回收(如果没有被闭包引用)。如果函数内部存在大量的局部变量,并且函数执行时间较长,可能会占用较多的内存。例如:
function largeFunction() {
    var largeArray = new Array(1000000).fill(0);
    // 函数执行一些操作
    return largeArray.reduce((acc, val) => acc + val, 0);
}
largeFunction();

在上述代码中,largeArray 在函数执行期间占用较大内存,直到函数执行完毕才可能被垃圾回收。 - 块级作用域:块级作用域的变量在块执行完毕后,其作用域内的变量就可以被垃圾回收(如果没有被外部引用)。这有助于更及时地释放内存,特别是在处理一些临时变量时。例如:

function useBlockScope() {
    {
        let tempArray = new Array(1000).fill(0);
        // 块内执行一些操作
        let sum = tempArray.reduce((acc, val) => acc + val, 0);
        console.log(sum); 
    }
    // 这里 tempArray 已经超出作用域,可被垃圾回收
}
useBlockScope();

在这个例子中,tempArray 在块执行完毕后就可以被垃圾回收,不会长时间占用内存。

混合使用函数作用域和块级作用域时的注意事项

  1. 变量命名冲突 当在代码中同时使用函数作用域和块级作用域时,要特别注意变量命名冲突的问题。由于块级作用域的引入,很容易在块内声明与函数作用域中同名的变量,导致意外的行为。例如:
function conflictExample() {
    var localVar = 'Function - level variable';
    console.log(localVar); 
    {
        let localVar = 'Block - level variable';
        console.log(localVar); 
    }
    console.log(localVar); 
}
conflictExample();

在上述代码中,块内使用 let 声明了与函数作用域中同名的 localVar。虽然块级作用域限制了块内 localVar 的可见性,但在阅读代码时可能会引起混淆。为了避免这种情况,应该尽量使用有意义且唯一的变量名。

  1. 作用域嵌套和访问规则 在复杂的代码结构中,可能会出现函数作用域和块级作用域的多层嵌套。理解作用域链的访问规则对于正确编写代码至关重要。例如:
function outerFunction() {
    var outerVar = 'Outer function variable';
    {
        let blockVar = 'Block variable';
        function innerFunction() {
            var innerVar = 'Inner function variable';
            console.log(outerVar); 
            console.log(blockVar); 
            console.log(innerVar); 
        }
        innerFunction();
    }
}
outerFunction();

在这个例子中,innerFunction 可以访问 outerFunction 的变量 outerVar 和块级作用域中的变量 blockVar,因为它们都在 innerFunction 的作用域链上。但是要注意,如果在不同作用域中声明了同名变量,访问的优先级是从内到外查找,先找到最近作用域中的变量。

  1. 闭包与块级作用域的结合 当闭包与块级作用域结合使用时,需要特别小心。闭包会保持对外部作用域变量的引用,而块级作用域的变量生命周期相对较短。例如:
function closureWithBlock() {
    var functions = [];
    for (let i = 0; i < 3; i++) {
        functions.push(() => i);
    }
    return functions.map(func => func());
}
console.log(closureWithBlock()); 

在上述代码中,由于 let 的块级作用域特性,每个闭包都捕获了自己独立的 i 变量副本。但如果不小心在块外修改了 i 的值(虽然在这种情况下不太可能,因为 i 只在块内有效),可能会影响闭包的行为。

  1. 兼容性问题 虽然现代浏览器都支持块级作用域(letconst),但在一些老旧的环境中,如一些古老的浏览器版本或者某些服务器端 JavaScript 运行时环境(如果没有进行适当的转译),可能不支持块级作用域。在编写代码时,如果需要考虑兼容性,可能需要使用一些 polyfill 或者仍然使用函数作用域和 var 声明变量。例如,可以使用 Babel 工具将 ES6 代码转译为 ES5 代码,以确保在不支持块级作用域的环境中也能正常运行。

总结

JavaScript 的函数作用域和块级作用域各有其特点和适用场景。函数作用域在封装代码逻辑、实现闭包等方面有着重要的作用,而块级作用域则在控制变量生命周期、避免命名冲突等方面表现出色。深入理解它们的区别和本质,能够帮助开发者编写出更清晰、更高效且更具维护性的 JavaScript 代码。在实际开发中,需要根据具体的需求和场景,合理地选择使用函数作用域或块级作用域,并且要注意它们混合使用时可能出现的各种问题,以确保代码的正确性和稳定性。无论是在前端开发中构建复杂的用户界面,还是在后端开发中处理业务逻辑,对作用域的准确把握都是成为一名优秀 JavaScript 开发者的关键因素之一。同时,随着 JavaScript 语言的不断发展,对作用域的理解也需要不断深化,以适应新的语言特性和开发需求。