[JS] You Don't Know JavaScript [Scope & Closures] - Limiting Scope Exposure ?

前言

目前为止我们都着重在解释作用域与变量的工作机制,有了这些基础後将进到下一步,首先我们要先探讨不同级别的作用域来组织宣告的变量,特别是减少作用域的过度暴露

Least Exposure

在软件工程中定义了The Princioke of Least Privilege(POLP)原则,他认为系统的component设计应该以最小特权,最少访问,最小暴露来运行,若是每个部分都以最小且必要的方式连接则整个系统的安全性会更强大,因为若是一部份发生故障那麽他对其他部分的影响会是最小的。

PLOE是针对较低级别的设计,可以将其应用到作用域中,如何做到最大程度地减少作用域的曝光?答案是在每个作用域中宣告自己的变量

在我们设计程序的时後常会避免将变量都在全域中宣告,虽然都这麽避免但是却不知道这样会造成什麽困难,当你程序中的变量超过自己的作用域暴露给另一个作用域的时候,通常会出现三个危险:
1.命名冲突:当你在一个共同的作用域中使用常用的宣告命名,可能会在不经意间产生两个一样名称的变量或function,这会导致命名的冲突,而这个冲突很可能会造成你程序的bug,因为他呼叫的不一定会是你要的那个变量或function。
2.意外的行为:若是你将你的变量或function公开能够让其他地方使用到,有可能会被其他开发者以你不希望的方式使用你宣告的变量或function。
3.意外的依赖:如果公开你的变量或function他可能在不经意间造成其他开发者的依赖或使用,尽管短时间内不会有问题但是未来如果面临重构则会有大麻烦,因为你的这个变量会影响到除了你自己之外的更多地方。

经过上面这些危险可以知道为什要避免将变量或function宣告在全域或是其他作用域可以访问到的地方,尽可能地将变数宣告在自身范围内以减少错误的发生。


Hiding in Plain (Function) Scope

在数学中有个操作叫做阶层,他是将给定整数与所有连续的较低整数的乘积(6! = 65432*1),你可以写一个function几算每次的值,也可以将之前几算的值储存起来这样就可以不用每次都从头计算

var cache = {};

function factorial(x) {
    if (x < 2) return 1;
    if (!(x in cache)) {
        cache[x] = x * factorial(x - 1);
    }
    return cache[x];
}

factorial(6); // 720

cache;
// {
//     "2": 2,
//     "3": 6,
//     "4": 24,
//     "5": 120,
//     "6": 720
// }

factorial(7); // 5040

我们使用cache来暂存factorial(...)的结果,但是因为他是在外部作用域的变数,所以有可能会发生上面提到的那三种危险,所以我们必须将cache隐藏在作用域中而不是让他暴露在其他地方可以访问的位置。

// outer/global scope

function hideTheCache() {
    // "middle scope", where we hide `cache`
    var cache = {};

    return factorial;

    // **********************

    function factorial(x) {
        // inner scope
        if (x < 2) return 1;
        if (!(x in cache)) {
            cache[x] = x * factorial(x - 1);
        }
        return cache[x];
    }
}

var factorial = hideTheCache();

factorial(6); // 720
factorial(7); // 5040

我们建立一个hideTheCache(...)来产生一个作用域,让我们吧cachefacto[](http://)rial(...)隐藏在这个function作用域中,因为他们存在於同一个作用域,所以也可以使factorial(...)访问到cache,最後我们在将factorial的reference return出去赋予给var factorial,这样既可以访问到cache也可以将它避免暴露在外面。

虽然这样的确能避免变量泄露到外层,但是每次只要有这个需求的时候就会需要在定义一个函式作用域,这是一件很烦琐的事情,我们可以使用函数表达式来取代每次都需要定义一个新的函示。

var factorial = (function hideTheCache() {
    var cache = {};

    function factorial(x) {
        if (x < 2) return 1;
        if (!(x in cache)) {
            cache[x] = x * factorial(x - 1);
        }
        return cache[x];
    }

    return factorial;
})();

factorial(6); // 720
factorial(7); // 5040

虽然可能会觉得他依然是建立了一个function来隐藏cache,但是回想一下The Scope Chain中的"Function Name Scope",由於factorial(...)是函数表达式所以对於function hideTheCache(...)这个function来说,他在做完後赋予结果给factorail之後他便会消失,举个简单的例子

let a = (function b(){return false};); //赋予a结果後便会消失

console.log(a()); //false
console.log(b()); // b is undefined

这代表着我们可以将函数表达式(function Exoression)的名字取的完全相同而不会发生冲突,意味着我们可以根据我们的想法任意取名而不会影响到其他部分造成冲突,当然你也可以使用匿名函式来达到一样的作用。

Invoking Function Expressions Immediately

在上面的程序码中在function expressions的最後添加了第二个(),那实际上是在要用刚刚定义的function expression,这种function expression的情况下第一个(...)不是严格需要的,但是为了可读性还是建议加上它们。

因此当我们定义了一个function expression後就立即调用他,我们称这个为Immediately Invoked Function Expression(IIFE),IIFE在JS中非常常用到,他可以使有名称的以可以是匿名的,而且他也可以是独立的将其返回的值=给指定的变量。

// outer scope

(function(){
    // inner hidden scope
})();

// more outer scope

Function Bonudaries

不同的定义范围会影响到IIFE的结果,因为他是一个完整的函数所以他会更改函数边界的某些语句或构造,举例来说,对於return而言如果将IIFE包在起中,某些程序的return会改变原本的意义,因为他会涉及到IIFE的功能;而非箭头函数的IIFE还会涉及到this的绑定,breakcontinue都不会跨越IIFE的函数边界以控制外部的循环或block,所以若你有需要使用return,this,break,continue的情况,使用IIFE可能不是个好办法。


Scoping with Blocks

到目前为止我们介绍了如何使用IIFE来完成作用域的隐藏,现在我们要介绍如何使用块状作用域(let)来达到一样的效果,通常{...}都会当作一个block,但是不一定会是作用域,而block只有在必要的时候才会成为作用域(其中含有块状宣告)。

{
    // not necessarily a scope (yet)

    // ..

    // now we know the block needs to be a scope
    let thisIsNowAScope = true;

    for (let i = 0; i < 5; i++) {
        // this is also a scope, activated each
        // iteration
        if (i % 2 == 0) {
            // this is just a block, not a scope
            console.log(i);
        }
    }
}

上面的程序码中可以看到,并不是所有的{...}都可以是作用域

  • object使用的{...}用来订定键值列表,这个{...}就不是作用域。
  • class所使用的{...}这也不是一个block或是作用域。
  • function所使用的{...}他不是一个block但是他是一个作用域。
  • switch...case上的{...}也不是block与作用域。

独立的{...}在过去不能成为作用域的情况下不常见,但是ES6中提供了块状作用域宣告let/const於是他们开始流行,独立的{...}可以在另一个作用域的内部执行。

if (somethingHappened) {
    // this is a block, but not a scope
    {
        // this is both a block and an explicit scope
        let msg = somethingHappened.message();
        notifyOthers(msg);
    }
    // ..
    recoverFromSomething();
}

在上面的程序中,我们定义了一个内部的块状作用域,因为不是整个if中都需要这些变量,大多数的开发者都会选择让这些宣告存在於if中,但是若整个程序的开发越来越大,泄露变量的风险也会提告,所以以POLE的规则来说,使用上面的方法比较安全的,回顾The (Not So) Secret Lifecycle of Variables提到的TDZ,所以建议在这些块状作用域的顶部就使用let/const宣告变数以降低TDZ Error的风险。

var and let

在JS的一开始var代表着属於这整个函数的变量,虽然也可以将var定义在block中,但是作者并不建议,他认为var应该宣告在函数作用域的顶部会最好。

那为什麽都不使用let就好呢?因为作者觉得var有明确的表达此变量是属於函数作用域的,若都在整个函式中使用let,那麽就不能从视觉上引起注意让人区分函数作用域中所有宣告的区别,换句话说var比let能更好的在函数作用域中进行沟通,而let则是能够让函式作用域与块状作用域中的通信,所以若是你的程序中同时需要使用函式作用域和块状作用域,那们推荐你同时使用var与let。

Where To let?

对於POLE的观点来说,他不干涉你使用哪一种宣告的语法,但是在做这个决定之前要先思考一个问题:对於我要宣告的这个变量而言,如何让他指曝光在最小的范围,作者推荐如果个宣告属於块状作用域则使用let,若属於函式作用域则使用var

对於for loop来说,建议一率都使用let,因为在loop的过程中i始终都只有在循环的内部做使用,在这种情况下需要使用let将i绑定在loop中减少他的泄露

for (let i = 0; i < 5; i++) {
    // do something
}

What's the Catch?

在ES3中提供了try...catch功能,而其中的catch提供了一个显微人知的块状作用域。

try {
    doesntExist();
}
catch (err) {
    console.log(err);
    // ReferenceError: 'doesntExist' is not defined
    // ^^^^ message printed from the caught exception

    let onlyHere = true;
    var outerVariable = true;
}

console.log(outerVariable);     // true
console.log(err);
// ReferenceError: 'err' is not defined
// ^^^^ this is another thrown (uncaught) exception

catch宣告了err是属於他自身的块状作用域,而catch的{...}若是使用let宣告变数,则他对被定义为块状作用域宣告(离开catch{...})之後就不存在,但是用var宣告变数,他会被附加到外部/全域作用域中。

在ES2019更改了catch,他变成可以更改他的声明(不一定要使用err),若不选择(不使用err)则catch不再是作用域,但他依然是一个block,因此若你需要对异常做出反应,但却不不关心错误的值,则可以直些忽略掉catch的声明。

try {
    doOptionOne();
}
catch {   // catch-declaration omitted
    doOptionTwoInstead();
}

Function Declarations in Blocks(FiB)

在JS中有个东西叫做FiB,他是说如果我们在block内部定义一个function会发生什麽事?

if (false) {
    function ask() {
        console.log("Does this run?");
    }
}
ask();

对於上面这个程序码中会发生什麽事?
1.因为ask是定义在if的块状作用域中,所以在外部/全域作用域中不可访问,所以产生ReferenceError。
2.因为if并未被运行,所以ask标示符尚未被定义为function,所以产生TypeError。
3.ask()正常运行。
其实若你在不同环境下运行这个程序会有不同的结果,若是在JS的环境下运行,因为对於JS来说ask是定义在内部作用域的所以不可访问(错误1);然而若你是在浏览器的环境下执行将表现为错误2,它意味着ask并不存在於内部作用域,他是在外面只是尚未被定义(undefined)。

在ES6引入块状作用域之前浏览器就已经接触到FiB行为了,为了不修改到一些老旧的网站,所以才所以才会造成浏览器与JS的结果不一样。

如果真的需要定义函式在block中,可以尝试使用function expression

var isArray = function isArray(a) {
    return Array.isArray(a);
};

// override the definition, if you must
if (typeof Array.isArray == "undefined") {
    isArray = function isArray(a) {
        return Object.prototype.toString.call(a)
            == "[object Array]";
    };
}

对於FiB来说是避免在block中declarations(宣告)一个函式,上面的程序中是使用function expression由於他不是宣告,所以是可以正常动作的。

参考文献:
You Don't Know JavaScript -2nd


<<:  [Day 30] 最後的行动装置

>>:  取得Microsoft Graph API 验证 token - MSAL

读书《代码大全》

什麽是隐喻? 重大发现往往是从类比中产生的。通过把一个你所陌生的事物与你所熟知的事物比较,你 会对它...

Day 3 - 新人报到前的准备与莫名的焦虑感

确定了offer也确定了报到时间後,距离到职日大概还有两周多的时间,因为自己是北漂青年因此开始寻找後...

Prototype 原型模式

今天来介绍 Creational Patterns 当中的最後一个模式。 假设这里有一个 Engin...

如何在 Angular 建立 Breadcrumb (面包屑)

面包屑 为提供网站里的导航,让使用者容易了解当下所在的位置 刚好也呼应前一天使用 router 的部...

[Day30] Room的坑只好自己补

Caused by: java.lang.IllegalStateException: Canno...