Read OSS

Vue 2 Source Code: Architecture Overview and How the Codebase is Organized

Intermediate

Prerequisites

  • Basic familiarity with Vue 2 from a user perspective (components, templates, reactivity)
  • Understanding of JavaScript module systems (ES modules, CommonJS, UMD)
  • Basic knowledge of build tools like Rollup

Vue 2 Source Code: Architecture Overview and How the Codebase is Organized

Vue 2.7.16 is the final release of the Vue 2 line — a capstone that backports the Composition API from Vue 3 while preserving every byte of backward compatibility. The result is a codebase that's been refined over eight years of production use, and it's surprisingly approachable once you understand its structure. This article gives you the mental map you need to navigate the ~25,000 lines of source code that power millions of applications.

We'll trace the layered module chain that constructs the Vue constructor, explore the build system that produces over 20 distribution variants from just a handful of entry points, and look at the path alias system that keeps imports clean across the entire codebase.

Repository Layout and Directory Structure

The Vue 2 source tree is organized into a clear hierarchy. Here's the high-level layout:

Directory Purpose
src/core/ Framework core: instance, observer (reactivity), vdom, global API, utilities
src/compiler/ Template-to-render-function compiler: parser, optimizer, codegen
src/platforms/web/ Web-specific runtime, compiler options, entry points
src/v3/ Backported Vue 3 Composition API (ref, reactive, watch, setup, etc.)
src/shared/ Shared utilities and constants used across all modules
packages/ Standalone packages: compiler-sfc, server-renderer, template-compiler
scripts/ Build configuration, alias resolution, feature flags
graph TD
    A[src/] --> B[core/]
    A --> C[compiler/]
    A --> D[platforms/web/]
    A --> E[v3/]
    A --> F[shared/]
    B --> B1[instance/]
    B --> B2[observer/]
    B --> B3[vdom/]
    B --> B4[global-api/]
    B --> B5[util/]
    D --> D1[runtime/]
    D --> D2[compiler/]
    D --> D3["entry-*.ts"]
    E --> E1[reactivity/]
    E --> E2["api*.ts"]

The src/core/ directory is the heart of Vue. It contains five subsystems: the instance lifecycle (instance/), the reactivity engine (observer/), the virtual DOM (vdom/), the global API (global-api/), and shared utilities (util/). These subsystems are platform-agnostic — they know nothing about the browser DOM.

The src/platforms/web/ directory is where browser-specific behavior lives. It provides DOM operations, platform-specific directives (v-model, v-show), transition components, and the entry points that wire everything together.

The src/v3/ directory is the Vue 3 backport — a self-contained module that implements ref(), reactive(), computed(), watch(), lifecycle hooks, and EffectScope using Vue 2's existing Observer/Dep/Watcher primitives.

Tip: When exploring the codebase, start in src/core/instance/index.ts. It's the root of the Vue constructor and gives you a clear view of the five mixins that compose the instance prototype.

The Layered Entry Point Chain

Vue 2 doesn't use an ES6 class for its constructor. Instead, it uses a plain function and decorates the prototype through a chain of mixin functions. This design allows each layer to be independently testable and enables the build system to produce variants with different feature sets.

The chain starts with a bare constructor and adds capabilities layer by layer:

flowchart LR
    A["Vue Constructor<br/>(5 mixins)"] --> B["Global API Layer<br/>(Vue.extend, etc.)"]
    B --> C["Platform Runtime<br/>(__patch__, $mount)"]
    C --> D["Compiler Layer<br/>($mount override)"]
    D --> E["Final Export<br/>(+ Composition API)"]

Layer 1: The Raw Constructorsrc/core/instance/index.ts#L9-L27

function Vue(options) {
  if (__DEV__ && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)    // _init
stateMixin(Vue)   // $data, $props, $set, $delete, $watch
eventsMixin(Vue)  // $on, $once, $off, $emit
lifecycleMixin(Vue)  // _update, $forceUpdate, $destroy
renderMixin(Vue)  // _render, $nextTick, render helpers

Five mixin functions extend Vue.prototype with distinct groups of instance methods. Using a plain function instead of a class is deliberate — you can't call mixins that add prototype methods on a class in the same way. This pattern trades class syntax for maximum flexibility in how the prototype is composed.

Layer 2: Global APIsrc/core/index.ts#L1-L27

This module imports the raw constructor and calls initGlobalAPI(Vue), which installs static methods like Vue.extend, Vue.mixin, Vue.use, Vue.set, Vue.delete, and Vue.nextTick. It also adds $isServer and $ssrContext getters to the prototype, and sets Vue.version.

Layer 3: Platform Runtimesrc/platforms/web/runtime/index.ts#L1-L75

The web platform layer installs browser-specific configuration (isReservedTag, mustUseProp, getTagNamespace), registers platform directives (v-model, v-show) and components (Transition, TransitionGroup), attaches the __patch__ function for DOM reconciliation, and defines the base $mount method.

Layer 4: Compiler Layersrc/platforms/web/runtime-with-compiler.ts#L20-L92

This module saves a reference to the runtime $mount, then overrides it with a version that compiles templates into render functions at runtime. The original $mount is called at the end after compilation. This override-and-delegate pattern is what allows the "runtime-only" build to simply skip this layer.

Layer 5: Final Exportsrc/platforms/web/entry-runtime-with-compiler.ts#L1-L10

The final entry point extends Vue with all Composition API exports from the v3 module using extend(Vue, vca). This is how Vue.ref, Vue.reactive, Vue.computed, etc. become available as named exports.

Build System and Distribution Variants

The build system in scripts/config.js defines over 20 build variants from a handful of entry points. Each variant combines an entry point, an output format, and an environment:

scripts/config.js#L34-L226

Build Name Entry Point Format Includes Compiler?
runtime-cjs-dev/prod entry-runtime.ts CJS No
full-cjs-dev/prod entry-runtime-with-compiler.ts CJS Yes
runtime-esm entry-runtime-esm.ts ES No
full-esm entry-runtime-with-compiler-esm.ts ES Yes
runtime-dev/prod entry-runtime.ts UMD No
full-dev/prod entry-runtime-with-compiler.ts UMD Yes
compiler entry-compiler.ts CJS Compiler only
server-renderer-* packages/server-renderer/ CJS/UMD SSR
compiler-sfc packages/compiler-sfc/ CJS SFC compiler

The key insight is that "runtime-only" and "full" builds differ only in which entry point they start from. The runtime entry skips the compiler layer entirely, yielding a smaller bundle (~30% smaller).

Path Aliases

The scripts/alias.js file defines path aliases that keep imports clean:

module.exports = {
  vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
  compiler: resolve('src/compiler'),
  core: resolve('src/core'),
  shared: resolve('src/shared'),
  web: resolve('src/platforms/web'),
  server: resolve('packages/server-renderer/src'),
  sfc: resolve('packages/compiler-sfc/src')
}

Throughout the codebase you'll see imports like import Vue from 'core/index' or import { query } from 'web/util/index'. These resolve through the alias system, which is configured in both Rollup and TypeScript.

Feature Flags

Build-time feature flags in scripts/feature-flags.js and the genConfig function control conditional compilation:

  • __DEV__ — replaced with process.env.NODE_ENV !== 'production' (or false in prod builds)
  • __TEST__false in all builds, true in test environment
  • __GLOBAL__true for UMD/browser builds
  • __VERSION__ — the version string from package.json

These flags are replaced at build time by @rollup/plugin-replace, enabling dead-code elimination. All those if (__DEV__) blocks you see in the source are stripped entirely from production bundles.

Runtime Configuration and Global API

The runtime configuration object is defined in src/core/config.ts with sensible defaults. It splits into three categories: user-facing options (silent, devtools, errorHandler, performance), platform-dependent hooks (isReservedTag, mustUseProp, getTagNamespace), and internal flags (async, _lifecycleHooks).

The platform hooks start as no-ops and get overridden by the web runtime layer — this is what makes the core platform-agnostic.

initGlobalAPI installs the global API and protects Vue.config from replacement:

src/core/global-api/index.ts#L20-L68

flowchart TD
    A[initGlobalAPI] --> B["Protect Vue.config<br/>(defineProperty getter)"]
    A --> C["Install Vue.util<br/>(warn, extend, mergeOptions, defineReactive)"]
    A --> D["Install Vue.set/delete/nextTick/observable"]
    A --> E["Create Vue.options<br/>(components, directives, filters)"]
    A --> F["initUse → initMixin → initExtend → initAssetRegisters"]

The Vue.config protection pattern is worth noting: Object.defineProperty defines a getter that returns the config object, and in development mode, a setter that warns if you try to replace the entire object. You can mutate individual properties like Vue.config.silent = true, but Vue.config = {} triggers a warning.

The Vue.options object is created with Object.create(null) — a prototype-less object that avoids hasOwnProperty conflicts. The built-in KeepAlive component is registered here, which is why it's globally available in every Vue application without explicit registration.

Understanding Vue 2's source code is easier once you know the five core subsystems and how they interact:

flowchart TD
    subgraph "Data Layer"
        OBS[Observer / Dep / Watcher]
    end
    subgraph "View Layer"
        VDOM[VNode / patch / createElement]
    end
    subgraph "Instance"
        INST[_init / state / lifecycle / events / render]
    end
    subgraph "Compiler"
        COMP[parse / optimize / generate]
    end
    subgraph "Composition API"
        V3[ref / reactive / setup / effectScope]
    end
    COMP -->|"produces render fn"| VDOM
    OBS -->|"notifies"| INST
    INST -->|"_render()"| VDOM
    INST -->|"creates watchers"| OBS
    V3 -->|"wraps"| OBS
    VDOM -->|"__patch__"| DOM[Real DOM]

When tracing execution from a Vue API call, the typical path is:

  1. new Vue(options)_init() in src/core/instance/init.ts → merges options, initializes lifecycle/events/render/state
  2. vm.$mount(el) → compiles template (if full build) → calls mountComponent() → creates a render Watcher
  3. Data change → setter triggers dep.notify()queueWatcher() → batched flush via nextTickwatcher.run()_render()_update()__patch__()

Key type definitions live in types/component.ts and types/options.ts. The Component type represents a Vue instance, and ComponentOptions describes the options object you pass to new Vue() or Vue.extend().

Tip: If you want to understand how a specific Vue feature works, search for its public API name (e.g., $watch, v-model, <keep-alive>) — the codebase is well-organized enough that the implementation is usually close to the API definition.

The shared utilities in src/shared/constants.ts define the canonical list of lifecycle hooks and asset types that are referenced throughout the framework. The LIFECYCLE_HOOKS array includes the Vue 3 backports (renderTracked, renderTriggered) alongside the classic hooks.

What's Next

Now that you have the architectural map, we're ready to dive into the engine that makes Vue reactive. In the next article, we'll dissect the Observer-Dep-Watcher triad — the Object.defineProperty-based dependency tracking system that sits at the very heart of Vue 2. We'll explore how getter interception enables automatic dependency collection, how the scheduler batches updates into a single DOM flush, and why the nextTick implementation carries a decade of browser bug workarounds in its comments.