Jak przejść z Webpack (Vue CLI) na Vite?
Published: 2022-02-18 12:20:14

Dnia 07.02.22 Vue 3 stał się główną wersją tego frameworka. Dla wielu autorów bibliotek, którzy myśleli, że ten dzień jeszcze nie nadejdzie, mógł to być szok. Ale ja chcę skupić Twoją uwagę na innym przypadku - może uczestniczysz w projekcie, który jest zbudowany na Vue 3, ale został uruchomiony z pomocą Vue CLI - bo w momencie uruchamiania projektu była to bardzo dobra decyzja. Ale dziś Evan You głosi zupełnie inną prawdę - łatwym początkiem uruchomienia projektu na Vue 3 jest narzędzie: `create-vue` a wynikiem jego działania jest zupełnie inny zestaw narzędzi. 

Minęły miesiące, twój projekt jest już wdrożony na produkcji, a jednocześnie jest dalej rozwijany, ponieważ pojawiają się kolejni klienci oraz kolejne feature requesty. Może myślisz: wszystko działa dobrze, to po co to zmieniać? Jeśli jesteś doświadczonym programistą, to prawdopodobnie znasz na to pytanie odpowiedź - to dług technologiczny. Budując jakiekolwiek rozwiązania w sensowym czasie, bazujemy na pracy innych programistów, zawartej w różnych bibliotekach. Przecież właśnie dlatego korzystamy z Vue, mnóstwa innych bibliotek oraz narzędzi programistycznych takich jak: Webpack, Jest, Cypress, ESLint, Stylelint, Prettier itp. itd.

## Trzy powody do zmiany

Nasz JS-owy światek bardzo szybko ewoluuje. Popularny żart, że programiści JS uczą się nowego frameworka co tydzień, ma w sobie małe ziarenko prawdy. Kierunek rozwoju jest wytyczany przez dwie siły rządzące ekosystemem JS: TC-39 - czyli komisję odpowiadającą za tworzenie kolejnych wersji standardu ECMAScript, który jest podwalinami pod implementacje, które trafiają do środowisk uruchomieniowych (czyli przeglądarek, NodeJS, Deno itp.) oraz community - czyli nas programistów, którzy tworzą biblioteki, narzędzia czy dzielą się patternami na różne rozwiązania.

Drugi powód do zmiany to środowisko Vue, które także ewoluowało. Evan You stworzył świetny bundler - Vite, który jest aktualnie głównym punktem startowym dla wszystkich nowych projektów we Vue 3. Co za tym idzie, nie jest to tylko zmiana narzędzia. Nie jest to kwestia typu: tu masz młotek ze zwykłą rączką, a tu z rączką ergonomiczną. Ta zmiana jest o wiele bardziej głęboka, ponieważ Vite nie opiera się o system modułów Node (CommonJS) a o natywne moduły ECMAScript (ESM). Te różnice powodują wiele innych zmian i jednocześnie otwierają nowe możliwości. 

Dlatego projekt oparty o Vite, choć niekoniecznie musi wymusić przebudowę naszego kodu, to zmieni niektóre narzędzia, z których dotychczas korzystamy lub ich konfigurację czy sposób w jaki z nich korzystamy. Dlatego coraz więcej autorów bibliotek dba o to, żeby umieszczać obie wersje modułów w swoich buildach, a niektórzy przechodzą wyłącznie na moduły ESM. Dlatego nie warto odkładać migracji na później, tu naprawdę ma zastosowanie powiedzenie - jeśli stoisz w miejscu, to się cofasz.

Trzeci powód do zmiany jest prosty i myślę, że najbardziej odczuwalny w codziennej pracy. Mam tu na myśli szybkość uruchamiania. Wspomniany przeze mnie przykład projektu nie jest wymyślony. W Evionica jednym z projektów jest [W&B 2.0](https://evionica.com/services/weight_and_balance.html), który jest developwany od ponad 10 miesięcy oraz wdrożony produkcyjnie u kilku klientów. Został uruchomiony z pomocą Vue-CLI v4.5., czyli działa w oparciu o Webpack 4, a co za tym idzie, prędkość uruchamiania tego projektu to... 33 s. To normalny czas dla Webpacka 4 - nic porażającego. Jednak po szybkiej próbie przeniesienia tego projektu na Vite, prędkość uruchamiania wersji deweloperskiej spada do <1 s. Więc chyba jest o co powalczyć. 

## Migrujemy Webpacka!

Teraz w kolejnych krokach pokażę, jak może wyglądać migracja dużego projektu uruchomionego przy pomocy VueCLI, budowanego przez Webpack na Vite. Jaki jest plan?
- instalujemy i konfigurujemy Vite
- badamy konfigurację Webpacka i znajdujemy elementy konfiguracji oraz pluginy, które trzeba przenieść
- migrujemy konfigurację 
- odszukujemy analogiczne pluginy lub rozwiązania dające ten sam efekt


Instalacja i konfiguracja Vite

```
yarn add -D vite @vitejs/plugin-vue
```

Tworzymy plik `vite.config.ts` z podstawową konfiguracją

```ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
});
```

Teraz musimy znaleźć odpowiedniki rzeczy, które mamy w aktualnym projekcie, a które stanowią plugin do Webpacka lub część konfiguracji projektu (choćby customowe aliasy). Zacznijmy od aliasów - znajdziemy je w pliku konfiguracji webpacka, jak i konfiguracji TypeScript'a.

```json
{
  "compilerOptions": {
    // TYPICAL TSCONFIG NOTHING FANCY

    "paths": {
      "@/*": [
        "src/*"
      ],
      "@np/*": [
        "src/modes/narrow-pax/*"
      ],
      "@wf/*": [
        "src/modes/wide-freighter/*"
      ],
      "~~/*": [
        "tests/*"
      ]
    }
  }
}
```

Z tego co widzimy, wystarczą nam następujące aliasy: `@`, `@np`, `@wf` i  `~~`. Zatem skonfigurujmy je w Vite.

```ts
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { fileURLToPath } from 'url'

export default defineConfig({
    plugins: [vue()],

    resolve: {
        alias: {
            '@': fileURLToPath(new URL('./src', import.meta.url)),
            '@np': fileURLToPath(new URL('./src/modes/narrow-pax', import.meta.url)),
            '@wf': fileURLToPath(new URL('./src/modes/wide-freighter', import.meta.url)),
            '~~': fileURLToPath(new URL('./tests', import.meta.url)),
        },
    },
})
```

Zajrzyjmy teraz do konfiguracji webpacka.

```js
/* eslint-disable @typescript-eslint/no-var-requires */
const clearTestId = require('unplugin-clear-testid/webpack');
const webpack = require('webpack');

const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');

const manifestOptions = require('./manifest');
const packageJson = fs.readFileSync('./package.json').toString();

const mapSizeToDimensions = {
    ['A4']: {
        width: '210mm',
        height: '297mm',
    },
    ['letter']: {
        width: '215.9mm', // 8.5in
        height: '279.4mm', // 11in
    },
};

let gitLastCommitHash = '';

try {
    gitLastCommitHash = childProcess
        .execSync('git rev-parse --short HEAD')
        .toString()
        .trim();
} catch (e) {
    console.error(e);
}

/* eslint-disable @typescript-eslint/camelcase */
module.exports = {
    publicPath: '/',
    productionSourceMap: process.env.NODE_ENV !== 'production',
    configureWebpack: {
        resolve: {
            alias: {
                '@np': path.resolve(__dirname, 'src/modes/narrow-pax'),
                '@wf': path.resolve(__dirname, 'src/modes/wide-freighter'),
            },
        },
        plugins: [
            new webpack.DefinePlugin({
                'process.env': {
                    VUE_APP_VERSION: JSON.stringify(JSON.parse(packageJson).version || 0),
                    VUE_APP_COMMIT_HASH: JSON.stringify(gitLastCommitHash),
                },
            }),
            new webpack.optimize.LimitChunkCountPlugin({
                maxChunks: 1,
            }),
            clearTestId.default({
                attrs: ['data-testid', 'data-cy'],
                testing: process.env.NODE_ENV === 'testing',
            }),
        ],
        output: {
            chunkFilename: '[id].js',
        },
    },
    css: {
        loaderOptions: {
            sass: {
                additionalData: `$DEFAULT_PAGE_SIZE: ${process.env.VUE_APP_DEFAULT_PAGE_SIZE}; $PAGE_HEIGHT: ${
                    mapSizeToDimensions[process.env.VUE_APP_DEFAULT_PAGE_SIZE].height
                }; $PAGE_WIDTH: ${mapSizeToDimensions[process.env.VUE_APP_DEFAULT_PAGE_SIZE].width};`,
            },
        },
    },
};

```

Możemy tu wyróżnić kilka elementów, które musimy przenieść:
- tworzenie sourceMap w trybie dev
- plugin `clearTestId`
- limitację w tworzeniu chunków
- konfigurację sass
- własny plugin wstrzykujący dwie zmienne środowiskowe

### Source-mapy w trybie dev

Zaczynamy po kolei, zacznijmy od source mapy. W Vite odpowiada za to opcja `build.sourcemap`, która domyślnie jest na `false` - zatem mamy to samo out-of-the-box. Gładko poszło :) 

### Plugin `clearTestId`

Kolejna rzecz to `unplugin-clear-testid`. Gdy widzisz, że nazwa jakiegoś pluginu zaczyna się od słów `unplugin` oznacza to, że autor bazuje na rozwiązaniu, które pozwala zbudować plugin raz w kilku wersjach dla różnych bundlerów: Webpack, Rollup i Vite. Zatem jedyne co musimy zrobić to:

```ts
import vue from '@vitejs/plugin-vue';
import clearTestId from 'unplugin-clear-testid/vite'
import { defineConfig } from 'vite';
import { fileURLToPath } from 'url';

export default defineConfig({
	plugins: [vue(), clearTestId()],
	
	// pozostała część konfiguracji
});

```

### Limitacja ilości tworzonych chunków

Kolejna rzecz z listy to limitacja ilości chunków. W rollup'ie, z którego korzysta Vite do budowania produkcyjnej wersji naszej aplikacji, możemy to osiągnąć bez korzystania z żadnego pluginu. Ważne jest zrozumienie, co powoduje chunki - w zdecydowanej większości powstają one z powodu asynchronicznych importów modułów, najczęściej komponentów. W zdecydowanej większości wypadków takie działanie jest jak najbardziej prawidłowe. Są jednak wyjątki - w przypadku aplikacji działających w trybie offline, ważne jest, aby cały kod aplikacji został załadowany od początku. Ponieważ użytkownik po zalogowaniu i pobraniu danych może przejść do trybu offline i w pełni korzystać z aplikacji. Zatem zmodyfikujmy ustawienia rollup'a:

```ts
// vite.config.ts

export default defineConfig({
    // ...
    
    build: {
	    // wyłączamy dzielenie plików CSS
	    cssCodeSplit: false,
	    // wyłączamy blokady wielkościowe przed łączeniem kodu
	    assetsInlineLimit: 100000000,
	    chunkSizeWarningLimit: 100000000,
	    rollupOptions: {
		    output: {
			    // inline dynamicznych importów
			    inlineDynamicImports: true,
			    // wyłączamy dzielenie na chunki
			    manualChunks: undefined
		    },
	    },
    },
});


```

### Konfiguracja zmiennych Sass

Następna rzecz na liście to wstrzykiwanie zmiennych do plików `sass`. Tu musimy zrobić dodatkowy krok, ponieważ są różnice w tym czego potrzebuje webpack, a czego Vite do obsługi sass. Webpack potrzebował dedykowanego loadera (`sass-loader` i biblioteki `node-sass`) natomiast Vite potrzebuje wyłącznie biblioteki `sass`. Zatem dodajmy ją:

```bash
yarn add -D  sass
```

Teraz przeniesiemy opcje konfiguracyjne sass z webpacka. Vite pozwala na takie same przekazanie opcji konfiguracji:

```js
export default defineConfig({
   // ...
	   
   css: {
        preprocessorOptions: {
            sass: {
                additionalData: `$DEFAULT_PAGE_SIZE: ${process.env.VUE_APP_DEFAULT_PAGE_SIZE}; $PAGE_HEIGHT: ${
                  mapSizeToDimensions[process.env.VUE_APP_DEFAULT_PAGE_SIZE].height
                }; $PAGE_WIDTH: ${mapSizeToDimensions[process.env.VUE_APP_DEFAULT_PAGE_SIZE].width};`,
            },
        }
    }
});
```

Jednak, aby to zadziałało, musimy mieć dostęp do zmiennych środowiskowych wewnątrz konfiguracji Vite. Aby to zrobić zmienimy użycie funkcji `defineConfig`, która aktualnie otrzymuje obiekt, a będzie otrzymywała funkcję, która zwraca obiekt - dzięki temu wewnątrz funkcji możemy dokonać pewnych operacji i odwoływać się do zmiennych, które w ich skutek powstały.

```ts
import vue from '@vitejs/plugin-vue';
import clearTestId from 'unplugin-clear-testid/vite'
import { defineConfig, loadEnv } from "vite";
import { fileURLToPath } from 'url';

const mapSizeToDimensions = {
    // bez zmian
};

export default defineConfig(({ command, mode }) => {
    const env = loadEnv(process.env.NODE_ENV ?? "", process.cwd(), 'VUE_APP')

    return {
	// ...
    
        envPrefix: 'VUE_APP',

        css: {
            preprocessorOptions: {
                sass: {
                    additionalData: `$DEFAULT_PAGE_SIZE: ${env.VUE_APP_DEFAULT_PAGE_SIZE}; $PAGE_HEIGHT: ${mapSizeToDimensions[env.VUE_APP_DEFAULT_PAGE_SIZE].height
                    }; $PAGE_WIDTH: ${mapSizeToDimensions[env.VUE_APP_DEFAULT_PAGE_SIZE].width};`,
                }
            }
        }
    }

});

```


Inną różnicą wynikającą z budowy Webpacka i Vite oraz z ich podejścia do modułów (CommonJS/ESM) jest obsługa zmiennych środowiskowych wewnątrz kodu aplikacji. 

Używając webpacka, odnosimy się do Node'owego `process.env.` natomiast używając Vite korzystamy z natywnego `import.meta.env.`. Druga różnica jest taka, że nazwy zmiennych środowiskowych w projekcie opartym o Vue-CLI muszą zaczynać się od `VUE_APP` a w Vite od `VITE`. Przy czym - możemy to w prosty sposób zmienić i oszczędzić sobie wielu zmian w plikach. Co więcej, zrobiliśmy to przy okazji wstrzykiwania zmiennych systemowych do konfiguracji `sass`. Zatem jedyne co musimy zrobić w naszym projekcie, to szybka akcja 'szukaj i zamień': `process.env.` -> `import.meta.env.`. 

Jeszcze jeden edge-case związany ze zmiennymi środowiskowymi to posługiwanie się `NODE_ENV` - tej zmiennej nie będziemy mieli dostępnej, ale Vite udostępnia 3 inne, które mogą być pomocne:
- `import.meta.env.MODE` - wskazująca na tryb, w którym działa Vite (`development` / `production`)
- `import.meta.env.PROD` - czy działamy w produkcyjnym trybie
- `import.meta.env.DEV` - czy działamy w developerskim trybie

Uwaga: zwróć uwagę na niektóre pliki konfiguracyjne narzędzi (w tym nawet plik `vite.config.ts`), które są uruchamiane z poziomu konsoli (a więc NodeJS) i mogą one korzystać z `process.env.`

Jeśli w projekcie korzystasz z TypeScript'a (tak jak w moim przypadku), to warto, abyś dodał definicję zmiennych systemowych do pliku z lokalnymi typami. Możliwe, że masz już plik typu `shims-vue.d.ts`, więc możesz go zmienić na `env.d.ts` lub pozostawić w takiej nazwie, ale warto dopisać w nim kilka rzeczy:

```ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VUE_APP_API_URL: string
  readonly VUE_APP_API_HEALTH_CHECK: string
  readonly VUE_APP_AWS_URL: string
  readonly VUE_APP_DEFAULT_PAGE_SIZE: string
}
```

### Wstrzykiwanie zmiennych środowiskowych

Kolejna rzecz na liście to wstrzykiwanie pluginem dwóch zmiennych środowiskowych. Może na pierwszy rzut oka wydaje Ci się to dziwne - przecież można je zdefiniować w pliku `.env`. Otóż można, ale wtedy nie mogą mieć dynamicznych wartości, jak w tym przypadku. Ogólna idea to przemycić do kodu numer wersji z package.json i hash ostatniego git'owego commitu. 

W Vite możemy skorzystać z opcji `define` w konfiguracji i zdefiniować globalne stałe (nie w sensie JS, opcja `define` !== `const`), które zostaną podmienione w trakcie buildu, jak i działania serwera developerskiego. Zatem dodajmy to do konfigu: 

```ts
let gitLastCommitHash = ''
try {
	gitLastCommitHash = childProcess
	  .execSync('git rev-parse --short HEAD')
	  .toString()
	  .trim()
} catch (e) {
	console.error(e)
}

const packageJson = fs.readFileSync('./package.json').toString()
let version = JSON.parse(packageJson).version

export default defineConfig(({ command, mode }) => {
	// ...
	return {
		// ...
	
		define: {
			__APP_VERSION__: version,
			__COMMIT_HASH__: gitLastCommitHash,
		},
	
	//...
	}
})
```

Teraz wystarczy, że wykonamy kolejne 'szukaj i zamień': w tych miejscach kodu, gdzie odwoływaliśmy się do `process.env.VUE_APP_VERSION` i `process.env.VUE_APP_COMMIT_HASH`, teraz odwołujemy się odpowiednio do `__APP_VERSION__` i `__COMMIT_HASH__`.

Aby móc uruchomić Vite potrzebujemy jeszcze punktu startowego - czyli prostego pliku `index.html`, w którym jest `<div id="app"></div>`, a w którym renderowana jest nasza aplikacja. Stwórzmy go w głównym katalogu projektu:

```html
<!DOCTYPE html>
<html lang="en" class="h-full">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
    <link rel="alternate icon" href="/favicon.ico" type="image/png" sizes="16x16" />
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
    <link rel="mask-icon" href="/favicon.svg" color="#FFFFFF" />
    <meta name="theme-color" content="#ffffff" />
  </head>
  <body class="h-full">
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

### Czy to wszystko?

Jeśli chodzi o same przeniesienie konfiguracji i pluginów z Webpacka do konfiguracji w Vite, która osiąga ten sam efekt, to tak. Jednak w naszym kodzie możemy nadal mieć trochę pracy. Tak jak wspomniałem wcześniej, Webpack i Vite to dwa różne światy jeśli chodzi o używanie modułów. To ma również znaczenie podczas unit testów. Jeśli projekt, który chcesz przenieść na Vite, korzysta z Jest - przygotuj się na kilka kwestii, szczególnie jeśli zaczniesz korzystać z pluginów vite, które (ułatwiają życie) odpowiadają za auto-import komponentów ([unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)), czy auto routing na wzór Nuxta ([vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages)) lub odpowiadają za PWA ([vite-plugin-pwa](https://vite-plugin-pwa.netlify.app/)). Korzystają one często z wirtualnych importów lub ścieżek i Jest po prostu sobie z tym nie radzi.

Ten problem można oczywiście rozwiązać za pomocą stubów i odpowiedniej konfiguracji Jest'a lub zmienić test runner na taki, który jest ESM-first jak choćby `vitest`.