vuejs/core
Design decisions
This page documents the major architectural decisions visible in the source. Where a decision was driven by an RFC, the link is included; where it was a maintainer call without a public RFC, that's noted too.
Proxy-based reactivity
Vue 2 used Object.defineProperty getter/setter pairs on every reactive property. Vue 3 uses ES2015 Proxy instead. The motivations:
- No boilerplate for new keys. Adding a property to a reactive object Just Works. Vue 2 needed
Vue.setto make a new key reactive. - Array index/length tracking. Vue 2's array reactivity was patched method-by-method (
push,splice, …); Proxy traps cover all index access uniformly. - Map/Set support. Reactive
Map/Setwere impossible in Vue 2 without rewriting their methods. - Lazy. Wrapping is per-key on access, not eagerly applied at init time. A 10,000-property reactive object pays per-property cost only when a property is actually read.
Trade-offs:
- Browser support.
Proxyis not polyfillable. Vue 3 dropped IE11 to take advantage. The team has been explicit that this is intentional and not coming back. - Identity.
reactive(obj) !== obj. Code that compares object identities across the boundary needstoRawormarkRaw. - Built-in objects. Only
Object,Array,Map,WeakMap,Set,WeakSetare tracked.Date, typed arrays, class instances are not. This is deliberate — the alternative is auditing every standard-library shape, which doesn't scale.
Implementation: packages/reactivity/src/baseHandlers.ts, packages/reactivity/src/collectionHandlers.ts.
Patch flags
A render function emitted by the compiler is far more informative than a hand-written one — the compiler knows what's static and what's dynamic. Patch flags encode that knowledge as a bitmask attached to each vnode (packages/shared/src/patchFlags.ts).
Why this works:
- The renderer can branch on
patchFlagearly inpatchElementand skip prop diffing entirely for static elements. - Static-children fragments (
STABLE_FRAGMENT) bypass keyed-children reconciliation. - A bail-out flag (
BAIL) handles the case where the runtime can't trust the flag (e.g.,cloneVNode).
Trade-off: the compiler and runtime have to stay in sync. The patch-flag enum is a public-ish contract between compiler-core and runtime-core — adding one requires changes on both sides.
The original RFC: vuejs/rfcs#23 Improvements to Component Update Performance.
Compiler/runtime separation
Vue 3's packages enforce a hard separation: compiler-side code never imports runtime-side code, and vice versa. Anything shared lives in @vue/shared. The reasoning:
- Bundle size. A runtime-only build (used by every bundler-driven app) drops the entire compiler. Without the boundary, every runtime bundle would inadvertently pull in megabytes of compiler infrastructure.
- Custom renderers. Targets like Vue Native, Lottie, or test renderers want only the runtime. The clean boundary makes this easy.
- Server-side rendering. SSR uses a different compiler (
@vue/compiler-ssr) with the same runtime (@vue/runtime-coreminus DOM). The boundary keeps this swap clean.
Implementation: the rule is enforced by code review and the import-graph itself. The eslint-plugin-import-x config catches accidental violations.
The monorepo
Vue 2 was a single package. Vue 3 is a monorepo of cooperating packages. The rationale, beyond the compiler/runtime separation above:
- Each package can be consumed standalone.
@vue/reactivityis used by Pinia, VueUse, and many third-party stores without pulling in the renderer. - Each package has its own published version and changelog scope.
- Custom-renderer authors can depend only on
@vue/runtime-coreand pin its version independently fromvue.
Implementation: pnpm workspaces (pnpm-workspace.yaml) + Rollup's externalization. The dependency graph is documented in .github/contributing.md.
Tree-shakable APIs
In Vue 2, every method was attached to the Vue class instance, so nothing was tree-shakable: importing Vue brought in the full surface area. Vue 3 exports everything as named functions instead. Combined with the esm-bundler build's preserved imports, an app that uses only ref and computed ends up with a runtime around 10–15 KB minified+gzipped.
The lazy-renderer pattern in packages/runtime-dom/src/index.ts extends this: importing createApp doesn't materialize the renderer. Only calling createApp(...) does.
Build flags as compile-time constants
Every conditional code path is gated by a build-flag identifier (__DEV__, __BROWSER__, __SSR__, __VUE_OPTIONS_API__, etc.) replaced at build time by the bundler. The advantages:
- Dead code is provably eliminated. There's no runtime check that a tree-shaker has to prove can be removed.
- Enables aggressive dev-only code without paying for it in prod. Stack traces in warnings, devtools hooks, validation — none of it makes it into the prod bundle.
- User-facing flags (
__VUE_OPTIONS_API__,__VUE_PROD_DEVTOOLS__) let app authors opt into smaller bundles by setting them at build time.
Trade-off: every contributor has to remember to wrap dev-only code in if (__DEV__). The size-report bot catches lapses on the way in.
Composition API alongside Options API
The Composition API (RFC vuejs/rfcs#42) was a controversial addition. The team's call: keep both. Reasons:
- Options API is the lower learning curve; Composition API is more powerful for large, reused logic.
- Migration friction would be enormous if Options API were dropped.
- The two coexist cleanly because Composition API is implemented underneath:
setup()returns state that the public proxy incomponentPublicInstance.tsmerges withdata/methods/computed.
Trade-off: some code paths exist twice (Options API normalization in componentOptions.ts, plus the Composition API surface). The __VUE_OPTIONS_API__ flag lets users compile out Options API support if they don't use it, recovering bundle size.
<script setup> macros, not real functions
defineProps, defineEmits, defineModel, etc., are compile-time-only. They don't actually run as runtime calls. Why:
- Type-only props.
defineProps<{...}>()with no runtime argument needs the compiler to extract runtime info from a type. There's no way to do this at runtime without bundling the TypeScript checker. - Hoisting. The macros are recognized by name and rewritten into the right places (some inside
setup(), some outside as component options). - Statelessness. The macros are never imported. Tooling shows them as available globals via the
vue/macrostypes.
Implementation: packages/compiler-sfc/src/script/. The runtime stubs in packages/runtime-core/src/apiSetupHelpers.ts exist purely so import { defineProps } doesn't fail in non-SFC contexts.
Doubly-linked Link for dependency tracking
The reactivity rewrite in 3.4 replaced an earlier "set of effects per dep, set of deps per effect" data structure with a doubly-linked list of Link nodes. Each link belongs to two lists simultaneously: the dep's subscribers, and the subscriber's deps. Why:
- Constant-time stale-link cleanup. After an effect runs, links that weren't touched in the new run are unlinked in one pass without rebuilding the data structure.
- Cache-friendly. Pointer chases are sequential within a single effect's dep list.
- No allocations on re-track. Existing links are recycled (
versionbumped); no new objects per re-track.
The globalVersion short-circuit in dep.ts lets a chain of unread computeds skip work when the underlying state hasn't changed at all.
Microtask-batched scheduler
Vue defers component updates to a microtask flush rather than running them synchronously inside trigger(). Reasons:
- Coalesce. Multiple state mutations in the same tick produce one render, not N.
- Order. Sorting jobs by component
uidensures parents render before children. Without this, a parent re-render could clobber a just-rendered child. - Pre/post hooks.
flush: 'pre'watchers run before the component re-renders;flush: 'post'watchers run after. Both modes coexist with the same scheduler.
Trade-off: await nextTick() is mandatory in tests (and sometimes in user code) to observe DOM after a state change. The scheduler also includes a recursion guard (RECURSION_LIMIT = 100) to detect infinite update loops.
Implementation: packages/runtime-core/src/scheduler.ts.
The const-enum inlining script
ES module bundlers (esbuild specifically) don't inline const enum references. Vue uses const enum heavily for hot-path bitmasks (ShapeFlags, PatchFlags, ErrorCodes, EffectFlags, SchedulerJobFlags) because numeric-literal compares are faster and smaller than property reads. To keep this working with esbuild, scripts/inline-enums.js runs as a Rollup plugin and rewrites SomeEnum.SomeMember into the literal numeric value. Without this, esbuild would emit a runtime object and every reference would compile to _enum.MEMBER.
"No relative cross-package imports"
The contributing guide is explicit: imports between packages must use the package name (@vue/reactivity), not a relative path. Three reasons:
- The packages are externalized in published bundles. Relative imports break this.
- Path aliasing (
scripts/aliases.js,tsconfig.json#paths) lets dev mode work without symlinks. - Refactoring a package's internal layout doesn't break other packages' imports.
Violations would catch in tests (relative imports between packages don't survive Rollup externalization), but the rule is also enforced by review.
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.