《You Dont Know JS上卷》(三) ---函数作用域和块级作用域

78 阅读10分钟

函数作用域和块级作用域

函数中的作用域

函数作用域是指: 属于这个函数的全部变量都可以在整个函数的范围内使用及复用(包括嵌套作用域).只要标识符声明在函数的括号内,这个标识符所代表的变量或者函数都将附属于该作用域

隐藏内部实现

在第二章中,我们解释了JS的作用域是静态的的原因,即js的作用域工作模型采用的是词法作用域,而词法作用域在词法阶段定义.

这里我们解释一下js作用域另一个特点: 单向性,即变量的查找只能由内层作用域向外层作用域查找

最小特权原则

也叫最小授权,最小暴露原则,是指: 在软件设计中,应该最小限度的暴露必要的内容,而其他内容都隐藏起来

这个原则引申到如何选择作用域来包含变量和函数时,我们就理解为什么作用域是单向的;

在编写代码时,我们大可以将所有变量和函数都写在全局作用域中,但这会破坏最小特权原则,因为我们可能会暴露过多的变量和函数,这是很危险的,在将来谁知道会产生什么意想不到的影响.正确的方式是只暴露需要暴露的变量和函数,而将其他的想办法隐藏起来,不让别人随意访问.这就是函数作用域做的事情.我们把私有的变量和函数包裹在一个函数作用域中,用函数作用域来隐藏他们.

于是产生的效果就是外层作用域无法访问内层作用域的变量和函数,因为对js来说,我们创建内层作用域就是为了隐藏私有变量和函数的

单向性的实现

至此我们知道了由于最小授权原则致使JS的作用域是单向的,那么js是如何实现这个单向呢?

其实这跟JS垃圾回收机制有关(这里稍微提一下,详情可见另一篇文章《JavaScript高级程序设计》(二) ---变量,作用域和内存):

JS变量中每个变量都有一个标记,这个标记标识出这个变量是否有被引用,在垃圾回收机制运行的时候,如果上下文中的变量没有标记,JS认为该变量没有任何地方用到它,于是该变量就会被清理掉.

在实际运行中,当函数执行完毕时,就没有任何地方再引用函数作用域中的变量了,相应的变量就被清除了,所以函数外部作用域再去访问的时候,就发现已经没有这个变量了,于是产生的效果就是外部作用域无法访问内部作用域的变量

规避冲突

"隐藏"作用域中的变量和函数的另一个好处就是: 可以避免同名标识符的冲突

其次还有其他方式可以很好的规避冲突:

  1. 全局命名空间

    变量冲突的一个典型例子存在于全局作用域中,当程序加载很多第三方库的时候,如果没有妥善管理库私有的变量和函数,就很容易引起冲突.

    一般情况下,这些库会在群居作用域中声明一个足够特殊的的变量如jquery的$,通常是一个对象.这个对象被用作库的命名空间,所有需要暴露给外界的功能都会称为这个对象的属性,而不是直接将标识符暴露出去

  2. 模块管理

    这种方式和现代的模块管理机制很接近,就是使用模块管理器,使用这些工具时,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制,将库的标识符显式的导入到另外一个作用域中

    只要你愿意,即使不使用任何依赖管理工具也可以实现相同的功效,在第五章会进行介绍

函数作用域

函数声明和函数表达式

我们已经知道,在任意代码片段外不添加包装函数,可以将内部的变量和函数"隐藏"起来,外部作用域无法访问包装函数内部的任何内容

虽然这种技术可以解决一些问题,但同时也带来了一下麻烦:

  1. 首先,必须声明一个具名函数,意味着这个具名函数的标识符本身就''污染''了所在的作用域
  2. 其次,必须显式的通过函数名调用这个函数才能运行其中的代码

好在JavaScript提供了能同时解决上述两个问题的方案: 函数表达式

(function foo(){})() //函数表达式
function foo(){} //函数声明

上述包装函数使用(function开头,却没有使用function开头,这里不仅是书写上的小不同,实际上有着很重要的区别: 这里的函数会被当作函数表达式而不是函数声明来处理

区分函数声明和函数表达式最简单的办法就是看function关键字的在声明中出现的位置不仅仅是所在的那一行代码,而是整个声明中的位置,如果function是第一个词,那就是函数声明,否则是函数表达式

setTimeout(function(){},1000) //其中的函数其实是函数表达式

函数声明和函数表达式的区别:

最大的区别就是,他们的标识符会被绑定在何处

  • 函数声明: 绑定在所在作用域中,不可以省略标识符
  • 函数表达式: 绑定在自身的函数中,而非所在作用域中.可以省略标识符(匿名函数表达式),
(function foo() {
    console.log(foo); //[Function: foo] //自身作用域可以访问到
})(); 
console.log(foo); //foo is not defined  //外界作用域无法访问到foo
再入:
setTimeout(function foo() {
  console.log(foo);  //[Function: foo] //自身作用域可以访问到
}, 1000);
console.log(foo); //foo is not defined  //外界作用域无法访问到foo

匿名和具名

对于函数表达式最属性的场景可能就是回调函数了:

setTimeout(function(){},1000)

这叫做匿名函数表达式,因为function没有名称标识符,函数表达式可以是匿名的,但函数声明不可以

匿名函数表达式的缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的命名,使调试很困难
  2. 如果没有函数名,当函数需要自身引用的时候只能使用已经过期的arguments.cellee引用,比如递归,事件触发后解绑
  3. 匿名函数省略了对代码可读性,很重要的函数名

给函数表达式一个指定的名称可以有效的解决这些问题:

setTimeout(function timeoutHandler(){},1000)

立即执行函数

IIFE代表立即执行函数

将函数包裹在一对括号内(),因此称为了函数表达式,再在末尾添加一个括号(),可以立即执行这个函数,这就是IIFE,以下两种形式功能上式一致的:

(function(){})()
或者
(function(){}())
用法
  1. 把他们当作函数调用,并传参进去

    (function IIFE(global){
        console.log(global.xxx)
    })(window)
    

    这样一来,从代码风格上将全局对象的引用变得比引用一个没有全局字样的变量更加清晰

  2. 解决`undefined被意外赋值的问题,我们知道undefined可以作为变量名被赋值,从而在判断undefined的时候发生意料之外的事情,我们可以使用IIFE将第一个参数命名为undefined,但是在对应位置不传递任何的值

    undefined = true
    (function IIFE(undefined){
        let a;
        if(a === undefined){} //这里可以正常进行判断,不用担心undefuned被覆盖
    })() //这里不要传递参数
    
  3. 倒置代码的顺序,将要运行的代码放在第二位,这种方式在UMD项目中被官广泛使用,

    UMD模式简介: 
    UMD 是一种用于创建可以在多种环境中运行的 JavaScript 模块的通用标准。
    在 JavaScript 中,存在多种模块系统,例如 CommonJSAMDAsynchronous Module Definition)和 ES6 Modules。这些模块系统在不同的环境中有不同的使用方式和语法规则。UMD 的目标是提供一种通用的方法,使得一个 JavaScript 模块都可以在多种环境下使用,包括在浏览器中作为全局变量使用,或通过 CommonJSAMD 等模块加载器引入。
    ​
    (function (root, factory) {
        if (typeof define === 'function' && define.amd) {
            // AMD 环境
            define(['dependency'], factory);
        } else if (typeof exports === 'object') {
            // CommonJS 环境
            module.exports = factory(require('dependency'));
        } else {
            // 浏览器全局变量
            root.MyModule = factory(root.Dependency);
        }
    }(this, function (Dependency) {
        // 模块代码
        return {
            // 模块导出的内容
        };
    }));
    这里通过一个IIFE函数根据当前的执行环境来决定如何导出模块.
    

块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从函数作用域中隐藏的信息扩展为在块作用域中隐藏信息,

for(var i = 0; i < 10; i++){
    console.log(i) //输出 0 ,1,2,3,4,5,6,7,8,9
}
console.log(i) //输出 10

上述代码是很常见的for循环,我们在循环头部直接定义了变量i,正常情况下,我们只想在循环内部使用i,不希望i暴露给外部,但从例子中我们可以看到,在循环外部成功的打印了i,这时因为i被绑定在了外部作用中,显然着违背了最小授权原则,那么如何将i固定在循环所在的上下文之中呢?

块作用域就可以做到这一点:

for(let i = 0; i < 10; i++){
    console.log(i) //输出 0 ,1,2,3,4,5,6,7,8,9
}
console.log(i) // ReferenceError i is not defined

看我们通过let关键字,将`for循环的{}变成了块级作用域,因此下方在访问的时候就会抛出错误,没有在当前作用域找到i

块作用域分类

块作用域写法只是简单的{}即可,但只用{}是无法构成块作用域的,需要特定的条件才可以开启,

with

虽然不推荐使用with,但使用with从对象创建的作用域确实仅在with声明中使用,外部作用域无法访问

try/catch

try/catch在ES3之后,其catch分分句会创建块级作用域,其中声明的变量仅仅在catch中可以访问

try {
  throw 1;
} catch (err) {
  console.log(err); //1
}
console.log(err); //ReferenceError 

书中说: catch会创建块级作用域,其中声明的变量仅在catch内部有效.

但我尝试了以下代码,代码行为并不像书中所述:

try {
  throw 1;
} catch (err) {
    var a = 2
  console.log(a); //2
}
console.log(a); //2 正常输出,并没有报错.

不知道这里是不是书中描述不准确,有路过懂的大神,还请指指教指教

let/const

使用let,const关键字可以开启块级作用域,将let所在的{}变成块级作用域

垃圾收集

块级作用域很有用的其中一个原因就是其和闭包及垃圾回收机制有关

function process(data){
    ....
}
var someData = {}
process(someData)
var btn = document.querSelector('my_button')
​
btn.addEventListener("click",function(){...})

上述代码中由于click函数形成了一个覆盖整个作用域的闭包,导致即使process执行完毕,垃圾回收机制可能依然保持这个结构,不会回收函数执行占用的大量空间,(之所以说可能是因为这取决于具体实现),

使用块作用域就可以打消这种顾虑:

function process(data){
    ....
}
{
    let someData = {}
    process(someData)
}
var btn = document.querSelector('my_button')
btn.addEventListener("click",function(){...})

这样引擎就清楚的知道没有必要继续保存someData这个变量了