Nieco ponad rok temu opisałem proces tworzenia prymitywnego bundlera. Nie tak dawno zacząłem się zastanawiać, czy dałoby się go w prosty sposób dostosować do bundle’owania plików z definicjami typów TS-a. A iż to wciąż jest faktyczny problem, postanowiłem to sprawdzić.
Teoria
W teorii pliki .d.ts wyglądają bardzo podobnie do zwykłych modułów ES. Definicje typów są podzielone na pliki, które odzwierciedlają podział plików źródłowych aplikacji. Dla przykładu, jeżeli mamy takie pliki z kodem:
to TS wygeneruje nam takie pliki z definicjami typów:
Jeśli przyjrzymy się losowemu plikowi .d.ts (ten jest akurat dla lodashowej funkcji assignWith()), zauważymy znajomą składnię importów i eksportów. Więc z perspektywy bundlera zmienia się na dobrą sprawę głównie rozszerzenie pliku i pojawia się nieco inna składnia (TS zamiast czystego JS-a). Niemniej zdecydowana większość logiki bundlera powinna być możliwa do ponownego użycia bez żadnych zmian.
Przykład
Stwórzmy sobie więc przykład, który będziemy chcieli bundle’ować. Składać się on będzie z trzech plików:
- index.d.ts,
- Fixture.d.ts,
- Test.d.ts.
Główny plik, index.d.ts, będzie eksportował wszystkie pozostałe typy:
Żeby było ciekawie, zastosowałem tutaj dwie metody eksportu – import (1) a następnie eksport (2) oraz eksport połączony z importem (3).
Plik Fixture.d.ts zawiera interfejs Fixture:
Najpierw definiuje on interfejs (1) a następnie go eksportuje (2).
Z kolei plik Test.d.ts eksportuje interfejs Test:
Wykorzystuje on interfejs Fixture (1), który importuje z pliku Fixture.d.ts (2). Natomiast eksport jest tutaj bezpośrednio połączony z deklaracją interfejsu (3).
Spróbujmy zatem zbundle’ować te trzy pliki razem!
Obsługa składni TypeScriptu
Babel jest parserem JS-a, więc nie ma wbudowanej obsługi składni TS-a. Niemniej istnieje oficjalny plugin, który taką obsługę dodaje. Wystarczy go zainstalować:
a następnie dołączyć do naszego parsera w bundlerze:
Do parsera dodajemy opcję plugins (1), która pobiera tablicę pluginów wraz z opcjami dla nich. Dodajemy do niej plugin typescript (2) wraz z opcją dts ustawioną na true (3), która pozwala na parsowanie właśnie plików .d.ts.
Yay, nasz bundler JS-a właśnie stał się bundlerem plików .d.ts!
Ścieżki do plików bez rozszerzeń
I choć nasz bundler już w teorii powinien radzić sobie z plikami .d.ts, to TS ma kilka przypadłości składniowych, które uniemożliwiają mu sensowne działanie. Jedną z nich jest omijanie rozszerzeń plików w importach, np:
Tak naprawdę import odbywa się z pliku Fixture.d.ts, nie zaś – Fixture. Musimy wziąć na to poprawkę i przygotować prostą funkcję, która będzie nam zamieniać ścieżki z importów na poprawne ścieżki do plików:
Sprawdzamy, czy ścieżka kończy się rozszerzeniem .d.ts (1) i jeżeli nie, to po prostu je dodajemy i zwracamy tak zmodyfikowaną ścieżką (2). W innym wypadku zwracamy oryginalną ścieżkę (3).
Teraz wypada dodać tę zmianę do kodu bundlera. Należy podmienić dwie linijki odpowiadające za wczytanie importowanego pliku na poniższy kod:
A iż tę logikę będziemy wykorzystywać też w innym miejscu (spoilers…), to wyciągnijmy sobie ją od razu do osobnej funkcji, processImport():
Przekazywane parametry to:
- node – czyli węzeł AST z importem,
- dir – katalog pliku importującego (wzięty z funkcji processModule()),
- modules – tablica modułów (wzięta z funkcji processModule()).
Lepsza obsługa eksportów
Pliki z definicjami typów raczej eksportują typy, więc byłoby miło, gdyby nasz bundler nie wycinał eksportów – ale tylko w głównym pliku (czyli tym, od którego zaczynamy bundle’owanie), bo inaczej dostaniemy niepoprawny składniowo plik, np:
Potrzebujemy więc sposobu, aby rozpoznawać, czy aktualnie obsługiwany plik jest tym głównym, czy nie. W tym celu wystarczy dodać parametr isMain do processModule():
W chwili, gdy będziemy zaczynać całe bundle’owanie, ustawimy go na true, a we wszystkich innych przypadkach – na false (lub całkowicie pominiemy i pozwolimy przyjąć mu domyślną wartość, czyli właśnie false).
Jednak proste wycinanie wszystkich eksportów z importowanych plików nie zadziała, ponieważ wytnie też konstrukcje typu export interface Test {}, w których deklaracja jest bezpośrednio w eksporcie. Z tego też powodu trzeba zastąpić usuwanie eksportu funkcją handleExport():
Trochę się tu dzieje, więc przyjrzyjmy się po kolei poszczególnym fragmentom. Na sam początek jest obsługa sytuacji, w których eksport jest połączony z importem (export { Something } from './file'):
Na początku pobieramy sobie węzeł eksportu do zmiennej (1), następnie sprawdzamy, czy ma ustawioną własność source (2). To właśnie ona wskazuje na plik, z którego eksportujemy. jeżeli tak, odpalamy na tym eksporcie opisaną już wcześniej funkcję processImport() (3). Wszystkie potrzebne parametry dostajemy z zewnątrz, z funkcji processModule().
Następny fragment dodaje dodatkową logikę dla takich eksportów w głównym pliku. Jest to spowodowane tym, iż musimy w nich zamienić eksport z zewnętrznego pliku na eksport lokalnego typu:
W tym celu używamy path.replaceWith():
Funkcja exportNamedDeclaration() (1) pochodzi z pakietu @babel/types i służy do tworzenia nowych deklaracji nazwanych eksportów. Przekazujemy do niej dane ze starego eksportu, dzięki czemu powstaje taki sam eksport, ale już bez informacji o zewnętrznym pliku. Z kolei return (2) pozwala zakończyć obsługę tego eksportu w tym miejscu, co pozwala zmniejszyć liczbę zagłębień i else-ów w reszcie funkcji handleExport().
To jest też cała logika dla głównego pliku, więc jeżeli w nim jesteśmy, teraz jest pora, by wyjść:
Następny fragment dotyczy obsługi eksportów połączonych z deklaracją (export interface Test {}) w importowanych plikach (w głównym takich eksportów nie ruszamy, bo i nie ma po co – główny plik powinien bez przeszkód eksportować):
Żeby wykryć taki eksport, sprawdzamy, czy zawiera deklarację (1). jeżeli tak, podmieniamy eksport na tę deklarację (2) i wychodzimy z funkcji handleExport() (3).
I w końcu, gdy mamy do czynienia z jakimkolwiek innym rodzajem eksportu, najzwyczajniej w świecie go usuwamy:
To pozwoli pozbyć się z importowanych plików konstrukcji typu export { Fixture }.
I to tyle, stworzyliśmy prymitywny bundler typów!
Możliwe ścieżki rozwoju
Podobnie do “normalnego” bundlera, który był dość prymitywny, tak i bundler typów jest mocno prymitywny i radzi sobie tylko z najprostszymi konstrukcjami. Istnieje zatem szereg możliwych usprawnień, np:
- obsługa aliasów w eksportach – często eksporty zawierają aliasy typu export { default as someName } from './File'; i w tej chwili bundler całkowicie sobie nie radzi z takimi aliasami (zostawia je bez zmian),
- obsługa składni export = SomeThing; – w tej chwili ta składnia działa częściowo; po prostu nie jest traktowana jako eksport, więc jest zostawiana bez zmian, co powinno działać w głównym pliku, ale już nie w importowanych,
- dodanie tree shakingu – wiedząc, jakich typów potrzebujemy, możemy importować tylko te potrzebne z poszczególnych plików .d.ts,
- dodanie zabezpieczenia przed konfliktami w importach – niektóre importowane typy mogą mieć takie same nazwy (np. Node z modułu obsługującego HTML i Node z modułu obsługującego SVG), więc warto byłoby się przed tym zabezpieczyć, choćby generując unikatowe nazwy dla wszystkich niepublicznych typów.
To oczywiście nie wszystkie możliwości, jedynie kilka luźnych propozycji. W żadnym razie bundler, jaki stworzyłem, nie jest produkcyjny i przy każdym bardziej zaawansowanym pliku .d.ts wyglebi się na pierwszej nierówności. Do poważnych zastosowań wypadałoby wybrać coś bardziej sprawdzonego, np. rollup-plugin-dts.