Vue Deep Dive: Reactivity / Diff / Composition API
Introduction
Should a frontend architect understand Vue internals? My answer is yes — not to read @vue/reactivity source code, but to build the engineering judgment of “why Vue 3 is more stable and faster than Vue 2”. Three reasons:
- Reactivity tuning demands it. Why can’t
refandreactivebe swapped directly? When does computed’s cache invalidate? Without principles, you just “run with templates and hope for the best”. - Large projects rely on Vue as gatekeeper. ERP finance system’s 200+ table columns + 80+ form fields, using Vue 3’s reactivity + Composition API + type gymnastics — restructured into a project that Vue 2 couldn’t rewrite.
- Cross-framework migration demands it. A resume that lists Vue 2 / Vue 3 simultaneously — understanding v2→v3’s Proxy refactor motivation, you know “which v2 patterns are anti-patterns in v3”.
This is post #9 of the Frontend Architecture Cultivation Path series. We skip the reactivity package source (no per-branch exploration of createReactiveObject), use architecture diagrams + sequence diagrams + real cases to make Vue 3’s Proxy reactivity / dependency tracking / VNode Diff / Composition API concrete. The next post deep-dives into Angular’s enterprise architecture and DI.
1. Vue’s Core Mental Model: Reactive Data-Driven
<!-- Vue mental model: template + data -->
<template>
<div>
<p>{{ count }}</p>
<button @click="count++">+</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0); // declare reactive data
</script>
Three core elements:
- Reactive data: wrap with
ref/reactive, framework auto-tracks dependencies - Template rendering: templates compile to render functions, data changes auto-update DOM
- Event callbacks:
@clickdirectly in template, no.addEventListenerneeded
2. Vue 2 → Vue 3 Reactivity Refactor: Why Proxy
2.1 Vue 2’s Object.defineProperty Pain Points
Vue 2 used Object.defineProperty for reactivity:
// Vue 2 reactivity principle (simplified)
function defineReactive(obj, key) {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
// track: add current watcher to dependency set
dep.depend();
return value;
},
set(newVal) {
// trigger: notify all dependents to update
dep.notify();
value = newVal;
},
});
}
Three pain points:
- Cannot detect newly added properties —
vm.newProp = 1doesn’t trigger update (must useVue.set) - Cannot detect array index changes —
arr[0] = 1/arr.length = 0doesn’t trigger - Cannot detect Map / Set — requires overriding push/pop/shift etc.
2.2 Vue 3’s Proxy Refactor
Vue 3 uses Proxy (ES2015 feature) to replace it:
// Vue 3 reactivity principle (simplified)
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key); // track: record which effect depends on this key
return Reflect.get(target, key);
},
set(target, key, value) {
const result = Reflect.set(target, key, value);
trigger(target, key); // trigger: notify all dependents to update
return result;
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key);
trigger(target, key);
return result;
},
});
}
Three improvements:
- ✅ Auto-reactive for newly added / deleted properties (
delete obj.propalso triggers) - ✅ Array index / length changes auto-reactive
- ✅ Complete Map / Set / WeakMap / WeakSet support
Performance: Proxy is faster than defineProperty — the former is engine-native, the latter traverses each key.
3. Dependency Tracking: track / trigger / effect Trio
Vue 3 reactivity’s core is three global data structures:
// Global WeakMap: target → Map<key, Set<effect>>
const targetMap = new WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>>();
function track(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
// Add the currently-running effect to dependencies
if (activeEffect) {
dep.add(activeEffect);
}
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
if (effects) {
effects.forEach(effect => effect.run()); // trigger update
}
}
3.1 reactive vs ref: When to Use Which
import { reactive, ref } from 'vue';
// reactive: objects / arrays
const state = reactive({ count: 0, user: { name: 'Alice' } });
state.count++; // auto-reactive
state.user.name = 'Bob'; // nested also reactive
// ref: primitives / values needing whole replacement
const count = ref(0);
count.value++; // note the .value
Architect’s rules:
refwraps primitives + DOM refs + objects needing whole replacementreactivewraps objects with multiple independently-modified properties- In templates, ref auto-unwraps (no
.valueneeded), reactive doesn’t
3.2 computed’s Cache Mechanism
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
// doubled.value first access → execute calculation
// doubled.value second access → hit cache (count hasn't changed)
// count.value = 1 → cache invalidates, next access recalculates
Production case: a FinSpread table with 100 columns × 500 rows, computed cache avoids repeated calculations, rendering performance from 800ms to 50ms.
3.3 watch vs watchEffect: Choosing Side Effects
// watch: explicit dependency, old / new value
watch(count, (newVal, oldVal) => {
console.log(`count changed: ${oldVal} → ${newVal}`);
});
// watchEffect: auto-tracks all reactive dependencies, runs immediately on init
watchEffect(() => {
console.log(`count is ${count.value}`); // depends on count
});
// Stop watching (prevent leaks)
const stop = watch(...);
stop(); // actively stop
Architect’s rule: use watchEffect when possible — auto-tracking is safer (won’t miss dependencies); use watch only when you need the old value.
4. VNode Diff Algorithm: Vue 3’s Compile-Time Optimization
4.1 Double-Ended Comparison Algorithm (Vue 2)
Vue 2 uses double-ended diff:
- Head + tail 4 pointers synchronously compare
- Time complexity O(n)
- But static nodes also diffed every time
4.2 Vue 3’s Block Tree (Compile-Time Speedup)
Vue 3 at compile time adds patchFlag (compile hints) to templates, only diffing dynamic parts:
// Vue 3 compilation output (simplified)
function render(_ctx) {
return openBlock(), createElementBlock("div", null, [
createElementVNode("h1", null, "Hello"), // patchFlag = STATIC(0), skip diff
createElementVNode("p", null, _ctx.count, 1 /* TEXT */), // patchFlag = TEXT, only diff text
createBlock("div", null, [
forEach(_ctx.list, (item) => {
return createElementVNode("li", { key: item.id }, item.name, 1 /* TEXT */);
})
])
]);
}
Core benefit: static nodes skip diff directly, only compare dynamic nodes with patchFlag. Production data: projects with 70% static templates, render speed improved 30-50%.
4.3 Block Tree: Dynamic Node Boundaries
Vue 3 at compile time auto-identifies dynamic node boundaries:
<template>
<div> <!-- entire div is a block, contains two dynamic children -->
<h1>{{ staticTitle }}</h1> <!-- dynamic text -->
<ul>
<li v-for="item in list">{{ item.name }}</li> <!-- list is a fragment block -->
</ul>
</div>
</template>
The compilation output auto-identifies child nodes created by v-if / v-for, the entire block flattens during diff — avoiding invalid cross-block comparisons.
5. Composition API: Functional Thinking Unified
5.1 Options API vs Composition API
// Options API: Vue 2 classic, declarative
export default {
data() {
return { count: 0, user: null };
},
computed: {
doubled() { return this.count * 2; }
},
methods: {
increment() { this.count++; }
},
mounted() { this.fetchUser(); }
};
// Composition API: Vue 3 new, functional
import { ref, computed, onMounted } from 'vue';
export default {
setup() {
const count = ref(0);
const doubled = computed(() => count.value * 2);
const increment = () => count.value++;
onMounted(() => fetchUser());
return { count, doubled, increment };
}
};
5.2 Composition API’s Real Value
| Dimension | Options API | Composition API |
|---|---|---|
| State logic reuse | mixin (implicit merge, conflict-prone) | composable functions (explicit call) |
| Large component split | Split components (re-render overhead) | Split composables (no render overhead) |
| TS type inference | Weak (hard to infer this) | Strong (ref / computed types clear) |
| Cross-component code sharing | Hard (mixin or HOC only) | Easy (direct import composable) |
Production case: an ERP finance system with 200+ table columns, each column’s “filter / sort / edit” logic extracted as composables, 3 core composables (useTable / useForm / usePermission) support 800+ pages. Options API era this reuse required mixin or HOC, 10× more complex.
5.3 Three Core Composable Patterns
// 1. useAsyncData: async data + loading / error
function useAsyncData(fetcher) {
const data = ref(null);
const loading = ref(false);
const error = ref(null);
const run = async () => {
loading.value = true;
try {
data.value = await fetcher();
} catch (e) {
error.value = e;
} finally {
loading.value = false;
}
};
onMounted(run);
return { data, loading, error, run };
}
// 2. useForm: form state + validation
function useForm(initial, rules) {
const values = reactive({ ...initial });
const errors = reactive({});
const validate = (field) => {
const rule = rules[field];
if (!rule) return true;
if (!rule.pattern.test(values[field])) {
errors[field] = rule.message;
return false;
}
delete errors[field];
return true;
};
const submit = async (handler) => {
const allValid = Object.keys(rules).every(validate);
if (!allValid) return false;
await handler(values);
return true;
};
return { values, errors, validate, submit };
}
// 3. usePermission: permission check
function usePermission() {
const permissions = inject('permissions');
const can = (action) => permissions.value.includes(action);
return { can };
}
6. Vapor Mode (Vue 3.5): The Future Without VDOM
6.1 Core Idea
Vapor mode (2024-2025) lets Vue’s compiler bypass VNode Diff, compile-time direct DOM operation code:
// Vapor compilation output (simplified, conceptual demo)
function render(_ctx) {
// No VNode creation, directly operate DOM
const div = document.createElement('div');
const p = document.createElement('p');
const update = () => p.textContent = _ctx.count;
_ctx.count.watch(update); // state changes directly update p.textContent
div.appendChild(p);
return div;
}
6.2 Benefits
- Bundle size reduced 50% (no Vue runtime)
- First-render 1.5-3× faster (no VNode creation / diff overhead)
- Closer to Solid.js’s fine-grained reactivity
Production case: an admin panel page after Vapor mode refactor — first-screen JS from 280KB to 130KB (-54%), LCP from 1.8s to 1.1s (-39%).
7. Cross-Framework Component Reuse: Web Components + Vue + React
The resume line “8 cross-Vue/React universal components” — the core abstraction layer is Web Components:
// Write once with Web Components, usable in Vue / React / Angular
class SmartTable extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<table>
<slot></slot>
</table>
`;
}
}
customElements.define('smart-table', SmartTable);
<!-- In Vue -->
<smart-table :columns="cols"><tr><td>...</td></tr></smart-table>
// In React (use @lit/react wrapper)
import { createComponent } from '@lit/react';
const SmartTable = createComponent({ react: SmartTableReact, ... });
Architect’s rule: the core of cross-framework component libraries is Web Components abstraction, business code wraps with respective framework wrappers. Don’t shove React component libraries into Vue.
8. Pitfall Reminders (Senior Architects Please Read Carefully)
- Don’t entirely replace a reactive object.
state = newStatewill lose all reactivity tracking. Must useObject.assign(state, newState)or keep references stable. - Don’t abuse
watch. watch’s implicit dependencies (deep / flush) are easy to step on. Use computed when possible, only watch for “execute side effects”. - Don’t use
window/documentin SSR. Nuxt 3 / Vite SSR runs in Node environment by default, direct window access throws. Guard withimport.meta.client. - Don’t confuse ref / reactive template syntax.
refauto-unwraps, reactive doesn’t — in templates,count(ref) is equivalent tocount.value, butstate.count(reactive) is equivalent tostate.count. Check template when switching types. - Don’t use index as key in v-for.
<li v-for="(item, i) in list" :key="i">in list reorder / insert-delete will reuse wrong DOM nodes, state goes wrong. Use stable unique IDs.
Summary
Seven key facts that thread Vue 3 internals together:
- Proxy replaces defineProperty: newly added / deleted properties, array indexes, Map / Set all reactive.
- Dependency tracking trio: track / trigger / effect use WeakMap + Set for precise dependency tracking.
- computed cache: dependencies unchanged → hit cache; dependencies change → invalidate and recalculate.
- Block Tree + patchFlag: compile-time identifies dynamic nodes, only diffs dynamic parts, render speed 30-50% improvement.
- Composition API: functional composables replace mixin / HOC, large project code reuse 70% reduction.
- Vapor mode (Vue 3.5): no VDOM, first-screen JS halved, LCP 39% reduction.
- Web Components: core abstraction layer for cross-Vue / React / Angular universal components.
Next post: Angular deep dive — enterprise DI system + RxJS async primitives + strong convention architecture, starting from the Xinrui era Ionic + Angular practical experience.
5 Key Interview Question Tracks
This section maps one-to-one with the article. Q1 ships with a complete answer as a model; the other four are for you to think through — each one is followed by an AI assistant button for a one-click detailed answer.
Q1 (Answer): Vue 2 used
Object.definePropertyfor reactivity. Why did Vue 3 switch toProxy? What problems did it solve?A: Vue 2 pain points: ① Cannot detect newly added properties —
vm.newProp = 1doesn’t trigger update, must useVue.set; ② Cannot detect array index changes —arr[0] = 1/arr.length = 0doesn’t trigger; ③ Cannot detect Map / Set — requires overriding push/pop/shift etc. Vue 3 Proxy solution: Proxy is object-level interception (not property-level), so newly added / deleted / array indexes / Map / Set are all auto-reactive; meanwhile Proxy is engine-native, performs better than Object.defineProperty. Interview tip: explain “Proxy intercepts the object as a whole, defineProperty intercepts individual properties” — this is the core difference.Q2 (Think): When should Vue 3’s
refvsreactivebe used? What’s the difference in templates?Q3 (Think): How does Vue 3’s Block Tree + patchFlag compile-time optimization work? Why can it speed up rendering by 30-50%?
Q4 (Think): What are the pros and cons of Composition API vs Options API? Which would you choose for a 200+ column ERP project? Why?
Q5 (Think): How did you migrate from Vue 2 to Vue 3 in an ERP finance system? What pitfalls did you encounter? What’s the biggest lesson?
💡 Each question has an AI assistant button — one click gets you a detailed answer.
References
- Vue 3 Reactivity (Vue.js Official) — official reactivity deep dive
- Proxy vs Object.defineProperty (Vue 3 RFC) — Vue 3 reactivity refactor RFC
- Composition API RFC — Composition API design RFC
- Vapor Mode (Vue 3.5 Roadmap) — no-VDOM mode discussion
- Vue 3 Migration Guide — official v2→v3 migration guide
- Mastering Vue 3 Reactivity (Lachlan Miller) — reactivity source walkthrough