Vue Deep Dive: Reactivity / Diff / Composition API

0 0

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:

  1. Reactivity tuning demands it. Why can’t ref and reactive be swapped directly? When does computed’s cache invalidate? Without principles, you just “run with templates and hope for the best”.
  2. 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.
  3. 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:

  1. Reactive data: wrap with ref / reactive, framework auto-tracks dependencies
  2. Template rendering: templates compile to render functions, data changes auto-update DOM
  3. Event callbacks: @click directly in template, no .addEventListener needed

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:

  1. Cannot detect newly added propertiesvm.newProp = 1 doesn’t trigger update (must use Vue.set)
  2. Cannot detect array index changesarr[0] = 1 / arr.length = 0 doesn’t trigger
  3. 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:

  1. Auto-reactive for newly added / deleted properties (delete obj.prop also triggers)
  2. Array index / length changes auto-reactive
  3. 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:

  • ref wraps primitives + DOM refs + objects needing whole replacement
  • reactive wraps objects with multiple independently-modified properties
  • In templates, ref auto-unwraps (no .value needed), 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

DimensionOptions APIComposition API
State logic reusemixin (implicit merge, conflict-prone)composable functions (explicit call)
Large component splitSplit components (re-render overhead)Split composables (no render overhead)
TS type inferenceWeak (hard to infer this)Strong (ref / computed types clear)
Cross-component code sharingHard (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)

  1. Don’t entirely replace a reactive object. state = newState will lose all reactivity tracking. Must use Object.assign(state, newState) or keep references stable.
  2. 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”.
  3. Don’t use window / document in SSR. Nuxt 3 / Vite SSR runs in Node environment by default, direct window access throws. Guard with import.meta.client.
  4. Don’t confuse ref / reactive template syntax. ref auto-unwraps, reactive doesn’t — in templates, count (ref) is equivalent to count.value, but state.count (reactive) is equivalent to state.count. Check template when switching types.
  5. 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.defineProperty for reactivity. Why did Vue 3 switch to Proxy? What problems did it solve?

A: Vue 2 pain points: ① Cannot detect newly added propertiesvm.newProp = 1 doesn’t trigger update, must use Vue.set; ② Cannot detect array index changesarr[0] = 1 / arr.length = 0 doesn’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 ref vs reactive be 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

🔗 Original Link Share to reach more people

Comments