mini-vue3实现记录-节点的挂载与属性的绑定

975 阅读4分钟

vue是一个声明式的框架,即用户在使用vue时仅关注结果,而不关注过程(在浏览器上体现为直接操作dom元素),vue中对节点的逻辑操作都是通过虚拟节点vnode进行判断的,事实上在相当一部分情况下虚拟节点逻辑判断所损耗的性能并不比其节省的性能高,所以引入虚拟节点很大一部分是为了框架的可维护性。

虚拟节点是一个对象,包括Element类型和Component类型,这个对象上包含typepropschildren等属性,vue提供的h函数实际上就是一个createVNode函数

const Text = Symbol("Text");
function createVNode(type, props, children) {
    const vnode = {
        type,
        props,
        children,
        key: props && props.key,
        shapeFlag: getShapeFlag(type),
        el: null,
    };
    // children
    if (typeof children === "string") {
       ...
    }
    else if (Array.isArray(children)) {
       ...
    }
    
    return vnode;
}

....
import Foo from "./foo"
h(Foo, {}, [h(h("div", {}, "foo: ")), ...] // Component
h("div", {}, "foo: ") // Element

利用虚拟节点实现元素的挂载

render函数中,我们会调用patch函数,patch函数是虚拟节点挂载的入口,通过patch函数(此处patch只进行虚拟节点的元素绑定和挂载,不涉及比对),针对不同的虚拟节点类型,进行不同的操作。

function patch(vnode, container) {
    // 处理组件
    // 针对vnode 是 component | element类型进行处理
    if (typeof vnode.type === "string") {
        processElement(vnode, container);
    }
    else if (typeof vnode.type === "object") {
        processComponent(vnode, container);
    }
}
function processElement(vnode, container) {
    mountElement(vnode, container);
}
function mountElement(vnode, container) {
    const el = document.createElement(vnode.type);
    // 判断 vnode.children 类型,如果是string, 直接赋值即可, 如果是数组,则为vnode类型,就继续调用patch处理, 且挂载的容器即为上面的el
    const { children, props } = vnode;
    if (typeof children === "string") {
        el.textContent = children;
    }
    else if (Array.isArray(children)) {
        mountChildren(vnode, el);
    }
    for (const key in props) {
        const val = props[key];
        el.setAttribute(key, val);
    }
    container.append(el);
}
function mountChildren(vnode, container) {
    vnode.children.forEach((v) => {
        patch(v, container);
    });
}
function processComponent(vnode, container) {
    // 挂载组件
    mountComponent(vnode, container);
}
function mountComponent(vnode, container) {
    // 创建组件实例, 用于挂载`props`,`slots`等
    const instance = createComponentInstance(vnode);
    // 处理组件
    setupComponent(instance);
    // 调用render函数,得到渲染的虚拟节点
    setupRenderEffect(instance, container);
}
function setupRenderEffect(instance, container) {
    // 虚拟节点树
    // 将 component类型的vnode初始化为组件实例instance后,调用`render`,进行拆箱,得到该组件对应的虚拟节点
    // 比如根组件得到的就为根虚拟节点
    const subTree = instance.render();
    // vnode -> patch
    // element类型 vnode -> element -> mountElement
    // 得到虚拟节点树,再次调用patch, 将vnode分为element类型(vnode.type为类似'div'的string)和component类型(vnode.type需初始化为instance)进行处理拆箱, 并挂载
    patch(subTree, container);
}

这里可以看到:当虚拟节点children为数组时,会继续递归调用patch函数,实现挂载。而对于Component类型虚拟节点,则先需要将其patchElement类型(称之为拆箱操作),在递归去patch其子元素等。

元素的卸载

元素的卸载与元素的dom节点相关联,这就是为什么我们需要在vnode上绑定el属性,通过parent.removeChild(node)正确的卸载元素,这在后续进行新旧节点比对会使用到。

事件绑定

事件绑定实际上就是在vnode的props上绑定key为on + Event的属性,我们对props进行筛选,调用addEventListenerapi即可

// 判断是否是注册事件
  const isOn = (key: string) => /^on[A-Z]/.test(key);
  for (const key in props) {
    const val = props[key];
    // on + Event name

    if (isOn(key)) {
      const event = key.slice(2).toLowerCase();
      el.addEventListener(event, val);
    } else {
      el.setAttribute(key, val);
    }
  }

props

props是父组件对子组件的单向数据流传递,实现了父子组件通信。
要实现props,需要实现两点:1. 父组件给子组件传值(在子组件虚拟节点的第二个参数)2. 子组件进行值的接收。
对于第二点,我们又可以分为两步:1. 在子组件setup函数中对props进行接收 2. 在子组件render内访问到接收的props。针对第二点,我们可以使用代理的方式,在render时将this绑定到组件实例instance上,然后在instance通过访问vnode得到props
需注意一点:props为单向数据流,且为浅层响应式的,所以它实际上为shallowReadonly类型。

emit

在vue3中, emit方法需要从setup函数的第二个参数获取,emit可以触发父组件的事件,并传递参数。但如果采用这样的逆向思维的话,其实不太好实现,实际上我们仍是在父组件中为子组件的props传入相应的函数,当emit的第一个参数符合时,父组件传入的函数则会被触发。

slot插槽

  • 对于最基本的slots,实际上就是在父组件内为子组件的children传入节点(或文本),子组件通过this.$slots获取children进行createVNode即可
  • 对于具名插槽, 实际上就是根据传入的插槽name, 取出对应的vnode,渲染至使用这个name的位置
const foo = h(
     Foo,
     {},
     {
       header: h("p", {}, "header"),
       footer: h("p", {}, "footer"),
     }
   );
   ...
render() {
   const foo = h("p", {}, "foo");
   console.log(this.$slots);
   return h("div", {}, [
     renderSlots(this.$slots, "header"),
     foo,
     renderSlots(this.$slots, "footer"),
   ]);
 },
  • 对于作用域插槽,我们仍是正向考虑,子组件在渲染时将一部分数据提供给插槽,从而提供给父组件,我们不要考虑子组件如何传值给父组件(实际也不被允许)
    可以考虑将插槽转化为一个函数通过形参占位,然后在子组件渲染插槽时,将对应的值传入达到渲染的效果。实际上类似于emit的思想.
const foo = h(
   Foo,
   {},
   {
     header: ({ age }) => [
       h("p", {}, "header" + age),
       createTextVNode("666"),
     ],
     footer: () => h("p", {}, "footer"),
   }
 );
 ...
 
 
 foo.js
 ...
 render() {
 const foo = h("p", {}, "foo");
 const age = 19;

 return h("div", {}, [
   renderSlots(this.$slots, "header", {
     age,
   }),
   foo,
   renderSlots(this.$slots, "footer"),
 ]);