实现mini-vue -- runtime-core模块(六)实现组件emit功能

408 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情

本篇文章会带你实现组件的emit功能,能够在子组件中发出自定义事件,并在父组件中由相应的onXxxprops接收并处理,还支持给自定义事件传递参数,并且支持短横线分隔的自定义事件名

1. 需求分析

  1. 组件setup方法能够接收第二个参数,是一个对象,其中有emit属性
  2. emit是一个方法,传入事件名xxx就可以将对应的事件广播出去
  3. 父组件中如果有在调用子组件时,在相应的props中传入onXxx就可以将子组件广播出来的事件接收到并进行处理

2. 案例环境搭建

创建example/componentEmit目录,我们会在里面定义一个父组件App.js和一个子组件Foo.js 父组件中会有一个onAdd的方法,用于处理子组件emit出来的add事件

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

export const Foo = {
  // setup(props, { emit }) {
  //   const emitAdd = () => {
  //     console.log('emit add');
  //     emit('add');
  //   };
  // },
  setup() {
    const emitAdd = () => {
      console.log('emit add');
    };

    return {
      emitAdd,
    };
  },
  render() {
    return h(
      'button',
      {
        onClick: this.emitAdd,
      },
      'add'
    );
  },
};
// App.js
import { h } from '../../lib/plasticine-mini-vue.esm.js';
import { Foo } from './Foo.js';

export const App = {
  setup() {
    return {};
  },
  render() {
    const onAdd = () => {
      console.log('onAdd');
    };

    return h('div', {}, [h(Foo, { onAdd })]);
  },
};

父组件调用Foo组件时,在props中传入了onAdd方法,我们希望它会在子组件调用emit('add')发出add事件时被父组件接收到并调用onAdd方法去响应该事件,由于setup暂时还不能接收第二个参数,所以我们先把它注释掉,等待会实现了再来取消注释看看是否实现


3. 总结 todo

根据以上的需求和案例场景,我们可以总结一下我们接下来要做什么事情

  1. 要让setup能够接收第二个参数对象,对象中有一个emit方法
  2. emit方法能够接收一个参数,即要广播的事件名
  3. props中寻找相应的onXxx事件去执行

4. 让 setup 接收第二个参数

只需要到setupStatefulComponent中修改调用组件的setup方法时,给它传入第二个参数即可

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

  // ctx -- context
  instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);

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

    // setupResult 可能是 function 也可能是 object
    // - function 则将其作为组件的 render 函数
    // - object 则注入到组件的上下文中
    handleSetupResult(instance, setupResult);
  }
}

emit是来自于组件实例上的emit,我们现在给组件实例添加上emit方法

+ import { emit } from './componentEmit';
export function createComponentInstance(vnode) {
  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
+   emit: () => {},
  };

+ component.emit = emit as any;

  return component;
}

emit的实现放到单独的文件中,创建src/runtime-core/componentEmit.ts

export function emit(event) {
  console.log('emit', event);
}

现在整个调用关系就搭建好啦,可以在setup中接收第二个参数了

export const Foo = {
- setup(props) {
+ setup(props, { emit }) {
    const emitAdd = () => {
      console.log('emit add');
+     emit('add');
    };

    return {
      emitAdd,
    };
  },
  render() {
    return h(
      'button',
      {
        onClick: this.emitAdd,
      },
      'add'
    );
  },
};

现在点击一下add按钮就会在控制台输出两条emit add,第一条是来自按钮的点击事件onClick的,第二条则是来自emitimage.png 现在todo的前两项我们就完成啦,接下来完成第三项


5. 在 props 中寻找相应的 onXxx 方法执行

5.1 利用bind给emit绑定组件实例参数

既然要用到props,那么我们就应该在emit函数中能够接收到propsprops是在instance上的,所以我们可以给emit函数传入一个instance参数

export function emit(instance, event) {
  const { props } = instance;

  console.log('emit', event);
}

但是用过vue的都知道,你在使用emit的时候,有让你传入第一个参数是组件实例的吗?并没有吧 如果每次使用都要让用户传入一个instance的话那太不合理了,用户只关注emit的广播事件的功能,至于其他用户不需要知道的细节我们应该屏蔽掉,那么这里的instance参数应该怎么处理呢? 这就要用到js的函数原型上的bind了,我们让bind帮我们传入第一个instance参数

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

  // ctx -- context
  instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);

  if (setup) {
    const setupResult = setup(shallowReadonly(instance.props), {
-     emit: instance.emit,
+     emit: instance.emit.bind(null, instance),
    });

    // setupResult 可能是 function 也可能是 object
    // - function 则将其作为组件的 render 函数
    // - object 则注入到组件的上下文中
    handleSetupResult(instance, setupResult);
  }
}

这样一来用户就只需要传入事件名就可以了,而emit函数实际接收到的有两个参数,第一个是bind传入的instance参数,第二个则是用户传入的事件名,从而屏蔽了用户不需要知道的细节,妙极了!


5.2 先实现特定行为,再重构具有通用性

能够获取到props之后,下一步就要思考如何找出其中的onXxx方法并调用了,这里我们根据TPP的思想,也就是先写一个特定的行为,然后再将它重构成通用的行为,什么意思呢?接着看就知道了

我们先针对目前我们案例中的场景,只处理一个add事件,那么是不是就应该从props中取出onAdd方法并执行?就像下面这样:

export function emit(instance, event) {
  console.log('emit', event);
  const { props } = instance;

+ const handler = props['onAdd'];
+ handler && handler();
}

现在再去点击add按钮,就会发现onAdd回调被执行了 image.png ok,已经完成我们的需求了,子组件emit了一个add事件,并且父组件监听到该事件后执行了onAdd回调,但是目前只能处理add事件,根据TPP的思想,接下来我们要去把它重构成通用的处理方式

如何重构呢?根据目前处理add事件这一特定行为,可以总结出,就是要在props中找出on + 首字母大写的event时间名方法并执行

export function emit(instance, event) {
  console.log('emit', event);
  const { props } = instance;

+  const capitalize = (str: string) => {
+    return str.charAt(0).toUpperCase() + str.slice(1);
+  };

+  const toHandlerKey = (str: string) => {
+    return str ? 'on' + capitalize(str) : '';
+  };
-  const handler = props['onAdd'];
+  const handlerKey = toHandlerKey(event);
+  const handler = props[handlerKey];
  handler && handler();
}

现在就变得通用了,可以处理任何事件名了


6. 给自定义事件传递参数

实际上,用户在使用emit的时候,除了要触发特定事件外,可能还需要传递一些参数,那么我们可以利用es6的剩余参数特性实现

- export function emit(instance, event) {
+ export function emit(instance, event, ...args) {
  console.log('emit', event);
  const { props } = instance;

  const capitalize = (str: string) => {
    return str.charAt(0).toUpperCase() + str.slice(1);
  };

  const toHandlerKey = (str: string) => {
    return str ? 'on' + capitalize(str) : '';
  };

  const handlerKey = toHandlerKey(event);
  const handler = props[handlerKey];
- handler && handler();
+ handler && handler(...args);
}

然后修改一下父子组件,子组件emit的时候传入两个参数,父组件将接收到这两个参数并输出

export const Foo = {
  setup(props, { emit }) {
    const emitAdd = () => {
      console.log('emit add');
- 		emit('add');
+     emit('add', 1, 2);
    };

    return {
      emitAdd,
    };
  },
  render() {
    return h(
      'button',
      {
        onClick: this.emitAdd,
      },
      'add'
    );
  },
};
export const App = {
  setup() {
    return {};
  },
  render() {
-   const onAdd = () => {
-     console.log('onAdd');
-   };
+   const onAdd = (a, b) => {
+     console.log('onAdd', a, b);
+   };

    return h('div', {}, [h(Foo, { onAdd })]);
  },
};

现在参数传递的功能也实现了 image.png


7. 支持短横线分隔事件名

当事件名由多个单词组成的时候,有的人喜欢用短横线分割的命名方式,比如想要触发一个addValue事件,那么用户可能会给emit传入add-value而不是addValue 由于js的变量命名不支持短横线,所以父组件中相应的回调处理函数一般都是onAddValue这样的驼峰式命名的 如果用户emit的时候是驼峰式命名,那么父组件是能够正常处理的,但是如果是短横线分隔命名的话,那么经过toHandlerKey函数转化后的事件名就是onAdd-value,父组件中显然是不存在这样的回调函数的,那么我们就需要做一些额外处理,将短横线分隔命名转成驼峰命名

我们先修改一下父子组件,在子组件中新增一个emit('add-value')广播add-value事件给父组件 父组件中新增一个onAddValue回调去处理add-value事件

export const Foo = {
  setup(props, { emit }) {
    const emitAdd = () => {
      console.log('emit add');
      emit('add', 1, 2);
+     emit('add-value');
    };

    return {
      emitAdd,
    };
  },
  render() {
    return h(
      'button',
      {
        onClick: this.emitAdd,
      },
      'add'
    );
  },
};
export const App = {
  setup() {
    return {};
  },
  render() {
    const onAdd = (a, b) => {
      console.log('onAdd', a, b);
    };
+   const onAddValue = () => {
+     console.log('onAddValue');
+   };

- 	return h('div', {}, [h(Foo, { onAdd })]);
+   return h('div', {}, [h(Foo, { onAdd, onAddValue })]);
  },
};

image.png 不出所料,并没有触发onAddValue回调,接下来我们就去处理一下短横线分隔命名

export function emit(instance, event, ...args) {
  console.log('emit', event);
  const { props } = instance;

  const capitalize = (str: string) => {
    return str.charAt(0).toUpperCase() + str.slice(1);
  };

+ const camelize = (str: string) => {
+   return str.replace(/-(\w)/g, (_, s: string) => {
+     return s ? s.toUpperCase() : '';
+   });
+ };

  const toHandlerKey = (str: string) => {
-   return str ? 'on' + capitalize(str) : '';
+   return str ? 'on' + capitalize(camelize(str)) : '';
  };

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

image.png 现在就真的是可以处理任何名字的事件了