S rozvojem technologie prohlížečů začaly webové aplikace přenášet stále více logiky na front-end, čímž se odlehčilo serveru a snížil se počet operací, které musí provádět. V základních CRUD se úloha serveru omezuje na autorizaci, validaci, komunikaci s databázemi a potřebnou obchodní logiku. Zbytek datové logiky, jak se ukazuje, může snadno zvládnout kód zodpovědný za reprezentaci aplikace na straně uživatelského rozhraní.
V tomto článku se vám pokusím ukázat několik příkladů a vzorů, které nám pomohou udržet naše kód efektivní, úhledné a rychlé.
Než se pustíme do konkrétních příkladů - v tomto článku bych se chtěl zaměřit pouze na ukázku případů, které podle mého názoru mohou překvapivě ovlivnit rychlost aplikace. To však neznamená, že použití rychlejších řešení je nejlepší volbou ve všech možných případech. Níže uvedené tipy bychom měli brát spíše jako něco, čím bychom se měli zabývat v případě, že naše aplikace běží pomalu, např. v produktech, které vyžadují vykreslování her nebo pokročilejších grafů na plátně, operace s videem nebo činnosti, které chceme synchronizovat v reálném čase co nejdříve.
Především - metody Array.prototype
Velkou část aplikační logiky zakládáme na polích - jejich mapování, třídění, filtrování, sčítání prvků atd. Snadno, přehledně a přirozeně využíváme jejich vestavěné metody, které jednoduše umožňují nás k provádění různých typů výpočtů, seskupování atd. V každém případě fungují podobně - jako argument předáváme funkci, kde se ve většině případů při každé iteraci střídavě posouvá hodnota prvku, index a pole. Zadaná funkce se provede pro každý prvek pole a výsledek se interpretuje různě v závislosti na metodě. Nebudu se podrobněji zabývat metodami Array.prototype, protože se chci zaměřit na to, proč ve velkém množství případů běží pomalu.
Metody pole jsou pomalé, protože provádějí funkci pro každý prvek. Funkce volaná z pohledu enginu musí připravit nové volání, poskytnout příslušný obor a spoustu dalších závislostí, což celý proces značně prodlužuje oproti opakování konkrétního bloku kódu v konkrétním oboru. A to jsou pravděpodobně dostatečné základní znalosti, které nám umožní pochopit následující příklad:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() });
console.time('Sum by reduce');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('Součet podle reduce');
console.time('Součet podle smyčky for');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Součet podle cyklu for');
console.log(reduceSum === forSum);
})();
Vím, že tento test není tak spolehlivý jako benchmarky (k nim se vrátíme později), ale spustí varovnou kontrolku. Pro náhodný případ na mém počítači se ukázalo, že kód se smyčkou for může být asi 50krát rychlejší, pokud ho porovnáme s mapováním a následným zmenšováním prvků, které dosáhnou stejného efektu! Jde o práci s nějakým podivným objektem vytvořeným pouze za účelem dosažení určitého cíle výpočtů. Vytvořme si tedy něco legitimnějšího, abychom byli objektivní ohledně metod Array:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() });
console.time('Sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ hodnota: a.value + b.value })).value
console.timeEnd('Součet podle reduce');
console.time('Součet podle smyčky for');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Součet podle cyklu for');
console.log(reduceSum === forSum);
})();
Vím, že tento test není tak spolehlivý jako benchmarky (k nim se vrátíme později), ale spustí varovnou kontrolku. Pro náhodný případ na mém počítači se ukázalo, že kód se smyčkou for může být asi 50krát rychlejší, pokud ho porovnáme s mapováním a následným zmenšováním prvků, které dosáhnou stejného efektu! Je to proto, že získání součtu v tomto konkrétním případě pomocí metody reduce vyžaduje mapování pole pro čisté hodnoty, které chceme sečíst. Vytvořme si tedy něco legitimnějšího, abychom byli objektivní ohledně metod pole:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() });
console.time('Sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ hodnota: a.value + b.value })).value
console.timeEnd('Součet podle reduce');
console.time('Součet podle smyčky for');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Součet podle cyklu for');
console.log(reduceSum === forSum);
})();
A jak se ukázalo, naše 50násobné zvýšení se snížilo na 4násobné. Omlouváme se, pokud se cítíte zklamaní! Abychom zůstali objektivní až do konce, analyzujme oba kódy ještě jednou. Především - nevinně vypadající rozdíly zdvojnásobily pokles naší teoretické výpočetní složitosti; místo toho, abychom nejprve mapovali a pak sčítali čisté prvky, stále operujeme s objekty a konkrétním polem, abychom nakonec vytáhli a získali součet, který nás zajímá. Problém nastane, když se na kód podívá jiný programátor - ten pak ve srovnání s dříve uvedenými kódy v určitém bodě ztrácí abstrakci.
Je to proto, že od druhé operace, kterou pracujeme s cizím objektem, s polem, které nás zajímá, a druhým, standardním objektem iterovaného pole. Nevím, co si o tom myslíte vy, ale z mého pohledu je v druhém příkladu kódu logika cyklu for mnohem jasnější a záměrnější než tato podivně vypadající redukce. A přesto, i když už to není bájná padesátka, je to stále 4krát rychlejší, pokud jde o výpočetní čas! Protože každá milisekunda je cenná, je volba v tomto případě jednoduchá.
Nejpřekvapivější příklad
Druhá věc, kterou jsem chtěl porovnat, se týká metody Math.max, přesněji řečeno, vycpání milionu prvků a jejich extrakce těch největších a nejmenších. Připravil jsem si kód, metody pro měření času také, pak kód spustím a dostanu velmi podivnou chybu - je překročena velikost zásobníku. Zde je kód:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max s operátorem ES6 spread');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max s operátorem ES6 spread');
console.time('Math.max s for cyklem');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max se smyčkou for');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('Math.max s operátorem ES6 spread');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max s operátorem ES6 spread');
console.time('Math.max s for cyklem');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max se smyčkou for');
console.log(maxByFor === maxBySpread);
})();
Ukázalo se, že nativní metody používají rekurzi, která je ve verzi v8 omezena zásobníky volání a její počet závisí na prostředí. To je něco, co mě hodně překvapilo, ale nese to s sebou závěr: nativní metoda je rychlejší, pokud naše pole nepřesáhne určitý magický počet prvků, který se v mém případě ukázal být 125375. Pro tento počet prvků byl výsledek for 5x rychlejší, pokud se porovnával se smyčkou. Nad zmíněným počtem prvků však smyčka for jednoznačně vítězí - na rozdíl od soupeře nám umožňuje získat správné výsledky.
Rekurze
Koncept, který chci v tomto odstavci zmínit, je rekurze. V předchozím příkladu jsme se s ní setkali u metody Math.max a skládání argumentů, kde se ukázalo, že u rekurzivních volání přesahujících určité číslo nelze získat výsledek kvůli omezení velikosti zásobníku.
Nyní se podíváme, jak vypadá rekurze v kontextu kódu napsaného v JS, a ne pomocí vestavěných metod.Asi nejklasičtější věcí, kterou si zde můžeme ukázat, je samozřejmě nalezení n-tého členu Fibonacciho posloupnosti. Tak si to pojďme napsat!
Dobře, v tomto konkrétním případě výpočtu 30. položky posloupnosti na mém počítači získáme výsledek iteračním algoritmem asi za 200x kratší dobu.
V rekurzivním algoritmu však lze napravit jednu věc - jak se ukázalo, funguje mnohem efektivněji, když použijeme taktiku zvanou ocasní rekurze. To znamená, že výsledek, který jsme získali v předchozí iteraci, předáme pomocí argumentů pro hlubší volání. To nám umožňuje snížit počet potřebných volání a v důsledku toho zrychlit výsledek. Opravme tedy náš kód odpovídajícím způsobem!
(() => {
const fiboIterative = (n) => {
nechť [a, b] = [0, 1];
for (let i = 0; i {
if(n === 0) {
return first;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('Fibonacciho posloupnost podle smyčky for');
const resultIterative = fiboIterative(30);
console.timeEnd('Fibonacciho posloupnost podle smyčky for');
console.time('Fibonacciho posloupnost pomocí rekurze na chvostu');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('Fibonacciho posloupnost pomocí rekurze na chvostu');
console.log(resultRecursive === resultIterative);
})();
Stalo se zde něco, co jsem tak docela nečekal - výsledek algoritmu rekurze na chvostu byl v některých případech schopen poskytnout výsledek (výpočet 30. prvku posloupnosti) téměř dvakrát rychleji než iterační algoritmus. Nejsem si zcela jist, zda je to způsobeno optimalizací pro ocasní rekurzi ze strany v8, nebo nedostatkem optimalizace cyklu for pro tento konkrétní počet iterací, ale výsledek je jednoznačný - ocasní rekurze vítězí.
To je zvláštní, protože smyčka for v podstatě ukládá mnohem méně abstrakce na výpočetní činnosti nižší úrovně a dalo by se říci, že má blíže k základním operacím počítače. Přesto jsou výsledky nepopiratelné - chytře navržená rekurze se ukazuje být rychlejší než iterace.
Používejte asynchronní příkazy volání co nejčastěji.
Poslední odstavec bych rád věnoval krátkému připomenutí způsobu provádění operací, který také může výrazně ovlivnit rychlost naší aplikace. Jak byste měli vědět, JavaScript je jednovláknový jazyk, který všechny operace provádí pomocí mechanismu smyčky událostí. Jde o cyklus, který běží stále dokola a všechny kroky v tomto cyklu se týkají vyhrazených zadaných akcí.
Aby byla tato smyčka rychlá a všechny cykly čekaly méně na svůj čas, měly by být všechny prvky co nejrychlejší. Vyhněte se spouštění dlouhých operací na hlavním vlákně - pokud něco trvá příliš dlouho, pokuste se tyto výpočty přesunout do WebWorkeru nebo rozdělit na části, které běží asynchronně. Může to sice zpomalit některé operace, ale podpoří to celý ekosystém JS, včetně IO operací, jako je obsluha pohybu myši nebo čekajícího HTTP požadavku.
Souhrn
Závěrem, jak již bylo zmíněno, honba za milisekundami, které lze ušetřit výběrem algoritmu, se může v některých případech ukázat jako nesmyslná. Na druhou stranu zanedbávání takových věcí v aplikacích, které vyžadují plynulý chod a rychlé výsledky, může být pro vaši aplikaci smrtící. V některých případech je třeba si kromě rychlosti algoritmu položit ještě jednu otázku - je abstrakce provozována na správné úrovni? Bude programátor, který kód čte, schopen mu bez problémů porozumět?
Jediným způsobem je zajistit rovnováhu mezi výkonem, snadnou implementací a vhodnou abstrakcí a mít jistotu, že algoritmus funguje správně jak pro malé, tak pro velké objemy dat. Způsob, jak toho dosáhnout, je poměrně jednoduchý - buďte chytří, při návrhu algoritmu zvažte různé případy a zařiďte jej tak, aby se choval co nejefektivněji pro průměrné provedení. Také je vhodné navrhnout testy - ujistěte se, že algoritmus vrací vhodné informace pro různá data, ať už pracuje jakkoli. Dbejte na správná rozhraní - aby vstupy i výstupy metod byly čitelné, přehledné a přesně odrážely to, co dělají.
Již dříve jsem se zmínil, že se vrátím ke spolehlivosti měření rychlosti algoritmů ve výše uvedených příkladech. Jejich měření pomocí console.time není příliš spolehlivé, ale nejlépe odráží standardní případ použití. Každopádně níže uvádím benchmarky - některé z nich vypadají trochu jinak než jednorázové provedení vzhledem k tomu, že benchmarky prostě opakují danou činnost v určitém čase a používají optimalizaci v8 pro smyčky.
https://jsben.ch/KhAqb - redukce vs. smyčka for
https://jsben.ch/F4kLY - optimalizovaná redukce vs. smyčka for
https://jsben.ch/MCr6g - Math.max vs for smyčka
https://jsben.ch/A0CJB - rekurzivní fibo vs iterativní fibo