#导入MD文档图片#关于JS中的作用域中的沉思

宝剑锋从磨砺出,梅花香自苦寒来。这篇文章主要讲述#导入MD文档图片#关于JS中的作用域中的沉思相关的知识,希望能为你提供帮助。
scopeclosurejavascript中两个非常关键的概念,前者JS用多了还比较好理解而且容易体会到,而closure就不一样了。这玩意是真的很容易迷糊
作用域作用域,也就是我们常说的词法作用域,说简单点就是你的程序存放变量、变量值和函数的地方。根据作用范围不同可以分为全局作用域和局部作用域,简单说来就是,花括号{}括起来的代码共享一块作用域,里面的变量都对内或者内部级联的块级作用域可见,这部分空间就是局部作用域,在{}之外则是全局作用域。
全局作用域在javaScript中,作用域是基于函数来界定的。也就是说属于一个函数内部的代码,函数内部以及内部嵌套的代码都可以访问函数的变量。

function test(a){ var b = a * 2 function test2(c){ console.log(a ,b, c) } test2(b * 3) } test(4) // 4 8 24

#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

我们不妨尝试着来为这套代码划分一下作用域,上面定义了一个函数test,里面嵌套了函数test2。图中三个不同的颜色,对应三个不同的作用域:
  1. ①对应着全局scope,这里只有test2
  2. ②是test2界定的作用域,包含a、b、bar
  3. ③是bar界定的作用域,这里只有c这个变量。
在查询变量并作操作的时候,变量是从当前向外查询的。就上图来说,就是③用到了a会依次查询③、②、①。由于在②里查到了a,因此不会继续查①了。这个其实就是作用域链的查找方式,详细内容我们后续介绍。
作用域中的错误
这里顺便讲讲常见的两种error,ReferenceErrorTypeError。如上图,如果在test2里使用了d,那么经过查询③、②、①都没查到,那么就会报一个ReferenceError;
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

如果bar里使用了b,但是没有正确引用,如b.abc(),这会导致TypeError
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

局部作用域在局部作用域里面的变量通常是用到with, let, const
with
对于with第一印象可能就是with关键字的作用在于改变作用域,但并不代表这个关键字不好用,至少面试的时候大概率会可以被卷起来,如果你不常用的话。
with语句的原本用意是为逐级的对象访问提供命名空间式的速写方式,也就是说在指定的代码区域,直接通过节点名称调用对象。with通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。如下面代码
var obj = {a: 2, b: 2, c: 2}; with (obj) { a = 5; b = 5; c = 5; } console.log(obj) // {a: 5, b: 5, c: 5}

我们快速的创建了一个obj对象,为了能快速改变obj的值我们可以通过with的方式来进行修改,当然了,我们也可以通过逐行赋值的方式来进行,代码不够简洁就是了。话说回来,在这段代码中,我们使用了with语句关联了obj对象,这就意味着在with代码块内部,每个变量首先被认为是一个局部变量,如果局部变量与obj对象的某个属性同名,则这个局部变量会指向obj对象属性。
弊端在上面的例子中,我们可以看到,with可以很好地帮助我们简化代码。但生产环境中却很少见到,事实上并不是少见多怪,主要是不推荐使用,为啥嘞?原因如下:
  • 数据泄露
  • 性能下降
数据泄露
function test3(obj) { with (obj) { a = 2; } }var o1 = { a: 3 }; var o2 = { b: 3 }foo(o1); console.log(o1.a)foo(o2); console.log(o2.a); console.log(a);

在运行的过程中,我们可以看到,对于o1.a,o2.a的回显结果都不奇怪,毕竟对于o1.a来说a是在作用域中定义的,而o2.a压根在o2中未定义,对于这个结果显而易见,但为何a的值会从未定义到已赋值之间的转变呢?这个很危险的,毕竟这个时候已然出现数据泄露
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

首先,我们来分析上面的代码。例子中创建了o1o2两个对象。其中一个有a属性,另外一个没有。test3(obj)函数接受一个obj的形参,该参数是一个对象引用,并对该对象引用执行了with(obj){...}。在 with 块内部,对a有一个词法引用,实际上是一个 LHS引用,将 2 赋值给了它。
当我们将o1传递进去,a = 2赋值操作找到了o1.a并将 2 赋值给它。而当 o2 传递进去,o2 并没有 a 的属性,因此不会创建这个属性,o2.a保持undefined
但为什么对 o2的操作会导致数据的泄漏呢?
要回答这个问题则是需要了解LHS查询的机制,后面有机会我们再展开来分享,基于LHS查询的原理分析,当我们传递o2with时,with所声明的作用域是o2, 从这个作用域开始对 a 进行 LHS查询,在 o2 的作用域、foo(…) 的作用域和全局作用域中都没有找到标识符 a,因此在非严格模式下,会自动在全局作用域创建一个全局变量,在严格模式下,会抛出ReferenceError异常。
性能下降with 会在运行时修改或创建新的作用域,以此来欺骗其他在开发时定义的词法作用域。with的使用可以令代码更具有扩展性,虽然有数据泄漏的可能,但只要稍加注意就可以避免,除此之后,灵活运用难道不可以创造出很好地功能吗?事实上真的不能,不妨我们考察一下性能特点
function test4() { console.time("test4"); var obj = { a: [1, 2, 3] }; for(var i = 0; i < 100000; i++) { var v = obj.a[0]; } console.timeEnd("test4"); } test4(); function testWith() { console.time("testWith"); var obj = { a: [1, 2, 3] }; with(obj) { for(var i = 0; i < 100000; i++) { var v = a[0]; } } console.timeEnd("testWith"); }testWith();

#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

在处理相同逻辑的代码中,没用with的运行时间仅为 1.94 ms。而用with的运用时间长达 44.13ms。
这是为什么呢?
原因是JavaScript引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果引擎在代码中发现了with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法知道传递给with用来创建新词法作用域的对象的内容到底是什么。此时引擎的所有的优化努力大概率都是无意义的。因此引擎会采取最简单的做法就是完全不做任何优化。这种情况下,设想我们代码大量使用with或者eval(),那么运行起来一定会变得非常慢。无论引擎多聪明,努力将这些悲观情况的副作用限制在最小范围内,也无法避免代码会运行得更慢的事实。┑( ̄Д  ̄)┍
let
在局部作用域中,关键字let、const倒是很常见了,先说说说let,其是ES6新增的定义变量的方法,其定义的变量仅存在于最近的{}之内。
var test5 = true; if (test5) { let bar = test5 * 2; console.log( bar ); } console.log( bar ); // ReferenceError

#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

const
与let一样,唯一不同的是const定义的变量值不能修改
var test6 = true; if (test6) { var a = 2; const b = 3; a = 3; b = 4; } console.log( a ); console.log( b );

对于a来说是全局变量,而对于b的作用范围仅仅是存在与if的块内,此外从尝试对b进行修改的时候也会出错,提示不能对其进行修改
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

作用域链在局部作用中,引用一个变量后,系统会自动在当前作用域中寻找var的声明语句,如果找到则直接使用,否则继续向上一级作用域中去寻找var的声明语句,如未找到,则继续向上级作用域中寻找…直到全局作用域中如还未找到var的声明语句则自动在全局作用域中声明该变量。我们把这种链式的查询关系就称之为" 作用域链" 。这个寻找的过程也是可以在局部作用域中可以引用全局变量的答案
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

代码中的testInner2函数中没有对变量a进行赋值操作,因此由内到外一层层寻找,发现在testInner中有var a的赋值操作,由此返回a的赋值,有兴趣的读者不妨把testInner里面的赋值操作去掉,可以发现函数运行返回a的赋值是yerik
其实作用域链本质是一个对象列表,其保证了变量对象可以有序的访问。其开始的地方是当前代码执行环境的变量对象,常被称之为“活跃对象”(AO),变量的查找会从第一个链的对象开始,如果对象中包含变量属性,那么就停止查找,如果没有就会继续向上级作用域查找,直到找到全局对象中,如果找不到就会报ReferenceError
闭包
简单的说就是一个函数内嵌套另一个函数,这就会形成一个闭包。请牢记这句话:“无论函数是在哪里调用,也无论函数是如何调用的,其确定的词法作用域永远都是在函数被声明的时候确定下来的”
function test7() { var a = 2; function test8() { console.log( a ); // 2 } test8(); } test7();

我们看到上面的函数test7里嵌套了test8,这样test8就形成了一个闭包。在test8内可以访问到任何属于test7的作用域内的变量。
function test7() { var a = 2; function test8() { console.log( a ); // 2 } return test8; } var test9 = test7(); test9(); // 2

在第8行,我们执行完test7()后按理说垃圾回收器会释放test7的词法作用域里的变量,然而没有,当我们运行test9()的时候依然访问到了test7中a的值。这是因为,虽然test7()执行完了,但是其返回了test8并赋给了test9test8依然保持着对test7形成的作用域的引用。这就是依然可以访问到test7中a的值的原因。再想想,“无论函数是在哪里调用,也无论函数是如何调用的,其确定的词法作用域永远都是在函数被声明的时候确定下来的”。
我们再来看另一个例子
function createClosure(){ var name = "yerik"; return { setStr:function(){ name = "naug"; }, getStr:function(){ return name + ":hello"; } } } var builder = new createClosure(); builder.setStr(); console.log(builder.getStr());

#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

上面在函数中返回了两个闭包,这两个闭包都维持着对外部作用域的引用,因此不管在哪调用都是能够访问外部函数中的变量。在一个函数内部定义的函数,闭包中会将外部函数的自由对象添加到自己的作用域中,所以可以通过内部函数访问外部函数的属性,这就是js模拟私有变量的一种方式。
注意:由于闭包会拓展附带函数的作用域(内部匿名函数携带外部函数的作用域),因此,闭包会比其他函数多占用些内存空间,过度使用会导致内存占用增加,这个时候如果要对性能进行优化可能会增加一些难度。
闭包对作用域链的影响由于作用域链机制的影响,闭包只能取得内部函数的最后一个值,这引起了一个副作用,如果内部函数在一个循环中,那么变量的值始终为最后一个值。
var data = https://www.songbingjia.com/android/[]; for (var i = 0; i < 3; i++) { data[i] = function () { console.log(i); }; } console.log(data[0]) console.log(data[1]) console.log(data[2])

#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

如果我们想要获取循环过程的中的结果,应该要怎么做呢?
  • 返回匿名函数的赋值或者立即执行函数
  • 使用es6的let
匿名函数的赋值
var data = https://www.songbingjia.com/android/[]; for (var i = 0; i < 3; i++) { data[i] = (function (num) { return function(){ console.log(num); } })(i); } console.log(data[0]) console.log(data[1]) console.log(data[2])

无论上是立即执行函数还是返回一个匿名函数赋值,原理上都是因为变量的按值传递,所以会将变量i的值赋值给实参num,在匿名函数的内部又创建了一个用于访问num的匿名函数,这样每一个函数都有一个num的副本,互不影响。
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

使用let
var data = https://www.songbingjia.com/android/[]; for (let i = 0; i < 3; i++) { data[i] = (function (num) { return function(){ console.log(num); } })(i); } console.log(data[0]) console.log(data[1]) console.log(data[2])

前面我们介绍到let主要是作用域局部变量,由于其的存在,使for中的i存在于局部作用域中,而不是再全局作用域。
#导入MD文档图片#关于JS中的作用域中的沉思

文章图片

这个函数表执行完毕,其中的变量会被销毁,但是因为这个代码块中存在一个闭包,闭包的作用域链中引用着局部作用域,所以在闭包被调用之前,这个块级作用域内部的变量不会被销毁。
这个循环本质上就是这样
var data = https://www.songbingjia.com/android/[]; // 创建一个数组data; { // 进入第一次循环 let i = 0; // 注意:因为使用let使得for循环为局部作用域 // 此次 let i = 0 在这个局部作用域中,而不是在全局环境中 data[0] = function() { console.log(i); }; } { // 进入第二次循环 let i = 1; // 因为 let i = 1 和上面的 let i = 0 // 在不同的作用域中,所以不会相互影响 data[1] = function(){ console.log(i); }; } ...

【#导入MD文档图片#关于JS中的作用域中的沉思】当我们执行data[1]()的时候,相当于是进入了以下的执行环境
{ let i = 1; data[1] = function(){ console.log(i); }; }

在上面这个执行环境中,它会首先寻找该执行环境中是否存在i,没有找到,就沿着作用域链继续向上找,在其所在的块级作用域执行环境中,找到i=1,于是输出1。
参考资料
  1. https://blog.csdn.net/zwkkkk1/article/details/79725934
  2. https://www.cnblogs.com/nyxd/p/5364350.html
  3. https://www.cnblogs.com/chengxs/p/10451494.html
  4. https://segmentfault.com/a/1190000000618597
  5. https://www.cnblogs.com/zhuzhenwei918/p/6131345.html

    推荐阅读