『手写Vue3』component、element、组件代理

252 阅读4分钟

从这节开始,reactivity暂告一段落,进入runtime-core部分。这部分负责的是组件渲染相关内容,即当我写好app.js文件后,如何挂载component使其显示在页面上,及其相关的问题。

组件的结构

一个典型的app.js的结构如下:

import { h } from '../../lib/guide-mini-vue.esm.js';

export const App = {
  render() {
    return h(
      'div',
      {
        id: 'root',
        class: 'red',
        onClick: () => console.log('onclick'),
        onMousedown: () => console.log('onmousedown')
      },
      'hi, mini-vue'
    );
  },

  setup() {
    return {
      msg: 'mini-vue'
    };
  }
};

暂时不用理会h函数,只需要关注:组件是一个含有render()和setup()方法的对象,其中render处理vnode的生成,setup会返回一个对象或函数,作为该组件的状态值。

vnode

vnode大体上的数据结构为:

{
  type: ...
  props: ...
  children: ...
}

创建vnode,需要使用createVNode函数,实现很简单,把传入的属性全部塞入一个对象,将其返回。

export function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
  };
  return vnode;
}

至于上文提到的h函数,则是为了让用户使用,将createVNode封装并暴露出去的函数:

export function h(type, props?, children?) {
  return createVNode(type, props, children);
}

暴露:在index.ts中将h函数export,该文件在rollup中配置为入口,这样打包后h函数就能出现在lib下的.js文件中,可以被用户使用。

render element 的流程

由main.js中的createApp(App).mount(rootContainer);知,起点是createApp函数,然后调用mount方法,流程是先生成vnode,然后调用render。

export function createApp(rootComponent) {
  return {
    mount(rootContainer) {
      const vnode = createVNode(rootComponent);

      render(vnode, rootContainer);
    },
  };
}

此处的rootComponent就是上面的App,所以生成的vnode结构如下:

{
  type: { render() {...}, setup() {...} },
  props: undefined,
  children: undefined
}

这种vnode我们不妨称为“component vnode”,特点是type属性值为含有render和setup方法的对象,props就是组件的props,children用于插槽。

这边也一并介绍另一种vnode,即“element vnode”,这种就是比较常见的vnode,可以拿它渲染出真实dom。

{
  type: 'div',
  props: { class: 'red' },
  children: 'hello world' // 或者是数组
}

之后的函数调用链比较长,先总结一下:

render -> patch -> processElement -> mountElement -> mountChildren
                                     (挂载真实dom)    (孩子的递归挂载)
                          /             
                   processComponent -> mountComponent -> createComponentInstance
                                                        (创建instance,会传入下面的函数)
                                                         setupComponent -> setupStatefulComponent -> handleSetupResult -> finishComponentSetup
                                                        (setup为对象或函数,分类讨论)                                        (从instance.type取出组件app,把其render赋值给instance.render)
                                                         
                                                         setupRenderEffect -> patch
                                                                             (这回处理element,进入processElement)

patch会对vnode进行判断,从而进入不同的分支:

function patch(vnode, container) {
  if (typeof vnode.type === "string") {
    processElement(vnode, container);
  } else if (isObject(vnode.type)) {
    processComponent(vnode, container);
  }
}

这里面提到了instance,它又是一层封装,结构如下:

{
  vnode,
  type: vnode.type, // component vnode的type,即组件app
  setupState: {}, // 用于组件代理
  ...
};

传入App到页面渲染出dom,流程大概是:先从patch进入processComponent分支,完成一系列初始化操作,构造了instance,最后执行render得到element vnode,传入patch,接着进入processElement分支,将其渲染为真实dom。

function processElement(vnode: any, container: any) {
  mountElement(vnode, container);
}

function mountElement(vnode: any, container: any) {
  const { type, props, children } = vnode;
  // 将el保存到element node上
  const el = (vnode.el = document.createElement(vnode.type));

  // props
  const isOn = (key) => /^on[A-Z]/.test(key);

  for (const key in props) {
    const value = props[key];
    if (isOn(key)) {
      const event = key.slice(2).toLocaleLowerCase();
      el.addEventListener(event, value);
    } else {
      el.setAttribute(key, value);
    }
  }

  // children
  if (typeof children === "string") {
    el.textContent = children;
  } else if (Array.isArray(children)) {
    mountChildren(vnode, el);
  }

  container.append(el);
}

function mountChildren(vnode, container) {
  vnode.children.forEach((v) => {
    patch(v, container);
  });
}

组件代理

组件代理,指的是可以在组件内通过this访问到setup暴露出去的属性,或者是一些特殊变量,比如this.$el访问根结点的dom。

上文提及instance的数据结构时,提及了其中的setupState属性用于组件代理。setupState是在这里初始化的:

function setupStatefulComponent(instance: any) {
  const Component = instance.type;
  // 也用于组件代理
  instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);

  const { setup } = Component;

  if (setup) {
    // 得到setupResult,可以是对象或函数
    const setupResult = setup();

    handleSetupResult(instance, setupResult);
  }
}

function handleSetupResult(instance, setupResult: any) {
  // 暂时只处理对象的情况
  if (typeof setupResult === 'object') {
    // 这里
    instance.setupState = setupResult;
  }

  finishComponentSetup(instance);
}

const publicPropertiesMap = {
  // 从component类型的vnode获取el
  $el: (i) => i.vnode.el
};

const PublicInstanceProxyHandlers = {
  // 把对象的_解构成instance变量
  get({ _: instance }, key) {
    const { setupState } = instance;
    if (key in setupState) {
      return setupState[key];
    }
    // 避免写过多if-else,要增加逻辑只用修改publicPropertiesMap
    const publicGetter = publicPropertiesMap[key];

    if (publicGetter) {
      return publicGetter(instance);
    }
  }
};

并且可以看到,instance上还有proxy属性,将proxy对象作为this调用render,这样this.msg相当于proxy.msg,就会触发proxy的getter,进而从setupState中拿到属性值。

function setupRenderEffect(instance: any, initialVNode, container) {
  const { proxy } = instance;
  // 将proxy作为this传入render
  const subTree = instance.render.call(proxy);

  patch(subTree, container);

  // initialVNode是component类型的vnode,需要从element类型的vnode获取
  initialVNode.el = subTree.el;
}

注意到最下面设置了component vnode的el属性。由于instance其实是对component vnode的封装,并没有保存真实的dom结点,当调用this.$el去获取dom时,从instance的setupState中查找,显然是得不到dom的。而subTree是element vnode,可以获取el,所以最后一行的赋值操作必不可少,这样在publicPropertiesMap中,就能通过instance.vnode.el拿到dom了。

修改App.js:

import { h } from '../../lib/guide-mini-vue.esm.js';

window.self = null; // 在控制台直接打印self
export const App = {
  render() {
    window.self = this;
    return h(
      'div',
      {
        id: 'root',
        class: 'red'
      },
      'hi, ' + this.msg
    );
  },

  setup() {
    return {
      msg: 'mini-vue'
    };
  }
};

image.png

shapeFlags

前面在判断组件类型,以及组件的孩子是字符串或数组时,逻辑分散,而且每次比较都是“对象的某个属性 === 某个值”,性能也比较低,因此有必要使用位运算优化。

创建枚举,给vnode添加shapeFlag属性,使用|可以设置shapeFlag某一位为1,然后用&判断某一位是否为1。

初始化:

const enum ShapeFlags {
  ELEMENT = 1, // 0001
  STATEFUL_COMPONENT = 1 << 1, // 0010
  TEXT_CHILDREN = 1 << 2, // 0100
  ARRAY_CHILDREN = 1 << 3, // 1000
}

function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
    shapeFlag: getShapeFlags(type),
    el: null
  };

  if (typeof children === 'string') {
    vnode.shapeFlag = vnode.shapeFlag | ShapeFlags.TEXT_CHILDREN;
  } else if (Array.isArray(children)) {
    vnode.shapeFlag = vnode.shapeFlag | ShapeFlags.ARRAY_CHILDREN;
  }

  return vnode;
}

function getShapeFlags(type) {
  return typeof type === 'string'
    ? ShapeFlags.ELEMENT
    : ShapeFlags.STATEFUL_COMPONENT;
}

判断是否为element vnode,以及孩子是text/array的逻辑修改如下:

function patch(vnode, container) {
  if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
    processElement(vnode, container);
  } else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    processComponent(vnode, container);
  }
}

function mountElement(vnode, container) {
  // ...

  // children
  if (vnode.shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    el.textContent = children;
  } else if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(vnode, el);
  }
  
  // ...
}