Vývoj tabulkové komponenty s podporou výpočtu vzorců
Pro sociální síť ITnetwork.cz jsem vytvořil tabulkovou komponentu integrovanou
do jejich vlastního komponentového systému.
Práce zahrnovala vytvoření knihovny pro výpočet vzorců podobných těm v Excelu,
podporu pro podmíněné formátování a unit testing.
Pro interní účely potřebovala sociální síť ITnetwork.cz vyvinout tabulkovou komponentu,
která umí vyhodnotit jednoduché vzorce podobné těm v Excelu a následně aplikovat
podmíněné formátování na základě vypočtené hodnoty jednotlivých buněk.
Pro tento účel jsem vyvinul rozšiřitelnou knihovnu pro vyhodnocení tabulkových vzorců.
Následně jsem s použitím komponentového systému od ITnetworku vytvořil tabulkovou komponentu, která rozšiřuje
funkčnost běžné HTML tabulky přidáním záhlaví řádků a sloupců, výpočtem tabulkových vzorců s pomocí zmíněné
knihovny a aplikací podmíněného formátování buněk podle uživatelem definovaných pravidel.
Součástí dohody bylo, že jsem směl zveřejnit kód knihovny starající se o vyhodnocení tabulkových vzorců,
kterou jsem v rámci projektu vyvinul.
Díky tomu jsem mohl i po dokončení zakázky dále pokračovat ve vývoji knihovny formou osobního
projektu a přidat vícero vylepšení, jako například podporu pro editaci buněk.
Protože nechci zacházet do přílišných podrobností o komponentovém systému klienta, tak se
zbytek tohoto článku zaměřuje především na vývoj knihovny na výpočet vzorců.
Výsledek
Jedním z výsledků tohoto projektu je tabulková komponenta s podporou vzorců, která klientovi pomáhá
zjednodušit a zefektivnit některé interní záležitosti.
Dalším výsledkem je knihovna pro výpočet vzorců, která vznikla v rámci projektu a kterou jsem
s povolením klienta mohl po skončení zakázky zveřejnit a dále vylepšovat.
Aby bylo možné demonstrovat schopnosti knihovny bez zacházení do detailů o komponentě
vyvinuté pro klienta, vytvořil jsem pro ukázku také jednoduchý tabulkový editor.
S použitím knihovny pro výpočet vzorců stačilo kolem 300 řádků JavaScriptu na to dát dohromady
jednoduchou tabulku s editovatelnými buňkami, možností vrácení/opakování změn
(Ctrl+Z/Ctrl+Y) a podporou pro ovládání pomocí klávesnice.
Zkuste si pohrát s ukázkou níže. Můžete třeba opravit chybu ve vzorci na D8 (nápověda: myslím, že někdo „zapomněl“
na koncovou závorku).
Nebo můžete pozorovat, jak úprava buňky s cenou či množstvím automaticky aktualizuje i hodnotu buňky s celkovou cenou.
Už od začátku projektu bylo poměrně jasné, že by logika vyhodnocení tabulkových vzorců měla být
obsažena v samostatné knihovně, odděleně od tabulkové komponenty, jejímž úkolem je především
zajistit vhodné zobrazení dat uživateli.
Toto oddělení zodpovědností kromě přehlednějšího kódu také
umožňuje snadno testovat logiku vyhodnocení vzorců odděleně od uživatelského rozhraní
a otevírá možnost znovupoužití knihovny v jiných projektech.
Ukládání dat o buňkách
Pro uchovávání dat o jednotlivých buňkách jsem se rozhodl nepoužít pole nebo datovou strukturu podobnou 2D mřížce,
nýbrž asociativní pole (v JavaScriptu Map), kde jako klíč slouží jméno buňky (např. "B3"). Kromě toho,
že je triviální vyhledat či upravit data určité buňky, tak toto rozhodnutí vychází z následujících pozorování:
Tabulky v naprosté většině případů nejsou zcela zaplněné a můžou obsahovat velké množství prázdných buněk.
Počet buněk v tabulce je šířka * výška, což se může snadno vyšplhat do tisíců, když má tabulka
pár desítek řádků a sloupců.
Pro výpočet hodnot buněk nás příliš nezajímá skutečné umístění buněk či rozměry tabulky.
To hlavní, na čem záleží, je rychlé vyhledání buňky podle jejího názvu.
Použití asociativního pole pro ukládání dat o buňkách umožňuje prázdné buňky jednoduše ignorovat.
Knihovna je díky tomu také méně závislá na tom, jak vypočtené hodnoty budou
ve výsledku použity, například nepotřebuje nic vědět o rozměrech tabulky.
Kdyby byly buňky uložené v poli či podobné datové struktuře, tak by v nejhorším případě velké tabulky s pouze několika
neprázdnými buňkami bylo nutné zbytečně nakládat i s tisíci prázdných buněk.
Syntaktická analýza a vyhodnocování vzorců
Lexikální a syntaktická analýza
Syntaktická analýza (parsování) a vyhodnocování vzorců v buňkách probíhá až v okamžiku, kdy
je hodnota dané buňky potřeba (tzv. „líné vyhodnocení“). Tento postup dává uživateli knihovny
více kontroly nad tím, kdy výpočty probíhají a předchází případné zbytečné práci. Například v tabulce
s tisíci vzorců nemusí dávat smysl rovnou vyhodnotit všechny z nich najednou – některé buňky uživatel možná ani neuvidí.
Pro syntaktickou analýzu vzorců jsem se rozhodl použít tzv.
analýzu rekurzivním sestupem („recursive descent“),
protože je relativně snadná na pochopení, což usnadňuje případnou pozdější úpravu knihovny v případě,
že by bylo by potřeba rozšířit syntaxi vzorců.
Před parsováním vzorců ještě probíhá krok lexikální analýzy („tokenizace“), neboli rozdělení řetězce textu na
menší jednotky („tokeny“) jako jsou čísla, názvy, operátory, závorky atd.
Vyhodnocení vzorců
Konečný výpočet hodnoty buňky probíhá též rekurzivně.
Avšak na rozdíl od syntaktické analýzy, která pracuje pouze s textem aktuální buňky,
je při vyhodnocení někdy potřeba znát i hodnoty dalších buněk, na které vzorec odkazuje.
Aby se zabránilo opakovanému vyhodnocování stejných buněk, jsou jejich vypočtené hodnoty ukládány do cache.
(Po editaci buňky je nutné hodnoty některých buněk přepočítat, ale více o tom později.)
Během vyhodnocování vzorců je také nutné evidovat, které buňky na sebe odkazují,
mimo jiné kvůli detekci kruhových závislostí (např. A1 → B1 → C1 → A1).
Tabulkové funkce
Součástí knihovny jsou i základní tabulkové funkce jako SUM(), AVERAGE() a podobně. Další funkce
dostupné ve vzorcích může uživatel knihovny snadno definovat formou běžných JavaScriptových funkcí.
Knihovna též podporuje funkce, jejichž argumenty musí být vyhodnoceny líně:
například u funkce IF(podminka, kdyzAno, kdyzNe) je vždy vyhodnocen pouze jeden z argumentů
kdyzAno a kdyzNe v závislosti na hodnotě podmínky.
Přidání možnosti úpravy buněk
Původní verze knihovny na vypočet vzorců nepodporovala editaci obsahu buněk,
protože tabulková komponenta, kterou jsem vyvinul pro klienta možnost editace nevyžadovala
(komponenta pouze vykresluje statickou tabulku s vypočtenými hodnotami a podmíněným formátováním).
Možnost úpravy buněk jsem ale přidal později, když jsem knihovnu dále rozvíjel jako osobní projekt.
Přidání podpory pro editaci vyžadovalo zneplatnit hodnoty buněk v cache přímo či nepřímo závislé na
hodnotě právě upravené buňky. Narazil jsem při tom na jeden nepatrný, ale důležitý detail:
editace buňky sice způsobí změnu hodnoty na ní závislých buněk, ale syntaktický strom vzorců
v závislých buňkách se nezmění, jelikož krok syntaktické analýzy vychází pouze
z textu dané buňky a nezávisí na obsahu jiných buněk.
Proto jsem použil oddělenou cache pro spočítané hodnoty a naparsované vzorce buněk (poté, co jsem
ověřil, že cachování výsledku parsování opravdu mělo znatelný vliv na výkon).
Dále jsem do knihovny jsem přidal callback, který je po editaci buňky
zavolán se seznamem všech buněk, jejichž hodnota se v důsledku úpravy změnila.
Uživatel knihovny tak může snadno na změny reagovat, např. aktualizací hodnot zobrazených v uživatelském rozhraní.
Unit testing
Logika pro vyhodnocení vzorců není úplně triviální a poskytuje prostor pro drobné chyby či
nejednoznačně definované chování, obzvláště když navíc do hry přichází cachování.
Proto bylo důležitou součástí tohoto projektu i unit testování.
Pro psaní a spouštění unit testů jsem použil jeden z nejpopulárnějších JavaScriptových
testovacích frameworků, Jest.
Testování knihovny probíhá pouze přes její veřejné rozhraní. Vědomě jsem se vyhnul testování implementačních detailů,
i když kvůli tomu občas bylo testování o něco náročnější. Trocha důvtipu byla například potřeba
pro ověření správného chování složitějších případů ohledně cachování bez přístupu k hodnotám v interní cache.
Testování pouze veřejného rozhraní knihovny má tu výhodu, že je možné snadno refaktorovat vnitřní fungování
části knihovny bez nutnosti zasáhnout do unit testů.
Kdyby bylo potřeba současně s refaktorováním provádět změny v testech,
tak by to nejen znamenalo více práce, ale bylo by také snazší způsobit nechtěné změny v chování knihovny.
Co jsem se naučil
Při práci na tomto projektu jsem si v praxi vyzkoušel, jak užitečné můžou být unit testy, a to
nejen na pouhé otestování funkčnosti.
Všimnul jsem si, jak testy mohou člověku pomoci do detailu si rozmyslet požadované chování a nenechat na náhodě,
jak se bude výsledné řešení (zejména v hraničních případech) chovat.
Také jsem si velmi cenil unit testů při refaktorování kódu, kdy jsem mohl okamžitě vidět,
jestli a kde nastala chyba či změna v chování knihovny. A pokud se i přes testy nějaká chyba vloudila,
tak přidání nového testu pomohlo zajistit, že ke stejné chybě nedojde v budoucnosti znovu.
Tento projekt mě také naučil několik věcí ohledně toho, jak v JavaScriptu zlepšit výkon parsování a
jak zacházet s vestavěným profilerem webových prohlížečů.
Kromě optimalizace kódu parseru a interpreteru bylo důležitou částí také ukládání výsledků
a mezivýsledků výpočtů do cache a jejich zneplatnění v okamžiku,
kdy již uložená hodnota není aktuální.
Poté, co jsem poslední dobou hodně pracoval s Reactem a Next.js, bylo hezké si připomenout, jak zábavné může
být čas od času programovat v čistém JavaScriptu, bez dalších knihoven či frameworků.
Vytvořit jednoduchý tabulkový editor včetně podpory vrácení/opakování změn a ovládání pomocí klávesnice bylo otázkou dvou
odpolední a jen několika set řádků kódu!
Jsem vděčný, že jsem měl možnost pracovat na tak zajímavém projektu a doufám, že se v budoucnu dostanu k tomu
knihovnu pro vyhodnocení vzorců dále vylepšovat.
Už teď mám nápady na vylepšení způsobu výpočtu vzorců a je také stále spousta prostoru pro přidávání
dalších tabulkových funkcí, operátorů či podporovaných datových typů.