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

相关推荐
Fantastic_sj1 小时前
CSS-in-JS 动态主题切换与首屏渲染优化
前端·javascript·css
鹦鹉0071 小时前
SpringAOP实现
java·服务器·前端·spring
再学一点就睡4 小时前
手写 Promise 静态方法:从原理到实现
前端·javascript·面试
再学一点就睡5 小时前
前端必会:Promise 全解析,从原理到实战
前端·javascript·面试
前端工作日常5 小时前
我理解的eslint配置
前端·eslint
前端工作日常6 小时前
项目价值判断的核心标准
前端·程序员
90后的晨仔6 小时前
理解 Vue 的列表渲染:从传统 DOM 到响应式世界的演进
前端·vue.js
OEC小胖胖7 小时前
性能优化(一):时间分片(Time Slicing):让你的应用在高负载下“永不卡顿”的秘密
前端·javascript·性能优化·web
烛阴7 小时前
ABS - Rhomb
前端·webgl
植物系青年7 小时前
10+核心功能点!低代码平台实现不完全指南 🧭(下)
前端·低代码