21 — Build Tooling & Vite
Vite Dev Server — HMR (Hot Module Replacement)
hmr-internals.ts
// ── Vite dev server architecture: ──────────────────────
// 1. Native ES modules: browser loads modules via <script type="module">
// No bundling in dev — each .vue/.ts/.css file is served as a separate module.
// 2. On file change: Vite notifies the browser via WebSocket.
// 3. Browser re-fetches ONLY the changed module + its dependencies.
// 4. Vue SFC HMR: if only <template> changes → re-render only (preserve state).
// If <script> changes → full component reload (state lost).
// If <style> changes → CSS hot-swap (no JS reload at all).
// ── HMR API for composables: accept module replacement ──
import { defineComponent, ref } from 'vue'
if (import.meta.hot) {
// ── import.meta.hot.accept: handle this module's own replacement ──
import.meta.hot.accept((newModule) => {
// This callback runs when THIS module is hot-updated.
// newModule is the new version of this module's exports.
// Use to: re-run initialization, migrate state to new module.
})
// ── import.meta.hot.dispose: cleanup before replacement ──
import.meta.hot.dispose((data) => {
// `data` is an object passed to the new module's accept callback.
// Use to: save state, clear timers, remove event listeners.
clearInterval(myTimer)
data.savedState = myRef.value // pass to new module
})
}
Vite Plugin Pipeline — Vue SFC Compilation
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
// ── @vitejs/plugin-vue: compiles .vue files to JS ──
// Handles: <template> → render function, <script setup> → setup(),
// <style scoped> → CSS with data-v attributes.
vue(),
// ── @vitejs/plugin-vue-jsx: enables JSX in .tsx files ──
// Uses @vue/babel-plugin-jsx under the hood.
vueJsx(),
// ── vite-plugin-vue-devtools: in-browser DevTools ──
vueDevTools(),
// ── unplugin-auto-import: auto-import Vue APIs ──
// No need for: import { ref, computed } from 'vue'
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/types/auto-imports.d.ts', // TS declaration for intellisense
}),
// ── unplugin-vue-components: auto-import components ──
// No need for: import MyComponent from './MyComponent.vue'
Components({
dirs: ['src/components'],
extensions: ['vue'],
dts: 'src/types/components.d.ts',
// ── resolvers: auto-import from UI libraries ──
// resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
// ── @ → /src: enables import Foo from '@/components/Foo.vue' ──
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// ── Environment variables: prefixed with VITE_ ──
// Access via: import.meta.env.VITE_API_BASE
// Only VITE_-prefixed vars are exposed to the client (security: other vars stay server-side).
// Defined in .env, .env.development, .env.production (loaded based on mode).
})
Environment Variables — .env Files
.env
# ── .env: loaded in all modes ──────────────────────────
VITE_API_BASE=http://localhost:3000/api
VITE_APP_TITLE=My Vue App
# ── .env.development: dev mode only (npm run dev) ───────
# .env.development
VITE_API_BASE=http://localhost:3000/api
VITE_DEBUG=true
# ── .env.production: production build only ──────────────
# .env.production
VITE_API_BASE=https://api.myapp.com
VITE_DEBUG=false
# ── .env.local: local overrides, gitignored ─────────────
# .env.local — loaded in ALL modes, highest priority
VITE_API_KEY=my-secret-key # never committed to git
# ── Access in code: ────────────────────────────────────
# import.meta.env.VITE_API_BASE → string (always string, even if "true")
# import.meta.env.DEV → boolean (true in dev mode)
# import.meta.env.PROD → boolean (true in production)
# import.meta.env.MODE → string ('development' | 'production' | 'test')
# import.meta.env.BASE_URL → string (from vite.config base option)
# import.meta.env.SSR → boolean (true during SSR build)
Production Build — Optimization Configuration
build-optimization.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// ── target: browser feature level for output JS ──
// 'esnext': modern browsers only (smaller, no polyfills)
// 'es2015': broader support (larger, includes polyfills)
target: 'esnext',
// ── outDir: output directory (default: 'dist') ──
outDir: 'dist',
// ── sourcemap: production source maps ──
// true: full sourcemaps (large, for error tracking)
// 'hidden': sourcemaps generated but not linked (upload to Sentry, etc.)
// false: no sourcemaps (smallest, hardest to debug)
sourcemap: 'hidden',
// ── minify: minification tool ──
// 'esbuild': fast, good default (Vite's default)
// 'terser': slower, slightly smaller output
// false: no minification (debug builds)
minify: 'esbuild',
// ── cssCodeSplit: split CSS per-chunk ──
// true (default): each async chunk gets its own CSS file (lazy-loaded)
// false: all CSS in one file (fewer requests, larger initial load)
cssCodeSplit: true,
// ── rollupOptions: advanced build customization ──
rollupOptions: {
output: {
// ── manualChunks: split vendor code for caching ──
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['@vueuse/core'],
},
// ── Asset naming: deterministic for cache stability ──
chunkFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
// ── external: don't bundle these (load via CDN or other means) ──
// external: ['vue', 'vue-router'],
},
// ── chunkSizeWarningLimit: raise if vendor chunks are large ──
chunkSizeWarningLimit: 600,
},
// ── esbuild: esbuild-specific options ──
esbuild: {
// ── drop: strip console.log and debugger in production ──
drop: ['console', 'debugger'], // only in production builds
},
})
CSS Processing — Scoped, Modules, and Preprocessors
CssPatterns.vue
<!-- ── 1. Scoped CSS: component-scoped via data-v-<hash> ── -->
<!-- Compiler rewrites .btn → .btn[data-v-abc123] at build time. -->
<!-- No runtime cost — pure build-time transformation. -->
<style scoped>
.btn { color: red; }
/* Deep selector: ::v-deep (or :deep()) pierces scoped boundary */
:deep(.child-class) { font-weight: bold; }
/* ⚠️ :deep() is an escape hatch — breaks encapsulation. Use sparingly. */
</style>
<!-- ── 2. CSS Modules: class names become camelCase JS properties ── -->
<style module>
.btn { color: red; }
.container { max-width: 1200px; }
</style>
<!-- Access: $style.btn, $style.container in template -->
<!-- <div :class="$style.btn"> — hashed class names, no conflicts -->
<!-- ── 3. v-bind in CSS: reactive CSS values ── -->
<style scoped>
.header {
color: v-bind('themeColor'); /* reads themeColor from <script setup> */
font-size: v-bind('fontSize + "px"'); /* arbitrary JS expression */
}
</style>
preprocessor.scss
/* ── Sass/SCSS: use lang="scss" on <style> ── */
/* vite.config: no extra config needed — Vite auto-detects .scss */
<style lang="scss" scoped>
$primary: #42b883;
.btn {
color: $primary;
&:hover { color: darken($primary, 10%); }
}
/* ── CSS variables with SCSS: use :root for global, scoped for local ── */
:root {
--primary: #{$primary}; /* SCSS variable → CSS custom property */
}
</style>
💡 Tips & Tricks
tips.ts
// ── 1. import.meta.glob: lazy-load all files matching a pattern ──
const modules = import.meta.glob('./views/*.vue')
// Returns: { './views/Home.vue': () => import('./views/Home.vue'), ... }
// Useful for: dynamic route generation, auto-registering all components.
// ── 2. import.meta.glob with eager option ──
const modules = import.meta.glob('./utils/*.ts', { eager: true })
// All modules loaded synchronously (no lazy import), default exports available.
// ── 3. Vite define: compile-time constants ──
define: { __APP_VERSION__: JSON.stringify(pkg.version) }
// Access: __APP_VERSION__ in code — replaced at build time, no runtime cost.
// ── 4. resolve.dedupe: deduplicate shared deps ──
resolve: { dedupe: ['vue'] }
// If multiple versions of 'vue' are in node_modules (monorepo), dedupe to one.
// ── 5. server.proxy: proxy API requests in dev ──
server: {
proxy: {
'/api': 'http://localhost:3000', // forward /api to backend
},
}
// Avoids CORS in dev — requests go through Vite dev server to backend.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. Only VITE_-prefixed env vars are exposed to client ──
// DB_PASSWORD=secret in .env → import.meta.env.DB_PASSWORD is undefined.
// Vite strips non-VITE_ vars for security. Rename: VITE_DB_PASSWORD.
// ── 2. import.meta.env values are ALWAYS strings ──
// VITE_DEBUG=true → "true" (string), not boolean.
// Must parse: const debug = import.meta.env.VITE_DEBUG === 'true'
// ── 3. :deep() breaks scoping — use only when necessary ──
// Parent styling child internals couples components. Prefer props/emit.
// If needed, scope tightly: :deep(.specific-class) not :deep(*).
// ── 4. v-bind() in CSS creates a reactive dependency ──
// Changing the bound value re-generates the CSS (inline style on element).
// Not free — don't use for frequently-changing values (perf cost).
// ── 5. manualChunks with shared deps can cause circular chunks ──
// If chunk A and B both depend on C, C becomes its own chunk.
// Plan chunks carefully — misaligned splits cause extra requests.
// ── 6. Vite dev (unbundled) vs prod (bundled) can mask issues ──
// Code that works in dev (native ESM, no tree-shaking) may break in prod
// (aggressive tree-shaking, minification). Test both modes regularly.
🧠 Spot the Bug
A component reads an environment variable but gets undefined in the browser.
EnvBug.ts
// .env
// API_KEY=my-secret-key
// Component
const apiKey = import.meta.env.API_KEY // undefined in browser
fetch(`${apiBase}/data`, { headers: { Authorization: `Bearer ${apiKey}` } })
Answer
The env variable is named API_KEY, but Vite only exposes variables prefixed with VITE_ to client-side code. API_KEY is stripped for security — it could be a server secret that shouldn't be in the browser bundle.
Fix — rename to VITE_API_KEY:
.env
# .env
VITE_API_KEY=my-secret-key # VITE_ prefix → exposed to client
EnvFixed.ts
const apiKey = import.meta.env.VITE_API_KEY // "my-secret-key"
The lesson: only VITE_-prefixed environment variables are injected into client-side code via import.meta.env. Non-prefixed variables are intentionally excluded to prevent accidentally leaking server secrets to the browser.