Vue3源码学习3——运行时(h函数)

559 阅读7分钟

Vue和React这类框架比起远古时期的jQuery,最大的改变就是采用了MVVM架构

而MVVM架构的核心,则是虚拟DOM

基本概念

虚拟DOM

说到虚拟DOM,先得说起真实DOM。真实的DOM就是一颗HTML树,里面包含了节点属性classidstyle等)、子节点信息。

例如这样的DOM节点

<div id="app" class="test">hello world</div>

虚拟的DOM也要包括和真实DOM一样的信息。区别在于,它用JS对象来表示

例如刚才的DOM节点,用JS可以这样表示

const vnode = {
    type: "div",
    props: {
        id: "app",
        class: "test"
    },
    children: "hello world"
}

可以看出,这个vnode对象,用了typepropschildren分别表示了这个真实DOM节点的类型(div)、属性(id为app,class为test)以及子节点(字符串hello world)。看着这样格式的JS对象,我们也可以很容易地推理得到一个真实的DOM节点。我们把这样的一个对象称之为虚拟DOM

h函数

在Vue源码中,虚拟DOM是由h函数生成的。生成时同样可以传入节点类型属性以及子节点信息

h("div", { id: "app", class: "test" }, "hello world")

尝试打印这个h函数生成的vnode,内部信息比较丰富,用注释标注了一些核心的属性

{
        // 是否是VNode对象
        "__v_isVNode": true,
        "__v_skip": true,
        // 节点类型
        "type": "div",
        // 节点属性
        "props": { "class": "test", "id": "app" },
        "key": null,
        "ref": null,
        "scopeId": null,
        "slotScopeIds": null,
        // 子节点
        "children": "hello world",
        "component": null,
        "suspense": null,
        "ssContent": null,
        "ssFallback": null,
        "dirs": null,
        "transition": null,
        "el": null,
        "anchor": null,
        "target": null,
        "targetAnchor": null,
        "staticCount": 0,
        "shapeFlag": 9,
        "patchFlag": 0,
        "dynamicProps": null,
        "dynamicChildren": null,
        "appContext": null
}

h函数框架

基本框架

对于h函数,我们知道应该包括三个参数

  • 节点类型type
  • 属性props(可选)
  • 子节点children(可选)

考虑到属性和子节点都是可选参数,因此要针对入参数量和类型做一些判断,大概的规则如下

h函数入参框架.drawio.png

export function h(type: any, propsOrChildren?: any, children?: any): VNode {
  const l = arguments.length;

  // 两个参数,不知道是props没传还是children没传
  if (l == 2) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      if (isVNode(propsOrChildren)) {
        return createVNode(type, null, [propsOrChildren]);
      }

      return createVNode(type, propsOrChildren, []);
    } else {
      return createVNode(type, null, propsOrChildren);
    }
  } else {
    // 三个及以上的参数,肯定是props和children都有
    if (l > 3) {
      children = Array.prototype.slice.call(arguments, 2);
    } else if (l === 3 && isVNode(children)) {
      children = [children];
    }

    return createVNode(type, propsOrChildren, children);
  }
}

至于是否为vnode的判断,直接根据__v_isVNode属性就可得知

export function isVNode(value: any): value is VNode {
  return value ? value.__v_isVNode === true : false;
}

整体框架出来了,接下来vnode的生成,主要就是createVNode这个方法了

vnode的类型

关于DOM节点,我们大概可以分成这样一些类别

  • 标准节点:div、h1等常规标签
  • 注释节点
  • Fragment节点
  • 组件
  • ……

那么,相对的,vnode也会区分一些节点类型。

查看Vue源码可以得知,里面包含了文本节点Text片段节点FragmentDOM节点Element组件节点Component注释节点Comment等等。

此外,对于children来说,可能是一个,也可能是数组,甚至是对象函数等等多种不同类型。

源码中,为了区分不同的节点,使用了shapeFlag,而且这个值还用二进制位进行计算。

export const enum ShapeFlags {
  /**
   * type = Element
   */
  ELEMENT = 1,
  /**
   * 函数组件
   */
  FUNCTIONAL_COMPONENT = 1 << 1,
  /**
   * 有状态(响应数据)组件
   */
  STATEFUL_COMPONENT = 1 << 2,
  /**
   * children = Text
   */
  TEXT_CHILDREN = 1 << 3,
  /**
   * children = Array
   */
  ARRAY_CHILDREN = 1 << 4,
  /**
   * children = slot
   */
  SLOTS_CHILDREN = 1 << 5,
  /**
   * 组件:有状态(响应数据)组件 | 函数组件
   */
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT,
}

h函数实现

element节点+文本children

h函数最简单的用法是创建一个有tag名称的节点,子节点是文本。

const vnode = h("div", "hello world");

为了实现VNode的构建,需要先构建createVNode方法,接收typepropschildren这三个参数。

export interface VNode {
  __v_isVNode: true
  type: any
  props: any
  children: any
  shapeFlag: number
}

export function createVNode(type, props, children): VNode {
  // 这里先处理文本类型,其他的shapeFlag先不处理
  const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : 0;
  
  return createBaseVNode(type, props, children, shapeFlag);
}

生成了shapeFlag后,要先创建VNode的一些基本属性,源码中用的就是createBaseVNode方法,并且在这之后使用normalizeChildren标准化children的类型(即使用位或运算得到一个shapeFlag值)

// 创建基础vnode
function createBaseVNode(type, props, children, shapeFlag) {
  const vnode = {
    __v_isVNode: true,
    type,
    props,
    shapeFlag,
  } as VNode;

  normalizeChildren(vnode, children);

  return vnode;
}

function normalizeChildren(vnode: VNode, children: unknown) {
  let type = 0;

  if (children === null) {
    children = null;
  } else if (isArray(children)) {
    // TODO: 数组类型children处理
  } else if (typeof children === "object") {
    // TODO: 对象类型的children处理
  } else if (isFunction(children)) {
    // TODO: 函数类型的children处理
  } else {
    children = String(children);
    type = ShapeFlags.TEXT_CHILDREN;
  }

  vnode.children = children;
  vnode.shapeFlag |= type;
}

此时我们可以通过h函数拿到tagstring(例如divp等),且children内容是text的vnode值了,和源码的核心内容是一致的

const vnode = h("div", "hello world");

console.log(vnode); // shapeFlag是9,children是hello world

element节点+数组children

相比于上一个节点类型,这个的区别就是children不再是一个节点,而是多个

const vnode = h("div", [h("p", "p1"), h("p", "p2"), h("p", "p3")]);

其中每一个节点的解析就是刚才element+text这种组合,而且这几个节点会先被处理成VNode,唯一的区别是整体div这个节点

查看源码可以知道,我们只需要在最后生成divVNode的时候提供一个type数值就行

function normalizeChildren(vnode: VNode, children: unknown) {
  ......

  else if (isArray(children)) {
    type = ShapeFlags.ARRAY_CHILDREN;
  }
  
  ......
}

这时候这个VNode的打印结果就和源码核心一致了

const vnode = h("div", [h("p", "p1"), h("p", "p2"), h("p", "p3")]);

console.log(vnode); // shapeFlag是17,children也是vnode节点,shapeFlag都是9

从这里不难发现,VNode中一个很关键的属性是shapeFlag,正如名字一样,它描述了虚拟DOM节点的形状,而这个形状包括了父节点类型子节点类型/形状,其中:

  • createBaseVNode:提供了父节点的类型
  • normalizeChildren:提供了子节点的类型和形状

组件Component

首先要明确一个问题:在Vue中,组件的本质是对象/函数

所以实际上在Vue中写的组件,如果需要渲染的话,要用对象包裹,其中包含一个render方法

const component = {
  render() {
    const vnode1 = h("div", "this is component");
    return vnode1;
  },
};

const vnode2 = h(component);

render(vnode2, document.querySelector('#app'));

按照之前的h函数实现和理解,其实我们可以在component的render中直接返回一个VNode对象render方法直接给一个VNode对象,也能达到一样的效果,主要的注意点就是shapeFlag值要保持和源码一致

const component = {
  render() {
    return {
      v__is_vnode: true,
      type: "div",
      children: "this is component",
      shapeFlag: 9
    }
  }
}

render({
  v__is_vnode: true,
  type: component,
  shapeFlag: 4
}, document.querySelector('#app'));

所以只需要注意处理一下typeshapeFlag值,就可以完成组件component的h函数了,只要在createVNode中修改一行即可

function createVNode(type, props, children): VNode {
  ......
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : 0;

  ......
}

按照之前的代码,最后生成的VNode里的children会变成"undefined",其实是normalizeChildren里面的判断条件过于严苛了,把===改成==即可

function normalizeChildren(vnode: VNode, children: unknown) {
  ......
  if (children == null) {
    children = null;
  }
  ......
}

其他一些简单节点

这里的简单节点包括:

  • 纯文本Text
  • 注释Comment
  • 片段Fragment

查看源码可以发现,用h函数构建的这些节点的type都是Symbol类型shapeFlag都是8

const vnodeText = h(Text, "this is text");

const vnodeComment = h(Comment, "this is comment");

const vnodeFragment = h(Fragment, "this is fragment");

相比于之前的代码,我们只需要考虑type的问题,这里直接创建几个常量处理即可

export const Fragment = Symbol("Fragment");
export const Text = Symbol("Text");
export const Comment = Symbol("Comment");

处理完之后,h函数对这些简单节点的输出就和源码核心保持一致了

class和style的增强处理

Vue中对classstyle,支持使用数组/对象,动态绑定值

const vnode = h('div', {
  class: {
    'red': true
  }
}, 'improve class');

按照上面的代码,可以拿到一个带有class为red的div

阅读源码得知,这个增强处理需要添加一个normalizeClass方法,方法本质上也是对class对象做一个遍历,针对string/array/object做不同的处理方式,最后拼接成string

function normalizeClass(value: unknown): string {
  let res = "";

  if (isString(value)) {
    res = value;
  } else if (isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      const normalized = normalizeClass(value[i]);
      if (normalized) {
        res += normalized + " ";
      }
    }
  } else if (isObject(value)) {
    for (const name in value as object) {
      if ((value as object)[name]) {
        res += name + " ";
      }
    }
  }

  return res.trim();
}

function createVNode(type, props, children): VNode {
  if (props) {
    let { class: klass, style } = props;

    if (klass && !isString(klass)) {
      props.class = normalizeClass(klass);
    }
  }
  ......
}

style的处理逻辑上和class一致