作为一名JS开发人员,是什么使我夜不能寐

5,400 阅读12分钟

作者:Justen Robertson

翻译:疯狂的技术宅

原文:www.toptal.com/javascript/…

未经允许严禁转载

JavaScript 是一种奇怪的语言。虽然受到 Smalltalk 的启发,但它用了类似 C 的语法。它结合了程序、函数和面向对象编程(OOP)的方方面面。它有许多能够解决几乎任何编程问题的方法,这些方法通常是多余的,并没有强烈推荐哪些是首选。它是弱动态类型,但采用了类似强制类型的方法,使经验丰富的开发人员也可以使用。

JavaScript 也有其瑕疵、陷阱和可疑的功能。新手程序员需要努力解决一些更为困难的概念 —— 异步性、封闭性和提升。具有其他语言经验的程序员合理地假设具有相似名称的东西,但是看上去与 JavaScript 相同的工作方法往往是错误的。数组不是真正的数组,什么是 this,什么是原型, new 实际上做了些什么?

ES6 类的麻烦

到目前为止,最糟糕的罪魁祸首是 JavaScript 的最新版本——ECMAScript 6(ES6)的。一些关于类的讨论坦率地令人震惊,并揭示了对语言实际运作机制的根深蒂固的误解:

“JavaScript 现在终于成为一种真正的面向对象的语言,因为它有类!”

要么是:

“让我们从 JavaScript 中被破坏的继承模型中解脱出来。”

甚至是:

“在 JavaScript 中创建类型是一种更安全、更简单的方法。”

这些言论并没有影响到我,因为它们暗示了原型继承中存在问题,让我们抛开这些论点。这些话让我感到困扰,因为它们都不是真的,它们证明了 JavaScript 的“everything for everyone”的语言设计方法的后果:它削弱了程序员对语言的理解。在我进一步说明之前,先举一个例子。

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 类语法的问题。

没错,第二个例子更具表现力,因此你可能会认为 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 究竟是什么。但这不是你的错。就像Arrayclass不是语言特征一样,它是蒙昧的语法。它试图隐藏原型继承模型和随之而来的笨拙的惯用语法,这意味着 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.fooundefined 时,它引用了 parent.foo。一旦在 child 上定义了 foochild.foo 的值为 'bar',但 parent.foo 保留了原始值。一旦我们 delete child.foo,它将会再次引用 parent.foo,这意味着当我们更改父项的值时,child.foo 指的是新值。

让我们来看看刚才发生了什么(为了更清楚地说明,我们假设这些是 Strings 而不是字符串字面量,这里的区别并不重要):

显示如何在JavaScript中处理缺少的引用的原型链

它的工作方式,特别是 newthis 的特点是另一个主题,但如果你想学到更多的内容,可以查阅 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 小测验 #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()

这不仅仅是为了避免继承的丑陋或强制封装。想一想你能用 secretFactoryleaker 做些什么,你用原型或类做可不能轻易的做到。

首先,你可以解构它,因为你不必担心 this 的上下文:

const { spillTheBeans } = secretFactory()

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

这真是太好了。除了避免使用 newthis 做蠢事之外,它还允许我们将对象与 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 classinterface 等,不过 JavaScript 没有任何抽象或接口的概念。

让我们用一个更好的例子来看看如何用工厂模式实现它:

function greeterFactory(greeting = "Hello", name = "World") {
  return {
    greet: () => `${greeting}, ${name}!`
  }
}

console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!

这比原型或类的版本更简洁。它可以更有效地实现其属性的封装。此外,它在某些情况下具有较低的内存和性能影响(乍一看似乎不太可能,但 JIT 编译器正悄悄地在幕后做了减少重复和推断类型的工作)。

因此它更安全,通常情况下也更快,并且编写这样的代码更容易。为什么我们又需要类了呢?哦,当然是可重用性。如果我们想要一个unhappy 且 enthusiastic 的 greeting会怎样?好吧,如果我们用的是 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 和一个 passionsticGreeterFactory

然后你的客户出现并说:“我需要一个不开心的新员工,希望整个办公室都能认识它!”

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

如果我们需要不止一次地使用这个 enthusiastically 且 unhappy 的 greeter,可以更容易实现:

const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory))

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

这种合成风格的方法适用于原型或类。例如,你可以将 UnhappyGreetingEnthusiasticGreeting 重新考虑为装饰器。它仍然需要比上面的函数风格方法更多的样板,但这是你为真正的类的安全性和封装所付出的代价。

问题是,在 JavaScript 中,你没有得到自动安全性。强调 class 用法的 JavaScript 框架会对这些问题变很多“魔术”,并强制类使用自己的行为。看看 Polymer 的 ElementMixin 源代码,我敢说。它简直是 JavaScript 神器级别的代码,我没有任何讽刺的意思。

当然,我们可以用 Object.freezeObject.defineProperties 来解决上面讨论的一些问题,以达到更大或更小的效果。但是为什么要在没有函数的情况下模仿表单,而忽略了 JavaScript 本身为我们提供的工具?当你的工具箱旁边有真正的螺丝刀时,你会用一把标有 “螺丝刀” 的锤子来驱动螺丝吗?

找到好的部分

JavaScript 开发人员经常强调语言的优点。我们选择试图通过坚持编写干净、可读、最小化、可重用的代码来避免其可疑的语言设计和陷阱。

关于 JavaScript 的哪些部分是合理的,我希望已经说服了你,class 不是其中之一。如果做不到这一点,希望你能理解 JavaScript 中的继承可能是混乱且令人困惑的。而且 class 既不去修复它,也不会让你不得不去理解原型。如果你了解到面向对象的设计模式在没有类或 ES6 继承的情况下正常工作的提示,则可获得额外的好处。

我并没有告诉你要完全避免 class。有时你需要继承,而 class 为此提供了更清晰的语法。特别是,class X extends Y 比旧的原型方法更好。除此之外,许多流行的前端框架鼓励使用它,你应该避免在原则上单独编写奇怪的非标准代码。我只是不喜欢它的发展方向。

在我的噩梦中,整整一代的 JavaScript 库都是使用 class 编写的,期望它的行为与其他流行语言类似。即使我们没有不小心掉进 class 的陷阱,它也可能复活在错误的 JavaScript 墓地之中。经验丰富的JavaScript开发人员经常受到这些怪物的困扰,因为流行的并不总是好的。

最终我们都沮丧地放弃了,开始重新发明 Rust、Go、Haskell 或者其它类似这样的轮子,然后为 web 编译为Wasm,新的 Web 框架和库扩散到无限多的语言中。

它确实让我夜不能寐。

欢迎关注前端公众号:前端先锋,获取前端工程化实用工具包。