作用域与闭包
一.什么是作用域?
它定义如何在某些位置存储变量,以及如何 在稍后找到这些变量。我们称这组规则为:作⽤域。
编译器理论
JavaScript是⼀个 编译型语⾔。
在传统的编译型语⾔处理中,在它被执⾏ 之前 通常将会经 历三个步骤,⼤致被称为**“编****译”**:
- **分词/词法分析:**将一连串字符打断成有意义的片段,称为token(记号)。举例来说,考虑这段程序: var a = 2; 。这段程序很可能会被 打断成如下 token: var , a , = , 2 ,和 ; 。空格也许会被保留为⼀个 token,这要看它是否是有意义的。
注意:
在变量中存储值和取出值的能⼒,给程序赋予了 状态。
分词分析:token 是以 无状态的方式被识别。
词法分析:token 是以 有状态的方式被识别。
-
**解析:**将⼀个 token 的流(数组)转换为⼀个嵌套元素的树,它综合地表示了程序的语法结构。
-
代码⽣成: 这个处理将抽象语法树转换为可执⾏的代码。
理解作用域
- 引擎:负责从始至终的编译和执行我们的JavaScript 程序。
- 编译器:处理有所有的解析和代码生成的重活。
- 作用域:收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行中的代码如何访问这些变量强制实施一组严格的规则。
反复
var a = 2;
-
遇到 var a ,编译器 让 作⽤域 去查看对于这个特定的作⽤域集合,变量 a 是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作⽤域 去为这个作⽤域集合声明⼀个称为 a 的新变量。
-
然后 编译器 为 引擎 ⽣成稍后要执⾏的代码,来处理赋值 a = 2 。引擎 运⾏的 代码⾸先让 作⽤域 去查看在当前的作⽤域集合中是否有⼀个称为 a 的变量可以 访问。如果有,引擎 就使⽤这个变量。如果没有,引擎 就查看 其他地⽅。
总结:对于⼀个变量赋值,发⽣了两个不同的动作:第⼀,编译器 声明⼀个变量 (如果先前没有在当前作⽤域中声明过),第⼆,当执⾏时,引擎 在 作⽤域 中查询 这个变量并给它赋值,如果找到的话。
复制的目标(LHS)和赋值的源(RHS)
RHS :简单地查询某个变量的值。
LHS :查询是试着找到变量容器本身,以便 它可以赋值。
注意: LHS 和 RHS 意味着“赋值的左/右⼿边”未必像字⾯上那样意味着“= 赋值操作符的左/右边”。
console.log( a );
//这个指向 a 的引⽤是⼀个 **RHS** 引⽤,因为这⾥没有东⻄被赋值给 a 。
var a = 2;
//这⾥指向 a 的引⽤是⼀个 **LHS** 引⽤,因为我们实际上不关⼼当前的值是什么,我们 只是想找到这个变量,将它作为 = 2 赋值操作的⽬标。
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
//1.调⽤ foo(..) 的最后⼀⾏作为⼀个函数调⽤要求⼀个指向 foo 的 **RHS** 引⽤
//2.隐含的 a = 2 。进 ⾏了⼀个 LHS 查询。
//3.console.log(..) 需要⼀个引⽤来执⾏,a 的值的 RHS 引⽤。
错误
ReferenceError 是关于 作⽤域 解析失败的。
TypeError 暗示着 作⽤域 解析成功,但是试图对这个结果进⾏了⼀个⾮法/不可能的动作。⽐如将⼀个⾮函数的值作为函数运⾏,或者引⽤ null 或者 undefined 值的 属性。
注意:
- 未被满⾜的 RHS 引⽤会导致 ReferenceError 被抛出。
- 未被满⾜的 LHS 引⽤会导致 ⼀个⾃动的,隐含地创建的同名全局变量(如果不是“Strict模式”)。
- 未被满⾜的 LHS 引⽤会导致⼀个 ReferenceError (如果是“Strict模式”)。
二.词法作用域(JavaScript 实际上没有动态作⽤域)
作⽤域”定义为⼀组规则,它主宰着 引擎 如何通过标识符名称在 当前的 作⽤域,或者在包含它的任意 嵌套作⽤域 中来查询⼀个变量。
作⽤域的⼯作⽅式:
词法作用域(最常见)和动态作用域
补充:动态作用域
它的作⽤域是在运⾏时被确定的,它的作⽤域是在运⾏ 时被确定的。
动态作⽤域本身关⼼ 它们是从何处被调⽤的。
词法作用域
词法作⽤域是在词法分析时被定义的作⽤域。词法作用域是基于你在写程序时,**变量和作用域的块儿在何处被编写决定的,**因此 它在词法分析器处理你的代码时(基本上)是固定不变的。
查询
⼀旦找到第⼀个匹配,作⽤域查询就停⽌了。
**遮蔽:**相同的标识符名称可以在嵌套作⽤域的 多个层中被指定(内部的标识符“遮蔽”了外部的标识 符)。
**注意:**全局变量也是全局对象(在浏览器中是window,等等)的属性。
window.a
这种技术给出了访问全局变量的⽅法,没有它全局变量将因为被遮蔽⽽不可访问。
欺骗词法作用域
欺骗词法作⽤域会导致更低下的性能。
eval
JavaScript 中的 eval(…) 函数接收⼀个字符串作为参数值,并将这个字符串的内容 看作是好像它已经被实际编写在程序的那个位置上。
eval(…) 通常被⽤于执⾏动态创建的代码。
function foo(str,a){
eval(str);
console.log(a,b);
}
var b = 2;
function("var b = 3",1); // 1,3
注意: 当 eval(…) 被⽤于⼀个操作它⾃⼰的词法作⽤域的 strict 模式程序时,在 eval(…) 内部做出的声明不会实际上修改包围它的作⽤域。
function foo(str){
"uesr strict";
eval(str);
console.log(a); // ReferenceError: a is not defined
}
foo("var a = 2");
with
with 的常⻅⽅式是作为⼀种缩写,来引⽤⼀个对象的多个属性,⽽ 不必 每次 都重复对象引⽤本身。
var obj = {
a:1,
b:2,
c:3
};
//重复“obj” 显得更繁冗
obj.a=2;
obj.b=3;
obj.c=4;
//更简单的缩写
with(obj){
a = 3;
b = 4;
c = 5;
}
with 语句接收⼀个对象,这个对象有0个或多个属性,并将这个对象视为好像它是 ⼀个完全隔离的词法作⽤域:
function foo(obj){
with(obj){
a = 2;
}
}
var obj1 = {
a:3;
};
var obj2 = {
b:2;
}
foo(obj1);
console.log(obj1.a); // 2
foo( obj2 );
console.log( obj2.a ); // undefined
console.log( a ); // 2 -- 哦,全局作⽤域被泄漏了
注意: 尽管⼀个 with 块⼉将⼀个对象视为⼀个词法作⽤域,但是在 with 块⼉内 部的⼀个普通 var 声明将不会归于这个 with 块⼉的作⽤域,⽽是归于包含它的函 数作⽤域。
总结:
- eval(…) 函数接收⼀个含有⼀个或多个声明的代码字符串,它就会修改现存的 词法作⽤域 。
- with 语句实际上是从你传递给它的对象中凭空制造了⼀个 全新的词 法作⽤域。
三.函数与块儿作用域
函数中的作用域
JavaScript 拥有基于函数的作⽤域。
函数作⽤域⽀持着这样的想法:所有变量都属于函数,⽽且贯穿整个函数始终都可以 使⽤和重⽤(⽽且甚⾄可以在嵌套的作⽤域中访问)。
隐藏于普通作用域
它们主要是由⼀种称为“最低权限原则”的 软件设计原则引起的,有时也被称为“最低授权”或“最少曝光”。
⽐如⼀个模块/对象的API,你应当只暴露所需要的最低限 度的东⻄,⽽“隐藏”其他的⼀切。
function doSomething(a){
function doSomethingElse(a){
return a-1;
}
var b;
b = a + doSomethingElse(a*2);
console.log(b*3);
}
doSomething(2); //15
好处一:
更安全。将这些私有细节隐藏在 doSomething(…) 的作⽤域内部,b 和 doSomethingElse(…) 对任何外界影响都是不可访问的,⽽是仅仅由 doSomething(…) 控制。
好处二:
避免冲突。避免两个同名但⽤处不同的 标识符之间发⽣⽆意的冲突。
全局“名称空间”
变量冲突(很可能)发⽣的⼀个特别强有⼒的例⼦是在全局作⽤域中。
在全局作⽤域中使⽤⼀个⾜够独特的名称来创建⼀个单独的变量声 明,它经常是⼀个对象。然后这个对象被⽤作这个库的⼀个“名称空间”,所有要明确 暴露出来的功能都被作为属性挂在这个对象(名称空间)上。
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
模块管理
另⼀种回避冲突的选择是通过任意⼀种依赖管理器,使⽤更加现代的“模块”⽅式。没有库可以向全局作⽤域添加任何标识符,取⽽代之的是使⽤依赖管理器的各种机制,要求库的标识符被明确地导⼊到另⼀个指定的作⽤域中。。
函数作为作用域
**注意:**区分声明与表达式的最简单的方法是,这个语句中"function"一词的位置。如果"function"是这个语句中的第一个东西,那么他就是一个函数声明。否则,他就是一个函数表达式。
函数表达式:
(function foo(){
var a = 3;
console.log( a ); // 3
})();
将名称 foo 隐藏在它⾃⼰内部意味着它不会没必要地污染外围作⽤域。
匿名与命名
回调函数:(匿名函数表达式)
setTimeout( function(){
console.log("I waited 1 second!");
}, 1000 );
**注意:**函数表达式可以是匿名的,但是函数声明不能省略名称 。
匿名函数的缺点:
- 没有可用的名称表示,这可能使得调试更加困难;
- 自引用时,递归等⽬的引⽤它⾃⼰或者在被触发后想要把⾃⼰解除绑定。
- ⼀个描述性的名称 可以帮助代码⾃解释。
内联函数表达式 很强⼤且很有⽤ ,最佳的⽅法是总是命名你的函数表达式。
立即调用函数表达式(IIFE)
变种一:我们可以从外围作用域传入任何你想要的东西,⽽且你可以将参数命名为任何适合你的名称。
IIFE 的另一种变种,IIFE只是函数调用的事实。
例如:
var a = 2;
(function IIFE(global){
var a = 3;
console.log(a); //3
conslole.log(global.a); //2
})(window);
console.log(a); //2
传⼊ window 对象引⽤,参数为global。
变种二:可以用来保证在⼀个代码块中 undefined 标识符确实是是⼀个未定义的值。
默认的 undefined 标识符的值也许会被不正确地覆盖掉,⽽导致意外的结果。
通过将参数命名为 undefined ,同时不为它传递 任何参数值。
(function IIFE(undefined){
var a;
if(a===undefined){
console.log("Undefined is safe here!");
}
})();
变种三:将事情的顺序倒了过来,要被执⾏的函数在调⽤和传递给它的参数之后给出。
这种模式被⽤于 UMD( 统⼀模 块定义)项⽬。
def 函数表达式作为一个参数被传递给在代码前半部分定义的IIFE 函数。参数def(函数)被调用,并将window 作为 global 参数传入。
var a = 2;
(function IIFE(def){
def(window);
})(function def(global){
var a = 3;
console.log(a); // 3
console.log(global.a); //2
});
块儿作为作用域
补充:let-er 是⼀个编译期代码转译器,它唯⼀的任务就是找到 let 语句形式并转译它们。
for (var i=0; i<10; i++){
console.log( i );
}
我们很可能认为是仅在这个 for 循环内部的上下⽂环境中使⽤ i ,⽽实质上忽略了这个***变量实际上将⾃⼰划⼊了外围 作⽤域中(函数或全局)***的事实。
为什么要⽤仅将在这个 for 循环中使⽤的变量 i 去污染⼀个函数的整个作⽤域呢?
with
它从对象中创建的作⽤域***仅存在于这个 with 语句的⽣命周期中***,⽽不再外围作⽤域中。
try/catch
catch ⼦句 中声明的变量,是属于 catch 块⼉的块⼉作⽤域的。
try{
undefined(); //⽤⾮法的操作强制产⽣⼀个异常!
}catch(err){
console.log( err ); // 好⽤!
}
console.log( err ); // ReferenceError: `err` not found
注意:如果你在同⼀个作⽤域中有两个或多个 catch ⼦句,⽽ 它们⼜各⾃⽤***相同***的标识符名称声明了它们表示错误的变量时,可以:
1. 将 catch 变量命名为 err1 , err2 ,等等。
2. ⼲脆关闭 linter 对重复变量名的检查。
let
let 关键字将变量声明附着在它所在的任何块⼉(通常是⼀个 { … } )的作⽤域 中。
换句话说, let 为它的变量声明隐含地劫持了任意块⼉的作⽤域。
var foo = true;
if (foo) {
{ // <-- 明确的块儿
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError
使⽤ let 做出的声明将 不会 在它们所出现的整个块⼉的作⽤域中提升。
{
console.log(bar); //ReferenceError!
let bar = 2;
}
垃圾回收
块儿作用域的有用指出之一:关于闭包和释放内存的垃圾回收。
function process(data) {
// 做些有趣的事
}
// 运⾏过后,任何定义在这个块中的东⻄都可以消失了
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
let循环
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
const
const ,它也创建⼀个块⼉作⽤域变量,但是它的值 是固定的(常量)。
四.提升
变量和函数声明被从它们在代码流中出现的位置**“移动”到代码的顶端**。
关于出现在⼀个作⽤域内各种位置的声明如何附着在作⽤域上。
编译过程的⼀部分就是找到所有的声明,并将它们关联在合适的作⽤域上。
var a = 2;
**第⼀个语句:var a;**声明,是在编译阶段被处理的。
**第⼆个语句:a = 2;**赋值,为了执⾏阶段⽽留在 原处。
注意:
- 提升是 以作⽤域为单位的。
- 函数声明会被提升,就像我们看到的。但是函数表达式不会。
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
foo(); // 不是 ReferenceError, ⽽是 TypeError!
var foo = function bar() {
//...
};
这个代码段可以(使⽤提升)更准确地解释为:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
函数优先
函数会⾸先被提升,然后才是变量
虽然多个/重复的 var 声明实质上是被忽略的,但是后续的函数声明确实会覆盖前⼀ 个。
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
五.作用域闭包
闭包就是函数能够记住并访问它的词法作⽤域,即使当这个函数在它的词法作⽤域之外执⾏时。
在 JavaScript 中闭包⽆所不在,闭包是依赖于词法作⽤域编写代码⽽产⽣的结果。
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 -- 看到闭包了。
bar() 依然拥有对 foo() 作⽤域的引⽤,⽽这个引⽤称为闭包。
函数可以被作为值传递:
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将`baz`赋值给⼀个全局变量
}
function bar() {
fn(); // 看到闭包了!
}
foo();
bar(); // 2
计时器、事件处理器、Ajax请求、跨窗 ⼝消息、web worker、或者任何其他的异步(或同步!)任务,当你传⼊⼀个 回调函 数,你就在它周围悬挂了⼀些闭包!
循环+闭包
错误:
for (var i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
//五个6
输出的结果反映的是 i 在循环终结后的最终值。
缺少循环的每次迭代都“捕捉”⼀份对 i 的拷 ⻉。由于作⽤域的⼯作⽅ 式,它们 都闭包在同⼀个共享的全局作⽤域上,⽽它事实上只有⼀个 i 。
**纠正:**使用IIFE
如果没有var j = i; 将不会好用,因为 拥有⼀个被闭包的 空的作⽤域 是不够的。
for (var i=1; i<=5; i++) {
(function(){
var j = i;
setTimeout( function timer(){
console.log( j );
}, j*1000 ); })();
}
**升级:**使用块儿作用域
for (let i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
模块
function CoolModule(){
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 = CoolModule;
foo.doSomething(); // "cool"
foo.doAnother(); // 1!2!3
⾏使模块模式有两个“必要条件”:
- 必须有一个外部的外围函数,而且他必须至少被调用一次(每次创建一个新的模块实例)。
- 外围的函数必须至少返回一个内部函数,这样这个内部函数才拥有私有作用域的闭包,并且可以访问和/或修改这个私有状态。
模块可以传递参数;
模块作为公有API返回的对象命名:
var foo = (function CoolModule(id) {
function change() {
// 修改公有 API
publicAPI.identify = identify2;
}
function identify1(){
console.log( id );
}
function identify2(){
console.log( id.toUpperCase() );
}
var publicAPI = {
change:change,
identify:identify1
};
return publicAPI;
})("foo module");
foo.identify1(); // foo module
foo.change();
foo.identify2(); // FOO MODULE
未来的模块
ES6 将⼀个⽂件 视为⼀个独⽴的模块。每个模块可以导⼊其他的模块或者特定的API成员,也可以导出它们⾃⼰的公有API成员。
ES6 模块API是静态的(这些API不会在运⾏时改变,在(⽂件加载和)编译期间检查⼀个指向被导⼊模 块的成员的引⽤是否 实际存在。如果API引⽤不存在,编译器就会在编译时抛出⼀ 个“早期”错误。
ES6 模块 没有 “内联”格式,它们必须被定义在⼀个分离的⽂件中(每个模块⼀个)。
import 在当前的作⽤域中导⼊⼀个模块的API的⼀个或多个成员,每个都绑定到⼀个变量
module 将整个模块的API导⼊到⼀个被绑定的变量
export 为当前模块的公有API导出⼀个标识符(变量,函数)
例如:
bar.js
funtion hello(who){
return "Let me introduce :"+who;
}
export hello; // 导出函数
foo.js
import hello from "bar";
var hungry = "hippo";
function asome(){
console.log(hello(hungry).toUpperCase());
}
export asome;
module foo from "foo"
module bar from "bar"
console.log(bar.hello("piao")); // Let me introduce : piao
foo.asome(); //Let me introduce : HIPPO
词法this
ES6为函数声明增加了一种特殊的语法形式,称为**“箭头函数”**(匿名函数)
var foo = a=>{
console.log(a);
};
foo(2); //2
=> 是function关键字的缩写,但不仅仅是可以少打⼀些“function”那么简单。
var obj = {
id:"asome",
cool:function coolFn(){
console.log(this.id);
}
};
var id = "not asome";
obj.cool(); // asome
setTimeout(obj.cool,100); // not asome
cool()函数上丢失了this绑定,解决方法:
- var self = this;
console.log(self.id);
self 变成了⼀个可以通过词法作⽤域和闭包解析的标识符。
var obj = {
count: 0,
cool: function coolFn() {
var self = this;
if (self.count < 1) {
setTimeout( function timer(){
self.count++;
console.log( "awesome?" );
}, 100 ); } }
};
obj.cool(); // awesome?
- 箭头函数,称为“词法this”的行为。
当箭头函数遇到它们的 this 绑定时,将它们的⽴即外围词法作⽤域作 为 this 的值,⽆论它是什么。
var obj = {
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( () => { // 箭头函数能好⽤?
this.count++;
console.log( "awesome?" );
}, 100 );
}
}
};
obj.cool(); // awesome?
- 正确地使⽤并接受 this 机制,使用bind(this)绑定。
var obj ={
count:0,
cool:function coolFn(){
if(this.count<1){
setTimeout(function timer(){
this.count++; // `this` 因为 `bind(..)` 所以安全
console.log("more awesome");
}.bind(this),100); //使用 bind
}
}
};
obj.cool(); // more awesome