『手写Vue3』props、emit、slot、fragment、textNode

225 阅读6分钟

经过上一期,我们大概知道了runtime-core模块是如何初始化vnode、instance,以及渲染到页面上的,实现了组件代理,并且使用位运算优化了判断类型的实现。这一次在搭好的大框架的基础上,实现props、emit以及slot。

props

新增Foo.js组件,并且在App中使用:

const Foo = {
  setup(props) {
    console.log(props);

    // shallow readonly
    props.count++;
    console.log(props);
  },
  render() {
    return h('div', {}, 'foo: ' + this.count);
  }
};

export const App = {
  render() {
    window.self = this;
    return h(
      'div',
      {
        id: 'root',
        class: 'red',
      },
      [
        h('div', {}, 'hi,' + this.msg),
        h(Foo, {
          count: 1
        })
      ]
    );
  },

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

props共有三点需要实现:

  1. 能在setup中获取到props。
  2. 假设 props === { foo: 1},能通过this.foo拿到1,实现组件代理。
  3. 不能修改props的值。

首先我们要思考,如何才能在setup中获取到props?很显然,props应该是作为参数传给setup的,那么就要找setup初始化的位置,根据上一节,可知是函数setupStatefulComponent

我们把props保存在instance中,让initProps函数完成。

export function setupComponent(instance) {
  initProps(instance, instance.vnode.props);
  // initSlots()
  setupStatefulComponent(instance);
}

export function initProps(instance, rawProps) {
  // 有时候component vnode不会传props
  // 本例中,Foo传了props,但App没有传
  // 如果props为undefind,之后访问props的属性会报错,所以此时要初始化为空对象
  instance.props = rawProps || {};
}

function setupStatefulComponent(instance: any) {
  const Component = instance.type;

  instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);

  const { setup } = Component;

  if (setup) {
    // props要求是shallowReadonly类型,从而实现第三点要求
    const setupResult = setup(shallowReadonly(instance.props));

    handleSetupResult(instance, setupResult);
  }
}

不能修改props靠套一层shallowReadonly实现,这样就实现了第一、三个要求。

然后修改组件代理,主要是给proxy新增逻辑,当某个属性不在setupState而在props中,就从props里取。

const hasOwn = (val, key) =>
  Object.prototype.hasOwnProperty.call(val, key);

const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, props } = instance;

    if (hasOwn(setupState, key)) {
      return setupState[key];
    } else if (hasOwn(props, key)) { // 新增
      return props[key];
    }

    const publicGetter = publicPropertiesMap[key];

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

emit

emit:父组件把函数传给孩子的props,孩子可以在setup中拿到emit,通过emit(函数名)的方式调用父组件传来的函数,从而影响父组件的状态。

emit中传入的函数名不一定要和父组件传来的函数名一致,可以用烤肉串命名方式,比如父亲传来的函数叫fooBar,子组件想要调用它时,可以使用emit('foo-bar')。

修改父组件如下:

export const App = {
  render() {
    window.self = this;
    return h(
      'div',
      {
        id: 'root',
      },
      [
        h('div', {}, 'hi,' + this.msg),
        h(Foo, {
          count: 1,
          // 函数作为props
          add: (a, b) => {
            console.log('add', a, b);
          },
          someEvent: () => {
            console.log('some event');
          }
        })
      ]
    );
  },

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

子组件:

export const Foo = {
  setup(props, { emit }) {
    // 拿到emit,调用函数
    emit('add', 1, 2);
    emit('some-event');
  },
  render() {
    return h('div', {}, 'foo: ' + this.count);
  }
};

现在思考如何实现emit,第一步是要往setup中传入emit函数,不难想到把emit保存在instance上,然后在setStatefulComponent中:

const setupResult = setup(shallowReadonly(instance.props), {
  emit: instance.emit
});

然后在初始化instance时,要设置emit:

// 用于把烤肉串转为驼峰,因为父组件传的props中,函数名都用驼峰
const camelize = (str) => {
  return str.replace(/-(\w)/g, (_, c) => {
    return c ? c.toUpperCase() : '';
  });
};

// 需要instance,因为需要拿到props,然后从props中获取函数
function emit(instance, event, ...args) {
  const { props } = instance;

  const handler = props[camelize(event)];
  handler && handler(...args);
}

function createComponentInstance(vnode) {
  // 这里的component就是instance
  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {}
  };

  // 初始化,使用bind将instance传入,这样用户就不用传了
  component.emit = emit.bind(null, component) as any;

  return component;
}

上面顺便做了烤肉串向驼峰的转换,这样用户就可以在setup中成功拿到emit,并且传入驼峰或烤肉串式函数名,执行函数。

slot

slot的实现有以下目标:

  • 实现基本的插槽
  • 具名插槽
  • 作用域插槽

基本的插槽

component vnode的children属性会作为插槽,所以应该先获取vnode的children。在instance上保存slots属性,在initSlots函数中,先简单实现为instance.slots = instance.vnode.children

现在,instance上已经保存了slots,this.$slots应当暴露给用户,这样用户在子组件Foo中就可以获取插槽中的结点,调用render生成vnode,最后被渲染出来。根据前面的博客,此处需要给组件代理的map新增成员:

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

现在,假设给子组件传的children是vnode类型(string属于文本结点),可以这样使用插槽:

  render() {
    const foo = h('p', {}, 'foo');
    const age = 18;

    return h('div', {}, [this.$slots, foo]);
  }

如果给子组件传的children是array类型,则不能成功渲染,可以再创建一个vnode,然后把插槽作为其children,children允许为array类型,但是这样写之后,children为vnode或string又不能成功渲染。

  render() {
    const foo = h('p', {}, 'foo');
    const age = 18;

    return h('div', {}, [h('div', {}, this.$slots), foo]);
  }

在initSlots中,如果children不是数组,instance.slots = [children]。从而保证this.$slots始终是数组。

至此,基本的插槽实现完毕。

具名插槽

具名插槽中,在拿到插槽vnode的基础上,还要考虑位置问题。刚才说给子组件传的children可以有array/string/vnode类型,现在修改为传一个对象。

父组件这样传:

    const app = h('div', {}, 'hi, ' + this.msg);
    const foo = h(
      Foo,
      {},
      {
        header: h('div', {}, 'header ' + age),
        footer: h('div', {}, 'footer')
      }
    );
    return h(
      'div',
      {
        id: 'root'
      },
      [app, foo]
    );
  },

子组件这样用:

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

先来看看renderSlots的逻辑,核心是构造一个新的vnode,并把slots作为其children,但是由于是具名插槽,只会从this.$slots中取出名字相符的slot。

export function renderSlots(slots, name) {
  const slot = slots[name];

  if (slot) {
    return createVNode('div', {}, slot);
  }
}

之前,this.$slots是一个数组,现在能通过slots[name]的方式获取到slot,数组显然不合适,所以instance.slots也是一个对象。

export function initSlots(instance, children) {
  const { vnode } = instance;
  
  // 新增ShapeFlags.SLOT_CHILDREN,判断有没有slot
  if (vnode.shapeFlag & ShapeFlags.SLOT_CHILDREN) {
    normalizeObjectSlots(instance.slots, children);
  }
}

function normalizeObjectSlots(slots, children) {
  for (const key in children) {
    const value = children[key];
    // instance.slots是一个对象,给每个key都初始化好slot
    slots[key] = normalizeSlotValue(value);
  }
}

function normalizeSlotValue(value) {
  // 上面说了slot要转为数组的原因
  return Array.isArray(value) ? value : [value];
}

现在,renderSlots(this.$slots, 'header')就可以从instance.slots上拿到header对应的slot,生成vnode了。footer也同理,具名插槽实现完毕。

作用域插槽

所谓作用域插槽,指的是插槽中可以传入变量,比如父组件给子组件的插槽内使用了变量age,age要能渲染出来。

插槽对象内的成员改为函数,函数的返回值为vnode:

  render() {
    const app = h('div', {}, 'hi, ' + this.msg);
    const foo = h(
      Foo,
      {},
      {
        // 解构出age。为了拿到age,需要使用函数
        header: ({ age }) => h('div', {}, 'header ' + age),
        footer: () => h('div', {}, 'footer')
      }
    );
    return h(
      'div',
      {
        id: 'root'
      },
      [app, foo]
    );
  },

在子组件中,给renderSlots加入第三个参数,用于作用域插槽:

  render() {
    const foo = h('p', {}, 'foo');
    const age = 18;

    return h('div', {}, [
      // 第三个参数传一个对象,对象里是一些props
      renderSlots(this.$slots, 'header', { age }),
      foo,
      renderSlots(this.$slots, 'footer')
    ]);
  }

然后就是修改之前的逻辑,因为slot成为了函数的返回值,之前用到slot的地方,都需要改为slot(),并且将props传入。

function renderSlots(slots, name, props) {
  // slot从this.$slots拿到,也是函数
  const slot = slots[name];

  if (slot) {
    // slot(props)
    return createVNode('div', {}, slot(props));
  }
}

这里也需要修改:

function normalizeObjectSlots(slots, children) {
  for (const key in children) {
    const value = children[key]; // value就是slot(函数)
    // 修改原函数,让其返回值改为数组
    slots[key] = (props) => normalizeSlotValue(value(props));
  }
}

function normalizeSlotValue(value) {
  return Array.isArray(value) ? value : [value];
}

到此作用域插槽也实现完毕,过程较为漫长,大家辛苦了!

fragment

由上方可知,renderSlots中需要把插槽作为一个结点children,type传入的是'div',这将导致插槽元素总是被div包裹:

image.png

想要去掉外边的div,需要使用fragment。定义变量 Fragment = Symbol('Fragment'),修改renderSlots:

export function renderSlots(slots, name, props) {
  const slot = slots[name];

  if (slot) {
    return createVNode(Fragment, {}, slot(props));
  }
}

patch的作用是判断结点的类型,再做不同的处理,所以现在要根据vnode的type进行判断:

function patch(vnode, container) {
  const { type } = vnode;

  switch (type) {
    case Fragment:
      mountChildren(vnode, container);
      break;
    // 处理text结点,待会讲
    case Text:
      processText(vnode, container);
      break;
    default:
      if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
        processElement(vnode, container);
      } else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        processComponent(vnode, container);
      }
  }
}

当判断结点是Fragment类型时,不用把它当一般的element vnode处理,只用渲染它的孩子即可。

image.png

现在插槽外面就不会有div了。

textNode

根据之前mountElement的逻辑:

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

能渲染成功的children类型有两种,要么children只有一个字符串,要么children是一个数组,数组里全都是使用h函数生成的vnode。如果数组里有字符串,不能被正常渲染出来,比如这样:

// 这两种 element vnode中的文本结点都不能正常渲染。
const bar = h('div', {}, ['str1', 'str2', 'str3']);
const baz = h('div', {}, ['text', h('span', {}, 'after')]);

所以需要单独添加对文本结点的处理。首先定义const Text = Symbol('Text'),并且在patch的switch中添加case,然后实现processText:

function processText(vnode, container) {
  const { children } = vnode;
  const textNode = document.createTextNode(children);
  container.append(textNode);
}

给用户提供创建文本结点的api:

// 类型是Text
export function createTextVNode(text) {
  return createVNode(Text, {}, text);
}

然后用户这样使用:

const bar = h('div', {}, [h('p', {}, 'test'), createTextVNode('text结点')]);

现在文本结点'text结点'就能正常渲染了。