JavaScript-作用域

作用域

作用域是什么

收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

理解作用域

var a = 2;

编译器处理流程:

  1. 先处理var a;询问作用域是否存在该名称的变量在同一个作用域中。如果是,编译器就会忽略该声明,继续进行编译(进行第2步);否则会在当前作用域中声明一个新变量,并命名为 a 。
  2. 处理a = 2这个赋值操作。先询问作用域是否存在一个叫做 a 的变量,如果是,将使用该变量进行赋值操作;如果否,将继续查找该变量。
  3. 如果最终没找到 a 变量,将抛出一个异常。

LHS查询和RHS查询

  • LHS查询:查找变量的容器本身(赋值操作的“左侧”)

    a = 2;
    // 此时对 a 的引用只是为 = 2 这个赋值操作找到一个目标(LHS)
    
  • RHS查询:查找某个变量的值(赋值操作的“非左侧”)

    console.log(a);
    // 此时是一个RHS引用,只需要找到变量a的值并传递给console.log(..)
    
  • function foo(a){	// LHS,查找名为a的变量并进行赋值
    	console.log(a);	
        /*	对console对象进行RHS查询,检查得到的值中是否存在名为log的方法
        	RSH,查询变量a的值
        */
    }
    foo(2);				// RHS,查找名为foo的函数类型的值
    

作用域嵌套

在当前作用域中无法找到某个变量时,将会在外层签到的作用域中继续查找,直到找到该变量或抵达最外层的作用域(全局作用域)。

异常

“严格模式” 非“严格模式”
LHS查询未找到变量 ReferenceError异常 在全局作用域中创建一个名为该名称的变量
RHS查询未找到变量 ReferenceError异常 同左
RHS查询变量为空
(null或undefined)
TypeError异常 同左
  • ReferenceError与作用域判别失败相关
  • TypeError表示对结果的操作是非法或不合理的

词法作用域

// 全局作用域
function foo(a) {
    // foo创建的作用域
	var b = a * 2;
	function bar(c) {
        // bar创建的作用域
		console.log(a, b, c);
	}
	var(b * 3);
}
foo(2);

欺骗词法

在运行时“修改”(欺骗)词法作用域。欺骗词法作用域会导致性能下降

eval

eval(..)函数可以接受一个字符串为参数,并将其视为存在程序这个位置的代码

function foo(str, a){
	eval(str);
    // 欺骗,此时foo(..)的词法作用域中创建了变量b,遮掩了外部作用域中的同名变量
	console.log(a, b);	// 1, 3	
}
var b = 2;
foo("var b = 3;", 1);

严格模式下eval(..)在运行时有自己的词法作用域,即其发出的声明无法改变所在作用域

类似的还有setTimeout(..)setInterval(..)的第一个参数可以是字符串,其可以被解释为一段动态生成的函数代码;new Funtion(..)函数的最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是新生成函数的形参),但都不提倡使用

with

通常被当作重复引用同一个对象的多个属性的快捷方式

var obj = {
	a: 1,
	b: 2,
	c: 3
};

// 重复“obj”
obj.a = 2;
obj.b = 3;
obj.c = 4;

// with快捷方式
with (obj) {
	a = 3;
	b = 4; 
	c = 5;
}

with(obj) {..}不仅是为了方便访问对象属性

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = {
    a: 3
};

var o2 = {
    b: 3
};

foo(o1);
console.log(o1.a);	// 2

foo(o2);
console.log(o2.a);	// undefined
console.log(a);		// 2
  • o1传递进foo(..),通过一个LHS查询将2赋值给了o1a属性
  • o2没有a属性,因此不会创建这个属性,o2.a保持undefined,但with将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符,通过LHS查询作用域发现并没有名为a的变量,则会在全局作用域中创建一个变量a(非严格模式下)

性能

JavaScript引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能那个在执行过程中快速找到标识符

词法分析阶段无法明确知道eval(..)会接收什么代码,这些代码会对作用域进行怎样的修改;也无法知道传递给with用来创建新词法作用域的对象的内容到底是什么,这样就会导致程序运行缓慢

总结

  • eval(..)会修改其所处的词法作用域
  • with会根据传递的对象创建一个新的词法作用域

函数作用域和块作用域

隐藏内部实现

对代码中的任意片段通过函数声明的方式对它进行包装,以达到“隐藏”代码的效果

为什么“隐藏”变量和函数是一个有用的技术?

最小特权原则:指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来

例:某个模块或对象的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(..)内部具体实现的“私有”内容,没有必要给予外部作用域对bdoSomethingElse(..)的“访问权限”

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

    此时bdoSomethingElse(..)都只能被diSomething(..)所控制,功能性和最终效果并没有受影响,但设计上将具体内容私有化

规避冲突

“隐藏”作用域中的变量是可以避免同名标识符之间的冲突,而冲突会导致变量的值被覆盖

function foo() {
    function bar(a) {
        i = 3;		// var i = 3;
        console.log(a + 3);
    }
    
    for (var i = 0; i < 10; i++){
        bar(i * 2);
    }
}

foo();
  • bar(..)内部的赋值表达式i = 3;覆盖了声明在foo(..)内部for循环中的i,导致无限循环
  • 使用var i = 3;会为i声明一个“遮掩变量”,也可以采用一个完全不同的标识符名称
  • 但软件设计在某种情况下可能自然而然的要求使用同样的标识符名称,因此使用作用域来“隐藏”内部声明是唯一的最佳选择
  1. 全局命名空间

    当程序中加载了多个第三方库时,如果它没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突

    这些库通常会在全局作用域中声明一个名字足够独特的变量(例如jQuery的$),通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中

  2. 模块管理

    无需将标识符加入到全局作用域中,而是依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域

    利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中

函数作用域

声明一个具名函数foo(),意味着foo这个名称本身“污染”了所在作用域,而且必须显示的通过函数名(foo())才能调用这个函数

var a = 2;

// 标准函数声明
function foo() {
    var a = 3;
    console.log(a);	// 3
}

// 包装函数
(function foo() {	// <-- 添加这一行
    var a = 3;
    console.log(a);	// 3
})();

console.log(a);		// 2
  • 包装函数声明的函数会被当作函数表达式而不是一个标准的函数声明来处理
  • 通过对比可以发现,标准函数声明中foo被绑定在所在作用域;包装函数中的foo被绑定在函数表达式自身的函数中而不是所在作用域中,即(function foo(){..})中的foo只能在..所代表的位置中被访问,foo变量名的隐藏意味着不会非必要地污染外部作用域

匿名和具名

  • 匿名函数表达式funtion()..

    没有名称标识符

    函数表达式可以匿名,函数声明不可以省略函数名(非法)

    缺点

    • 调试困难,匿名函数在栈追踪中不会显示有意义的函数名
    • 无法引用自身,例如:递归,另一个函数需要引用自身的例子
    • 省略了对于代码可读性/可理解性很重要的函数名,一个描述性的名称可以让代码不言自明

    行内函数表达式——匿名和具名之间的区别并不会对函数表达式这点有任何影响

    setTimeout(function timeoutHandler() {	// 有名字
        console.log("I waited 1 second!");	// 等待一秒后输出
    }, 1000);
    
    setTimeout(console.log("I waited 1 second!"), 1000);	// 立即输出
    
    • this取决于函数运行时谁调用了这个函数
    • setTimeout第一个参数要是个函数
    • 执行setTimeout函数时,参数如果是表达式,会先计算表达式的结果
  • 立即执行函数表达式 IIFE

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

    函数被包含在一对( )括号内部,因此成为了一个表达式,通过在末尾加上另外一个( )可以立即执行这个函数

    函数名对IIFE不是必须的,IIFE最常见的用法是使用一个匿名函数表达式,其拥有匿名表达式的所有优势

    • 将一个参数传递进函数里,是IIFE的另一个普遍的进阶用法

      var a = 2;
      (function IIFE(global) {
          var a = 3;
          console.log(a);			// 3
          console.log(global.a);	// 2
      })(window);
      
      console.log(a);				// 2
      
    • 倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后作为参数传递进去(被UMD项目广泛使用)

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

块作用域

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

with

with从对象中创建的作用域尽在with声明中而非外部作用域有效

try/catch

try/catch中的catch分句会创建一个块作用域,其中声明的变量尽在catch内部有效

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

console.log(err);		// ReferenceError: err not found

err尽在catch分句中有效

let

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

只要声明有效,在声明中的任何位置都可以使用{..}括号来为let创建一个用于绑定的块

使用let进行的声明不会在块作用域中进行提升

  1. 垃圾收集

    function process(data) {
        // do something
    }
    
    var someReallyBigData = {..};
    
    process(someReallyBigData);
    
    var btn = document.getElementById("my_button");
    
    btn.addEventListener("click", function click(evt) {
        console.log("button clicked");
    }, /*capturingPhase*/false);
    

    click函数的点击回调并不需要someReallyBigData变量。理论上当process(..)执行后,内存中占用大量空间的数据结构就可以被垃圾回收了。但由于click函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能保存这个结构

    块作用域可以让引擎知道没有必要保存someReallyBigData

    function process(data) {
        // do something
    }
    
    // 在这个块中定义的内容在结束后可以销毁!
    {
        let someReallyBigData = {..};
        process(someReallyBigData);
    }
    
    var btn = document.getElementById("my_button");
    
    btn.addEventListener("click", function click(evt) {
        console.log("button clicked");
    }, /*capturingPhase*/false);
    

    为变量显式声明块作用域,并对变量进行本地绑定

  2. let循环

    for循环头部的let不仅可以将i绑定到循环的块中,实际上它将重新绑定到循环的每个迭代

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
posted @ 2021-05-30 00:06  陈嘉懿  阅读(49)  评论(0编辑  收藏  举报