PopupInner源码分析 – ant-design-vue系列
1 综述
上一篇讲解了vc-align
的工作原理,也就是对齐是如何完成的。这一篇主要讲述包裹 Align
的组件:PopupInner
组件是如何工作的。
PopupInner
主要是对动画状态的管理,比如打开弹窗的时候,弹出的动画是从上到下的,关闭的时候正好相反。我们把动画时长改成两秒,进行观察。
**动画的管理,更深层次来说是对元素类名的管理。**接下来需要搞清楚在动画的过程中,状态是如何发生变化的。
2 极简代码
使用vue3
提供的动画组件Transition
来包裹Align
组件,但是没有对Transition
组件设置任何属性,所以暂时还没有动画效果。
return () => (
<Transition>
{props.visible ? (
<Align align={props.align} target={props.target}>
{slots.default?.()}
</Align>
) : null}
</Transition>
);
3 源码实现
3.1 前置知识
组件Transition
:https://cn.vuejs.org/guide/built-ins/transition.html#transition-on-appear
3.2 PopupInner
组件定义的动画状态
源码地址:https://github.com/vueComponent/ant-design-vue/blob/main/components/vc-trigger/Popup/useVisibleStatus.ts
在源码中有这样一个hook
,监控visible
的变化,并且提供了当前状态和修改状态的函数。doMeasure
是另一个hook
提供的函数,目的是得到source
节点的宽/高度。
// ======================== Status ========================
const [status, goNextStatus] = useVisibleStatus(visible, doMeasure);
在useVisibleStatus
函数中,注释如下:
/**
* 每个组件正确工作的步骤如下,弹出窗口应遵循这个流程。
* measure - 检查拉伸尺寸值
* align - 让组件对齐位置
* aligned - 再次重新对齐,以防止其他className更改了大小(这一步后续再解释)
* afterAlign - 选择下一步是触发运动还是结束
* beforeMotion - 应将motion重置为invisible,以便CSSMotion可以进行正常运动
* motion - 执行动作
* stable 一切结束
*/
对源码进行拆解:
- 整体框架:
type PopupStatus = null | 'measure' | 'align' | 'aligned' | 'motion' | 'stable';
type Func = () => void;
const StatusQueue: PopupStatus[] = ['measure', 'align', null, 'motion'];
export default (
visible: Ref<boolean>,
doMeasure: Func,
): [Ref<PopupStatus>, (callback?: () => void) => void] => {
// 弹出状态
const status = ref<PopupStatus>(null);
// raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback);
// rafRef 就是requestAnimationFrame执行器的引用,这个hook不做拆解了。
const rafRef = ref<number>();
// 标记组件的销毁状态
const destroyRef = ref(false);
const goNextStatus = () => {
// ......
}
onMounted(() => {
// ......
})
return [status, goNextStatus];
}
🎯 状态驱动如图所示:
- 监控visible的变化
当visible
变化的时候,重新从measure
状态开始流转。
watch(
visible,
() => {
setStatus('measure');
},
{ immediate: true, flush: 'post' },
);
- 监听status的变化
在组件挂载时,监控了status
的变化。
如果当前是measure
,调用传入的doMeasure
,测量target
的大小。接下来会设置新状态:
- 如果
status === 'measure'
,则执行setStatus('align')
- 如果
status === 'align'
,这里因为要多次定位,所以nextStatus
为空,也就是无法自动进入下个状态 - 如果
status === 'motion'
,nextStatus
同样为空,无法自动进入下个状态
所以,当popup
处于align
和motion
状态时,都需要等待动作完成,由外部调用goNextStatus
来进入下个状态。
onMounted(() => {
// Go next status
watch(
status,
() => {
switch (status.value) {
case 'measure':
doMeasure();
break;
default:
}
if (status.value) {
rafRef.value = raf(async () => {
const index = StatusQueue.indexOf(status.value);
const nextStatus = StatusQueue[index + 1];
if (nextStatus && index !== -1) {
setStatus(nextStatus);
}
});
}
},
{ immediate: true, flush: 'post' },
);
});
function setStatus(nextStatus: PopupStatus) {
if (!destroyRef.value) {
status.value = nextStatus;
}
}
- goNextStatus 函数
可以看到,正好弥补了上一个watch
不能自动触发的状态变更。
function goNextStatus(callback?: () => void) {
// 取消原先的动作,注册新的。目的是为了动画的流程。
cancelRaf();
rafRef.value = raf(() => {
// Only align should be manually trigger
let newStatus = status.value;
switch (status.value) {
case 'align':
newStatus = 'motion';
break;
case 'motion':
newStatus = 'stable';
break;
default:
}
setStatus(newStatus);
callback?.();
});
}
- 其他部分
都是一些清理函数。
onBeforeUnmount(() => {
destroyRef.value = true;
cancelRaf();
});
function cancelRaf() {
raf.cancel(rafRef.value);
}
3.3 核心变量
先看一下渲染函数,最重要的就是transitionProps
和mergedStyle
,其他变量暂时不管。
return (
<Transition
// ......
{...transitionProps}
v-slots={{
default: () => {
return !destroyPopupOnHide || props.visible ? (
<Align
// ......
v-slots={{
default: () => (
<div
// ......
style={mergedStyle}
>
{childNode}
</div>
),
}}
></Align>
) : null;
},
}}
></Transition>
);
对代码进行debug
,观察这两个变量在动画状态过程中的变化。可以看到,在第一次measure
过程结束后:
const transitionProps = {
name: 'ant-slide-up',
appear: true,
enterFromClass: 'ant-slide-up-enter ant-slide-up-enter-prepare',
enterActiveClass: 'ant-slide-up-enter ant-slide-up-enter-prepare',
enterToClass: 'ant-slide-up-enter ant-slide-up-enter-active',
leaveFromClass: ' ant-slide-up-leave',
leaveActiveClass: 'ant-slide-up-leave ant-slide-up-leave-active',
leaveToClass: 'ant-slide-up-leave ant-slide-up-leave-active'
};
const mergedStyle = [{ minWidth: '76px', opacity: 0, pointerEvents: 'none' }, null];
// 后续opacity会变成null,弹窗逐步显现。从源码中可以清晰的看到
opacity: statusValue === 'motion' || statusValue === 'stable' || !visible.value ? null : 0,
pointerEvents: !visible.value && statusValue !== 'stable' ? 'none' : null,
这个就是动画的核心了,其他代码都是为这个服务的。比如对齐popup
的位置;控制内部status
状态,进而让动画在对齐完成后再开始。
动画的源码:https://github.com/vueComponent/ant-design-vue/blob/main/components/style/core/motion/slide.less
3.4 代码详解
由于代码逻辑都穿插在一起,所以先把所有逻辑分开看一下,最后在用一个流程进行串联。
遵照源码对代码的分块。
3.4.1 Measure
// ======================= Measure ========================
/**
* 如果 stretch 是width,那么stretchStyle返回元素的 width;如果stretch 是minWidth,那么stretchStyle返回元素的 minWidth
* height也是一样的。
*
* 开始时 width = height = 0px
* 只有调用measureStretchStyle才能获得目标元素的属性
*/
const [stretchStyle, measureStretchStyle] = useStretchStyle(toRef(props, 'stretch'));
const doMeasure = () => {
if (props.stretch) {
measureStretchStyle(props.getRootDomNode());
}
};
const visible = ref(false);
let timeoutId: any;
watch(
() => props.visible,
val => {
clearTimeout(timeoutId);
if (val) {
/**
* 如果visible变成true,那么延迟变更
*/
timeoutId = setTimeout(() => {
visible.value = props.visible;
});
} else {
visible.value = false;
}
},
{ immediate: true },
);
3.4.2 Aligns
// ======================== Aligns ========================
/**
* 解释见下文
*/
const prepareResolveRef = ref<(value?: unknown) => void>();
/**
* Align组件的target可以接受函数或者对象,获取的时候也要区分。
*/
const getAlignTarget = () => {
if (props.point) {
return props.point;
}
return props.getRootDomNode;
};
/**
* 调用Align组件的对齐方法
*/
const forceAlign = () => {
alignRef.value?.forceAlign();
};
/**
* popupDomNode是弹窗节点,matchAlign是对齐的结果reuslt
* 使用align-dom对齐,会有自适应视口的位置调整,因此result并不一定是是设置的位置
*/
const onInternalAlign = (popupDomNode: HTMLElement, matchAlign: AlignType) => {
/**
* 根据对齐结果获取类名,比如默认的左上角对齐,类名是 ant-dropdown-placement-bottomLeft
*/
const nextAlignedClassName = props.getClassNameFromAlign(matchAlign);
const preAlignedClassName = alignedClassName.value;
if (alignedClassName.value !== nextAlignedClassName) {
alignedClassName.value = nextAlignedClassName;
}
/**
* 如果当前状态是align,并且新的类名和旧的不同(也就是对齐方式不同),就重新执行对齐方法,拿到新的对齐结果再比较
* 直到对齐无误后,status进入下个阶段motion,同时放开动画,进入动画的下个阶段
*/
if (status.value === 'align') {
// Repeat until not more align needed
if (preAlignedClassName !== nextAlignedClassName) {
Promise.resolve().then(() => {
forceAlign();
});
} else {
goNextStatus(() => {
prepareResolveRef.value?.();
});
}
props.onAlign?.(popupDomNode, matchAlign);
}
};
prepareResolveRef
是一个Promise
的resolve
方法,执行这个方法,可以让Promise
结束。这个promise
的定义在Motion
模块。
/**
* 从名字可以看出,这个是动画开始前的一个阶段。
*/
const onShowPrepare = () => {
return new Promise(resolve => {
prepareResolveRef.value = resolve;
});
};
使用的地方在Transition
组件,也就是说,onBeforeEnter
要想结束,需要等到prepareResolveRef.value?.()
的执行。
<Transition
// ......
onBeforeEnter={onShowPrepare}
</Transition>
onInternalAlign
在Align
组件中,有以下这句代码。执行的就是onInternalAlign
这个函数,传入source
和result
if (latestOnAlign && result) {
latestOnAlign(source, result);
}
3.4.3 Motion
// ======================== Motion ========================
const motion = computed(() => {
/**
* 如果设置了动画,就使用设置好的,否则使用默认的。也就是3.3 展示的变量
*/
const m = typeof props.animation === 'object' ? props.animation : getMotion(props as any);
['onAfterEnter', 'onAfterLeave'].forEach(eventName => {
/**
* 拦截了两个状态
* 如果动画状态是onAfterEnter或者onAfterLeave,也就是打开和关闭的结束时间点。
* 就手动把status状态设置为stable,同时执行原动画逻辑。
*/
const originFn = m[eventName];
m[eventName] = node => {
goNextStatus();
// 结束后,强制 stable
status.value = 'stable';
originFn?.(node);
};
});
return m;
});
const onShowPrepare = () => {
return new Promise(resolve => {
prepareResolveRef.value = resolve;
});
};
/**
* 如果不需要动画,且status是动画状态,则直接进入下个阶段stable
*/
watch(
[motion, status],
() => {
if (!motion.value && status.value === 'motion') {
goNextStatus();
}
},
{ immediate: true },
);
expose({
forceAlign,
getElement: () => {
return (elementRef.value as any).$el || elementRef.value;
},
});
const alignDisabled = computed(() => {
if ((props.align as any)?.points && (status.value === 'align' || status.value === 'stable')) {
return false;
}
return true;
});
3.5 主流程讲解
Align
组件使用了v-show
属性,可以应用Transition
的动画效果。由visible
变量控制。- 当
visible
变成true
时,触发了useVisibleStatus
这个hook
,status
状态开始流转。 - 在
useVisibleStatus
这个hook
中,status被设置成了measure
- 还是在这个hook中,因为
status
被watch
监听,因此触发了回调,执行了doMeasure
方法,算出popup
的样式,同时status
被推到align
- 此时再次触发
useVisibleStatus
这个hook
中status
的监听,状态被推到了null
,也就是这个hook
暂时不能控制状态了。 - 因为
doMeasure
方法的执行,source
的大小发生了变化,对齐方法forceAlign
开始执行(详情请看上一篇)。 - 每次
forceAlign
执行的末尾,都会执行latestOnAlign(source, result);
对应的就是PopupInner
中的onInternalAlign
方法。 - 这个方法中,会多次调用
forceAlign()
,直到source
被准确定位(排除各种视口变化对位置的影响),以便动画的位置没有错误。 - 当位置稳定后,
onInternalAlign
会手动将状态推进到下一步motion
。同时执行prepareResolveRef.value?.()
,让css
动画也进入下一个阶段。 - 如果动画执行完成,通过劫持动画的
onAfterEnter
阶段,推进状态到stable
,到此一次动画结束。
4 总结
本篇介绍了PopupInner
的源码实现,由于状态变化的复杂,所以只要理解流程即可,在实际开发中,我们的对齐定位大多不会如此复杂。如果只允许超着一个方向对齐,那么只要为Transtion
设置类名即可。