《编写可维护的 JavaScript》编程实践 - 第 11 章 不是你的对象不要动

编程实践 - 第 11 章 不是你的对象不要动

《编写可维护的 JavaScript》—— Nicholas C. Zakas

JavaScript 的特殊之处在于任何东西都是可修改的。

默认情况下,你可以修改任何你能访问到的对象,解析器根本不在乎这些对象是开发者定义的还是默认执行环境的一部分。

在一个开发者独自开发的项目中,这不是问题,开发者确切地知道正在修改什么,因为他对所有代码了如指掌;
然而,在一个多人开发的项目中,对象的任意修改就是大问题。

1. 什么是你的

你创建(或维护)的对象,才是你拥有的。比如,jQuery 团队拥有 jQuery 对象。

当在项目中使用第三方库(或框架)时,你不是库对象的拥有者。
在多人协同开发的项目中,每个人按照官方文档来使用库对象,如果你修改了库对象,则给你的团队设置了一个陷阱,这必将导致一些问题。

请牢记,如果你的代码没有创建这些对象,不要修改它们,包括:

  • 原生对象(如 Object、Array)
  • DOM 对象(如 document)
  • BOM 对象(如 window)
  • 类库的对象

上面所有这些对象是你项目执行环节的一部分。你可以直接使用这些对象,而不应该去修改它们。

2. 原则

企业软件需要一致而可靠的执行环境使其方便维护。

在其他语言中,将已存在的对象作为库对象来使用,以完成开发任务;
在 JavaScript 中,将存在的对象作为一种背景(普通对象),在这之上可以做任何事。

但你应该把已存在的 JavaScript 对象作为工具函数库一样来对待:

  • 不覆盖方法
  • 不新增方法
  • 不删除方法

对已存在的对象的修改会导致大量的混乱。

2.1. 不覆盖方法

在 JavaScript 中,最糟糕的实践是覆盖别人的对象的方法。

遗憾的是,覆盖一个已存在的方法是难以置信的容易,如

// 不好的写法
document.getElementById = function() {
  return null; // 引起混乱
};

没有任何方法能阻止覆盖 DOM 方法。
更严重的是,页面中所有脚本都可以覆盖其他脚本的方法,
所有任何脚本都可以覆盖 document.getElementById() 方法使其返回 null,这会让 JavaScript 库和其他依赖该方法的代码都失效。

也许,你看到过类似下面这样的 “override-plus-fallback” 模式:

// 不好的写法
document._originalGetElementById = document.getElementById;
document.getElementById = function(id) {
  if (id === "window") {
    return window;
  } else {
    return document._originalGetElementById(id);
  }
};

上例中,将原生 document.getElementById 方法保存起来以便后续使用,然后覆盖 document.getElementById
这种方式有时不会调用原生的方法,也许会导致更糟的情况。

2.2. 不新增方法

在 JavaScript 中为已存在的对象新增方法是很简单的,只需要将一个函数赋值给对象的属性即可。
这种做法可以修改所有类型的对象:

// 不好的写法:在 DOM 对象上增加了方法
document.getElementById = function() {
  alert("You're awesome");
};

// 不好的写法:在原生对象上增加了方法
Array.prototype.reverseSort = function() {
  return this.sort().reverse();
};

// 不好的写法:在库对象增加了方法
jQuery.doSomething = function() {
  // ...
}

几乎不可能阻止你为任何对象添加方法。
给不属于自己的对象添加方法会导致命名冲突,一个对象此刻没有某个方法不代表它未来也没有,
更糟糕的是如果将来元素的方法和你的方法行为不一致,你将陷入一场代码维护的噩梦。

我们要从 Prototype 类库的发展历史中吸取教训,以修改各种 JavaScript 而著名,它随意地为 DOM 和原生对象增加方法;
实际上,它大多数的代码都是在扩展已存在的对象,而不是创建自己的对象。
Prototype 的开发者将该库看作是对 JavaScript 的补充。

在 Prototype 1.6 之前,它实现了 document.getElementsByClassName(),返回的是原生数组,
同时它在数组上也增加了一个方法 Array.prototype.each()

document.getElementsByClassName("selected").each(doSomething);

而 HTML 5 中标准化了该方法,为此 Prototype 团队增加了一些防守型的代码,如下:

if (!document.getElementsByClassName) {
  document.getElementsByClassName = function(classes) {
    // 非原生的实现
  }
}

但是,原生的 document.getElementsByClassName() 返回的是类数组对象(NodeList),它是没有 each() 方法的,
所以用户不得不在升级类库的同时,还要修改自己的代码。

从 Prototype 中可以学到,你不可能精确预测 JavaScript 将来会如何变化。
大多数 JavaScript 库都有一个插件机制,允许为库安全地增加一些功能。
如果想修改,最好的方式创建一个插件。

2.3. 不删除方法

删除方法和新增方法一样简单,最简单的方式时给对应的属性赋值为 null,相当于覆盖:

// 不好的写法:删除了 DOM 方法
document.getElementById = null;

如果方法是在对象的实例上定义的,则可以使用 delete 操作符来删除,但无法删除原型链上的方法:

var person = {
  name: "Nicholas",
};

delete person.name;
console.log(person.name); //=> undefined

// 不影响:不能删除原型链上的方法
delete document.getElementById;

如果你的团队不应该使用某个方法,应该通过文档注释或静态代码分析器将其标志为 “deprecated”,删除一个方法绝对是应该是最后的选择。

不删除你拥有对象的方法是比较好的实践。
在很多案例中,库代码或浏览器都会将有 bug 或不完整的方法保留很长一段时间,因为删除它们以后会在数不胜数的网站上导致错误。

3. 更好的途径

有时通过修改非自己拥有的对象能很好解决一些问题,但解决问题的方案不止一种。
所谓的设计模式,不直接修改这些对象而是扩展这些对象。

在 JavaScript 之外,最受欢迎的对象扩展方式是继承。
如果一种类型的对象已经做到了你想要的大多数工作,你只要继承它,然后再新增一些功能即可。
在 JavaScript 中有两种继承形式:基于对象的基础,基于类型的继承。

但在 JavaScript 中,继承有很大的限制: (ES6中的类可以)

  • 不能从 DOM 和 BOM 对象继承
  • 不能继承 Array 。(由于数组索引和 length 属性的复杂关系)

3.1. 基于对象的继承

基于对象的继承也叫做原型继承,一个对象继承另外一个对象是不需要调用构造函数的。
通过 ECMAScript 5 的 Object.create() 方法可实现这种继承,如:

var person = {
  name: "Nicholas",
  sayName: function() {
    alert(this.name);
  }
};

// <=> myPerson.prototype = person
var myPerson = Object.create(person);
myPerson.sayName();

上面的例子创建了一个新对象 myPerson,它继承自 person,这种继承等价于将 myPerson.prototype = person
以后 myPerson 可以访问 person 的属性和方法,可通过在 myPerson 上定义同名的方法来覆盖:

myPerson.sayName = function() {
  alert("Anonymous");
};

myPerson.sayName(); // 弹出 “Anonymous”
person.sayName();   // 弹出 “Nicholas”

Object.create() 方法可以指定第二个参数,该参数中的属性和方法可以添加到新对象中去:

var myPerson = Object.create(person, {
  name: {
    value: "Greg"
  }
});

一旦你通过 Object.create() 方法创建了一个新对象,你就是该对象的拥有者,你可以任意修改它。

3.2. 基于类型的继承

基于类型的继承和基于对象的继承差不多,都是从一个已存在的对象继承。
但基于类型的继承依赖原型,通过构造函数创建要继承的对象:

function MyError(message) {
  this.message = message;
}

myError.prototype = new Error();

var error = new MyError("Something bad happened.");

console.log(error instanceof Error);    // true
console.log(error instanceof MyError);  // true

MyError 类继承自 Error(超类),给 MyError.prototype 赋值 Error 类的实例后,
每个 MyError 类的实例从 Error 类那里继承了属性和方法。

基于类型的继承一般需要两步:

  1. 原型继承
  2. 构造器继承

构造器继承是调用超类的构造函数时传入新建的对象作为其 this 的值:

function Person(name) {
  this.name;
}

function Author(name) {
  Person.call(this, name);
}

Author.prototype = new Person();

属性 name 是由 Person 类管理的;
Person.call(this, name)Person 构造函数是在 this(Author 实例)上执行的,最终 name 属性是 Author 实例所有。

对比基于对象的继承,基于类型的继承在创建新对象时更加灵活。
定义一个类型可以让你创建多个实例对象,所有的对象都是继承自一个通用的超类。

3.3. 门面模式

门面模式是一种流行的设计模式,它为一个已存在的对象创建一个新的接口。

门面是一个全新的对象,其背后有一个已存在的对象在工作。
门面有时也叫做包装器,它用不同的接口来包装已存在的对象。
如果继承无法满足要求,那么就可以创建一个门面。

你无法从 DOM 对象上继承,所以唯一的能够安全地为其新增功能的选择就是创建一个门面。
如下,DOM 对象包装器代码:

function DOMWrapper(element) {
  this.element = element;
}

// 创建新的接口:添加样式类
DOMWrapper.prototype.addClass = function(className) {
  element.className += " " + className;
};

// 创建新的接口:删除自己
DOMWrapper.prototype.remove = function() {
  this.element.parentNode.removeChild(this.element);
};

// 使用
var wrapper = new DOMWrapper(document.getElementById("myDiv"));

// 添加一个样式类
wrapper.addClass("selected");

// 删除元素
wrapper.remove();

DOMWrapper 类型期望传递给其构造器的是一个 DOM 元素,该元素会保存起来以便后用,它还定义了一些操作该元素的方法。

从 JavaScript 的可维护性而言,门面是非常适合的方法,你可以完全控制这些接口:

  • 你可以访问任何底层对象的属性或方法,更可以过滤对底层对象的方法;
  • 你可以对已有的方法进行改造,使其更加简单易用
  • 底层对象无论如何改变,只要修改门面,应用程序就能继续正常工作

门面实现一个特定接口,让一个对象看上去更像另一个对象,就称作一个适配器。
门面(包装器)和适配器唯一不同的是:包装器创建新的接口,适配器实现已存在的接口。

4. 关于 Polyfill 的注解

随着 ECMAScript 5 和 HTML 5 的开始在各种浏览器中的实现,JavaScript polyfills(也称为 shims)变得流行起来了。

一个 polyfill 是指一种新功能的模拟,新功能指的是在较新的浏览器中已经定义并原生实现了的功能。
例如,ES5 为数组新增了 forEach() 方法,该方法可以在 ES3 中模拟,以便在老版本的浏览器中如同新版本一样使用。

polyfills 的关键是模拟的功能要以完全兼容的方式来实现。

为了达到目的,polyfills 经常会给不是自己拥有的对象新增一些方法。
相对来说,polyfills 是相对安全的,仅在原生方法不存在时,polyfills 才新增这些方法,并且它们和原生版本方法的行为是完全一致的。

polyfills 的优点是,当只模拟浏览器的原始功能,且非常容易删除。
当你选择使用某个 polyfill 时,要保证它的功能和原生的版本尽可能的近似。
polyfills 的缺点是,一旦没有精确模拟所缺失的功能,给你带来的麻烦比缺失的功能要多得多。

从最佳的可维护性角度来说,避免使用 polyfills,可在已存在的功能之上创建门面来实现。

5. 阻止修改

ECMAScript 5 引入了几个方法来防止对对象的修改,锁定这些对象,保证任何人不能有意或无意地修改他们。
在 IE9+、Firefox 4+、Safari 5.1+、Chrome 中已经提供了这些功能。

有三种锁定修改的级别:

  • 防止扩展(prevent extension):禁止添加属性和方法,但可修改或删除已存在的属性和方法
  • 密封(seal):在防止扩展的基础上,禁止删除已存在的属性和方法
  • 冻结(freeze):在密封的基础上,禁止修改已存在的属性和方法

每种锁定级别都拥有两个方法:一个用来实施操作,一个用来检测是否应用了相应的操作。

防止扩展:

var person = {
  name: "Nicholas"
};

Object.preventExtensions(person);
console.log(Object.isExtensible(person)); // false
person.age = 25; // 在严格模式下抛出错误,在非严格模式下悄悄失败

密封:

"use strict";
var person = {
  name: "Nicholas"
};

Object.seal(person);
console.log(Object.isExtensible(person)); // false
console.log(Object.isSealed(person)); // true

delete person.name; // 抛出错误:
// Uncaught TypeError: Cannot delete property 'name' of #<Object>
person.age = 25;    // 抛出错误:
// Uncaught TypeError: Cannot add property age, object is not extensible

冻结:

"use strict";
var person = {
  name: "Nicholas"
};

Object.freeze(person);
console.log(Object.isExtensible(person)); // false
console.log(Object.isSealed(person)); // true
console.log(Object.isFrozen(person)); // true

person.name = "Greg";
person.age = 25;
delete person.name;

一旦一个对象被锁定了,它将无法解锁。

如果决定锁定你的对象,一定要使用严格模式,否则别人对锁定对象的修改会静默失败,给调试带来困惑。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值