持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情
本篇文章会带你实现组件的emit功能,能够在子组件中发出自定义事件,并在父组件中由相应的onXxx的props接收并处理,还支持给自定义事件传递参数,并且支持短横线分隔的自定义事件名
1. 需求分析
- 组件
setup方法能够接收第二个参数,是一个对象,其中有emit属性 emit是一个方法,传入事件名xxx就可以将对应的事件广播出去- 父组件中如果有在调用子组件时,在相应的
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
根据以上的需求和案例场景,我们可以总结一下我们接下来要做什么事情
- 要让
setup能够接收第二个参数对象,对象中有一个emit方法 emit方法能够接收一个参数,即要广播的事件名- 到
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的,第二条则是来自emit的
现在
todo的前两项我们就完成啦,接下来完成第三项
5. 在 props 中寻找相应的 onXxx 方法执行
5.1 利用bind给emit绑定组件实例参数
既然要用到props,那么我们就应该在emit函数中能够接收到props,props是在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回调被执行了
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 })]);
},
};
现在参数传递的功能也实现了
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 })]);
},
};
不出所料,并没有触发
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);
}
现在就真的是可以处理任何名字的事件了