潘忠显 / 2021-04-15
“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显。
How JavaScript works: The internals of classes and inheritance + transpiling in Babel and TypeScript
Jun 7, 2018 · 10 min read
This is post # 15 of the series dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript application that needs to be robust and highly-performant to help users see and reproduce their web app defects real-time.
If you missed the previous chapters, you can find them here:
- An overview of the engine, the runtime, and the call stack
- Inside Google’s V8 engine + 5 tips on how to write optimized code
- Memory management + how to handle 4 common memory leaks
- The event loop and the rise of Async programming + 5 ways to better coding with async/await
- Deep dive into WebSockets and HTTP/2 with SSE + how to pick the right path
- A comparison with WebAssembly + why in certain cases it’s better to use it over JavaScript
- The building blocks of Web Workers + 5 cases when you should use them
- Service Workers, their life-cycle, and use cases
- The mechanics of Web Push Notifications
- Tracking changes in the DOM using MutationObserver
- The rendering engine and tips to optimize its performance
- Inside the Networking Layer + How to Optimize Its Performance and Security
- Under the hood of CSS and JS animations + how to optimize their performance
- Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time
The most popular way to structure any type of software project nowadays is by using classes. In this post # 15, we are going to explore different ways to implementing classes in JavaScript and how we can build class hierarchies. We’ll start by diving into how prototypes work and analyze ways to simulate class-based inheritance in popular libraries. Next, we’ll see how transpiling can add features to the language that are not natively supported and how it was used in Babel and TypeScript to introduce the support of ECMAScript 2015 classes. Last, we’ll finish with some examples of how classes are natively implemented in V8.
Overview
In JavaScript, there are no primitive types and everything we create is an object. For example, if we create a new string:
We can immediately call different methods on the newly-created object:
Unlike other languages, in JavaScript, the declaration of a string or a number automatically creates an object that encapsulates the value and provides different methods that can be executed even on the primitive types.
Another interesting fact is that complex types such as arrays are also objects. If you peek into the typeof of an array instance, you’ll see that it’s an object. The index of every element in the list is just property in the object. So when you access an element by its index in the array, you actually access a property of the array object and you get the latter’s value. When it comes to the way the data is stored these two definitions are identical:
As a result, the time it takes to access an element in the array and a property of an object is the same. I found that out the hard way. Some time ago I had to do a massive optimization on a critical piece of code in a project. After trying all the easy options, I replaced all objects that were used in the project with arrays. In theory, accessing an element in an array is faster than accessing a key in a hash map. I was surprised to find out that it didn’t have any effect on the performance. In JavaScript, both operations are implemented as accessing a key in a hash map and take the same amount of time.
Simulating classes with prototypes
When we think of objects, the first thing that comes to mind is classes. We are all used to structuring our applications in terms of classes and the relationships between them. Although objects in JavaScript are everywhere, the language doesn’t use the classical class-based inheritance. Instead, it relies on prototypes.
In JavaScript, every object is connected to another object — its prototype. When you try to access a property or a method on the object, the search is first performed on the object itself. If nothing is found, the search continues in the object’s prototype.
We’ll start with a simple example that defines a constructor for our base class:
We attach the render function to the prototype because we want every instance of the Component class to be able to find it. When you call this method on any instance of the Component class, first a search will be performed in the instance itself. Then a search will be performed in the prototype and this is where the render method will be found.
So now let’s try to extend the component class. We’ll introduce a new child class.
If we want the InputField to extend the functionality of the component class and be able to call its render method, we need to change its prototype. When a method is called on an instance of the child class, we wouldn’t want to search in its empty prototype. The search should continue in the Component class.
This way, the render method can be found in the prototype of the Component class. In order to get inheritance, we need to connect the InputField’s prototype to an instance of the Component class. Most libraries use the Object.setPrototypeOf method to do so.
This, however, is not the only thing we need to do. Every time we extend a class we need to:
- Set the prototype of the child class to be an instance of the parent class.
- Call the parent constructor in the child constructor so that the initialization logic in the parent constructor can be executed.
- Introduce a way to access a parent method. You need this when you overwrite a method and you want to call the original implementation in the parent method.
As you can see, if you want to get all the features of the class-based inheritance, you need to execute this complex logic every time. Whenever you need to create many classes, it makes sense to encapsulate the logic in reusable functions. This is how developers originally solved the problem of having a class-based inheritance — by simulating it with different libraries. These solutions became very popular which made it obvious that something was missing in the language. This is why a new syntax for creating classes that support class-based inheritance was introduced with the first major revision of the language ECMAScript 2015.
Transpiling classes
When the new features in ES6 or ECMAScript 2015 were proposed, the JavaScript developer community couldn’t wait for all engines and browsers to start supporting them. A good way to achieve this was through transpiling. It allows a piece of code that was written in ECMAScript 2015 to be transformed into JavaScript that any browser can understand. This includes the ability to write classes with class-based inheritance and have them transpiled to working code.
One of the most popular transpilers for JavaScript is Babel. Let’s see how transpiling works by running it on a class definition for the component class we explored above:
This is how Babel transpiles the class definition:
As you can see, the code is transformed into ECMAScript 5 that can be executed in any environment. Plus, some functions are added. They are part of Babel’s standard library.
The _classCallCheck and _createClass are included as functions in the compiled file. The first one makes sure that the constructor function is never invoked as a function. This is achieved by checking whether the context in which the function is evaluated is an instance of the Component object. The code checks if this points to such instance. The second function _createClass handles the creation of the properties of the object that are passed as a list of objects with a key and a value.
To explore how inheritance works, let’s analyze the InputField class that inherits from Component.
Here is the output we get when we process the above example using Babel.
In this example, the inheritance logic is encapsulated in the _inherits function call. It performs the same actions we described in the previous section by setting the prototype of the child class to be an instance of the parent class.
To transpile the code, Babel performs several transformations. First, the ECMAScript 2015 code is parsed and transformed into an intermediary representation called an abstract syntax tree, which we’ve already discussed in a previous post. Then this tree is transformed into a different abstract syntax tree where each node is transformed into it’s ECMAScript 5 equivalent. Finally, the AST is transformed into code.
Abstract Syntax Tree in Babel
The AST contains nodes, each of which has only one parent node. In Babel, there is a base type for the nodes. It contains information about what the node is and where it can be found in the code. There are different types of nodes such as Literals that represent string, numbers, nulls, etc. There are also Statements nodes for flow control(if) and loops(for, while). And there is also a special type of node for classes. It’s a child of the base Node class. It extends it by adding fields to store references to the base class and the body of the class as a separate node.
Let’s transform the following code snippet to an Abstract Syntax Tree:
Here is how the Abstract Syntax Tree for this snippet looks like:
After the Abstract Syntax Tree is created, each node is transformed into its equivalent ECMAScript 5 node and back into code that follows the ECMAScript 5 standard. This is done by a process that finds the nodes that are farthest away from the root node and transforms them into code. Then their parent nodes are transformed into code by using the snippets that are already generated for each child, and so on. This process is called a depth-first traversal.
In the example above, first the code for the two MethodDefinition nodes will be generated, followed by the code for the class body node, and finally the code for the ClassDeclaration node.
Transpiling with TypeScript
Another popular framework that leverages transpiling is TypeScript. It introduces a new syntax for writing JavaScript applications that is transformed into EMCAScript 5 that any browser or engine can execute. Here is how we can implement the component class with Typescript:
And here is the Abstract Syntax Tree:
It also supports inheritance.
Here’s what the result from the transpiling is:
The end result is again ECMAScript 5 code with some functions from the TypeScript library. The logic that is encapsulated in __extends is the same as the one we discussed in the first section.
With Babel and TypeScript becoming widely adopted, the standard classes and class-based inheritance become the standard way of structuring JavaScript applications. This pushed for the introduction of native support for classes in the browsers.
Native Support
In 2014, native support for classes was introduced in Chrome. This allows the class declaration syntax to be executed without the need of any libraries or transpilers.
The process of natively implementing classes is what we call a syntax sugar. This is just a fancy syntax that compiles down to the same primitives that are already supported in the language. You can use the new easy-to-use class definition, but it will still lead to creating constructors and assigning prototypes.
V8 Support
Let’s see how the native support for ECMAScript 2015 classes works in V8. As we discussed in the previous article, first the new syntax has to be parsed as valid JavaScript code and added to the AST. So as a result of the class definition, a new node with type ClassLiteral is added to the tree.
This node stores couple of things. First, it holds the constructor as a separate function. It also holds a list of class properties. They can be a method, a getter, a setter, a public field or a private field. This node also stores a reference to the parent class that this class extends which again stores the constructor, list of properties and the parent class.
Once this new ClassLiteral is transformed into code, it gets translated again into functions and prototypes.
For us at SessionStack, optimizing every bit of our code has been a pretty important but also a very challenging job. There are two reasons for the high level of optimizations required on our end.
The first one is our library which gets integrated into web apps — it’s collecting data from user sessions, such as user events, DOM changes, network data, exceptions, debug messages and so on. Capturing this data without causing any performance impact has been a challenge which we’ve successfully solved.
The second reason for obsessing with optimizations is our player which has to recreate as a video everything that happened to end users at the time they experienced an issue while browsing a web app. The player is highly optimized to accurately render and make use of all the collected data in order to offer a pixel-perfect simulation of end users’ browser and everything that happened in it, both from a visual and technical standpoint. We do all this in a sandboxed environment, real-time and directly in the browser, which requires an excellent utilization of the event loop in order to create a nice and smooth experience.
There is a free plan if you’d like to give SessionStack a try.