Most of the content I write on this blog is of so-called "beginner-friendly" type. I'm always trying to keep that statement true. That's because it's these "beginners" that are the most eager to learn and try new things. They're just starting their journeys and want to get to know as many things as possible. Of course, such a process takes time and is often very hard because of how fast the web development and general programming landscape changes.
All the terminology...
If you've ever read about JS on pages like Wikipedia, there, from the start, you're bombarded with a lot of terms like high-level, interpreted, JIT-compiled, dynamic typing, prototype-based, etc. While some of them are self-explanatory and surely well-known to experienced programmers, others - not so much. And, even though you don't have to know them all to know how to code well, such knowledge could definitely help you in better understanding the language and programming as a whole. That's why getting to know JS from the inside-out basically means learning the meaning of these terms...
From a higher-level
Beginning JS developers don't really care about how their code actually works... or at least they don't have to! That's because JS is a high-level language. This means that all the details, like how your data is stored in the memory (RAM) or how provided instructions are executed by the CPU, are hidden from the end programmer. In this way, "high" indicates the level of abstraction or simplification that the language provides.
Starting from the very bottom, we have the machine code. As most people know, it's just a set of 0s and 1s arranged in a specific way so that their different groups are meaningful to the machine. Some might indicate a particular instruction, others - data, and all that stuff...
One level above that we have Assembly - the lowest-level programming language, second only to the machine code. Assembly code, in comparison to the machine code, has a human-readable form. In this way, Assembly is the lowest you can go (while keeping sanity and not having to look at a machine code reference all the time). Still, even with its "readability", writing actual Assembly code, with instructions like ADD or MOV, is a very hard task. And that's even before adding up the need to write different Assembly code for each different processor architectures that you'd like to run on (like x86-64 on desktop and ARM on mobile)! Not even mentioning different OSs! Definitely something really far of from what we're used to in JS, isn't it? Anyway, as Assembly is still just an abstraction, in order to run, it needs to be compiled, or should I say assembled to the form of machine code with a utility program called assembler. Fun fact is that many of those assemblers aren't even written in pure Assembly - interesting, right?
Above Assembly, we finally see languages that many of us are quite familiar with - most notably C and C++. Here, we get to write code much more similar to what we see in JS. Yet, we still have access to a wide range of "low-level" (when compared to JS) tools and with them - we still have to manage (allocate/deallocate) the memory ourselves. The code is later converted aka compiled to the machine code (indirectly, with Assembly step in-between) by a program called the compiler. Notice the difference between an assembler and a compiler - as a compiler sits between a much higher level of abstraction and the machine code, it's able to do much, much more! That's why e.g. C code is "portable" in the way that it can be written once and compiled to many, many platforms and architectures!
Very high level
As you can see, JS is indeed a very high-level language. This has many benefits, with the main one being that programmers don't have to think about the details that become visible once we "go down". The only disadvantage to such a high level of abstraction is the performance loss. While JS is very fast and it's only getting better, everybody knows that a piece of C++ code (given that it's properly written) can easily out-perform its JS equivalent. Still, a higher level of abstraction increases the developer's productivity and general comfort of living. It's a compromise and one of many reasons, why different programming languages suit best for different tasks.
Of course that just an oversimplified look at what's behind the scenes, so please - take all of that with a grain of salt. To give you a preview of how great this oversimplification is, we'll continue exploring only the highest levels of abstraction - with JS in the center!
Dynamically- and weakly-typed
In this specification many different terms related to how JS is designed and how it works find their place. It's here that we get to know that JS is dynamically- and weakly-typed language. What this means is that JS variables' types are implicitly resolved and can be changed at runtime (the dynamic part) and they aren't distinguished very strictly (the weak part). Hence the even higher abstractions like TypeScript exist, and we have two equality operators - the usual (
==) and the strict one (
===). Dynamic typing is very popular among interpreted languages, while its opposite - static typing - is popular among the compiled ones.
Another term related to JS is that it's a multi-paradigm language. That's because JS has features that allow you to write code the way you want. This means that your code can vary from being declarative and functional to imperative and object-oriented... or even mix the two paradigms! Anyway, programming paradigms are so different and complex, that they deserve an article of their own.
So, how did JS achieve its "multi-paradigm" badge? Well, definitely one fact that contributes to it is related to another concept that's vital to JS - prototypal inheritance. By now you most likely already know that everything in JS is an object. You might also know what object-oriented programming and class-based inheritance terms mean. You must know that while prototypal inheritance might seem similar to its class-based counterpart, it's actually quite different. In prototype-based languages object's behaviors are reused through one object serving as a prototype for another. In such a prototypal chain, when the given object doesn't have the specified property, it's looked for in its prototype, and the process continues until it's either found or not in any of the underlying prototypes.
const arr = ; const arrPrototype = Object.getPrototypeOf(arr); arr.push(1) // .push() originates in arrPrototype
If you wonder if prototype-based inheritance has been replaced by a class-based one in ES6 (with the introduction of classes), then - no. ES6 classes are only a nicely-done syntactic sugar based on the prototypal inheritance concept.
We've already covered a lot of interesting stuff, yet we've still barely scratched the surface! All the stuff I mentioned just a moment ago is defined in the ECMAScript specification. But, fun fact - many things like the event loop or even the garbage collector are not!. ECMAScript focuses only on the JS itself while leaving its implementation details for others to think about (mostly browser vendors)! That's why all JS engines - even though they follow the same specification - can manage memory differently, JIT-compile or not and etc. So, what does it all mean?
Let's talk about JIT first. Like I said, thinking of JS as an interpreted language isn't right. While it's been true for years, there's been a change recently, which makes such assumption obsolete. Many of popular JS engines, in order to make JS execution faster, introduced a feature called Just-In-Time compilation. How does it work? Well, in a nutshell, instead of being interpreted, the JS code is compiled directly to the machine code (at least in the case of V8) during its execution. This process takes slightly more time but results in a much faster output. For fulfilling such purpose in decent time frame, V8 actually has 2 compilers (not counting the WebAssembly-related stuff) - one is the general-purpose one, able to compile any JS very fast, but with only decent results, while the other one is a bit slower and it's meant for the code that's used very often and needs to be very, very fast. Naturally, dynamically typed nature of JS doesn't make life easier for these compilers. That's why the second one works best when types don't change, letting your code run much faster!
But, if JIT is so fast, why wasn't it used for JS in the first place? Well, we don't know exactly, but I think the right guess would be that JS didn't need that much of a performance boost and standard interpreter was just much easier to implement. Still, back in the days, JS code was usually limited only to a handful of lines, which may even lose some speed due to JIT compilation overhead! Now, that the amount of JS code used in the browsers (and in many other places) grew significantly, JIT compilation is definitely a move in the right direction!
You might have heard or read somewhere that JS runs in this mysterious event loop, which you haven't really had time to care about. So, it's finally time to learn something new about it! But first, we need to set up some background...
Call stack & heap
During the execution process of the JS code, two regions of memory are allocated - the call stack and the heap. The first one is very high-performant and thus serves the purpose of continuously executing provided functions. Every function call creates a so-called "frame" in the call stack, which contains the copy of its local variables and
this. You can see it in action through Chrome debugger as we've done in the previous article. Just like in any stack-like data structure, call stack's frames are pushed or pop out of the stack, depending on a new function being executed or terminated. Whether you like it or not, you might already get to know the call stack, if you've ever written code that threw Maximum call stack size exceeded error, usually as a result of some form of an infinite loop.
What about the heap? Just like a real heap in real life, JS heap is a place where your objects from outside the local scope are stored. It's also much slower than the call stack. That's why you might see a performance difference when accessing a local variable vs the one from the upper scope. A heap is also a place for objects that aren't accessed or used, aka garbage. That's where the garbage collector comes into play. This part of JS runtime will activate whenever it feels it's necessary and will clean up your heap and free the memory.
Now that we know what the call stack and the heap is, it's time to discuss the event loop itself! You probably know that JS is a single-threaded language. Again, this is something that's not defined in the actual specs, rather than just an implementation detail. Historically, all JS implementations were single-threaded and that's how it is. If you know things like browser's Web Workers or Node.js child processes - they don't really make JS itself multi-thread! Both of these features indeed provide multi-threading capabilities, but both of them aren't parts of the JS itself, rather than Web APIs and Node.js runtime respectively.
With this out of the way, how does the event loop work? It's in fact extremely simple! JS never really waits for the return value of the function, rather than listens to incoming events. In this way, once JS detects a newly-emitted event, like e.g. user's click, it invokes the specified callback. Then, JS only waits for the synchronous code to finish execution, and all that repeats in the never-ending, non-blocking loop - event loop! Yup - that's greatly oversimplified, but's that's the basics!
A thing to note about the event loop is that synchronous and asynchronous code isn't treated equally. Instead, JS executes the synchronous code first and then checks the task queue for any async operations needed to be done. For an example of that, check the code below:
setTimeout(() => console.log("Second"), 0); console.log("First"); /* Console: > "First" > "Second" */
If you execute the above snippet of code, you should notice that even though the
setTimeout is first and it's timeout time is
0, it'll still be executed after the synchronous code.
If you work with async code, you most likely know what Promises are. A small detail to notice here is that promises are their own things and so, they have a special queue of their own - the microtask queue. The only important fact to remember here is that this microtask queue has priority over the usual task queue. Thus, if there's any promise awaiting in the queue, it'll be run before any other async operation e.g.
setTimeout(() => console.log("Third"), 0); Promise.resolve().then(() => console.log("Second")); console.log("First"); /* Console: > "First" > "Second" > "Third" */
A lot of knowledge!
As you can clearly see, even the basics can be... not so basic. Still, you shouldn't have much of a problem with understanding all of this! And even if, you don't have to know all of it to write great JS code! I think only the event loop stuff is mandatory. But, you know, the more the merrier!
So, what do you think of this post? Would you like to see some topics covered more in-depth? Let me know down in the comments and the reactions section below. If you like it, consider sharing it and following me on Twitter, on my Facebook page, or through my weekly newsletter. And - as always - have a great day!