不是你的对象不要动
JavaScript 独一无二之处在于任何东西都不是神圣不可侵犯的。默认情况下,可以修改任何可以触及的对象。解析器根本不在乎这些对象是开发者定义的还是默认执行环境的一部分——只要是能访问到的对象都可以修改。在一个开发者独自开发的项目中,这不是问题,开发者确切地知道正在修改什么,因为他对所有代码都了如指掌。然而,在一个多人开发的项目中,对象的随意修改就是个大问题了。
什么是你的
- 当你的代码创建了这些对象时;
- 维护这些对象是你的责任。
【举例】:YUI 团队拥有 YUI 对象,Dojo 团队拥有 dojo 对象。即使编写代码定义该对象的原始作者离开了,各自对应的团队仍然是这些对象的拥有者。
【注意】:在项目中使用 JavaScript 类库,个人不会成为这些对象的拥有者。
【理解】:不是你的对象请不要修改。比如在一个多人开发的项目中。每个人都假设库对象会按照它们的文档中描述的一样正常工作。如果你修改了其中的对象,这就给你的团队设置了一个陷阱。这必将导致一些问题,有些人可能会因此掉进去。
【牢记】:如果你的代码没有创建这些对象,不要修改它们,包括:
- 原生对象(Object、Array等等)。
- DOM 对象(例如,document)。
- 浏览器对象模型(BOM)对象(例如,window)。
- 类库的对象。
【牢记】:不要修改已有执行环境:上面所有对象都是项目执行环境的一部分,由于它们已经存在了,可以直接使用或者通过它们构建新的功能,而不应该去修改它们。
原则
在 JavaScript 中,可以将已存在的对象视为一种背景,在这之上可以做任何事情。把已存在的 JavaScript 对象当作一个实用工具函数库来对待。
- 不覆盖方法。
- 不新增方法。
- 不删除方法。
不覆盖方法
在 JavaScript 中,有史以来最糟糕的实践是覆盖一个非自己拥有的对象的方法。
// 不好的写法
document.getElementById = function() {
return null; // 引起混乱
};
【注意】:没有任何方法能阻止覆盖 DOM 方法。更严重的是,页面中所有脚本都可以覆盖其他脚本的方法。
【示例2】:
// 不好的写法:这种做法亦被称为函数劫持
document._originalGetElementById = document.getElmentById;
document.getElementById = function(id) {
if (id === 'window') {
return window;
} else {
return document._originalGetElementById(id);
}
};
【说明】:这种“覆盖加可靠退化”的模式至少和覆盖原生方法一样不好,也许会更糟,因为 document.getElementById() 时而符合预期,时而不符合。
不新增方法
在 JavaScript 中为已存在的对象新增方法是很简单的。只需要创建一个函数赋值给一个已存在的对象的属性,使其成为方法即可。这种做法可以修改所有类型的对象。
【示例】:
// 不好的写法:在 DOM 对象上增加了方法
document.sayImAwesome = function() {
alert("You're awesome.");
};
// 不好的写法:在原生对象上增加了方法
Array.prototype.reverseSort = function() {
return this.sort().reverse();
};
// 不好的写法:在库对象上增加了方法
YUI.doSomething = function() {
// 代码
};
【说明】:几乎不可能阻止开发者为任何对象添加方法。为非自己拥有的对象增加方法一个大问题——导致命名冲突。因为一个对象此刻没有某个方法,不代表它未来也没有。更糟糕的是如果将来原生的方法和你的方法行为不一致,你将陷入一场代码维护的噩梦。
【好的实践】:大多数 JavaScript 库代码有一个插件机制,允许为代码库安全地新增一些功能。如果想修改,最佳最可维护的方式是创建一个插件。
不删除方法
删除 JavaScript 方法和新增方法一样简单。当然,覆盖一个方法也是删除已存在的方法的一种方式。最简单的删除一个方法的方式就是给对应的名字赋值为 null。
【示例】:
// 不好的写法:删除了 DOM 方法
document.getElementById = null;
【其他方式】:如果方法是在对象的实例上定义的(相对于对象的原型而言),也可以使用 delete 操作符来删除。
var person = {
name: "Nicholas"
};
delete person.name;
console.log(person.name); // 未定义
【注意】:delete 操作符只能对实例的属性和方法起作用,如果在 prototype 的属性或方法上使用 delete 是不起作用的。
// 不影响
delete document.getElementById;
console.log(document.getElementById("myelement")); // 仍然能工作
【说明】:虽然使用 delete 无法删除,但是仍然可以用对其赋值为 null 的方式来阻止被调用。
【最后】:删除一个已存在对象的方法是糟糕的实践,不仅有依赖那个方法的开发者存在,而且使用该方法的代码有可能已经存在。删除一个在用的方法会导致运行时错误。如果你的团队不应该使用某个方法,将其标识为“废弃”,可以用文档或者用静态代码分析器。删除一个方法绝对应该是最后的选择。
更好的途径
修改非自己拥有的对象是解决某些问题很好的方案。在“一种无公害”的状态下,通常不会发生;但真正发生了,我们就要采用方法来尽可能解决它。
【解决思路】:不直接修改这些对象而是扩展这些对象。
【注意】:在 JavaScript 中,继承仍然有一些很大的限制。首先,不能从 DOM 或 BOM 对象继承。其次,由于数组索引和 length 属性之间错综复杂的关系,继承自 Array 是不能正常工作的。
基于对象的继承
在基于对象的继承中,也经常叫作原型继承,一个对象继承另外一个对象是不需要调用构造函数的。
【方式1】:ECMAScript5 的 Object.create()。
var person = {
name: "Nicholas",
sayName: function() {
alert(this.name);
}
};
var myPerson = Object.create(person);
myPerson.sayName(); // 弹出"Nicholas"
【说明】:这种继承方式就如同把 myPerson 的原型设置为 person,从此 myPerson 可以访问 person 的属性和方法,而不需要同名变量在新的对象上再重新定义一遍。例如,重新定义 myPerson.sayName() 会自动切断对 person.sayName() 的访问。
myPerson.sayName = function() {
alert("Anonymous");
};
myPerson.sayName(); // 弹出 "Anonymous"
person.sayName(); // 弹出 "Nicholas"
【关于切断的理解】:先找对象自身是否有该方法,若不存在,则沿着原型链继续寻找。因此可理解为切断。
【说明】:Object.create() 方法可以指定第二个参数,该参数对象中的属性和方法将添加到新的对象中。
var myPerson = Object.create(person, {
name: {
value: "Greg"
}
});
myPerson.sayName(); // 弹出"Greg"
person.sayName(); // 弹出"Nicholas"
【说明】:一旦以这种方式创建了一个新对象,该新对象完全可以随意修改。毕竟,你是该对象的拥有者,在自己的项目中你可以任意新增方法,覆盖已存在方法,甚至是删除方法(或者阻止它们的访问)。
基于类型的继承
基于类型的继承是通过构造函数实现的,而非对象。这意味着,需要访问被继承对象的构造函数。
【示例】:
function MyError(message) {
this.message = message;
}
MyError.prototype = new Error();
【说明】:MyError 类继承自 Error(所谓的超类)。给 MyError.prototype 赋值为一个 Error 的实例。然后,每个 MyError 实例从 Error 那里继承了它的属性和方法,instanceof 也能正常工作。
var error = new MyError("Something bad happened.");
console.log(error instanceof Error); // true
console.log(error instanceof MyError); // true
【步骤】:比起 JavaScript 中原生的类型,在开发者定义了构造函数的情况下,基于类型的继承是最合适的。
- 原型继承;
- 构造器继承:调用超类的构造函数时传入新建的对象作为其 this 的值。
function Person(name) {
this.name;
}
function Author(name) {
Person.call(this, name); // 继承构造器
}
Author.prototype = new Person();
【说明】:Author 类型继承 Person。属性 name 实际上是由 Person 类管理的,所以 Person.call(this, name) 允许 Person 构造器继续定义该属性。Person 构造器是在 this 上执行的,this 指向一个 Author 对象,所以最终的 name 定义在这个 Author 对象上。
【优点】:对比基于对象的继承,基于类型的继承在创建新对象时更加灵活。定义一个类型可以让你创建多个实例对象,所有的对象都是继承自一个通用的超类。新的类型应该明确定义需要使用的属性和方法,它们与超类中的应该完全不同。
门面模式
门面模式是一种流行的设计模式,它为一个已存在的对象创建一个新的接口。门面是一个全新的对象,其背后有一个已存在的对象在工作。所有有时也叫包装器,用不同的接口来包装已存在的对象。
【示例】:当你的用例继承已经无法满足要求时,那么下一步骤就应该创建一个门面,这比较合乎逻辑。
function DOMWrapper(element) {
this.element = element;
}
DOMWrapper.prototype.addClass = function(className) {
elemenet.className += " " + className;
};
DOMWrapper.prototype.remove = function() {
this.element.parentNode.removeChild(this.element);
};
// 用法
var wrapper = new DOMWrapper(document.getElementById("my-div"));
// 添加一个 className
wrapper.addClass("selected");
// 删除元素
wrapper.remove();
【说明】:
- jQuery 和 YUI 的 DOM 接口都是用了门面。如上所述,你无法从 DOM 对象上继承,所以唯一的能够安全地为其新增功能的选择就是创建一个门面。
- 从 JavaScript 的可维护性而言,门面是非常合适的方式,自己可以完全控制这些接口。可以允许访问任何底层对象的属性或方法,反之亦然,也就是有效地过滤对该对象的访问。也可以对已有的方法进行改造,使其更加简单易用。底层对象无论如何改变,只要修改门面,应用程序就能继续正常工作。
关于 Polyfill 的注解
随着 ECMAScript5 和 HTML5 的特性逐渐被各种浏览器实现。JavaScript polyfills(也被称为 shim)变得流行起来了。
【概念】:polyfill 是对某种功能的模拟,这些功能在新版本的浏览器中有完整的定义和原生实现。例如,ECMAScript5 为数组增加了 forEach() 函数。该函数在 ECMAScript3 中有模拟实现,这样就可以在老版本浏览器中用上这个方法了。
【关键】:polyfill 的模拟实现要与浏览器原生实现保持完全兼容。正是由于少部分浏览器原生实现这些功能,才需要尽可能的检测不同情况下这些功能的处理是否符合标准。
【使用须知】:
- 搞清楚哪些浏览器提供了原生实现;
- 确保 polyfills 的实现和浏览器原生实现保持完全一致;
- 检查类库是否提供验证这些方法正确性的测试用例。
【优点】:
- 为了达到目的,polyfills 经常会给非自己拥有的对象新增一些方法,但相比其他对象修改而言,polyfill 是有界限的,是相对安全的。因为原生实现中是存在这些方法并能工作的,有且仅当原生不存在时,polyfills 才新增这些方法,并且它们和原生版本方法的行为是完全一致的。
- 如果浏览器提供原生实现,可以非常轻松地移除 polyfills 新增的方法。
【缺点】:和浏览器的原生实现相比,polyfill 的实现可能不精确。
【推荐】:从最佳的可维护性角度而言,避免使用 polyfills,相反可以在已存在的功能之上创建门面来实现。这种方法给了你最大的灵活性,当原生实现中有 bug 时避免使用 polyfills 就显得特别重要。这种情况下,你根本不想直接使用原生的 API,不然无法将原生实现带有的 bug 隔离开来。
阻止修改
ECMAScript5 引入了几个方法来防止对对象的修改。
【锁定级别】:
- 防止扩展:禁止为对象“添加”属性和方法,但已存在的属性和方法是可以被修改或删除。
- 密封:在“防止扩展”的基础上,禁止为对象“删除”已存在的属性和方法。
- 冻结:在“密封”的基础上,禁止为对象“修改”已存在的属性和方法(所有字段均只读)。
【说明】:每种锁定的类型都拥有两个方法:一个用来实施操作,另一个用来检测是否应用了相应的操作。
- 防止扩展
var person = {
name: "Nicholas"
};
// 锁定对象
Object.preventExtension(person);
console.log(Object.isExtensible(person)); // false
person.age = 25; // 正常情况悄悄地失败,除非在 strict 模式下抛出错误
- 密封对象
// 锁定对象
Object.seal(person);
console.log(Object.isExtensible(person)); // false
console.log(Object.isSealed(person)); // true
delete person.name; // 正常情况悄悄地失败,除非在 strict 模式下抛出错误
person.age = 25; // 同上
- 冻结对象
// 锁定对象
Object.freeze(person);
console.log(Object.isExtensible(person)); // false
console.log(Object.isSealed(person)); // true
console.log(Object.isFrozen(person)); // true
person.name = "Greg"; // 正常情况悄悄地失败,除非在 strict 模式下抛出错误
person.age = 25; // 同上
delete person.name; // 同上
【好的实践】:使用 ECMAScript5 中的这些方法,是保证你的项目在不经过你同意时锁定修改的极佳做法。
- 代码库的作者可以锁定核心库某些部分来保证它们不被意外修改,或者想强迫允许扩展的地方继续存活着。
- 应用程序的开发者,锁定应用程序任何不想被修改的部分。
【注意】:在上述两种情况中,在全部定义好这些对象的功能之后,才能使用上述的锁定方法。一旦一个对象被锁定了,它将无法解锁。
【推荐】:如果决定将你的对象锁定修改,强烈推荐使用严格模式。因为在非严格模式下,试图修改不可修改的对象总是悄无声息地失败,这在调试期间非常令人沮丧。通过使用严格模式,同样的尝试将抛出一个错误,使得不能修改的原因更加明显。
【未来】:将来,原生 JavaScript 对象和 DOM 对象很有可能都将统一内置使用 ECMAScript5 的锁定修改的保护功能。