Trigger源码分析 – ant-design-vue系列
1 概述
源码地址: https://github.com/vueComponent/ant-design-vue/blob/main/components/vc-trigger/Trigger.tsx
在源码的实现中,Trigger组件主要有两个作用:
- 使用
Portal
组件,把Popup
组件传送到指定的dom
下,默认是body
。 - 为
target
节点绑定事件,控制事件的触发逻辑。
2 极简实现
为了实现以上功能,我们可以和源码一样,使用vue3
提供的Teleport
组件,来实现节点的传送;同时把所有事件进行透传即可。
在这里trigger
就是我们原先的target
节点,可以翻译成切换器。
setup(props, { slots }) {
const align: any = computed(() => {
const { placement } = props;
return placements[placement];
});
const getComponent = () => {
return (
<Popup
style={{ position: 'absolute' }}
target={() => triggerRef.value!}
align={align.value}
visible={props.visible}
>
{slots.popup?.()}
</Popup>
);
};
const triggerRef = ref<HTMLElement>();
return () => {
// 1 Popup 部分
const portal = <Portal>{getComponent()}</Portal>;
// 2 target部分
const trigger = (
<div style={{ display: 'inline-block' }} ref={triggerRef}>
{slots.default?.()}
</div>
);
return (
<>
{portal}
{trigger}
</>
);
};
}
3 源码分析
3.1 整体结构
这个组件比较特殊,使用了选项式
的写法。
export default defineComponent({
name: 'Trigger',
mixins: [BaseMixin],
inheritAttrs: false, // 用于控制组件的根元素是否应该继承父作用域中的属性(attribute)和事件监听器(listener)。
porps: {},
setup() {}, // 使用props提供的响应式变量,这里是 定位&portal 相关的;并且声明了一些初始值
data() (), // 处理visible变量,为this挂载所有事件,尝试让PopupRef变量指向Portal
watch: (), // 监听visible的变化
created() {}, // 依赖注入,提供vcTriggerContext和PortalContextKey上下文
deactivated() {}, // 组件失活时,关闭popup弹窗
mounted() {}, // 调用updatedCal(),这个函数的作用是在visible为true的时候,注册点击/滚动/失焦的相关事件,以便于在点击popup外部/页面滚动/窗口失焦的时候关闭弹窗;在visible为false时,移除事件监听。
updated() {}, // 组件属性更新后调用updatedCal(),重新注册。
beforeUnmount() {}, // 卸载前清除所有监听器
methods: {}, // 事件的执行、事件是否绑定、获取组件的方法等
render() {} // 渲染trigger和portal
})
3.2 render函数
从const child = children[0];
来看,代码默认使用第一个子节点,所以调用的时候最好只传入一个子节点。
render() {
const { $attrs } = this;
const children = filterEmpty(getSlot(this));
const { alignPoint } = this.$props;
const child = children[0];
this.childOriginEvents = getEvents(child);
const newChildProps: any = {
key: 'trigger',
};
/**
* 这里有各种事件,其他删除,以click为例
*/
if (this.isClickToHide() || this.isClickToShow()) {
newChildProps.onClick = this.onClick;
newChildProps.onMousedown = this.onMousedown;
newChildProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = this.onTouchstart;
} else {
newChildProps.onClick = this.createTwoChains('onClick');
newChildProps.onMousedown = this.createTwoChains('onMousedown');
newChildProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] =
this.createTwoChains('onTouchstart');
}
/**
* 这个函数内部是vue3提供的cloneVNode实现的
*/
const trigger = cloneElement(child, { ...newChildProps, ref: 'triggerRef' }, true, true);
if (this.popPortal) {
return trigger;
} else {
const portal = (
<Portal
key="portal"
v-slots={{ default: this.getComponent }}
getContainer={this.getContainer}
didUpdate={this.handlePortalUpdate}
></Portal>
);
return (
<>
{portal}
{trigger}
</>
);
}
},
-
找到
Trigger
组件包裹的所有非空子节点,取出第一个子节点child
,把child
上注册的事件收集起来,挂到childOriginEvents
属性上。👑 节点为空的判断如下:c.type === Comment || (c.type === Fragment && c.children.length === 0) ||(c.type === Text && c.children.trim() === '')
-
给
child
节点挂上一些新的属性。以click
事件为例,如果action
中包含click
事件,那么调用者就是希望点击的时候触发这个事件:也就是说isClickToHide
或者isClickToShow
为true
,那么直接把传给Trigger
组件的click
事件给child
挂上。isClickToHide
判断如下:/** * action 和 hideAction都是数组,假设action=['click', 'hover'],那么 isClickToHide 就是 true */ isClickToHide() { const { action, hideAction } = this.$props; return action.indexOf('click') !== -1 || hideAction.indexOf('click') !== -1; },
-
如果
isClickToHide
和isClickToShow
都是false
,那么调用this.createTwoChains('onClick')
。这个函数模拟了“事件冒泡”的过程,因为原来的层级节点已经不存在了,但是绑定的事件不能丢失。具体做法是:如果第一个子节点和
Trigger
组件都有click
事件,那么给child
挂上的新属性就是fireclick
,调用的时候会依次触发两个click
事件(如下图);如果不是都有,那么哪个有就执行哪个;如果一个都没有,就执行空函数。代码如下:createTwoChains(event: string) { let fn = () => {}; const events = getEvents(this); if (this.childOriginEvents[event] && events[event]) { return this[`fire${event}`]; } fn = this.childOriginEvents[event] || events[event] || fn; return fn as any; }, fireEvents(type: string, e: Event) { if (this.childOriginEvents[type]) { this.childOriginEvents[type](e); } const event = this.$props[type] || this.$attrs[type]; if (event) { event(e); } },
-
Portal
组件中,container
并不是body
,而是一个div
,这是通过getContainer={this.getContainer}
实现的,看一下这个函数的实现。生成一个新的
div
,设置为absolute
定位,保证popup
不会导致滚动条出现。
getContainer() {
const { $props: props } = this;
const { getDocument } = props;
const popupContainer = getDocument(this.getRootDomNode()).createElement('div');
popupContainer.style.position = 'absolute';
popupContainer.style.top = '0';
popupContainer.style.left = '0';
popupContainer.style.width = '100%';
this.attachParent(popupContainer);
return popupContainer;
},
Portal
组件中,Popup
一定会注册onMousedown
事件,对应以下第一段代码。根据条件会注册onMouseenter
或者onMouseleave
事件,对应以下第二段代码。
/**
* 执行的是vcTriggerContext的方法
*/
onPopupMouseDown(...args: any[]) {
// ......
if (vcTriggerContext.onPopupMouseDown) {
vcTriggerContext.onPopupMouseDown(...args);
}
},
onPopupMouseenter
只清除回调;onPopupMouseleave
会在延迟后关闭弹窗。我们可以调整延迟时间,达到如下效果:如果当鼠标离开后,再次快速进入,那么关闭弹窗的回调就会被取消。
/**
* delayTimer是requestAnimationTimeout的执行器,作用是在delay时间后的requestAnimationFrame中执行回调
* clearDelayTimer 是取消掉回调的执行。
*/
onPopupMouseenter() {
this.clearDelayTimer();
}
/**
* 在延迟后关闭。
* relatedTarget指向与当前事件相关的元素,包括焦点、悬停和其他事件
*/
onPopupMouseleave(e) {
if (
e &&
e.relatedTarget &&
!e.relatedTarget.setTimeout &&
contains(this.popupRef?.getElement(), e.relatedTarget)
) {
return;
}
this.delaySetPopupVisible(false, this.$props.mouseLeaveDelay);
}
/**
* 如果delayS是0,直接修改状态;否则在延迟结束后的requestAnimationFrame中执行回调
*/
delaySetPopupVisible(visible: boolean, delayS: number, event?: any) {
const delay = delayS * 1000;
this.clearDelayTimer();
if (delay) {
const point = event ? { pageX: event.pageX, pageY: event.pageY } : null;
this.delayTimer = requestAnimationTimeout(() => {
this.setPopupVisible(visible, point);
this.clearDelayTimer();
}, delay);
} else {
this.setPopupVisible(visible, event);
}
},
3.3 其他函数
contains
函数:判断一个节点是否是另一个节点的子节点
export default function contains(root: HTMLElement | null | undefined, n?: HTMLElement) {
if (!root) {
return false;
}
return root.contains(n);
}
onClick
函数
onClick(event) {
this.fireEvents('onClick', event);
/**
* 聚焦会触发click事件,如果这两个事件时间不超过20ms,则不触发click事件
* 因为onFocus事件已经把visible修改了,不需要多次修改
*/
if (this.focusTime) {
let preTime;
if (this.preClickTime && this.preTouchTime) {
preTime = Math.min(this.preClickTime, this.preTouchTime);
} else if (this.preClickTime) {
preTime = this.preClickTime;
} else if (this.preTouchTime) {
preTime = this.preTouchTime;
}
if (Math.abs(preTime - this.focusTime) < 20) {
return;
}
this.focusTime = 0;
}
this.preClickTime = 0;
this.preTouchTime = 0;
// Only prevent default when all the action is click.
// https://github.com/ant-design/ant-design/issues/17043
// https://github.com/ant-design/ant-design/issues/17291
if (
this.isClickToShow() &&
(this.isClickToHide() || this.isBlurToHide()) &&
event &&
event.preventDefault
) {
event.preventDefault();
}
if (event && event.domEvent) {
event.domEvent.preventDefault();
}
const nextVisible = !this.$data.sPopupVisible;
if ((this.isClickToHide() && !nextVisible) || (nextVisible && this.isClickToShow())) {
this.setPopupVisible(!this.$data.sPopupVisible, event);
}
}
/**
* focus的时候,会更新focusTime
*/
onFocus(e) {
this.fireEvents('onFocus', e);
// incase focusin and focusout
this.clearDelayTimer();
if (this.isFocusToShow()) {
this.focusTime = Date.now();
this.delaySetPopupVisible(true, this.$props.focusDelay);
}
},
4 Portal组件的实现
源码地址:https://github.com/vueComponent/ant-design-vue/blob/main/components/_util/Portal.tsx
去掉多余的判断,剩下的逻辑就是:挂载时生成容器,卸载时删除容器,更新时执行传入的方法。
setup(props, { slots }) {
// getContainer 不会改变,不用响应式
let container: HTMLElement;
onBeforeMount(() => {
container = props.getContainer();
});
onUpdated(() => {
nextTick(() => {
props.didUpdate?.(props);
});
});
onBeforeUnmount(() => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
});
return () => {
return container ? <Teleport to={container} v-slots={slots}></Teleport> : null;
};
},
5 总结
本篇对Trigger
组件和Portal
组件的核心代码进行分析,剩下的都是事件处理函数,可以自行阅读。需要注意的是visible
相关的处理都进行了延时,防止错误。