Приемы Ускорения Кода На Js И Других Языках: Подборка От Разработчика Поиска Яндекса

Привет! Меня зовут Виктор Хомяков, в Яндексе я занимаюсь скоростью поиска страниц.

Однажды мне пришла в голову идея обобщить свой опыт и систематизировать приемы ускорения JavaScript-кода.

Что получилось в итоге, собрано в этом материале.

Некоторые приемы будут полезны тем, кто пишет на других языках.

Все методы разделены на группы в порядке убывания специфики: от самого общего к частному.

Практически все примеры кода взяты из реальных проектов, из реального продакшена.

  • Организационный   Культура разработки, ориентированная на производительность   Бюджет скорости   Мантры производительности
  • Те, которые можно использовать независимо от языка и его реализации.

      Изменение языка или структуры   Изменение алгоритма   Оптимизация алгоритма   Перенос инвариантов на более высокий уровень   Логическое короткое замыкание   Ранний выход из цикла   Предварительные вычисления

  • Для языков/фреймворков, в которых нет методов отложенного вычисления и копирования при записи.

      Ярлык слияния   Ленивая оценка   Копирование при записи   сверхинжиниринг

  • Железозависимый   Развертывание малых циклов   Прогнозирование ветвей   Доступ к памяти: направление итерации   Доступ к памяти: [i][j] против [j][i]
  • Для языков со сборкой мусора   Мутабельность   Нулевое выделение памяти или отсутствие GC
  • Специально для JavaScript   Антипаттерн: накопление строк в массиве   Антипаттерн: Lodash_.defaults   Простой до срочного   Понижение кода: ES6 → ES5
  • Примеры из проверки кода


Организационный



Культура развития производительность прежде всего

Это самое важное.

Чем раньше вы начнете контролировать скорость в своем проекте, тем лучше для проекта.

Это позволит заранее избежать серьезных просчетов, которые потом будет сложно исправить.

При этом обратите внимание: я не призываю сразу превратить весь код в нечитаемую «портянку».

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



Бюджет скорости

— Будьте быстрее конкурентов на ≈ 20%! — Открытие страницы в сети 4G < 3 s — Открытие страницы в сети 3G < 5 s — Продолжительность запросов данных < 1 s — Первая содержательная краска < 1 s — Самая большая содержательная краска < 2 s — Общее время блокировки < 500 ms — Оценка производительности маяка > 70 — Совокупное изменение макета < 0.1 wp-rocket.me/blog/performance-budgets
Очень важно определить бюджет скорости в проекте.

Как именно его определить, какие метрики и какие значения выбрать – тема отдельного длинного разговора.

Главное, чтобы у вас был бюджет.

Мантры производительности

1. Не делай этого 2. Делай это, но больше так не делай 3. Делайте это меньше 4. Сделайте это позже 5. Делайте это, когда они не смотрят 6. Делайте это одновременно 7. Делайте это дешевле brendangregg.com/blog/2018-06-30/benchmarking-checklist.html
Еще один интересный подход к проблеме.

Попробуйте применить семь шагов мантры к решению проблем со скоростью и производительностью кода.

Если один из них предлагает решение, смело используйте его.

Такой подход работает: все техники, которые я покажу далее, подпадают под один из пунктов мантры.

Следующая группа техник будет полезна не только в JavaScript.

Те, которые можно использовать независимо от языка и его реализации.



Изменение языка или структуры

Самое главное: если вы понимаете, что ваши инструменты не подходят для данной задачи, то как можно скорее ищите другие, более подходящие.

Или вообще измените свой язык программирования или структуру.

Если подходящих нет, напишите свои.

Так в итоге родились многие ныне известные фреймворки и библиотеки.



Изменение алгоритма

Если текущий язык вас устраивает и вы его не меняете, но проблема в конкретном алгоритме, то посмотрите — возможно, есть алгоритмы, выполняющие ту же задачу, но с меньшей сложностью.

Например, вы можете попробовать перейти от O(N 2 ) до O(N log N) или до O(N).

Проверьте, как алгоритм работает с вашими данными.

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

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



Оптимизация алгоритма

Если лучших вариантов нет, посмотрите реализацию текущего алгоритма.

Постарайтесь сократить количество итераций, проходов по коллекциям и массивам.

То есть N-1 проход быстрее, чем N проходов, хотя в О-нотации получается та же сложность.

Если ваш сервис использует сложную математику, требующую времени, попробуйте упростить ее.

Однажды мы искали точки на плоскости, наиболее близкой к заданной.

Для формулы расчета расстояния нам понадобился квадратный корень: Math.sqrt(dx**2 + dy**2).

Но чтобы найти ближайшую точку, достаточно было сравнить квадраты расстояний: dx**2 + dy**2. Это даст тот же ответ, но мы избежим медленного вычисления корня.

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

Перенос инвариантов на более высокий уровень

Бывает, что на каждой итерации вы постоянно проверяете или оцениваете одно и то же выражение, которое не изменится на следующей итерации.

Например:

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

items.forEach(i => doClear ? i.clear() : i.mark());

Вместо того, чтобы вычислять его N раз, было бы неплохо вывести его из цикла и проверить или оценить только один раз:

if (doClear) items.forEach(clear)
 else items.forEach(mark);

Альтернативно, в этом коде мы можем условно подставить нужную функцию-итератор в итерацию массива через forEach:

const action = doClear ? clear : mark; items.forEach(action);

Этот прием применим не только к циклам и операциям с массивами.

Это также относится к методам и функциям.

Вот изоморфный код, который вычисляет, виден ли domNode в указанной точке экрана:

const visibleAtPoint = function(x, y, domNode) { if (canUseDom && document.elementsFromPoint) { // .

} };

При запуске на сервере код проверяет, можно ли использовать DOM. И когда код запускается в браузере, он проверяет, есть ли в браузере необходимый API. Такие проверки происходят каждый раз при вызове функции.

Это лишняя трата времени как на сервере при серверном рендеринге, так и на клиенте, потому что в браузере либо есть API, либо нет, и достаточно один раз его проверить.

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

Внутри реализации больше не будет никаких if или проверок условий.

Это будет работать максимально быстро.



const visibleAtPoint = (canUseDom && document.elementsFromPoint) ? fn1 : fn2;



Логическое короткое замыкание

Сейчас, к сожалению, люди часто забывают о логическом коротком замыкании.

Даже JavaScript может обходить логические выражения, состоящие из операторов и/или заранее предсказывать результат, как в этом примере.



const isVisibleBottomLeft = visibleAtPoint(left, bottom, element); const isVisibleBottomRight = visibleAtPoint(right, bottom, element); return isVisibleBottomLeft && isVisibleBottomRight;

Код проверяет видимость нижней левой и нижней правой точек прямоугольника – диалогового, модального окна.

Если нижний левый угол больше не виден, то нам не нужно рассчитывать видимость нижнего правого угла.

Но в такой записи мы все равно сначала все посчитаем, а потом подставим в логическое выражение.

Это неоправданно замедляет наш код, особенно если проверки очень частые.

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

Тогда вещи, которые не нужны, не будут рассчитаны.



return visibleAtPoint(left, bottom, element) && visibleAtPoint(right, bottom, element);



Ранний выход из цикла

Иногда мы уже знаем результат нашего выражения и нам не нужно искать по всему массиву, как в этом примере:

let negativeValueFound = false; for (let i = 0; i < values.length; i++) { if (value[i] < 0) negativeValueFound = true; }

Ищем, есть ли в массиве отрицательные значения.

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

Нам нужно вернуть результат заранее:

let negativeValueFound = false; for (let i = 0; i < values.length; i++) { if (value[i] < 0) {negativeValueFound = true; break;} }

К таким методам, которые досрочно завершают поиск массива, относятся find, findIndex, Every и Some. Есть проблема с методом сокращения: он продолжает перебирать массив до конца.

В этом случае вы можете использовать библиотечные методы, например Lodash предоставляет метод трансформировать - аналог редукта, но с возможностью досрочного выхода.



Предварительные вычисления

Иногда некоторые константы можно рассчитать заранее.

Например, мы обрабатываем все пиксели изображения и вычисляем кубический корень из RGB-компонент каждого пикселя:

const result = Math.pow(r, 1/3);

Очень часто при работе с изображениями требуются сложные и дорогостоящие математические расчеты.

Скорость вычислений не очень высокая: на моей машине она получается около 7 миллионов операций в секунду.

Другими словами, за секунду мы успеем обработать примерно два мегапикселя изображения.

Для современных автомобилей этого недостаточно.

Мы можем заметить, что константа ⅓ вычисляется каждый раз, и запомнить ее.

При этом скорость работы увеличится до 10 миллионов операций в секунду.



const N = 1/3; const result = Math.pow(r, N);

Но этого все еще недостаточно.

Обратите внимание, что чаще всего компоненты R, G и B являются байтами.

То есть каждый из них принимает всего 256 значений.

Соответственно, наш корень куба, как и результат любой формулы над байтом, тоже может принимать только 256 значений.

Мы можем заранее записать все значения формулы, какой бы сложной она ни была, и во время выполнения просто выбирать из массива нужное значение:

const result = CUBE_ROOTS[r];

Получаем примерно десятикратное ускорение по сравнению с исходным кодом — точные результаты может быть немного другим.

Чем сложнее формула, тем большее ускорение мы можем получить.

Этот прием называется справочной таблицей (LUT): мы записываем в таблицу заранее рассчитанные значения и за счет дополнительной памяти получаем дополнительную скорость.



Для языков/фреймворков, в которых нет методов отложенного вычисления и копирования при записи.



Ярлык слияния

Это интересная концепция, которая полностью отсутствует в JavaScript. Допустим, вам нужна целая цепочка выражений над массивом: array.map().

filter().

reduce().

JavaScript будет делать все это последовательно.

Сначала он выполнит карту и построит промежуточный массив.

Затем он выполнит фильтр, построит второй промежуточный массив и, наконец, выполнит сокращение всех элементов промежуточного массива.

В результате получается три прохода, которые мы могли бы объединить в один, сделав сокращение: написав один сложный array.reduce() с кодом из нашей карты, отфильтровав и сократив.

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

В мощных языках это делается самим компилятором.

В JavaScript нам приходится делать это вручную.



Ленивая оценка

Он также отсутствует в JavaScript. Иногда нам нужны только первые пять элементов из всего массива: arr.map().

slice(0, 5).

Или первый элемент, удовлетворяющий некоторому условию: arr.map().

filter(Boolean)[0].

В JS такие вещи делаются неэффективно: сначала мы делаем все операции над всем массивом, а потом оставляем только нужные элементы.

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

Если написать такую конструкцию напрямую с использованием фильтра и карты, то сложность реализации составит O(N):

array .

filter(n => n % 2) .

map(n => Math.sqrt(n)) .

slice(0, 5);

Библиотека Лодаш может прийти нам на помощь.

В нем тот же расчет записан очень похоже, но имеет сложность близко к постоянному :

_(array) .

filter(n => n % 2) .

map(n => Math.sqrt(n)) .

take(5).

value();

Лодаш использует как сокращенное слияние, так и ленивую оценку, находя только первые пять элементов.

Не имеет значения, насколько длинным является массив: как только мы найдем первые пять элементов, Lodash прекратит вычисления.

Подобные возможности, помимо Lodash, реализованы и в других библиотеках, например, Immutable и Ramda. Используйте их, когда у вас есть такие цепочки вычислений.



Копирование при записи

В тех языках, которые используют неизменность и копирование структур, есть приём: ленивое копирование содержимого этих структур в новый экземпляр, которое происходит только тогда, когда мы действительно намереваемся изменить значение внутри.



const state = { todos: [{todo: "Learn typescript", done: true}], otherData: {} };

Для нас это очень важно, потому что мы используем React, Redux и неизменяемое состояние.

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

const nextState = {.

state, todos: [.

state.todos, {todo: "Try immer"}]};

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



const nextState = produce(state, draft => { draft.todos.push({todo: "Try immer"}); });

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

Помимо Immer, существует несколько библиотек, реализующих подобные вещи.

В библиотеке Immutable есть методы.

обновлениев , которые явно работают с неизменяемыми структурами.

В библиотеке Рамда есть концепция , что называется «линзами».

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

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



сверхинжиниринг

Иногда легко увлечься и усложнить код там, где на самом деле это можно сделать гораздо проще.

Например, если вам нужно изменить порядок элементов в массиве, вам не нужны дополнительные библиотеки или такой сложный код:

array.map().

reverse()

Существует классический цикл for, в котором мы можем указать противоположное направление:

for (let i = len - 1; i >= 0; i--)

Допустим, вам нужно выполнить расчет части массива:

array.slice(1).

forEach()

Затем опять же можно использовать for, указав в нем нужный диапазон индексов:

for (let i = 1; i < len; i++)

Бывает, что мы усложняем код, делая слишком сложную цепочку:

_.chain(data) 
 .

map()
 .

compact()
 .

value()[0]

Мы можем упростить и ускорить его, заменив его одним вызовом _.find() и выполнив операции из map() только с одним найденным элементом.



Железозависимый

До сих пор неявно предполагалось, что все, что мы пишем, выполняется на идеальных компьютерах со сверхбыстрыми процессорами и мгновенным доступом к памяти.

На самом деле это не так, что становится особенно заметно в некоторых горячих точках.



Развертывание малых циклов

Как вы, вероятно, знаете, небольшие циклы работают неэффективно, поскольку стоимость организации цикла превышает стоимость выполнения самого кода в цикле.

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



[1, 2, 3].

map(i => fn(i))

Если в горячем коде вы заметили небольшие циклы на нескольких элементах с использованием for, map, forEach, лучше развернуть их вручную:

[fn(1), fn(2), fn(3)]



Прогнозирование ветвей

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

Здесь пример бенчмарк (это синтетический бенчмарк, подобных примеров в реальном коде я не встречал).

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

Внутри цикла есть if, и в зависимости от проверки мы обрабатываем ровно половину элементов, а половину — нет. Но в первом случае массив сортируется, и мы сначала обрабатываем 50% элементов, а потом не обрабатываем остальные 50% элементов.

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

Необходимо обработать ровно такое же количество элементов, но предсказание ветвей не работает. Обработка такого неупорядоченного массива даже на современных машинах занимает в разы больше времени: 550 мс против 130 мс.

То есть даже в JavaScript предсказание ветвей может оказывать заметное влияние на вычисления.

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

Этот трюк может помочь вам ускорить ваш код.

Доступ к памяти: направление итерации

Как известно, доступ к памяти не происходит мгновенно — современные компьютеры используют кэширование и упреждающее чтение данных для ускорения процесса.

Есть старая закономерность, зародившаяся в Internet Explorer 6 при выполнении операций над циклами и строками: итерация в обратном направлении тогда была самой быстрой.

С тех пор этот шаблон очень часто повторялся в современном коде «для большей скорости».



let i = str.length; while (i--) str.charCodeAt(i);

Но, к сожалению, уже давно этого не происходит. В современных браузерах прямое направление обычно происходит быстрее (в данном примере — 1,6 против 1,4 миллиона операций в секунду):

for (let i = 0; i < str.length; i++) str.charCodeAt(i);

Даже на относительно коротких строках в несколько сотен символов мы легко можем заметить разницу в скорости между всеми современными браузерами и Node.js. Пример из библиотеки хеширования строк .

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



Доступ к памяти: [i][j] против [j][i]

Допустим, у вас есть двумерные структуры.

Например, вы читаете в память записи таблицы или двумерный массив.

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

Если вы обрабатываете массив построчно, элементы одной и той же строки должны лежать в памяти рядом.

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

Этот прием может дать заметный прирост скорости( 1 2 ).



Для языков со сборкой мусора

Эта группа оптимизаций подходит для языков, в которых есть сборка мусора и автоматическое управление памятью: JavaScript, C#, Java и некоторые другие.



Мутабельность

Первая проблема — плохая поддержка неизменяемости.

Неизменяемость объектов означает генерацию новых объектов, иногда с достаточно высокой скоростью.

А старые объекты необходимо собирать через сборщик мусора.

В горячем коде это может сильно повлиять на скорость работы.

Именно затраты на сбор мусора могут превысить затраты на выполнение вашего кода.

А если вы видите, что горячий код потребляет большое количество памяти, попробуйте использовать изменчивость: удалите разброс, удалите клонирование объектов и мутируйте существующие объекты.

Иногда это можно сделать совершенно безболезненно.



const obj = createNewObj(); return {.

obj, prop: value};

Например, в таком «горячем» участке кода мы создаем новый объект с нуля.

Это гарантированно уникальный объект, на него никто не ссылается.

И тут же, в следующей строке, клонируем его в новый объект. Здесь и клонирование объектов, и создание мусора совершенно напрасны.

Этот кусок кода можно переписать так, будет гораздо быстрее:

const obj = createNewObj(); obj.prop = value; return obj;

Но, повторюсь, это только в горячем коде.

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



Нулевое выделение памяти или отсутствие GC

Так называются алгоритмы с низким или нулевым потреблением памяти.

Распространенным методом в таких алгоритмах является использование пула объектов.

Мы создаем N объектов заданного типа один раз, а те, кто их использует, мутируют их так, как им нужно, а затем возвращают обратно в пул.

Таким образом, нет потребления новой памяти.

Допустим, есть возвращаемый объект, который нужен один раз «на отбрасывание» — то есть нам нужно один раз выполнить какую-то операцию, и объект нас больше не интересует, мы нигде не сохраняем ссылки на него.

Тогда вы можете использовать синглтон.

Этот шаблон называется легковесным объектом.

Здесь пример из среды ExtJS:

Ext.fly(id)

Используйте это для одноразовых ссылок на элементы DOM, к которым больше не будет доступа ни код приложения, ни классы Ext.
Это довольно распространенная схема работы с DOM: мы получаем DOM-элемент по идентификатору, проверяем или меняем на нем CSS-класс, стили, атрибуты и выбрасываем, потому что он нам больше не нужен.

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

Вот ссылки на регистрацию клиентов в Go и Java:

В документации хорошо описано, что именно делалось и как именно делалось.

Вы можете найти и проанализировать запрос на включение, который снизил потребление памяти.



Специально для JavaScript

Эта группа оптимизаций наиболее близка к JS и малопригодна в других языках.



Антипаттерн: накопление строк в массиве

Еще один антипаттерн со времен шестого Internet Explorer — если нужно накопить длинную строку кусочков, некоторые разработчики до сих пор сначала собирают эти строки в массив, а потом вызывают join:

[string1, string2, … stringN].

join('')

К сожалению, это работало быстро только в Internet Explorer 6. С тех пор суммировать строки стало намного быстрее:

string1 + string2 + … + stringN

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

КонСтрока , «объединенная строка».

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

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



Антипаттерн: Lodash_.defaults

Когда у нас есть объект, в котором мы хотим установить значение по умолчанию, мы часто используем для этого функцию _.defaults из Lodash или ее аналоги.

В этом случае результат сложных вычислений, занимающих много времени, легко записать в сами значения по умолчанию.



_.defaults(button, { size: getDefaultButtonSize(window), text: getDefaultButtonText() });

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

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

То есть мы сначала рассчитаем объект значений по умолчанию, а затем решим, стоит ли его использовать.

Быстрое и некрасивое решение: сначала проверяем, нужны ли нам значения по умолчанию в данном поле, и только потом проводим тяжелые вычисления:

if (button.size === undefined) button.size = getDefaultButtonSize(window); if (button.text === undefined) button.text = getDefaultButtonText();

Но, конечно, такой код оказывается некрасивым.

Было бы немного красивее написать с использованием геттеров:

_.defaults(button,{ get size() {return getDefaultButtonSize(window)}, get text() {return getDefaultButtonText()} });

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

Постарайтесь сделать этот код красивым и быстрым — это вполне достижимый результат.

Простой до срочного

Часто в конструкторе объекта мы инициализируем все поля, которые могут нам понадобиться только после каких-то действий пользователя, а могут и не понадобиться вообще — как в этом примере, взятом из статьи Филип Уолтон:

constructor() { this.formatter = new Intl.DateTimeFormat(…); } handleUserClick() { const formatter = this.formatter; this.clickTime = formatter.format(new Date()); }

Мы создаем поле форматера для форматирования даты и времени, и это создание занимает очень много времени.

Но форматтер, вероятно, будет использоваться только через длительный период времени, когда пользователь на что-то нажмет. Мы можем обернуть медленное создание объекта форматирования в обертку, которая будет выполнять код создания объекта, когда браузер простаивает, а пользователь ничего не делает, или когда нам явно нужен форматтер:

constructor() { this.formatter = new IdleValue( () => new Intl.DateTimeFormat(…)); } handleUserClick() { const formatter = this.formatter.getValue(); this.clickTime = formatter.format(new Date()); }

IdleValue — это класс, реализующий ленивую инициализацию.

Описано в статье выше и в библиотеке.

Теги: #программирование #разработка сайтов #Высокая производительность #JavaScript #frontend #backend #веб-разработка #быстрый код #практические советы

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.