木灵鱼儿

木灵鱼儿

阅读:180

最后更新:2022/10/18/ 23:49:41

函数作用域和块作用域

前言

函数作用域和块作用域,并不是高出词法作用域的一种东西,可以将其理解为词法作用域的一些单元,如果词法作用域是房子,函数作用域和块作用域就是房间。

函数作用域

每个函数都会为其自身创建一个新的局部环境(函数作用域),属于这个函数的全部变量(函数内声明的),都只能在该环境范围内使用,当然也包括作用域嵌套。

function foo(a) {
    var b = 2;
    // 一些代码
    function bar() {
        // ...
    } // 更多的代码
    var c = 3;
}

bar(); // ReferenceError 

上述代码中,bar函数存在于foo函数的作用域下,所以外部作用域无法查找到它,而在bar函数内,可以通过作用域链获取到a、b、c三个变量。

由于外部无法访问到内部,所以这种能力也常常用来隐藏一些内部实现。

有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

function doSomething(a) {
    b = a + doSomethingElse(a * 2)
    console.log(b * 3);
}

function doSomethingElse(a) {
    return a - 1;
}
var b;
doSomething(2); // 15

在这个代码片段中,变量 b 和函数 doSomethingElse(..) 应该是 doSomething(..) 内部具体实现的“私有”内容。给予外部作用域对 b 和 doSomethingElse(..) 的“访问权限”不仅没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用,从而导致超出了 doSomething(..) 的适用条件。更“合理”的设计会将这些私有的具体内容隐藏在 doSomething(..) 内部,例如:

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomethingElse(a * 2)
    console.log(b * 3);
}
doSomething(2); // 15

现在,b 和 doSomethingElse(..) 都无法从外部被访问,而只能被 doSomething(..) 所控制。功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会依此进行实现。

隐藏内部实现所带来的另一个好处就是规避冲突,可以避免相同的变量名标识符之间的冲突,因为作用域将其分隔了。

例子:

function foo() {
    function bar(a) {
        i = 3; // 修改 for 循环所属作用域中的 i
        console.log(a + i);
    }
    for (var i = 0; i < 10; i++) {
        bar(i * 2); // 糟糕,无限循环了!
    }
}
foo();

很明显i变量名冲突了,从而导致了无限循环。

我们可以对bar函数中的i进行var变量声明,将其作为bar自身作用域下的变量标识符使用。

规避冲突还有其他的一些处理方式,比如:

  1. 全局命名空间,通过在全局作用域中声明一个独特的变量名,通常是一个对象,将这个对象作为库的命名空间,将所有需要暴露的功能都作为该对象的属性,而不是将所有的功能都作为全局的标识符来使用,最经典的就是jq的$符号了。
  2. 第二种是模块管理,通过一些工具实现一种模块机制,将一些库的标识符导入到特定的作用域下,比如通过函数传参的方式,将库的标识符传入,这样就不会影响到全局作用域了,比较经典就是AMD的方式了,反正很复杂,现在ES6模块完全吊打,不管是用法还是写法,所以不需要太过了解。

匿名函数和函数表达式

现在我们知道,在任何代码段外部添加一个包装用的函数,可以将内部的变量和函数“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。

虽然这种技术可以解决一些问题,但是却并不理想,第一是必须声明一个具名函数,这个函数的名称本身就会污染到全局环境,第二是必须显式的调用函数名才能运行其中的代码。

var a = 2;

function foo() { // <-- 添加这一行
    var a = 3;
    console.log(a); // 3
} // <-- 以及这一行

foo(); // <-- 以及这一行
console.log(a); // 2

如果不需要函数名并且能够自己运行,这就会非常的好。

于是就有了这种写法:

var a = 2;

(function foo() { // <-- 添加这一行
    var a = 3;
    console.log(a); // 3
})(); // <-- 以及这一行

console.log(a); // 2

在()里的foo函数其实已经不是标准的函数声明了,它被当做函数表达式所使用,因为foo函数本身成为了自运行函数的一部分,所以foo的标识符,只能在foo函数的内部访问到,而外部是访问不到的,这也意味着foo的变量名不会污染外部作用域。

如何区分函数声明和函数表达式呢?

  1. 函数声明时函数名是必须的,而函数表达式中的函数名是可选的。
  2. 函数声明的函数不能在后面加()运行,函数表达式可以
  3. 函数声明的标识符会比函数表达式的标识提前。
function fun() { 
    console.log('我是一个函数声明式')
}();  //unexpected token
​
var foo = function() {  
    console.log('我是一个函数表达式')
}();  //我是一个函数表达式

相对于自运行的函数表达式,我们常用的还有定时器的回调函数,也就是常用的匿名函数表达式

setTimeout(function() {
    console.log("I waited 1 second!");
}, 1000);

匿名函数因为没有名称,所以常常用于回调或自运行函数,但是它也是有缺点的:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身
  3. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明

有时候给匿名函数表达式提供一个名称可以有效的进行调试排查:

setTimeout(function timeoutHandler() { // <-- 快看,我有名字了!
    console.log("I waited 1 second!");
}, 1000);

需要注意的是,如果你这么声明一个函数,是会报错的:

function() {}   //Uncaught SyntaxError: Function statements require a function name

因为函数声明必须要有name。

立即执行函数表达式

自运行的函数表达式也被称为立即执行函数表达式,由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个( ) 可以立即执行这个函数,比如 (function foo(){ .. })()。第一个 ( ) 将函数变成表达式,第二个 ( ) 执行了这个函数。

这种模式很常见,于是社区给他规定了一个专业术语:IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression)

由于是函数表达式,所以函数名不是必须的,所以常常会有这种写法:

var a = 2;

(function() {
    var a = 3;
    console.log(a); // 3
})();

console.log(a); //2

但是很多人不是很喜欢这种方式,因为它的两个()是独立的,所以后续延伸了改进型:

var a = 2;

(function() {
    var a = 3;
    console.log(a); // 3
}());

console.log(a); //2

这两种方式功能一致,看个人喜好使用。

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。

例如:

var a = 2;

(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(window);

console.log(a); // 2

我们将 window 对象的引用传递进去,但将参数命名为 global,因此在代码风格上对全局对象的引用变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传递任何你需要的东西,并将变量命名为任何你觉得合适的名字。这对于改进代码风格是非常有帮助的。

这个模式的另外一个应用场景是解决 undefined 标识符的默认值被错误覆盖导致的异常(然不常见)。将一个参数命名为 undefined,但是在对应的位置不传入任何值,这样就可以保证在代码块中 undefined 标识符的值真的是 undefined:

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做!
(function IIFE(undefined) {
    var a;
    if (a === undefined) {
        console.log("Undefined is safe here!");
    }
})();

块作用域

尽管函数作用域是最常见的作用域单元,但是其他类型的作用域单元也是存在的,除了JavaScript外,还有很多语言也支持块级作用域,我们来详细了解一下。

在早期var的时候,我们会有一些很头疼的地方:

for (var i = 0; i < 10; i++) {
    console.log(i);
}

我们希望这个for循环中的i变量只是在该循环块中使用,但是使用var就会导致它与for存在于相同的作用域下。

var foo = true;
if (foo) {
    var bar = foo * 2;
    bar = something(bar);
    console.log(bar);
}

bar变量只会在if声明的上下文中使用,但是如果使用了var,就会导致bar写哪都一样,因为最终都会属于外部作用域。

我们非常希望一些变量标识符不会污染到其他环境,因为有些内容只是在该地区使用,但是因为var的存在导致它在别处也可以获取并修改。

这显然非常危险,也不符合我们的最小授权原则。

在es6标准还没有出来的时候,JavaScript其实也是有块级作用域的,比如:with和try...catch;es6的则是let和const

with

with在上一章已经说过了,它会将传入的对象处理成一个词法作用域,所以它是有块级作用域的,但是并不是很好,因为它只能处理对象已有的属性,没有的还是会走LHS查询,从而影响到外部作用域。

try...catch

非常少有人会注意到 JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

try {
    undefined(); // 执行一个非法操作来强制制造一个异常
} catch (err) {
    console.log(err); // 能够正常执行!
}
console.log(err); // ReferenceError: err not fou

尽管这个行为已经被标准化,并且被大部分的标准 JavaScript 环境(除了老版本的 IE 浏览器)所支持,但是当同一个作用域中的两个或多个 catch 分句用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告。
实际上这并不是重复定义,因为所有变量都被安全地限制在块作用域内部,但是静态检查工具还是会很烦人地发出警告。

为了避免这个不必要的警告,很多开发者会将 catch 的参数命名为 err1、err2 等。也有开发者干脆关闭了静态检查工具对重复变量名的检查。

let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。

var foo = true;

if (foo) {
    let bar = foo * 2;
    bar = something(bar);
    console.log(bar);
}

console.log(bar); // ReferenceError

用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性地移动这些块或者将其包含在其他的块中,就会导致代码变得混乱。

所以一些常见的做法是显示的创建作用域:

var foo = true;

if (foo) {
    { // <-- 显式的块
        let bar = foo * 2;
        bar = something(bar);
        console.log(bar);
    }
}

console.log(bar); // ReferenceError

这样当我们去移动部分代码时,可以很快的识别出来,并且使用它们。

但是使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。

{
    console.log(bar); // ReferenceError!
    let bar = 2;
}

这个提升我们后续再说。

1. 垃圾收集

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。这里简要说明一下,而内部的实现原理,也就是闭包的机制。

function process(data) {
    // 在这里做点有趣的事情
}

var someReallyBigData = {..
};

process(someReallyBigData);

var btn = document.getElementById("my_button");
btn.addEventListener("click", function click(evt) {
    console.log("button clicked");
})

由于click事件,他的click函数形成了一个闭包,在该函数内虽然我们并没有书写调用外部变量的代码,但是在引擎认为,它可能需要保留外部的变量,因为有可能在click的函数内部的某处需要使用到,特别是在多个回调嵌套的情况下。

此时浏览器就无法正确的回收垃圾,但是如果我们使用作用域块,就可以让引擎准确的知道,外面的变量已经不需要someReallyBigData了,可以回收了。

function process(data) {
    // 在这里做点有趣的事情
}

{
  var someReallyBigData = {..
  };
  
  process(someReallyBigData);
}

var btn = document.getElementById("my_button");
btn.addEventListener("click", function click(evt) {
    console.log("button clicked");
})

2. let循环

使用let可以在for循环中,将变量仅绑定到循环块中,而不会影响到外部作用域。

for (let i = 0; i < 10; i++) {
    console.log(i);
}
console.log(i); // ReferenceError

它可以这么理解:

{
    let j;
    for (j = 0; j < 10; j++) {
        let i = j; // 每个迭代重新绑定!
        console.log(i);
    }
}

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

const

const用于声明一个固定值,也就是常量,之后的任何试图修改值的操作都会导致报错。它同样也可以用来创建块级作用域变量。

var foo = true;

if (foo) {
    var a = 2;
    const b = 3; // 包含在 if 中的块作用域常量
    a = 3; // 正常 !
    b = 4; // 错误 !
}

console.log(a); // 3
console.log(b); // ReferenceError!

小结

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。

从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。

在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if(..) { let a = 2; } 会声明一个劫持了 if 的 { .. } 块的变量,并且将变量添加到这个块中。

有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。

版权申明

本文系作者 @木灵鱼儿 原创发布在木灵鱼儿 - 有梦就能远航站点。未经许可,禁止转载。

关于作者

站点职位 博主
获得点赞 1
文章被阅读 180

相关文章