手写简易的MiniVue(02-渲染器实现)

930 阅读5分钟

前言

上文:响应式系统实现

在上一篇文章中我们实现了响应式系统,那么在这一个章节我们将要实现渲染器了,我们主要要实现三个部分:

  • h函数:返回虚拟节点对象
  • mount函数:用于将虚拟节点挂载到真实 DOM 上
  • patch函数:用于更新虚拟节点。

现在就让我们开始吧

实现 h 函数

话不多说,先上代码:

/**
 * tag: 标签名,
 * props: 属性
 * children:子节点
 */
function h(tag, props, children) {
  return {
    tag,
    props,
    children,
  };
}
​
const vnode = h(
  "div",
  {
    style: {
      width: "200px",
      height: "200px",
      backgroundColor: "red",
    },
  },
  [
    h("h2", null, "hello world"),
    h(
      "button",
      {
        onClick() {
          alert("click");
        },
      },
      "clicke me"
    ),
  ]
);

上面的代码做的事无非就是接收三个参数,再把这三个参数作为一个vnode返回。让我们来打印一下看看:

image.png

也许看到这里会觉得有些懵,不知道要干啥,其实要做的事无非就是待会要通过 mount 函数这个vnode转换成真实的DOM。话不多 说,让我们继续接下来的操作

实现 mount 函数

这个函数要实现的功能就是把刚刚获取调用h函数获取到的vnode转换成真实 DOM,话不多说,直接上代码:

function mount(vnode, root) {
  const el = document.createElement(vnode.tag); // 创建真实节点
  vnode.el = el; // 将真实节点挂载到虚拟节点上
  const props = vnode.props; // 获取属性
  const children = vnode.children; // 获取子节点
  if (props) { // 如果有属性
    Object.keys(props).forEach((key) => { // 遍历属性
      if (key.startsWith("on")) { // 如果是事件属性
        el.addEventListener(key.slice(2).toLocaleLowerCase(), props[key]); // 添加事件监听器
      } else if (key === "style") { // 如果是样式属性
        Object.keys(props[key]).forEach((styleKey) => { // 遍历样式
          el.style[styleKey] = props[key][styleKey]; // 设置样式
        });
      } else { // 其他属性
        el.setAttribute(key, props[key]); // 设置属性
      }
    });
  }
​
  if (typeof children === "string" || typeof children === "number") { // 如果子节点是文本节点
    el.textContent = children; // 设置文本内容
  }
  if (Array.isArray(children) && children.length) { // 如果子节点是元素节点
    children.forEach((childrenVnode) => { // 遍历子节点
      mount(childrenVnode, el); // 递归挂载子节点
    });
  }
​
  root.appendChild(el); // 将真实节点添加到根节点
}

上面代码中的 mount 函数无非就是接受两个参数:vnode、root,一个为 vnode,一个则是真实 DOM 元素,也就是根节点。在这个函数内部经过一序列操作,将传进来的 vnode 转换为真实的 DOM 再挂载到 根节点(root)上。

让我们结合 h 函数 和 mount 函数来生成真实的 DOM 元素:

const vnode = h(
  "div",
  {
    style: {
      width: "200px",
      height: "200px",
      backgroundColor: "red",
    },
  },
  [
    h("h2", null, "hello world"),
    h(
      "button",
      {
        onClick() {
          alert("click");
        },
      },
      "clicke me"
    ),
  ]
);
mount(vnode, document.querySelector('#root'))

image.png

可以看到,我们已经实现了将虚拟DOM转换成真实DOM并挂载到指定的根节点上,点击按钮的时候也能触发我们添加的点击事件

实现 patch 函数

在前面我们已经实现了 h 函数 以及 mount 函数 ,已经完成了最基本的渲染器的实现,我们还差最后一步:更新虚拟节点。让我们来设想一个场景:假设我们创建的 vnode 中需要依赖于在前文我们实现了的调用 reactive(obj)返回的响应式对象中的数据,并且是在页面的显示中依赖这个数据。每当这个依赖的数据发生更新的时候,我们需要vnode也发生更新这样页面上依赖的数据会随之而更新。随即我们可以想到每当依赖的响应式数据发生改变的时候,重新执行我们的mount 函数操作。这样做是可以,但是太浪费资源了每次发生更新就要重新执行将vnode转换成真实DOM操作,那有没有更具通用性的操作呢?答案是肯定的,我们可以写一个 patch 函数 这个函数接收两个参数:vnode1(old)、vnode2(new)。顾名思义,都是两个 vnode,然后在这个函数中比较新旧两棵虚拟DOM树的差异,然后根据差异对真实的DOM进行更新。

话不多说,直接上代码:

function patch(vnode1, vnode2) {
  if (vnode1.tag !== vnode2.tag) { // 如果标签名不同
    const el = vnode1.el; // 获取元素节点
    const parentEl = el.parentElement; // 获取父元素节点
    parentEl.removeChild(el); // 移除元素节点
    mount(vnode2, parentEl); // 挂载新的虚拟节点
  } else {
    const el = (vnode2.el = vnode1.el); // 获取元素节点
​
    const newProps = vnode2.props ?? {}; // 获取新属性
    const oldProps = vnode1.props ?? {}; // 获取旧属性
​
    Object.keys(newProps).forEach((key) => { // 遍历新属性
      if (oldProps[key] !== newProps[key]) { // 如果属性值不同
        if (key.startsWith("on")) { // 如果是事件属性
          el.removeEventListener(
            key.slice(2).toLocaleLowerCase(),
            oldProps[key]
          ); // 移除旧事件监听器
          el.addEventListener(key.slice(2).toLocaleLowerCase(), newProps[key]); // 添加新事件监听器
        } else if (key === "style") { // 如果是样式属性
          Object.keys(oldProps[key]).forEach((styleKey) => { // 遍历旧样式属性
            if (!(styleKey in newProps[key])) { // 如果新样式属性中没有旧样式属性
              el.style.removeProperty(styleKey); // 移除旧样式属性
            }
          });
​
          Object.keys(newProps[key]).forEach((styleKey) => { // 遍历新样式属性
            el.style[styleKey] = newProps[key][styleKey]; // 设置样式
          });
        } else { // 其他属性
          el.setAttribute(key, newProps[key]); // 设置属性
        }
      }
    });
​
    Object.keys(oldProps).forEach((key) => { // 遍历旧属性
      if (!(key in newProps)) { // 如果新属性中没有旧属性
        if (key.startsWith("on")) { // 如果是事件属性
          el.removeEventListener(
            key.slice(2).toLocaleLowerCase(),
            oldProps[key]
          ); // 移除旧事件监听器
        } else { // 其他属性
          el.removeAttribute(key); // 移除属性
        }
      }
    });
​
    // 处理 children
    if (vnode2.children) { // 如果有子节点
      const oldChildren = vnode1.children ?? []; // 获取旧子节点
      const newChildren = vnode2.children ?? []; // 获取新子节点
​
      if (typeof newChildren === "string" || typeof newChildren === "number") { // 如果新子节点是文本节点
        if (oldChildren !== newChildren) { // 如果文本内容不同
          el.innerHTML = newChildren; // 设置文本内容
        }
      } else { // 如果新子节点是元素节点
        if (
          typeof oldChildren === "string" ||
          typeof oldChildren === "number"
        ) { // 如果旧子节点是文本节点
          el.innerHTML = ""; // 清空元素节点
          newChildren.forEach((childrenVnode) => { // 遍历新子节点
            mount(childrenVnode, el); // 递归挂载子节点
          });
        } else { // 如果旧子节点是元素节点
          const commenIndex = Math.min(oldChildren.length, newChildren.length); // 获取公共子节点的数量
​
          for (let i = 0; i < commenIndex; i++) { // 遍历公共子节点
            patch(oldChildren[i], newChildren[i]); // 递归更新子节点
          }
​
          newChildren.slice(oldChildren.length).forEach((childrenVnode) => { // 遍历新增子节点
            mount(childrenVnode, el); // 递归挂载子节点
          });
​
          oldChildren.slice(newChildren.length).forEach((childrenVnode) => { // 遍历删除子节点
            el.removeChild(childrenVnode.el); // 移除元素节点
          });
        }
      }
    } else { // 如果没有子节点
      el.innerHTML = ""; // 清空元素节点
    }
  }
}

至此我们已经完整的实现了渲染器中所有的功能函数,其中 patch 函数的操作要在下一章节才能完全使用到,这里我们先不做测试操作

下文:实现createApp