Ant Design Drawer + Autofit.js 布局抽动问题

0 阅读10分钟

现象

在社区看到了这样一个问题:Drawer组件设置了getContainer并且项目中使用了autofit.js,在打开时会出现动画和整体页面布局抽动的问题。

542617471-fc201930-0c07-4190-acc8-65af565634f7.gif

问题连接:github.com/ant-design/…

根据描述,我本地复现了一下,发现确实有这个问题,当快速点击的时候,抽屉看起来把页面顶上去了 但是很快又恢复正常,现象如下:

  • 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,其内部的useFocusable Hook(焦点管理钩子)。
  • 发现:useFocusable Hook 在 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 内最近的那个输入框。

image.png

那为什么既需要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 事件中移除监听器。

总结一下链路

  1. 用户操作: 点击按钮打开 Drawer。

  2. AntD: 接收 open={true},将配置传递给 RC-Drawer

  3. RC-Drawer (Drawer.tsx) :

    • useLayoutEffect 记录当前焦点位置(比如那个按钮)到 lastActiveRef
  4. RC-Drawer (DrawerPopup.tsx) :

  5. RC-Drawer (hooks/useFocusable.ts) :

    • useEffect 检测到 open,执行 container.focus() (初始聚焦)。
    • 调用 rc-util 的 useLockFocus
  6. RC-Util (focus.js) :

    • lockFocus 启动。
    • onWindowKeyDown 拦截 Tab 键,确保焦点在 Drawer 内部循环。
    • syncFocus 确保焦点不逃逸。
  7. 用户关闭 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 滚动),所以浏览器通常会忽略或静默处理这种聚焦请求引发的滚动
  • Autofit 场景 (With Autofit)

    • Autofit 为了做全屏适配,给 <body> 或根容器如 #app 加了一个 transform: scale(...)

    • 核心物理规则变化:根据 CSS 规范,任何设置了 transform 属性非 none 的祖先元素,都会成为其内部 position: fixed 后代元素的包含块

    • 后果

      1. Drawer 的 position: fixed 失效了。虽然它 CSS 还写着 fixed,但它在渲染引擎眼里变成了 position: absolute(相对于被 transform 的那个父容器)。
      2. 当 Drawer 刚打开时(动画第0帧),它位于容器底部(例如 translateY(100%))。
      3. rc-util 执行 focus()
      4. 浏览器现在的逻辑是:“哦,这是一个在容器底部的绝对定位元素,用户想看它,但它现在在可视区域下面。那我必须滚动父容器把这个元素挪进视野里。”
      5. BOOM:页面发生了剧烈滚动。

我试了一下,在容器上加transform:scale(1.0);还真就出现问题了,不过原因是不是上面说的那样,浏览器视口 (Viewport) 不用滚动就不知道了。

2. 为什么很快就正常了?为什么快速点击由于?(时序与竞态分析)

动画结束了......translateY(100%)--------->translateY(0%)

结束

好了,问题解决了,但一般还真想不到,看似是布局引起的问题,最后竟然是焦点管理引起的。。。。。