作为一名 JS 开发人员,这是让我彻夜难眠的原因
已发表: 2022-03-11JavaScript 是一种奇怪的语言。 虽然受到 Smalltalk 的启发,但它使用了类似 C 的语法。 它结合了过程、功能和面向对象编程 (OOP) 范式的各个方面。 它有许多(通常是多余的)方法来解决几乎任何可以想象的编程问题,并且对于哪些是首选方法没有强烈的意见。 它是弱类型和动态类型,具有迷宫般的类型强制方法,即使是经验丰富的开发人员也会绊倒。
JavaScript 也有它的缺陷、陷阱和有问题的特性。 新程序员会在其中的一些更困难的概念上苦苦挣扎——想想异步性、闭包和提升。 具有其他语言经验的程序员合理地假设具有相似名称和外观的事物在 JavaScript 中将以相同的方式工作,并且通常是错误的。 数组并不是真正的数组。 this
有什么关系,原型是什么, new
实际做了什么?
ES6 类的问题
到目前为止,最严重的违规者是 JavaScript 最新版本 ECMAScript 6 (ES6) 的新版本: classes 。 坦率地说,一些围绕类的讨论令人担忧,并揭示了对该语言实际工作方式的根深蒂固的误解:
“有了类,JavaScript 终于成为真正的面向对象语言了!”
要么:
“类让我们从思考 JavaScript 损坏的继承模型中解放出来。”
甚至:
“类是在 JavaScript 中创建类型的更安全、更简单的方法。”
这些陈述并没有打扰我,因为它们暗示原型继承存在问题。 让我们搁置这些论点。 这些陈述让我感到困扰,因为它们都不是真的,它们展示了 JavaScript 的“一切为了每个人”的语言设计方法的后果:它削弱了程序员对语言的理解,而不是它所能实现的。 在我继续之前,让我们举例说明。
JavaScript Pop Quiz #1:这些代码块之间的本质区别是什么?
function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())
这里的答案是没有。 它们有效地做同样的事情,只是是否使用了 ES6 类语法的问题。
诚然,第二个例子更具表现力。 仅出于这个原因,您可能会争辩说class
是该语言的一个很好的补充。 不幸的是,这个问题有点微妙。
JavaScript 小测验 #2:以下代码有什么作用?
function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())
正确的答案是它打印到控制台:
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
如果您回答错误,您将不了解实际的class
是什么。 这不是你的错。 就像Array
一样, class
不是语言特性,它是语法晦涩难懂。 它试图隐藏原型继承模型和随之而来的笨拙习语,这意味着 JavaScript 正在做一些它没有做的事情。
您可能听说过 JavaScript 中引入了class
,以使来自 Java 等语言的经典 OOP 开发人员更适应 ES6 类继承模型。 如果您是这些开发人员中的一员,那么该示例可能会让您感到震惊。 这应该。 它表明 JavaScript 的class
关键字没有附带任何类应提供的保证。 它还展示了原型继承模型的主要区别之一:原型是对象实例,而不是类型。
原型与类
基于类和基于原型的继承之间最重要的区别在于,类定义了一种可以在运行时实例化的类型,而原型本身就是一个对象实例。
ES6 类的子类是另一种类型定义,它使用新的属性和方法扩展父类,而这些属性和方法又可以在运行时实例化。 原型的子对象是另一个对象实例,它将任何未在子对象上实现的属性委托给父对象。
旁注:您可能想知道为什么我提到了类方法,而不是原型方法。 那是因为 JavaScript 没有方法的概念。 函数在 JavaScript 中是一等的,它们可以具有属性,也可以是其他对象的属性。
类构造函数创建类的实例。 JavaScript 中的构造函数只是一个简单的旧函数,它返回一个对象。 JavaScript 构造函数的唯一特殊之处在于,当使用new
关键字调用时,它将其原型分配为返回对象的原型。 如果这听起来让您感到有些困惑,那么您并不孤单——确实如此,这也是为什么对原型了解甚少的重要原因。
确切地说,原型的子代不是其原型的副本,也不是与原型具有相同形状的对象。 子对象对原型有一个活动引用,并且子对象上不存在的任何原型属性都是对原型上同名属性的单向引用。
考虑以下:
let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
在前面的示例中,虽然child.foo
是undefined
,但它引用parent.foo
。 一旦我们在child
上定义了foo
, child.foo
的值'bar'
,但是parent.foo
保留了它的原始值。 一旦我们delete child.foo
,它就会再次引用parent.foo
,这意味着当我们更改父级的值时, child.foo
引用新的值。
让我们看看刚刚发生的事情(为了更清楚地说明,我们将假设这些是Strings
而不是字符串文字,这里的区别并不重要):
this 在底层的工作方式,尤其是new
和this
的特性,是另一天的话题,但如果您想阅读更多内容,Mozilla 有一篇关于 JavaScript 原型继承链的详尽文章。
关键的一点是原型没有定义type
; 它们本身就是instances
,并且它们在运行时是可变的,具有所有暗示和需要。
还在我这儿? 让我们回到剖析 JavaScript 类。
JavaScript 小测验 #3:如何在类中实现隐私?
我们上面的原型和类属性与其说是“封装”,不如说是“摇摇晃晃地挂在窗外”。 我们应该解决这个问题,但如何解决?
这里没有代码示例。 答案是你不能。
JavaScript 没有任何隐私的概念,但它确实有闭包:
function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"
你明白刚刚发生了什么吗? 如果不是,你不了解闭包。 没关系,真的 - 它们并不像他们想象的那样令人生畏,它们非常有用,你应该花一些时间来了解它们。
JavaScript Pop Quiz #4:使用class
关键字与上面的等效项是什么?
对不起,这是另一个技巧问题。 你可以做基本相同的事情,但它看起来像这样:
class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"
让我知道这是否比SecretiveProto
更容易或更清晰。 在我个人看来,情况更糟——它破坏了 JavaScript 中class
声明的惯用用法,而且它不像你期望的那样工作,比如来自 Java。 这将通过以下方式明确:

JavaScript 小测验 #5: SecretiveClass::looseLips()
做什么?
让我们来了解一下:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
嗯……这很尴尬。
JavaScript Pop Quiz #6:有经验的 JavaScript 开发人员更喜欢哪个——原型还是类?
你猜对了,这是另一个技巧问题——有经验的 JavaScript 开发人员往往会尽可能避免这两个问题。 这是使用惯用 JavaScript 完成上述操作的好方法:
function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()
这不仅仅是为了避免继承固有的丑陋,或者强制封装。 想想你还可以用secretFactory
和leaker
做些什么,而用原型或类做不到这些。
一方面,您可以对其进行解构,因为您不必担心this
的上下文:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
这很不错。 除了避免new
和this
愚蠢之外,它还允许我们将我们的对象与 CommonJS 和 ES6 模块互换使用。 它还使合成更容易一些:
function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)
blackHat
的客户不必担心exfiltrate
是从哪里来的, spyFactory
也不必弄乱Function::bind
上下文杂耍或深度嵌套的属性。 请注意,在简单的同步过程代码中我们不必太担心this
,但它会导致异步代码中的各种问题,最好避免。
稍加思考, spyFactory
就可以发展成一个高度复杂的间谍工具,可以处理各种渗透目标——或者换句话说,一个门面。
当然你也可以用一个类,或者更确切地说,一个分类的类,所有这些类都继承自一个abstract class
或interface
……除了 JavaScript 没有任何抽象或接口的概念。
让我们回到 greeter 示例,看看我们如何使用工厂实现它:
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
您可能已经注意到,随着我们的发展,这些工厂变得越来越简洁,但别担心——它们做同样的事情。 训练轮即将脱落,伙计们!
这已经比相同代码的原型或类版本更少样板。 其次,它更有效地实现了对其特性的封装。 此外,在某些情况下,它的内存和性能占用较低(乍一看可能不像,但 JIT 编译器正在悄悄地在幕后工作以减少重复和推断类型)。
所以它更安全,通常更快,并且更容易编写这样的代码。 为什么我们又需要上课? 哦,当然,可重用性。 如果我们想要不快乐和热情的迎宾变体会发生什么? 好吧,如果我们使用ClassicalGreeting
类,我们可能会直接跳入梦想类层次结构。 我们知道我们需要参数化标点符号,所以我们将进行一些重构并添加一些子元素:
// Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!
这是一个很好的方法,直到有人出现并要求一个不完全适合层次结构的功能并且整个事情不再有意义。 当我们尝试使用工厂编写相同的功能时,请记住这一点:
const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!
这段代码是否更好并不明显,即使它有点短。 实际上,您可能会争辩说它更难阅读,也许这是一种迟钝的方法。 难道我们不能只有一个unhappyGreeterFactory
和一个enthusiasticGreeterFactory
的GreeterFactory 吗?
然后你的客户过来说:“我需要一个不开心的新迎宾员,希望整个房间都知道!”
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
如果我们需要不止一次地使用这个热情不快的问候语,我们可以让自己更容易:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
有一些方法可以使用原型或类来处理这种组合风格。 例如,您可以将UnhappyGreeting
和EnthusiasticGreeting
重新考虑为装饰器。 它仍然需要比上面使用的函数式方法更多的样板,但这就是您为真实类的安全性和封装付出的代价。
问题是,在 JavaScript 中,你并没有获得那种自动的安全性。 强调class
使用的 JavaScript 框架做了很多“魔术”来解决这类问题并强制类表现自己。 有时间看一下 Polymer 的ElementMixin
源代码,我敢。 这是 JavaScript 奥秘的大向导级别,我的意思是没有讽刺或讽刺。
当然,我们可以使用Object.freeze
或Object.defineProperties
来修复上面讨论的一些问题,以达到或多或少的效果。 但是,为什么要在没有函数的情况下模仿表单,而忽略 JavaScript 为我们提供的在 Java 等语言中可能找不到的工具呢? 当您的工具箱旁边有真正的螺丝刀时,您会使用标有“螺丝刀”的锤子来驱动螺丝吗?
寻找好的零件
JavaScript 开发人员经常强调该语言的优点,无论是口语化还是参考同名书籍。 我们试图避免其更可疑的语言设计选择所设置的陷阱,并坚持让我们编写干净、可读、错误最小化、可重用代码的部分。
关于 JavaScript 的哪些部分符合条件存在合理的争论,但我希望我已经说服你class
不是其中之一。 如果做不到这一点,希望您了解 JavaScript 中的继承可能会令人困惑,并且class
既不能修复它,也不能让您不必理解原型。 如果您发现面向对象的设计模式在没有类或 ES6 继承的情况下也能正常工作,那么您将获得额外的荣誉。
我不是要你完全避免class
。 有时您需要继承,而class
为此提供了更简洁的语法。 特别是, class X extends Y
比旧的原型方法好得多。 除此之外,许多流行的前端框架都鼓励使用它,您应该避免仅在原则上编写奇怪的非标准代码。 我只是不喜欢这是怎么回事。
在我的噩梦中,整整一代的 JavaScript 库都是使用class
编写的,期望它的行为与其他流行语言相似。 发现了全新的错误类别(双关语)。 如果我们没有不小心掉入class
陷阱,那些很容易被留在畸形 JavaScript 墓地中的旧的已经复活了。 有经验的 JavaScript 开发人员被这些怪物所困扰,因为流行的东西并不总是好的东西。
最终,我们都沮丧地放弃了,开始用 Rust、Go、Haskell 或谁知道还有什么重新发明轮子,然后编译为 Web 的 Wasm,新的 Web 框架和库激增为多语言的无穷无尽。
它确实让我彻夜难眠。