一、 说明
先浏览问题,判断自己对作用域、变量提升和闭包的掌握情况。然后了解概念和原理,再对照问题检查掌握情况。
二、 问题
- 下面代码输出的结果?(函数作用域)
var name = 'window';
console.log(name);
function outer() {
var name = 'outer';
console.log(name);
function inner() {
console.log(name);
}
inner();
}
outer();
第一个name是全局作用域,打印’window’,第二个name是函数作用域,打印’outer’,第三个name是函数作用域,inner里面未定义name因此上溯到outer函数中,打印结果也是’outer’。
- 下面代码执行的结果?(块级作用域)
function test() {
if (true) {
var a = 'true';
}
else {
var a = 'false';
}
console.log(a);
}
test();
function test1() {
if (true) {
const a = 'true';
}
else {
const a = 'false';
}
console.log(a);
}
test1();
test()执行结果是’true’,因为是函数作用域,在函数内声明的变量都可以访问到。test1()执行结果报错,因为const和let声明的变量定义在块级作用域,只有语句块({}括起来的区域)可以访问到。
- 下面代码执行的结果?(变量提升)
function test() {
console.log(a);
var a = 1;
console.log(a);
}
test();
function test1() {
console.log(a);
const a = 1;
console.log(a);
}
test1();
function test2() {
console.log(inner());
function inner() {
return 'inner';
}
}
test2();
function test3() {
console.log(inner());
var inner = function () {
return 'inner';
}
}
test3();
function test4() {
var a = 1;
function inner() {
console.log(a);
var a = 2;
}
}
写出变量提升后各个函数的等价形式不难得出结果。
test()结果是undefined、1
test1()结果是报错,因为const声明的变量不会提升,a访问不到
test2()结果是’inner’,因为函数声明提升了
test3()结果是报错,因为inner声明提前,但是定义没有提前,所以调用的时候取值是undefined
test4()结果是undefined,因为执行console.log(a)时候先在inner函数作用域内寻找,由于inner内部变量a提升,取值是undefined,因此打印undefined。
- 实现一个创建计数器的方法,支持增加计数和获取计数,对比下列两种实现方式。(闭包的应用场景)
/*方法1*/
var count = 0;
function createCounter() {
function increase() {
count++;
}
function getCount() {
return count;
}
return {
increase: increase,
getCount: getCount
};
}
var counter = createCounter();
counter.increase();
console.log(counter.getCount());
console.log(count);
/*方法2*/
function createCounter() {
var count = 0;
function increase() {
count++;
}
function getCount() {
return count;
}
return {
increase: increase,
getCount: getCount
};
}
var counter = createCounter();
counter.increase();
console.log(counter.getCount());
console.log(count);
答案见闭包的概念
- 下面各个代码段执行结果?(闭包练习)
// 代码段1
function test() {
var arr = [];
for(var i = 0; i <= 5; i++) {
arr[i] = function () {
console.log(i);
};
}
return arr;
}
var funcList = test();
funcList.forEach(function (func) {
func();
});
// 代码段2
function test1() {
var arr = [];
for(let i = 0; i <= 5; i++) {
arr[i] = function () {
console.log(i);
};
}
return arr;
}
var funcList = test1();
funcList.forEach(function (func) {
func();
});
// 代码段3
function test2() {
var arr = [];
for(var i = 0; i <= 5; i++) {
arr[i] = (function (i) {
return function () {
console.log(i);
}
})(i);
}
return arr;
}
var funcList = test2();
funcList.forEach(function (func) {
func();
});
test()打印6 6 6 6 6 6,因为test内部返回的函数访问的i是函数作用域,在test函数内部,随着for的执行i一直自增至6,所以调用arr的函数时候,这些func函数访问的i取值都是6
test1()结果是0 1 2 3 4 5。原因是let声明的i具有块级作用域,每次循环会生成一个块级作用域,在这个块里面i是随自增而改变的。
test2()结果也是0 1 2 3 4 5,与test不同,test2对arr赋值时候是用了一个函数嵌套另一个函数并返回,这样就形成了一个闭包,内部函数访问的i是闭包内的变量,即匿名函数的参数i,循环6次,就生成了6个闭包,这6个闭包的参数分别是0 1 2 3 4 5,因此arr的函数执行打印的是0 1 2 3 4 5
三、 概念和原理
1. 作用域
说明
作用域是可访问变量的**集合**或者说**范围**(例如全局的范围、函数的范围、语句块的范围),在作用域内,变量可访问,在作用域外变量不可访问。例如
function test() {
var name = 'test';
console.log('inner', name);
}
test();
console.log('outer', name);
test函数内部可以访问到变量name,而外部则访问不到。
作用域也可以理解为引擎查找变量的规则,js引擎执行代码,访问变量时候,引擎会按照规则查找该变量,如果能找到则执行相应的操作,找不到则报错。
从确定变量访问范围的阶段的角度,可以分为2类,词法作用域和动态作用域,js是词法作用域。
从变量查找的范围的角度,分为3类,全局作用域,函数作用域和块级作用域。
下面介绍不同的作用域类型。
词法作用域和动态作用域
词法作用域是在词法分析阶段就确定的作用域,变量的访问范围仅由声明时候的区域决定。动态作用域则是在调用时候决定的,它是基于调用栈的。
var a = 2;
function foo() {
console.log( a );
}
function bar() {
var a = 3;
foo();
}
bar();
如果处于词法作用域,也就是现在的javascript环境。变量a首先在foo()函数中查找,没有找到。于是顺着作用域链到全局作用域中查找,找到并赋值为2。所以控制台输出2。
如果处于动态作用域,同样地,变量a首先在foo()中查找,没有找到。这里会顺着调用栈在调用foo()函数的地方,也就是bar()函数中查找,找到并赋值为3。所以控制台输出3。
作用域查找从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止,因此如果内部和外部具有同名的标识符,内部的会被首先查找到,从而“遮蔽”外面的,这叫做“遮蔽效应”。
普通的函数中的this指向有动态作用域的特性,和调用时的对象有关。而箭头函数则使用词法作用域规则。箭头函数的this,就在定义箭头函数的范围内寻找,具体地说,就是外层最近的一个非箭头函数内,或者语句块内,或者全局。看下面的示例:
var name = 'win';
const obj = {
name: 'obj',
a: () => {
console.log(this.name);
}
};
obj.a();
我看可以看到,obj.a声明时候,外层就是全局作用域,因此this指向window。
全局作用域、函数作用域和块级作用域
js有三种作用域:全局作用域、函数作用域和块级作用域(es6)。
全局作用域
直接编写在 script 标签之中的JS代码,或者是一个单独的 JS 文件中的,都是全局作用域。全局作用域在页面打开时创建,页面关闭时销毁。在全局作用域中有一个全局对象 window(代表的是一个浏览器的窗口,由浏览器创建),可以直接使用。
函数作用域
JavaScript的函数作用域是指在函数内部声明的变量,在函数内部和函数内部声明的函数中都可以访问到。访问变量时候先在函数内部找,找不到则在外层函数中找,直到最外层的全局作用域,形成“作用域链”。
变量在函数内部可访问的含义是,在函数内部的语句中或者函数内部声明的函数中都可以访问,比如
function outer() {
var name = 'outer';
console.log(name); // outer
function inner() {
console.log(name); // outer
}
inner();
}
outer();
函数outer内部定义了变量name,在outer内部可以访问,在outer内部定义的inner也可以访问到。
在访问变量时候,先在当前函数作用域内寻找是否有该变量,如果有则使用之,如果没有则向上寻找上层函数的作用域,一直到全局作用域,如果都没有,则报错。
function outer() {
var name = 'outer';
console.log(name); // outer
function inner() {
var name = 'inner';
console.log(name); // inner
}
inner();
}
outer();
块级作用域
(关于块级作用域详细内容,请参考let和const ——《ECMAScript 6 入门》)
变量只在语句块内可访问。通过const和let关键字创建的变量都是在声明的语句块内才可访问。
function test() {
if (true) {
const variable = 'test';
console.log(variable); // test
}
console.log(variable); // Error: variable is not defined
}
test();
块级作用域有几个特性:不存在变量提升、暂时性死区、不允许重复声明
不存在变量提升:
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;
// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;
暂时性死区:
只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。
var tmp = 123;
if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}
不允许重复声明:
// 报错
function func() {
let a = 10;
var a = 1;
}
// 报错
function func() {
let a = 10;
let a = 1;
}
2. 变量提升
概念
JavaScript在执行之前会先进行预编译,主要做两个工作:
- 将全局作用域或者函数作用域内所有函数声明提前。
- 将全局作用域或者函数作用域内所有var声明的变量提前声明,并赋值为undefined。
这就是变量提升。
看下面例子
function test() {
var name = 'test';
}
// 等价于
function test() {
var name;
name = 'test';
}
function test1() {
console.log(name);
var name = 'test';
}
// 等价于
function test1() {
var name;
console.log(name);
name = 'test';
}
function test2() {
exec();
var exec = function () {
console.log('exec');
}
}
// 等价于
function test2() {
var exec;
exec();
exec = function () {
console.log('exec');
}
}
另外,多个变量声明,后面会覆盖前面的
var a = 1;
var a = 2;
console.log(a); // 2
// 等价于
var a = undefined;
a = 1;
a = 2;
函数的声明也会提升,提升到最前面
function test() {
exec();
function exec() {
console.log('exec');
}
}
// 等价于
function test() {
function exec() {
console.log('exec');
}
exec();
}
注意:
- 函数声明可以提升,但是函数表达式不提升,具名的函数表达式的标识符也不会提升。
- 同名的函数声明,后面的覆盖前面的。
- 函数声明的提升,不受逻辑判断的控制。
// 函数表达式和具名函数表达式标识符都不会提升
test(); // TypeError test is not a function
log(); // TypeError log is not a function
var test = function log() {console.log('test')};
// 同名函数声明,后面的覆盖前面的
function test() {
console.log(1);
}
function test() {
console.log(2);
}
test(); // 2
// 函数声明的提升,不受逻辑判断的控制
// 注意这是在ES5环境中的规则,在ES6中会报错,原因后面说明
function test() {
log();
if (false) {
function log() {
console.log('test');
}
}
}
test(); // 'test'
在块级作用域内声明函数会是什么效果呢?这在ES5和ES6环境中是不同的,详细的说明可以参考块级作用域与函数声明
规则描述如下
ES5环境中,语句块中的函数声明将被提升到函数作用域前面
function f() { console.log('I am outside!'); }
(function () {
if (false) {
// 重复声明一次函数f
function f() { console.log('I am inside!'); }
}
f(); // 'I am inside!'
}());
// 等价于
function f() { console.log('I am outside!'); }
(function () {
function f() { console.log('I am inside!'); }
if (false) {
}
f();
}());
ES6环境中,如果在语句块中声明函数,按照正常的规范,函数声明应该被封闭在语句块里面,因此应该打印”I am outside!”,但是为了兼容老代码,因此语法标准允许其他的实现:
- 允许在块级作用域内声明函数。
- 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
- 同时,函数声明还会提升到所在的块级作用域的头部。
所以上面代码在ES6中表现是
function f() { console.log('I am outside!'); }
(function () {
if (false) {
// 重复声明一次函数f
function f() { console.log('I am inside!'); }
}
f(); // Uncaught TypeError: f is not a function
}());
// 等价于
function f() { console.log('I am outside!'); }
(function () {
var f = undefined;
if (false) {
function f() { console.log('I am inside!'); }
}
f();
}());
注意:在前面已经提到过,const和let定义的变量不会提升。
循环打印数字
下面看一个经典的例子,循环打印数字
for(var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
},100);
}
上面代码会打印3个3。
由于变量提升的特性,变量i会被提升到函数作用域或者全局作用域首部,因此setTimeout中的回调方法访问到的是外层的变量i,当循环结束时候,i变为3,因此每次打印的都是3。
如果想要打印0, 1, 2。要利用闭包的特性。
for(var i = 0; i < 3; i++) {
setTimeout((function(num) {
return function() {
console.log(num);
}
})(i), 100);
}
上面代码每次循环生成一个闭包,每个闭包都保存了一个循环变量i的值,这样就能够打印正确的数值了。
也可以使用let生成块级作用域,来实现打印0, 1, 2的效果。
for(let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
},100);
}
为什么使用let可以实现打印连续数字的功能呢?
因为使用let声明循环变量,js引擎执行循环语句时候会在每个循环体(每个循环体是一个独立的语句块)内重新重新声明变量i,并且js引擎会记录上一次循环的值,所以每个循环体中的i相互不影响,setTimeout的回调中访问到的是块级作用域自身中的i。
闭包
函数和函数内部能访问到的变量的总和,就是一个闭包。
如何生成闭包?函数内嵌套函数,并且函数执行完后,内部函数会被引用,这样内部函数可以访问外部函数中定义的变量,于是就生成了一个闭包。
下面是一个闭包的例子:
function outer() {
var a = 1;
function inner() {
console.log(a);
}
return inner;
}
var b = outer();
注意,如果没有将outer()执行结果赋值给b,那么内部函数不会被引用,因此没有形成闭包。如果把inner挂在window下面也形成了对inner的引用,也可以生成闭包:
function outer() {
var a = 1;
function inner() {
console.log(a);
}
window.inner = inner;
}
outer();
闭包的作用是什么?可以让内部的函数访问到外部函数的变量,避免变量在全局作用域中存在被修改的风险。
比如我们要实现一个计数器,支持增加计数和获取计数的功能。计数器使用方法如下
var counter = createCounter();
counter.increase(); // +1
console.log(counter.getCount()); /
我们首先可以想到,全局作用域的变量在函数内部可以访问到,所以可以这样实现
var count = 0;
function createCounter() {
function increase() {
count++;
}
function getCount() {
return count;
}
return {
increase: increase,
getCount: getCount
};
}
var counter = createCounter();
counter.increase();
console.log(counter.getCount());
console.log(count);
但是变量count放在全局,很容易被其他模块修改从而导致不可预知的问题。因此我们希望count变量不会被其他模块访问到,于是需要把count放在函数作用域中:
function createCounter() {
var count = 0;
function increase() {
count++;
}
function getCount() {
return count;
}
return {
increase: increase,
getCount: getCount
};
}
var counter = createCounter();
counter.increase();
console.log(counter.getCount());
console.log(count);
这样函数createCounter中的increate和getCount两个函数可以访问到createCounter内部定义的count,这样就形成了闭包。而count只能被createCounter内部定义的函数访问到,因此不会有被随意修改的风险。
通常情况下函数中定义的变量在函数执行完成后会被销毁,例如:
function createCounter() {
var count = 0;
function increase() {
count++;
}
function getCount() {
return count;
}
return {
increase: increase,
getCount: getCount
};
}
createCounter();
通常执行完createCounter()方法之后,内部的所有变量都被从内存中销毁(因为没有其他地方使用了)。但是如果生成了闭包(即有对内部嵌套函数的引用),则内部变量不会被销毁(因为还有其他地方在用,嵌套的内部函数还在使用),还是以上面createCounter闭包为例
function createCounter() {
var count = 0;
function increase() {
count++;
}
function getCount() {
return count;
}
return {
increase: increase,
getCount: getCount
};
}
var counter = createCounter();
counter.increase();
console.log(counter.getCount());
console.log(count);
由于createCounter返回的方法们被引用,因此形成闭包,所以内部变量count不会被销毁,而是会继续被increase和getCount使用。
生成闭包之后,如果我们不再需要使用counter可以执行counter = null;这样失去了对内部嵌套函数的引用,浏览器就会将方法内资源都销毁调了。因此当我们使用完闭包之后如果后续不再需要使用,最好通过取消引用来释放闭包的资源。
总结:
- 什么是闭包?函数和函数内部能访问到的变量的总和,就是一个闭包。
- 如何生成闭包? 函数嵌套 + 内部函数被引用。
- 闭包作用?隐藏变量,避免放在全局有被篡改的风险。
- 使用闭包的注意事项?不用的时候解除引用,避免不必要的内存占用。
- 闭包的缺点:使用时候不注意的话,容易产生内存泄漏。