Vue 2 Source Code: Architecture Overview and How the Codebase is Organized
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 Constructor — src/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 API — src/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 Runtime — src/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 Layer — src/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 Export — src/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:
| 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 withprocess.env.NODE_ENV !== 'production'(orfalsein prod builds)__TEST__—falsein all builds,truein test environment__GLOBAL__—truefor UMD/browser builds__VERSION__— the version string frompackage.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.
Navigating the Code: A Reader's Guide
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:
new Vue(options)→_init()insrc/core/instance/init.ts→ merges options, initializes lifecycle/events/render/statevm.$mount(el)→ compiles template (if full build) → callsmountComponent()→ creates a renderWatcher- Data change → setter triggers
dep.notify()→queueWatcher()→ batched flush vianextTick→watcher.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.