43 Build-Optimierung – Tree-Shaking, Environments und Production-Readiness

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.

43.1 Tree-Shaking: Dead Code Elimination

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:

  1. ES Modules (ESM): import/export, nicht require/module.exports
// ✓ Tree-shakable
export function myFunction() {}

// ❌ Nicht tree-shakable
module.exports = { myFunction: () => {} };
  1. Side-Effect-Free: Code hat keine versteckten Effekte beim Import
// ❌ 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 aufgerufen
  1. package.json sideEffects
// 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"
  ]
}

43.1.1 Tree-Shaking bei Libraries

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';

43.1.2 Bundle Analyzer: Was ist drin?

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-Analyse

Typical 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

43.2 Environment-Variablen: Development vs. Production

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-PROD

Wichtig: 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.com

Verwendung 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>;
}

43.2.1 TypeScript-Typen für env

// 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.

43.2.2 Build mit verschiedenen Modes

// 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

43.2.3 Secrets Management: Was NICHT in .env

# ❌ 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!

43.2.4 .gitignore für .env

# .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

43.3 Build-Konfiguration: Production-Optimierungen

// 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
      }
    }
  }
});

43.3.1 Manual Chunks: Warum?

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.

43.4 Performance-Budget: BuildFailenIf too large

// 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);
      }
    }
  }
});

43.5 Feature Flags: Conditional Code

// 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.

43.6 Deployment-Environments: CI/CD Integration

# .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 }}/ --delete

Production-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.