深入学习作用域和闭包—全面(JS系列之二)

作用域
在学习作用域之前,先了解两个重要的概念:编译器、引擎
编译器:负责词法分析及代码生成等编译过程
引擎:负责整个 JavaScript 程序的编译和执行
什么是作用域 通俗的来讲就是变量起作用的范围。比较规范的解释(引用《你不知道的 JavaScript 》上卷),负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行代码对这些标识符的访问权限。
ES6之前,JavaScript只有全局作用域和函数作用域,与其他类型语言不同的是它没有块级作用域。

if(true){ var a = 1; //全局作用域 } console.log(a); // 1function foo(){ var b = 1; //函数作用域 console.log(a); //1 } console.log(b); // ReferenceError

在上面的代码中,a 属于全局作用域,if 后的花括号并没有形成块级作用域,而 b 属于 foo 函数的作用域,在JavaScript中函数外部作用域访问不到函数内部作用域,所以在全局作用域中访问foo函数作用域变量b会报错。
es6之后,JavaScript 拥有了块级作用域
if (true) { let a = 1 } console.log(a)// ReferenceError

ifforwhiletry...catch 等在大括号中使用letconst 声明的变量会形成块级作用域,如果在外部访问会报错。
作用域如何工作 变量提升
刚开始接触 JavaScript 的同学可能会对变量先声明后使用的现象十分不解,要理解它我们得了解JavaScript编译的两个原则:①编译时声明 ②运行时赋值
var a = 2; //相当于↓ var a; //编译时 a = 2; //运行时

上面这段代码 var a = 2只做一件事,对a进行赋值 ,不过浏览器引擎不这么看, 它会被分为 var aa = 2 两步进行,一个在编译器编译时声明变量,另一个在引擎运行时赋值。
编译器首先将上面这段程序分解为词法单元,然后将词法单元解析成一个树结构(AST抽象语法树)。在开始代码生成时,编译器遇到var a,编译器询问作用域是否已经声明了这个变量;如果是,编译器忽略该声明,否则在当前作用域集合声明一个新的变量,命名为a
引擎执行a = 2首先询问作用域,在当前的作用域集合中是否存在一个叫做a的变量。如果是,引擎就会使用这个变量,否则引擎会继续延着作用域链查找该变量。如果引擎最终找到了a变量,就会将 2 赋值给它,否则引擎会抛出一个异常Uncaught ReferenceError: a is not defined
函数提升
a()// aaa => 函数a被提升,所以在声明前可以调用函数var a function a () { console.log('aaa') }console.log(a) // ? a() {} 函数声明优先级比变量声明高

var声明的变量会提升,function 声明的函数也会被提升,并且函数声明优先级比变量声明优先级高,所以上面这段代码打印 a 是个函数,因为var a声明的变量被function声明的函数覆盖了。
词法作用域
词法作用域就是定义在词法阶段的作用域,也就是说作用域是在书写代码时函数声明的位置来决定,与执行过程无关,JavaScript 采用的是词法作用域。
相对词法作用域另外一种叫做动态作用域,作用域是在执行阶段确定的,比如Bash脚本、Perl语言等。
看下面这段代码示例:
var a = 1function foo () { console.log(a) } function bar () { var a = 'local' foo () }bar() // 词法作用域是:1 ;动态作用域是:‘local’

我们使用词法作用域和动态作用域分析一下上面这段代码执行过程,bar 函数内部调用 foo 函数
如果是词法作用域,调用 foo 查找变量a会从foo函数代码定义的位置向外一层也就是全局作用域访问,此时var a = 1,结果是 1;
如果是动态作用域,调用foo查找变量a会从当前调用函数位置开始向往搜索,发现外部声明var a = 'local',所以 a的值是local;
而在JS引擎中上面这段代码运行结果是 1,所以JavaScript采用的是词法作用域
不过,thisJavaScript 中比较特殊,JavaScript 程序在执行的时候才会对this进行赋值,在未执行时不能知道this的作用域,所以比较准确的说在JavaScriptthis采用的是动态作用域。
修改词法作用域: eval 和 with eval 欺骗词法作用域
eval 函数接收一个或多个声明的代码,会修改其所处的词法作用域。
var a = 2 function foo (str, b) { eval(str) // 欺骗 console.log(a, b) } foo('var a = 3', 1) // 3, 1

执行 eval 函数,传入的字符串会解析成脚本执行,声明一个变量 a 修改了 foo 函数的词法作用域,遮蔽了外部(全局)作用域中的同名变量访问,欺骗了 foo 词法作用域。另外,使用 eval 函数还容易受到xss攻击。
with 欺骗词法作用域
with 将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,如果对象中没有该标识号,会在全局创建一个新的词法作用域
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 的缺陷
function foo(obj) { with(obj) { a = 2 } } var obj1 = { a: 3 } var obj2 = { b: 3 } foo(obj1) console.log(obj1.a) // 2foo(obj2) console.log(obj2.a) // undefined console.log(a) // 2 —— a被泄露到了全局作用域上

with 会修改引用中属性的值,如果引用中没有该属性,在非严格模式下会在全局作用域中创建一个全新的词法作用域,欺骗了全局词法作用域
除此之外,使用 evalwith 还会带来性能问题,因为JS 引擎无法在编译时对它们作用域进行查询优化,这样会导致代码运行效率变慢,所以建议不要使用它们。
作用域链
作用域链形成是由词法作用域和编译时词法环境对外部环境引用的结果,关于词法环境外部环境的引用可以参考这篇文章【深入了解JavaScript执行过程】
)
现在主要说说作用域链的构成过程,开始执行脚本时创建全局作用域,在全局环境调用 foo函数 时,编译foo 函数并创建foo函数作用域,foo 函数中声明 bar函数,在调用 bar函数会创建 bar 函数作用域。JavaScript中,内部函数可以访问外部函数的变量,这样, bar 函数作用域 =》 foo 函数作用域 =》全局作用域 构成了一条作用域链。
var a = 'global' function foo () { var b = 'foo scoped' function bar () { var c = 'bar scoped' console.log(a, b, c) } bar() } }foo() // 'global''foo scoped''bar scoped'

闭包
谈起闭包,它可是JavaScript两个核心技术之一(异步和闭包),在面试以及实际应用当中,我们都离不开它们,甚至可以说它们是衡量js工程师实力的一个重要指标。下面我们就罗列闭包的几个常见问题,从回答问题的角度来理解和定义闭包。
问题如下:
- 什么是闭包- 闭包的原理是什么- 闭包是如何使用的- 闭包的应用场景有哪些

如果你能回答上面这些问题,说明你对闭包非常熟悉了;如果脑子里比较模糊回答不上来也不用担心,继续往下读,相信你会找到答案的。
什么是闭包 网上有很多种对闭包解释的说法:
1、闭包是由函数以及创建该函数的词法环境组合而成
2、闭包是能够读取其他函数内部变量的函数
读起来比较抽象和拗口,用代码来理解闭包。
function foo() { var a = 2 function bar () { console.log(a) } return bar } var baz = foo()baz() // 2 —— 这就是闭包的效果

深入学习作用域和闭包—全面(JS系列之二)
文章图片
image 函数是一等公民,可以当成数值来使用,它既可以作为函数参数,也可以作为函数返回值。调用foo函数返回bar,理论上来说foo函数执行完之后会被销毁,不过bar函数引用着fooa变量,所以执行完foo,函数体会被销毁,但是a被引用着不能被回收仍然保存在内存当中,所以在外部调用bar函数可以访到foo内部函数的a变量。这时我们给foo起了另外一个名字叫闭包函数。
我们知道根据作用域链函数内部可以访问函数外部的变量,反过来是不行的,但是闭包可以做到,这就是闭包的神奇之处
总结一下,闭包本质上是一个函数,它返回另一个函数,可以使外部函数可以访问其他函数内部的变量。
闭包原理 细心的朋友可能知道答案了,闭包的原理就是词法作用域和作用域链形成的结果。
如何使用闭包 为了能让我们的程序更健壮,我们往往需要将实现细节隐藏起来,只对外提供暴露接口,这也是面向对象三大特性之一封装性
私有变量
function foo () { var num = 0 function bar () { ++num return num } return bar } var add1 = foo () add1() // 1 add1() // 2 add1() // 3 var add2 = foo () add2() // 1 add2() // 2 add2() // 3

每次执行foo都得到相同的值,不会相互污染
function Person() { var age = 20 var sex = 'man' getAge () { return age } setAge(value) { age = value } getSex () { return sex } setSex(value) { sex = value } return { getAge, setAge, getSex, setSex } }var zhangsan = Person() zhangsan.getAge() // 20 zhangsan.getSex() // 男

隐藏实现细节,对外暴露接口。模拟实现了面向对象的思想,代码也显得健壮、易理解、可扩展可维护。
闭包的应用场景 定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是使用闭包
闭包使用注意事项
1、闭包会使得函数中的变量都被保存在内存中,内存消耗很大,处理不当,容易造成内存泄漏
2、如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
总结
写的内容有点多,梳理一下
1、首先讲了什么是作用域,作用域类型分为全局作用域、函数作用域、函数作用域
2、其次作用域工作时,使用varfunctioin声明会出现变量提升和函数提升;JavaScript 是词法作用域,evalwith 会欺骗词法作用域
【深入学习作用域和闭包—全面(JS系列之二)】3、最后讲了作用域链的原理和闭包使用介绍
引用链接
深入javascript——作用域和闭包
JavaScript中的作用域和闭包
从作用域链谈闭包
【第863期】深入学习JavaScript闭包
推荐阅读
  • 深入了解JavaScript执行过程(JS系列之一)
  • 深入学习作用域和闭包—全面(JS系列之三)

    推荐阅读