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.

CriteriaVue CLI (Webpack)Vite
Dev server cold startBundles entire app graph first — seconds to tens of seconds on large projectsNative ESM — serves files on demand, near-instant start
HMR speedDegrades as module count grows (full recompile of affected chains)Stays near-constant; only the edited module is invalidated
Production buildWebpack (mature, plugin-heavy)Rollup under the hood — smaller, more tree-shakeable output
Config complexityvue.config.js abstracts webpack; escape hatches are verbosevite.config.js is flat and explicit
Vue 2 supportFirst-class, officialRequires @vitejs/plugin-vue2 (community-maintained, stable)

Exact Setup: @vitejs/plugin-vue2

terminalbash
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-jsx
vite.config.jsjavascript
import { 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.

.envbash
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 code
main.jsjavascript
import 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')
index.htmlhtml
<!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.

HookTimingExact Use Case
beforeCreateBefore data/computed/methods are initializedAlmost never used directly; plugins hook here to inject options
createddata/computed/methods ready, DOM not yet renderedFetch initial data, set up non-DOM subscriptions, initialize non-reactive class instances
beforeMountCompiled render function ready, about to mountRarely 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
beforeUpdateReactive data changed, DOM not yet re-patchedRead DOM state before it changes (e.g. scroll position preservation)
updatedDOM has been re-patched to match statePost-update DOM reads; avoid mutating state here (risk of infinite loops)
beforeDestroyInstance still fully functionalClean up timers, event listeners, subscriptions, third-party instances
destroyedAll directives unbound, event listeners removedFinal 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.

reactivity-internals.jsjavascript
// 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] = value or arr.length = n directly 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.

DirectiveBehaviorPerformance Trade-off
v-ifConditionally creates/destroys the DOM subtree and its component instancesHigher toggle cost (full destroy/recreate + lifecycle hooks fire); zero cost while hidden (no render, no watchers)
v-showAlways renders; toggles CSS display:noneHigher initial render cost (always mounted); near-zero toggle cost (just a style mutation)
v-for without :keyVue reuses DOM nodes in-place using an 'in-place patch' strategyCauses state bleed between items (e.g. input values sticking to the wrong row) — never omit :key
v-for with :keyVue tracks nodes by identity and reorders/reuses preciselyCorrect behavior; use a stable unique id, never the array index for mutable lists
DirectivesDemo.vuevue
<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.

ProductInventoryManager.vuevue
<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 (&lt; 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>