基于 vue3源码 尝试 mini-vue 的实现

基于 vue3源码 尝试 mini-vue 的实现

1. 实现思路

  • 渲染系统模块
  • 响应式系统
  • mini-vue 程序入口

2. 渲染系统模块

2.1 初识 h 函数

以下是 vue 的模版语法代码:

xml 复制代码
<template>
    <div class='container'>hello mini-vue</div>
</template>

它并不是传统的 html 代码,而是通过 h 函数生成的虚拟 dom 节点,h 函数接收 3 个参数:

  • 第一个参数是标签名 (tag),此例中为 'div'
  • 第二个参数是标签的属性 (props), 此例中为 'container'
  • 第三个参数是子节点,可以是字符串、数组 (children), 此例中为 'hello mini-vue'
javascript 复制代码
/**
 * h 函数
 * 功能:返回vnode
 *
 * @param {String} tagName  - 标签名
 * @param {Object | Null} props  - 传递过来的参数
 * @param {Array | String} children  - 子节点
 * @return {vnode} 虚拟节点
 */
const h = (tagName, props, children) => {
    // 直接返回一个对象,里面包含vnode结构
    return {
        tagName,
        props,
        children,
    };
};
​
export default h;

2.2 创建一个 vnode

vnode 就是虚拟 dom 节点,创建方法很简单:

kotlin 复制代码
h(
    'div', { class: 'container' }, [
        h('h1', {}, `文本:${this.data.msg},可变数字:${this.data.count}`),
        h('button', {
            onclick: () =>{
                this.data.msg = 'hello miniVue',
                    this.data.count++
            }
        }, '点击试试'),
    ]
)

2.3 挂载真实 DOM

mount函数的主要功能是将虚拟节点(vnode)挂载到真实的DOM容器(container)中:

  1. 创建真实元素:

    • 使用 document.createElement 创建一个具有指定标签名的真实元素。
    • 将创建的元素保存在 vnode.el 属性中,以便后续的操作。
  2. 处理属性(props):

    • 遍历虚拟节点的 props 属性,分别处理函数类型的事件监听器和其他类型的属性。
    • 如果属性名以 "on" 开头,将其作为事件处理函数添加到元素上。
    • 否则,使用 setAttribute 方法设置元素的属性。
  3. 处理子节点(children):

    • 如果虚拟节点有子节点,分两种情况处理:

      • 如果子节点是字符串,直接设置元素的文本内容。
      • 如果子节点是数组,递归调用 mount 函数挂载每个子节点。
  4. 挂载到容器中:

    • 最后,使用 container.appendChild 将创建的真实元素挂载到指定的DOM容器中。

这个mount函数的重点在于递归调用,通过递归处理子节点,实现了对整个虚拟DOM树的挂载。

javascript 复制代码
/**
 * mount 函数
 * 功能:挂载 vnode 为 真实dom
 * 重点:递归调用处理子节点
 *
 * @param {Object} vnode -虚拟节点
 * @param {elememt} container -需要被挂载节点
 */
const mount = (vnode, container) => {
  // 1. 创建出真实元素, 同时给 vnode 添加 el 属性
  const el = (vnode.el = document.createElement(vnode.tagName));
​
  // 2. 处理 props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];
​
      // 2.1 prop 是函数
      if (key.startsWith("on")) {
        el.addEventListener(key.slice(2).toLowerCase(), value);
      } else {
        // 2.2 prop 是字符串
        el.setAttribute(key, value);
      }
    }
  }
​
  // 3. 处理 children
  if (vnode.children) {
    // 3.1 如果 children 是字符串,直接设置文本内容
    if (typeof vnode.children === "string") {
      el.textContent = vnode.children;
    }
    // 3.2 如果 children 是数组,递归挂载每个子节点
    else {
      // 先拿到里面的每一个 vnode
      vnode.children.forEach((item) => {
        // 再把里面的vnode递归调用
        mount(item, el);
      });
    }
  }
  // 4. 挂载
  container.appendChild(el);
};
​
export default mount;
​

3. 响应式系统

3.1 依赖收集与 proxy 劫持

主要包含两个功能:

  1. 观察者函数 (watchEffect):

    • 通过 createEffect 函数创建观察者函数,用于定义在数据变化时执行的逻辑。
    • 设置一个全局变量 activeEffect 作为当前活跃的观察者,以便在属性访问时收集依赖。
  2. 响应式对象创建函数 (reactive):

    • 使用 Proxy 对象对原始数据对象进行代理,以便捕获对对象属性的访问和修改。
    • 当访问对象属性时,通过 getDependencies 函数收集依赖关系,将当前观察者添加到依赖中。
    • 当设置对象属性时,通过 set 方法触发依赖更新,通知所有依赖的观察者执行。
javascript 复制代码
/**
 * 此段响应式代码主要完成两个功能:
 * 1. 创建观察者函数
 * 2. 响应式的对象创建函数
 */
​
// 定义一个依赖管理类
class DependencyManager {
  constructor() {
    // 使用 Set 存储订阅者(观察者)
    this.subscribers = new Set();
  }
​
  // 添加订阅者
  addSubscriber(subscriber) {
    if (activeEffect) {
      this.subscribers.add(subscriber);
    }
  }
​
  // 通知所有订阅者执行
  notifySubscribers() {
    this.subscribers.forEach((subscriber) => subscriber());
  }
}
​
// 定义一个全局变量,表示当前活跃的观察者
let activeEffect = null;
​
// 定义一个函数,用于创建并执行观察者
function createEffect(effect) {
  // 将当前观察者设置为全局活跃观察者
  activeEffect = effect;
​
  // 执行观察者函数
  effect();
​
  // 执行完后将全局活跃观察者重置为 null
  activeEffect = null;
}
​
// 使用 WeakMap 存储目标对象与其对应的依赖映射关系
const targetDependenciesMap = new WeakMap();
​
// 获取指定目标对象和键值的依赖对象
function getDependencies(target, key) {
  let dependenciesMap = targetDependenciesMap.get(target);
  // 如果目标对象还没有对应的依赖映射关系,则创建一个
  if (!dependenciesMap) {
    dependenciesMap = new Map();
    targetDependenciesMap.set(target, dependenciesMap);
  }
  let dependency = dependenciesMap.get(key);
  // 如果键值还没有对应的依赖对象,则创建一个
  if (!dependency) {
    dependency = new DependencyManager();
    dependenciesMap.set(key, dependency);
  }
  return dependency;
}
​
// 创建响应式对象
function createReactiveObject(raw) {
  return new Proxy(raw, {
    // 当访问对象的属性时,收集依赖
    get(target, key) {
      const dependency = getDependencies(target, key);
      dependency.addSubscriber(activeEffect);
      return target[key];
    },
    // 当设置对象的属性时,触发依赖更新
    set(target, key, newValue) {
      const dependency = getDependencies(target, key);
      target[key] = newValue;
      dependency.notifySubscribers();
      return true;
    },
  });
}
​
// 导出观察者函数和响应式对象创建函数
export { createEffect as watchEffect, createReactiveObject as reactive };
​

3.2 diff 算法

patch函数的核心思想是通过比较两个虚拟节点(Vnode)来更新实际的DOM,以减少不必要的DOM操作,提高性能。它首先比较节点的标签名,如果不同则直接替换整个节点;然后比较节点的属性和事件,更新发生变化的部分;最后比较子节点,支持使用key进行优化,减少删除和添加的操作。这种差异比较的方式避免了不必要的DOM重新渲染,以更有效地实现视图的更新。这符合虚拟DOM的设计理念,通过最小化实际DOM的操作来提高性能。

ini 复制代码
/**
 * 节点比较
 * 调用时机:节点发生变化(数量,内容)
 * 功能:比较节点数组,尽可能减少 DOM 操作
 */
​
/**
 * @param {Vnode} n1 - 旧节点
 * @param {Vnode} n2 - 新节点
 */
const patch = (n1, n2) => {
  // 节点不相同,卸载旧节点,挂载新节点
  if (n1.tagName !== n2.tagName) {
    const parentElementNode = n1.el.parentElement;
    parentElementNode.removeChild(n1.el);
    mount(n2, parentElementNode);
  } else {
    // 1. 取出 element 并保存到 n2
    const el = (n2.el = n1.el);
​
    // 2. 处理 props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    for (const key in newProps) {
      const oldValue = oldProps[key];
      const newValue = newProps[key];
      // 2.1 值不同才替换
      if (oldValue !== newValue) {
        if (key.startsWith("on")) {
          el.addEventListener(key.slice(2).toLowerCase(), newValue);
        } else {
          // 2.2 prop 是字符串
          el.setAttribute(key, newValue);
        }
      }
    }
    // 3. 删除旧的 props
    for (const key in oldProps) {
      if (key.startsWith("on")) {
        const value = oldProps[key];
        el.removeEventListener(key.slice(2).toLowerCase(), value);
      }
      // 如果旧 key 不在新的 props 里
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }
​
    // 4. 处理 children
    const oldChildren = n1.children;
    const newChildren = n2.children;
    // children 字符串或者数值
    if (typeof newChildren === "string") {
      // 4.1 如果新 children 是字符串,直接设置文本内容
      if (oldChildren !== newChildren) {
        el.textContent = newChildren;
      } else {
        el.innerHTML = newChildren;
      }
    } else {
      // 4.2 如果新 children 是数组,递归挂载每个子节点
      // 如果旧 children 的是字符串
      if (typeof oldChildren === "string" || typeof oldChildren === "number") {
        el.innerHTML = "";
        // 遍历 children
        newChildren.forEach((item) => {
          mount(item, el);
        });
      } else {
        // 两个都是数组,开始 diff 算法
        // n1: [a,b,d]
        // n2: [b,a,c,f]
​
        /**
         * 没有 key
         */
        if (!n1.props.key && !n2.props.key) {
          // 4.3.1 获取两个 vnode 数组的公共长度,比较相同的
          const commonLength = Math.min(oldChildren.length, newChildren.length);
          for (let i = 0; i < commonLength; i++) {
            patch(oldChildren[i], newChildren[i]);
          }
​
          // 4.3.2 新的长度多于旧的,挂载
          if (oldChildren.length < newChildren.length) {
            newChildren.slice(oldChildren.length).forEach((item) => {
              mount(item, el);
            });
          }
          // 4.3.3 旧的长度多于新的,卸载
          if (oldChildren.length > newChildren.length) {
            oldChildren.slice(newChildren.length).forEach((item) => {
              el.removeChild(item.el);
            });
          }
        } else {
          /**
           * 有 key
           */
          // 4.4.1 根据 key 创建一个映射表,方便查找和比较
          const keyMap = {};
          oldChildren.forEach((child) => {
            if (child.props.key) {
              keyMap[child.props.key] = child;
            }
          });
​
          // 4.4.2 遍历新的 children 数组
          newChildren.forEach((newChild, index) => {
            const oldChild = keyMap[newChild.props.key];
            if (oldChild) {
              // 4.4.2.1 如果旧的 children 存在对应的 key,对比并更新子节点
              patch(oldChild, newChild);
              oldChildren[index] = oldChild; // 更新旧的 children 数组,方便后续删除处理
            } else {
              // 4.4.2.2 如果旧的 children 中没有对应的 key,说明是新增的节点,直接挂载
              mount(newChild, el, index);
            }
          });
​
          // 4.4.3 删除旧的 children 中没有对应的 key 的子节点
          oldChildren.forEach((oldChild) => {
            if (
              !oldChildren.find(
                (child) => child.props.key === oldChild.props.key
              )
            ) {
              el.removeChild(oldChild.el);
            }
          });
        }
      }
    }
  }
};
​
export default patch;
​

4. 程序的入口

当第一次挂载组件时,直接使用 mount 函数挂载就行,但是当已经挂载了组件,需要执行 path 函数对比新旧节点,然后挂载。

同时需要使用 watchEffect 观察者函数观察。

ini 复制代码
import h from "./renderer.js";
import mount from "./mount.js";
import patch from "./patch.js";
import { reactive, watchEffect } from "./reactive.js";
​
const createApp = (rootComponent) => {
  return {
    mount: (selector) => {
      let container = document.getElementById(selector);
      let isMounted = false;
      let oldVNode = null;
      watchEffect(function () {
          
        if (!isMounted) {
          oldVNode = rootComponent.renderTemplate();
          mount(oldVNode, container);
          isMounted = true;
        } else {
          const newVNode = rootComponent.renderTemplate();
          patch(oldVNode, newVNode);
          oldVNode = newVNode;
        }
      });
    },
  };
};
​
export  {
  createApp,
  mount,
  h,
  reactive,
};

5. 测试运行

以点击按钮修改文字信息和数字自增加一的案例测试:

xml 复制代码
<!DOCTYPE html>
<html lang="en">
​
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Document</title>
</head>
​
<body>
  <div id="app"></div>
</body>
<script type="module">
  import {
    reactive, h, createApp,
  } from './index.js'
​
  // 1.将被解析的 vue 组件
  let miniVueComponent = {
    data: reactive({
      msg: 'hello world',
      count: 1,
    }),
    renderTemplate:
      function () {
        return h(
          'div', { class: 'container' }, [
          h('h1', {}, `文本:${this.data.msg},可变数字:${this.data.count}`),
          h('button', {
            onclick: () =>{
            this.data.msg = 'hello miniVue',
            this.data.count++
          }
          }, '点击试试'),
        ]
        )
      }
    ,
    method: {
      sayHello: () => {
        if (window) {
          alert(`i say ${msg}`)
        }
      }
    }
  }
​
  // 2. 挂载,生成真实 dom,添加到 container 容器中
  createApp(miniVueComponent).mount('app');
​
</script>
​
</html>
相关推荐
迷雾漫步者1 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-2 小时前
验证码机制
前端·后端
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235244 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240255 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar5 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人5 小时前
前端知识补充—CSS
前端·css
GISer_Jing5 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试