Как стать автором
Обновить

JavaScript. Оптимизация: опыт, проверенный временем

Время на прочтение10 мин
Количество просмотров42K

Предисловие


Давно хотел написать. Мысли есть, желание есть, времени нету… Но вот нашлось, так что привет, Хабра.
Здесь я собрал все идеи, которые помогали и помогают в разработке веб-приложений. Для удобства я разбил их на группы:
  1. Память
  2. Оптимизация операций
  3. Выделение критических участков
  4. Циклы и объектные свойства
  5. Немножко о DOM
  6. DocumentFragment как промежуточный буфер
  7. О преобразованиях в объекты
  8. Разбитие кода
  9. События перетаскивания
  10. Другие советы

Сейчас речь не пойдёт ни о каких библиотеках. Я постараюсь передать знания о механизмах самого языка, а не их реализациях в библиотеках.

Память

Хоть это и не должно волновать клиентского программиста, но не забываем, что память всё-таки не бесконечна и когда-нибудь может закончиться, например, когда запущено несколько массивных программ: офис, графический редактор, компиляция большой программы и др. Несмотря на то, что приведенный пример тривиален, у меня действительно такое случилось, хоть и не из-за браузера, но он тоже сыграл свою роль: 1,3 Гб оперативы (отладчик, около 30 вкладок), начались тормоза по перегрузке страниц ОП в файл подкачки.
Чтобы уменьшить расход памяти, я предлагаю несколько способов:

1) уменьшение числа локальных переменных.
Вы спросите, что это значит? Объясняю, на своей практике видел, как «code monkey»-студенты писали подобный код:

(function init(){ //здесь и далее я буду использовать функции-обёртки
	for(var i=0,n=1;i<10;i++) //суть не в действии внутри цикла
		n+=n;
	alert(n); //выводим результат
	for(var i=0,m=1;i<10;i++)
		m*=m;
	alert(m);
})();


Может быть, вы сразу и не видите, в чем фишка, но: зачем создавать новые переменные, если у нас есть использованные и уже хранящие в себе совершенно ненужные значения старые переменные? В данном примере для нормального решения необходимо заменить все m на n, что сэкономит память.
Этот метод лучше всего проявляет себя в рекурсивных функциях, потому что каждый вызов такой функции провоцирует создание и, обратите внимание, удаление локальных переменных после завершения работы функции, что тоже требует процессорного времени и памяти.
Для наглядного восприятия можно привести аналогию со шкафчиками: у вас есть 6 шкафчиков, три из которых могут быть заполнены; зачем тогда вам ещё три шкафчика, если в таком случае вам придётся открывать все 6, а потом и закрывать все 6?

2) уменьшение числа замыканий.
Замыкания вызывают ощутимый расход памяти (3 Мб на 1000 объектов для хрома, возможно, в новых версиях другой объем), поэтому используйте их как можно реже. Я использую их в двух случаях:
  1. Необходимо скрыть данные внутри некоторого интерфейса, не дать доступа извне;
  2. При рекурсии, когда нужно сделать какие-то пометки в одном общем объекте (например, при обходе HTML занести в массив все узлы, которые имеют пользовательское свойство «dragndrop») в том случае, если выборка по селекторам не подходит.

Оба случая подразумевают какие-то частные, уникальные, случаи. Имеется в виду, что создаются единичные интерфейсы.
Пример первого случая:
(function init(){
    var INTERNAL_NUMBER=0;//замкнутая переменная
    return {
        get:function get(){return INTERNAL_NUMBER;},//функция, возвращающая значенеи замкнутой переменной
        set:function set(value){ //функция, фильтрующая передаваемые значения и устанавливающая замкнутую переменную
            if(typeof value==”number”)
                INTERNAL_NUMBER=value;
            return INTERNAL_NUMBER;
        }
    }
})();


Вот таким образом я и создаю ЕДИНИЧНЫЕ интерфейсы.
Если с первым уже, я думаю, всё понятно, то второй случай следует пояснить: фактически он относится к предыдущему пункту, потому что мы заменяем переменную, которую можно было передать в качестве аргумента в функцию, замыканием, тем самым уменьшая количество локальных переменных внутри этой функции и в то же время соблюдаем принцип минимума замыканий: это замыкание характерно только для это рекурсивной функции (хотя, это уже как вы захотите), ведь при рекурсии используется одна и та же функция (новые замыкания не создаются).
Пример:

(function init(){
    var found=[];
    (function traverse(html){
    for(var i=html.firstChild;i;i=i.nextSibling)
        arguments.callee(i);
    if(typeof html.dragndrop==”object”)
        found.push(html);
    })(document.body);
    return found;
})();


Как видно из примера, в рекурсивной функции содержатся 2 локальные переменные (html, i) вместо трёх (html, i, found). На практике выигрыш в скорости несущественен (по крайней мере, от замыкания всего лишь одной переменной), зато даёт знать о себе выигрыш в памяти.
И, пожалуйста, не упрекайте за nextSibling, а не за nextElementSibling, все делалось в первую очередь ради разъяснения сути замыкания внутри рекурсивной функции.
ВНИМАНИЕ: Никогда не делайте замыкания посредством цикла — это вызывает чрезмерный расход памяти. Исключения составляют случаи, когда логика скрипта требует безоговорочного сокрытия данных (но в любом случае, если у меня есть отладчик, я и туда доберусь? ). Пример неправильного использования замыканий:

function addEvents2(divs) {
    for(var i=0; i<divs.length; i++) {	
        divs[i].innerHTML = i
        divs[i].onclick = function(x) {
            return function() { alert(x) }
        }(i);
    }
}


Да-да. Это тот самый пример из статьи Ильи Кантора про замыкания. Для разъяснения сути — да, нормально, но для практического использования это абсолютно неправильно: создаются несколько функций, каждая со своим замыканием. Ладно, если несколько штук. Но если тысячи… Лучший выход в данном случае — создавать только одну функцию БЕЗ ЗАМЫКАНИЯ и использовать свойство this:
function addEvents2(divs) {
    var f=function f(){alert(this.$i)};
    for(var i=0; i<divs.length; i++) {	
        divs[i].innerHTML = i;
        divs[i].$i=i;
        divs[i].onclick = f;
    }
}

А лучше всего использовать обработчик на родительском элементе (замечание egorinsk).

Оптимизация операций

Я когда-то об этом писал, но повторюсь еще раз: для каждого типа операции среди всех возможных вариантов существует один наибыстрейший, который и предпочтительно использовать.
Пускай у нас есть переменная v, содержание которой зависит от контекста рассмотрения; также есть переменная k, имеющая тот же смысл.
Операция Исходный код Комментарий
Приведение к boolean !!v Наверное, это знают все
Приведение к целому числу v-0 Просто вычитаем ноль
Приведение к дробному числу v-0.0 Небольшой, но выигрыш
Приведение к строке v+”” Прибавляем пустую строку
Создание объекта {} Действительно быстрее, чем через оператор new. Выигрышем является и возможность указать свойства
Создание массива [] Массив тоже является объектом, поэтому именно такое создание быстрее
Сравнение v===k Сравнение именно без приведения (если логика скрипта это допускает)
Операция ин-/декремента, операции присваивания с арифметическим действием v+=1;v/=5; Может это и покажется странным, но именно такой способ более быстрый, причем во всех браузерах
Операции деления/умножения на числа, являющиеся степенью двойки (замечание Dzuba) v<<2 Операции заменяются побитовым сдвигом. Выигрыш характерен и для других языков

Использование этих операций рекомендуемо, но логика скрипта не всегда позволяет поставить их в то или иное место, так что будьте бдительны.

Выделение критических участков

Я бы посоветовал теоретически прикидывать, насколько данный участок (например, цикл) будет критичным для данной области видимости, т.е. при увеличении числа итераций для этого участка так же линейно будет возрастать время его выполнения или зависимость будет уже в виде степенной функции, где степень больше единицы. И после оптимизации всех операций на данном участке, нужно оптимизировать участок в целом, например, ввести дополнительную переменную, которая бы хранила промежуточный результат, используемый более чем один раз.
Проще говоря, критический участок — это код, над которым процессор будет трудиться дольше всего, причём ощутимо для страницы. Например, 100k операций при интерполировании, если приложение для прикладной математики.
В подразделах я приведу пример и укажу в чем суть.

Циклы и объектные свойства

По скорости действия циклы for и while примерно одинаковы. Однако их различие наиболее проявляется в IE, где цикл for в разы быстрее, поэтому я рекомендую использовать именно его. Цикл for-in НИКОГДА не применяйте для массивов, в таком случае вы резко потеряете производительность: мало того, что он работает медленнее из-за обращения к таблице свойств, так еще будут потери и из-за перечисления ненужных в цикле свойств, таких как, например, length.
Наверное, вы уже знаете о технике обратного прохода по массиву, за счёт которой реально уменьшается время выполнения прохода. Она применима не всегда, но в большинстве случаев допустима. Предлагаю рассмотреть пример, чтобы понять, из-за чего происходит «ускорение»:

var arr=[];
arr.length=100500;
for(var i=0;i<arr.length;i++)
    …;//что-то происходит


Здесь нужно ввести понятие объектного обращения — это получение или установка указанного свойства текущего объекта либо значения какой-либо переменной.
Таким образом, в приведенном выше примере за одну итерацию цикла (без учета действий внутри цикла) происходит 4 объектных обращения (получение i, получение arr, получение arr.length, увеличение i). В наилучшем обратном проходе эти 4 обращения заменены одним:

for(var i=arr.length;i--;)
    …;//что-то происходит


Стоит отметить, что оператор постдекремента сразу же возвращает значение, в результате чего отпадает необходимость обращаться к i еще раз.
Что касается логики циклов, то старайтесь завершить цикл как можно раньше: например, идёт проверка на истинность каждого объекта массива (по условию все должны быть истинными); делайте break как только достигли первый неистинный элемент.

Немножко о DOM

Лирическое отступление: перед тем, как написать эту статью, проверил, есть ли здесь подобные. Оказалось, что есть. Я зашёл почитать, и, О БОЖЕ, что я там увидел:

document.getElementById('elem').propertyOne = 'value of first property';
document.getElementById('elem').propertyTwo = 'value of second property';
document.getElementById('elem').propertyThree = 'value of third property';


А теперь суть: НИКОГДА НЕ ПОВТОРЯЙТЕ УЖЕ ВЫПОЛНЕННЫЕ ДЕЙСТВИЯ, особенно, если они связаны с DOM!
Под выполненным действием в данном случае я понимаю получение элемента по идентификатору. Чем больше документ, тем медленнее производится поиск, а в данном случае поиск проводится трижды. Правильное решение:

var item=document.getElementById('elem');
item.propertyOne = 'value of first property';
item.propertyTwo = 'value of second property';
item.propertyThree = 'value of third property';


Во-первых, увеличивается быстродействие, во-вторых, сокращается код. Такую же конструкцию можно привинтить к циклам:

for(var i=arr.length,c;i--;){
     c=arr[i];
     …
}


---

Развивая тему циклов: как быстрее всего удалить подэлементы узла, зная, что существуют объектные обращения? Правильно, вот так:

var z,node=document.body;
while(z=node.lastChild)
	node.removeChild(z);


Фактически, этот код и будет являться критическим участком внутри функции, что обусловлено большим количеством обращений к DOM-объектам.
Стоит отметить, что интерфейсы DOM в десятки раз медленнее встроенных объектов JavaScript. Поэтому, если идёт речь о выполнении обращений к DOM-свойствам, то количество таких обращений необходимо свести к минимуму, особенно, если они происходят в цикле. В предыдущем примере в цикле всего 2 DOM-обращения: к свойству lastChild и функции removeChild; обращения к z и node сюда не относятся, потому что это обычные переменные. Но, возможно, я заблуждаюсь.
Еще производительность теряется, если в предыдущем случае в документе были расставлены обработчики DOM-событий. Старайтесь расставлять их как можно меньше и делать их как можно проще. В отдельных случаях их код может оказаться критичным.

DocumentFragment как промежуточный буфер

Если у вам необходимо вставить в элемент несколько подэлементов один за одним, не спешите делать это напрямую. Ведь при каждой вставке будет генерироваться DOM-событие. Для обхода этой проблемы существует DocumentFragment — промежуточный буфер, который позволяет коллекционировать элементы и вставлять их в нужное место ОДНИМ разом, что существенно повышает производительность. Для того, чтобы убедиться в этом создайте таблицу 200 на 100 с использованием DocumentFragment и без его использования. За этот опыт отдельная благодарность Илье Кантору.
И все операции над объектами (присваивание класса, айди, установка атрибутов) тоже лучше делать именно в DocumentFragment. Такой подход не допускает генерацию событий уже внутри документа, что не вызывает нагрузки.

О преобразованиях в объекты

На больших объемах JSON-данных eval работает ОЧЕНЬ медленно, но так как значения undefined и функции не входят в JSON, то создать такой объект можно только с помощью eval.
Для настоящего JSON-формата используйте JSON.parse. Но недостаток последнего в том, что он требует полного соответствия JSON спецификации: двойные кавычки для ключей, отсутствие комментариев (хотя они и должны быть).

Разбитие кода

Для понимания текста пониже, вам нужно будет знать о стеке вызовов JS (замечание spmbt): грубо говоря — это стек, куда помещаются все выполняющиеся в данный момент функции в порядке вызова. Помещение первой функции в стек может быть вызвано несколькими способами: 1) вызовом из глобальной области выполнения; 2) запуском функции по таймеру/таймауту; 3) выполнением обработчика пользовательского события. Когда функция завершает свою работу, то она удаляется из стека. Также она удаляется из стека, если внутри нее произошла ошибка, которая не была обработана.

А знаете ли вы, что перерисовка документа (например, когда вы изменили некоторые стили элемента) происходит только когда очищается стек вызовов? Теперь точно знаете. JavaScript не позволяет создавать конструкции типа wait(2000); с продолжением выполнения кода той же функции без ущерба производительности. Поэтому код разбивается на отдельные функции, которые и управляют элементами. Совет заключается в том, что старайтесь при таком разбитии кода на функции равномерно распределить нагрузку по разным функциям. Ведь может получиться, что одна функция изменяет чуть ли не все стили большинства элементов документа, другая вычисляет сложную математическую задачу, а остальные почти ничего не делают, в результате страница «висит».
Равномерность может быть достигнута с помощью установки интервала выполнения. Что касается эффектов, то чем больше интервал между запуском функций, тем больше времени на перерисовку документа и тем меньше нагрузка на процессор. Но если интервал слишком большой, то будет заметно «подергивание эффекта». Оптимальным является интервал в 20мс. Минимальный интервал — 4 мс, кроме Opera (1мс) и IE (15 мс). Даже если вы установите интервал в 0, то реальный вызов функции все равно произойдёт через минимальный интервал.

События перетаскивания

Такие события являются критическими, ведь они вызываются КАЖДЫЙ РАЗ, как только обнаружено перетаскивание. Для снижения нагрузки их лучше заменять функциями по таймауту с помощью замыкания (замечание egorinsk):

(function init(){//в отличие от прошлой версии, именно такая запись работает в полтора раза быстрее.
	//Неверящих профайлер исправит
	var MODE_MOUSE_MOVE=true;
		var move_listener=function move_listener (evnt){
		if(!MODE_MOUSE_MOVE)
			return;
		ev=evnt||window.event;//for IE8
		timeout=setTimeout(move_handler,10);
	},
	ev=null,
	move_handler=function  move_handler(){
		timeout=0;
		MODE_MOUSE_MOVE=true;
		if(typeof document.$onmousemove=="function")
			document.$onmousemove(ev);
	},
	timeout=0;
	document.onmousemove=move_listener;
})();


Если назначить функцию document.$onmousemove, то она будет выполняться каждый раз при обнаружении перетаскивания, причем ее критичность будет снижена с помощью таймера. Айдишник таймера назначен на случай, если вдруг придется сделать возможность прерывания этого таймера.
Сюда же относится и событие изменения размеров окна.

Другие советы

Старайтесь использовать функции-обёртки: они придают модульность коду и изолируют локальные переменные одного модуля от локальных переменных другого модуля. Таким образом снижается вероятность использовать уже объявленную переменную, которой уже отведена некоторая роль.
В принципе, всё сказано. Может быть только, оптимизируйте не только код, но и его читаемость. В необфускированных исходниках имена функций и их параметров делайте логичными, префиксуйте типом (s — строка, n — число и т.п.). Я так и делаю, и код понимается даже через год.

Послесловие


Я рад, что вы дочитали эту статью. Я надеюсь, что она вам понравилась в плане изложения и доступности. Может быть плохо, что здесь нет примеров работы с популярными библиотеками, но я считаю, что нужно уметь пользоваться JS и без библиотек, особенно если вы — клиентский разработчик.

Может, большинство советов уже успели стать для вас «боянами», но я думаю, что что-то полезное вы отсюда всё-таки вынесли. Спасибо

UPD 1. Учитывая высказанные советы в комментах, исправляю все найденные недостатки
Теги:
Хабы:
Всего голосов 113: ↑92 и ↓21+71
Комментарии99

Публикации

Истории

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань