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

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

TJ · 霍洛韦查克应该 JS为例,他从 添加一个简单的 getter 函数Object,一切正常(来源)。

Object.defineProperty(Object.prototype, 'should', {
set: function(){},
get: function(){
return new Assertion(Object(this).valueOf());
},
configurable: true
});

这真的很有意义。例如,可以扩展 Array

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

是否有反对扩展本机类型的理由?

48191 次浏览

当你扩展一个对象时,你改变了它的行为。

更改只由您自己的代码使用的对象的行为是可以的。但是,当您改变某些同时被其他代码使用的代码的行为时,存在着破坏其他代码的风险。

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

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

改变类的工作方式而不用子类化是任何优秀编程语言的一个重要特性,但是这种特性必须很少使用并且要谨慎使用。

在我看来,这是一个糟糕的做法。主要原因是整合。引用 should d.js 文档:

天哪,它扩展了对象? ? ? ! ? !@是的,是的,只有一个读取器 应该,不,它不会破坏你的代码

作者怎么会知道?如果我的嘲笑框架也这样做呢?如果我的承诺也是这样呢?

如果你在你自己的项目中这样做,那么没关系。但对于一个图书馆来说,这是一个糟糕的设计。Js 就是正确处理事情的一个例子:

var arr = [];
_(arr).flatten()
// or: _.flatten(arr)
// NOT: arr.flatten()

没有明显的缺点,比如性能上的打击。至少没人提起过。所以这是一个个人偏好和经历的问题。

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

主要的反对意见是: 代码可以干扰。如果 lib A 添加了一个函数,它可能会覆盖 lib B 的函数。这可以非常容易地破解代码。

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

这就是我不喜欢 jQuery、下划线等库的原因。不要误解我的意思; 它们绝对是编程良好的,而且它们工作起来非常有魅力,但它们是 很大。您只使用其中的10% ,理解大约1% 。

这就是为什么我更喜欢 原子论方法,你只需要你真正需要的。这样的话,你就知道会发生什么了。微库只做你想让它们做的事,所以它们不会干涉。在让终端用户知道添加了哪些特性的情况下,扩展本机类型可以被认为是安全的。

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

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


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

如果您根据具体情况来看待它,也许有些实现是可以接受的。

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

覆盖已经创建的方法所产生的问题比它解决的问题要多,这就是为什么在许多编程语言中通常都要说明这一点,以避免这种做法。开发人员如何知道函数已更改?

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

在本例中,我们没有覆盖任何已知的核心 JS 方法,但是我们扩展了 String。这篇文章中的一个论点提到了新的开发人员如何知道这个方法是否是核心 JS 的一部分,或者在哪里可以找到文档?如果核心 JSString 对象得到一个名为 资本化的方法会发生什么?

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

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原型,但完全没有触及它们:

// 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作为其单个参数,因为它可以从 Array 本身的元素中提取如何创建空累加器以及如何将元素与该累加器连接起来的信息。

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

我可以看到三个不这样做的理由(至少在 申请中) ,其中只有两个在现有的答案中提到:

  1. 如果执行错误,将意外地向扩展类型的所有对象添加可枚举属性。很容易使用 Object.defineProperty,它在默认情况下创建不可枚举的属性。
  2. 您可能会与正在使用的库发生冲突。可以通过勤奋来避免; 只需在向原型添加内容之前检查您使用的库定义了哪些方法,在升级时检查发布说明,并测试您的应用程序。
  3. 您可能会与未来版本的本机 JavaScript 环境发生冲突。

第三点可以说是最重要的一点。通过测试,您可以确保您的原型扩展不会与您使用的库产生任何冲突,因为 决定您使用哪些库。假设代码在浏览器中运行,那么本机对象就不是这样。如果你今天定义了 Array.prototype.swizzle(foo, bar),明天谷歌将把 Array.prototype.swizzle(bar, foo)添加到 Chrome 浏览器中,你很可能会遇到一些困惑的同事,他们想知道为什么 .swizzle的行为似乎与 MDN 上记录的行为不一致。

(详情请参阅 Mootools 如何摆弄他们并不拥有的原型的故事迫使 ES6方法被重命名以避免破坏网络。)

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

没有应该扩展本机对象的另一个原因:

我们使用 Magento,它使用 原型机并在本地对象上扩展了很多内容。 这个工作很好,直到您决定添加新特性,这就是大麻烦开始的地方。

我们已经在我们的一个页面上引入了 WebComponent,所以 webComponent-lite。Js 决定在 IE 中替换整个(本机)事件对象(为什么?). 这当然打破了 原型机,反过来又打破了 Magento。 (直到你发现问题,你可能会投入大量的时间追踪它回来)

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

Perf 也是一个原因。有时你可能需要循环键。有几种方法可以做到这一点

for (let key in object) { ... }
for (let key in object) { if (object.hasOwnProperty(key) { ... } }
for (let key of Object.keys(object)) { ... }

我通常使用 for of Object.keys(),因为它做正确的事情,是相对简洁的,没有必要添加检查。

但是,慢多了

for-of vs for-in perf results

光是猜测 Object.keys慢的原因是显而易见的,Object.keys()必须进行分配。事实上,AFAIK 必须分配一个副本的所有关键,因为。

  const before = Object.keys(object);
object.newProp = true;
const after = Object.keys(object);


before.join('') !== after.join('')

JS 引擎可能会使用某种不可变的键结构,这样 Object.keys(object)就会返回一个引用,这个引用会在不可变的键上迭代,而 object.newProp就会创建一个全新的不可变的键对象,但是不管怎样,它的速度明显慢了15倍

即使检查 hasOwnProperty也要慢2倍。

所有这些的要点是,如果您有性能敏感的代码,并且需要在键上循环,那么您希望能够使用 for in而不必调用 hasOwnProperty。只有在没有修改 Object.prototype的情况下才能这样做

请注意,如果您使用 Object.defineProperty修改原型(如果您添加的内容不可枚举) ,那么在上述情况下它们不会影响 JavaScript 的行为。不幸的是,至少在 Chrome83中,它们确实会影响性能。

enter image description here

我添加了3000个不可枚举属性,只是为了强制显示任何 perf 问题。由于只有30个属性,测试太接近,无法判断是否有任何冲击。

Https://jsperf.com/does-adding-non-enumerable-properties-affect-perf

Firefox 77和 Safari 13.1在增强类和非增强类之间的 perf 没有什么区别,也许 v8会在这方面得到修复,你可以忽略 perf 问题。

但是,我还要补充一点,那就是 Array.prototype.smoosh的故事。简短的版本是 Mootools,一个流行的库,制作了他们自己的 Array.prototype.flatten。当标准委员会试图添加一个原生的 Array.prototype.flatten,他们发现不能不破坏许多网站。发现这个故障的开发人员建议把 es5方法命名为 smoosh作为一个笑话,但是人们不明白这是一个笑话,这让他们感到害怕。他们选择了 flat而不是 flatten

这个故事的寓意是你不应该扩展本地对象。如果你这样做,你可能会遇到同样的问题,你的东西破碎,除非你的特定库正好像 MooTools 一样流行,否则浏览器供应商不太可能解决你造成的问题。如果你的图书馆真的那么受欢迎,那么强迫其他人围绕你引起的问题工作就有点刻薄了。所以,请 不要扩展本机对象

编辑:

一段时间后,我改变了我的想法-原型污染很严重(不过我离开的例子,在文章的结尾)。

它可能会造成比上面和下面提到的更多的麻烦。

在整个 JS/TS 范围内拥有单一标准很重要(拥有一致的 npmjs 会更好)。

以前我写过“胡说八道”,鼓励人们在自己的图书馆里这样做——很抱歉:

杰夫 · 克莱顿 建议似乎也很好-方法名称前缀可以是你的 包名后加下划线如: Array.prototype.<custom>_Flatten(不存在的包前缀可以 成为现有的一揽子计划)


原始答案的一部分:

我个人是在扩展本机方法,我只是在我的库中使用 x前缀(在扩展第三方库时也使用它)。

仅提供技术支持:

declare global {
interface Array<T> {
xFlatten(): ExtractArrayItemsRecursive<T>;
}
}

JS + TS:

Array.prototype.xFlatten = function() { /*...*/ }