个性化阅读
专注于IT技术分析

作为JS开发人员,这就是让我彻夜难眠的原因

点击下载

本文概述

JavaScript是一种语言的怪胎。尽管受到Smalltalk的启发, 但它使用的是类似C的语法。它结合了过程, 功能和面向对象编程(OOP)范例的各个方面。它具有解决几乎所有可能的编程问题的许多方法, 通常是多余的方法, 并且对于哪种方法是优选的没有强烈的意见。它的输入方式弱而动态, 采用迷宫般的方式来强制输入, 甚至使经验丰富的开发人员也无法使用。

JavaScript也有其缺陷, 陷阱和可疑的功能。新程序员要面对一些较困难的概念(例如异步性, 闭包和吊装)。具有其他语言经验的程序员会合理地假设名称和外观相似的事物在JavaScript中的工作方式相同, 并且通常是错误的。数组不是真正的数组;这有什么用, 原型是什么, 新的实际作用是什么?

ES6类的麻烦

到目前为止, 最严重的违规行为是JavaScript的最新发行版ECMAScript 6(ES6):类的新增内容。围绕类的一些讨论坦率地令人震惊, 并揭示了对语言实际工作方式的根深蒂固的误解:

“现在有了类, JavaScript终于是一种真正的面向对象的语言!”

要么:

“类使我们摆脱了JavaScript破坏的继承模型的思考。”

甚至:

“类是在JavaScript中创建类型的更安全, 更轻松的方法。”

这些陈述不会打扰我, 因为它们暗示原型继承有问题。让我们搁置这些论点。这些陈述令我感到困扰, 因为它们都不是真的, 而且它们证明了JavaScript”为所有人提供的一切”方法在语言设计中的后果:它使程序员对语言的理解更多地失去了对语言的理解。在继续之前, 让我们举例说明。

JavaScript流行测验#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类语法的问题。

是的, 第二个例子更具表现力。仅出于这个原因, 你可能会认为类是对语言的一种不错的补充。不幸的是, 这个问题更加微妙。

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

如果回答不正确, 说明你实际上不知道什么是上课。这不是你的错就像Array一样, class不是语言功能, 它是句法晦涩的词。它试图隐藏原型继承模型和随之而来的笨拙习惯用法, 这暗示着JavaScript正在做某些事情, 而事实并非如此。

你可能已经被告知JavaScript引入了类, 以使来自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, 但它引用了parent.foo。一旦我们在child上定义了foo, child.foo的值就为’bar’, 但是parent.foo保留了其原始值。删除child.foo之后, 它将再次引用parent.foo, 这意味着当我们更改父项的值时, child.foo会引用新值。

让我们看一下发生了什么(为了更清楚地说明, 我们将它们假装成字符串而不是字符串文字, 这里的区别不重要):

遍历原型链,以显示如何在JavaScript中处理丢失的引用。

它的工作方式, 尤其是new和this的特性, 是另一天的话题, 但是如果你想了解更多, Mozilla会提供一篇有关JavaScript原型继承链的详尽文章。

关键的一点是, 原型没有定义类型。它们本身就是实例, 它们在运行时是可变的, 具有所有隐含和必然性。

还在我这儿?让我们回到剖析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流行测验#4:使用关键字类与上述内容等效?

抱歉, 这是另一个技巧问题。你可以做基本上相同的事情, 但是看起来像这样:

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中类声明的惯用用法, 并且效果不像你期望的那样, 例如Java。这将通过以下内容清楚地表明:

JavaScript流行测验#5:SecretiveClass :: looseLips()的作用是什么?

让我们找出:

try {
  liar.looseLips()
}
catch(e) {
  // ReferenceError: secret is not defined
}

好吧……那很尴尬。

JavaScript流行测验#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做什么, 而原型或类则很难做到。

一方面, 你可以对其进行重组, 因为你不必担心其上下文:

const { spillTheBeans } = secretFactory()

spillTheBeans() // Favor composition over inheritance, (...)

很好除了避免新的和这种愚弄之外, 它还允许我们将对象与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的客户不必担心漏洞的来源, 而spyFactory则不必乱搞Function :: bind上下文杂乱或深层嵌套的属性。请注意, 我们在简单的同步过程代码中不必为此担心, 但是它会导致异步代码中的各种问题, 而这些问题最好避免。

稍加思考, 即可将spyFactory开发为一种高度复杂的间谍工具, 可以处理各种渗透目标(换句话说, 是立面)。

当然, 你也可以使用一个类, 或者说一个各种各样的类来做到这一点, 所有这些类都继承自抽象类或接口……除了JavaScript没有任何抽象或接口的概念。

让我们回到问候示例, 看看如何在工厂中实现它:

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!!

尽管这段代码短了一点, 但它显然不是更好的代码。实际上, 你可能会争辩说它很难阅读, 也许这是一种过时的方法。我们不能只拥有一个不快乐的GreetFactory和一个热情的GreetFactory吗?

然后, 你的客户说:”我需要一个不高兴的新迎宾员, 并希望整个房间都知道这一点!”

console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(

如果我们需要多次使用这种热情不满的问候语, 我们可以使自己更轻松:

const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory))

console.log(aggressiveGreeterFactory("You're late", "Jim").greet())

有一些方法可以与原型或类一起使用。例如, 你可以重新考虑将UnhappyGreeting和EnthusiasticGreeting作为装饰器。与上面使用的功能样式方法相比, 它仍然需要更多样板, 但这就是你为实型类的安全性和封装所付出的代价。

关键是, 在JavaScript中, 你不会获得这种自动安全性。强调类使用的JavaScript框架在解决此类问题方面具有很多”魔力”, 可以迫使类表现自己。我敢, 有一段时间请看Polymer的ElementMixin源代码。这是JavaScript至上的怪癖级别, 我的意思是没有讽刺或讽刺意味。

当然, 我们可以用Object.freeze或Object.defineProperties解决以上讨论的某些问题, 以或多或少地产生效果。但是, 为什么在不使用功能的情况下模仿表单, 而忽略工具, JavaScript确实为我们提供了我们在Java等语言中找不到的工具?当你的工具箱旁边有真正的螺丝刀时, 你会使用标有”螺丝刀”的锤子来驱动螺丝吗?

寻找好零件

JavaScript开发人员经常在口语上和参考同名书时都强调该语言的优点。我们试图避免由其更可疑的语言设计选择所设置的陷阱, 并坚持让我们编写简洁, 可读, 错误最小化, 可重用代码的部分。

关于JavaScript的哪些部分合格, 有合理的论据, 但我希望我已经说服你该类不是其中之一。失败的话, 希望你理解JavaScript中的继承可能会造成混乱, 并且该类既不能解决该问题, 也使你不必去理解原型。如果你发现有关面向对象的设计模式在没有类或ES6继承的情况下可以很好地工作的提示, 则格外值得赞扬。

我并不是要你完全避免上课。有时你需要继承, 而类提供了更简洁的语法来实现这一点。特别是, 类X扩展Y比旧的原型方法好得多。除此之外, 许多流行的前端框架都鼓励使用它, 你应该避免仅凭原则编写怪异的非标准代码。我只是不喜欢这要去哪里。

在我的噩梦中, 整个JavaScript库都是使用类编写的, 期望它的行为与其他流行语言相似。发现了全新的bug类(双关语)。如果我们不小心落入类陷阱中, 很可能会遗留在格式错误的JavaScript墓地中的旧版本。经验丰富的JavaScript开发人员为这些怪物所困扰, 因为流行的并不总是善良的。

最终, 我们所有人都放弃了挫败感, 开始重新发明Rust, Go, Haskell或其他人知道的轮子, 然后编译为Wasm进行网络使用, 新的Web框架和库激增为多语言的无限。

的确使我彻夜难眠。

赞(0)
未经允许不得转载:srcmini » 作为JS开发人员,这就是让我彻夜难眠的原因

评论 抢沙发

评论前必须登录!