目录
1.作用域
每一种编程语言,它最基本的功能都是存储变量的值,并对这个值进行使用和修改。有了变量之后,应该把它放到哪里,我们如何使用,这时候就需要一套规则,这套规则就是作用域,即作用域就是变量起作用的范围和区域。 作用域的目的是隔离变量,保证不同作用域下同名变量不会冲突。
在JS中分为作用域分三种:
- 全局作用域
- 函数作用域
- 块作用域
1.1 全局作用域
以下三种情形拥有全局作用域。第一种:最外层变量以及函数。
var num = 10; //最外层变量
function f1(){ //最外层函数
var num2 = 20; //函数内变量
function f2(){ //内层函数
console.log(num2);
}
console.log(num)
}
console.log(num); // global
f1(); // global
console.log(num2); // not defined
console.log(f2); // not defined
从上面例子中可以看出num变量和f1函数在任何地方都可以访问到,反之不在全局作用域下的变量只能在当前作用域下使用。
第二种:不使用var声明的变量。
function f1(){
num = 10;
var num2 = 20
}
f1();
console.log(num); // global
console.log(num2); // not defined
从这个例子我们看出,不使用var声明的变量会进行变量提升(提到全局作用域下),所以num变量在任何作用域下可以访问到。 有时候,我们也将不使用var声明的变量我们称作隐式全局变量。
第三种:window对象所有属性和函数拥有全局作用域。
window对象代表的是整个浏览器窗口,我们尝试着在浏览器打印window对象。
window对象具有双重角色,一是上图中JS访问浏览器的一个接口。window对象下的所有属性和函数都拥有全局作用域,例如我们经常用到过的:window.innerHeight
、window.alert()
、 window.setTimeout()
等等。
二是ECMAScript中规定的Global对象。 在全局作用域中使用var所创建的变量都会作为window对象的属性保存;全局作用域中所有的函数都会作为window对象的方法保存(如下图)。
var a =10
function a1(){
console.log('hello')
}
注:全局作用域在网页打开时创建,在网页关闭时销毁。
全局作用域有个弊端,就是如果我们在全局作用域中写了很多变量,如果命名冲突,后面的变量会覆盖前面同名变量,从而污染全局命名空间。
1.2 函数作用域
在函数内部定义的变量,拥有函数作用域。
var num = '小明';//name全局变量
function sayHi(){
// str是函数中的局部变量
var str ='hi word'
console.log(str)
}
function showName(myName){
// 函数的形参也是局部变量
console.log(myName);
}
sayHi(); // 输出'hi word'
showName(num); // 输出'小明'
console.log(str); // 抛出错误:str在全局作用域未定义
console.log(myName); // 抛出错误:myName 在全局作用域未定义
在这个例子中,str和myName都是函数内部定义的变量,他们的作用域也就仅限于函数内部,全局作用域中不会访问到。
注:函数调用时创建,调用结束作用域随之销毁。 每调用一次产生一个新的作用域,之间相互独立。
1.3 块级作用域
使用let或const声明的变量,如果被一个大括号括住,那么这个大括号括住的变量就形成了一个块级作用域。
{
let num = 10;
console.log(num);
}
console.log(num); // 报错
在这个例子中,我们可以看出:块级作用中定义的变量只在当前块中生效,这和函数作用域类似,他们都是只在自己的地盘内生效。
2.作用域链
以上,我们对作用域有了基本认识,我们不难发现,只要是代码,就至少拥有一个作用域。写在函数内部的函数作用域,如果函数中还有函数,那么这个作用域中就又可以诞生一个作用域。比如这样:
var num = 10 ;
function fn(){ // 外部函数
var num = 20;
function fun(){ // 内部函数
console.log(num)
}
fun()
}
fn()
在这个例子中,有三个作用域,全局作用域、fn的函数作用域、fun的函数作用域。
当我们试图在 fun 这个函数里访问变量 num 的时候,此时函数作用域内没有num变量,当前作用域找不到。要想找到num,根据作用域链的查找规则,我们需要去上层作用域(fn函数作用域),在这里我们找到了 num ,就可以拿来使用了。
我们把作用域层层嵌套,形成的关系叫做作用域链,作用域链也就是查找变量的过程。 查找变量的过程:
:当前作用域 --》上一级作用域 --》上一级作用域 .... --》直到找到全局作用域 --》还没有,报错。
3.作用域实现机制
要想理解作用域实现的机制,我们需要结合JS编译原理来看,我们先来看一个简单的声明语句:
var name = '小明'
在这段代码中,有两个阶段:
编译阶段:编译器在当前作用域中声明一个变量name
执行阶段:JS引擎在当前作用域中查找该变量,找到name变量并为其赋值
证明以上说法:
console.log(name); // undefined
var name = '小明'
我们直接输出name变量,此时并没有报错,而是输出undefined,说明输出的时候改变量已经存在了,只是没有赋值而已。
其实,上面这段代码包含两种变量查找方式:如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询。LHS(Left-hand Side)
、RHS(Right-hand Side)
是JS引擎执行代码的时候,查询变量的两种方式。这里的“Left”和“Right”,是相对于赋值操作来说,当变量出现在赋值操作左侧时,执行LHS操作。
4.作用域模型
4.1 词法作用域和动态作用域
我们说过,作用域本质是一套规则,而这个规则的底层遵循的就是词法作用域模型,简单来说,“词法作用域”就是作用域的成因。
从语言的层面来说,作用域模型分两种:
词法作用域:也称静态作用域,是最为普遍的一种作用域模型
动态作用域:相对“冷门”,bash脚本、Perl等语言采纳的是动态作用域
要想理解这种模型区别,我们看以下例子:
var num = 10;
function f1(){
console.log(num)
}
function f2(){
var num = 20;
f1()
}
f2();
因为JS基于的是词法作用域,不难得出它的运行结果是10。这段代码经历了这样的执行过程:
f2函数调用,f1函数调用
在f1函数作用域内查找是否有局部变量num
发现没找到,于是根据书写位置,向上一层作用域(全局作用域)查找,发现num,打印num=10
这里我们作用域的划分遵循的就是词法作用域,即在词法分析时生成的作用域,词法分析阶段,也可以理解为代码书写阶段,当你把函数(块级作用域同理)书写到某个位置,不用执行,它的作用域就已经确定了。
与之相对应的动态作用b域,我们也分析这段代码的的执行过程:
f2函数调用,f1函数调用
在f1函数作用域内查找是否有局部变量num
发现没找到,于是沿着调用栈,在调用f1函数地方继续找,也就是在f2函数中查找,刚好,f2函数中有num,此时就会打印20
我们总结一下。词法作用域和动态作用域最根本区别在于生成作用域的时机:
词法作用域:在代码书写时完成划分,作用域沿着它定义的位置往外延伸
动态作用域:在代码运行时完成划分,作用域链沿着他的调用栈往外延伸
4.2 修改词法作用域
修改词法作用域也又叫做“欺骗词法作用域”,词法作用域意味着作用域是由书写代码时函数声明的位置来决定的,那么为什么在运行过程中将划分好的词法作用域改掉呢?怎样才能在运行时"修改"(欺骗)词法作用域呢?
在JS中有两个函数来实现这个目的,分别是eval
和with
4.21 eval函数
我们知道,eval函数入参是一个字符串。当eval拿到一个字符串入参后,它会把这段字符串的内容当做js代码(不管它是不是一段代码),插入到自己被调用的那个位置.
我们先看以下代码:
function f1(str){
eval(str);
console.log(num);
}
var num = 10;
var str ="var num = 20"
f1(str)
上面代码,被eval“改造后”,就会变成:
function f1(str){
var num =20
console.log(num);
}
var num = 10;
f1(str)
这时当再我们打印num时,函数作用域内的num已经被eval传入的这行代码给修改掉了,所以打印结果就由10变成了20。 eval它成功修改了词法作用域规则,在书写阶段就划分好的作用域。
4.22 with函数
with
函数是引用对象的一种简写方式。当我们去引用一个对象中的多个属性时,可不用重复引用对象本身。
var obj={
a:1
}
// 打印属性
console.log(obj.a);
// 使用with简写
with(obj){
console.log(a);
}
接下来我们来看with是如何改变词法作用域的,请看看例子
function fn(obj){
with(obj){
a = 2
}
}
var f1 = {a:3}
var f2 = {b:3}
fn(f1)
console.log(f1.a) // 3
fn(f2)
console.log(f2.a) // 输出undefined
console.log(a) // 2
当fn函数第一次调用时,with会为f1这个对象凭空创造出一个新的作用域,这使得我们在这个作用域内可以直接访问a对象属性。
当第二次调用fn函数时,with也会为f2这个对象创造出一个新的作用域,使得我们可以在这个作用域内直接访问b这个对象属性,此时a属性已不存在。
当我们直接打印a时,会打印全局变量2。这是为什么呢?事实上这是因为我们使用with,在非严格模式下,使用with声明的a因为没有var,所以是一个隐式全局变量,隐式全局变量在任何位置都能访问到.
我们总结下with改变作用域的方式:
with 会原地创建一个全新的作用域,这个作用域内的变量集合,其实就是传入 with 的目标对象的属性集合。
因为 “创建” 这个动作,是在 with 代码实际已经被执行后发生的,因此with实现了对书写阶段就划分好的作用域进行修改。
事实上,在我们实际开发中很少用到这两个函数,因为存在性能问题,它会导致我们的代码变得很慢,而且还会像上面例子一样“横空出世全局变量”。以上,eval和with不建议实际开发使用.
5. 闭包
5.1 闭包的定义
书中给出的定义时:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
通俗说:闭包就是从函数外部访问函数内部的变量,函数内部的变量可以持续存在的一种实现。
还有一种理解就是:闭包就是函数内部定义的函数,被返回了出去并在外部调用。
在了解了词法作用域和变量的查询方式之后,我们看看一个简单的闭包的实现逻辑:
function foo() {
let num = 1 // 里面的变量
function add() {
num += 1
}
function log() {
console.log(num)
}
return { add, log } // 我要到外面去了
}
const { add, log } = foo()
log() // 1 我从里面来,我在外面被调用,还是可以获得里面的变量
add()
log() // 2
- 首先定义一个 f 函数,函数内部维护一个变量 num,然后定义两个函数 add 和 log
- add 函数每次调用会增加 num 的值
- log 函数每次调用会打印 num 的值
- 然后我们将两个函数通过 return 方法返回
- 紧接着先调用外部的 log 方法打印 f 方法维护的 num,此时为 1
- 然后调用外部的 add 方法增加 num 的值
- 最后再次调用 log 方法打印 num,此时则为 2
在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然的会考虑对其进行回收。
而闭包的神奇之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收,拜add()和log()所声明的位置所赐,它们拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供add()和log()在之后任何时间进行引用。
add()和log()依然持有对该作用域的引用,而这个引用就叫做闭包。
让我们再看一个例子:
function wait(message){
setTimeout(function timer() {
console.log(message);
},1000);
}
wait("Hello,Amiy");
将一个内部函数(名为timer)传递给setTimeout(...)
,time具有涵盖wait(...)
作用域的闭包,因此还保有对变量message的引用。wait(...)
执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait(...)
作用域的闭包。
在引擎内部,内置的工具函数setTimeout(...)
持有对一个参数的引用,这个参数也许叫做fn或者func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的timer函数,而词法作用域在这个过程中保持完整。
这就是闭包。
本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当做第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web workers或者任何其他的异步(或者同步)任务中,只要使用了
回调函数
,实际上就是在使用闭包!
5.2 经典的 for 循环问题
arr = []
for (var i = 0; i < 10; i ++) {
arr[i] = function() {
console.log(i)
}
}
arr[2]() // 10
首先我们知道 for 循环体内的 i 实际上会被定义在全局作用域中
每次循环我们都将 function 推送到一个 arr 中,for 循环执行完毕,
随后我们执行代码 arr2 此时 arr[2] 对应的函数 function(){ console.log(i) } 会被触发
函数尝试搜索函数局部作用域中的 i 变量,搜索不到则会继续向外层搜索,i 被定义到了外层,因此会直接采用外层的 i,就是这里的全局作用域中的 i,等到这个时候调用这个函数,i 早已变成 10 了
那么有什么方法能够避免出现这种情况吗?
5.2.1 ES6 之前的解决方案:
了解了闭包我们就知道了闭包内的变量可以持续存在,所以修改代码将 arr 中的每一项改为指向一个闭包:
了解了闭包我们就知道了闭包内的变量可以持续存在,所以修改代码将 arr 中的每一项改为指向一个闭包:
arr = []
for (let i = 0; i < 10; i ++) { // 使用 let
arr[i] = function() {
console.log(i)
}
}
在使用 let 之后,我们每次定义 i 都是通过 let i 的方法定义的,这个时候 i 不再是被定义到全局作用域中了,而是被绑定在了 for 循环的块级作用域中
因为是块级作用域所以对应 i 的 arr 每一项都变成了一个闭包,arr 每一项都在不同的块级作用域中因此不会相互影响
5.3 模块
还有其他的代码模式利用闭包的强大威力,下面让我们来研究最强大的一个:模块
。
function foo(){
var something = "cool";
var another = [1,2,3];
function doSomething(){
console.log(something);
}
function doAnother(){
console.log(another.join("!");
}
}
正如在这段代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量something
和another
,以及doSomething()
和doAnother()
两个内部函数,他们的词法作用域(而这就是闭包)也就是foo()的内部作用域。
带var的私有作用域变量提升阶段,都声明为私有变量,和外界没有任何关系。
接下来考虑以下代码:
function CoolMoudle(){
var something ="cool";
var another = [1,2,3];
function doSomething(){
console.log(something);
}
function doAnother(){
console.log(another.join("!"));
}
return {
doSomething:doSomething,
doAnother:doAnother
}
}
var foo = CoolMoudle();
foo.doSomething(); //cool
foo.doAnother(); //1!2!3!
这个模式在JavaScript中被称为模块
我们仔细研究以下这些代码:
首先,CoolMoudle()
只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。
其次,CoolMoudle()
返回一个用对象字面量语法{key:value,....}
来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用,我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API。
这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性方法,比如foo.doSomething()
。
模块模式要具备两个必要条件。
1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例。
2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
一个具有函数属性的对象本身并不是真正的模块,从方便观察的角度看一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
5.4 实现迭代器
function setup(x) {
var i = 0;
return function(){
return x[i++];
};
}
var next = setup(['a', 'b', 'c']);
执行效果:
> next();
"a"
> next();
"b"
> next();
"c"
5.5 闭包的优点
- 可以减少全局变量的定义,避免全局变量的污染
- 能够读取函数内部的变量
- 在内存中维护一个变量,可以用做缓存
5.6 闭包的缺点
1)造成内存泄露
闭包会使函数中的变量一直保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题。
解决方法——使用完变量后,手动将它赋值为null;
将不再使用的变量,
2)闭包可能在父函数外部,改变父函数内部变量的值。
3)造成性能损失
由于闭包涉及跨作用域的访问,所以会导致性能损失。
解决方法——通过把跨作用域变量存储在局部变量中,然后直接访问局部变量,来减轻对执行速度的影响
5.7 闭包不会引起内存泄漏
由于IE9 之前的版本对JScript 对象和COM 对象使用不同的垃圾收集。因此闭包在IE 的这些版本中会导致一些特殊的问题。具体来说,如果闭包的作用域链中保存着一个HTML 元素,那么就意味着该元素将无法被销毁请看例子:
function assignHandler(){
var element = document.getElementById("someElement");
element.onclick = function(){
alert(element.id);
};
}
以上代码创建了一个作为element 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用。由于匿名函数保存了一个对assignHandler()的活动对象的引用,因此就会导致无法减少element 的引用数。只要匿名函数存在,element 的引用数至少也是1,因此它所占用的内存就永远不会被回收,这是IE的问题,所以闭包和内存泄漏没半毛钱关系。
解决办法前言已经提到过,把element.id 的一个副本保存在一个变量中,从而消除闭包中该变量的循环引用同时将element变量设为null
function assignHandler(){
var element = document.getElementById("someElement");
var id = element.id;
element.onclick = function(){
alert(id);
};
element = null;
}
总结:闭包并不会引起内存泄漏,只是由于IE9 之前的版本对JScript对象和COM对象使用不同的垃圾收集,从而导致内存无法进行回收
5.8 闭包的范例
5.8.1返回匿名闭包
function funA(){
var a = 10; // funA的活动对象之中;
return function(){ //匿名函数的活动对象;
alert(a);
}
}
var b = funA();
b(); //10
5.8.2各自独立的闭包
function outerFn(){
var i = 0;
function innerFn(){
i++;
console.log(i);
}
return innerFn;
}
var inner = outerFn(); //每次外部函数执行的时候,都会开辟一块内存空间,外部函数的地址不同,都会重新创建一个新的地址
inner();
inner();
inner();
var inner2 = outerFn();
inner2();
inner2();
inner2(); //1 2 3 1 2 3
5.8.3闭包的链式调用
var add = function (x) {
var sum = 1;
var tmp = function (x) {
console.log('执行tmp')
sum = sum + x;
return tmp;
}
tmp.toString = function () {
return sum;
}
return tmp;
}
console.log(add(1)(2)(3).toString())
控制台输出结果:
执行tmp
执行tmp
6
add(1) 时执行的是最外面的匿名函数,从(2) 开始,才执行tmp
所以第一个参数无论是几,最终结果都是6
console.log(add(8)(2)(3).toString()) // 最终结果还是 6
5.8.4留意父函数执行过一次!
function love1(){
var num = 223;
var me1 = function() {
console.log(num);
}
num++;
return me1;
}
var loveme1 = love1();
loveme1(); //输出224
参考资料
1.你不知道的JavaScript 上
2.作用域:【JavaScript】深入理解JS中的词法作用域与作用域链 - 掘金
3.js详解闭包:js 【详解】闭包_朝阳39的博客-CSDN博客_js闭包
4.闭包会造成内存泄漏吗:闭包会造成内存泄漏吗? - yancyenough - 博客园