背景
作为一名有一年多经验的前端开发者,我一直使用 React 和 Ant Design 构建页面。某天灵光一闪,作为一名有追求的前端工程师,是时候深入研究源码了!于是,我踏上了与 @rc-component/trigger 之间的探索之旅,一场源码解读的较量正式拉开帷幕。
起初,我只是想分析 ToolTip 组件——对,就是我们常用的那个,当鼠标悬停在某个元素上时会弹出的小浮层。看似简单的小东西,但当我深入研究时,却发现 Ant Design 居然在“套娃”——一层又一层的组件,层层嵌套,直接给我整懵了!为了理清思路,我决定跳过表面的复杂性,直接分析 Trigger 组件,这才是真正的核心。
下面是它套娃的整个过程
rc-component/trigger源码探索
第一步,我先将 @rc-component/trigger 包 clone 下来,查看其文件结构。不出所料,index.tsx 是主文件,想着半小时就能搞定,结果……这一看就是三天。
核心代码精简如下
export function generateTrigger() {
PortalComponent: React.ComponentType<any> = Portal,
const Trigger = React.forwardRef<TriggerRef, TriggerProps>((props, ref) => {});
return Trigger;
}
export default generateTrigger(Portal);
可以看到,Trigger 组件通过 generateTrigger() 函数返回,它的作用是创建一个触发器组件。整个文件有 759 行代码,我们接下来就从重点部分开始分析。
调试实例
运行项目的调试 demo:
pnpm start
点击后会弹出浮层,再次点击则消失。示例代码如下:
<Trigger
arrow
action="click"
popupVisible={open1}
onPopupVisibleChange={(next) => setOpen1(next)}
popupTransitionName="rc-trigger-popup-zoom"
popup={
<div style={{ background: 'yellow', border: '1px solid blue', width: 200, height: 60 }}>
<button onClick={() => setOpen1(false)}>Close</button>
</div>
}
popupStyle={{ boxShadow: '0 0 5px red' }}
popupAlign={{
points: ['tc', 'bc'],
overflow: { shiftX: 50, adjustY: true },
htmlRegion: 'scroll',
}}
>
<span style={{ background: 'green', color: '#FFF', padding: '30px 70px' }}>Target Click</span>
</Trigger>
属性分析
码中的几个关键属性——action、popupVisible、onPopupVisibleChange——控制了 Trigger 的行为。猜测 action 负责触发事件类型,popupVisible 控制浮层的显示隐藏,onPopupVisibleChange 则是状态变化的回调函数。
细节分析
const [showActions, hideActions] = useAction(mobile, action, showAction, hideAction);
const clickToShow = showActions.has('click');
const clickToHide = hideActions.has('click') || hideActions.has('contextMenu');
if (clickToShow || clickToHide) {
cloneProps.onClick = (event: React.MouseEvent<HTMLElement>) => {
if (openRef.current && clickToHide) {
triggerOpen(false);
} else if (!openRef.current && clickToShow) {
setMousePosByEvent(event);
triggerOpen(true);
}
};
}
const triggerNode = React.cloneElement(child, { ...mergedChildrenProps, ...passedProps });
功能步骤
- 兼容移动端:因为手机不支持
hover,所以将hover替换为click。 - 判断点击显示/隐藏逻辑:通过
useAction判断click事件是否需要显示或隐藏浮层。 - 绑定点击事件:通过
React.cloneElement劫持子组件,并绑定onClick事件。 - 调试:点击时,触发
triggerOpen,判断是否触发onPopupVisibleChange,进而更新popupVisible。
可以看到,当我点击元素的时候,是debugger住了这里
进入triggerOpen,triggerOpen会调用internalTriggerOpen,internalTriggerOpen会判断数组中最后一个元素是否跟nextopen不同,如果不同才会触发onPopupVisibleChange方法,(这样做的一个好处就是防止用户多次点击出现屏幕闪的情况)。而onPopupVisibleChange就是我们提供给trigger回调。
onPopupVisibleChange回调又会把popupVisible设置为true。
popupVisible改变会触发真正计算位置的hook,将浮层展示出来,下面一部分重点说这部分。
计算位置
绑定的整个过程说完了,说一下浮层元素的整个计算过程,useAlign这个hook主要处理了这件事情。
光这个方法有751行,各种边界条件要去处理。 (所以开源真的是一个为爱发电的过程)接下来我会分步骤进行说明。
if (popupEle && target && open) {
const popupElement = popupEle;
debugger
const doc = popupElement.ownerDocument;
const win = getWin(popupElement);
const {
width,
height,
position: popupPosition,
} = win.getComputedStyle(popupElement);
const originLeft = popupElement.style.left;
const originTop = popupElement.style.top;
const originRight = popupElement.style.right;
const originBottom = popupElement.style.bottom;
const originOverflow = popupElement.style.overflow;
// Placement
const placementInfo: AlignType = {
...builtinPlacements[placement],
...popupAlign,
};
// placeholder element
const placeholderElement = doc.createElement('div');
popupElement.parentElement?.appendChild(placeholderElement);
placeholderElement.style.left = `${popupElement.offsetLeft}px`;
placeholderElement.style.top = `${popupElement.offsetTop}px`;
placeholderElement.style.position = popupPosition;
placeholderElement.style.height = `${popupElement.offsetHeight}px`;
placeholderElement.style.width = `${popupElement.offsetWidth}px`;
// Reset first
popupElement.style.left = '0';
popupElement.style.top = '0';
popupElement.style.right = 'auto';
popupElement.style.bottom = 'auto';
popupElement.style.overflow = 'hidden';
- 判断是否需要进行位置信息的计算
-
- 如果有浮层元素并且有触发元素并且open为true,就可以触发浮层元素的计算。
- 记录了一些浮层元素的值
- 创建了一个占位符,防止由于需要改动浮层元素位置而出现屏幕抖动
- 重置浮层元素
现在是浮层元素所在的位置
let targetRect: Rect;
if (Array.isArray(target)) {
targetRect = {
x: target[0],
y: target[1],
width: 0,
height: 0,
};
} else {
const rect = target.getBoundingClientRect();
rect.x = rect.x ?? rect.left;
rect.y = rect.y ?? rect.top;
targetRect = {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
};
}
const popupRect = popupElement.getBoundingClientRect();
popupRect.x = popupRect.x ?? popupRect.left;
popupRect.y = popupRect.y ?? popupRect.top;
const {
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
scrollTop,
scrollLeft,
} = doc.documentElement;
const popupHeight = popupRect.height;
const popupWidth = popupRect.width;
const targetHeight = targetRect.height;
const targetWidth = targetRect.width;
const targetPoints = splitPoints(targetPoint);
const popupPoints = splitPoints(popupPoint);
function getAlignPoint(rect: Rect, points: Points) {
const topBottom = points[0];
const leftRight = points[1];
let x: number;
let y: number;
// Top & Bottom
if (topBottom === 't') {
y = rect.y;
} else if (topBottom === 'b') {
y = rect.y + rect.height;
} else {
y = rect.y + rect.height / 2;
}
// Left & Right
if (leftRight === 'l') {
x = rect.x;
} else if (leftRight === 'r') {
x = rect.x + rect.width;
} else {
x = rect.x + rect.width / 2;
}
return { x, y };
}
const targetAlignPoint = getAlignPoint(targetRect, targetPoints);
const popupAlignPoint = getAlignPoint(popupRect, popupPoints);
// Real align info may not same as origin one
const nextAlignInfo = {
...placementInfo,
};
// Next Offset
let nextOffsetX = targetAlignPoint.x - popupAlignPoint.x + popupOffsetX;
let nextOffsetY = targetAlignPoint.y - popupAlignPoint.y + popupOffsetY;
const nextOffsetInfo = {
ready: true,
offsetX: nextOffsetX / scaleX,
offsetY: nextOffsetY / scaleY,
offsetR: offsetX4Right / scaleX,
offsetB: offsetY4Bottom / scaleY,
arrowX: nextArrowX / scaleX,
arrowY: nextArrowY / scaleY,
scaleX,
scaleY,
align: nextAlignInfo,
};
setOffsetInfo(nextOffsetInfo);
- 拿到触发器元素的位置信息
- 拿到popupElement元素的位置信息
- 处理targetPoint和popupPoint,这个属性是计算浮层位置信息的。 如demo中这个属性分别是: "bc" "tc" 代表浮层元素的顶部和中间部分与触发器元素的底部和中间想接触(说人话就是箭头的位置 ,我理解)
- getAlignPoint 这个函数很重要,它根据元素位置信息和标志位来计算位置信息,下面我们画图来说明
-
- 如果topBottom === 't' 这就是它的高度
- 如果topBottom === 'b',相当于是top的高度+元素本身heigh的高度
- leftRight === 'l'
- leftRight === 'r'
- 其他情况,计算的是中间的位置
- 如果topBottom === 't' 这就是它的高度
- let nextOffsetX = targetAlignPoint.x - popupAlignPoint.x + popupOffsetX;
let nextOffsetY = targetAlignPoint.y - popupAlignPoint.y + popupOffsetY; 这两行代码其实就算出了浮层元素的x和y的坐标,拿demo 的属性举例,['tc','bc'] ,相当于要把浮层元素的顶部和中间位置与触发器元素的底部和中间位置接触,画图说明
说明需要知道红线的距离和白线的距离,那么按照这个算法来看
其实就是相当于触发器元素的x减去了浮层元素一半的width,所以就可以正常放置了。(x,y生效的位置是浮层元素的左上角)
const nextOffsetInfo = {
ready: true,
offsetX: nextOffsetX / scaleX,
offsetY: nextOffsetY / scaleY,
offsetR: offsetX4Right / scaleX,
offsetB: offsetY4Bottom / scaleY,
arrowX: nextArrowX / scaleX,
arrowY: nextArrowY / scaleY,
scaleX,
scaleY,
align: nextAlignInfo,
};
setOffsetInfo(nextOffsetInfo);
最后将位置信息setOffsetInfo进去,浮层元素就会定位到目标位置了。
说明分析的是对的。
这就是整套的基础流程把~
展示浮层
trigger会将我们定义的popup属性传给Popup, Popup会利用React中的createPortal,将popup挂载到body。
然后会根据传入的位置信息设置浮层的位置
总结
Trigger 组件的核心是利用 useAlign 计算浮层的位置,通过 createPortal 动态挂载到 body 中,同时处理浮层的显示与隐藏逻辑。组件还支持通过边界检测和位置翻转来优化浮层的展示效果。更多细节将在后续进一步分析。
通过这次源码探索,我对 Trigger 组件有了更加深入的理解,也让自己在处理类似交互时拥有了更多的思路和经验。下一步,我会继续探索更复杂的逻辑,并持续分享我的心得。