300 行代码!手把手教你写一个简版 Vue3 框架 📣

一、前言

Vue3 是前端开发中最为常用的框架之一,其简洁的语法和高效的响应式系统深受开发者喜爱。那么,Vue3 究竟是如何实现的?今天,我们就来"拆轮子"------用 XXX 行原生 JavaScript,从零手搓一个简版 Vue3 框架。响应式怎么实现?依赖是如何收集?组件又该怎样渲染?虚拟 DOM 是怎么样的?通过简版 Vue3 框架的实现,深入剖析 Vue3 的核心原理,这些问题都能逐一被解答。

完整版(含注释和示例)源码请移步 Github 👉 Vue-Like

二、代码实现

2.1 响应式系统和依赖追踪

Vue 3 的响应式系统是其核心功能之一,通过 Proxy API 实现了对对象的响应式处理。与 Vue 2 的 defineProperty 方法相比,Proxy 提供了更强大的拦截能力,能够支持对对象的嵌套属性、数组等进行响应式处理。

javascript 复制代码
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      track(target, key); // 收集依赖
      return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const res = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key); // 触发依赖
      }
      return res;
    },
  });
}

reactive 函数的作用是将一个普通对象转换为响应式对象。我们可以通过 Proxy API 对对象的读取和设置操作进行拦截,从而实现响应式功能。在获取值的场景下收集依赖,在设置值的场景下触发依赖。

javascript 复制代码
function track(target, key) {
  if (!effectStack.length) return;
  let depsMap = reactiveMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    reactiveMap.set(target, depsMap);
  }
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }
  const effect = effectStack[effectStack.length - 1];
  dep.add(effect);
  effect.deps.push(dep);
}

track 函数的作用是收集依赖。当一个响应式对象的属性被读取时,我们需要将当前的副作用函数(effect)与该属性关联起来,以便在属性值发生变化时能够触发更新。⚠️ 注意,所有的响应依赖关系通过 WeakMap 数据结构存储,reactiveMap 的键是响应式对象,值是一个 Map,其中的键是对象的属性名,值是一个 Set,存储了与该属性相关的副作用函数。

javascript 复制代码
function trigger(target, key) {
  const depsMap = reactiveMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;

  // 创建副本避免无限循环
  const effects = new Set(dep);
  effects.forEach((effect) => {
    if (effect.scheduler) {
      effect.scheduler();
    } else {
      effect();
    }
  });
}

trigger 函数的作用是触发依赖更新。当一个响应式对象的属性值发生变化时,我们需要通知所有与该属性相关的副作用函数重新执行。⚠️ 注意,执行副作用函数遍历前需要创建副本避免无限循环,因为执行副作用函数的时候可能会导致 dep 内容发生变化,导致 forEach 会无限执行下去。

javascript 复制代码
// 基于 reactive 实现
function ref(value) {
  return reactive({
    value, 
    __v_isRef: true,
  })
}
// 基于 proxy 实现
function ref(value) {
  const r = {
    get value() {
      track(r, "value");
      return value;
    },
    set value(newVal) {
      if (newVal !== value) {
        value = newVal;
        trigger(r, "value");
      }
    },
    __v_isRef: true,
  };
  return r;
}

ref 函数的作用是创建一个响应式的引用对象。它通常用于处理基本数据类型的响应式需求。最简单的方式是通过 reactive 来创建,但是这种方式会导致产生很多多余的依赖跟踪,因此也可以单独基于 proxy 实现。

2.2 副作用函数

javascript 复制代码
function effect(fn, scheduler) {
  const effectFn = () => {
    try {
      effectStack.push(effectFn);
      cleanupEffect(effectFn);
      fn();
    } finally {
      effectStack.pop();
    }
  };
  effectFn.scheduler = scheduler;
  effectFn.deps = [];
  effectFn();
}

副作用函数是响应式系统中的一个重要概念。它通常用于执行与响应式对象相关的操作,例如更新 DOM 或执行计算属性的逻辑等。在 effect 函数中,我们将传入的函数包装为一个副作用函数,并将其压入 effectStack。在执行副作用函数时,会收集依赖,并在属性值发生变化时重新执行函数触发更新。

2.3 虚拟 DOM

虚拟 DOM( VDOM )是一种轻量级 JS 数据格式,它旨在将渲染逻辑与实际的 DOM 解耦,提高 DOM 操作性能。通过将真实 DOM 转换为虚拟 DOM,我们可以在内存中进行高效的比较和更新操作,从而减少对真实 DOM 的直接操作。虚拟 DOM 由虚拟节点组成,其包含三部分内容,tagpropschildren,类似于这样:

JSON 复制代码
{
    "tag": "div",
    "props": {
        "class": "container"
    },
    "children": [
        {
            "tag": "h1",
            "props": {},
            "children": "Hello World"
        },
        {
            "tag": "p",
            "props": {},
            "children": "I'm Sherwin."
        }
    ]
}

相当于这个 HTML 代码:

html 复制代码
<div class="container">
    <h1>Hello World</h1>
    <p>I'm Sherwin.</p>
</div>

创建和渲染虚拟 DOM 主要涉及以下几个函数:

  1. h函数:创建一个虚拟节点;
  2. render 函数负责将虚拟 DOM 渲染到容器中;
  3. mount 函数用于挂载新的虚拟 DOM;
  4. patch 函数用于比较新旧虚拟 DOM 的差异并进行更新

代码内容较多不再一一贴出,可移步阅读完整版代码 👉 Vue-Like。注意,代码中 patch 函数仅提供了简易版 diff 算法,实际 patch 中的 diff 算法复杂得多,详见源码,尤其关注最长递增子序列算法的应用,详见源码

2.4 组件渲染

javascript 复制代码
function createComponent(vnode) {
  const component = vnode.tag;
  const props = vnode.props || {};
  const ctx = component.setup(props);
  const container = document.createElement("div");
  const updateComponent = () => {
    console.log("子组件渲染");
    const combinedCtx = { ...ctx, ...props };
    const subVnode = component.render(combinedCtx)();
    if (vnode.component) {
      patch(vnode.component.vnode, subVnode, container);
    } else {
      mount(subVnode, container);
      vnode.component = { instance: ctx, vnode: subVnode, container };
    }
    vnode.component.vnode = subVnode;
  };
  vnode.el = container;
  effect(updateComponent);
  return container;
}

当虚拟 DOM 对应的 tag 不为字符串,即 DOM 标签,而是一个对象时,则认为是一个组件,需要创建对象对应的组件。子组件渲染是一个副作用函数,当其中的依赖发生改变时就需要重新执行渲染。

2.5 watch 实现

javascript 复制代码
function watch(source, callback, options = {}) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else if (source.__v_isRef) {
    getter = () => source.value;
  }

  let oldValue;

  // 调度函数
  const job = () => {
    const newValue = effectFn();
    callback(newValue, oldValue);
    oldValue = newValue;
  };
  const effectFn = () => getter();
  effect(effectFn, job);

  if (options.immediate) {
    job();
  }
}

watch 函数用于监听响应式对象的变化,并执行指定的回调函数。它通常用于执行副作用操作,例如发送网络请求或更新 DOM。在 watch 函数中,我们通过 effect 函数创建了一个副作用函数,用于监听响应式对象的变化。当响应式对象的值发生变化时,副作用函数会被触发,执行指定的回调函数。⚠️ 注意,在 watch 这种场景副作用函数使用了调度器 schedulergetter 函数用于变动依赖收集,但真正遇到变动时执行的副作用函数不再是 getter 函数,而是调度器 scheduler

2.6 computed 实现

javascript 复制代码
function computed(getter) {
  let dirty = true; // 脏检查标志
  let value;
  const effectFn = () => {
    value = getter();
    dirty = false;
  };

  effect(effectFn, () => {
    dirty = true; // 当依赖变化时,标记为脏值,后续读取则会重新计算
  });

  const computedObj = {
    get value() {
      if (dirty) {
        effectFn();
        console.log("重新计算");
      } else {
        console.log("读取缓存");
      }
      return value;
    },
  };
  return computedObj;
}

computed 函数用于创建计算属性。计算属性值是基于其依赖的响应式数据动态计算的。当依赖的响应式数据发生变化时,计算属性的值也会自动更新。在 computed 函数中,通过 effect 函数创建了一个副作用函数,用于计算计算属性的值。当依赖的响应式数据发生变化时,副作用函数会被触发,重新计算计算属性的值。计算属性的一个重要特性是缓存。计算属性的值只有在依赖的响应式数据发生变化时才会重新计算,否则直接返回缓存的值。这可以避免不必要的计算,提高性能。通过 dirty 标志,我们可以判断计算属性的值是否需要重新计算。

三、使用示例

javascript 复制代码
/**
 * 挂载容器
 */
const container = document.getElementById("app");

/**
 * 子组件
 */
const Counter = {
  setup(props) {
    const count = ref(0);
    const increment = () => count.value++;
    const decrement = () => count.value > 0 && count.value--;
    const reset = () => (count.value = 0);

    const total = computed(() => Number(props.parentCount.value || 0) + count.value);

    return {
      count,
      total,
      increment,
      decrement,
      reset,
    };
  },
  render(ctx) {
    return () =>
      h("div", { class: "component-container" }, [
        h("h2", null, "Counter Component"),
        h("div", { class: "value-display" }, `Parent Count: ${ctx.parentCount.value}`),
        h("div", { class: "value-display" }, `Current Count: ${ctx.count.value}`),
        h("div", { class: "value-display" }, `Total Count: ${ctx.total.value}`),
        h("div", { class: "btn-group" }, [
          h("button", { onClick: ctx.increment }, "Increment"),
          h("button", { onClick: ctx.decrement }, "Decrement"),
          h("button", { onClick: ctx.reset }, "Reset"),
        ]),
      ]);
  },
};

/**
 * 根组件
 */
const App = {
  setup() {
    // ref 场景
    const count = ref(0);

    // reactive 场景
    const user = reactive({
      name: { first: "Sherwin", last: "Williams" },
    });

    // computed 场景
    const double = computed(() => count.value * 2);
    const triple = computed(() => count.value * 3);

    // 执行方法
    const increment = () => count.value++;
    const decrement = () => count.value > 0 && count.value--;
    const changeName = () => {
      const names = [
        ["Emma", "Smith"],
        ["Liam", "Johnson"],
      ];
      const randomName = names[Math.floor(Math.random() * names.length)];
      user.name.first = randomName[0];
      user.name.last = randomName[1];
    };

    // watch 场景
    watch(count, (newValue, oldValue) => {
      console.log(`Count changed from ${oldValue} to ${newValue}`);
    });
    watch(
      () => user.name.first,
      (newValue, oldValue) => {
        console.log(`Name changed from ${oldValue} to ${newValue}`);
      },
      { immediate: true }
    );

    return {
      count,
      user,
      double,
      triple,
      increment,
      decrement,
      changeName,
    };
  },
  render(ctx) {
    return () =>
      h("div", null, [
        h("h1", null, "Vue-Like Framework"),

        // User information section demonstrating reactive objects
        h("div", { class: "card user-card" }, [
          h("h2", null, "User Information"),
          h("div", { class: "value-display" }, `Full Name: ${ctx.user.name.first} ${ctx.user.name.last}`),
          h("div", { class: "btn-group" }, [h("button", { onClick: ctx.changeName }, "Change Random Name")]),
        ]),

        // Counter with computed properties demonstration
        h("div", { class: "card" }, [
          h("h2", null, "Counter & Computed Properties"),
          h("div", { class: "value-display" }, `Count: ${ctx.count.value}`),
          h("div", { class: "value-display" }, `Double: ${ctx.double.value}`),
          h("div", { class: "value-display" }, `Triple: ${ctx.triple.value}`),
          h("div", { class: "btn-group" }, [
            h("button", { onClick: ctx.increment }, "Increment"),
            h("button", { onClick: ctx.decrement }, "Decrement"),
          ]),
        ]),

        // Nested component example
        // Demonstrates component composition and props passing
        h(Counter, { parentCount: ctx.count }),

        h("div", { class: "footer" }, "This demo shows a Vue-like framework."),
      ]);
  },
};

createApp(App).mount(container);

四、总结

通过上述代码,我们实现了一个简版 Vue3 框架,虽然功能简单,但它涵盖了 Vue3 的核心原理,包括响应式系统、依赖追踪、副作用函数、虚拟 DOM、computed 和 watch 等内容。通过亲手实现这些功能,我们能够更深入地理解 Vue3 的工作原理,不再对 Vue3 的内部机制感到陌生。当然,这个简版 Vue3 框架距离真正的 Vue3 框架还有缺少非常多内容,如果感兴趣也可以尝试在我们的简版框架源码中进一步扩展和实现,欢迎评论完善,完整源码 👉 Vue-Like

相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax