起因
阅读学习《Effective JavaScript》,以自身阅读和理解,着重记录内容精华部分以及对内容进行排版,便于日后自身回顾学习以及大家交流学习。
因内容居多,分为每个章节来进行编写文章,每章节的准条多少不一,故每篇学习笔记的文章以章节为准。
适合碎片化阅读,精简阅读的小友们。争取让小友们看完系列 === 看整本书的 85+%。
前言
内容总览
- 第一章让初学者快速熟悉 JavaScript,了解 JavaScript 中的原始类型、隐式强制转换、编码类型等几本概念;
- 第二章着重讲解了有关 JavaScript 的变量作用域的建议,不仅介绍了怎么做,还介绍了操作背后的原因,帮助读者加深理解;
- 第三章和第四章的主题涵盖函数、对象及原型三大方面,这可是 JavaScript 区别于其他语言的核心;
- 第五章阐述了数组和字典这两种容易混淆的常用类型及具体使用时的建议,避免陷入一些陷阱;
- 第六章讲述了库和 API 设计;
- 第七章讲述了并行编程,这是晋升为 JavaScript 专家的必经之路
第 4 章「对象和原型」
对象是 JavaScript 中的基本数据结构,直观来看,对象表示字符串与值映射的一个表格。
JavaScript 与许多面向对象的语言一样支持继承,即通过动态代理机制重用代码或数据。而 JavaScript 的继承机制基于原型,而不是类。
在许多语言中,每个对象是相关类的实例,该类提供在其所有实例间共享代码。相反,JavaScript 并没有类的内置概念,对象是从其他对象中继承而来,每个对象与其他一些对象是相关的,称为它的原型,因此原型对于对象来说十分重要。
第 30 条:理解 prototype、getPrototypeOf 和 __proto__ 之间的不同
原型包括三个独立但相关的访问器,都是对单词 prototype 做了一些变化。
- C.prototype 用于建立由
new C()创建的对象的原型。 - Object.getPrototypeOf(obj) 是 ES5 中用来获得 obj 对象的原型对象的标准方法。
- obj.__proto__ 是获取 obj 对象的原型对象的非标准方法。
对于这一些访问器,我们设想一个典型的 JavaScript 数据类型的定义。User 构造函数需要通过 new 操作符来调用。它需要两个参数,即姓名和密码的哈希值,并将它们存储在创建的对象中。
function User(name, passwordHash) {
this.name = name;
this.passwordHash = passwordHash;
}
User.prototype.toString = function() {
return "[User]" + this.name + "]";
};
User.prototype.checkPassword = function(password) {
return hash(password)
};
const u = new User("Lin", "0ef33ae791068ec64b502d6cb0191387");
prototype
User 函数带一个默认的 prototype 属性,我们添加了两个方法 toString 和 checkPassword 到该属性对象上。当使用 new 操作符创建 User 的实例时,产生的对象 u 得到了自动分配的原型对象,该原型对象存储在 User.prototype 中。
实例对象 u 通过 prototype 来实现与原型对象 User.prototype 的继承关系。当实例对象在自身没有找到相应的属性时,会接着查找 u 的原型对象,如存储在 User.prototype 的属性。
Object.getPrototypeOf()
构造函数的 prototype 属性用来设置新实例的原型关系。ES5 中的函数 Object.getPrototypeOf() 可以用于检索现有对象的原型,如上述例子:
Object.getPrototypeOf(u) === User.prototype; // true
__proto__
一些环境提供了一个非标准的方法检索对象的原型,即特殊的 __proto__ 属性。这可作为在不支持 ES5 的 Object.getPrototypeOf() 方法的环境中的一个权宜之计。
u.__proto__ === USer.prototype; // true
总结
JavaScript 程序员往往将 User 描述为一个类,尽管它跟一个函数差不多。JavaScript 中你的类本质上是一个构造函数 User 与一个用于在该类 User.prototype 实例间共享方法的原型对象的结合,是实例之间共享方法的一个内部实现。
C.prototype属性是new C()创建的对象的原型。Object.getPrototypeOf(obj)是 ES5 中检索对象原型的标准函数。obj.__proto__是检索对象原型的非标准方法。- 类是由一个构造函数和一个关联的原型组成的一种设计模式。
第 31 条:使用 Object.getPrototypeof 函数而不要使用 __proto__ 属性
ES5 引入 Object.getPrototypeOf 函数作为获取对象原型的标准 API,而之前是使用特殊的 __proto__ 属性来获取对象的原型。而不是所有的 JavaScript 环境都支持通过 __proto__ 属性来获取对象的原型,兼容性很差。
例如,对于 null 原型的对象,不同的环境处理不一样,在一些环境中,__proto__ 属性继承自 Object.prototype,因此拥有 null 原型的对象没有这个特殊的 __proto__ 属性。
无论在什么情况下,Object.getPrototypeOf 函数都是有效的,也是提取对象原型更加标准、可移植的方法。并且 __proto__ 属性会污染所有的对象,因此会导致大量 Bug。
而对象那些没有提供该 ES5 API 的 JavaScript 环境,也可以很容易地利用 __proto__ 属性来使用 Object.getPrototypeOf 函数。
if (typeof Object.getPrototypeOf === "undefined") {
Object.getPrototypeOf = function(obj) {
const t = typeof obj;
if (!obj || (t !== "object" && t !== "function")) {
throw new TypeError("not an object");
}
return obj.__proto__;
}
}
该实现避免了即使 Object.getPrototypeOf 函数已经存在而仍然设置该函数的情况。
总结
- 使用符合标准的
Object.getPrototypeOf函数而不要使用非标准的__proto属性。 - 在支持
__proto__属性的非 ES5 环境中实现Object.getPrototypeOf函数。
第 32 条:始终不要修改 __proto__属性
修改__proto__属性可能造成的问题
__proto__属性具有修改对象原型链接的能力。因为并非所有平台都支持改变对象原型的特性,会造成可移植性问题。
另一个问题是性能问题,所有现代的 JavaScript 引擎都深度优化了获取和设置对象属性的行为,这些优化都是基于对对象结构的认识上,当更改了对象的内部结构(如添加或删除该对象或其原型链中的对象的属性),将会使一些优化失效。修改 _propto_ 属性实际上改变了继承结构本身,因此比起普通的属性修改,修改 _proto_ 属性会导致更多的优化失效。
避免修改 _proto_ 属性最大原因是为了保持行为的可预测性。对象的原型链通过其一套确定的属性及属性值来定义它的行为,修改对象的原型链将会将对象的整个继承层次结构破坏。保持继承层次结构的相对稳定是一个基本的准则。
Object.create
ES5 中的 Objct.create 函数来创建一个具有自定义原型链的新对象,对不支持 ES5 的运行环境,第 33 条中提供了一种不依赖于 _proto_ 属性的可移植的 Object.creat 函数实现。
总结
- 始终不要修改对象的 _proto_ 属性。
- 可移植性问题。
- 性能问题。
- 破坏继承层次结构导致的行为的不可预测性。
- 使用
Object.create函数给新对象设置自定义的原型。
第 33 条:使构造函数与 new 操作符无关
当使用者创建了一个 User 的构造函数时,程序依赖 new 操作符来判断调用该构造函数,注意该函数假设接受者是一个全新的对象。如果调用者忘记使用 new 关键字,那么函数的接受者将是全局对象。
function User(name, password) {
this.name = name;
this.passwordHash = password;
}
const u = User('lin', '123456');
u; // undefined
this.name; // 'lin'
this.password; // '123456'
当和 new 操作符一同使用时,它能按预期工作。然而,当将它作为一个普通的函数调用时便会失败,我们可以在该函数开头检查函数的接受者是否是一个正确的 User 实例来避免。这样一来不管是以函数的方式还是以构造函数的方式调用 User 函数,它都返回一个继承自 User.prototype 的对象。
function User(name, password) {
if (!(this instanceof User)) {
return new User(name, password);
}
this.name = name;
this.passwordHash = password;
}
const u1 = User('lin', '123456');
const u2 = new User('lin', '123456');
u1 instanceof User; // true
u2 instanceof User; // true
这种模式的缺点是需要回调一次额外的函数调用,代价有点高。因此可以利用 ES5 的 Object.create 函数来返回一个继承自 User.prototype 的新对象,并且该对象已经初始化了 name 和 password 属性。
function User(name, password) {
const self = this instanceof User
? this
: Object.create(User.prototype);
self.name = name;
self.passwordHash = password;
return self;
}
Object.create 只有在 ES5 环境中才生效,在一些旧环境中可以通过创建一个局部的构造函数并使用 new 操作符初始化该构造函数来替代 Object.create。
if (typeof Object.create === 'undefined') {
Object.create = function(prototype) {
function C() {}
C.prototype = prototype;
return new C();
}
}
总结
- 通过使用 new 操作符或 Object.create 方法在构造函数定义中调用自身使得该构造函数与调用语法无关。
- 当一个函数期望使用 new 操作符调用时,需要兼容并文档化该函数。
- 可以使用 Object.create 并兼容它。
第 34 条:在原型中存储方法
实例方法
若实现上述 30 条中的 User 类,且不用其原型中定义任何特殊的方法。
// 第 30 条中原型中存储方法
function User(name, passwordHash) {
this.name = name;
this.passwordHash = passwordHash;
}
User.prototype.toString = function() {
return "[User]" + this.name + "]";
};
User.prototype.checkPassword = function(password) {
return hash(password)
};
const u = new User("Lin", "0ef33ae791068ec64b502d6cb0191387");
// 在实例中存储方法
function User(name, passwordHash) {
this.name = name;
this.passwordHash = passwordHash;
this.toString = function() {
return "[User]" + this.name + "]";
};
this.checkPassword = function(password) {
return hash(password)
};
}
大多数情况下,这两个类的行为是一致的,但当我们构造多个 User 的实例时,第二种构造方法每个实例都会创建 toString 和 checkPassword 方法的副本。
// 一共会有 6 个函数对象
const u1 = new User();
const u2 = new User();
const u2 = new User();
而第一种方法 toString 和 checkPassword 方法只被创建了一次,对象实例间通过原型来共享它们。并且现代的 JavaScript 引擎深度优化了原型查找,所有将方法复制到实例对象并不一定保证速度有明显的提升,但实例方法比原型方法肯定会占用更多的内存。
总结
- 将方法存储在实例对象中将创建该函数的多个副本,因此每个实例对象都有一份副本。
- 将共同方法存储于原型中优于存储在实例对象中。
第 35 条:使用闭包存储私有数据
JavaScript 没有强制机制来对待私有属性,任意一段程序都可以简单地通过访问属性名来获取相应地对象属性。如 for...in、ES5 的 Object.keys() 和 Object.getOwnPropertyNames() 函数等特性都能轻易地获取到对象的所有属性名。
一些程序员通过编码规范来对待私有属性,如使用命名规范给私有属性名前置或后置一个下划线字符(_),但该对象仍然能自由地改变其实现。
对于一些需要更高程度的信息隐藏程序,JavaScript 为信息隐藏提供了一种可靠的机制——闭包。可将数据存储到封闭的变量中而不提供对这些变量的直接访问,获取闭包内部结构的唯一方式是该函数显示地暴露获取它的途径。
对象和闭包具有相反的策略:对象的属性会被自动地暴露出去,然而闭包中的变量会自被自动地隐藏起来。
因此我们可以在构造函数中以变量来存储私有数据,并将对象的方法转变为引用这些变量的闭包。
function User(name, passwordHash) {
this.toString = function() {
return "[User " + name + "]";
};
this.checkPassword = function(password) {
return hash(password) === passwordHash;
};
}
这样一来,name 和 passwordHash 将被方法闭包隐藏,User 的实例根本不包含任何实例属性,因此外部的代码不能直接访问 User 实例的 name 和 passwordHash 变量。
该方法的一个缺点是,因为变量作用域的缘故,这些方法不能放在原型共享,必须置于实例对象中,这样会导致方法副本的扩散。但对于更看重保障信息安全的场景,这样是值得的。
总结
- 闭包变量是私有的,只能通过局部的引用获取。
- 将局部变量作为私有数据从而通过方法实现信息隐藏。
第 36 条:只将实例状态存储在实例对象中
原型对象与实例之间是一对多的关系。注意不能将每个实例的数据存储到其原型中,造成实例之间的数据污染。
因此在原型共享状态的数据可能会导致问题,通过在一个类的多个实例之间共享方法,方法通过是无状态的,并且采用 this 来引用实例状态。
总结
- 共享可变数据可能会出问题,因为原型是被其所有的实例共享的。
- 将可变的实例状态存储在实例对象中。
- 一般将无状态的共享方法挂载在原型上。
第 37 条:认识到 this 变量的隐式绑定问题
看一段读取 CSV 文件的代码片段:
// 省略 CSVReader 构造函数...
CSVReader.prototype.read = function(str) {
const lines = str.trim().split(/\n/);
return lines.map(function() {
return line.split(this.regexp); // 错误!
})
}
const reader = new CSVReader();
reader.read("a,b,c\nd,e,f\n"); // [["a,b,c"], ["d,e,f"]];
传递给 line.map 的毁掉函数引用了 this.regexp 期望能提取到 CSVReader 实例的 regexp 属性,然而 map 函数将其毁掉函数的接收者绑定到了 lines 数组,该 lines 数组没有 regexp 属性,导致 undefined 值结果错误。
因为每个函数都有一个 this 变量的隐式绑定。该 this 变量的绑定值是在调用该函数时确定,如果没有显式地调用该函数,this 变量会隐式地绑定到最近的封闭函数。
CSVReader.prototype.read = function(str) {
const lines = str.trim().split(/\n/);
return lines.map(function() {
return line.split(this.regexp);
}, this) // map 第二个参数传递 this
}
const reader = new CSVReader();
reader.read("a,b,c\nd,e,f\n");
// [["a","b","c"], ["d","e","f"]];
因此可以利用 map 函数的第二个参数传递外部的 this 绑定到回调函数上。如果函数没有额外的参数,那么我们可以在词法作用域的变量来存储这个额外的外部 this 绑定的引用。
CSVReader.prototype.read = function(str) {
const lines = str.trim().split(/\n/);
const self = this;
return lines.map(function() {
return line.split(self.regexp); // 通过作用域链找到外部的 this
})
}
const reader = new CSVReader();
reader.read("a,b,c\nd,e,f\n");
// [["a","b","c"], ["d","e","f"]];
也可以用高阶函数的方法来私用回调函数的 bind 方法
CSVReader.prototype.read = function(str) {
const lines = str.trim().split(/\n/);
return lines.map(function() {
return line.split(self.regexp);
}.bind(this)); // 通过 bind 显式绑定
}
const reader = new CSVReader();
reader.read("a,b,c\nd,e,f\n");
// [["a","b","c"], ["d","e","f"]];
总结
- this 变量的作用域总是由其最近的封闭函数所确定。
- 使用一个局部变量(词法作用域)self、me 或 that 使得 this 绑定对于内部函数是可用的。
- 使用高阶函数方法来显式绑定 this 对象。
第 39 条:不要重用父类属性名
如下给每个类加上唯一标示 id 属性。
function Actor() {
this.id = ++Actor.nextId;
}
function Alien() {
Actor.call(this);
this.id = ++Alien.nextId;
}
Actor.nextId = 0;
Alien.nextId = 0;
这段代码中两个类都试图给实例属性 id 写数据,虽然每个类都认为该属性是“私有”,但该属性存储在实例对象上并命名为一个字符串,指向的是同一个属性。因此子类必须留意父类使用的所有属性,避免采用通用的属性名。
function Actor() {
this.id = ++Actor.nextId;
}
function Alien() {
Actor.call(this);
this.alienId = ++Alien.nextId; // 标识 id
}
Actor.nextId = 0;
Alien.nextId = 0;
总结
- 留意父类使用的所有属性名,避免重用冲突。
第 40 条:避免继承标准类
ECMAScript 具备许多重要的标准类,如 Array、Function 以及 Date 等。它们的定义具有很多特殊的行为,因此很难写出行为正确的子类。如创建一个抽象的目录类并继承数组的所有行为:
function Dir(path, entries) {
this.path = path;
for (const i = 0; i < entries.length; i < n; i++) {
this[i] = entries[i]; // 给实例赋值数组
}
}
// 继承 Array 标准类
Dir.prototype = Object.create(Array.prototype);
const dir = new Dir('/', [1, 2, 3, 4]);
// 数组类属性错误
dir.length; // 0
因为 Array 标准类 length 属性只对在内部被标记为“真正”数组的特殊对象起作用,ECMAScript 标准规定它是一个不可见的内部属性,称为 [[Class]]。
JavaScript 并不具有秘密的内部类系统,[[Class]] 的值仅仅是一个简单的标签,数组对象通过 Array 构造函数或 [] 语法创建加上了值为“Array”的 [[Class]] 属性。如函数被加上了值为 “Function” 的 [[Class]] 属性。因此 JavaScript 保持 length 属性与该内部属性 [[Class]] 值为 “Array” 的特殊对象数量同步,实例中当我们扩展 Array 类时,子类的实例并不是通过 new Array() 或字面量 [] 语法创建的,Dir 实例的 [[Class]] 属性值为 "Object"。
| [[CLass]] | Construction |
|---|---|
| Array | new Array(), [] |
| Boolean | new Boolean() |
| Date | new Date() |
| Error | new Error(), ... 一系列错误类型 |
| Function | new Function(), function(){} |
| JSON | JSON |
| Math | Math |
| Number | new Number() |
| Object | new object(), {}, new MyClass |
| RegExp | new RegExp(), /.../ |
| String | new String() |
默认的 Object.prototype.toString 方法可以通过查询其接收者的内部 [[Class]] 属性来创建对象的通用描述。
const dir = new Dir('/', []);
Object.prototype.toString.call(dir); // "[object Object]"
Object.prototype.toString.call([]); // "[object Array]"
在 ECMAScript 标准库中标准类的某些属性或方法期望具有正确的 [[Class]] 属性或其他特殊的内部属性,然后子类却无法提供,因此避免继承以下标准类:Array、Boolead、Date、Function、Number、RegExp、String。
总结
- 继承标准类往往会由于一些特殊的内部属性而被破坏。
- 避免继承类,可以采用属性委托方式。
第 41 条:将原型视为实现细节
一个对象给其使用者提供了轻量、简单、强大的操作集,这些操作不在意属性储存在原型继承结构的哪个位置。那么实现对象时可能会将一个属性实现在对象原型链的不同位置,但只要其值保持不变,那么这些基本操作的行为也不变。简而言之,原型是一种对象行为的实现细节。
**JavaScript 提送了便利的内省机制Object.prototype.hasOwnProperty 方法确定一个属性是否为对象实例自己的属性,完全忽视原型继承结构。**如 Object.getPrototypeOf 和 __proto__ 特性允许程序员遍历对象的原型链并单独查询其原型对象。
JavaScript 并不区分对象的公有属性和私有属性,那么需要依靠文档和约束,如果一个程序库提供的对象属性没有文档化或者明文标注为内部属性,对于使用者来说,最好不要干涉那些属性。
总结
- 对象是接口,原型是实现。
- 避免你无法控制的对象的原型结构。
- 避免实现在你无法控制的对象内部的属性。
第 42 条:避免使用轻率的猴子补丁
猴子补丁
由于对象共享原型,因此每个对象都可以增加、删除或修改原型的属性,这个通常被称为猴子补丁,它的吸引力在于它的强大。但若是多个库以不兼容的方式给同一个原型打猴子补丁时,问题就出现了。
// 猴子补丁1
Array.prototype.split = function(i) {
return [this.slic(0, i), this.slice(i)];
}
// 猴子补丁2
Array.prototype.split = function(i) {
const i = Math.floor(this.length / 2);
return [this.slic(0, i), this.slice(i)];
}
这两个以冲突的方式给原型打猴子补丁的程序库不能在同一个程序中使用,一种代替的方法是:**如果库仅仅是将给原型打猴子补丁作为一种便利,那么可以将这些修改置于一个函数中,用户可以选择调用或忽略。**这样实际上便不依赖于 Array.prototype.split 函数。
function addArrayMethod() {
Array.prototype.split = function(i) {
return [this.slic(0, i), this.slice(i)];
}
}
polyfill
尽管猴子补丁很危险,但又一种特别可靠且有价值的使用场景:polyfill。用于兼容 JavaScript 程序和库在不同平台或浏览器版本上标准 API 的差异性。
例如 ES5 定义的数组方法(map、filter 等等),一些定版本浏览器版本可能并不支持这些方法,由于这些行为是标准化的,因此多个库都可以给同一个标准方法提供实现,避免造成库与库之间不兼容性的风险。下面是一个简易的版本兼容:
if (typeof Array.porotype.map !== "function") {
Array.prototype.map = function(f, thisArg) {
const result = [];
for (const i = 0, n = this.length; i < n; i++) {
result[i] = f.call(thisArg, this[i], i);
}
return result;
}
}
总结
- 避免使用轻率的猴子补丁。
- 记录程序库所执行的所有猴子补丁,避免冲突错误。
- 考虑通过将猴子补丁置于一个导出函数中,使之成功一个可选的方法。
- 使用猴子补丁为缺失的标准 API 提供 polyfills。