Implementation Principle of Vue3's Reactive System

Implementation Principle of Vue3's Reactive System

Description: Vue3's reactive system is one of its core features, capable of automatically tracking data changes and updating related views. Unlike Vue2's implementation based on Object.defineProperty, Vue3 uses ES6's Proxy and Reflect API to build the reactive system. Understanding its principle is key to mastering the Vue3 framework.

Problem-Solving Process:

  1. Core Goal and Basic Idea

    • Goal: When a property of data (a JavaScript object) is read, we need to record "who" read it (this process is called dependency collection). When a property of data is modified, we need to notify all places that have "read" it to update (this process is called triggering updates).
    • Idea: Vue3 uses Proxy to "proxy" a plain object. Proxy can intercept (or "trap") various fundamental operations on the target object, such as getting a property (get), setting a property (set), deleting a property (deleteProperty), etc. This way, we can execute custom logic when these operations occur, thereby achieving dependency collection and triggering updates.
  2. Key Players: effect and reactive

    • reactive function: This is the function used in Vue3 to create reactive objects. It takes a plain object as a parameter and returns a Proxy of that object.
    • effect function: This is the "side effect" function of the reactive system. It takes a function (let's call it fn) as a parameter. effect will immediately execute fn once. The key point is that when fn reads a property of a reactive object during execution, Vue can establish a connection between fn (the side effect) and this property (the dependency).
  3. Step-by-Step Implementation Principle

    • Step One: Create the Simplest reactive Function
      First, create a function that returns a Proxy. This proxy temporarily only intercepts get and set operations.

      function reactive(target) {
        return new Proxy(target, {
          get(obj, key) {
            // Intercept the "get" operation
            console.log(`Read property ${key}: ${obj[key]}`);
            return obj[key]; // Return the original value
          },
          set(obj, key, value) {
            // Intercept the "set" operation
            console.log(`Modified property ${key}: from ${obj[key]} to ${value}`);
            obj[key] = value; // Set the original value
            return true; // Indicate success
          }
        });
      }
      
      // Test
      const state = reactive({ count: 0 });
      state.count; // Console output: "Read property count: 0"
      state.count = 1; // Console output: "Modified property count: from 0 to 1"
      

      Now we can intercept basic operations on the object.

    • Step Two: Introduce effect and Dependency Collection (Core)
      Now we need a mechanism to record "which effect is currently running" and "which effects depend on which property of which reactive object".

      • activeEffect global variable: Used to store the currently executing effect function.
      • targetMap global WeakMap: Used to build the dependency relationship tree.
        • Key: The reactive object (target).
        • Value: A Map.
          • The key of this Map is the property name (key) of the reactive object.
          • The value is a Set, storing all effect functions that depend on this target.key.
      let activeEffect = null; // Current active effect
      const targetMap = new WeakMap(); // Dependency storage warehouse
      
      function track(target, key) {
        // Dependency collection function, called inside the get interceptor
        if (!activeEffect) return; // No collection needed if not read inside an effect
      
        // 1. Get the depsMap corresponding to the current target from targetMap
        let depsMap = targetMap.get(target);
        if (!depsMap) {
          depsMap = new Map();
          targetMap.set(target, depsMap);
        }
      
        // 2. Get the dep (a Set) corresponding to the current key from depsMap
        let dep = depsMap.get(key);
        if (!dep) {
          dep = new Set();
          depsMap.set(key, dep);
        }
      
        // 3. Add the current active effect to the dep
        dep.add(activeEffect);
        console.log(`Tracked dependency: ${key} -> current effect`);
      }
      
      function trigger(target, key) {
        // Trigger update function, called inside the set interceptor
        const depsMap = targetMap.get(target);
        if (!depsMap) return;
      
        const dep = depsMap.get(key);
        if (dep) {
          // Iterate through the dep set, execute all effect functions inside
          dep.forEach(effect => effect());
          console.log(`Triggered update: ${key}`);
        }
      }
      
      function effect(fn) {
        // Wrap the passed function into an effect
        activeEffect = fn; // Set as the current active effect
        fn(); // Execute the function immediately. During execution, get will be triggered, thus collecting dependencies.
        activeEffect = null; // Reset after execution
      }
      
      // Update the reactive function
      function reactive(target) {
        return new Proxy(target, {
          get(obj, key) {
            track(obj, key); // Collect dependencies on read
            return obj[key];
          },
          set(obj, key, value) {
            obj[key] = value;
            trigger(obj, key); // Trigger updates on set
            return true;
          }
        });
      }
      
    • Step Three: Complete Flow Demonstration
      Let's use a complete example to connect the entire flow.

      // Create a reactive object
      const user = reactive({ name: 'Alice', age: 25 });
      
      // Define an effect
      effect(() => {
        // This function executes immediately
        console.log(`Username is: ${user.name}`);
      });
      // Console immediately outputs: "Username is: Alice"
      // Meanwhile, during effect execution, user.name is read,
      // triggering the get interceptor and calling the track function.
      // At this point, the following dependency is established in targetMap:
      // targetMap: { user -> depsMap }
      // depsMap: { 'name' -> Set( [current effect function] ) }
      
      // Modify data to trigger an update
      user.name = 'Bob';
      // 1. Trigger the set interceptor
      // 2. Call trigger(user, 'name')
      // 3. trigger finds the dep (a Set) corresponding to user.name from targetMap
      // 4. Iterate through this Set, executing all effect functions inside
      // 5. Console outputs again: "Username is: Bob"
      
  4. Vue3's Actual Implementation and Optimizations
    The above is the core principle. The implementation in Vue3's source code (in the @vue/reactivity package) is more complex and robust, mainly with the following optimizations:

    • Nested effects: Supports component nesting, managed via an effect stack.
    • Scheduler: Allows controlling the execution timing after trigger is called, e.g., placing it in a microtask queue to achieve batch asynchronous updates, which is the basis of nextTick's principle.
    • Avoiding duplicate collection: Ensures the same effect is not collected repeatedly in the same property's dep.
    • Cleanup for branch switching: For example, in v-if, when conditions change, dependencies that are no longer needed need to be cleaned up.
    • Ref & Computed: Based on the same reactive core, ref solves reactivity for primitive types using the .value property, and computed is a special effect with lazy calculation and cached value properties.

Summary: Vue3's reactive system proxies objects via Proxy, calls track for dependency collection during get operations, and calls trigger to trigger updates during set operations. The effect function is key to building the bridge between reactive data and side effect functions. The entire system revolves around a global targetMap (WeakMap) to maintain all dependency relationships.