现象
在社区看到了这样一个问题:Drawer组件设置了getContainer并且项目中使用了autofit.js,在打开时会出现动画和整体页面布局抽动的问题。
根据描述,我本地复现了一下,发现确实有这个问题,当快速点击的时候,抽屉看起来把页面顶上去了 但是很快又恢复正常,现象如下:
- Drawer 的 mask 被 content-wrapper 顶出视野
- 按钮和其他元素被推到屏幕外
- IntersectionObserver 检测到元素不在视野
- 关闭 mask 或移除 Drawer 内容后恢复正常
背景
1.autofit.js
根据官网介绍:
autofit.js 是一个可以让你的PC项目自适应屏幕的工具,其原理非常简单,即在
scale等比缩放的基础上,向右或向下增加了宽度或高度,以达到充满全屏的效果,使用 autofit.js 不会挤压、拉伸元素,它只是单纯的设置了容器的宽高。
Autofit.js 的作用:
它通过 transform: scale(...) 强制缩放根容器以适应屏幕。
关键点: transform 属性会创建一个新的堆叠上下文 (Stacking Context) 和 包含块 (Containing Block)。更重要的是,它改变了浏览器对元素位置(Client Rect)的计算方式。
其实就是监听resize/ 视口尺寸变化,实时重计算页面元素的尺寸 / 位置,效果就是滚轮缩放的时候看起来页面是一致的。
2. Drawer 的 DOM 结构
<div class="rc-drawer rc-drawer-bottom" style="overflow: hidden;">
<!-- mask 先渲染 -->
<div class="rc-drawer-mask" style="position: absolute; inset: 0; z-index: 1050;">
</div>
<!-- content-wrapper 后渲染 -->
<div class="rc-drawer-content-wrapper"
style="position: absolute; bottom: 0; z-index: 1051;">
<!-- 动画进入时添加 transform -->
<div class="panel-motion-bottom-enter"
style="transform: translateY(100%);">
</div>
</div>
</div>
问题排查
Ant Design 的 Drawer 在打开瞬间(open={true}),其 Panel 的入场动画通常是从 translateY(100%) (底部) 或类似位置开始。此时,Drawer 内部的内容实际上位于视口之外或者包含块边缘。
此时,Drawer 内部的内容实际上位于视口之外或者包含块边缘。
这个效果是正常的,在慢速点击的时候,Drawer由下方动画进入,translateY(100%)最后变成了translateY(0%),问题是,快速点击,Drawer由下方进入,此时竟然把页面元素顶上去了。
也就是问题里描述的抽动。
初步定位方向
聚焦冲突核心:Drawer 的动画效果、mask(遮罩)、内部元素与 autofit.js 的缩放逻辑存在交互异常,导致页面布局瞬态偏移(“推挤” 效应)。
链路分析
1. 容器与动画交互
- 疑问:Drawer 默认使用绝对定位(absolute/fixed),为何仍会推挤其他元素?
- 观察:Drawer 的入场动画依赖
transform: translateY(100%)(从底部滑入),且通过getContainer指定了自定义容器,而该容器同时被 autofit.js 应用了transform: scale(等比缩放)。 - 嵌套的 transform 属性可能导致浏览器布局计算异常,尤其动画执行时容器尺寸 / 位置的瞬态变化,被 autofit.js 的
resize监听捕捉,触发不必要的重排。
2. mask 的作用
- 验证:禁用 mask(
mask={false})后问题消失,说明 mask 并非直接原因,而是触发了某种关联逻辑。 - 进一步分析:mask 启用时,Drawer 会激活焦点锁定(Focus Trap)功能;禁用 mask 时,焦点锁定同步失效,推测焦点管理与布局偏移存在关联。
3. 焦点管理机制
- 排查依赖:定位到 antd Drawer 的底层依赖
rc-drawer,其内部的useFocusableHook(焦点管理钩子)。 - 发现:
useFocusableHook 在 Drawer 的open属性变为true时,会执行getContainer()?.focus({ preventScroll: true }),即自动聚焦 Drawer 的容器元素。
一开始直接在仓库里,发现复现不了,现在才发现没有同步远程分支,焦点管理是最近才加上的(捂脸)。
层级一:Ant Design (antd/es/drawer)
Drawer.tsx: 接收open,mask等属性。useFocusable(Antd): 这是一个配置预处理 Hook。它计算出trap默认为true(只要 mask 存在)。- 组件传递: 将处理后的 props 传给
rc-drawer。
层级二:RC-Drawer (@rc-component/drawer)
Drawer.tsx(Wrapper): 渲染 Portal。DrawerPopup.tsx: 实际的 DOM 结构渲染的地方。useFocusable.ts(核心钩子):- 代码:
useLockFocus(open && mergedFocusTrap, getContainer)。 - 时机: 这里的
useLockFocus副作用(Effect)通常在 DOM 挂载和 Layout 之后执行。
- 代码:
这一层焦点管理做两件事:
1. 焦点还原,当 Drawer 关闭时,焦点应该回到打开 Drawer 之前的那个按钮上,否则键盘用户会迷失方向。思路就是记下来在 Drawer 打开前,谁是焦点的拥有者?然后在Drawer 关闭后的回调中,让之前的元素重新获得焦点。代码如下:
// src/Drawer.tsx
const Drawer: React.FC<DrawerProps> = props => {
// ...
// 1. 记录案发地:在 Drawer 打开前,谁是焦点的拥有者?
const lastActiveRef = React.useRef<HTMLElement>(null);
useLayoutEffect(() => {
if (mergedOpen) {
// 记录当前的 activeElement
lastActiveRef.current = document.activeElement as HTMLElement;
}
}, [mergedOpen]);
// 2. 还魂:Drawer 关闭后的回调
const internalAfterOpenChange: DrawerProps['afterOpenChange'] = nextVisible => {
// ...
if (
!nextVisible && // Drawer 关闭了
focusTriggerAfterClose !== false && // 用户没禁用这个功能
lastActiveRef.current // 之前记录过
) {
// 让之前的元素重新获得焦点
lastActiveRef.current?.focus({ preventScroll: true });
}
};
// ...
}
2. 初始聚焦 ,当 Drawer 打开时,焦点必须立刻转移到 Drawer 内部,否则屏幕阅读器用户不知道新内容出现了。但是useLockFocus是干啥的呢?
// src/hooks/useFocusable.ts
export default function useFocusable(
getContainer: () => HTMLElement,
open: boolean,
autoFocus?: boolean,
// ...
) {
// ...
// Focus lock
useLockFocus(open && mergedFocusTrap, getContainer);
// Auto Focus 逻辑
React.useEffect(() => {
// 如果打开,且 autoFocus 为 true(默认是 true)
if (open && autoFocus === true) {
// 强制让 Drawer 的容器获得焦点
getContainer()?.focus({ preventScroll: true });
}
}, [open]);
}
层级三:RC-Util (@rc-component/util)
rc-util 中的 focus.js 实现了 Focus Trap (焦点陷阱) 。它的目的是:把焦点锁死在 Drawer 内部,不让 Tab 键跑到外面的页面去。
useLockFocus里面直接调用了lockFocus,这个函数会在全局添加监听
function lockFocus(element) {
if (element) {
// ... 将当前 element 推入栈中管理(支持多层 Drawer 嵌套)
// 核心:添加全局事件监听
window.addEventListener('focusin', syncFocus); // 监听焦点移动
window.addEventListener('keydown', onWindowKeyDown, true); // 监听键盘按键,使用捕获阶段
syncFocus();
}
}
可以看到这里添加了focusin的事件监听,继续看syncFocus
function syncFocus() {
const lastElement = getLastElement(); // 当前激活的 Drawer 容器
// ...
if (lastElement && !hasFocus(lastElement)) {
// 如果焦点不在 Drawer 内部
// 强制聚焦回 Drawer 内的某个元素
const matchElement = focusableList[0];
matchElement?.focus();
}
}
如果不小心(比如鼠标点击了外部,或者程序代码强行 focus 了外部元素),焦点跑出去了,syncFocus 负责把它抓回来。
低情商:用户手贱点了 Drawer 外面的空白 -> 焦点短暂跑出去 -> focusin 触发 -> syncFocus 发现越界 -> 瞬间把焦点抓回 Drawer 内最近的那个输入框。
那为什么既需要useLockFocus还需要getContainer()?.focus,既然 useLockFocus 里面已经有 syncFocus 试图把焦点拉进来了,为什么还要再手动 focus 一次?
猜测一下,是为了解耦“锁定”与“初始聚焦”,
-
useLockFocus是持续性状态。它负责的是 open 期间的每一秒,监控Tab键和鼠标点击。 -
useEffect是一次性动作。它只在 open 变为true的那一瞬间执行一次。 -
场景支持:如果你设置
autoFocus={false}但 focusTrap={true}。- 期望:打开 Drawer 时,焦点不自动跳进去(比如用户可能还在读之前的文章),但如果用户一旦按了 Tab 键或者想点 Drawer 里的东西,焦点就再也出不去了。
- 如果完全依赖
useLockFocus的初始化逻辑来做聚焦,你就很难实现这种精细的控制。
事件冒泡:
focus和blur事件不冒泡。这意味着当一个元素获得或失去焦点时,只有该元素本身会触发这些事件,其父元素不会收到通知。focusin和focusout事件会冒泡。这意味着当一个元素获得或失去焦点时,该元素本身会触发事件,并且事件会沿着 DOM 树向上传播,触发其祖先元素上的相应事件。事件触发顺序:
当一个元素获得焦点时,事件触发的顺序是:
focusin->focus。当一个元素失去焦点时,事件触发的顺序是:
blur->focusout。使用场景:
由于
focus和blur不冒泡,它们更适用于处理特定元素的焦点变化,例如:
- 表单验证:在
blur事件中检查输入字段的值是否有效。- UI 更新:在
focus事件中高亮输入框,在blur事件中移除高亮。由于
focusin和focusout会冒泡,它们更适用于处理包含多个可聚焦元素的容器的焦点变化,例如:
- 跟踪焦点:监听容器的
focusin和focusout事件,可以知道焦点是否在容器内,而无需监听每个子元素。- 动态添加/移除事件监听器:在容器的
focusin事件中为获得焦点的元素添加事件监听器,在focusout事件中移除监听器。
总结一下链路
-
用户操作: 点击按钮打开 Drawer。
-
AntD: 接收 open={true},将配置传递给 RC-Drawer。
-
RC-Drawer (Drawer.tsx) :
useLayoutEffect记录当前焦点位置(比如那个按钮)到lastActiveRef。
-
RC-Drawer (
DrawerPopup.tsx) :- 渲染 DOM。
- 调用 useFocusable。
-
RC-Drawer (
hooks/useFocusable.ts) :useEffect检测到 open,执行container.focus()(初始聚焦)。- 调用
rc-util的useLockFocus。
-
RC-Util (
focus.js) :lockFocus启动。onWindowKeyDown拦截 Tab 键,确保焦点在 Drawer 内部循环。syncFocus确保焦点不逃逸。
-
用户关闭 Drawer:
- RC-Util: 解除事件监听,释放“结界”。
- RC-Drawer (Drawer.tsx) :
internalAfterOpenChange触发,读取lastActiveRef,执行.focus(),焦点回到最初的按钮。
回到最初的问题
- Autofit 使用了 transform: scale。这不仅缩放了元素,还改变了浏览器对于“将元素滚动到可视区域”的计算逻辑。
- Drawer 初始动画位置通常在
translateY(100%)(即屏幕外或边缘)。 - 当 focus() 发生时,浏览器试图把这个“屏幕边缘”的元素滚动到中心。也就是动画刚开始,聚焦逻辑生效了,把正要进场的抽屉拉到了视野中,把页面元素顶了上去。
因此解决方法就是加一个preventScroll
element.focus({ preventScroll: true });
但是其实还有一些疑问,
1. 为什么只有 Autofit + Drawer 会出问题?(Autofit 到底破坏了什么?)
-
正常场景 (Without Autofit) :
- Drawer 的 CSS 是
position: fixed。 - 在标准 W3C 规范中,
fixed元素的包含块(Containing Block)是 浏览器视口 (Viewport) 。 - 当你对一个刚开始进场、还在屏幕边缘外的
fixed元素调用focus()时,浏览器知道它是“固定”在屏幕上的。即使它是看不见的(off-screen),现代浏览器通常足够智能,或者因为它是相对于视口的,浏览器无法通过滚动<body>来让它显示(因为它根本不随 body 滚动),所以浏览器通常会忽略或静默处理这种聚焦请求引发的滚动。
- Drawer 的 CSS 是
-
Autofit 场景 (With Autofit) :
-
Autofit 为了做全屏适配,给
<body>或根容器如#app加了一个 transform: scale(...)。 -
核心物理规则变化:根据 CSS 规范,任何设置了 transform 属性非
none的祖先元素,都会成为其内部position: fixed后代元素的包含块。 -
后果:
- Drawer 的
position: fixed失效了。虽然它 CSS 还写着 fixed,但它在渲染引擎眼里变成了position: absolute(相对于被 transform 的那个父容器)。 - 当 Drawer 刚打开时(动画第0帧),它位于容器底部(例如
translateY(100%))。 rc-util执行focus()。- 浏览器现在的逻辑是:“哦,这是一个在容器底部的绝对定位元素,用户想看它,但它现在在可视区域下面。那我必须滚动父容器把这个元素挪进视野里。”
- BOOM:页面发生了剧烈滚动。
- Drawer 的
-
我试了一下,在容器上加transform:scale(1.0);还真就出现问题了,不过原因是不是上面说的那样,浏览器视口 (Viewport) 不用滚动就不知道了。
2. 为什么很快就正常了?为什么快速点击由于?(时序与竞态分析)
动画结束了......translateY(100%)--------->translateY(0%)
结束
好了,问题解决了,但一般还真想不到,看似是布局引起的问题,最后竟然是焦点管理引起的。。。。。