Three Rewrites of Vue: From Angular Directives to Proxy Reactivity
Chapter 1: Vue's Three Rewrites — From Angular Directives to Proxy Reactivity
In July 2013, Evan You spent a weekend writing the first version of Vue — originally called Seed.js — in under 1,000 lines of code. A decade later, Vue 3's source is over 400,000 lines, serving more than 3 million developers worldwide.
Core Question: Why did Vue need three complete rewrites? What fundamental contradictions did each rewrite resolve?
After reading this chapter, you will understand:
- The specific technical motivations behind each major Vue version — not marketing copy
- The three insurmountable limitations of
Object.defineProperty, and how Proxy solves them - How Vue and React's evolution paths differ, and what those differences reveal about their design philosophies
Level 1 · What You Need to Know (1-3 Years Experience)
1.1 It All Started with "AngularJS is Too Heavy"
In 2013, Evan You was doing frontend development at Google Creative Lab, using AngularJS daily to build internal tools. AngularJS's two-way binding and dependency injection were genuinely powerful, but for simple UI prototyping, the full framework felt too heavyweight — a steep learning curve, verbose configuration, and a large runtime footprint.
What Evan wanted was Angular's data binding capability, stripped of everything else. Over a weekend, he wrote the first version — initially called Seed.js, later renamed Vue.js. The core idea was simple: a lightweight data-view binding library, not a framework, that required no "Angular way" of thinking to use.
This was Vue's first positioning: Angular's data binding, without the framework overhead.
1.2 Vue 1.x: The First Attempt at Fine-Grained Dependency Tracking
Vue 1.x was released in 2015. It was built on Object.defineProperty. The core idea: convert each data property into getter/setter pairs. When a property is read, record "who depends on me." When it changes, notify all dependents to update.
This mechanism is called dependency tracking. It lets Vue know exactly which DOM node to update, rather than re-rendering the entire tree.
// Simplified reactive mechanism from Vue 1.x internals
function defineReactive(obj, key, val) {
const dep = new Dep(); // Container for collecting dependencies
Object.defineProperty(obj, key, {
get() {
if (Dep.target) { // A component is currently rendering
dep.depend(); // Record that this component depends on this property
}
return val;
},
set(newVal) {
val = newVal;
dep.notify(); // Notify all dependents to update
}
});
}
Vue 1.x reactivity was extremely fine-grained: each data property corresponded to a set of Watchers. Data changes triggered precise updates, no virtual DOM, directly manipulating the real DOM. For small applications, this was highly efficient.
But it had a fundamental problem: Object.defineProperty can only intercept properties that already exist on an object. If you added a new property to a data object after the Vue instance was created, Vue had no way of knowing. The getter/setter for that new property simply didn't exist.
// In Vue 1.x, this does not trigger a view update
this.someObject.newProp = 'hello'; // The reactive system is blind to this operation
Vue 1.x exposed a $set method to work around this limitation, but it was a surgical patch, not a fundamental fix.
1.3 Vue 2.x: Virtual DOM Arrives, Same Limitations Remain
Vue 2.0 was released in 2016. The biggest change was introducing Virtual DOM, inspired by the Snabbdom library (author Simon Friis Vindum's implementation was roughly 200 lines).
Why virtual DOM?
Vue 1.x's fine-grained dependency tracking worked well at the component level, but in large applications, when a single piece of state was subscribed to by hundreds of Watchers, managing those Watchers became a non-trivial overhead. Vue 2's solution was to shift granularity from "per property → per DOM node" to "per property → per component": when data changes, re-run the component's render function, generate a new virtual DOM tree, and use a diff algorithm to find the minimal update set.
Vue 2 also introduced render functions: instead of templates, you could write JavaScript functions directly to describe the view structure. This significantly expanded Vue's capability boundary — JSX support, higher-order components, and more complex rendering logic all became possible.
But Vue 2 inherited Vue 1's same limitations, because it was still based on Object.defineProperty:
Limitation 1: Cannot detect property addition
// This does NOT trigger a view update
this.user.age = 18; // 'user' exists, but 'age' is a new property
// Must use:
this.$set(this.user, 'age', 18);
Limitation 2: Cannot detect property deletion
// This does NOT trigger a view update
delete this.user.name;
// Must use:
this.$delete(this.user, 'name');
Limitation 3: Cannot detect array index assignment
// This does NOT trigger a view update
this.list[0] = newItem;
// Must use:
this.$set(this.list, 0, newItem);
// Or:
this.list.splice(0, 1, newItem);
Vue 2's handling of arrays was an interesting engineering compromise: it hijacked 7 mutation methods (push, pop, shift, unshift, splice, sort, reverse) to make them reactive. But direct index assignment and length modification remained invisible to the reactivity system.
These three limitations frustrated Vue 2 developers for four years. The documentation had a dedicated "Caveats" section listing them. A significant proportion of Vue-related Stack Overflow questions trace back to these three constraints.
1.4 Vue 3.x: Five Motivations for a Complete Rewrite
In late 2018, Evan You published a Medium article announcing that Vue 3 would be rewritten from scratch. He gave five motivations:
Motivation 1: TypeScript Support
Vue 2 was written in Flow (Facebook's type checker), but Flow's type inference capabilities were far inferior to TypeScript's. More critically, Vue 2's this context made TypeScript type inference nearly non-functional — you couldn't get proper type hints for component data and methods.
Vue 3 was fully rewritten in TypeScript, with API design treating type inference as first priority.
Motivation 2: Composition API
When component logic grows complex, Options API scatters the code for a single feature across data, methods, computed, and watch. Composition API allows organizing code by feature rather than option type. (See Chapter 2 for details.)
Motivation 3: Proxy-based Reactivity
Proxy is a native ES2015 object proxy mechanism that can intercept all fundamental operations on an object, including property addition, property deletion, and array index assignment. All three Object.defineProperty limitations disappear.
Motivation 4: Tree-shakable Architecture
Vue 2 mounted all APIs on the Vue global object (Vue.component, Vue.filter, Vue.mixin), which couldn't be tree-shaken by bundlers. Vue 3 switched to named exports — unused APIs won't be included in the final bundle.
Motivation 5: Performance Improvements The PatchFlag system based on compile-time analysis: the compiler marks each node's dynamic parts when generating virtual DOM. The runtime diff only checks parts marked as dynamic, skipping static content.
The Vue team measured Vue 3 vs Vue 2 performance on Chrome's js-framework-benchmark:
- Initialization speed: +55%
- Update speed: +133%
- Memory usage: -54%
1.5 Vue vs React: Two Parallel Evolution Tracks
Vue and React were born around the same time (circa 2013), but their evolution tracks are strikingly different:
| Dimension | React Evolution | Vue Evolution |
|---|---|---|
| State Management | Class component state → Hooks useState | Options API data → Composition API ref/reactive |
| Logic Reuse | HOC → Render Props → Hooks | Mixin → Composition API |
| Type Support | Gradual improvement → Major gains with Hooks | Flow → Full TypeScript rewrite |
| Render Optimization | Manual memo/useMemo/useCallback | Compile-time automatic dependency tracking |
| Philosophy | Functions-first, UI = f(state) | Reactivity-first, auto-track changes |
React Hooks (2019) and Vue Composition API (2020) solved the same problem at nearly the same time: logic reuse and code organization. But their solutions are fundamentally different. React Hooks re-execute on every render. Vue's setup() executes only once. This difference is why React needs useCallback, useMemo, and dependency arrays, while Vue needs none of these.
Level 2 · How It Works Under the Hood (3-5 Years Experience)
2.1 How Object.defineProperty Works and Why It Has Fundamental Limits
To understand why Vue 3 needed Proxy, you need to understand what Object.defineProperty actually does, and why it cannot solve those three limitations.
Object.defineProperty was introduced in ES5 to define or modify properties on an object, operating on property descriptors:
// Data descriptor
Object.defineProperty(obj, 'count', {
enumerable: true, // visible to for...in and Object.keys()
configurable: true, // can be deleted, can be redefined
writable: true, // can be changed with assignment operator
value: 0
});
// Accessor descriptor (cannot have value/writable simultaneously)
Object.defineProperty(obj, 'count', {
enumerable: true,
configurable: true,
get() { return this._count; },
set(val) { this._count = val; }
});
Vue's reactivity uses accessor descriptors (getter/setter). When you define data() { return { count: 0 } } in Vue 2, Vue internally iterates all properties of that object and calls defineProperty on each one, converting them into getter/setter pairs.
Why can't it detect property addition?
defineProperty can only operate on properties that already exist on the object. When you write this.user.newProp = 1, the JavaScript engine executes a plain property assignment — it doesn't trigger any existing getter/setter, because the accessor descriptor for newProp simply doesn't exist. Vue has no opportunity to intercept this operation.
To intercept property addition, you need a mechanism that intercepts "assignment to any property on an object," not just a specific property's assignment — which is exactly what Proxy's set trap does.
Why can't it detect property deletion?
The delete obj.prop operator is a distinct operation that doesn't trigger getters/setters. Even if a property has a setter, delete bypasses it and directly removes the property descriptor from the object. There is no way to intercept delete at the defineProperty level.
Why can't it detect array index assignment?
This is the most subtle case. Theoretically, you could call defineProperty on each array index:
const arr = [1, 2, 3];
Object.defineProperty(arr, '0', {
get() { return this._0; },
set(val) { this._0 = val; dep.notify(); }
});
Vue 2 actually does this for arrays of known length! For each existing index, Vue 2 calls defineProperty.
The problem is: when you write arr[100] = 'new', index 100 is new — same problem as property addition. Worse, arr.length = 0 batch-deletes elements in a way that can't be intercepted via getter/setter.
Vue 2's solution was to hijack array mutation methods, but this only covers those 7 methods — index assignment and length modification remain blind spots.
2.2 How Proxy Works
Proxy, introduced in ES2015, allows creating a "proxy" for an object that can intercept and redefine its fundamental operations:
const handler = {
get(target, key, receiver) {
console.log(`Reading property: ${String(key)}`);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log(`Setting property: ${String(key)} = ${value}`);
return Reflect.set(target, key, value, receiver);
}
};
const obj = {};
const proxy = new Proxy(obj, handler);
proxy.newProp = 1; // Triggers set trap: "Setting property: newProp = 1"
delete proxy.newProp; // Triggers deleteProperty trap (if defined)
Proxy's set trap fires on any property assignment, regardless of whether the property already exists. This directly solves the "cannot detect property addition" problem.
Object.defineProperty interception layer:
obj observer
┌──────────────────┐ ┌────────────────────────────────────────┐
│ .name [getter] │ │ Registered when defineProperty('name') │
│ [setter] │◄──│ Can only intercept known properties │
│ .age [getter] │ │ │
│ [setter] │ │ New prop: obj.xxx = 1 → BLIND ✗ │
│ .xxx ??? │ │ Delete: delete obj.yyy → BLIND ✗ │
└──────────────────┘ └────────────────────────────────────────┘
Proxy interception layer:
target handler
┌──────────────────┐ ┌────────────────────────────────────────┐
│ .name │ │ get trap: ALL property reads pass here │
│ .age │ │ set trap: ALL property writes go here │
│ .xxx (new) │◄─│ deleteProperty trap: delete operator │
│ .yyy │ │ has trap: in operator │
└──────────────────┘ │ ownKeys trap: Object.keys() etc. │
└────────────────────────────────────────┘
2.3 Architectural Changes in Vue 3's Reactivity System
Vue 3's reactivity system is fully extracted into a standalone package: @vue/reactivity. This package can be used independently, without any other part of Vue.
Vue 3 package structure (key parts):
@vue/reactivity @vue/runtime-core @vue/runtime-dom
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ reactive() │ │ defineComponent() │ │ createApp() │
│ ref() │─────►│ computed() │──────►│ render (DOM) │
│ effect() │ │ watch/watchEffect │ │ patch (DOM diff) │
│ track/trigger │ │ component lifecycle│ │ nodeOps │
│ Proxy handlers │ │ VNode system │ └───────────────────┘
└─────────────────┘ └──────────────────┘
Vue 3's core dependency-tracking data structure is a three-level nested Map:
WeakMap<target, Map<key, Set<ReactiveEffect>>>
targetMap (WeakMap)
│
├── target1 (depsMap: Map)
│ ├── "key1" → dep (Set<ReactiveEffect>)
│ │ ├── effect1
│ │ └── effect2
│ └── "key2" → dep (Set<ReactiveEffect>)
│ └── effect3
│
└── target2 (depsMap: Map)
└── "keyA" → dep (Set<ReactiveEffect>)
└── effect4
Using WeakMap means that when a target object has no other references, the garbage collector can automatically reclaim it — no manual dependency graph cleanup needed.
2.4 Real-World Code Organization Benefits of Composition API
Consider a real example: a list page with search, pagination, and sorting features.
Options API approach: logic scattered across multiple options:
// Vue 2 Options API
export default {
data() {
return {
// Search
searchQuery: '',
searchResults: [],
isSearching: false,
// Pagination
currentPage: 1,
pageSize: 20,
total: 0,
// Sorting
sortBy: 'name',
sortOrder: 'asc'
}
},
computed: {
// Search
hasResults() { return this.searchResults.length > 0 },
// Pagination
totalPages() { return Math.ceil(this.total / this.pageSize) },
// Sorting
sortIcon() { return this.sortOrder === 'asc' ? '↑' : '↓' }
},
methods: {
// Search
async search() { /* ... */ },
clearSearch() { /* ... */ },
// Pagination
goToPage(page) { /* ... */ },
changePageSize(size) { /* ... */ },
// Sorting
sort(field) { /* ... */ }
},
watch: {
searchQuery(val) { this.search(); }
}
}
Composition API approach: each feature encapsulated in its own composable:
// useSearch.js
export function useSearch() {
const searchQuery = ref('');
const searchResults = ref([]);
const isSearching = ref(false);
const hasResults = computed(() => searchResults.value.length > 0);
async function search() { /* complete logic */ }
function clearSearch() { /* complete logic */ }
watch(searchQuery, search);
return { searchQuery, searchResults, isSearching, hasResults, search, clearSearch };
}
// usePagination.js
export function usePagination() { /* complete pagination logic */ }
// useSorting.js
export function useSorting() { /* complete sorting logic */ }
// In the component:
export default {
setup() {
const search = useSearch();
const pagination = usePagination();
const sorting = useSorting();
return { ...search, ...pagination, ...sorting };
}
}
The advantage of Composition API isn't reduced code volume — it's that code for the same feature lives physically together, making it easier to understand, test, and move around.
Level 3 · Design Documents and Source Code (Senior Developers)
3.1 Key Design Decisions During the Vue 3 Rewrite
Vue 3's rewrite process is documented across multiple RFCs on GitHub. The most critical ones:
RFC-0004 (Jan 2019): Function-based Component API The original Composition API proposal. It sparked one of the most heated RFC discussions in GitHub history, with over 1,000 comments. The final decision preserved Options API, with Composition API as an optional addition.
RFC-0006 (Jun 2019): Slots Unification Vue 3 unified normal slots and scoped slots syntactically, both becoming functions under the hood. This decision made TypeScript type inference significantly more tractable.
RFC-0008 (Oct 2019): Composition API (Final)
After six months of community discussion, Composition API was formally accepted as a standalone API, with setup() as the entry point.
3.2 Proxy Handler Source Code
The core of Vue 3's reactivity system lives in packages/reactivity/src/baseHandlers.ts:
// packages/reactivity/src/baseHandlers.ts
import { track, trigger } from './effect'
import { TrackOpTypes, TriggerOpTypes } from './operations'
import { reactive, readonly, isReactive } from './reactive'
// Handler for normal mutable objects
export const mutableHandlers: ProxyHandler<object> = {
get, // property access triggers track
set, // property write triggers trigger
deleteProperty, // delete operator triggers trigger
has, // in operator triggers track
ownKeys // Object.keys() etc. trigger track
}
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
// Special key handling
if (key === ReactiveFlags.IS_REACTIVE) {
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
return isReadonly
} else if (
key === ReactiveFlags.RAW &&
receiver === (isReadonly ? readonly : reactive)(target)
) {
return target // How toRaw() works
}
const targetIsArray = isArray(target)
if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
// Special handling for array methods (includes, indexOf, lastIndexOf)
return Reflect.get(arrayInstrumentations, key, receiver)
}
const res = Reflect.get(target, key, receiver)
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res // Don't track built-in Symbols
}
if (!isReadonly) {
track(target, TrackOpTypes.GET, key) // KEY: trigger dependency collection
}
if (shallow) {
return res // shallowReactive doesn't recursively proxy
}
if (isRef(res)) {
// Nested ref auto-unwrapping (but NOT inside arrays)
const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
return shouldUnwrap ? res.value : res
}
if (isObject(res)) {
// Lazy proxy: only create Proxy for nested objects when accessed
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
Note the lazy proxy strategy: nested objects are not proxied when reactive() is called. A Proxy is created only when the nested object is accessed. This avoids premature traversal of deeply nested objects, improving initialization performance.
// set handler implementation
function createSetter(shallow = false) {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
let oldValue = (target as any)[key]
if (!shallow) {
if (!isShallow(value) && !isReadonly(value)) {
oldValue = toRaw(oldValue)
value = toRaw(value)
}
// If old value is a ref and new value is not, auto-update ref.value
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
}
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// Only trigger when target is the nearest proxy in receiver's prototype chain
// This prevents duplicate triggering when objects are inherited
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value) // New property
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue) // Modified property
}
}
return result
}
}
The target === toRaw(receiver) check is a subtle detail: it prevents the parent object's set trap from firing twice when an object has inherited properties.
3.3 Vue 3's Compile-Time Optimization: The PatchFlag System
Vue 3's compiler analyzes templates and marks dynamic nodes with PatchFlags. The runtime diff only checks flagged portions:
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
TEXT = 1, // dynamic text content
CLASS = 1 << 1, // dynamic class
STYLE = 1 << 2, // dynamic style
PROPS = 1 << 3, // dynamic props (excluding class and style)
FULL_PROPS = 1 << 4, // props with dynamic keys (need full diff)
HYDRATE_EVENTS = 1 << 5,
STABLE_FRAGMENT = 1 << 6,
KEYED_FRAGMENT = 1 << 7,
UNKEYED_FRAGMENT = 1 << 8,
NEED_PATCH = 1 << 9,
DYNAMIC_SLOTS = 1 << 10,
DEV_ROOT_FRAGMENT = 1 << 11,
HOISTED = -1, // static hoisted node, never diff'd
BAIL = -2 // skip all optimization during diff
}
The template <div :class="cls">{{ text }}</div> compiles to:
// Compiled render function
createElementVNode("div", { class: _ctx.cls }, _ctx.text,
PatchFlags.CLASS | PatchFlags.TEXT // = 3
)
When the runtime receives PatchFlag = 3, it only checks class and text, skipping all other attribute comparisons. This is the core source of Vue 3's 133% update speed improvement.
3.4 The Independence of @vue/reactivity and Its Ecosystem Impact
Vue 3 fully decoupled the reactivity system, making @vue/reactivity usable in any JavaScript environment:
import { reactive, effect } from '@vue/reactivity';
const state = reactive({ count: 0 });
effect(() => {
console.log('count is:', state.count); // Prints immediately
});
state.count++; // Triggers effect re-execution, prints "count is: 1"
The impact of this design is far-reaching:
- VueUse (the largest Vue composable library) is built on
@vue/reactivityat its core - Petite-vue (approximately 6KB) reuses the same reactivity core
- @vue/reactivity-transform (experimental) makes
.valuehandling automatic at compile time
Level 4 · Edge Cases and Traps (Everyone Should Read)
Trap 1: reactive() Does Not Work with Primitive Types
import { reactive } from 'vue';
// Wrong usage
const count = reactive(0); // Returns the number 0, not reactive
count++; // This triggers no updates whatsoever
// Correct usage
const count = ref(0);
count.value++;
// Why?
// reactive() internally does: new Proxy(target, handlers)
// Proxy can only proxy objects, not primitives like number/string/boolean
// Vue will print a warning:
// [Vue warn]: value cannot be made reactive: 0
Root cause: The first argument to Proxy must be an object. Vue has an internal check specifically for this — null also fails because even though typeof null === 'object', Vue explicitly guards against it.
Trap 2: Destructuring a reactive Object Loses Reactivity
import { reactive, watch } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
// Wrong: after destructuring, count is a plain number
const { count } = state;
console.log(count); // 0
state.count = 10;
console.log(count); // Still 0, didn't follow the change
// Fix 1: Don't destructure, use state.count directly
// Fix 2: Use toRefs
import { toRefs } from 'vue';
const { count } = toRefs(state);
console.log(count.value); // 0, count is now a ref linked to state.count
state.count = 10;
console.log(count.value); // 10, correctly follows the change
// Fix 3: Use ref directly instead of reactive (recommended)
const count = ref(0);
const name = ref('Vue');
Root cause: const { count } = state is equivalent to const count = state.count. This only reads the current value (triggering the get trap once, returning the number 0) and assigns it to the count variable. Numbers are primitives — they have no reactivity. Subsequent changes to state.count have no effect on the count variable.
Trap 3: Accessing Reactive Data Outside an Effect Does Not Establish Tracking
import { reactive, watchEffect } from 'vue';
const state = reactive({ count: 0 });
// Mistaken assumption: that a reactive object "actively notifies" subscribers
// Reality: you must access it inside an effect first to establish the tracking relationship
let lastCount = state.count; // Reading outside an effect — no tracking established
state.count = 5;
console.log(lastCount); // Still 0
// Correct: access inside watchEffect
watchEffect(() => {
lastCount = state.count; // Reading inside an effect — tracking established
console.log('count changed:', lastCount);
});
state.count = 5; // Triggers watchEffect to re-execute
Root cause: Reactive tracking is "pull-based." There must be a consumer (an effect). When the effect executes and reads data, the dependency relationship (target → key → effect) is established. Outside an effect context, the track() function checks whether activeEffect exists — if not, it returns immediately without doing anything.
Trap 4: The Necessity of toRaw with Nested reactive Objects
import { reactive, toRaw } from 'vue';
const original = { nested: { value: 1 } };
const state = reactive(original);
// Due to lazy proxying, accessing state.nested creates a Proxy for original.nested
console.log(state.nested === original.nested); // false!
// state.nested is a Proxy wrapping original.nested
// Scenario: passing data to a third-party library (which doesn't need reactivity)
thirdPartyLib.process(state); // May cause issues — it's receiving a Proxy
// Correct: use toRaw to get the original object
thirdPartyLib.process(toRaw(state)); // Passes the raw object
// Another common trap: equality comparisons
const rawState = toRaw(state);
console.log(rawState === original); // true
console.log(rawState.nested === original.nested); // true
Root cause: Vue 3's lazy proxy strategy means that each time you access a nested object through a Proxy, you receive a Proxy wrapping that nested object. This means the object reference you see inside the Proxy environment differs from the original object reference. === comparisons fail, and if you cache a nested object obtained from a Proxy, you have a Proxy — passing it to third-party code unaware of Vue may produce unexpected behavior.
Trap 5: Vue 3 Array Reactivity and Index Tracking
import { reactive, watchEffect } from 'vue';
const list = reactive([1, 2, 3]);
// These operations ARE reactive in Vue 3 (unlike Vue 2)
watchEffect(() => {
console.log(list[0]); // Tracks index 0
});
list[0] = 99; // Triggers update! Vue 3 CAN track index assignment
// But this trap still exists:
watchEffect(() => {
console.log(list.length);
});
// These two have different tracking effects:
list.push(4); // Triggers length tracking (length changes)
list[10] = 99; // Also triggers length tracking (sparse array, length becomes 11)
// But:
const arr = reactive(['a', 'b', 'c']);
watchEffect(() => {
// This accesses arr.length but NOT specific elements
console.log('length:', arr.length);
});
arr[0] = 'A'; // Does NOT trigger! Only length was tracked, not index 0
Root cause: Vue 3 tracks exactly the properties you actually access. If your effect only reads arr.length, it re-executes only when length changes. An in-place index replacement (which doesn't change length) won't trigger an effect that only tracked length. This is the cost of precise tracking: you get updates for exactly what you track — no spurious updates, but also no automatic tracking of what you didn't access.
Chapter Summary
-
Each of Vue's three rewrites had clear, specific motivations: Vue 1 was "AngularJS, stripped down"; Vue 2 introduced virtual DOM to handle large-scale updates; Vue 3 used Proxy to eliminate
Object.defineProperty's three fundamental limitations while fully rewriting in TypeScript to improve developer experience. -
Object.defineProperty's three limitations are structural — patches cannot fix them: inability to intercept property addition, property deletion, and array index assignment.
$set/$deleteare API-level workarounds, not technical solutions. -
Proxy solves more than just those three limitations: Proxy's 13 traps cover all fundamental operations on objects, letting Vue 3 track the
inoperator,Object.keys(),delete, and other operations that were completely invisible in Vue 2. -
Vue 3's performance gains come from two orthogonal optimizations: runtime Proxy reactivity (precise tracking, no need for
$set) + compile-time PatchFlag system (static analysis reduces diff work). Combined, they produce the measured 55% initialization improvement and 133% update improvement. -
Vue and React's evolution solved the same problem but in fundamentally different ways: Hooks re-execute on every render (stale closures and dependency arrays are the price).
setup()executes only once (no stale closures, no dependency arrays). This is not a quality difference — it is the inevitable consequence of two different reactivity models.