当前位置: 首页 > 工具软件 > mini-vue > 使用案例 >

Vue3 源码学习之实现 mini-vue

麹凯捷
2023-12-01

总的来说实现 mini-vue 需要实现以下几个部分。

渲染部分

创建 VNode 函数

首先需要搞清楚 VNode 是什么。VNode 的 全称叫 Virtual Node,顾名思义,指的是虚拟 Node(DOM)。在 JavaScript 中,VNode 实际就是一个 JS 对象。这个对象通过 mount 函数转换成真实 DOM,挂载在 DOM 树中。

分析 VNode 需要的属性

  • VNode 转换为真实 DOM 所对应的标签名
  • VNode 的本身属性
  • VNode 的 children 结点(这些节点的类型同样是 VNode)
  • VNode 创建出来的真实 DOM 对象

于是我们可以写出 h 函数:

const h = (tag, props, children) => {
  return {
    tag,
    props,
    children,
    el: document.createElement(tag)
  }
}

可以看到这个 h 函数实际就是对传入的参数进行了封装。各个参数的含义如下:

  • el 元素的真实 DOM 名
  • props 元素的 attrs
  • children 元素的子节点

挂载 VNode 的 mount 函数

mount 函数主要解决一个问题:把 VNode 挂载到真实 DOM 上。可以得出这个函数至少需要两个参数:

  • VNode 通过 h 函数创建的 VNode对象
  • el 挂载到的 DOM 节点

又因为 VNode 的 children 也是 VNode 类型,所以可以得出 mount 函数有可能会递归:创建父 VNode 后递归创建这个父 VNode 的子 VNode。

// vnode 一个 js 对象
// container vnode 创建出来后挂载到的真实 DOM
const mount = (vnode, container) => {
  const el = vnode.el;

  if (vnode.props) {
    for (key in vnode.props) {
      const val = vnode.props[key];

      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), val)
      } else {
        el.setAttribute(key, val);
      }
    }
  }

  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children;
  } else if (vnode.children) {
    vnode.children.forEach(item => {
      if (typeof item === 'string') {
        el.textContent = item;
      } else {
        mount(item, el);
      }
    });
  }

  container.appendChild(el);
}

更新 VNode 比较函数 patch

先记下思路:

function patch(oldn, newn) {
  // 新旧节点 tag 不同:直接把旧节点替换成新节点
  if (oldn.tag !== newn.tag) {
    const parentEle = oldn.el.parentElement;
    parentEle.removeChild(oldn.el);
    mount(newn, parentEle);

    return;
  }

  const el = newn.el = oldn.el;

  // 新旧节点 tag 相同,比较 props 和 children
  // 先处理 props
  const newProps = newn.props || {}
  const oldProps = oldn.props || {}

  // 两种情况:
  // 1. 新属性旧元素没有:直接添加
  // 2. 新属性旧元素有:替换
  for (key in newProps) {
    // 更新 el 的 attrs;具体判断可以看 mount 函数,这里简写
    const val = newProps[key];
    el.setAttribute(key, val);
  }

  for (key in oldProps) {
    // 删除旧元素中不在新属性的属性
    if (!(key in newProps)) {
      el.removeAttribute(key);
    }
  }

  // 处理 children
  const newChildren = newn.children || [];
  const oldChildren = oldn.children || [];

  if (!newChildren) {
    // 最简单的情况:新元素没有 children,直接把旧元素的 innerHTML 清空
    el.innerHTML = "";
  } else if (typeof newChildren === 'string') {
    // 另一种简单情况:新元素的 children 是个字符串
    el.textContent = newChildren;
  } else {
    const newChildrenLength = newChildren.length;
    const oldChildrenLength = oldChildren.length;
    const commLength = Math.min(newChildrenLength, oldChildrenLength);

    // 如果老元素的子元素是个字符串,但新元素是数组
    if (typeof oldChildren === 'string') {
      el.innerHTML = '';
      for (let i = 0; i < newChildrenLength; i++) {
        mount(newChildren[i], el);
      }

      return ;
    }

    // 新元素是个数组: 递归的逐个比较新旧 children
    // 这里我们认为新旧元素的数组是有序的,比如
    // 旧: [1, 2, 3]
    // 新:[2, 4, 5, 7]
    // 这样比较

    // 比较都有的前 n 个
    for (let i = 0; i < commLength; i++) {
      patch(oldChildren[i], newChildren[i]);
    }

    // 这里有两种情况:
    // 1. 新元素数组比老元素数组长  直接把新的加到旧元素里
    for (let i = commLength; i < newChildrenLength; i++) {
      mount(newChildren[i], el);
    }

    // 2. 旧元素数组比新元素数组长  删除这些多余的元素
    for (let i = commLength; i < oldChildrenLength; i++) {
      el.removeChild(oldChildren[i].el);
    }
  }
}

响应式部分

先简单介绍下什么是响应式:当一个对象的值发生改变的时候,所有依赖该值的函数应该发送更新(被执行)。

实现响应式的两个重点是:

  1. 收集依赖 就是收集使用某个对象的属性的函数
  2. 通知更新 当对象的属性发生更新的时候,需要重新调用所有使用了这个属性的函数

Vue2 响应式核心:defineProperty

响应式收集类

我们先定一个类,该类主要实现两个方法:收集依赖和通知更新依赖。

// 全局变量 用于保存当前的依赖函数
let activeEffect = null;

class Dep {
  constructor() {
    // 用 Set 收集依赖,以免依赖重复
    this.deps = new Set();
  }

  depend() {
    // 判断 activeEffect 是否存在,存在才添加
    if (activeEffect) {
      this.deps.add(activeEffect);
    }
  }

  notify() {
    // 遍历依赖 Set,执行函数
    this.deps.forEach(effect => {
      effect();
    })
  }
}

这里的依赖是针对某个对象的某个属性而言的。比如现在有个 obj,其属性有 age 和 name 两个,那么当 obj.age 变化时,我们应该更新所有使用了 obj.age 的函数,而没使用 obj.age 的函数不会被更新。

获取响应式收集类的函数

考虑下,我们会有很多不同的对象,每个对象又有很多不同的属性。响应式依赖类针对的是某个对象的某个属性,不难想出,我们需要对每个对象的每个属性创建 Dep 实例。于是我们需要一个数据结构保存对象和属性到 Dep 实例的映射。这里使用两个 Map:

const person = {
    name: 'www',
    age: 19
}

// 保存对象
const targetMap = new WeakMap();
// 保存属性到 Dep 实例的映射
const personDepMap = new Map();

Object.keys(person).forEach(key => {
    const value = person[key];
    personDepMap.set(key, new Dep());
})

targetMap.set(person, personDepMap)

上面是最基本的代码,封装成函数的,不难想出这个函数需要两个参数:

  • target 目标对象
  • key 该对象的 key,用于获取 Dep 实例
const targetMap = new WeakMap();

function getDep(target, key) {
    let t = targetMap.get(target);
    // target 不在 target map 中,添加
    if (!t) {
        t = new Map();
        targetMap.set(target, t);
    }
    
    let dep = t.get(key);
    // dep 不在 dep map 中,添加
    if (!dep) {
        dep = new Dep();
        t.set(key, dep);
    }
    
    return dep;
}

补充:Map 和 WeakMap 的区别

Map 的 key 是一个字符串

WeakMap 的 key 是个对象。并且这个对象只是引用。这意味当我们删除这个对象的时候,WeakMap 的对应 key 也会消失,所以无需额外清除 WeakMap 的 key。

创建响应式对象

通过 Object.defineProperty 函数劫持对象的属性的 get 和 set 方法,在 get 方法中,添加依赖;在 set 方法中,更新依赖。

function reactive(target) {
    Object.keys(target).forEach(key => {
        let value = target[key];
        const dep = getDep(target);
        
        Object.defineProperty(target, key, {
            get() {
                dep.depend();
                return value;
            },
            set(newValue) {
                value = newValue;
                dep.notify()
            }
        })
    })
    
    return target;
}

收集响应式对象的函数

function watchEffect(effect) {
    activeEffect = effect;
    // 执行 effect 函数时会触发这个函数内的对象的 getter 或者 setter 方法
    // 于是会执行 dep.depend 收集依赖(通过 activeEffect 这个全局变量
    effect();
    activeEffect = null;
}

全部代码

reactive_vue2.js

class Dep {
  constructor() {
    this.deps = new Set();
  }

  depend() {
    if (activeEffect) {
      this.deps.add(activeEffect);
    }
  }

  notify() {
    this.deps.forEach(effect => {
      effect()
    })
  }
}


let activeEffect = null;

// key 是个对象
const depMap = new WeakMap();


function getDep(target, key) {
  let targetMap = depMap.get(target);

  if (!targetMap) {
    targetMap = new Map();
    depMap.set(target, targetMap);
  }

  let dep = targetMap.get(key);

  if (!dep) {
    dep = new Dep();
    targetMap.set(key, dep);
  }

  return dep;
}


function reactive(target) {

  Object.keys(target).forEach(key => {
    let val = target[key];
    const dep = getDep(target, key);

    Object.defineProperty(target, key, {
      get() {
        dep.depend();
        return val;
      },
      set(newValue) {
        if (val !== newValue) {
          val = newValue;
          dep.notify();
        }
      }
    })

  });

  return target;
}


function watchEffect(effect) {
  activeEffect = effect;
  effect();
  activeEffect = null;
}


const info = reactive({
  count: 10,
  name: 'hhh'
})

watchEffect(() => {
  console.log(info.count * info.count)
})


watchEffect(() => {
  console.log(info.name)
})

Vue3 响应式核心:Proxy

Vue3 响应式代码的区别在于创建响应式对象函数reactive,其它的都可以直接拿来用:

function reactive(raw) {
    return new Proxy(raw, {
        get(target, key) {
            const dep = getDep(target, key);
            dep.depend();
            return target[key];
        },
        set(target, key, newValue) {
            const dep = getDep(target, key);
            target[key] = newValue;
            dep.notify();
        }
    })
}

Proxy 和 defineProperty 的区别

修改对象的区别

  • Proxy 返回的是一个 Proxy 对象,直接操作这个对象并不会把结果反应到原对象上

  • defineProperty 是对原对象进行修改,返回的还是原对象

Proxy 的优点

  1. Proxy 直接代理整个对象,所以给对象添加属性的时候,无需执行额外的代码
  2. 新标准,各大浏览器厂商会逐步优化 Proxy 的性能
  3. 能够响应的 API 更多

Proxy 的缺点

  1. 不支持 IE,且不能 polyfill
 类似资料: