ant-design Affix 固钉组件:将页面元素钉在可视范围的完整实现解析
本文基于 ant-design 官方文档 components/affix/index.zh-CN.md 及其组件源码展开,完整覆盖 Affix 固钉组件的使用场景、全部 API 参数与官方示例,并深入到 components/affix/index.tsx 与 components/affix/utils.ts 的测量算法、事件监听策略与帧率节流机制,帮助你在侧边栏菜单、悬浮按钮等长页面场景中正确、高性能地使用 Affix。
一、何时使用 Affix
官方文档给出的使用场景非常明确(见 Affix 中文文档):
- 长内容页面:当内容区域比较长、需要滚动页面时,这部分内容对应的操作或导航需要在滚动范围内始终展现,例如侧边菜单与按钮的组合;
- 慎用提示:页面可视范围过小时,慎用此功能,以免出现遮挡页面内容的情况。
文档同时给出一条重要的开发者注意事项:
自
5.10.0起,由于 Affix 组件由 class 重构为 FC(函数组件),之前获取ref并调用内部实例方法的写法都会失效。
从当前仓库源码可以印证这一重构的落地方式:components/affix/index.tsx 中的 Affix 已是一个 React.forwardRef 包裹的函数组件,并通过 useImperativeHandle 向外暴露新的受控接口(见下文“ref 与实例方法”一节)。
二、官方示例速览
2.1 基本用法:offsetTop / offsetBottom
官方 demo basic.tsx 演示了顶部固钉与底部固钉两种方式,并且通过点击按钮动态调整偏移量:
import React from 'react';
import { Affix, Button } from 'antd';
const App: React.FC = () => {
const [top, setTop] = React.useState<number>(100);
const [bottom, setBottom] = React.useState<number>(100);
return (
<>
<Affix offsetTop={top}>
<Button type="primary" onClick={() => setTop(top + 10)}>
Affix top
</Button>
</Affix>
<br />
<Affix offsetBottom={bottom}>
<Button type="primary" onClick={() => setBottom(bottom + 10)}>
Affix bottom
</Button>
</Affix>
</>
);
};
export default App;
可以看到,offsetTop 与 offsetBottom 是互斥的两种固钉方向:前者在“距离窗口顶部达到指定偏移量后触发”,后者在“距离窗口底部达到指定偏移量后触发”。
2.2 固定状态改变的回调:onChange
demo on-change.tsx 展示如何在固钉状态切换时收到通知:
<Affix offsetTop={120} onChange={(affixed) => console.log(affixed)}>
<Button>120px to affix top</Button>
</Affix>
onChange 的参数 affixed 为布尔值,表示元素当前是否处于固钉状态。从源码 index.tsx 可以看到它的触发时机:每次测量后,仅当新的固钉状态与上一次的 lastAffix 不同才会调用,即状态未变化时不会重复触发:
newState.lastAffix = !!newState.affixStyle;
if (lastAffix !== newState.lastAffix) {
onChange?.(newState.lastAffix);
}
2.3 自定义滚动容器:target
demo target.tsx 展示了 Affix 在非 window 的滚动容器内固钉的用法。核心是两个要点:容器需要 overflow: auto,以及把容器元素通过函数传给 target:
import React from 'react';
import { Affix, Button } from 'antd';
const containerStyle: React.CSSProperties = {
width: '100%',
height: 100,
overflow: 'auto',
boxShadow: '0 0 0 1px #1677ff',
scrollbarWidth: 'thin',
scrollbarGutter: 'stable',
};
const style: React.CSSProperties = {
width: '100%',
height: 1000,
};
const App: React.FC = () => {
const [container, setContainer] = React.useState<HTMLDivElement | null>(null);
return (
<div style={containerStyle} ref={setContainer}>
<div style={style}>
<Affix target={() => container}>
<Button type="primary">Fixed at the top of container</Button>
</Affix>
</div>
</div>
);
};
export default App;
注意 target 的签名是 () => Window | HTMLElement | null——它是一个返回 DOM 元素的函数而非元素本身。这种设计的动机在源码中有明确注释(index.tsx):由于 target 是函数形式,组件无法在挂载时同步校验元素是否已存在,因此挂载时会通过一个 setTimeout 延迟绑定监听,以“等待父组件 ref 有值”。
2.4 观察容器尺寸变化
文档还提供了一个标记为 debug 的示例 debug.tsx:调整浏览器大小,观察 Affix 容器是否发生变化,跟随变化为正常行为(对应 issue #17678 的讨论)。这一现象的根源是组件用 ResizeObserver 监听了外层占位节点与内容节点的尺寸,详见后文原理部分。
三、完整 API 参考
通用属性参考:通用属性。
| 参数 | 说明 | 类型 | 默认值 | 版本 | 全局配置 |
|---|---|---|---|---|---|
| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | - | × | |
| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | 0 | × | |
| target | 设置 Affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 |
() => Window | HTMLElement | null | () => window | × | |
| onChange | 固定状态改变时触发的回调函数 | (affixed?: boolean) => void | - | × |
注意:Affix 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 Affix 为绝对定位:
<Affix style={{ position: 'absolute', top: y, left: x }}>...</Affix>
结合源码可以补充两条理解参数的细节:
-
offsetTop的默认值在实现层面是这样处理的(index.tsx):const internalOffsetTop = offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop;即当两个偏移量都未提供时,
offsetTop按0处理,等价于“顶部一越过视口就固钉”;只要显式提供了offsetBottom,就只走底部固钉逻辑。 -
target的回退链(index.tsx):const targetFunc = target ?? getTargetContainer ?? getDefaultTarget;优先级为:显式传入的
target函数 →ConfigProvider的getTargetContainer→ 默认的window。也就是说在ConfigProvider中配置了getTargetContainer后,未传target的 Affix 会自动监听该容器。
四、工作原理:源码级解析
4.1 双层 DOM 结构:占位节点 + fixed 节点
Affix 固钉的本质不是 position: sticky,而是手动测量 + position: fixed 重定位。从 index.tsx 的渲染结构可以看到:
<ResizeObserver onResize={updatePosition}>
<div style={{ ...contextStyle, ...style }} className={...} ref={placeholderNodeRef} {...restProps}>
{affixStyle && <div style={placeholderStyle} aria-hidden="true" />}
<div className={mergedCls} ref={fixedNodeRef} style={affixStyle}>
<ResizeObserver onResize={updatePosition}>{children}</ResizeObserver>
</div>
</div>
</ResizeObserver>
- 外层
div是占位节点(placeholder),始终留在文档流中,保证固钉前后页面布局不塌陷; - 内层
div是真正承载子元素的 fixed 节点,固钉时被赋予position: fixed以及计算出的top/bottom/width/height; - 当处于固钉状态时(
affixStyle存在),占位节点内部还会渲染一个与固钉节点同尺寸的隐藏div(aria-hidden="true")来占位。
这一结构正是官方 FAQ 第二条的解释依据:固定后元素脱离了文档流,其 left 等水平位置不再由容器决定,因此水平滚动容器中 left 可能不正确。
4.2 触发事件与测量时机
组件会在目标元素上绑定以下事件(index.tsx):
const TRIGGER_EVENTS: (keyof WindowEventMap)[] = [
'resize', 'scroll', 'touchstart', 'touchmove', 'touchend', 'pageshow', 'load',
];
即窗口/容器的缩放、滚动、触摸交互、页面重新显示都可能触发一次位置重新计算。同时,两个 ResizeObserver(分别包裹外层占位节点和子内容节点)确保浏览器窗口尺寸或子内容尺寸变化时也会触发 updatePosition——这就是 2.4 节 debug 示例中“浏览器缩放时容器跟随变化”的来源。
所有测量入口都经过 throttleByAnimationFrame 做帧率节流:同一帧内多次事件只会执行一次 measure,避免滚动高频事件造成重复计算。
4.3 核心判定算法:getFixedTop / getFixedBottom
几何判定集中在 utils.ts:
export function getTargetRect(target: BindElement): DOMRect {
return target !== window
? (target as HTMLElement).getBoundingClientRect()
: ({ top: 0, bottom: window.innerHeight } as DOMRect);
}
export function getFixedTop(placeholderRect: DOMRect, targetRect: DOMRect, offsetTop?: number) {
if (
offsetTop !== undefined &&
Math.round(targetRect.top) > Math.round(placeholderRect.top) - offsetTop
) {
return offsetTop + targetRect.top;
}
return undefined;
}
export function getFixedBottom(placeholderRect: DOMRect, targetRect: DOMRect, offsetBottom?: number) {
if (
offsetBottom !== undefined &&
Math.round(targetRect.bottom) < Math.round(placeholderRect.bottom) + offsetBottom
) {
const targetBottomOffset = window.innerHeight - targetRect.bottom;
return offsetBottom + targetBottomOffset;
}
return undefined;
}
可以读出三点实现细节:
getTargetRect对window与HTMLElement做了统一抽象:window 的视口矩形被视为{ top: 0, bottom: window.innerHeight },因此“默认监听 window”与“监听任意容器”可以走同一套算法;- 判定中使用
Math.round比较,是为了规避亚像素(sub-pixel)滚动值带来的抖动,防止固钉状态在临界点反复横跳; - 固钉时返回的
top/bottom值是相对 target 视口的(offsetTop + targetRect.top),所以即使容器本身在页面中有偏移,fixed 元素也能贴在容器视口的正确位置。
回到 index.tsx 的 measure 函数,判定结果直接映射为内层 fixed 节点的内联样式,并同时给占位节点写入同尺寸样式:
const targetRect = getTargetRect(targetNode);
const fixedTop = getFixedTop(placeholderRect, targetRect, internalOffsetTop);
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom);
if (fixedTop !== undefined) {
newState.affixStyle = { position: 'fixed', top: fixedTop, width: placeholderRect.width, height: placeholderRect.height };
...
} else if (fixedBottom !== undefined) {
newState.affixStyle = { position: 'fixed', bottom: fixedBottom, width: placeholderRect.width, height: placeholderRect.height };
...
}
注意 getFixedTop 与 getFixedBottom 的优先级:顶部判定在前,当两者同时满足(例如可视区域很小、内容被上下夹住)时会优先生效 offsetTop 分支。
4.4 滚动热路径上的惰性检查:lazyUpdatePosition
事件回调实际绑定的是 lazyUpdatePosition 而非直接的 prepareMeasure(index.tsx)。它的优化思路是:在已经固钉(affixStyle 存在)时,先做廉价的“快速比较”——重新算一遍 fixedTop / fixedBottom,如果与当前样式中的值完全一致就直接 return,跳过完整的 measure 流程;只有位置真的可能变化时才进入完整测量。源码注释也说明了动机:Check position change before measure to make Safari smooth,即在滚动热路径上尽量减少不必要的重排计算。
4.5 ref 与实例方法
与 5.10.0 的 class→FC 重构对应,当前通过 forwardRef 暴露的实例接口只有 updatePosition(index.tsx、L233):
export interface AffixRef {
updatePosition: ReturnType<typeof throttleByAnimationFrame>;
}
React.useImperativeHandle(ref, () => ({ updatePosition }));
当你在 Affix 外部发生了布局变化(例如手动修改了容器结构、异步数据到达改变了内容高度)而 ResizeObserver 未覆盖到时,可以通过 ref 主动调用 updatePosition() 触发一次重新测量。旧版 class 组件上暴露的内部实例方法在这一版本中已不可用。
4.6 样式类与全局定制
组件渲染时通过 useStyle(components/affix/style/index.ts)注入 cssinjs 样式,根节点类名为 ant-affix,并合并 prefixCls、className、rootClassName 与 ConfigProvider 下发的 contextStyle / className(index.tsx)。因此你可以用 ConfigProvider 的组件级配置统一调整 Affix 的类名与前缀,但 offsetTop、offsetBottom、target、onChange 这四个行为参数不支持全局配置(见上表 API 的“×”标记)。
五、FAQ:两个典型陷阱
以下两条 FAQ 完整继承自官方文档(index.zh-CN.md)。
5.1 使用 target 绑定容器时,元素会跑到容器外
从性能角度考虑,Affix 只监听容器自身的滚动事件(addListeners 把事件绑在 targetFunc() 返回的元素上,见 index.tsx)。当页面实际的滚动发生在 window 或其他祖先元素上时,Affix 收不到对应的 scroll 通知,元素位置就可能“漂”出容器。如果你需要支持任意滚动来源,可以自行在窗体上添加滚动监听,并在回调中调用 ref 的 updatePosition 兜底。
相关 issue:#3938、#5642、#16120。
5.2 水平滚动容器中使用时,元素 left 位置不正确
Affix 一般只适用于单向(垂直)滚动的区域,只支持在垂直滚动容器中使用。如前文 4.1 节所述,固钉后的元素处于 position: fixed 状态,其水平位置不再跟随水平滚动容器平移。如果确实希望在水平容器中使用,可以考虑改用原生 position: sticky 实现。
相关 issue:#29108。
六、测试如何验证固钉行为
组件行为在 Affix.test.tsx 中有较完整的覆盖,值得关注的验证方式:
- 测试通过 mock
HTMLElement.prototype.getBoundingClientRect返回受控的矩形数据(L46-L54),再用movePlaceholder(top)模拟滚动,验证.ant-affix类名在越过临界点时出现/消失(L78-L90)——这正是 4.3 节判定算法的行为级验证; support offsetBottom用例(L97-L110)验证了底部固钉在 placeholder 滚出视口底部时正确触发;updatePosition when offsetTop changed等用例验证了属性变化会触发重测,以及target={() => null}时组件不会崩溃(L92-L95)。
如果你需要对 Affix 做二次开发或排查行为,这份测试文件是很好的“可运行规格说明”。
七、相关文件索引
| 文件 | 说明 |
|---|---|
| components/affix/index.zh-CN.md | 官方中文文档(本文主骨架) |
| components/affix/index.tsx | 组件主实现:状态管理、事件绑定、双层 DOM 结构 |
| components/affix/utils.ts | getTargetRect / getFixedTop / getFixedBottom 几何判定 |
| components/affix/style/index.ts | cssinjs 样式定义 |
| components/affix/demo/basic.tsx、target.tsx、on-change.tsx、debug.tsx | 四个官方示例 |
| components/affix/tests/Affix.test.tsx | 行为测试 |
| components/_util/throttleByAnimationFrame.ts | 帧率节流工具 |
| docs/react/common-props.zh-CN.md | 通用属性说明 |
| components/config-provider/index.zh-CN.md | ConfigProvider 与组件全局配置 |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00