关于javascript:为什么扩展本机对象是一种不好的做法?

Why is extending native objects a bad practice?

每个JS意见领袖都说扩展本机对象是一个糟糕的实践。但为什么呢?我们的表演受到打击了吗?他们是否担心有人做了"错误的方式",并向Object添加了可枚举类型,实际上破坏了任何对象上的所有循环?

以TJ Holowaychuk的should.js为例。他在Object中添加了一个简单的getter,一切都很好(source)。

1
2
3
4
5
6
7
Object.defineProperty(Object.prototype, 'should', {
  set: function(){},
  get: function(){
    return new Assertion(Object(this).valueOf());
  },
  configurable: true
});

这很有道理。例如,可以扩展Array

1
2
3
4
5
6
7
8
Array.defineProperty(Array.prototype,"remove", {
  set: function(){},
  get: function(){
    return removeArrayElement.bind(this);
  }
});
var arr = [0, 1, 2, 3, 4];
arr.remove(3);

是否有任何反对扩展本机类型的论点?


扩展对象时,会更改其行为。

更改只由您自己的代码使用的对象的行为是可以的。但是,当您更改其他代码也使用的某个代码的行为时,可能会破坏其他代码。

当它在javascript中向对象和数组类添加方法时,由于javascript的工作方式,破坏某些东西的风险非常高。多年的经验告诉我,这类东西会导致JavaScript中出现各种可怕的错误。

如果需要自定义行为,最好定义自己的类(可能是子类)而不是更改本机类。这样你就不会破坏任何东西。

在不进行子类化的情况下改变类的工作方式的能力是任何一种好的编程语言的一个重要特性,但它是一种必须很少使用且必须谨慎使用的功能。


没有可测量的缺点,比如性能下降。至少没人提到。所以这是个人偏好和经验的问题。

主要的支持论点是:它看起来更好而且更直观:语法糖。它是一个特定于类型/实例的函数,因此应该专门绑定到该类型/实例。

相反的主要论点是:代码可以干扰。如果lib a添加了一个函数,它可能会覆盖lib b的函数。这很容易破译代码。

两者都有道理。当您依赖两个直接更改类型的库时,很可能会以代码中断告终,因为预期的功能可能不一样。我完全同意。宏库不能操作本机类型。否则,作为一个开发人员,你将永远不知道幕后到底发生了什么。

这就是我不喜欢jquery、下划线等libs的原因。不要误会我;它们的编程非常好,而且它们的工作方式很有魅力,但是它们很大。你只使用其中的10%,并且了解大约1%。

这就是为什么我更喜欢原子论的方法,你只需要你真正需要的。这样,你总是知道会发生什么。微型图书馆只做你想让他们做的,所以他们不会干涉。在让最终用户知道添加了哪些功能的情况下,可以认为扩展本机类型是安全的。

当有疑问时,不要扩展本机类型。只有当您100%确定最终用户知道并希望该行为时,才扩展本机类型。在任何情况下都不能操作本机类型的现有函数,因为它会破坏现有接口。

如果决定扩展类型,请使用Object.defineProperty(obj, prop, desc);如果不能,请使用该类型的prototype

我最初提出这个问题是因为我希望Error可以通过JSON发送。所以,我需要一种方法来把它们串起来。error.stringify()errorlib.stringify(error)感觉好多了;正如第二个结构所示,我在errorlib上操作,而不是在Error上操作。


在我看来,这是一种不好的做法。主要原因是整合。引用should.js文档:

OMG IT EXTENDS OBJECT???!?!@ Yes, yes it does, with a single getter
should, and no it won't break your code

嗯,作者怎么知道?如果我的模拟框架也是这样呢?如果我的承诺Lib也这么做呢?

如果你是在自己的项目中做的,那就没问题了。但对于图书馆来说,这是一个糟糕的设计。underline.js是正确操作的一个例子:

1
2
3
4
var arr = [];
_(arr).flatten()
// or: _.flatten(arr)
// NOT: arr.flatten()


If you look at it on a case by case basis, perhaps some implementations are acceptable.

1
2
3
String.prototype.slice = function slice( me ){
  return me;
}; // Definite risk.

覆盖已经创建的方法会产生比它解决的问题更多的问题,这就是为什么在许多编程语言中,通常会说它是为了避免这种做法。开发人员如何知道功能已更改?

1
2
3
String.prototype.capitalize = function capitalize(){
  return this.charAt(0).toUpperCase() + this.slice(1);
}; // A little less risk.

在这种情况下,我们不会覆盖任何已知的核心JS方法,但我们正在扩展字符串。本文中的一个参数提到了新开发人员如何知道这个方法是核心JS的一部分,还是在哪里找到文档?如果核心JS字符串对象得到一个名为capitalize的方法,会发生什么?

如果不添加可能与其他库冲突的名称,而是使用所有开发人员都能理解的特定于公司/应用程序的修饰符,该怎么办?

1
2
3
4
5
6
7
String.prototype.weCapitalize = function weCapitalize(){
  return this.charAt(0).toUpperCase() + this.slice(1);
}; // marginal risk.

var myString ="hello to you.";
myString.weCapitalize();
// => Hello to you.

如果您继续扩展其他对象,所有开发人员都会在野外与我们(在本例中)相遇,我们会通知他们这是公司/应用程序特定的扩展。

这不会消除名称冲突,但会降低发生冲突的可能性。如果您确定扩展核心JS对象适合您和/或您的团队,那么这可能适合您。


扩展内置原型确实是一个坏主意。然而,ES2015引入了一种新技术,可用于获得预期行为:

利用WeakMap将类型与内置原型关联起来

以下实现扩展了NumberArray原型,根本不接触它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// new types

const AddMonoid = {
  empty: () => 0,
  concat: (x, y) => x + y,
};

const ArrayMonoid = {
  empty: () => [],
  concat: (acc, x) => acc.concat(x),
};

const ArrayFold = {
  reduce: xs => xs.reduce(
   type(xs[0]).monoid.concat,
   type(xs[0]).monoid.empty()
)};


// the WeakMap that associates types to prototpyes

types = new WeakMap();

types.set(Number.prototype, {
  monoid: AddMonoid
});

types.set(Array.prototype, {
  monoid: ArrayMonoid,
  fold: ArrayFold
});


// auxiliary helpers to apply functions of the extended prototypes

const genericType = map => o => map.get(o.constructor.prototype);
const type = genericType(types);


// mock data

xs = [1,2,3,4,5];
ys = [[1],[2],[3],[4],[5]];


// and run

console.log("reducing an Array of Numbers:", ArrayFold.reduce(xs) );
console.log("reducing an Array of Arrays:", ArrayFold.reduce(ys) );
console.log("built-ins are unmodified:", Array.prototype.empty);

正如您所看到的,即使是原始原型也可以通过这种技术进行扩展。它使用映射结构和Object标识将类型与内置原型关联起来。

我的示例启用了一个reduce函数,该函数只期望一个Array作为它的单个参数,因为它可以从数组本身的元素中提取如何创建空的累加器以及如何将元素与该累加器连接起来的信息。

请注意,我本可以使用普通的Map类型,因为弱引用仅表示内置原型时没有意义,而这些原型永远不会被垃圾收集。但是,除非您有正确的钥匙,否则WeakMap不可重复使用,也无法进行检查。这是一个理想的特性,因为我想避免任何形式的类型反射。


不应扩展本机对象的另一个原因是:

我们使用magento,它使用prototype.js并在本机对象上扩展了许多内容。这很好,直到你决定加入新功能,这就是大麻烦的开始。

我们在其中一个页面上引入了WebComponents,因此webcomponents-lite.js决定替换IE中的整个(本机)事件对象(为什么?).这当然会破坏prototype.js,反过来又会破坏magento。(在你发现问题之前,你可能会花很多时间来追踪它)

如果你喜欢麻烦,继续做吧!


我可以看到三个不这样做的原因(至少在一个应用程序中),在这里的现有答案中只有两个:

  • 如果操作错误,则会意外地向扩展类型的所有对象添加一个可枚举属性。很容易使用Object.defineProperty,这在默认情况下创建了不可枚举的属性。
  • 您可能会与正在使用的库发生冲突。可以通过勤奋避免;只需在向原型添加内容之前检查您使用的库定义的方法,在升级时检查发行说明,并测试您的应用程序。
  • 您可能会导致与本机JavaScript环境的未来版本发生冲突。
  • 第3点可以说是最重要的一点。通过测试,您可以确保原型扩展不会与您使用的库产生任何冲突,因为您可以决定使用什么库。假定代码在浏览器中运行,则本机对象的情况也不一样。如果你今天定义Array.prototype.swizzle(foo, bar),明天谷歌将Array.prototype.swizzle(bar, foo)添加到chrome中,你可能会遇到一些困惑的同事,他们想知道为什么.swizzle的行为似乎与MDN中记录的不符。

    (另请参见MooTools如何摆弄自己没有的原型的故事,强制重命名ES6方法以避免破坏Web。)

    对于添加到本机对象中的方法(例如,定义Array.prototype.myappSwizzle而不是Array.prototype.swizzle),使用特定于应用程序的前缀是可以避免的,但这有点难看;使用独立的实用程序函数而不是增强原型也是可以解决的。