Tworzymy własny bundler typów

blog.comandeer.pl 2 lat temu

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:

src/ |- index.ts |- tools.ts |- SomeClass.ts

to TS wygeneruje nam takie pliki z definicjami typów:

types/ |- index.d.ts |- tools.d.ts |- SomeClass.d.ts

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:

import { Test } from './Test'; // 1 export { Test }; // 2 export { Fixture } from './Fixture'; // 3

Ż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:

interface Fixture { // 1 name: string; path: string; } export { Fixture }; // 2

Najpierw definiuje on interfejs (1) a następnie go eksportuje (2).

Z kolei plik Test.d.ts eksportuje interfejs Test:

import { Fixture } from './Fixture'; // 2 export interface Test { // 3 readonly name: string; createFixture( name: string ): Fixture; // 1 }

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ć:

npm i @babel/plugin-transform-typescript

a następnie dołączyć do naszego parsera w bundlerze:

function processModule( path, isMain = false ) { [] const ast = parse( code, { sourceType: 'module', plugins: [ // 1 [ 'typescript', // 2 { dts: true // 3 } ] ] } ); [] }

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:

import { Fixture } from './Fixture';

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:

function createFilePath( importSpecifier ) { if ( !importSpecifier.endsWith( '.d.ts' ) ) { // 1 return `${ importSpecifier }.d.ts`; // 2 } return importSpecifier; // 3 }

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:

const importRelativePath = createFilePath( node.source.value ); const depPath = resolvePath( dir, importRelativePath ); modules.push( ...processModule( depPath ) );

A iż tę logikę będziemy wykorzystywać też w innym miejscu (spoilers…), to wyciągnijmy sobie ją od razu do osobnej funkcji, processImport():

function processImport( node, dir, modules ) { const importRelativePath = createFilePath( node.source.value ); const depPath = resolvePath( dir, importRelativePath ); modules.push( ...processModule( depPath ) ); }

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:

export interface Test { [] } export { Test };

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():

function processModule( path, isMain = false ) { [] }

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():

function handleExport( path, { isMain, dir, modules } ) { const node = path.node; if ( node.source ) { processImport( node, dir, modules ); } if ( isMain && node.source ) { path.replaceWith( exportNamedDeclaration( node.declaration, node.specifiers ) ); return; } if ( isMain ) { return; } if ( node.declaration ) { path.replaceWith( node.declaration ); return; } path.remove(); }

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'):

const node = path.node; // 1 if ( node.source ) { // 2 processImport( node, dir, modules ); // 3 }

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:

export { Fixture } from './Fixture'; // trzeba zmienić na: export { Fixture };

W tym celu używamy path.replaceWith():

if ( isMain && node.source ) { path.replaceWith( exportNamedDeclaration( node.declaration, node.specifiers ) ); // 1 return; // 2 }

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ść:

if ( isMain ) { return; }

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ć):

if ( node.declaration ) { // 1 path.replaceWith( node.declaration ); // 2 return; // 3 }

Ż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:

path.remove();

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.

Bundler jest dostępny na GitHubie.

Idź do oryginalnego materiału