Strona główna / Artykuły

Pomijaj pętle odbudowy dla pakietów monorepo z warunkowymi eksportem

W warunkach rozwojowych plik package.json wskazuje na źródło w TypeScript, dzięki czemu Vite, Node i Webpack mogą realizować HMR dla wspólnej interfejsu użytkownika bez ponownego budowania każdego wewnętrznego pakietu.

Wydawca: REACTAPP.TOP6 min czytania1466 słów

Źródła: Avoiding repeatedly building internal packages in a Monorepo

Założenia

Wyobraź sobie monorepo w TypeScript dla Node.js zarządzane za pomocą przestrzeni roboczych Yarn. Może ono zawierać główną aplikację Vite, drugorzędną aplikację Next.js oraz pakiety z wspólnym konfiguratorem Tailwind, wspólną interfejsem JSX, ikonami i podobnymi bibliotekami. Turborepo łączy te elementy, dzięki czemu zależności wewnętrzne są kompilowane we właściwej kolejności:

// Tip: name your turbo config file 'turbo.jsonc' instead of 'turbo.json'. This lets you write comments.
{
  "tasks": {
    // App 1 and it's three internal dependencies
    "@repo/vite-app#build": {
      "outputs": ["dist/**"],
      "dependsOn": [
        "@repo/icons#build",
        "@repo/ui#build",
        "@repo/tailwind-config#build"
      ]
    },
    // App 2 and it's three internal dependencies
    "@repo/next-app#build": {
      "outputs": [".next/**"],
      "dependsOn": [
        "@repo/icons#build",
        "@repo/ui#build",
        "@repo/tailwind-config#build"
      ]
    },
    // Package 1, depends on @repo/icons
    "@repo/ui#build": {
      "outputs": ["dist/**"],
      "dependsOn": ["@repo/icons#build"]
    },
    // Package 2
    "@repo/icons#build": {
      "outputs": ["dist/**"]
    },
    // Package 3
    "@repo/tailwind-config#build": {
      "outputs": ["theming.css", "postcss.config.js"]
    },
    // End of build tasks.
    //
    // The following two tasks are for when the app is started in dev mode.
    "@repo/vite-app#dev": {
      // '^build' below is a way of saying -- this task (@repo/vite-app#dev) depends on the #build tasks of ALL of the packages 'vite-app' depends on.
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    },
    "@repo/next-app#dev": {
      "dependsOn": ["^build"],
      "persistent": true,
      "cache": false
    }
  }
}

Każdy pakiet przeprowadza własną kompilację, wytwarza pliki wynikowe i publikuje je za pomocą package.json, aby aplikacje mogły je importować. Wymienione powyżej narzędzia to jedynie przykład zastosowania. Podobne problemy występują w niemal każdym monorepo Node, gdzie biblioteki wewnętrzne są kompilowane przed ich użyciem przez aplikacje.

Problem

Edycje wewnątrz aplikacji Vite dają natychmiastową reakcję podczas rozwoju: uruchamia się zadanie turbo, HMR przesyła zmiany, a przeglądarka się aktualizuje. Edycje w pakiecie interfejsu użytkownika, który jest importowany przez aplikację Vite, zachowują się inaczej. Uruchomiona aplikacja nadal wykorzystuje wcześniej utworzony plik z pakietu, więc przeglądarka pokazuje przestarzały interfejs, dopóki pakiet nie zostanie ponownie zbudowany.

Zespoły często reagują poprzez obserwowanie folderów pakietów i łączenie skryptów ponownego budowania. To przywraca poprawność, ale kosztem czekania na kompilatory, które nie powinny znajdować się na ścieżce krytycznej przy zmianie jednego elementu interfejsu. Warunkowe eksporty radzą sobie z tym problemem w inny sposób: pozwalają aplikacji na czytanie źródła podczas iteracji.

To pętlenie nie jest zaskakujące, jeśli porównać je z publicznymi pakietami npm: autorzy zmieniają kod źródłowy, kompilują go, a następnie publikują wyniki. Lokalne pakety udostępniane między aplikacjami nadal korzystają z tej samej domyślnej strategii „korzystania z plików wygenerowanych”, mimo że drzewo źródłowe znajduje się w sąsiednim katalogu. Doświadczenie rozwojowe, którego naprawdę pragniemy, polega na tym, aby podczas iteracji importować kod źródłowy, a podczas dystrybucji – importować pliki skompilowane, którym już ufa pipeline produkcyjny.

Ratunkiem są warunkowe eksporty

Plik package.json pakietu jest umową, którą widzą użytkownicy. Dokładniej mówiąc, pole exports określa, które konkretne pliki zostaną zwrócone na podstawie danego żądania importu. Zgodnie z dokumentacją pakietów Node.js, warunkowe eksporty mapują różne ścieżki w zależności od spełnionych warunków:

{
  "exports": {
    "import": "./index-module.js",
    "require": "./index-require.cjs"
  },
  "type": "module"
}
// ... rest of the package.json

To oznacza, że jeden logiczny pakiet może udostępniać alternatywne implementacje. Powszechne wbudowane warunki to:

  • ESM import odnoszący się do ./index-module.js
  • CJS require() odnoszący się do ./index-require.cjs

Ten sam mechanizm może być używany w zależności od warunków związanych z środowiskiem, co umożliwia wygodną obsługę monorepo.

To wspaniale, ale jak to wpływa na mój problem z monorepo?

Pakietom publikowanym rzadko potrzeba czegoś więcej niż import, require oraz ewentualnie default, wszystkie odnoszące się do gotowych plików. Pakiet wewnętrzny może zawierać specjalny warunek odnoszący się do źródeł w formacie TypeScript lub JSX, który jest wybierany tylko podczas lokalnego rozwoju.

Zacznij od typowego mapowania służącego wyłącznie do budowy plików w katalogach components/ i utilities/:

"exports": {
  "./components/*": {
    "default": "./dist/components/*.js" // 'default' export condition only, just for simplicity
  },
  "./utilities/*": {
    "default": "./dist/utilities/*.js"
  }
}

Ponieważ pakiet znajduje się obok swojego źródła w monorepo, rozszerz mapę o gałąź development (a często także o gałąź types, która również czyta źródło):

"exports": {
  "./components/*": {
    // Custom conditions can be named whatever, I've named it
    // 'development' because we'd want to use it when the app
    // is run in dev mode.
    // This file path points to the raw source files.
    "development": "./lib/components/*/index.jsx",
    "default": "./dist/components/*.js" // unchanged from above
  },
  "./utilities/*": {
    "development": "./lib/utilities/*/index.jsx",
    "default": "./dist/utilities/*.js" // unchanged from above
  }
}
"exports": {
    "./components/*": {
      "types": "./lib/components/*/index.tsx", // one additional 'types' condition that lets the consuming app access the types directly from the source
      "development": "./lib/components/*/index.tsx",
      "default": "./dist/components/*.js"
    },
    "./utilities/*": {
      "types": "./lib/utilities/*/index.ts",
      "development": "./lib/utilities/*/index.ts",
      "default": "./dist/utilities/*.js"
    }
  },

Ważne: kolejność kluczy w exports ma znaczenie. Node najpierw sprawdza wpisy o wyższej priorytecie. Umieść ogólną gałąź default na końcu. Jeśli default pojawi się pierwsza, zawsze zwycięży, a specjalistyczne gałęzie nie będą miały żadnego znaczenia.

Gdy mapa jest już przygotowana, pozostałą pracą jest poinformowanie każdego użytkownika, jakie warunki są aktywne w danym trybie.

Fajnie! Ale jak sprawić, by mój aplikacja/pakiet spełniał ten nowy warunek?

Celowe zachowanie jest łatwe do sformułowania. Podczas rozwoju lokalnego odbiorca powinien preferować eksport typu development, aby źródło kodu było ładowane bezpośrednio – jakby plik znajdował się w aplikacji. Podczas budowy w środowisku produkcyjnym odbiorca powinien preferować default (lub production) i nie może przypadkowo wybrać development.

Różne środowiska wykonywania umożliwiają zmianę tego ustawienia w różny sposób.

Aplikacje Vanilla NodeJS

Aplikacje, które działają bez narzędzi do pakietowania, mogą podać flagę --conditions w Node podczas uruchamiania procesu:

node --conditions=development index.js # Or whatever you've named your condition

Każdy pakiet importowany przez ten proces będzie traktował development jako dopuszczalną warunek i użyje odpowiedniego eksportu, jeśli taki istnieje. Aby uruchomić aplikację w środowisku produkcyjnym, wystarczy pominąć tę flagę, dzięki czemu dostępne pozostaną tylko standardowe warunki związane z publikowaniem.

Vite

Vite posiada już listę warunków dozwolonych, uzyskaną na podstawie trybu:

  • W trybie dev: ['module', 'browser', 'development']
  • W trybie build: ['module', 'browser', 'production']

Dlatego pakiet, który deklaruje development, jest automatycznie wybierany podczas pracy w trybie rozwojowym vite i ignorowany podczas budowy w trybie produkcyjnym, gdzie obowiązywałby production, gdyby był zdefiniowany. Listę tę można dostosować za pomocą resolve.conditions, ale domyślne wartości już odpowiadają opisanemu tutaj wzorcowi monorepo.

Uwaga: zarówno w przypadku zwykłego Node, jak i Vite, kolejność plików export-map nadal decyduje o rozstrzygnięciu w przypadku remisów. Wygrywa pierwszy warunek, który jest zarówno obecny w mapie, jak i dozwolony przez środowisko wykonawcze.

Webpack

Webpack również może dostosowywać warunki rozwiązywania, jednak przez domyślność może nie zawierać wartości development w taki sam sposób jak Vite. Aby zobaczyć przykład, konfiguruj wyraźnie resolve.conditionNames:

resolve: {
    conditionNames: ["development", "..."],
},

Gdy użytkownicy rozwiązują źródła podczas rozwoju, Turborepo nie musi już ponownie budować każdego wewnętrznego pakietu, zanim aplikacja będzie mogła odzwierciedlić zmianę w interfejsie użytkownika polegającą na jednej linii kodu. Procesy deweloperskie mogą pominąć długie oczekiwanie na realizację tych zadań budowania pakietów:

// Tip: name your turbo config file 'turbo.jsonc' instead of 'turbo.json'. This lets you write comments.
{
  "tasks": {
    // App 1 and it's three internal dependencies
    "@repo/vite-app#build": {
      "outputs": ["dist/**"],
      "dependsOn": [
        "@repo/icons#build",
        "@repo/ui#build",
        "@repo/tailwind-config#build"
      ]
    },
    // App 2 and it's three internal dependencies
    "@repo/next-app#build": {
      "outputs": [".next/**"],
      "dependsOn": [
        "@repo/icons#build",
        "@repo/ui#build",
        "@repo/tailwind-config#build"
      ]
    },
    // Package 1, depends on @repo/icons
    "@repo/ui#build": {
      "outputs": ["dist/**"],
      "dependsOn": ["@repo/icons#build"]
    },
    // Package 2
    "@repo/icons#build": {
      "outputs": ["dist/**"]
    },
    // Package 3
    "@repo/tailwind-config#build": {
      "outputs": ["theming.css", "postcss.config.js"]
    },
    // End of build tasks.
    //
    // The following two tasks are for when the app is started in dev mode.
    "@repo/vite-app#dev": {
      // #dev tasks no longer depend on the #build tasks of the dependencies.
      "persistent": true,
      "cache": false
    },
    "@repo/next-app#dev": {
      "persistent": true,
      "cache": false
    }
  }
}

Rezultatem jest kontrakt dwumodowy: importy w trybie rozwojowym sprawiają wrażenie edycji jednego kodu, podczas gdy w trybie produkcyjnym nadal pakowane są skompilowane pliki. Taki podział zazwyczaj wystarcza, aby przywrócić pętlę informacyjną HMR dla wspólnego interfejsu użytkownika, nie rezygnując przy tym z dyscypliny budowania, od której zależy produkcja.

Kilka szczegółów operacyjnych zapobiega temu, by ten wzorzec zaskoczył kolegów z zespołu. Zapisz nazwę wybranej warunkowości (development jest konwencjonalna, ale nie magiczna) w dokumencie README monorepo, aby nowe pakiety kopiowały tę samą strukturę eksportu. Upewnij się, że rozszerzenia źródłowe są rozpoznawalne przez transformator używany przez odbiorcę – pipeline JS/TS w Vite już rozumie pliki .tsx, natomiast zwykły proces Node może wymagać loadera. Pamiętaj również, że pomijanie budowy pakietów w trybie rozwojowym nie eliminuje potrzeby ich budowania w środowisku CI: obrazy produkcyjne oraz artefakty przeznaczone do publikacji powinny nadal kompilować biblioteki wewnętrzne, aby hosty w czasie wykonywania nie polegały na surowym TypeScript.

Gdy są używane razem, warunkowe eksporty w połączeniu z nieco bardziej elastycznym modelem turbo graph przekształcają wspólne pakiety z powrotem w zwykłe foldery źródłowe podczas codziennej pracy nad interfejsem użytkownika, co zazwyczaj stanowi główny cel umieszczania tych pakietów w tym samym repozytorium.