Vue 2 Source Code Architecture: Navigating the Codebase
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.jsonusing 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
templateoption 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.