By Justen Robertson
Translation: Crazy geek
Original: www.toptal.com/javascript/…
Reproduced without permission
JavaScript is a strange language. Although inspired by Smalltalk, it uses a C-like syntax. It combines all aspects of programs, functions and object-oriented programming (OOP). It has many ways to solve almost any programming problem, and these methods are often redundant and do not strongly recommend which ones are preferred. It is weakly dynamic, but takes a type-like approach that makes it available to experienced developers.
JavaScript has its flaws, pitfalls, and questionable features. Novice programmers wrestle with some of the more difficult concepts — asynchrony, closure, and promotion. Programmers with experience in other languages can reasonably assume something with a similar name, but it is often wrong to work in a way that looks the same as JavaScript. Arrays are not really arrays, what is this, what is prototype, what does new actually do?
ES6 class trouble
By far the worst culprits are classes in the latest version of JavaScript, ECMAScript 6 (ES6). Some of the discussions about classes are frankly shocking and reveal deep-seated misunderstandings about how language actually works:
“JavaScript is now finally a true object-oriented language because it has classes!”
Either:
“Free us from the broken inheritance model in JavaScript.”
Even:
“Creating types in JavaScript is a much safer and easier way to do it.”
These statements don’t bother me because they imply that there is a problem with archetypal inheritance, so let’s get rid of these arguments. These statements bother me because they’re not true, and they demonstrate the consequences of JavaScript’s “everything for everyone” approach to language design: it weakens programmers’ understanding of the language. Before I go any further, let me give you an example.
JavaScript Quiz #1: What are the essential differences between these code blocks?
function PrototypicalGreeting(greeting = "Hello", name = "World") {
this.greeting = greeting
this.name = name
}
PrototypicalGreeting.prototype.greet = function() {
return `The ${this.greeting}.The ${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 `The ${this.greeting}.The ${this.name}! `}}const classyGreeting = new ClassicalGreeting("Hey"."folks")
console.log(classyGreeting.greet())
Copy the code
The answer here is not unique. The code does work; it’s just a matter of using ES6 class syntax.
Yes, the second example is more expressive, so you might think that class is a good complement to the language. Unfortunately, the issue becomes more nuanced.
JavaScript Quiz #2: What does the following code do?
function Proto() {
this.name = 'Proto'
return this;
}
Proto.prototype.getName = function() {
return this.name
}
class MyClass extends Proto {
constructor() {
super(a)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())
Copy the code
The correct answer is the output it prints to the console:
> MyClass
> Overridden in Proto
> Overridden in MyClass
> Overridden in instance
Copy the code
If you answer incorrectly, you don’t know what class is. But it’s not your fault. Just as Array and class are not language features, they are ambiguous syntax. It tries to hide the archetypal inheritance model and the clunky idioms that come with it, which means JavaScript isn’t doing what you think it is.
You’ve probably been told that class was introduced in JavaScript to make the ES6 class inheritance model more familiar to classic OOP developers from languages like Java. If you’re one of those developers, that example might terrify you. The example shows that the JavaScript class keyword does not provide any of the guarantees required for a class. It also demonstrates a major difference in the stereotype inheritance model: stereotypes are object instances, not types.
The prototype with the class
The most important difference between class-based and prototype-based inheritance is that a class defines a type that can be instantiated at run time, whereas a stereotype is itself an object instance.
A subclass of an ES6 class is another type definition that extends the parent class with new properties and methods that can then be instantiated at run time. A child of a stereotype is another object instance that delegates any properties not implemented on the child to the parent.
Side note: You may be wondering why I mentioned class methods but not prototype methods. That’s because JavaScript has no concept of methods. Functions are classy in JavaScript; they can have properties or properties of other objects.
Class constructors are used to create instances of classes. A constructor in JavaScript is just a normal function that returns an object. The only special thing about a JavaScript constructor is that when called using the new keyword, it specifies its prototype as the one that returns the object. If this sounds a bit confusing to you, you’re not alone — it’s why prototypes are so hard to understand.
To make a point, a child of a prototype is not a copy of the prototype, nor is it the same object as the prototype. The e children have a living reference to the stereotype, and a stereotype attribute that does not exist on the child is a one-way reference to a stereotype attribute with the same name.
Consider the following code:
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'
Copy the code
Note: You’d almost never write code like this in real life — it’s a terrible thing to do — but it’s a neat demonstration of the principle.
In the previous example, when child.foo is undefined, it refers to parent. Once foo is defined on child, child.foo has a value of ‘bar’, but parent. Foo retains the original value. Once we delete child.foo, it will refer to parent. Foo again, which means that when we change the parent’s value, child.foo refers to the new value.
Let’s take a look at what just happened (for clarity, we’re assuming these are Strings rather than string literals, and the difference isn’t important here) :
The way it works, especially the features of New and this, is another topic, but if you want to learn more, check out Mozilla’s exhaustive article on JavaScript’s prototype inheritance chain.
The key point is that stereotypes do not define types, they are instances themselves, and they are mutable at run time.
Still have the courage to read on? Let’s go back and look at JavaScript classes.
JavaScript quiz #3: How do I implement private in a class?
The above stereotype and class attributes are not “encapsulated” as externally inaccessible private members. How to solve this problem?
There is no code example here. The answer is, you can’t.
JavaScript doesn’t have any proprietary concepts, but it does have closures:
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!"
Copy the code
Do you understand what just happened? If you don’t understand closures, you don’t understand closures. Okay, but they’re not that intimidating, and they’re very useful, and you should spend some time getting to know them.
JavaScript Quiz #4: How to Use itclass
Does the keyword write the same code as above?
Sorry, that’s another technical question. You can do the same thing, but it looks like this:
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!"
Copy the code
If you think this looks simpler or clearer than SecretiveProto, then let me know. Personally, it’s a bit bad — it breaks the JavaScript conventions of class declarations, and it doesn’t come from Java as you might expect. This will be indicated by:
JavaScript Quiz # 5:SecretiveClass::looseLips()
What is it for?
Let’s look at this code:
try {
liar.looseLips()
}
catch(e) {
// ReferenceError: secret is not defined
}
Copy the code
HMM… It’s embarrassing.
JavaScript Pop Quiz# 6: Do experienced JavaScript developers prefer prototypes or classes?
Again, it’s a matter of skill — experienced JavaScript developers tend to avoid both whenever possible. Here are some good generic ways to do this with JavaScript:
function secretFactory() {
const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!"
const spillTheBeans = (a)= > console.log(secret)
return {
spillTheBeans
}
}
const leaker = secretFactory()
leaker.spillTheBeans()
Copy the code
This is not just to avoid the ugliness of inheritance or forced encapsulation. Think about what you can do with secretFactory and Leaker that you can’t easily do with prototypes or classes.
First, you can deconstruct it because you don’t have to worry about the context of this:
const { spillTheBeans } = secretFactory()
spillTheBeans() // Favor composition over inheritance, (...)
Copy the code
That’s great. In addition to avoiding silly things with new and this, it allows us to use objects interchangeably with CommonJS and ES6 modules. It also makes development easier:
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)
Copy the code
Programmers using blackHat don’t have to worry about where Exfiltrate comes from, and spyFactory doesn’t have to mess with Function:: Bind’s contextual tricks or deeply nested properties. Note that we don’t need to worry about this in simple synchronous procedure code, but it can cause all sorts of problems in asynchronous code.
After some thought, spyFactory can be developed into a highly sophisticated spy tool that can handle a variety of infiltration targets – in other words, appearance patterns.
Of course you could do it with classes, or rather, all kinds of classes, all of which inherit from Abstract classes or interfaces, etc., but JavaScript doesn’t have any notion of abstraction or interfaces.
Let’s use a better example to see how to implement this with the factory pattern:
function greeterFactory(greeting = "Hello", name = "World") {
return {
greet: (a)= > `${greeting}.${name}! `}}console.log(greeterFactory("Hey"."folks").greet()) // Hey, folks!
Copy the code
This is much cleaner than the prototype or class version. It can encapsulate its attributes more effectively. In addition, it has a low memory and performance impact in some cases (unlikely at first glance, but JIT compilers are quietly working behind the scenes to reduce duplication and infer types).
So it’s safer, it’s usually faster, and it’s easier to write such code. Why do we need classes again? Oh, reusability, of course. What if we wanted an unhappy and enthusiastic greeting? Well, if we were using the ClassicalGreeting class, we might jump right into our dream class hierarchy. We knew we needed parameterized notation, so we did some refactoring and added some subclasses:
// Greeting class
class ClassicalGreeting {
constructor(greeting = "Hello", name = "World", punctuation = "!" ) {this.greeting = greeting
this.name = name
this.punctuation = punctuation
}
greet() {
return `The ${this.greeting}.The ${this.name}The ${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!!
Copy the code
This is a good approach, and until someone comes along and asks for a feature that doesn’t fit perfectly into the hierarchy, the whole thing doesn’t make any sense. When we try to write the same functionality in factory mode, put a pin in the idea:
const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") = > ({
greet: (a)= > `${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: (a)= > greeter(greeting, name, "!!!!!").greet().toUpperCase()
})
console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!
Copy the code
Although the code is shorter, the benefits are not obvious. You might actually find it harder to read, perhaps in a blunt way. Can’t we only a unhappyGreeterFactory and a passionsticGreeterFactory?
Then your client shows up and says, “I need an unhappy new employee and want the whole office to know him!”
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
Copy the code
If we needed to use the enthusiastically and enthusiastically Greeter more than once, it would be much easier:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory))
console.log(aggressiveGreeterFactory("You're late"."Jim").greet())
Copy the code
This composit-style approach works with prototypes or classes. For example, you can rethink UnhappyGreeting and EnthusiasticGreeting as decorators. It still requires more boilerplate than the functional style approach above, but that’s the price you pay for the security and encapsulation of real classes.
The problem is, in JavaScript, you don’t get automatic security. JavaScript frameworks that emphasize class use can do a lot of “magic” on these issues and force classes to use their own behavior. Look at Polymer’s ElementMixin source code, I dare say. It’s JavaScript wizard-level code, and I don’t mean that sarcastically.
Of course, we can solve some of the problems discussed above with Object.freeze or Object.defineproperties to achieve larger or smaller effects. But why mimic forms without functions and ignore the tools JavaScript itself provides? When you have a real screwdriver next to your toolbox, do you use a hammer marked “Screwdriver” to drive the screws?
Find the good parts
JavaScript developers often stress the benefits of the language. We chose to try to avoid its dubious language design and pitfalls by insisting on writing clean, readable, minimal, reusable code.
As to what parts of JavaScript are reasonable, I hope I’ve convinced you that class is not one of them. Failing that, hopefully you understand that inheritance in JavaScript can be messy and confusing. And class doesn’t fix it, nor does it force you to understand the prototype. Bonus points if you know the hints that the object-oriented design pattern works without classes or ES6 inheritance.
I’m not telling you to avoid class altogether. Sometimes you need inheritance, and class provides a clearer syntax for this. In particular, Class X extends Y is better than the old prototype approach. In addition, many popular front-end frameworks encourage its use, and you should avoid writing strange non-standard code in principle alone. I just don’t like where it’s going.
In my nightmare, an entire generation of JavaScript libraries is written in class, expecting it to behave like other popular languages. Even if we don’t accidentally fall into the trap of class, it can be resurrected in the wrong JavaScript graveyard. Experienced JavaScript developers often suffer from these monsters because what’s popular isn’t always good.
Eventually we all gave up in frustration and started reinventing Rust, Go, Haskell, or other such wheels, then compiling to Wasm for the Web, and the new Web frameworks and libraries spread to an infinite number of languages.
It does keep me up at night.