一、作用域的认识
1.1LHS和RHS
LHS:赋值操作的目标是谁。比如a=2,那么就是把2赋值给a,就是一个LHS操作。
RHS:谁是赋值操作的源头。比如console.log(a),就是查找a的值,是一个RHS操作。
1.2.作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。
1.3.异常处理
观察如下代码:
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
在第一次RHS b 的时候,并没有找到b的值,当他找遍所有作用域都找不到b的话,就会报ReferenceError 异常。
在非严格模式下,如果LHS没有查询到赋值的目标变量,会帮你创建一个。如果是严格模式,那也会报ReferenceError 异常。
如果RHS查询到了值,但是你对这个值做了不合规的操作,那么会报TypeError。
二、词法作用域
2.1 词法阶段
观察以下代码
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
上述代码中,有三个作用域,分别是全局作用域,foo函数作用域,bar函数作用域。
在bar函数在查找abc时,a和b都查找不到,所以要到上层作用域去查找,知道第一次找到匹配的标识符停止,如果外层又定义了匹配的标识,那么将被遮蔽,我们叫它遮蔽效应。
作用域的查找使用是从最内层开始查找,直到找到第一次匹配的标识停止。
可以利用window.a来访问全局作用域中的被遮蔽的a的值,但是如果不是在全局作用域中被遮蔽的值无法访问到。
无论函数在哪里被调用,如何被调用,他的词法作用域始终由他声明的位置决定。
2.2 eval
function foo(str, a) {
eval( str ); // 欺骗!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
eval将var b = 3放到了foo函数中,这样修改了foo的词法作用域,在查找b时会查找到3,而全局作用域中的2被遮蔽了。如果是严格模式,则会报ReferenceError 异常。
2.3 with
快捷的访问obj里面的abc的值。
with (obj) {
a = 3;
b = 4;
c = 5;
}
观察以下代码:
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2——不好,a 被泄漏到全局作用域上了!
当我们把o1传入时,with的作用域实际上是o1,那么进行LHS查询,会改变o1中的a的值。当把o2传入时,with的作用域是o2,但是o2没有a,foo内没有a,全局没有a,所以在进行LHS查询时,会自动的帮我们新建一个全局的变量a,将2赋值给a。
三、函数作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复 用(事实上在嵌套的作用域中也可以使用)。
3.1隐藏内部实现——最小暴露原则
这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来。例如如下代码:
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
上述代码在doSomething中应该包含着b和doSomethingElse(),但是却在全局中声明的这两个变量,导致外部可以访问到,可能会造成奇奇怪怪的错误。所以我们尽量将b和doSomethingElse封装在doSomething()中。
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
现在我们虽然将他们封装在了doSomething()中,但其实我们在全局作用域中添加了一个doSomething的具名函数,就已经在污染作用域了,并且我们必须要调用该函数才会执行内部的代码。
所以JavaScript给我们提供了一个解决方案:
(function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
})()
上述为立即执行函数(IIFE),doSomething函数被封装在了()中,所以外部的作用域不会访问到他。
3.1.1匿名函数表达式和具名函数表达式
最常用的匿名函数表达式可能就是回调函数了,比如
setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );
其中的function即为一个匿名函数表达式,不过匿名有匿名的缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身 。
- 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
所以我们还是需要函数名称的,所以我们可以用行内函数表达式:
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
3.1.2立即执行函数表达式
上述我们已经提到了IIFE,由于函数被包在了()内部,那么他就是一个表达式,用后面的()来调用。IIFE使用匿名函数和具名函数都可以。他的另一个进阶的玩法就是传参:
var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
将window传入函数,这样就可以访问到外部全局作用于中a的值。
当然也可以把一个函数传到这个IIFE中。
(function IIFE( def ) {
def( window );
})(function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});
3.2如何规避同名冲突
两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致 变量的值被意外覆盖。
function foo() {
function bar(a) {
i = 3; // 修改 for 循环所属作用域中的 i
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 糟糕,无限循环了!
}
}
foo();
上述代码中循环的i被上面bar()中的i覆盖为了3,一直<10,所以无限循环。这就是同名冲突,我们可以将bar()中的i=3更改为var i = 3,这样,根据遮蔽原理,优先找到了循环的i,就可以正常运行,或者用一个其他变量j其实更好。
3.3块作用域
3.3.1 try catch
try/catch 的 catch 分句会创建一个块作 用域,其中声明的变量仅在 catch 内部有效 。
3.3.2 let
下面的代码,利用let声明bar,就将bar锁在了if的块作用域中,外部是无法访问到bar的。 并且let声明也不会有变量提升。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
3.3.3 const
在es6中除了let还引入了const,他定义的是一些常量,任何试图修改值的操作 都会报错。
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在 if 中的块作用域常量
a = 3; // 正常 !
b = 4; // 错误 !
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
四、 提升
4.1先声明,再赋值(调用)
下面的代码中,有两个提升:foo的函数声明提升,和var a的变量声明提升。
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
需要注意,函数会优先提升。下面的代码会打印出1二不是2,说明foo的函数提升优先于变量提升。
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
五、作用域闭包
5.1闭包的实质:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。我们来看如下代码:
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。
上述代码中,bar的词法作用域可以访问到外服的foo的函数作用域,将bar()作为foo的返回值,当foo调用时,将返回的bar给到了baz,调用baz时其实就是调用了内部的bar,这样,我们就从外部执行了bar()。
通常foo()在执行后会进行垃圾回收机制,将内部作用域全部销毁,但是闭包的神奇之处在于,bar在使用foo的内部作用域,所以foo()的作用域就一直存活下去供bar()使用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
当foo被调用后的几微妙后,baz被调用,foo内部的bar就被调用了,不出所料的可以打印出a的值。
再比如我们观察如下代码:
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 妈妈快看呀,这就是闭包!
}
上述代码中调用的是bar的参数fn函数,也就是baz,所以也是在函数外部调用了baz,这也是闭包。
5.2闭包与循环
我们观察如下代码,我们想让他输出12345,但是他却输出了5个6,这是为什么呢?
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
因为延时函数的回调函数是在循环后进行的,所以每一个回调都调用的同一个i,输出6个6。我们希望在每次迭代运行后都能捕获到新的i,所以我们在每次循环迭代中都需要闭包作用域。
利用之前学过可以创造作用域的方法,IIFE是个不错的选择:
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}
这样timer中的j访问的就是IIFE作用域中每次迭代传递进来的新的i了。或者选用let建立块作用域也可以。
for (var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}
timer中的j会访问到了块级作用域中的j,这个j是每次迭代后的i的值,所以成功。如果在for循环的头部使用let来定义,还有一个特殊功能,即每个迭代都会使用上次迭代的值来初始化这个变量,就相当于替代了let j = i。
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
5.3 模块
观察如下代码:这个模式就是模块。CoolModule是一个函数,我们在调用该函数的时候创建了一个模块实例,这个函数的返回值包括了对内部函数的使用,那么我们把这个返回值给到变量foo,就可以通过foo.doSomething来使用方法。
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
模块也可以使用IIFE来定义,并且可以传递参数,也可以改变模块实例
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.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE
六、this的使用
6.1绑定规则——取决于调用位置
6.1.1默认绑定
这是一种最常见的调用类型,独立函数调用。foo()调用在全局作用域中,所以this就是全局作用域。
function foo() {
console.log( this.a );
}
var a = 2;
foo(); // 2
6.1.2隐式绑定
直接看代码:foo是在obj对象中调用的,当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。如果说是链式调用,只和最后一层的位置有关。
我们必须在一个对象内部包含一个指向函 数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
隐式丢失:他虽然调用的还是obj中的foo,但是因为bar()是一个独立调用的函数,这时候的this会绑定在全局对象上。
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"
当我们在执行传入的回调函数时,也是类似。调用的是obj中的foo但是是再fn()处执行的。所以this指向doFoo函数对象,doFoo是再全局中调用,所以this指向全局。
function foo() {
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"
在定时器中传入obj中的foo执行也是一样的,因为定时器的执行规律如下伪代码所示
function setTimeout(fn,delay) {
// 等待 delay 毫秒
fn(); // <-- 调用位置!
}
6.1.3显示绑定——call(),apply()
1.硬绑定:显示的强制绑定,我们称之为硬绑定。下面是硬绑定的经典应用场景:创建一个包裹函数,传入所有的参数并返回接收到的所有值:
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5
在 ES5 中提供了内置的方法 Function.prototype. bind,它的用法如下:
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5
6.1.4 new绑定
首先我们明确一个概念,所有的函数都可以被new调用,这种被调用的函数叫构造函数。实际上并不存在所谓的“构造函数”,存在的是对函数的“构造调用”。
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
1. 创建(或者说构造)一个全新的对象。
2. 这个新对象会被执行 [[ 原型 ]] 连接。
3. 这个新对象会绑定到函数调用的 this。
4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
在使用new的时候,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this 上。
6.2 优先级
知道了4种this的绑定形式,那我们来讨论一下他们的优先级。显然默认绑定的优先级最低,那么我们先来看一下显示绑定和隐式绑定,他们的优先级谁高一点?
function foo() {
console.log( this.a );
}
var obj1 = {
a: 2,
foo: foo
};
var obj2 = {
a: 3,
foo: foo
};
obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2
显然,显示绑定的优先级要高一点,所以我们可以优先使用显示绑定。
现在我们来搞清楚 new 绑定和显示绑定的优先级谁高谁低??因为new 和 call/apply 无法一起使用,所以我们可以通过硬绑定的方法来测试他们的优先级。
function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2
var baz = new bar(3);
console.log( obj1.a ); // 2
console.log( baz.a ); // 3
通过上面的代码我们可以发现,new bar(3)并没有改变obj1中的a的值,二是将3给到了baz这个新对象中,所以new的优先级要大于显示绑定的优先级。
所以!new>显示>隐式>默认绑定,这样this的绑定就会判断了把!但是也有例外的
6.3绑定例外
6.3.1被忽略的this
如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值 在调用时会被忽略,实际应用的是默认绑定规则:
function foo() {
console.log( this.a );
}
var a = 2;
foo.call( null ); // 2
那么我们为什么要传入null呢,比如用apply展开数组,比如用bind预置参数。
function foo(a,b) {
console.log( "a:" + a + ", b:" + b );
}
// 把数组“展开”成参数,在ES6中可以用...来实现
foo.apply( null, [2, 3] ); // a:2, b:3
// 使用 bind(..) 进行柯里化
var bar = foo.bind( null, 2 );
bar( 3 ); // a:2, b:3
但是总是使用null和undefined来忽略this会产生一个副作用,因为这样将this忽略成全局对象,可能会修改全局对象的某些属性,造成奇奇怪怪的bug。所以我们有一种更安全的this——交给Object.create(null),他是一个没有protoype的{}.
function foo(a,b) {
console.log( "a:" + a + ", b:" + b );
}
// 我们的 DMZ 空对象
var ø = Object.create( null );
// 把数组展开成参数
foo.apply( ø, [2, 3] ); // a:2, b:3
// 使用 bind(..) 进行柯里化
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2, b:3
6.3.2间接引用
间接引用一般发生在赋值时,将o中的foo赋值给了p,所以实际调用的是foo函数。他的this就是全局。
function foo() {
console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2
第七章、对象
7.1 语法
对象可以通过两种形式来定义,声明形式、构造形式。
声明形式即 var obj = {a:123},构造形式即 var obj = new Object(); obj.a = b。他们的唯一区别就是构造形式只能一个一个添加键值对。
7.2 内置对象
Js中有一些对象的子类型,我们叫做内置对象。他们实际上就是一些内置函数,当然可以用new调用成构造函数来使用。这样就会构造一个确定子类型的对象。
var strObject = new String( "I am a string" );
当我们使用声明形式来定义对象时,其实他是一个字面量,并不是一个String对象,如果我们想获取到它的长度等一些操作时,就必须把他转换为String对象,好麻烦。幸好js在必要时会自己转换,所以使用声明式比较多。
var strPrimitive = "I am a string";
console.log( strPrimitive.length ); // 13
console.log( strPrimitive.charAt( 3 ) ); // "m"
比如上述两种方法就会自动进行转换,数字和布尔类型也是如此,null和undefined只有声明形式,Date只有构造形式。
7.3 内容
我们在访问对象中的内容可以用以下两种方法:
obj.a和obj[a],如果a是不符合要求的字符串,则只能用obj[a]。
这里的a无论是用123还是true还是其他,它永远都会转换为字符串的形式。
7.3.1可计算属性名
ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名:
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
7.3.2属性与方法
当对象的属性是一个函数时,一般喜欢叫他方法,但是如下代码除了对应的this可能有所不同以外,都是在调用foo,所以函数和方法在js中可以互换
function foo() {
console.log( "foo" );
}
var someFoo = foo; // 对 foo 的变量引用
var myObject = {
someFoo: foo
};
foo; // function foo(){..}
someFoo; // function foo(){..}
myObject.someFoo; // function foo(){..}
7.3.3数组
数组也可以通过[]来访问内容,比如arr[1],但是这里期望[]中为数字,不过我们还是可以给数组添加属性,比如arr.a = b,这样arr中就多了一个属性,不过他的length是不变的。如果添加的属性名看起来是一个数字,那么就会改变数组的长度。
7.3.4复制对象
对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解 析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );
对于浅拷贝来说,ES6 定义了 Object.assign(..) 方 法来实现浅复制。第一个参数为目标对象,第二个为拷贝对象。
var newObj = Object.assign( {}, myObject );
7.3.5属性描述符
从ES5之后,所有的属性都具有了属性描述符。
var myObject = {};
Object.defineProperty( myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
} );
myObject.a; // 2
其中writable为是否可以修改;configurable为是否可配置,如果设置为false则不能配置回true;enumerable为是否可枚举。(是否可循环遍历)
7.3.6不变性
1.常量:如果我想创建一个不可修改的常量属性,可以设计writable:false 和 configurable:false。
2.禁止拓展:如果想禁止一个对象添加新属性,并且保留已有属性,以 使 用 Object.prevent Extensions(..):
3. 密封 Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。 所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以 修改属性的值)。
4. 冻结 Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(..) 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们 的值。
7.3.7存在性
我们可以在不访问属性值的情况下判断是否存在该属性。
var myObject = {a:2};
("a" in myObject); // true
myObject.hasOwnProperty( "a" ); // true
in会检查到原型链,hasOwnProperty不检查原型链。
7.4遍历
for in 可以遍历对象的可枚举属性列表
for of 可以遍历到数组中的所有属性值。它会通过调用迭代器对象的next()来遍历所有值。
var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();
it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }
因为数组中有iterator,所以可以直接使用,但对象中没有,如果我们想要使用for of需要手动写。
var myObject = {
a: 2,
b: 3
};
Object.defineProperty(myObject, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function () {
var o = this;
var idx = 0;
var ks = Object.keys(o);
return {
next: function () {
return {
value: o[ks[idx++]],
done: (idx > ks.length)
};
}
};
}
});