Цепочки прототипов JavaScript, цепочки областей видимости и производительность: что вам нужно знать
Опубликовано: 2022-03-11JavaScript: больше, чем кажется на первый взгляд
Поначалу JavaScript может показаться очень простым языком для изучения. Возможно, это из-за его гибкого синтаксиса. Или, возможно, это из-за его сходства с другими известными языками, такими как Java. Или, возможно, это потому, что в нем так мало типов данных по сравнению с такими языками, как Java, Ruby или .NET.
Но на самом деле JavaScript гораздо менее прост и более утончен, чем кажется большинству разработчиков. Даже у опытных разработчиков некоторые из наиболее важных функций JavaScript по-прежнему остаются непонятыми и приводят к путанице. Одной из таких особенностей является способ выполнения поиска данных (свойств и переменных) и влияние производительности JavaScript, о котором следует знать.
В JavaScript поиск данных управляется двумя вещами: прототипным наследованием и цепочкой областей видимости . Как разработчик, важно четко понимать эти два механизма, поскольку это может улучшить структуру, а часто и производительность вашего кода.
Поиск свойств через цепочку прототипов
При доступе к свойству на языке, основанном на прототипах, таком как JavaScript, происходит динамический поиск, который включает различные уровни внутри прототипного дерева объекта.
В JavaScript каждая функция является объектом. Когда функция вызывается с оператором new
, создается новый объект. Например:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');
В приведенном выше примере p1
и p2
— это два разных объекта, каждый из которых создан с использованием функции Person
в качестве конструктора. Они являются независимыми экземплярами Person
, как показано в этом фрагменте кода:
console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'
Поскольку функции JavaScript являются объектами, они могут иметь свойства. Особенно важное свойство, которым обладает каждая функция, называется prototype
.
prototype
, который сам является объектом, наследуется от прототипа своего родителя, который наследуется от прототипа своего родителя, и так далее. Это часто называют цепочкой прототипов . Object.prototype
, который всегда находится в конце цепочки прототипов (т. е. на вершине дерева наследования прототипов), содержит такие методы, как toString()
, hasProperty()
, isPrototypeOf()
и так далее.
Прототип каждой функции можно расширить, чтобы определить собственные пользовательские методы и свойства.
Когда вы создаете экземпляр объекта (вызывая функцию с помощью оператора new
), он наследует все свойства прототипа этой функции. Имейте в виду, однако, что эти экземпляры не будут иметь прямого доступа к объекту- prototype
, а только к его свойствам. Например:
// Extending the Person prototype from our earlier example to // also include a 'getFullName' method: Person.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName; } // Referencing the p1 object from our earlier example console.log(p1.getFullName()); // prints 'John Doe' // but p1 can't directly access the 'prototype' object... console.log(p1.prototype); // prints 'undefined' console.log(p1.prototype.getFullName()); // generates an error
Здесь есть важный и несколько тонкий момент: даже если p1
был создан до того, как был определен метод getFullName
, он все равно будет иметь к нему доступ, поскольку его прототипом является прототип Person
.
(Стоит отметить, что браузеры также хранят ссылку на прототип любого объекта в свойстве __proto__
, но прямой доступ к прототипу через свойство __proto__
является плохой практикой , так как это не является частью стандартной спецификации языка ECMAScript, поэтому не не делай этого! )
Поскольку экземпляр p1
объекта Person
сам по себе не имеет прямого доступа к объекту- prototype
, если мы хотим перезаписать getFullName
в p1
, мы должны сделать это следующим образом:
// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }
Теперь у p1
есть собственное свойство getFullName
. Но экземпляр p2
(созданный в нашем предыдущем примере) не имеет собственного такого свойства. Следовательно, p1.getFullName()
обращается к методу getFullName
самого экземпляра p1
, а p2.getFullName()
поднимается по цепочке прототипов к объекту прототипа Person
для разрешения getFullName
:
console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe'
Еще одна важная вещь, о которой следует помнить, это то, что также возможно динамически изменять прототип объекта. Например:
function Parent() { this.someVar = 'someValue'; }; // extend Parent's prototype to define a 'sayHello' method Parent.prototype.sayHello = function(){ console.log('Hello'); }; function Child(){ // this makes sure that the parent's constructor is called and that // any state is initialized correctly. Parent.call(this); }; // extend Child's prototype to define an 'otherVar' property... Child.prototype.otherVar = 'otherValue'; // ... but then set the Child's prototype to the Parent prototype // (whose prototype doesn't have any 'otherVar' property defined, // so the Child prototype no longer has 'otherVar' defined!) Child.prototype = Object.create(Parent.prototype); var child = new Child(); child.sayHello(); // prints 'Hello' console.log(child.someVar); // prints 'someValue' console.log(child.otherVar); // prints 'undefined'
При использовании прототипного наследования не забудьте определить свойства в прототипе после наследования от родительского класса или указания альтернативного прототипа.
Подводя итог, можно сказать, что поиск свойств в цепочке прототипов JavaScript работает следующим образом:
- Если у объекта есть свойство с заданным именем, возвращается это значение. (Метод
hasOwnProperty
можно использовать для проверки наличия у объекта определенного именованного свойства.) - Если у объекта нет именованного свойства, проверяется прототип объекта.
- Поскольку прототип также является объектом, если он также не содержит свойства, проверяется прототип его родителя.
- Этот процесс продолжается вверх по цепочке прототипов до тех пор, пока свойство не будет найдено.
- Если
Object.prototype
достигнут и у него тоже нет свойства, свойство считаетсяundefined
.
Понимание того, как работает прототипное наследование и поиск свойств, важно для разработчиков в целом, но также важно из-за его (иногда значительного) влияния на производительность JavaScript. Как упоминалось в документации для V8 (высокопроизводительный движок JavaScript с открытым исходным кодом Google), большинство движков JavaScript используют структуру данных, подобную словарю, для хранения свойств объекта. Таким образом, каждый доступ к свойству требует динамического поиска в этой структуре данных для разрешения свойства. Такой подход делает доступ к свойствам в JavaScript обычно намного медленнее, чем доступ к переменным экземпляра в таких языках программирования, как Java и Smalltalk.
Поиск переменных через цепочку областей видимости
Другой механизм поиска в JavaScript основан на области действия.
Чтобы понять, как это работает, необходимо ввести понятие контекста выполнения.
В JavaScript существует два типа контекстов выполнения:
- Глобальный контекст, создаваемый при запуске процесса JavaScript.
- Локальный контекст, создаваемый при вызове функции
Контексты выполнения организованы в стек. Внизу стека всегда находится глобальный контекст, уникальный для каждой программы JavaScript. Каждый раз, когда встречается функция, создается новый контекст выполнения, который помещается на вершину стека. После завершения выполнения функции ее контекст извлекается из стека.
Рассмотрим следующий код:
// global context var message = 'Hello World'; var sayHello = function(n){ // local context 1 created and pushed onto context stack var i = 0; var innerSayHello = function() { // local context 2 created and pushed onto context stack console.log((i + 1) + ': ' + message); // local context 2 popped off of context stack } for (i = 0; i < n; i++) { innerSayHello(); } // local context 1 popped off of context stack }; sayHello(3); // Prints: // 1: Hello World // 2: Hello World // 3: Hello World
В каждом контексте выполнения есть специальный объект, называемый цепочкой областей видимости, который используется для разрешения переменных. Цепочка областей видимости — это, по сути, стек доступных в данный момент областей видимости, от самого непосредственного контекста до глобального контекста. (Чтобы быть более точным, объект наверху стека называется объектом активации , который содержит ссылки на локальные переменные для выполняемой функции, именованные аргументы функции и два «специальных» объекта: this
и arguments
. ) Например:

Обратите внимание на приведенную выше диаграмму, как this
указывает на объект window
по умолчанию, а также как глобальный контекст содержит примеры других объектов, таких как console
и location
.
При попытке разрешить переменные через цепочку областей видимости непосредственный контекст сначала проверяется на наличие соответствующей переменной. Если совпадений не найдено, проверяется следующий объект контекста в цепочке областей видимости и так далее, пока не будет найдено совпадение. Если совпадений не найдено, ReferenceError
.
Важно также отметить, что новая область видимости добавляется в цепочку областей действия, когда встречается блок try-catch
или блок with
. В любом из этих случаев создается новый объект и помещается в начало цепочки областей видимости:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; }; function persist(person) { with (person) { // The 'person' object was pushed onto the scope chain when we // entered this "with" block, so we can simply reference // 'firstName' and 'lastName', rather than person.firstName and // person.lastName if (!firstName) { throw new Error('FirstName is mandatory'); } if (!lastName) { throw new Error('LastName is mandatory'); } } try { person.save(); } catch(error) { // A new scope containing the 'error' object is accessible here console.log('Impossible to store ' + person + ', Reason: ' + error); } } var p1 = new Person('John', 'Doe'); persist(p1);
Чтобы полностью понять, как происходит поиск переменных на основе области видимости, важно помнить, что в настоящее время в JavaScript нет областей видимости на уровне блоков. Например:
for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'
В большинстве других языков приведенный выше код привел бы к ошибке, поскольку «жизнь» (т. е. область действия) переменной i
была бы ограничена блоком for. Однако в JavaScript это не так. Вместо этого i
добавляется к объекту активации в верхней части цепочки областей видимости и остается там до тех пор, пока этот объект не будет удален из области видимости, что происходит, когда соответствующий контекст выполнения удаляется из стека. Такое поведение известно как подъем переменной.
Однако стоит отметить, что поддержка блочных областей видимости проникает в JavaScript с помощью нового ключевого слова let
. Ключевое слово let
уже доступно в JavaScript 1.7 и должно стать официально поддерживаемым ключевым словом JavaScript, начиная с ECMAScript 6.
Разветвления производительности JavaScript
То, как поиск свойств и переменных с использованием цепочки прототипов и цепочки областей действия соответственно работает в JavaScript, является одной из ключевых особенностей языка, но при этом одной из самых сложных и тонких для понимания.
Операции поиска, которые мы описали в этом примере, независимо от того, основаны ли они на цепочке прототипов или цепочке областей видимости, повторяются каждый раз при доступе к свойству или переменной. Когда этот поиск происходит в циклах или других интенсивных операциях, он может иметь значительные последствия для производительности JavaScript, особенно в свете однопоточной природы языка, которая предотвращает одновременное выполнение нескольких операций.
Рассмотрим следующий пример:
var start = new Date().getTime(); function Parent() { this.delta = 10; }; function ChildA(){}; ChildA.prototype = new Parent(); function ChildB(){} ChildB.prototype = new ChildA(); function ChildC(){} ChildC.prototype = new ChildB(); function ChildD(){}; ChildD.prototype = new ChildC(); function ChildE(){}; ChildE.prototype = new ChildD(); function nestedFn() { var child = new ChildE(); var counter = 0; for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += child.delta; } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');
В этом примере у нас есть длинное дерево наследования и три вложенных цикла. Внутри самого глубокого цикла переменная counter увеличивается на значение delta
. Но delta
находится почти на вершине дерева наследования! Это означает, что при каждом доступе к child.delta
нужно перемещаться по всему дереву снизу вверх. Это может очень негативно сказаться на производительности.
Понимая это, мы можем легко улучшить производительность приведенной выше функции nestedFn
, используя локальную delta
-переменную для кэширования значения в child.delta
(и тем самым избежать необходимости повторного обхода всего дерева наследования) следующим образом:
function nestedFn() { var child = new ChildE(); var counter = 0; var delta = child.delta; // cache child.delta value in current scope for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += delta; // no inheritance tree traversal needed! } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');
Конечно, этот конкретный метод применим только в сценарии, где известно, что значение child.delta
не изменится во время выполнения цикла for; в противном случае локальная копия должна быть обновлена текущим значением.
Хорошо, давайте запустим обе версии метода nestedFn
и посмотрим, есть ли какая-либо заметная разница в производительности между ними.
Мы начнем с запуска первого примера в REPL node.js:
diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
Таким образом, для запуска требуется около 8 секунд. Это долго.
Теперь давайте посмотрим, что произойдет, когда мы запустим оптимизированную версию:
diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds
На этот раз это заняло всего одну секунду. Намного быстрее!
Обратите внимание, что использование локальных переменных во избежание дорогостоящих поисков — это метод, который можно применять как для поиска свойств (через цепочку прототипов), так и для поиска переменных (через цепочку областей видимости).
Более того, этот тип «кеширования» значений (т. е. в переменных в локальной области видимости) также может быть полезен при использовании некоторых наиболее распространенных библиотек JavaScript. Возьмем, к примеру, jQuery. jQuery поддерживает понятие «селекторов», которые в основном представляют собой механизм для извлечения одного или нескольких совпадающих элементов в DOM. Легкость, с которой можно указать селекторы в jQuery, может заставить забыть, насколько затратным (с точки зрения производительности) может быть каждый поиск селектора. Соответственно, сохранение результатов поиска селектора в локальной переменной может быть чрезвычайно полезным для производительности. Например:
// this does the DOM search for $('.container') "n" times for (var i = 0; i < n; i++) { $('.container').append(“Line “+i+”<br />”); } // this accomplishes the same thing... // but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"<br />"); } // or even better yet... // this version only does the DOM search for $('.container') once // AND only modifies the DOM once var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '<br />'; } $('.container').append($html);
Особенно на веб-странице с большим количеством элементов второй подход в приведенном выше примере кода потенциально может привести к значительно более высокой производительности, чем первый.
Заворачивать
Поиск данных в JavaScript сильно отличается от того, что есть в большинстве других языков, и имеет множество нюансов. Поэтому важно полностью и правильно понимать эти концепции, чтобы по-настоящему овладеть языком. По возможности следует избегать поиска данных и других распространенных ошибок JavaScript. Это понимание, вероятно, приведет к более чистому и надежному коду, обеспечивающему улучшенную производительность JavaScript.