Minifikace JavaScriptu

Školení, která pořádám

Článek vyšel na serveru Zdroják.

Přiznám se, že cizí knihovny nepoužívám moc ochotně. Nejdřív strávím spoustu času hledáním té, která by mi vyhovovala. Pak často zjistím, že mi stejně něčím nevyhovuje. Když to opravím, pošlu změnu autorovi a on ji přijme, tak čekám na vydání nové verze. Když vyjde, tak musím otestovat, jestli se neporouchalo něco jiného. Cizí knihovny taky často mají závislosti (např. na Javě), které já vyžadovat nechci.

Často si proto radši něco spíchnu sám, protože s tím mám nakonec míň starostí, strávím tím míň času a dělám to, co mě baví mnohem víc, než hledat, zkoušet a domlouvat.

Když jsem hledal minifikátor JavaScriptu pro použití v Admineru, tak jsem se podíval na stávající řešení a vybral JSMin pro PHP. Ten mi v zásadě vyhovoval: neměl žádné závislosti, dělal skoro přesně to, co jsem chtěl, aktualizace probíhaly hladce. Navíc v podobný čas jako Adminer přešel ze SVN na Git, takže i provázání repozitářů bylo bez problémů.

Než…

Než se dobrá duše rozhodla Adminer zpřístupnit jako balík Debianu. JSMin má totiž celkem běžnou licenci, ale s nezvyklým dovětkem: “The Software shall be used for Good, not Evil.” A podle dogmatiků to není dost svobodné, protože chudáci zlořádi teď nemůžou využívat JSMin pro páchání zla. Dokonce by se projekty využívající JSMin neměly hostovat na Google Code.

Když by autor změnil slovo shall na should, tak by se všechno vyžehlilo, ten o tom ale nechce ani slyšet (a já se mu nedivím).

Takže jsem byl slušně požádán, jestli bych nemohl JSMin z Admineru vyhodit. A i když existují jiné kvalitní nástroje, především Google Closure Compiler, tak než to zase řešit, rozhodl jsem se jako obvykle, že si něco spíchnu sám.

JsShrink

Úlohu jsem vyřešil pomocí regulárního výrazu. Ten přeskakuje všechno, co má v JavaScriptu zvláštní význam (řetězce uzavřené do uvozovek nebo do apostrofů a regulární výrazy uzavřené do lomítek). Ze zbytku odstraňuje mezery a komentáře, případně je nahradí novým řádkem, pokud by došlo k nežádoucímu slepení, např. v kódu var a. Mimochodem nový řádek je mnohem důmyslnější než mezera, která se používá obvykle – zabírá shodně jeden bajt a nevytváří kilometr dlouhé řádky, takže se dá kód v nouzi i ladit a nepřekáží při prohledávání.

Nejsložitější jsou JavaScriptové regulární výrazy. Jejich oddělovač koliduje s operátorem dělení a se začátkem komentářů, takže je potřeba sledovat kontext, ve kterém se lomítko vyskytuje. Navíc ani neošetřené lomítko ještě regulární výraz nutně neukončuje: /[/]/ je platný regulární výraz.

<?php
/** Remove spaces and comments from JavaScript code
* @param string code with commands terminated by semicolon
* @return string shrinked code
* @link http://vrana.github.io/JsShrink/
* @author Jakub Vrana, http://www.vrana.cz/
* @copyright 2012 Jakub Vrana
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0
* @license http://www.gnu.org/licenses/gpl-2.0.html GNU General Public License, version 2 (one or other)
*/
function jsShrink($input) {
    return preg_replace_callback('(
        (?:
            (^|[-+\([{}=,:;!%^&*|?~]|/(?![/*])|return|throw) # context before regexp
            (?:\s|//[^\n]*+\n|/\*(?:[^*]|\*(?!/))*+\*/)* # optional space
            (/(?![/*])(?:\\\\[^\n]|[^[\n/\\\\]|\[(?:\\\\[^\n]|[^]])++)+/) # regexp
            |(^
                |\'(?:\\\\.|[^\n\'\\\\])*\'
                |"(?:\\\\.|[^\n"\\\\])*"
                |([0-9A-Za-z_$]+)
                |([-+]+)
                |.
            )
        )(?:\s|//[^\n]*+\n|/\*(?:[^*]|\*(?!/))*+\*/)* # optional space
    )sx', 'jsShrinkCallback', "$input\n");
}

function jsShrinkCallback($match) {
    static $last = '';
    $match += array_fill(1, 5, null); // avoid E_NOTICE
    list(, $context, $regexp, $result, $word, $operator) = $match;
    if ($word != '') {
        $result = ($last == 'word' ? "\n" : ($last == 'return' ? " " : "")) . $result;
        $last = ($word == 'return' || $word == 'throw' || $word == 'break' ? 'return' : 'word');
    } elseif ($operator) {
        $result = ($last == $operator[0] ? "\n" : "") . $result;
        $last = $operator[0];
    } else {
        if ($regexp) {
            $result = $context . ($context == '/' ? "\n" : "") . $regexp;
        }
        $last = '';
    }
    return $result;
}
?>

Funkce vyžaduje příkazy ukončené středníkem, nespokojí se s jejich ukončením novým řádkem (to mimochodem nesplňují frameworky Dojo a Ext JS). Jinak by se měla vypořádat i s těmi největšími špeky. Např. JSMin nepodporuje platný výraz a++ + +2, zmiňuje to dokonce i v dokumentaci. Dojo ShrinkSafe si zase vyláme zuby na výrazu 8 / /.*/ / 2. Přijde vám výraz nesmyslný? Jistě, ale validní JavaScript to je. Navíc může vracet platný výsledek po následující definici:

RegExp.prototype.toString = function () {
	return 2;
};

ShrinkSafe si ostatně vyláme zuby i na zmíněném /[/]/ a a++ + +2 a chvalozpěv na domácí stránce o tom, jak je tento nástroj bezpečný, protože nepoužívá křehké regulární výrazy, mi přijde poněkud nemístný.

Implementace v JavaScriptu

Regulární výraz je tak jednoduchý (např. nepoužívá lookbehind aserce), že se dá převést i do JavaScriptu, takže si ho můžete vyzkoušet přímo ve svém prohlížeči.

Srovnání

O tom, proč JavaScript zkracovat, včetně popisu dostupných nástrojů vyšel dříve článek na Zdrojáku.

KódoriginálJsShrinkJSMinShrinkSafeGCC WSGCC AdvYUI
var a = 1;108910808
a++ + +2;99chybachyba97chyba
8 / / .* / / 2;1512chybachyba1212chyba
/[/]/;667chyba666
jQuery 1.7.1248 235138 572139 171123 951136 45184 197104 684
jQuery 1.7.1 gzip72 44839 62639 75540 07739 65031 57936 194

Z porovnávaných nástrojů vychází zdaleka nejlépe Google Closure Compiler. Dokáže správně zpracovat všechny vstupy a poskytuje velikostně nejlepší výsledky. Dokonce i ve whitespace only režimu si vede lépe než JsShrink, protože ve skutečnosti neodstraňuje jen bílé znaky, ale i zbytečné středníky, závorky a podobně.

JSMin a ShrinkSafe si se spoustou vstupů neporadily, jiné vstupy byly schopné dokonce i prodloužit (kvůli přidaným prázdným řádkám na začátek nebo konec souboru). Jedině u jQuery si ShrinkSafe vedl slušně, protože zkracuje i názvy proměnných. Po zagzipování se ale tato výhoda úplně ztratí a kód je opět větší než u JsShrink.

YUI Compressor si u jQuery díky agresivnější minimalizaci proměnných vede dobře, zákeřné vstupy ale zpracovat nedokáže.

Závěr

JsShrink se zaměřuje pouze na vypuštění mezer a komentářů, neprovádí zkrácení názvů proměnných, ani jiná kouzla. Dělá to ale svědomitě, snaží se nevyprodukovat jediný zbytečný bajt a zpracovat všechny platné vstupy. Používá se přes jednoduché API v PHP nebo JavaScriptu.

Jakub Vrána, Řešení problému, 12.3.2012, diskuse: 18 (nové: 0)

Diskuse

ikona Jan Tojnar:

Nemáte náhodou zkušenost s obdobným minifikátorem pro php? Měl by dokázat slučovat soubory (včetně cachovaných šablon) a vypouštět nepotřebné třídy (používám nette framework), nejlépe i odstranit zmínky o autoloaderu. Nic podobného se mi najít nepodařilo, tak by mě alespoň zajímal nějaký minifikátor, který by se na toto chování nechal snadno upravit.

ikona Jakub Vrána OpenID:

Základem je http://latrine.dgx.cz/jak-zredukovat-php-skripty. Adminer doplňuje svoje specifika: https://github.com/vrana/adminer/blob/master/compile.php. Způsob minifikace Nette je také veřejný: https://github.com/nette/build-tools/tree/master/tasks. O úplně univerzálním nástroji nevím.

Juan:

Pěkné, akorát hlásím, že s bootstrap.js (http://pastebin.com/A3UFvssy) od Twitteru (http://twitter.github.com/bootstrap) je po minifikaci nefunknčí (minimálně v Chrome).

ikona Jakub Vrána OpenID:

Jako obvykle je příčina v tom, že příkazy nejsou ukončené středníkem. Doporučuji nejprve zkontrolovat JSHintem nebo JSLintem.

Juan:

No jo, to mě taky mohlo napadnout, když to článek zmiňuje. Každopádně - bylo by složité funkci upravit tak, aby akceptovala i příkazy bez středníku?

ikona Jakub Vrána OpenID:

Ano, to by bylo složité.

Honza:

Jak to? Stačilo by ponechat stávající odřádkování skriptu.

ikona Jakub Vrána OpenID:

To by ale vedlo k plýtvání v případě, kdy to není potřeba.

Honza:

Škoda, JsShrink by byl mnohem univerzálnější (viz třeba ten Twitter Bootstrap JS).

Nejprve projíždět skripty JSHintem nebo rovnou vylučovat knihovny, které nepoužívají středníky ničí to kouzlo jendoduchosti.

ikona Jakub Vrána OpenID:

Souhlasím, je to velká slabina. Bohužel k jejímu odstranění by to bylo potřeba celé předělat.

Honza:

Dělá to tak JSMin, myslím.

Kirara:

Nechybí ve výrazu ukončení hranaté závorky "\]" uvnitř regulárního výrazu? Hned za "++" kvantifikátorem. Vím, že to projde i bez toho, ale to by pak prošlo i bez ukončovacích uvozovek, ne?

ikona Jakub Vrána OpenID:

Ukončovací uvozovky tam být musí, protože bez nich by ten znak příště strefil začátek řetězce.

S tím ] si to ještě rozmyslím. Podle mě tam být nemusí, protože v dalším kontextu nemá žádný zvláštní význam. Ale stejně to tam asi doplním, ať je to jasnější. Díky za postřeh.

Každopádně protipříklad, který tím neprojde, by pomohl.

Kirara:

Popravdě asi jediný důvod proč jsem myslel, že by to tak mělo být je určitá konzistentnost. Z výrazu jasně vyplývá, že komentář je ukončen */, regulární výraz je ukončen /, to samé uvozovky a apostrofy, ale [ ve výrazu viditelně ukončeno není.

Jasně komentáře chceš odstraňovat a reg. výrazy a uvozovky jsou začaty a ukončeny stejným znakem, takže tam to nejde nechat. Jakkoliv mě skutečnost, že ] ve skutečnosti nemá žádný zvláštní význam, přijde zvláštní, je to fakt. Lidsky přirozené by mi přišlo, kdyby [ mělo zvláštní význam jen v páru s ] a tudíž třeba /a[bc/ byl platný výraz ve kterém [ nemá zvláštní význam. Ale tak to není právě proto, že v hranatých závorkách nemá zvláštní význam delimiter.

Měl bych argument pro jinou situaci, kde bys z tohoto výrazu chtěl mít nějaký podrobný výstup, např. nějaký tokenizér nebo zvýrazňovač syntaxe. Pak bys asi chtěl, aby ] prošlo stejným "pravidlem" a bylo vráceno či zvýrazněno spolu se svým párovým bratříčkem.

Schmutzka:

Pěkné, vyzkoušel jsem hned ve WebLoaderu jako filter: https://gist.github.com/2026081

Petr Král:

Moc pěkné, můžu se optat jak dlouho Vám trvalo toto vytvořit? :-)

ikona Jakub Vrána OpenID:

Základní verzi jsem měl hotovou za večer. Pak jsem nad tím ještě pár dní ve volných přemýšlel, občas změnil nějaký znak nebo něco přesunul. Mezery se kupříkladu nejdřív parsovaly před ostatními výrazy, což se ukázalo jako neprůchozí, takže jsem to musel přesunout.

David:

Perfektní, přesně tohle jsem hledal. Díky Kubo! :)

Vložit komentář

Používejte diakritiku. Vstup se chápe jako čistý text, ale URL budou převedeny na odkazy a PHP kód uzavřený do <?php ?> bude zvýrazněn. Pokud máte dotaz, který nesouvisí s článkem, zkuste raději diskusi o PHP, zde se odpovědi pravděpodobně nedočkáte.

Jméno: URL:

avatar © 2005-2018 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.