深入剖析 Vue 响应式系统:从零实现一个精简版

Vue 的响应式系统是其核心魅力之一,它能够在你改变数据时,自动更新依赖这些数据的视图。本文将从零开始,带你深入剖析 Vue 响应式系统的核心机制,逐步构建一个精简版的响应式系统。

一、什么是副作用函数(Effect Function)?

在编程世界中,副作用函数 (effect function)是指那些会间接或直接改变外部状态的函数。在 Vue 的响应式系统中,它扮演着至关重要的角色,特指那些"依赖于响应式数据,并在数据变化时会自动重新执行"的函数。

举个例子,下面的 effect 函数就是一个典型的副作用函数。它通过读取响应式对象 obj.title 的值,从而改变了外部的 document.body.innerHTML

JavaScript

scss 复制代码
const obj = {
  title: "hello world",
};

// 这是一个副作用函数,因为它依赖于 obj.title 并改变了外部的 DOM
function effect() {
  document.body.innerHTML = obj.title;
}

// 另一个函数,它的执行结果间接依赖于 effect 的执行
function showResult() {
  console.log(document.body.innerHTML);
}

effect(); // 执行 effect,此时 document.body.innerHTML 被设置为 "hello world"
showResult(); // 输出 "hello world"

二、初步实现响应式系统雏形

为了实现"当数据变化时,依赖于该数据的副作用函数能自动重新执行"的神奇效果,Vue 的响应式系统遵循两大核心步骤:依赖收集依赖触发

  • 依赖收集(Track) :当副作用函数执行时,它会访问响应式数据。此时,系统就像一个"侦探",默默追踪这个访问行为,并将该副作用函数"记住"下来,作为该数据的依赖
  • 依赖触发(Trigger) :当响应式数据发生变化时,系统会"通知"所有之前收集到的依赖(也就是那些副作用函数),让它们重新执行,从而更新视图或执行其他操作。

下面,我们使用 Proxy 来实现一个简化的响应式系统,模拟这个过程。

javascript 复制代码
const obj = {
  title: "hello world",
};

// 存储所有副作用函数的桶(依赖集合)
const bucket = new Set();

function effect() {
  document.body.innerHTML = obj.title;
}

// 使用 Proxy 创建响应式对象
const proxyObj = new Proxy(obj, {
  // get 拦截器:进行依赖收集
  get(target, key) {
    // 将 effect 函数添加到依赖桶中
    bucket.add(effect);
    return target[key];
  },

  // set 拦截器:进行依赖触发
  set(target, key, value) {
    // 设置新值
    target[key] = value;
    // 遍历依赖桶,执行所有副作用函数
    bucket.forEach((fn) => fn());
  },
});

// 在 1 秒后修改数据,这会触发依赖更新
setTimeout(() => {
  proxyObj.title = "hello vue";
  // 此时,document.body.innerHTML 将自动更新为 "hello vue"
}, 1000);

三、硬编码与不精准触发的优化

在上面的初步实现中,我们遇到了两个明显的问题:

  1. 副作用函数硬编码bucket.add(effect) 这种写法将副作用函数 effect 的名称写死了,这无法灵活处理多个副作用函数。
  2. 不精准的依赖触发set 拦截器会无差别地执行 bucket 中的所有副作用函数,即使修改的属性与它们无关,这会造成不必要的性能开销。

优化后的数据结构

为了解决这些问题,我们需要对存储依赖的数据结构进行升级。我们将用一个多层嵌套的数据结构来存储依赖关系,就像一个精心组织的档案库:

  • WeakMap ( bucket ) :最顶层的结构,它的键是响应式对象target)。使用 WeakMap 是一个聪明的选择,因为它的键是弱引用,当对象没有其他引用时,垃圾回收器会自动清理它,有效防止内存泄漏。
  • Map ( depsMap ) :中层结构,它的键是属性名key),值是一个 Set
  • Set ( deps ) :最底层结构,它存储了所有依赖于该属性的副作用函数 。使用 Set 可以确保每个副作用函数只被存储一次,避免重复。

通过这种结构,我们实现了精准的依赖触发 。当你只修改 proxyObj.age 时,trigger 函数会因为 depsMap.get('age') 返回 undefined 而直接返回,effect 函数将不会被触发。只有当你修改了 proxyObj.title 时,才会精准地执行与它关联的副作用函数,这解决了之前不精准触发的问题,实现了更高效、健壮的响应式系统。

ini 复制代码
const bucket = new WeakMap();
const obj = {
  title: "hello world",
};

let activeEffect = null;

function effect(fn) {
  activeEffect = fn;
  fn();
}

const proxyObj = new Proxy(obj, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);
  },
});

// 依赖收集
function track(target, key) {
  // 如果没有正在执行的副作用函数,则直接返回
  if (!activeEffect) return;
  // 获取 depsMap
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 获取 deps 集合
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 将当前 activeEffect 添加到 deps 集合中
  deps.add(activeEffect);
}

// 触发依赖
function trigger(target, key) {
  // 获取 depsMap
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  // 获取 deps 集合
  let deps = depsMap.get(key);
  // 遍历并执行所有副作用函数
  deps && deps.forEach((fn) => fn());
}

effect(() => {
  document.body.innerHTML = proxyObj.title;
  console.log("trigger effect");
});

setTimeout(() => {
  // 修改一个不相关的属性,不会触发 effect
  proxyObj.age = 18;
}, 1000);

四、解决"分支"导致的依赖遗留问题

想象一下这样的场景:

  • 第一次执行proxyObj.checktrueeffect 函数会读取 proxyObj.checkproxyObj.title。此时,proxyObj.checkproxyObj.title 都收集了该 effect 函数作为依赖。
  • 修改数据 :当 proxyObj.check 被修改为 false 时,trigger 函数会执行 effect
  • 第二次执行effect 函数再次执行,由于 proxyObj.checkfalse,它现在只访问 proxyObj.check,而不再访问 proxyObj.title。然而,proxyObj.title 的依赖集合中仍然残留着这个 effect 函数。
ini 复制代码
effect(() => {
  document.body.innerHTML = proxyObj.check ? proxyObj.title : "hahaha";
});

这会导致一个"幽灵依赖":当 title 改变时,这个本不应再执行的函数却被错误地触发了。

解决方案:先清理,再收集

为了解决这个问题,我们引入一个核心思想:在每次执行副作用函数之前,先将它从所有旧的依赖集合中移除,然后再重新收集新的依赖

为此,我们引入了两个关键机制:

  • effectFn.deps 数组 :在 effect 函数内部,我们为每一个副作用函数实例 effectFn 创建一个 deps 数组,用来存储它所关联的所有依赖集合(Set)。这样,我们就能反向追踪该函数都存在于哪些依赖集合中。
  • clean 函数 :在副作用函数重新执行前,clean 函数会遍历 effectFn.deps 数组,将该副作用函数从所有它关联的依赖集合中移除,并清空 deps 数组。
scss 复制代码
function effect(fn) {
  function effectFn() {
    clean(effectFn); // 对所有依赖集合中抹除该副作用函数
    activeEffect = effectFn; // 依赖收集需要通过 activeEffect 拿到副作用函数
    fn(); // 执行函数体
  }
  effectFn.deps = []; // 初始化 deps
  effectFn();
}

function clean(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0; // delete 不会改变 length,需要手动处理
}

// 依赖收集
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  activeEffect.deps.push(deps); // 将属性值的依赖集合添加到 activeEffect 中,用于反向追踪
}

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  // 新建一个 Set 是为了避免在遍历时因副作用函数执行而导致 Set 改变
  const newSet = new Set(deps);
  newSet && newSet.forEach((fn) => fn());
}

五、处理"嵌套"的 Effect 与 Effect 栈问题

在之前的实现中,我们使用一个全局变量 activeEffect 来存储当前正在执行的副作用函数。当存在 Effect 嵌套 (例如,一个组件内部渲染另一个子组件)时,这会导致一个严重的问题:内部的 Effect 可能会覆盖 activeEffect,导致外部的 Effect 无法正确收集到依赖。

scss 复制代码
// 假设这是外部组件的渲染函数
effect(() => {
  // 假设这是内部组件的渲染函数
  effect(() => {
    document.body.innerHTML = proxyObj.title; // 内部 effect 读取 title
  });
  // 此时,activeEffect 已经被内部 effect 覆盖
  // 如果这里有读取操作,比如 proxyObj.someOtherProp,它将错误地被收集到内部 effect 中
  console.log(proxyObj.check); // 外部 effect 读取 check
});

解决方案:引入 Effect 栈

为了解决这个问题,我们需要一个副作用函数栈 ( effectStack )

  • 执行时:将当前副作用函数压入栈中。
  • 执行后:将其从栈中弹出。
  • activeEffect:始终指向栈顶的副作用函数。

通过这种方式,我们可以保证每一个响应式数据只会收集直接读取它的副作用函数,避免了相互干扰,让依赖关系变得清晰而精准。

ini 复制代码
const bucket = new WeakMap();
const obj = {
  title: "hello world",
  check: true,
};

let activeEffect = null;
const effectStack = [];

function effect(fn) {
  function effectFn() {
    clean(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  effectFn();
}

六、解决"读写"自身属性导致的栈溢出

如果在一个副作用函数内部,我们同时读取写入同一个响应式属性,就会陷入一个无限递归的死循环:

  1. 读取属性get 拦截器触发 track,将当前副作用函数收集到"桶"中。
  2. 写入属性set 拦截器触发 trigger,从"桶"中取出副作用函数并执行。

问题在于,这个副作用函数正在执行中 ,但又被 trigger 再次调用,这会导致它无限递归地调用自己,最终引发栈溢出

scss 复制代码
// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      // 避免无限递归调用(读写同一个属性)
      if (fn !== activeEffect) {
        fn();
      }
    });
}

effect(() => {
  proxyObj.title += "2222"; // 读和写发生在同一个副作用函数中
});

为了解决这个问题,在 trigger 函数中,我们增加一个判断:当要执行的副作用函数与当前正在执行的副作用函数是同一个 时,就跳过本次执行。


七、可调度性(Scheduler)

可调度性 是响应式系统非常重要的特性。它赋予我们决定副作用函数执行时机、次数以及方式的能力。

通过引入调度器scheduler),我们可以将副作用函数的执行权交给用户。例如,我们可以设置在数据变化时,不是立即执行副作用函数,而是将它放入一个任务队列中,等待下一个"tick"再执行,从而实现批量更新,提高性能。

ini 复制代码
function effect(fn, options = {}) {
  function effectFn() {
    clean(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  effectFn.options = options; // 存储用户传入的 options
  effectFn();
}

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      if (fn !== activeEffect) {
        // 如果有调度器,则调用调度器
        if (fn.options.scheduler) {
          fn.options.scheduler(fn);
        } else {
          // 否则直接执行副作用函数
          fn();
        }
      }
    });
}

总结

至此,我们已经构建了一个功能强大、健壮、且支持嵌套的响应式系统雏形。它能够处理复杂的依赖关系,解决循环引用和依赖遗留问题,并通过调度器提供了高度的可控性。以下是完整的代码实现,你可以直接复制运行。

ini 复制代码
const bucket = new WeakMap();

const obj = {
  title: "hello world",
  check: true,
};

let activeEffect = null;
const effectStack = [];

function effect(fn, options = {}) {
  function effectFn() {
    // 1. 在执行前,先清理旧的依赖
    clean(effectFn);
    // 2. 将当前 effectFn 设置为 activeEffect
    activeEffect = effectFn;
    // 3. 将当前 effectFn 压入栈
    fn();
    // 4. 执行完毕后,将当前 effectFn 弹出栈
    effectStack.pop();
    // 5. 恢复 activeEffect 为栈顶的 effectFn
    activeEffect = effectStack[effectStack.length - 1];
  }
  // 在 effectFn 上添加一个数组,用于反向存储它所在的 deps 集合
  effectFn.deps = [];
  // 存储用户传入的 options
  effectFn.options = options;
  // 首次执行
  effectFn();
}

/**
 * 清理副作用函数的所有依赖
 */
function clean(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    // 从每个依赖集合中移除 effectFn
    deps.delete(effectFn);
  }
  // 清空 effectFn 的依赖数组
  effectFn.deps.length = 0;
}

const proxyObj = new Proxy(obj, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);
  },
});

/**
 * 依赖收集
 */
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  activeEffect.deps.push(deps);
}

/**
 * 触发依赖
 */
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      if (fn !== activeEffect) {
        if (fn.options.scheduler) {
          fn.options.scheduler(fn);
        } else {
          fn();
        }
      }
    });
}

// 示例:触发响应式
effect(() => {
  proxyObj.title += "2222";
});

setTimeout(() => {
  console.log(bucket);
}, 1000);
相关推荐
羊羊小栈4 小时前
基于「YOLO目标检测 + 多模态AI分析」的PCB缺陷检测分析系统(vue+flask+数据集+模型训练)
vue.js·人工智能·yolo·目标检测·flask·毕业设计·大作业
晚星star4 小时前
在 Web 前端实现流式 TTS 播放
前端·vue.js
本末倒置1834 小时前
前端面试高频题:18个经典技术难点深度解析与解决方案
前端·vue.js·面试
不一样的少年_5 小时前
同事以为要重写,我8行代码让 Vue 2 公共组件跑进 Vue 3
前端·javascript·vue.js
Zz_waiting.7 小时前
案例开发 - 日程管理 - 第六期
前端·javascript·vue.js·路由·router
A 风7 小时前
封装日期选择器组件,带有上周,下周按钮
开发语言·javascript·vue.js
Simon_He7 小时前
vue-markdown-renderer:比 vercel streamdown 更低 CPU、更多节点支持、真正的流式渲染体验
前端·vue.js·markdown
BillKu8 小时前
npm 安装命令中关于 @ 的讲解,如:npm install @vue-office/docx vue-demi
前端·vue.js·npm
超人不会飛9 小时前
大模型应用 Vue H5 模板:快速落地流式交互与富文本渲染的开箱方案
前端·vue.js·github