Всё, что вы должны знать о прототипах, замыканиях и производительности

в 19:33, , рубрики: Без рубрики

Не всё так просто

На первый взгляд, JavaScript может показаться достаточно простым языком. Возможно, это из-за достаточно гибкого синтаксиса. Или из-за схожести с другими известными языками, например, с Java. Ну или из-за достаточно малого количества типов данных, по сравнению с Java, Ruby, или .NET.

Но в действительности, синтаксис 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); // выводит 'true'
console.log(p2 instanceof Person); // выводит 'true'
console.log(p1 === p2);            // выводит 'false'

Раз функции в JavaScript объекты, они могут иметь свойства. Наиболее важное из имеющихся у них свойств называется prototype.

prototype, представляющий собой объект, наследуется от родительского прототипа снова и снова, пока не доберется до самого верхнего уровня. Это часто называется цепочкой прототипов. В начале цепочки всегда Object.prototype (т. е. на самом верхнем уровне цепочки прототипов); он содержит методы toString(), hasProperty(), isPrototypeOf() и т. д.

Всё, что вы должны знать о прототипах, замыканиях и производительности

Прототип каждой функции может быть расширен собственными методами и свойствами.

При создании нового экземпляра объекта (вызывая функцию с оператором new), он наследует все свойства через прототип. Однако, имейти ввиду что экземпляры не имеют прямого доступа к объекту прототипа, только к его свойствам.

// Расширим прототип Person из примера выше
// методом 'getFullName':
Person.prototype.getFullName = function() {
  return this.firstName + ' ' + this.lastName;
}

// Объект p1 также из примера выше
console.log(p1.getFullName());            // выводит 'John Doe'
// но у p1 нет прямого доступа к объекту 'prototype'...
console.log(p1.prototype);                // выводит 'undefined'
console.log(p1.prototype.getFullName());  // выкидывает ошибку

Это важный и тонкий момент: даже если p1 был создан перед определением метода getFullName, он всё-равно будет иметь к нему доступ, потому что его прототипом был прототип Person.

(Стоит упомянуть что браузеры сохраняют ссылку на прототип в свойстве __proto__, но его использование очень портит карму, как минимум потому что его нет в стандарте ECMAScript, так что не надо его использовать).

Так как экземпляр Person p1 не имеет прямого доступа к объекту прототипа, мы должны перезаписывать метод getFullName в p1 вот так:

// Мы ссылаемся на p1.getFullName, *НЕ* p1.prototype.getFullName,
// ибо p1.prototype нет:

p1.getFullName = function(){
  return 'Я есъм аноним';
}

Теперь у p1 есть собственное свойство getFullName. Но у экземпляра p2 собственной реализации этого свойства нет. Соответственно, вызов p1.getFullName дергает собственный метод объекта p1, в то время как вызов p2.getFullName() идет вверх по цепочке прототипов до Person.

console.log(p1.getFullName()); // выводит 'Я есъм аноним'
console.log(p2.getFullName()); // выводит 'Robert Doe'

image

Ещё одна вещь, которой стоит опасаться, возможность динамически поменять прототип объекта:

function Parent() {
  this.someVar = 'someValue';
};

// расширяем прототип Parent, что бы определить метод 'sayHello'
Parent.prototype.sayHello = function(){
    console.log('Hello');
};

function Child(){
  // убеждаемся что родительский конструктор вызывается
  // и состояние корректно инициализируется.
  Parent.call(this);
};

// расширяем прототип Child что бы задать свойство 'otherVar'...
Child.prototype.otherVar = 'otherValue';

// ... но затем мы устанавливаем вместо прототипа Child прототип Parent
// (в нем нет никакого свойства 'otherVar',
//  поэтому и в прототипе Child свойства ‘otherVar’ больше не определено)
Child.prototype = Object.create(Parent.prototype);

var child = new Child();
child.sayHello();            // выводит 'Hello'
console.log(child.someVar);  // выводит 'someValue'
console.log(child.otherVar); // выводит 'undefined'

При использовании прототипного наследования, помните о том, что свойства прототипа нужно задавать после наследования от родительского объекта.

Всё, что вы должны знать о прототипах, замыканиях и производительности

Итак, получение свойств в цепочке прототипов работает следующим образом:

  • Если у объекта есть свойство с нужным именем, возвращается оно. (При помощи метода hasOwnProperty можно проверить что это именно собственное свойство объекта).
  • Если у объекта ничего похожего нет, смотрим в прототип объекта.
  • Если и тут ничего, то проверяем уже его собственный прототип.
  • И так далее, пока не найдем нужное свойство.
  • Если мы добрались до Object.prototype, но так ничего и не нашли, свойство считается не заданным.

Понимание того, как работает прототипное наследование в целом важно для разработчиков, но помимо этого оно имеет важное значение из-за своего влияния (порой заметного) на производительность. Как написано в документации к V8, большинство JavaScript движков использует для хранения свойств структуру данных, подобную словарю. Поэтому вызов любого свойства требует динамического поиска для нахождения нужного свойства. Такой способ делает доступ к свойствам в JavaScript гораздо медленнее чем доступ к переменным экземпляра на таких языках как Java или Smalltalk.

Поиск переменной через цепочку областей видимости

Другой механизм поиска в JavaScript основывается на замыкании.

Для понимания того как это работает, необходимо ввести такое понятие как контекст исполнения.

В JavaScript, два типа контекста исполнения:

  • Глобальный, создается при запуске JavaScript
  • Локальный, создается при вызове функции

Контексты исполнения организованы в виде стека. Внизу стека всегда глобальный контекст, уникальный для каждой программы. Каждый раз, когда встречается функция, создается новый контекст исполнения и помещается в начало стека. Как только выполнение функции завершено, её контекст выкидывается из стека.

// глобальный контекст
var message = 'Hello World';

var sayHello = function(n){
  // локальный контекст 1 создается и помещается в стек
  var i = 0;
  var innerSayHello = function() {
    // локальный контекст 2 создается и помещается в стек
    console.log((i + 1) + ':  ' + message);
    // локальный контекст 2 покидает стек
  }
  for (i = 0; i < n; i++) {
    innerSayHello();
  }
  // локальный контекст 1 покидает стек
};

sayHello(3);
// Выводит:
// 1:  Hello World
// 2:  Hello World
// 3:  Hello World

В каждом контексте исполнения есть специальный объект называемый цепочкой областей видимости, используемый для разрешения переменных. Цепочка по сути стек доступных контекстов исполнения, от текущего до глобального. (Если быть точнее, то объект на вершине стека называется Activation Object и содержит: ссылки на локальные переменные для исполняемой функции, заданные аргументы функции и два «особых» объекта: 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) {
    // Объект 'person' попадает в цепочку областей видимости
    // как только мы попадаем в блок "with", так что мы можем просто ссылаться на
    // 'firstName' и 'lastName', а не на person.firstName и
    // person.lastName
    if (!firstName) {
      throw new Error('FirstName is mandatory');
    }
    if (!lastName) {
      throw new Error('LastName is mandatory');
    }
  }
  try {
    person.save();
  } catch(error) {
    // Новая область видимости, содержащая объект 'error'
    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' всё ещё в области видимости!
console.log(i);  // выводит '10'

В большинстве других языков, код выше приведет к ошибке, потому что «жизнь» (т. е. область видимости) переменной i будет ограничена блоком for. Но не в JavaScript. i добавляется в Activation Object наверх цепочки областей видимости, и остается там до тех пор пока объект не будет удален, что происходит после удаления контекста исполнения из стека. Это поведение известно как всплытие переменных.

Стоит упомянуть, что поддержка области видимости на уровне блока появилось в JavaScript с появлением нового ключевого слова let. Оно уже доступно в JavaScript 1.7 и должно стать официально поддерживаемым ключевым словом начиная с ECMAScript 6.

Влияние на производительность

Способ поиска и разрешения переменных и свойств одна из ключевых особенностей 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');

В коде выше, у нас есть длинное дерево наследования и три вложенных цикла. В самом глубоком цикле инкрементируется счетчик значением переменной delta. Но значение дельты определяется на самом верху дерева наследования! Это значит, что каждый раз при вызове child.delta просматривается полностью всё дерево снизу доверху. Это может негативно сказываться на производительности.

Осознав это раз, мы можем с лёгкостью улучшить производительность nestedFn, закешировав локально значение child.delta в переменной 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 не будет меняться во время выполнения циклов; в противном случае мы должны будем периодически обновлять значение переменной актуальным значением.

Так, давайте запустим теперь обе версии nestedFn и посмотрим, есть ли заметная разница в производительности между ними.

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, для примера. Он поддерживает «селекторы», механизм получения одного или более элементов DOM. Лёгкость с которой это происходит «помогает» забыть насколько поиск по селектору тяжелая операция. Поэтому сохранение результатов поиска в переменной дает ощутимый прирост к производительности.

// это причина поиска по DOM селектора $('.container') "n" раз
for (var i = 0; i < n; i++) {
    $('.container').append(“Line “+i+”<br />”);
}

// и снова...
// ок, мы ищем селектор $('.container') всего раз,
// зато измываемся над его DOM "n" раз
var $container = $('.container');
for (var i = 0; i < n; i++) {
    $container.append("Line "+i+"<br />");
}

// гораздо лучше было бы вот так...
// так мы ищем по DOM селектор $('.container') один раз,
// И модифицируем DOM только один раз
var $html = '';
for (var i = 0; i < n; i++) {
    $html += 'Line ' + i + '<br />';
}
$('.container').append($html);

Второй подход даст значительно лучшую производительность, нежели первый, особенно на страницах с большим количеством элементов.

Подытожим

Поиск данных в JavaScript довольно сильно отличается от других языков, и в нём много нюансов. Посему, необходимо полностью и главное правильно понимать его концепции, дабы по-настоящему владеть этим языком. Эти знания принесут более чистый, более надежный код и улучшенную производительность.

Автор:

Источник



https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js