O projektu

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 pod­mí­ně­né formátování na základě vypočtené hodnoty jednotlivých buněk.

Pro tento účel jsem vyvinul roz­ši­ři­tel­nou knihovnu pro vyhodnocení ta­bul­ko­vých vzorců. Následně jsem s použitím kom­po­nen­to­vé­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 sloup­ců, výpočtem tabulkových vzorců s pomocí zmíněné knihovny a aplikací pod­mí­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 do­kon­če­ní 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é de­mon­stro­vat 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 jed­no­du­chou tabulku s edi­to­va­tel­ný­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.

Demo tabulkového editoru
Fullscreen

V případě zájmu se také můžete podívat na syntaxi tabulkových vzorců, případně mrknout na zdrojový kód tabulkového editoru. (Ne­za­po­meň­te, že jde pouze o rychlou demonstraci možného použití knihovny a kód není úplně ukázkový.)

Můj přístup

Oddělení UI a výpočtu vzorců

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ž úko­lem je především zajistit vhodné zobrazení dat uživateli. Toto oddělení zod­po­věd­nos­tí kromě pře­hled­něj­ší­ho kódu také umožňuje snadno testovat logiku vyhodnocení vzorců od­dě­le­ně od uži­va­tel­ské­ho rozhraní a otevírá možnost znovupoužití knihovny v jiných projektech.

Přehled rozhraní knihovny.

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í aso­ci­a­tiv­ní­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 syn­tak­tic­kou 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.

Kroky výpočtu vzorce.

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 vy­hod­no­co­vá­ní stejných buněk, jsou jejich vypočtené ho­dno­ty 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 vy­hod­no­co­vá­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 Ja­va­Scrip­to­vý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ů kdyzAnokdyzNe v závislosti na hodnotě podmínky.

Přidání možnosti úpravy buněk

Původní verze knihovny na vypočet vzorců ne­pod­po­ro­va­la editaci obsahu buněk, protože tabulková komponenta, kterou jsem vyvinul pro klienta možnost editace ne­vy­ža­do­va­la (kom­po­nen­ta pouze vykresluje statickou tabulku s vypočtenými hodnotami a pod­mí­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­ži­tý 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ži­va­tel­ském rozhraní.

Unit testing

Logika pro vyhodnocení vzorců není úplně triviální a poskytuje prostor pro drobné chyby či ne­jed­no­znač­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 nej­po­pu­lár­něj­ších Ja­va­Scrip­to­vých testovacích frameworků, Jest.

Testování knihovny probíhá pouze přes její veřejné rozhraní. Vědomě jsem se vyhnul testování im­ple­men­tač­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 re­fak­to­ro­vat vnitřní fungování části knihovny bez nutnosti zasáhnout do unit testů. Kdyby bylo po­tře­ba současně s re­fak­to­ro­vá­ním provádět změny v testech, tak by to nejen zna­me­na­lo 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 roz­mys­let požadované chování a nenechat na náhodě, jak se bude vý­sled­né řešení (zejména v hraničních případech) chovat. Také jsem si velmi cenil unit testů při re­fak­to­ro­vá­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 par­so­vá­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 me­zi­vý­sled­ků 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 pod­po­ro­va­ných datových typů.