Deep JS. Scopes of darkness or where variables live
In the article, Deep JS. In memory of data and types, we talked about what the structure of a variable of each specific type looks like in the memory of the V8 engine. In this article, I now propose to consider exactly where these variables are stored and how they get into memory.
As usual, we will investigate the latest version of the engine (12.2.136) at the time of writing.
Table of contents
Abstract Syntax Tree (AST)
Before we go directly to the variables, it's worth saying a few words about where V8 gets them from in general. After all, JavaScript code, like any other program code, is just a text that is convenient for human perception. Which is parsed and converted into machine code (understandable already directly to the executable environment, and not to a person).
Traditionally, programming languages parse the text of the program code and decompose it into a structure called an Abstract Syntax Tree or AST. The V8 developers did not reinvent the wheel here and followed the same proven path.
After receiving a file or string as input, the engine parses the text and lays out the instructions in the AST tree.
For example, the code for Euclidean algorithm
while (b !== 0) if (a > b) a = a - b else b = b - a;
In pasred mode it will look like this
%> v8-debug --print-ast test.js [generating bytecode for function: ] --- AST --- FUNC at 0 . KIND 0 . LITERAL ID 0 . SUSPEND COUNT 0 . NAME "" . INFERRED NAME "" . BLOCK at -1 . . EXPRESSION STATEMENT at -1 . . . ASSIGN at -1 . . . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result" . . . . LITERAL undefined . . WHILE at 0 . . . COND at 9 . . . . NOT at 9 . . . . . EQ_STRICT at 9 . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b" . . . . . . LITERAL 0 . . . BODY at 18 . . . . IF at 18 . . . . . CONDITION at 24 . . . . . . GT at 24 . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a" . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b" . . . . . THEN at 29 . . . . . . EXPRESSION STATEMENT at 29 . . . . . . . ASSIGN at -1 . . . . . . . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result" . . . . . . . . ASSIGN at 31 . . . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a" . . . . . . . . . SUB at 35 . . . . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a" . . . . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b" . . . . . ELSE at 46 . . . . . . EXPRESSION STATEMENT at 46 . . . . . . . ASSIGN at -1 . . . . . . . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result" . . . . . . . . ASSIGN at 48 . . . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b" . . . . . . . . . SUB at 52 . . . . . . . . . . VAR PROXY unallocated (0x7fe71480bbd0) (mode = DYNAMIC_GLOBAL, assigned = true) "b" . . . . . . . . . . VAR PROXY unallocated (0x7fe71480bc00) (mode = DYNAMIC_GLOBAL, assigned = true) "a" . RETURN at -1 . . VAR PROXY local[0] (0x7fe71480ba60) (mode = TEMPORARY, assigned = true) ".result"
Here we see the parent nodes (tree vertices), which represent operators, and the end nodes (tree leaves), which represent variables.
Already at this stage, you can notice that the variables have been declared, but the memory for them has not yet been allocated. For each such variable, a certain VariableProxy node is created in the ASD, which will represent a specific variable in memory. Moreover, several such Variable Proxies can refer to one variable at once. The fact is that the memory allocation process will take place later and in another place, in Scope (more on this below), and VariableProxy is a kind of placeholder link. The ASD never directly accesses variables, only via VariableProxy.
VariableMode
Now let's look at what types of variables there are in V8. Conditionally, all variables can be divided into three groups
User variables
Variables that the user can declare explicitly (or implicitly). There are only three of them
- kLet - declared via 'let' declarations (first lexical)
- kConst - declared via 'const' declarations (last lexical)
- kVar - declared via 'var', and 'function' declarations
Compiler variables
К ним относят внутренние временные переменные и динамические - переменные, не объявленные явным образом
- kTemporary - not user-visible, live in a stack
- kDynamic - declaration is unknown, always require dynamic lookup
- kDynamicGlobal - declaration is unknown, requires dynamic lookup, but we know that the variable is global unless it has been shadowed by an eval-introduced variable
- kDynamicLocal - declaration is unknown, requires dynamic lookup, but we know that the variable is local and where it is unless it has been shadowed by an eval-introduced variable
a = "a"; // creates variable DYNAMIC_GLOBAL a;
Class private variables
Variables for private class methods and accessors. They require access check and live in the context of a class.
- kPrivateMethod - does not coexist with any other variable with the same name in the same scope
- kPrivateSetterOnly - does not coexist with any other variable with the same name in the same scope other than kPrivateGetterOnly
- kPrivateGetterOnly - does not coexist with any other variable with the same name in the same scope other than kPrivateSetterOnly
- kPrivateGetterAndSetter - if both kPrivateSetterOnly and kPrivateGetterOnly variables with the same name exist they are being transitioned to a single variable with this type
// The order of this enum has to be kept in sync with the predicates below. enum class VariableMode : uint8_t { // User declared variables: kLet, // declared via 'let' declarations (first lexical) kConst, // declared via 'const' declarations (last lexical) kVar, // declared via 'var', and 'function' declarations // Variables introduced by the compiler: kTemporary, // temporary variables (not user-visible), stack-allocated // unless the scope as a whole has forced context allocation kDynamic, // always require dynamic lookup (we don't know // the declaration) kDynamicGlobal, // requires dynamic lookup, but we know that the // variable is global unless it has been shadowed // by an eval-introduced variable kDynamicLocal, // requires dynamic lookup, but we know that the // variable is local and where it is unless it // has been shadowed by an eval-introduced // variable // Variables for private methods or accessors whose access require // brand check. Declared only in class scopes by the compiler // and allocated only in class contexts: kPrivateMethod, // Does not coexist with any other variable with the same // name in the same scope. kPrivateSetterOnly, // Incompatible with variables with the same name but // any mode other than kPrivateGetterOnly. Transition to // kPrivateGetterAndSetter if a later declaration for the // same name with kPrivateGetterOnly is made. kPrivateGetterOnly, // Incompatible with variables with the same name but // any mode other than kPrivateSetterOnly. Transition to // kPrivateGetterAndSetter if a later declaration for the // same name with kPrivateSetterOnly is made. kPrivateGetterAndSetter, // Does not coexist with any other variable with the // same name in the same scope. kLastLexicalVariableMode = kConst, };
Isolate
Another important aspect of V8 is Isolate. Isolate is an abstraction that represents an isolated instance of the engine. This is where the state of the engine will be stored. Anything inside a particular Isolate cannot be used in another Isolate. Isolate itself is not thread-safe. I.e., only one thread can access it at a time. To organize multithreading on the Embedder side, such as a browser, the V8 team suggests using the Locker/Unlocker API. As an example of Isolate, you can take, for example, a browser tab or a Worker.
Scope
In the ECMAScript specification, the concept of scope is somewhat vague, but we know that variables are always allocated in one of these areas. In V8, this area is called Scope. In total, at the moment, there are 9 proposed
- CLASS_SCOPE
- EVAL_SCOPE
- FUNCTION_SCOPE
- MODULE_SCOPE
- SCRIPT_SCOPE
- CATCH_SCOPE
- BLOCK_SCOPE
- WITH_SCOPE
- SHADOW_REALM_SCOPE
enum ScopeType : uint8_t { CLASS_SCOPE, // The scope introduced by a class. EVAL_SCOPE, // The top-level scope for an eval source. FUNCTION_SCOPE, // The top-level scope for a function. MODULE_SCOPE, // The scope introduced by a module literal SCRIPT_SCOPE, // The top-level scope for a script or a top-level eval. CATCH_SCOPE, // The scope introduced by catch BLOCK_SCOPE, // The scope introduced by a new block. WITH_SCOPE, // The scope introduced by with. SHADOW_REALM_SCOPE // Synthetic scope for ShadowRealm NativeContexts. };
In addition to these nine types, there is another one - Global Scope, which exists at the top level of Isolate and stores all other declarations. It is this viewport that, for example, the global Window object in the browser will refer to.
So where are the boundaries of a particular scope really? To understand this, let's look at each scope separately.
CLASS_SCOPE
It is clear from the name that we are talking about classes, its properties and methods
class A extends B { prop1 = "prop1"; method1() {} }
In the case of classes, the scope starts with the keyword class
and ends with the symbol }
.
/* start position -> */class A extends B { body }/* <- end position */
That is, the following is stored into the class scope:
Let's see what the Scope of a simple class looks like
class A {}
%> v8-debug --print-scopes test.js Global scope: global { // (0x7f7b0a80c630) (0, 1371) // will be compiled // NormalFunction // 1 stack slots // 3 heap slots // temporary vars: TEMPORARY .result; // (0x7f7b0a80cec0) local[0] // local vars: LET A; // (0x7f7b0a80cde0) context[2] class A { // (0x7f7b0a80c820) (0, 10) // strict mode scope // 2 heap slots // class var, unused, index not saved: CONST A; // (0x7f7b0a80ca40) function () { // (0x7f7b0a80ca88) (0, 0) // strict mode scope // DefaultBaseConstructor } } }
Here we see that the reference to the class is defined by a variable of type LET. In our case, the link is declared in the Global Scope. Inside CLASS_SCOPE, we see the class constant CONST A
and the base constructor.
class A { method1 () {} }
%> v8-debug --print-scopes test.js Inner function scope: function method1 () { // (0x7fcf8c80f250) (19, 24) // strict mode scope // ConciseMethod // 2 heap slots } Global scope: global { // (0x7fcf8c80ee30) (0, 1387) // will be compiled // NormalFunction // 1 stack slots // 3 heap slots // temporary vars: TEMPORARY .result; // (0x7fcf8c80f8a8) local[0] // local vars: LET A; // (0x7fcf8c80f7c8) context[2] class A { // (0x7fcf8c80f020) (0, 26) // strict mode scope // 2 heap slots // class var, unused, index not saved: CONST A; // (0x7fcf8c80f428) function () { // (0x7fcf8c80f470) (0, 0) // strict mode scope // DefaultBaseConstructor } function method1 () { // (0x7fcf8c80f250) (19, 24) // strict mode scope // lazily parsed // ConciseMethod // 2 heap slots } } }
Here we can see a link to the method1
function inside CLASS_SCOPE, as well as, separately, the FUNCTION_SCOPE of this function (about FUNCTION_SCOPE below).
Now let's try to add a class property
class A { prop1 = "prop1" }
%> v8-debug --print-scopes test.js Global scope: global { // (0x7fa78502c230) (0, 1390) // will be compiled // NormalFunction // 1 stack slots // 3 heap slots // temporary vars: TEMPORARY .result; // (0x7fa78502cde8) local[0] // local vars: LET A; // (0x7fa78502cd08) context[2] class A { // (0x7fa78502c420) (0, 29) // strict mode scope // 2 heap slots // class var, unused, index not saved: CONST A; // (0x7fa78502c8e0) function () { // (0x7fa78502c928) (0, 0) // strict mode scope // DefaultBaseConstructor } function A () { // (0x7fa78502c650) (8, 29) // strict mode scope // will be compiled // ClassMembersInitializerFunction } } }
Strangely enough, we do not see the prop1
method here. Instead, the function A ()
appeared in the class area. This is due to the fact that class methods can have different access levels, in particular, they can be private, which requires checking rights when accessing them. The V8 engine has an appropriate mechanism for determining access rights to class properties, which is implemented through a special function like kClassMembersInitializerFunction. In general, there are many types of functions in V8, as many as 27 of them, but more on that next time.
EVAL_SCOPE
This scope is created by calling the eval function
eval("var a = 'a'")
%> v8-debug --print-scopes test.js Global scope: global { // (0x7fc5e1838230) (0, 1380) // inner scope calls 'eval' // will be compiled // NormalFunction // 1 stack slots // temporary vars: TEMPORARY .result; // (0x7fc5e18384e0) local[0] // dynamic vars: DYNAMIC_GLOBAL eval; // (0x7fc5e18385a0) never assigned } Global scope: eval { // (0x7fc5e1838420) (0, 11) // will be compiled // NormalFunction // temporary vars: TEMPORARY .result; // (0x7fc5e1838700) // dynamic vars: DYNAMIC a; // (0x7fc5e1838610) lookup, never assigned }
Actually, EVAL_SCOPE is not much different from Global Scope, except that the variables inside eval are often dynamic (requiring constant search in memory) because the scope of their declaration is unknown in advance.
FUNCTION_SCOPE
We have already encountered the scope of the function when we considered CLASS_SCOPE.
function fun/* start postion ->*/(a,b) { stmts }/* <- end position */
For a function, the scope starts with the first parenthesis and ends with the last curly
function fun(a) { var b = "b"; }
%> v8-debug --print-scopes test.js Inner function scope: function fun () { // (0x7f881c03c220) (12, 34) // NormalFunction // 2 heap slots // local vars: VAR a; // (0x7f881c03e648) never assigned VAR b; // (0x7f881c03e690) never assigned } Global scope: global { // (0x7f881c03c030) (0, 1395) // will be compiled // NormalFunction // local vars: VAR fun; // (0x7f881c03c3e0) function fun () { // (0x7f881c03c220) (12, 34) // lazily parsed // NormalFunction // 2 heap slots } }
In the Global Scope, only the reference to the function (type VAR) will be saved, and the entire functional scope will be dedicated in FUNCTION_SCOPE, where we see two variables: a
- the argument of the function and b
- the internal permanent function.
The similar picture with arrow functions
var fun = (a) => { var b = "b"; }
%> v8-debug --print-scopes test.js Inner function scope: arrow (a) { // (0x7fec1e821098) (10, 35) // ArrowFunction // 2 heap slots // local vars: VAR a; // (0x7fec1e821270) never assigned VAR b; // (0x7fec1e822f08) never assigned } Global scope: global { // (0x7fec1e820e30) (0, 1396) // will be compiled // NormalFunction // 1 stack slots // temporary vars: TEMPORARY .result; // (0x7fec1e821410) local[0] // local vars: VAR fun; // (0x7fec1e821050) arrow () { // (0x7fec1e821098) (10, 35) // lazily parsed // ArrowFunction // 2 heap slots } }
The type of the function, in this case, will be kArrowFunction, however, the scope does not differ from the usual kNormalFunction function.
It is worth noting that, despite the fact that arrow functions do not have their own context, the argument a
and the internal variable b
are declared in the internal scope, just like regular functions. I.e., they cannot be accessed from the scope above.
var fun = (a) => { var b = "b"; } console.log(this.a); // <- undefined console.log(this.b); // <- undefined
MODULE_SCOPE
To declare a module, it is enough to specify the .mjs
extension of the script file.
// test.mjs var a = "a"
%> v8-debug --print-scopes test.mjs Global scope: module { // (0x7f793d00c820) (0, 1080) // strict mode scope // will be compiled // Module // 3 stack slots // 3 heap slots // temporary vars: TEMPORARY .generator_object; // (0x7f793d00cab8) local[0], never assigned TEMPORARY .result; // (0x7f793d00cc58) local[2] // local vars: VAR a; // (0x7f793d00cb60) local[1] }
The module has a number of useful properties and features, but its Scope, in essence, does not differ from the usual Global Scope. Unless, here you can find a system (hidden) variable .generator_object
, which stores the JSGeneratorObject
object for generators. It can also be found in asynchronous functions and REPL scripts.
SCRIPT_SCOPE
The script area. There are different types of scripts, for example, a script tag or a REPL script in Node.js
Consider the classic script tag
<script> var a = "a"; let b = "a"; </script>
Tag parsing lies outside of V8 (the browser does this before building the DOMTree), so talking about the beginning and end of the script area is not entirely correct. The browser passes the script body to the engine in the form of a string, which, in turn, will be placed in the SCRIPT_SCOPE area.
In the example above, the variable a
will be declared in the Global Scope (according to the rules of VAR hoisting), and b
will remain visible only within this script.
CATCH_SCOPE
A separate Scope type has been allocated specifically for the try ...catch
structure. More precisely, for the catch(e) {}
block.
try { stms } catch /* start position -> */(e)/* <- end position */ { stmts }
Such a scope begins with an opening parenthesis after the catch
keyword and ends with a closing parenthesis. This scope has only one purpose - to store a reference to a variable containing an error.
try { var a = "a"; } catch (e) { var b = "b"; }
%> v8-debug --print-scopes test.js Global scope: global { // (0x7f8207010830) (0, 1412) // will be compiled // NormalFunction // 1 stack slots // temporary vars: TEMPORARY .result; // (0x7f8207011200) local[0] // local vars: VAR a; // (0x7f8207010bc0) VAR b; // (0x7f82070110b8) catch { // (0x7f8207010c58) (29, 51) // 3 heap slots // local vars: VAR e; // (0x7f8207010ee8) context[2], never assigned } }
In this example, we see that variables a
and b
are in the Global Scope, while there is nothing in CATCH_SCOPE except e
. Since the try {}
and catch{}
structures are nothing but blocks, which means that the block visibility rule applies to them.
BLOCK_SCOPE
It is with the block scope that other types of Scope are often confused. According to the specification, as I said, the visibility rule applies to the block scope:
- Variables of the VAR type pop up in the higher Scope
- Variables of type LET and CONST remain inside BLOCK_SCOPE
/* start postion -> */{ stmts }/* <- end position */
The scope begins with an opening curly brace and ends with a closing one.
{ var a = "a"; let b = "b"; }
%> v8-debug --print-scopes test.js Global scope: global { // (0x7fb799835e30) (0, 1411) // will be compiled // NormalFunction // 3 stack slots // temporary vars: TEMPORARY .result; // (0x7fb799836448) local[0] // local vars: VAR a; // (0x7fb7998361c0) block { // (0x7fb799836020) (0, 50) // local vars: CONST c; // (0x7fb799836340) local[2], never assigned, hole initialization elided LET b; // (0x7fb799836280) local[1], never assigned, hole initialization elided } }
In this example, the variable a
hoisted into the Global Scope because it was declared with the VAR type, and the variables b
and c
remained inside BLOCK_SCOPE.
The expression for (let x ...) stmt
also applies to block structures
for /* start position -> */(let x ...) stmt/* <- end position */
The beginning of the scope will be the first opening parenthesis, the end will be the last stmt
token
for (let i = 0; i < 2; i++) { var a = "a"; let b = "b"; }
%> v8-debug --print-scopes test.js Global scope: global { // (0x7fcdfd010430) (0, 1510) // will be compiled // NormalFunction // 3 stack slots // temporary vars: TEMPORARY .result; // (0x7fcdfd010ef0) local[0] // local vars: VAR a; // (0x7fcdfd010d00) block { // (0x7fcdfd010770) (4, 61) // local vars: LET i; // (0x7fcdfd0108e8) local[1], hole initialization elided block { // (0x7fcdfd010b60) (28, 61) // local vars: LET b; // (0x7fcdfd010dc0) local[2], never assigned, hole initialization elided } } }
Here we see two BLOCK_SCOPES, the first area stores the loop variable i
, and the nested scope provides block visibility of the loop body.
One more block structure switch (tag) { cases }
switch (tag) /* start position -> */{ cases }/* <- end postion */
The beginning of the scope is the first opening curly brace, the end is the last closing curly brace.
var a = ""; switch (a) { default: let b = "b"; break; }
%> v8-debug --print-scopes test.js Global scope: global { // (0x7fd4a1033230) (0, 1590) // will be compiled // NormalFunction // 3 stack slots // temporary vars: TEMPORARY .switch_tag; // (0x7fd4a10337a8) local[0] TEMPORARY .result; // (0x7fd4a10338e8) local[1] // local vars: VAR a; // (0x7fd4a1033450) block { // (0x7fd4a1033538) (13, 66) // local vars: LET b; // (0x7fd4a10336b0) local[2], never assigned, hole initialization elided } }
Here, the variable b
is inside the operator brackets of the switch block, so it is declared inside this scope.
WITH_SCOPE
In practice, the structure with (obj) stmt
does not occur often, but I can't skip it, since it also has its own Scope type.
with (obj) stmt
The beginning of the scope is the first stmt
token, the end is the last stmt
token.
var obj = { prop1: "prop1" }; with (obj) prop1 = "prop2"; console.log(obj.prop1); // <- "prop2"
%> v8-debug --print-scopes test.js Global scope: global { // (0x7fea4480ee30) (0, 1447) // will be compiled // NormalFunction // 1 stack slots // temporary vars: TEMPORARY .result; // (0x7fea4480f650) local[0] // local vars: VAR obj; // (0x7fea4480f050) // dynamic vars: DYNAMIC_GLOBAL console; // (0x7fea4480f730) never assigned with { // (0x7fea4480f370) (46, 62) // 3 heap slots // dynamic vars: DYNAMIC prop1; // (0x7fea4480f790) lookup } }
Here we see that the prop1
variable (which, in fact, is a property of the obj
object) was declared in WITH_SCOPE as dynamic (dynamic, since its declaration was made without the keyword var
, let
or const
).
SHADOW_REALM_SCOPE
The scope of the so-called ShadowRealm. The feature was proposed in 2022 and is still in experimental status.
The main motivation is to be able to create multiple, completely independent isolated global objects. In other words, to be able to dynamically create Realms. Previously, this feature was available only to "embedders", for example, browser manufacturers, through the API of the engine. Now it is proposed to give this opportunity to JS developers.
// test.mjs import { myRealmFunction } from "./realm.mjs"; var realm = new ShadowRealm(); realm.importValue("realm.mjs", "myRealmFunction").then((myRealmFunction) => {});
// realm.mjs export function myRealmFunction() {}
A flag --harmony-shadow-realm
is required to activate the feature
%> v8-debug --print-scopes --harmony-shadow-realm test.mjs V8 is running with experimental features enabled. Stability and security will suffer. Global scope: module { // (0x7faddd810c20) (0, 1231) // strict mode scope // will be compiled // Module // 3 stack slots // 3 heap slots // temporary vars: TEMPORARY .generator_object; // (0x7faddd810eb8) local[0], never assigned TEMPORARY .result; // (0x7faddd811558) local[2] // local vars: CONST myRealmFunction; // (0x7faddd810f60) module, never assigned VAR realm; // (0x7faddd811090) local[1] arrow (myRealmFunction) { // (0x7faddd811218) (135, 158) // strict mode scope // ArrowFunction // local vars: VAR myRealmFunction; // (0x7faddd8113f0) parameter[0], never assigned } } Inner function scope: function myRealmFunction () { // (0x7faddd811f38) (31, 36) // strict mode scope // NormalFunction // 2 heap slots } Global scope: module { // (0x7faddd811c20) (0, 37) // strict mode scope // will be compiled // Module // 2 stack slots // 3 heap slots // temporary vars: TEMPORARY .generator_object; // (0x7faddd811eb8) local[0], never assigned TEMPORARY .result; // (0x7faddd812210) local[1] // local vars: LET myRealmFunction; // (0x7faddd8120f8) module function myRealmFunction () { // (0x7faddd811f38) (31, 36) // strict mode scope // lazily parsed // NormalFunction // 2 heap slots } }
Scope for ShadowRealm so far looks like a regular MODULE_SCOPE, which is logical, since the feature only works with modules. Therefore, it is premature to talk about what the scope for Realm will look like in the final version.
Allocate
After declaring variables in Scope, the memory allocation stage begins. This happens at the moment when we assign a value to a variable. We know from the specification that there are two abstract stores of values Stack and Heap.
Heap is actually associated with a specific execution context. The following get here:
- variables that might be accessed from the internal Scope
- there is a possibility that the variable might be accessed from the current or internal Scope (through an
eval
or a runtime with lookup)
- variables in CATCH_SCOPE
- in SCRIPT_SCOPE and EVAL_SCOPE all variables of types kLet and kConst
- unallocated variables
- variables, requiring lookup (all dynamic types)
- variables within a module
In the article, we examined the fundamental data structure in the V8 engine. The article turned out to be voluminous, but hopefully useful.
EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru
Русская версия: https://blog.frontend-almanac.ru/UH_MQVhvQ7t