手写实现一个简单的Mini-Vue系统

举报
CoderBin 发表于 2022/11/06 16:22:18 2022/11/06
【摘要】 大家好,我是 CoderBin,本文将带着大家从零实现一个Mini-Vue系统。完成之后应该对vue整体的底层实现有一定的清晰,希望对你有所帮助,谢谢。 创作不易,你们的点赞收藏留言就是我最大的动力💓 如果文中有不对、疑惑的地方,欢迎各位小伙伴们在评论区留言指正🌻

前言

大家好,我是 CoderBin,本文将带着大家从零实现一个Mini-Vue系统。完成之后应该对vue整体的底层实现有一定的清晰,希望对你有所帮助,谢谢。

创作不易,你们的点赞收藏留言就是我最大的动力💓

如果文中有不对、疑惑的地方,欢迎各位小伙伴们在评论区留言指正🌻

说明:实现一个简洁版的Mini-Vue框架,该Vue包括三个模块

  • 渲染系统模块
  • 可响应式系统模块
  • 应用程序入口模块

一、渲染系统模块

说明:渲染系统,该模块主要包含三个功能:

  • 功能一:h函数,用于返回一个VNode对象;
  • 功能二:mount函数,用于将VNode挂载到DOM上;
  • 功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;

1. h函数的实现 - 生成VNode

1.1 实现原理及步骤

直接返回一个VNode对象即可
代码实现:

/**
 * * 功能一: 实现 h 函数,生成VNode
 * @param {*} tag  标签名
 * @param {*} props 属性|事件
 * @param {*} children 文本|子节点
 * @returns 
 */
const h = (tag, props, children) => {
  // vnode -> javascript对象
  return {
    tag,
    props,
    children
  }
}

注意:h函数的的第二个参数只考虑为属性或者事件的情况,第三个参数只考虑为文本或子节点的情况。

1.2 测试代码

在html中的<script>脚本中编写如下代码:

const vnode = h('div', {class:'bin'}, 'CoderBin')
console.log(vnode);

1.3 代码效果

image.png

2. mount函数 - 挂载VNode

2. 1 实现原理及步骤

  1. 第一步:根据tag,创建HTML元素,并且存储到vnode的el中;
  2. 第二步:处理props属性
    * 如果以on开头,那么监听事件;
    • 普通属性直接通过 setAttribute 添加即可;
  3. 第三步:处理子节点
    * 如果是字符串节点,那么直接设置textContent;
    • p如果是数组节点,那么遍历调用 mount 函数;

代码实现:

/**
 * * 功能二:实现 mount 函数,挂载 DOM
 * @param {*} vnode 虚拟节点
 * @param {*} container 容器
 * 创建dom -> 处理props -> 处理children -> 挂载元素
 */
const mount = (vnode, container) => {
  // vnode -> element
  //TODO 1. 创建出真实的元素,并且在vnode上保留一份el
  const el = vnode.el = document.createElement(vnode.tag);
  //TODO 2. 处理props(要么监听事件,要么添加属性)
  if (vnode.props) {
    for (const key in vnode.props) {  // 遍历对象
      const value = vnode.props[key]; // 取出对应的值
      if (key.startsWith('on')) {     // 对事件监听的判断
        el.addEventListener(key.slice(2).toLowerCase(), value); // 去掉on,并将事件转为小写
      } else {
        el.setAttribute(key, value);  // 设置标签属性
      }
    }
  };
  //TODO 3. 处理children(要么设置文本,要么递归调用mount函数继续挂载)
  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children;  // 如果是文本,就添加到标签内容当中
  } else {
    vnode.children.forEach(item => {
      mount(item, el);  // 如果是数组就遍历每一项(每个vnode),然后递归调用mount函数
    })
  }
  //TODO 4. 将el挂载到容器上
  container.appendChild(el);
}

2.2 测试代码

在html中的<script>脚本中编写如下代码:

const vnode = h('h1', {class:'bin'}, 'CoderBin')
mount(vnode, document.querySelector("#app"))

2.3 代码效果

image.png

3. patch函数 - 对比两个VNode

3. 1 实现原理及步骤

patch函数的实现,分为两种情况

  1. n1 和 n2 是不同类型的节点

    • 找到n1的el父节点,删除原来的n1节点的el
    • 挂载n2节点到n1的el父节点上
  2. n1 和 n2 节点是相同的节点

    • 2.1 处理props的情况
      - [x] 先将新节点的props全部挂载到el上
    • [x] 判断旧节点的props是否不需要再新节点上,如果不需要,那么删除对应的属性
    • 2.2 处理children的情况
      • [x] 如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;

      • [x] 如果新节点不是一个字符串类型,那就是数组类型:

        • 旧节点是一个字符串类型
          • 将el的textContent设置为空字符串
          • 那么直接遍历新节点,挂载到el上
        • 旧节点也是一个数组类型
          • 取出数组的最小长度
          • 遍历所有节点,新节点和旧节点进行patch操作
          • 如果新节点的length更长,那么剩余的新节点进行挂载操作;
          • 如果旧节点的length更长,那么剩余的旧节点进行卸载操作;

代码实现

/**
 * * 功能三:实现 patch 函数,对比两个VNode
 * @param {*} n1 旧节点
 * @param {*} n2 新节点
 */
const patch = (n1, n2) => {
  //TODO 1. 先判断两个VNode的标签是否一致,不是则直接移除就VNode,添加新VNode
  if (n1.tag !== n2.tag) {
    const n1ElParent = n1.el.parentElement;  // 获取父节点
    n1ElParent.removeChild(n1.el); // 移除旧节点
    mount(n2, n1ElParent);  // 挂载新VNode
  } else {
    //TODO 2. 处理props
    // 取出element对象,并且在n2中进行保存
    const el = n2.el = n1.el;
    const oldProps = n1.props || {};  // 如果有props就取出,没有就赋值{}
    const newProps = n2.props || {};
    // 1. 获取所有的 newProps 添加到el
    for (const key in newProps) {
      const oldValue = oldProps[key];
      const newValue = newProps[key];
      // 判断标签属性值是否一致
      if (newValue !== oldValue) { 
        if (key.startsWith('on')) {     // 对事件监听的判断
          el.addEventListener(key.slice(2).toLowerCase(), newValue); // 去掉on,并将事件转为小写
        } else {
          el.setAttribute(key, newValue);  // 设置标签属性
        }
      }
    }
    // 2. 删除旧的props
    for (const key in oldProps) {
      if (!(key in newProps)) {
        if (key.startsWith('on')) {     // 对事件监听的判断
          el.removeEventListener(key.slice(2).toLowerCase(), newValue); // 去掉on,并将事件转为小写
        } else {
          el.removeAttribute(key);  // 设置标签属性
        }
      }
    }
    //TODO 3. 处理children 
    const oldChildren = n1.children || [];
    const newChildren = n2.children || [];

    if (typeof newChildren === 'string') {
      // 边界判断
      if (typeof oldChildren === 'string') {  //* 情况一:newChildren本身是一个string
        if (newChildren !== oldChildren) {
          el.textContent = newChildren;
        }
      } else {
        el.innerHTML = newChildren;
      }
    } else { //* 情况二:newChildren本身是一个数组
      if (typeof oldChildren === 'string') {
        el.innerHTML = '';  // 如果oldChildren,先清空旧的内容
        newChildren.forEach(item => { // 然后再遍历newChildren数组,挂载DOM
          mount(item, el);
        })
      } else {
        // oldChildren: [v1, v2, v3, v8, v9]
        // newChildren: [v1, v5, v6]
        // 进行简易的diff算法
        // 1.前面有相同节点的元素进行patch操作
        const commonLength = Math.min(newChildren.length, oldChildren.length);
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChildren[i]);
        }
        // 2. newChildren.length > oldChildren.length 时,挂载多出的newChildren节点
        if (newChildren.length > oldChildren.length) {
          newChildren.splice(oldChildren.length).forEach(item => {
            mount(item, el)
          })
        }
        // 3. newChildren.length < oldChildren.length 时,卸载oldChildren多余的节点
        if (newChildren.length < oldChildren.length) {
          oldChildren.splice(newChildren.length).forEach(item => {
            el.removeChild(item.el)
          })
        }
      }
    }
  }
}

3.2 测试代码

在html中的<script>脚本中编写如下代码:

// 1. 通过 h 函数来创建一个vnode
const vnode = h('div', {class: "why", id: "aaa"}, [
    h("h2", null, "当前计数: 100"),
    h("button", {onClick: function() {}}, "+1")
]); // vdom
console.log(vnode);

// 2. 通过 mount 函数将vnode挂载到id为app的div上
mount(vnode, document.querySelector("#app"));

// 3. 创建一个新的vnode,为了看到效果,使用 setTimeout 3秒后再进行替换操作
setTimeout(() => {
    const vnode1 = h('div', {class: "coderwhy", id: "aaa"}, [
        h("h2", null, "呵呵呵"),
        h("button", {onClick: function() {}}, "-1")
    ]); 
    patch(vnode, vnode1);
}, 2000)

3.3 代码效果

image.png

3秒后,渲染新的dom

image.png

4. 渲染系统模块总结

渲染系统模块的总体流程是:生成VNode --> 挂载VNode --> 新旧VNode对比,重新挂载

  • h函数的实现比较简单,只要将参数作为对象返回即可
  • mount函数也不难,核心实现主要是以下四步,其他的都是一些边界的判断:
    1. 创建HTML元素 document.createElement;
    2. 监听元素事件 el.addEventListener、添加元素属性 el.setAttribute;
    3. 设置元素内容 el.textConent,递归调用mount函数继续挂载
    4. 挂载VNode, container.appendChild(el);
  • patch函数就稍微复杂一点,其中大前提是两个VNode是否是相同节点类型,不是相同节点类型的操作比较简单,只需要替换新的节点即可,如果是相同类型的节点,就要进行额外的情况、边界判断,最后还涉及到了diff算法的基础实现

二、可响应式系统模块

class Dep {
  constructor() {
    // 订阅
    this.subscribers = new Set();
  }
  // 收集依赖
  depend() {
    if (activeEffect) {
      this.subscribers.add(activeEffect)
    }
  }
  // 通知
  notify() {
    this.subscribers.forEach(effect => {
      effect()
    })
  }
}

// 实现简易侦听器
let activeEffect = null;
function watchEffect(effect) {
  activeEffect = effect;
  effect(); // 第一次也会执行一次
  activeEffect = null;
}

// Map({key: value}): key是一个字符串
// WeakMap({key(对象): value}): key是一个对象, 弱引用
const targetMap = new WeakMap();
// 获取数据
function getDep(target, key) {
  // 1. 根据对象(target)去除对应的Map对象
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  };
  // 2. 取出具体的dep对象
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Dep();
    depsMap.set(key, dep);
  }
  return dep;
}

// vue3对raw进行数据劫持,实现reactive响应式数据
// 返回一个proxy代理对象
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();
    }
  })
}

三、应用程序入口模块

/**
 * createApp 程序入口函数
 * @param {*} rootComponent 根组件
 * @returns 
 */
function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector);
      let isMounted = false;
      let oldVNode = null;
	  // 调用响应式系统里面的watchEffect侦听函数
      watchEffect(function() {
        if (!isMounted) {
          oldVNode = rootComponent.render();
          mount(oldVNode, container);	// 这个mount是渲染模块中的mount函数
          isMounted = true;
        } else {
          const newVNode = rootComponent.render();// 这个render是渲染模块中的render函数
          patch(oldVNode, newVNode);
          oldVNode = newVNode;
        }
      })
    }
  }
}

四、Mini-Vue系统测试

<body>
  <div id="app"></div>
  <script src="./mini-vue/renderer.js"></script>
  <script src="./mini-vue/reactive.js"></script>
  <script src="./mini-vue/index.js"></script>
  <script>
    // 1.创建根组件
    const App = {
      data: reactive({
        counter: 0
      }),
      render() {
        return h("div", null, [
          h("h2", null, `当前计数: ${this.data.counter}`),
          h("button", {
            onClick: () => {
              this.data.counter++
              console.log(this.data.counter);
            }
          }, "+1")
        ])
      }
    }
    // 2.挂载根组件
    const app = createApp(App);
    app.mount("#app");
  </script>
</body>

每文一句:生活的全部意义在于无穷地探索尚未知道的东西,在于不断地增加更多的知识。

ok,本次的分享就到这里,如果本章内容对你有所帮助的话可以点赞+收藏,希望大家都能够有所收获。有任何疑问都可以在评论区留言,大家一起探讨、进步!

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。