写这篇《你不知道的javascript读书笔记》是因为最近找到了一本非常好的js的书,叫《你不知道的javascript》,讲的非常通俗易懂,但是又非常深入原理,豆瓣上评分9.4,可以说是非常高了。这本书有三本,而且比较新,里面有许多ES6的内容,所以写一篇读书笔记,推荐给大家,共勉。
如果每次遇到 JavaScript 中出乎意料的行为时,你的反应就是把它加入黑名单(很多人都是这么做的),那用不了多久你就会把 JavaScript 语言真正的多样性全部排除。剩下的部分就是非常著名的“好的部分”(Good Parts),但是亲爱的读者们,我恳请你们把它称作“简单的部分”、“安全的部分”甚至“不完整的部分”。“你不知道的 JavaScript”系列丛书要做的事恰好相反:学习并且深入理解整个 JavaScript,尤其是那些“难的部分”。
作用域
LHS和RHS
当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋值操作的右侧”,更准确地说是“非左侧”。
考虑以下代码:
console.log( a );
其中对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任何值。相应地,需要查找并取得 a 的值,这样才能将值传递给 console.log(..)
。
相比之下,例如:
a = 2;
这里对 a 的引用则是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 =2 这个赋值操作找到一个目标。
作用域嵌套
我们说过,作用域是根据名称查找变量的一套规则。实际情况中,通常需要同时顾及几个作用域。当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
考虑以下代码:
function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4
对 b 进行的 RHS 引用无法在函数 foo 内部完成,但可以在上一级作用域(在这个例子中就是全局作用域)中完成。
严格模式
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError异常。值得注意的是,ReferenceError 是非常重要的异常类型。
ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。接下来,如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
欺骗词法
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;"
这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新的变量 b,因此它对已经存在的 foo(..) 的词法作用域进行了修改。
事实上,和前面提到的原理一样,这段代码实际上在 foo(..) 内部创建了一个变量 b,并遮蔽了外部(全局)作用域中的同名变量。当 console.log(..) 被执行时,会在 foo(..) 的内部同时找到 a 和 b,但是永远也无法找到外部的 b。因此会输出“1, 3”而不是正常情况下会输出的“1, 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;
}
但实际上这不仅仅是为了方便地访问对象属性。考虑如下代码:
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 和 o2 两个对象。其中一个具有 a 属性,另外一个没有。foo(..) 函数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {..}
。
在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个LHS 引用(查看第 1 章),并将 2 赋值给它。当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console.log(o1.a)
中可以体现。而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性,o2.a 保持 undefined。
函数中的作用域
首先需要研究一下函数作用域及其背后的一些内容。
考虑下面的代码:
function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
// 更多的代码
var c = 3;
}
由于标识符 a、b、c 和 bar 都附属于 foo(..) 的作用域气泡,因此无法从 foo(..) 的外部对它们进行访问。也就是说,这些标识符全都无法从全局作用域中进行访问,因此下面的代码会导致 ReferenceError 错误:
bar(); // 失败
console.log( a, b, c ); // 三个全都失败
但是,这些标识符(a、b、c、foo 和 bar)在 foo(..) 的内部都是可以被访问的,同样在bar(..) 内部也可以被访问(假设 bar(..) 内部没有同名的标识符声明)。
函数作用域
我们已经知道,在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。
例如:
var a = 2;
function foo() { // <-- 添加这一行
var a = 3;
console.log( a ); // 3
} // <-- 以及这一行
foo(); // <-- 以及这一行
console.log( a ); // 2
虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先,必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。
其次,必须显式地通过函数名(foo())调用这个函数才能运行其中的代码。如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,这将会更加理想。
幸好,JavaScript 提供了能够同时解决这两个问题的方案。
var a = 2;
(function foo(){ // <-- 添加这一行
var a = 3;
console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。比较一下前面两个代码片段。
第一个片段中 foo 被绑定在所在作用域中,可以直接通过foo() 来调用它。
第二个片段中 foo 被绑定在函数表达式自身的函数中而不是所在作用域中。换句话说,(function foo(){ .. })
作为函数表达式意味着 foo 只能在 .. 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
对象
可计算属性名
ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名
根据这个特性,便可以很方便的构造动态的属性。
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
可计算属性名最常用的场景可能是 ES6 的符号(Symbol),它们是一种新的基础数据类型,包含一个不透明且无法预测的值(从技术角度来说就是一个字符串)。
比如:
var myObject = {
[Symbol.Something]: "hello world"
}
数组的属性访问
数组也支持[]的访问形式,数组也支持 [] 访问形式,不过就像我们之前提到过的,数组有一套更加结构化的值存储机制(不过仍然不限制值的类型)。
正常的字符串属性访问可以保存值,但是不会被计算到length中。
var myArray = [ "foo", 42, "bar" ];
myArray.length; // 3
myArray[0]; // "foo"
myArray[2]; // "bar"
// 数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性:
var myArray = [ "foo", 42, "bar" ];
myArray.baz = "baz";
myArray.length; // 3
myArray.baz; // "baz"
你完全可以把数组当作一个普通的键 / 值对象来使用,并且不添加任何数值索引,但是这并不是一个好主意。数组和普通的对象都根据其对应的行为和用途进行了优化,所以最好只用对象来存储键 / 值对,只用数组来存储数值下标 / 值对。
但属性的值如果是数字的话,情况便会发生变化,看似很像“属性”的值在这里却变成了下标。看代码:
var myArray = [ "foo", 42, "bar" ];
myArray["3"] = "baz";
myArray.length; // 4
myArray[3]; // "baz"
可以看到,这里的length发生了变化,说明[“3”]已经变成了下标。
复制对象
我自己的理解中,浅拷贝中新对象的值只是对原对象的一种引用,并不是真真的拷贝,而像是一个快捷方式。
那么,该如何简单的完成对象的深拷贝呢?
许多 JavaScript 框架都提出了自己的解决办法,
对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解
析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );
当然,这种方法需要保证对象是 JSON 安全的,所以只适用于部分情况。
相比深复制,浅复制非常易懂并且问题要少得多,所以 ES6 定义了 Object.assign(..) 方法来实现浅复制。Object.assign(..) 方法的第一个参数是目标对象,之后还可以跟一个或多个源对象。它会遍历一个或多个源对象的所有可枚举(enumerable,参见下面的代码)的自有键(owned key,很快会介绍)并把它们复制(使用 = 操作符赋值)到目标对象,最后返回目标对象,就像这样:
var newObj = Object.assign( {}, myObject );
newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true
存在性
如 myObject.a 的属性访问返回值可能是 undefined,但是这个值有可能是属性中存储的undefined,也可能是因为属性不存在所以返回 undefined。那么如何区分这两种情况呢?
其实,我们不访问他的属性值,便可以判断对象是否存在这个属性。
var myObject = {
a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "b" ); // false
in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链。相比之下,hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链。
所有的普通对象都可以通过对于Object.prototype
的委托来访问hasOwnProperty(..)
,但是有的对象可能没有连接到 Object.prototype(通过 Object.create(null) 来创建)。在这种情况下,形如myObejct.hasOwnProperty(..)
就会失败。
这时可以使用一种更加强硬的方法来进行判断:
object.prototype.hasOwnProperty.call(myObject,"a")
它借用基础的 hasOwnProperty(..)
方法并把它显式绑定到 myObject 上。
枚举
var myObject = { };
Object.defineProperty(
myObject,
"a",
// 让 a 像普通属性一样可以枚举
{
enumerable: true, value: 2
}
);
Object.defineProperty(
myObject,
"b",
// 让 b 不可枚举
{
enumerable: false, value: 3
}
);
我们可以用循环的方法来实际测试一下该属性目前是否可以枚举:
myObject.b; // 3
("b" in myObject); // true
myObject.hasOwnProperty( "b" ); // true
// .......
for (var k in myObject) {
console.log( k, myObject[k] );
}
// "a" 2
或者,我们可以调用对象的预设的方法来直接查看是否可以枚举:
myObject.propertyIsEnumerable( "a" ); // true
myObject.propertyIsEnumerable( "b" ); // false
Object.keys( myObject ); // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]
propertyIsEnumerable(..)
会检查给定的属性名是否直接存在于对象中(而不是在原型链
上)并且满足enumerable:true。Object.keys(..)
会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(..)
会返回一个数组,包含所有属性,无论它们是否可枚举。in
和hasOwnProperty(..)
的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(..)
和Object.getOwnPropertyNames(..)
都只会查找对象直接包含的属性。(目前)并没有内置的方法可以获取 in 操作符使用的属性列表(对象本身的属性以及 [[Prototype]] 链中的所有属性,参见第 5 章)。不过你可以递归遍历某个对象的整条[[Prototype]] 链并保存每一层中使用Object.keys(..)
得到的属性列表——只包含可枚举属性。
for of 原理解析
数组有内置的 @@iterator,因此 for..of 可以直接应用在数组上。我们使用内置的 @@iterator 来手动遍历数组,看看它是怎么工作的:
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 }
如你所见,调用迭代器的 next() 方法会返回形式为 { value: .. , done: .. } 的值,
value 是当前的遍历值,done 是一个布尔值,表示是否还有可以遍历的值
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)
};
}
};
}
});
// 手动遍历 myObject
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }
// 用 for..of 遍历 myObject
for (var v of myObject) {
console.log(v);
}
// 2
// 3
原型
[[Prototype]]
先看这样一段代码:
var anotherObject = {
a:2
};
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject );
myObject.a; // 2
现在 myObject 对象的 [[Prototype]]
关联到了 anotherObject。显然 myObject.a 并不存在,但是尽管如此,属性访问仍然成功地(在 anotherObject 中)找到了值 2。但是,如果 anotherObject 中也找不到 a 并且 [[Prototype]]
链不为空的话,就会继续查找下去。这个过程会持续到找到匹配的属性名或者查找完整条 [[Prototype]]
链。如果是后者的话,[[Get]] 操作的返回值是 undefined。
使用 for..in 遍历对象时原理和查找 [[Prototype]] 链类似,任何可以通过原型链访问到(并且是 enumerable,参见第 3 章)的属性都会被枚举。使用 in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举):
var anotherObject = {
a:2
};
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject );
for (var k in myObject) {
console.log("found: " + k);
}
// found: a
("a" in myObject); // true
因此,当你通过各种语法进行属性查找时都会查找 [[Prototype]]
链,直到找到属性或者查找完整条原型链。
Object.prototype
但是到哪里是 [[Prototype]]
的“尽头”呢?
所有普通的 [[Prototype]]
链最终都会指向内置的 Object.prototype
。由于所有的“普通”(内置,不是特定主机的扩展)对象都“源于”(或者说把 [[Prototype]]
链的顶端设置为)这个 Object.prototype
对象,所以它包含 JavaScript 中许多通用的功能。有些功能你应该已经很熟悉了,比如说 .toString()
和 .valueOf()
,第3章还介绍过.hasOwnProperty(..)
。稍后我们还会介绍 .isPrototypeOf(..)
,这个你可能不太熟悉。
目前到:page 159
非常非常有用