Vue 2 reached End-of-Life in December 2023, but a massive volume of production codebases still run on it, and Vue 2.7 (the final minor release) backported the Composition API, <script setup>, and Vite-based tooling patterns from Vue 3. This series treats Vue 2.7 as the baseline — you get modern developer ergonomics without a full Vue 3 migration.
Why Vite Over Webpack/Vue CLI for Vue 2
Vue CLI is in maintenance mode and built on webpack, which bundles your entire dependency graph before serving anything in dev mode. As a project grows past a few hundred modules, cold start and HMR latency climb linearly.
| Criteria | Vue CLI (Webpack) | Vite |
|---|---|---|
| Dev server cold start | Bundles entire app graph first — seconds to tens of seconds on large projects | Native ESM — serves files on demand, near-instant start |
| HMR speed | Degrades as module count grows (full recompile of affected chains) | Stays near-constant; only the edited module is invalidated |
| Production build | Webpack (mature, plugin-heavy) | Rollup under the hood — smaller, more tree-shakeable output |
| Config complexity | vue.config.js abstracts webpack; escape hatches are verbose | vite.config.js is flat and explicit |
| Vue 2 support | First-class, official | Requires @vitejs/plugin-vue2 (community-maintained, stable) |
Exact Setup: @vitejs/plugin-vue2
npm create vite@latest my-vue2-app -- --template vanilla
cd my-vue2-app
npm install vue@^2.7.16
npm install -D @vitejs/plugin-vue2 vite-plugin-vue2-jsximport { defineConfig, loadEnv } from 'vite'
import { createVuePlugin } from 'vite-plugin-vue2'
import path from 'path'
// Note: the package name on npm is 'vite-plugin-vue2', exporting createVuePlugin.
// @vitejs/plugin-vue2 is the newer scoped equivalent; both expose the same API surface.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
createVuePlugin({
jsx: true,
vueTemplateOptions: {
compilerOptions: {
whitespace: 'condense'
}
}
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
vue: '@vue/compat' === env.VUE_COMPAT ? '@vue/compat' : 'vue/dist/vue.esm.js'
}
},
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false
},
server: {
port: 5173,
open: true
},
build: {
target: 'es2019',
sourcemap: mode !== 'production'
}
}
})Project Anatomy: Entry Points and Environment Variables
Vite injects environment variables at build time via import.meta.env, replacing Vue CLI's process.env.VUE_APP_* convention. Only variables prefixed with VITE_ are exposed to client code — this is a deliberate security boundary so server secrets in .env never leak into the bundle.
VITE_API_BASE_URL=https://api.example.com
VITE_APP_NAME=Product Inventory Manager
# DB_PASSWORD=never-do-this — not prefixed, so Vite will NOT expose it to client codeimport Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
Vue.config.devtools = import.meta.env.DEV
// Global error handler — catches errors from any component render/watcher/lifecycle hook
Vue.config.errorHandler = (err, vm, info) => {
console.error(`[Vue Error] ${info}:`, err)
if (import.meta.env.PROD) {
// send to monitoring service (Module 6 covers this in depth)
}
}
new Vue({
router,
store,
render: (h) => h(App)
}).$mount('#app')<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Product Inventory Manager</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>The Vue Instance & Lifecycle
Every Options API component goes through a strict, ordered sequence of hooks. Picking the wrong hook for a task is the single most common source of bugs in Options API code — DOM access before mount, data mutation after destroy, subscriptions that never unsubscribe.
| Hook | Timing | Exact Use Case |
|---|---|---|
| beforeCreate | Before data/computed/methods are initialized | Almost never used directly; plugins hook here to inject options |
| created | data/computed/methods ready, DOM not yet rendered | Fetch initial data, set up non-DOM subscriptions, initialize non-reactive class instances |
| beforeMount | Compiled render function ready, about to mount | Rarely used; final synchronous check before first render |
| mounted | $el exists and is in the document (for root; may be detached for nested async) | DOM measurements, third-party DOM libraries (charts, sliders), attaching native event listeners |
| beforeUpdate | Reactive data changed, DOM not yet re-patched | Read DOM state before it changes (e.g. scroll position preservation) |
| updated | DOM has been re-patched to match state | Post-update DOM reads; avoid mutating state here (risk of infinite loops) |
| beforeDestroy | Instance still fully functional | Clean up timers, event listeners, subscriptions, third-party instances |
| destroyed | All directives unbound, event listeners removed | Final confirmation/logging only |
Reactivity Basics: Object.defineProperty Under the Hood
Vue 2's reactivity system walks every property on the data object recursively at component initialization and converts each one into a getter/setter pair via Object.defineProperty. This is fundamentally different from Vue 3's Proxy-based system and explains every Vue 2 reactivity limitation you'll hit.
// Simplified version of what Vue 2's observer does internally
function defineReactive(obj, key, val) {
const dep = new Dep() // dependency tracker for this specific property
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// If we're inside a component render or a watcher, register it
if (Dep.target) {
dep.depend()
}
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
// Notify every subscriber (render watcher, computed, user watchers)
dep.notify()
}
})
}
// This is why Vue 2 must walk the ENTIRE data object upfront:
// properties that don't exist yet cannot have a getter/setter installed.
// Adding obj.newProp = 'value' later is INVISIBLE to the reactivity system.Direct Consequences of the defineProperty Model
- Every property must exist in
data()at component creation — Vue cannot retroactively make new properties reactive - Arrays are NOT observed via index getters/setters (would be prohibitively expensive for large arrays) — Vue instead patches array mutator methods (push, pop, splice, etc.)
- Setting
arr[index] = valueorarr.length = ndirectly bypasses reactivity entirely - Vue.set() / vm.delete() exist specifically to patch around these two gaps (fully covered in Module 5)
Declarative Rendering & Directives
Directives are the DOM-facing surface of the reactivity system. Understanding the compiled output changes how you reason about performance.
| Directive | Behavior | Performance Trade-off |
|---|---|---|
| v-if | Conditionally creates/destroys the DOM subtree and its component instances | Higher toggle cost (full destroy/recreate + lifecycle hooks fire); zero cost while hidden (no render, no watchers) |
| v-show | Always renders; toggles CSS display:none | Higher initial render cost (always mounted); near-zero toggle cost (just a style mutation) |
| v-for without :key | Vue reuses DOM nodes in-place using an 'in-place patch' strategy | Causes state bleed between items (e.g. input values sticking to the wrong row) — never omit :key |
| v-for with :key | Vue tracks nodes by identity and reorders/reuses precisely | Correct behavior; use a stable unique id, never the array index for mutable lists |
<template>
<div>
<!-- v-bind shorthand -->
<img :src="product.imageUrl" :alt="product.name" />
<!-- v-model on a custom component requires 'value' prop + 'input' event (Vue 2 default) -->
<input v-model="searchQuery" type="text" placeholder="Search inventory..." />
<!-- v-show: stays in DOM, toggles display -->
<div v-show="isPanelOpen" class="filter-panel">Filters</div>
<!-- v-if/v-else chain: full mount/unmount -->
<p v-if="loading">Loading inventory...</p>
<p v-else-if="error">{{ error }}</p>
<ul v-else>
<!-- :key is mandatory — product.id, never index -->
<li v-for="product in filteredProducts" :key="product.id">
{{ product.name }} — {{ product.stock }} units
</li>
</ul>
</div>
</template>Practical Example: Product Inventory Manager (Options API)
A modular component demonstrating props, custom events, computed properties, and watchers together — the four pillars of Options API component design.
<template>
<section class="inventory-manager">
<header class="inventory-manager__header">
<h2>{{ title }}</h2>
<span class="inventory-manager__count">{{ totalUnits }} units in stock</span>
</header>
<div class="inventory-manager__controls">
<input
v-model.trim="searchQuery"
type="text"
class="inventory-manager__search"
placeholder="Search products..."
/>
<select v-model="stockFilter" class="inventory-manager__filter">
<option value="all">All</option>
<option value="low">Low Stock (< 10)</option>
<option value="out">Out of Stock</option>
</select>
</div>
<p v-if="lowStockWarning" class="inventory-manager__warning">
{{ lowStockWarning }}
</p>
<ul class="inventory-manager__list">
<li
v-for="product in filteredProducts"
:key="product.id"
class="inventory-manager__item"
>
<span class="inventory-manager__item-name">{{ product.name }}</span>
<span class="inventory-manager__item-stock">{{ product.stock }}</span>
<button
type="button"
class="inventory-manager__btn"
:disabled="product.stock <= 0"
@click="decrementStock(product)"
>
Sell 1
</button>
<button
type="button"
class="inventory-manager__btn inventory-manager__btn--restock"
@click="restock(product)"
>
Restock +10
</button>
</li>
</ul>
</section>
</template>
<script>
export default {
name: 'ProductInventoryManager',
props: {
title: {
type: String,
default: 'Product Inventory'
},
products: {
type: Array,
required: true,
validator: (list) => list.every((p) => 'id' in p && 'name' in p && 'stock' in p)
}
},
data() {
return {
searchQuery: '',
stockFilter: 'all'
}
},
computed: {
filteredProducts() {
const query = this.searchQuery.toLowerCase()
return this.products
.filter((p) => p.name.toLowerCase().includes(query))
.filter((p) => {
if (this.stockFilter === 'low') return p.stock > 0 && p.stock < 10
if (this.stockFilter === 'out') return p.stock <= 0
return true
})
},
totalUnits() {
// Computed properties are cached and only recompute when `products` changes
return this.products.reduce((sum, p) => sum + p.stock, 0)
},
lowStockWarning() {
const lowCount = this.products.filter((p) => p.stock > 0 && p.stock < 10).length
return lowCount > 0 ? `${lowCount} product(s) are running low on stock.` : ''
}
},
watch: {
products: {
deep: true,
handler(newList) {
const outOfStock = newList.filter((p) => p.stock === 0)
if (outOfStock.length) {
this.$emit('out-of-stock', outOfStock)
}
}
}
},
methods: {
decrementStock(product) {
if (product.stock <= 0) return
// Mutating a prop's nested object directly is legal (props are shallow-readonly reactive refs)
// but the parent should own this mutation for clean data flow — emit instead:
this.$emit('update-stock', { id: product.id, delta: -1 })
},
restock(product) {
this.$emit('update-stock', { id: product.id, delta: 10 })
}
}
}
</script>
<style scoped>
.inventory-manager {
font-family: system-ui, sans-serif;
max-width: 640px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.25rem;
}
.inventory-manager__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.inventory-manager__controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.inventory-manager__search,
.inventory-manager__filter {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.inventory-manager__warning {
color: #b45309;
background: #fef3c7;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
}
.inventory-manager__list {
list-style: none;
padding: 0;
margin: 0;
}
.inventory-manager__item {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.inventory-manager__btn {
padding: 0.25rem 0.6rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
.inventory-manager__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.inventory-manager__btn--restock {
border-color: #16a34a;
color: #16a34a;
}
</style>