Vue3源码学习(简单实现一个Mini-Vue)

514 阅读5分钟

学习了coderwhy的vue3+ts视频课的笔记

1. 真实的DOM渲染

  • 我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的,那么它是什么样的过程呢?

image.png

2. 虚拟DOM

1). 虚拟DOM的优势

  • 目前框架都会引入虚拟DOM来对真实的DOM进行抽象,这样做有很多的好处:
    • 首先是可以对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行各种操作:
      • 因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这些,就变得非常的简单;
      • 我们可以使用JavaScript来表达非常多的逻辑,而对于DOM本身来说是非常不方便的;
    • 其次是方便实现跨平台,包括你可以将VNode节点渲染成任意你想要的节点;
      • 如渲染在canvas、WebGL、SSR、Native(iOS、Android)上;
      • 并且Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染。 2). 虚拟DOM的渲染过程

image.png

3. 三大核心系统

  • 事实上Vue的源码包含三大核心:
    • Compiler模块:编译模板系统;
    • Runtime模块:也可以称之为Renderer模块,真正渲染的模块;
    • Reactivity模块:响应式系统。

image.png

  • 三个系统之间如何协同工作呢:

image.png

4. 实现Mini-Vue

  • 这里我们实现一个简洁版的Mini-Vue框架,该Vue包括三个模块:
    • 渲染系统模块;
    • 可响应式系统模块;
    • 应用程序入口模块。

1). 渲染系统实现

  • 渲染系统,该模块主要包含三个功能:
    • 功能一:h函数(更详细介绍跳转链接) 用于返回一个VNode对象;
    • 功能二:mount函数,用于将VNode挂载到DOM上;
    • 功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode。

第一步:h函数 – 生成VNode

image.png

此时返回的是一个vnode的JavaScript对象。

第二步:Mount函数 – 挂载VNode

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

image.png

image.png

第三步:Patch函数 – 对比两个VNode

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

  • 第一种:n1和n2是不同类型的节点:
    • 找到n1的el父节点,删除原来的n1节点的el;
    • 挂载n2节点到n1的el父节点上。
  • 第二种:n1和n2节点是相同的节点:
    • 处理props的情况
      • 先将新节点的props全部挂载到el上;
      • 判断旧节点的props是否不需要在新节点上,如果不需要,那么删除对应的属性。
    • 处理children的情况
      • 如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;
      • 如果新节点不同一个字符串类型:
        • 旧节点是一个字符串类型:
          • 将el的textContent设置为空字符串;
          • 就节点是一个字符串类型,那么直接遍历新节点,挂载到el上。
        • 旧节点也是一个数组类型
          • 取出数组的最小长度;
          • 遍历所有的节点,新节点和旧节点进行path操作;
          • 如果新节点的length更长,那么剩余的新节点进行挂载操作;
          • 如果旧节点的length更长,那么剩余的旧节点进行卸载操作。

image.png

image.png

全部代码如下:

// index.html
<!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>

  <script src="./renderer.js"></script>
  <script>

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

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

    // 3.创建新的vnode
    setTimeout(() => {
      const vnode1 = h('div', {class: "coderwhy", id: "aaa"}, [
        h("h2", null, "呵呵呵"),
        h("button", {onClick: function() {}}, "-1")
      ]); 
      patch(vnode, vnode1);
    }, 2000)

  </script>

</body>
</html>
// renderer.js
const h = (tag, props, children) => {
  // vnode -> javascript对象 -> {}
  return {
    tag,
    props,
    children
  }
}

const mount = (vnode, container) => {
  // vnode -> element
  // 1.创建出真实的原生, 并且在vnode上保留el
  const el = vnode.el = document.createElement(vnode.tag);

  // 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)
      } else {
        el.setAttribute(key, value);
      }
    }
  }

  // 3.处理children
  if (vnode.children) {
    if (typeof vnode.children === "string") {
      el.textContent = vnode.children;
    } else {
      vnode.children.forEach(item => {
        mount(item, el);
      })
    }
  }

  // 4.将el挂载到container上
  container.appendChild(el);
}

const patch = (n1, n2) => {
  if (n1.tag !== n2.tag) {
    const n1ElParent = n1.el.parentElement;
    n1ElParent.removeChild(n1.el);
    mount(n2, n1ElParent);
  } else {
    // 1.取出element对象, 并且在n2中进行保存
    const el = n2.el = n1.el;

    // 2.处理props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    // 2.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)
        } else {
          el.setAttribute(key, newValue);
        }
      }
    }

    // 2.2.删除旧的props
    for (const key in oldProps) {
      if (key.startsWith("on")) { // 对事件监听的判断
        const value = oldProps[key];
        el.removeEventListener(key.slice(2).toLowerCase(), value)
      } 
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }

    // 3.处理children
    const oldChildren = n1.children || [];
    const newChidlren = n2.children || [];

    if (typeof newChidlren === "string") { // 情况一: newChildren本身是一个string
      // 边界情况 (edge case)
      if (typeof oldChildren === "string") {
        if (newChidlren !== oldChildren) {
          el.textContent = newChidlren
        }
      } else {
        el.innerHTML = newChidlren;
      }
    } else { // 情况二: newChildren本身是一个数组
      if (typeof oldChildren === "string") {
        el.innerHTML = "";
        newChidlren.forEach(item => {
          mount(item, el);
        })
      } else {
        // oldChildren: [v1, v2, v3, v8, v9]
        // newChildren: [v1, v5, v6]
        // 1.前面有相同节点的原生进行patch操作
        const commonLength = Math.min(oldChildren.length, newChidlren.length);
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChidlren[i]);
        }

        // 2.newChildren.length > oldChildren.length
        if (newChidlren.length > oldChildren.length) {
          newChidlren.slice(oldChildren.length).forEach(item => {
            mount(item, el);
          })
        }

        // 3.newChildren.length < oldChildren.length
        if (newChidlren.length < oldChildren.length) {
          oldChildren.slice(newChidlren.length).forEach(item => {
            el.removeChild(item.el);
          })
        }
      }
    }
  }
}

2). 可响应式系统实现

vue2和vue3响应式原理 具体实现看另一篇文章

  • 为什么Vue3选择Proxy呢?
    • Object.definedProperty 是劫持对象的属性时,如果新增元素:
      • 那么Vue2需要再次调用definedProperty,而 Proxy 劫持的是整个对象,不需要做特殊处理。
    • 修改对象的不同:
      • 使用 defineProperty 时,我们修改原来的 obj 对象就可以触发拦截;
      • 而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截。
    • Proxy 能观察的类型比 defineProperty 更丰富:
      • has:in操作符的捕获器;
      • deleteProperty:delete 操作符的捕捉器;
      • 等等其他操作。
    • Proxy 作为新标准将受到浏览器厂商重点持续的性能优化;
    • 缺点:Proxy 不兼容IE,也没有 polyfill, defineProperty 能支持到IE9。

3). 框架外层API设计(应用程序入口实现)

  • 这样我们就知道了,从框架的层面来说,我们需要有两部分内容:
    • createApp用于创建一个app对象;
    • 该app对象有一个mount方法,可以将根组件挂 载到某一个dom元素上。

image.png

全部代码如下:

// index.html
<!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>
  <script src="../02_渲染器实现/renderer.js"></script>
  <script src="../03_响应式系统/reactive.js"></script>
  <script src="./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>
</html>
// index.js
function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector);
      let isMounted = false;
      let oldVNode = null;

      watchEffect(function() {
        if (!isMounted) {
          oldVNode = rootComponent.render();
          mount(oldVNode, container);
          isMounted = true;
        } else {
          const newVNode = rootComponent.render();
          patch(oldVNode, newVNode);
          oldVNode = newVNode;
        }
      })
    }
  }
}