Ein npm run build ist nicht gleich
npm run build. Bundle-Size, Load-Time, Runtime-Performance
– alles hängt davon ab, wie der Build konfiguriert ist. Vite bringt
Defaults mit, die gut sind – aber für Production-Grade Apps braucht man
Kontrolle: Tree-Shaking verstehen, Environments sauber trennen,
Bundle-Analysis betreiben.
Tree-Shaking entfernt ungenutzten Code aus dem finalen Bundle. Das basiert auf statischer Analyse zur Build-Zeit.
Beispiel:
// utils.ts - Library mit vielen Functions
export function add(a: number, b: number) {
return a + b;
}
export function subtract(a: number, b: number) {
return a - b;
}
export function multiply(a: number, b: number) {
return a * b;
}
export function divide(a: number, b: number) {
return a / b;
}// App.tsx - Nutzt nur add()
import { add } from './utils';
const result = add(5, 3);Ohne Tree-Shaking: Alle 4 Functions landen im Bundle
(8 KB)
Mit Tree-Shaking: Nur add() landet im
Bundle (2 KB)
Wie funktioniert’s?
Build-Tool analysiert Imports: 1. App.tsx importiert
add aus utils.ts 2. subtract,
multiply, divide werden nie importiert 3. →
Entfernt aus Bundle
Voraussetzungen:
import/export, nicht
require/module.exports// ✓ Tree-shakable
export function myFunction() {}
// ❌ Nicht tree-shakable
module.exports = { myFunction: () => {} };// ❌ Nicht tree-shakable - Side-Effect beim Import!
export function setupGlobalState() {
window.myApp = { initialized: true };
}
// Wird beim Import ausgeführt, auch wenn nie called
setupGlobalState();
// ✓ Tree-shakable
export function setupGlobalState() {
window.myApp = { initialized: true };
}
// Wird nur ausgeführt wenn explizit aufgerufensideEffects// package.json einer Library
{
"name": "my-ui-library",
"sideEffects": false // Alle Files sind side-effect-free
}
// Oder spezifisch
{
"sideEffects": [
"*.css", // CSS hat Side-Effects (globals)
"polyfills.js"
]
}Lodash (schlecht):
// ❌ Import entire library
import _ from 'lodash';
_.debounce(fn, 300);
// Bundle: ~70 KB (alles von Lodash!)// ✓ Named Import
import { debounce } from 'lodash-es'; // -es = ESM version
debounce(fn, 300);
// Bundle: ~2 KB (nur debounce)Material UI:
// ❌ Falsch: Alles importieren
import * as MUI from '@mui/material';
<MUI.Button>Click</MUI.Button>
// ✓ Richtig: Named Imports
import { Button } from '@mui/material';
<Button>Click</Button>date-fns:
// ❌ Falsch
import { format } from 'date-fns';
// ✓ Besser: Subpath imports
import format from 'date-fns/format';npm install --save-dev rollup-plugin-visualizer// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true, // Öffnet Visualisierung nach Build
filename: 'dist/stats.html',
gzipSize: true,
brotliSize: true
})
]
});npm run build
# Öffnet Browser mit interaktiver Bundle-AnalyseTypical Findings: - Moment.js: 200 KB → Replace mit date-fns (10 KB) - Lodash statt lodash-es → 70 KB sparen - Unused Icons aus Icon-Library → 50 KB sparen
Problem: Verschiedene Umgebungen brauchen verschiedene Config:
| Setting | Development | Staging | Production |
|---|---|---|---|
| API URL | localhost:3000 | staging.api.com | api.com |
| Logging | Debug-Level | Info-Level | Error-Level |
| Analytics | Disabled | Test-Account | Production-Account |
| Source Maps | Ja | Ja | Nein |
Lösung: .env Files
# .env - Base für alle Environments
VITE_APP_NAME=My React App
VITE_APP_VERSION=1.0.0# .env.development - Nur Development
VITE_API_URL=http://localhost:3000/api
VITE_ENABLE_LOGGING=true
VITE_ENABLE_ANALYTICS=false# .env.staging - Staging Environment
VITE_API_URL=https://staging-api.example.com/api
VITE_ENABLE_LOGGING=true
VITE_ENABLE_ANALYTICS=true
VITE_ANALYTICS_ID=UA-12345-STAGING# .env.production - Production
VITE_API_URL=https://api.example.com/api
VITE_ENABLE_LOGGING=false
VITE_ENABLE_ANALYTICS=true
VITE_ANALYTICS_ID=UA-12345-PRODWichtig: Nur VITE_* Variablen sind im
Client verfügbar!
# ❌ Nicht verfügbar im Client
API_SECRET=abc123
# ✓ Verfügbar im Client
VITE_API_URL=https://api.comVerwendung im Code:
// config.ts
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
enableLogging: import.meta.env.VITE_ENABLE_LOGGING === 'true',
enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
analyticsId: import.meta.env.VITE_ANALYTICS_ID,
isDev: import.meta.env.DEV, // Built-in
isProd: import.meta.env.PROD, // Built-in
mode: import.meta.env.MODE // 'development' | 'production'
};// App.tsx
import { config } from './config';
function App() {
useEffect(() => {
if (config.enableAnalytics) {
initAnalytics(config.analyticsId);
}
}, []);
return <div>{/* App */}</div>;
}// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_ENABLE_LOGGING: string;
readonly VITE_ENABLE_ANALYTICS: string;
readonly VITE_ANALYTICS_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}Jetzt hat import.meta.env.VITE_API_URL Autocomplete und
Type-Check.
// package.json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"build:staging": "vite build --mode staging",
"build:production": "vite build --mode production",
"preview": "vite preview"
}
}# Development Build (nutzt .env.development)
npm run build
# Staging Build (nutzt .env.staging)
npm run build:staging
# Production Build (nutzt .env.production)
npm run build:production# ❌ NIEMALS in .env (wird ins Bundle kompiliert!)
VITE_API_SECRET=super-secret-key
VITE_STRIPE_SECRET_KEY=sk_live_...
# ✓ Secrets gehören ins Backend
# Frontend nutzt Public Keys:
VITE_STRIPE_PUBLIC_KEY=pk_live_...Warum? Alles mit VITE_* wird
direkt ins JavaScript-Bundle kompiliert. User kann es
im DevTools sehen.
// Kompiliert zu:
const apiUrl = "https://api.com"; // Sichtbar im Bundle!# .gitignore
.env.local
.env.*.local
# Committe:
.env # Base config
.env.development # Dev defaults
.env.production # Production defaults
# Nicht committen:
.env.local # Lokale Overrides
.env.development.local
.env.production.local// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// Output Directory
outDir: 'dist',
// Source Maps (nur für Staging, nicht Production)
sourcemap: process.env.NODE_ENV === 'staging',
// Minification (terser ist langsamer aber kleiner)
minify: 'terser',
// Terser Options
terserOptions: {
compress: {
drop_console: true, // console.log entfernen
drop_debugger: true
}
},
// Chunk Size Warning (Default: 500 KB)
chunkSizeWarningLimit: 1000,
// Rollup Options
rollupOptions: {
output: {
// Manual Chunks für besseres Caching
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['@mui/material', '@emotion/react', '@emotion/styled'],
'utils': ['lodash-es', 'date-fns']
}
}
},
// Target (moderne Browser)
target: 'es2020'
},
// Base URL (für Subpath Deployment)
base: '/',
// Server Config (Development)
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
});Problem: Ein großer vendor.js (2 MB)
mit allem.
User ändert einen Button → Gesamtes Vendor-Bundle neu laden.
Lösung: Aufteilen nach Change-Frequenz:
manualChunks: {
// React ändert sich selten
'react-vendor': ['react', 'react-dom'],
// Router ändert sich selten
'router-vendor': ['react-router-dom'],
// UI-Library ändert sich manchmal
'ui-vendor': ['@mui/material'],
// Utils ändern sich oft
'utils': ['lodash-es', 'date-fns']
}Browser cached react-vendor.js für Monate.
utils.js wird öfter invalidiert.
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// Custom Logic
if (id.includes('node_modules')) {
if (id.includes('react')) {
return 'react-vendor';
}
return 'vendor';
}
}
},
// Performance Budget
onwarn(warning, warn) {
// Fail build bei zu großen Chunks
if (warning.code === 'CHUNK_SIZE_LIMIT') {
throw new Error(`Chunk size limit exceeded: ${warning.message}`);
}
warn(warning);
}
}
}
});// features.ts
export const features = {
newDashboard: import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true',
betaFeatures: import.meta.env.VITE_FEATURE_BETA === 'true',
experimentalUI: import.meta.env.VITE_FEATURE_EXPERIMENTAL === 'true'
};// Dashboard.tsx
import { features } from './features';
function Dashboard() {
if (features.newDashboard) {
return <NewDashboard />;
}
return <LegacyDashboard />;
}
Tree-Shaking-freundlich:
// ✓ Dead Code wird entfernt
if (import.meta.env.VITE_FEATURE_NEW_DASHBOARD === 'true') {
// Dieser Code wird bei false komplett entfernt!
console.log('New Dashboard enabled');
}Vite ersetzt zur Build-Zeit
import.meta.env.VITE_FEATURE_NEW_DASHBOARD mit
'true' oder 'false'. Terser entfernt dann
if (false) { ... } Blocks.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main, develop, staging]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
# Install
- run: npm ci
# Determine Environment
- name: Set Environment
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "ENV=production" >> $GITHUB_ENV
elif [ "${{ github.ref }}" == "refs/heads/staging" ]; then
echo "ENV=staging" >> $GITHUB_ENV
else
echo "ENV=development" >> $GITHUB_ENV
fi
# Build with correct mode
- name: Build
run: npm run build:${{ env.ENV }}
env:
VITE_API_URL: ${{ secrets[format('API_URL_{0}', env.ENV)] }}
VITE_ANALYTICS_ID: ${{ secrets[format('ANALYTICS_ID_{0}', env.ENV)] }}
# Deploy
- name: Deploy to S3
run: |
aws s3 sync dist/ s3://my-app-${{ env.ENV }}/ --deleteProduction-Ready Build-Setup macht den Unterschied zwischen “funktioniert auf meinem Rechner” und “skaliert für Millionen User”. Tree-Shaking reduziert Bundle-Size, Environment-Variablen halten Config sauber, Manual Chunks optimieren Caching. Der Build-Prozess ist nicht Nebensache – er definiert Performance, Sicherheit und Wartbarkeit der Production-App.