Read OSS

Vue 2 Source Code Architecture: Navigating the Codebase

Intermediate

Prerequisites

  • Basic JavaScript and prototypal inheritance
  • Familiarity with Vue 2 API (components, templates, lifecycle hooks)
  • Understanding of module bundlers (Rollup/Webpack)

Vue 2 Source Code Architecture: Navigating the Codebase

Vue 2 has been running in production on millions of sites for nearly a decade, yet few developers have explored its internals. With Vue 2.7 backporting the Composition API from Vue 3 — while preserving the original Object.defineProperty-based reactivity — the codebase is a fascinating hybrid of two architectural eras. This article maps out the entire repository: where things live, how they build, and why the Vue constructor is assembled across five separate files instead of being defined in one place.

Repository Directory Structure

The src/ directory divides Vue into distinct layers, each with a clear responsibility:

Directory Purpose
src/core/ Platform-agnostic runtime: reactivity, virtual DOM, component system, global API
src/compiler/ Template-to-render-function compiler (parser, optimizer, code generator)
src/platforms/web/ Browser-specific bindings: DOM patching, directives, $mount
src/v3/ Composition API backport (ref, reactive, setup, EffectScope, etc.)
src/shared/ Shared utilities used by both compiler and runtime
packages/ Published sub-packages: compiler-sfc, server-renderer, template-compiler
graph TD
    subgraph "src/"
        CORE["core/<br/>Platform-agnostic runtime"]
        COMPILER["compiler/<br/>Template → render fn"]
        PLATFORMS["platforms/web/<br/>Browser bindings"]
        V3["v3/<br/>Composition API backport"]
        SHARED["shared/<br/>Utilities"]
    end
    subgraph "packages/"
        SFC["compiler-sfc"]
        SSR["server-renderer"]
        TC["template-compiler"]
    end
    PLATFORMS --> CORE
    PLATFORMS --> COMPILER
    V3 --> CORE
    SFC --> COMPILER
    SSR --> CORE
    TC --> COMPILER
    CORE --> SHARED
    COMPILER --> SHARED

The core/ directory itself follows a clean internal structure: instance/ holds the component system, observer/ holds the reactivity system, vdom/ holds the virtual DOM, and global-api/ holds static methods like Vue.extend and Vue.mixin. This separation is more than organizational — it enables the build system to produce variants that include or exclude the compiler.

Build System and Path Aliases

Vue uses Rollup to produce over a dozen build variants from the same source. The build configuration lives in scripts/config.js, where each variant specifies an entry point, output format, and environment:

flowchart LR
    CONFIG["scripts/config.js"] --> |"defines"| BUILDS["20+ build variants"]
    BUILDS --> UMD["UMD<br/>vue.js / vue.min.js"]
    BUILDS --> CJS["CommonJS<br/>vue.common.dev.js"]
    BUILDS --> ESM["ES Modules<br/>vue.esm.js"]
    BUILDS --> COMP["Compiler-only<br/>template-compiler"]

What makes module resolution elegant is the path alias system. The file scripts/alias.js defines short names for source directories:

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')
}

These aliases are injected into Rollup via @rollup/plugin-alias, allowing the codebase to write import { observe } from 'core/observer' instead of fragile relative paths. When you see an import like import Vue from 'core/index', the build system resolves core to src/core/.

Tip: If you're reading Vue source in an IDE without the Rollup alias plugin configured, these imports will show as unresolved. Set up path mappings in your tsconfig.json using the same aliases to get full IntelliSense.

The Layered Entry Point Chain

This is arguably the most distinctive architectural decision in Vue 2. The Vue constructor isn't defined in a single file — it's assembled through a chain of five files, each adding a layer of functionality:

flowchart TD
    A["1. src/core/instance/index.ts<br/>Vue constructor + 5 prototype mixins"] --> B["2. src/core/index.ts<br/>initGlobalAPI (Vue.extend, Vue.set, etc.)"]
    B --> C["3. src/platforms/web/runtime/index.ts<br/>Web config, __patch__, $mount"]
    C --> D["4. src/platforms/web/runtime-with-compiler.ts<br/>$mount override with template compilation"]
    D --> E["5. src/platforms/web/entry-runtime-with-compiler.ts<br/>Re-exports + Composition API"]
    
    style A fill:#e1f5fe
    style B fill:#e8f5e9
    style C fill:#fff3e0
    style D fill:#fce4ec
    style E fill:#f3e5f5

Layer 1 (src/core/instance/index.ts) creates the bare constructor and applies five mixin functions that decorate the prototype:

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)     // adds Vue.prototype._init
stateMixin(Vue)    // adds $data, $props, $set, $delete, $watch
eventsMixin(Vue)   // adds $on, $once, $off, $emit
lifecycleMixin(Vue)// adds _update, $forceUpdate, $destroy
renderMixin(Vue)   // adds $nextTick, _render, render helpers

Layer 2 (src/core/index.ts) imports this constructor and calls initGlobalAPI(Vue), which adds static methods like Vue.extend, Vue.mixin, Vue.use, Vue.set, Vue.delete, Vue.nextTick, and Vue.observable.

Layer 3 (src/platforms/web/runtime/index.ts) configures web-specific behavior: it sets platform detection functions (isReservedTag, mustUseProp), registers built-in directives (v-model, v-show) and components (Transition, TransitionGroup), installs the DOM patch function, and defines the basic $mount method.

Layer 4 (src/platforms/web/runtime-with-compiler.ts) overrides $mount to compile templates into render functions before calling the original $mount. This is the layer that makes the "full build" different from the "runtime-only" build.

Layer 5 (src/platforms/web/entry-runtime-with-compiler.ts) re-exports Vue with the Composition API bolted on via extend(Vue, vca).

This layered approach means the runtime-only build simply skips Layer 4, importing directly from Layer 3. No code is conditionally compiled out — the build system just starts at a different entry point.

Mixin-Based Prototype Decoration Pattern

Why does Vue use a plain function constructor instead of an ES6 class? The answer lies in the mixin pattern. Each of the five mixin functions receives the Vue constructor and attaches methods to its prototype from a separate file:

graph TD
    VUE["Vue (plain function)"] --> IM["initMixin<br/>init.ts<br/>→ _init()"]
    VUE --> SM["stateMixin<br/>state.ts<br/>→ $data, $props, $set, $delete, $watch"]
    VUE --> EM["eventsMixin<br/>events.ts<br/>→ $on, $once, $off, $emit"]
    VUE --> LM["lifecycleMixin<br/>lifecycle.ts<br/>→ _update, $forceUpdate, $destroy"]
    VUE --> RM["renderMixin<br/>render.ts<br/>→ $nextTick, _render, helpers"]

With ES6 classes, all methods must be defined within the class body (or via awkward external assignments). The mixin pattern lets each concern live in its own file — events don't need to know about rendering, and state management doesn't need to know about lifecycle. Each file imports the constructor type and attaches its methods independently.

Consider eventsMixin — it receives Vue and attaches $on, $once, $off, and $emit to the prototype. Meanwhile stateMixin adds $data, $props (as computed properties), $set, $delete, and $watch. Each file is under 200 lines and testable in isolation.

This is a pragmatic JavaScript pattern that predates ES6 — and for a library that was originally written in the Flow-typed ES5 era, it remains more flexible than class-based alternatives.

Build Variants and When to Use Each

The build system produces several distinct variants. Here's the reference for what you'll find in dist/:

Build Entry File Format Includes Compiler? Use Case
vue.runtime.js entry-runtime.ts UMD No CDN script tag (runtime only)
vue.js entry-runtime-with-compiler.ts UMD Yes CDN script tag (with compiler)
vue.runtime.common.dev.js entry-runtime.ts CJS No Bundler (Webpack/Browserify)
vue.common.dev.js entry-runtime-with-compiler.ts CJS Yes Bundler with runtime compilation
vue.runtime.esm.js entry-runtime-esm.ts ES No Bundler with tree-shaking
vue.esm.js entry-runtime-with-compiler-esm.ts ES Yes Bundler with runtime compilation + ES modules

The runtime-only builds are ~30% smaller because they exclude the HTML parser, optimizer, and code generator. In production, tools like vue-loader or vite pre-compile .vue file templates during the build step, so the compiler isn't needed at runtime.

The ESM entry point entry-runtime-esm.ts is remarkably simple — it re-exports the runtime Vue and the entire Composition API namespace via export * from 'v3'. This enables named imports like import { ref, computed } from 'vue' in bundler environments.

Tip: If you see a runtime warning saying "You are using the runtime-only build of Vue where the template compiler is not available," you're using a runtime-only build but passing a template option to a component. Either switch to the full build or pre-compile your templates.

Wrapping Up

Vue 2.7's codebase is organized around a clear principle: separate concerns into layers, and let the build system compose them. The constructor starts as a bare function, gets decorated with prototype methods across five files, then wrapped with platform-specific and compiler capabilities depending on the target build.

In the next article, we'll follow what happens when that constructor is actually called — tracing new Vue() step by step through _init, options merging, and the precise firing order of lifecycle hooks.