From 4548219f8e4e859969df3e4190de98edaa827d29 Mon Sep 17 00:00:00 2001 From: HaixingOoO <974758671@qq.com> Date: Sat, 6 Sep 2025 22:49:07 +0800 Subject: [PATCH 1/4] refactor(affix): refactor affix component --- packages/components/_util/dom.ts | 8 + packages/components/affix/Affix.tsx | 236 ++++++++++-------- .../components/affix/__tests__/affix.test.tsx | 34 ++- .../components/affix/_example/container.tsx | 10 +- packages/components/affix/utils.ts | 14 ++ test/snap/__snapshots__/csr.test.jsx.snap | 28 ++- test/snap/__snapshots__/ssr.test.jsx.snap | 8 +- 7 files changed, 203 insertions(+), 135 deletions(-) create mode 100644 packages/components/affix/utils.ts diff --git a/packages/components/_util/dom.ts b/packages/components/_util/dom.ts index 6339945693..feb2126daf 100644 --- a/packages/components/_util/dom.ts +++ b/packages/components/_util/dom.ts @@ -92,3 +92,11 @@ export function getCurrentPrimaryColor(token: string): string { } return ''; } + +export type BindElement = HTMLElement | Window | null | undefined; + +export function getTargetRect(target: BindElement): DOMRect { + return target !== window + ? (target as HTMLElement).getBoundingClientRect() + : ({ top: 0, bottom: window.innerHeight } as DOMRect); +} diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index 47d7f8de48..d9b9e931be 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -1,11 +1,31 @@ -import React, { useEffect, forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { isFunction } from 'lodash-es'; -import { StyledProps, ScrollContainerElement } from '../common'; +import React, { useEffect, forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { StyledProps } from '../common'; import { TdAffixProps } from './type'; import useConfig from '../hooks/useConfig'; import { affixDefaultProps } from './defaultProps'; import useDefaultProps from '../hooks/useDefaultProps'; import { getScrollContainer } from '../_util/scroll'; +import useEventCallback from '../hooks/useEventCallback'; +import { getTargetRect } from '../_util/dom'; +import { getFixedBottom, getFixedTop } from './utils'; +import useResizeObserver from '../hooks/useResizeObserver'; + +function getDefaultTarget() { + return typeof window !== 'undefined' ? window : null; +} + +const AFFIX_STATUS_NONE = 0; +const AFFIX_STATUS_PREPARE = 1; + +type AffixStatus = typeof AFFIX_STATUS_NONE | typeof AFFIX_STATUS_PREPARE; + +interface AffixState { + affixStyle?: React.CSSProperties; + placeholderStyle?: React.CSSProperties; + status: AffixStatus; + prevTarget: Window | HTMLElement | null; +} export interface AffixProps extends TdAffixProps, StyledProps {} @@ -14,122 +34,130 @@ export interface AffixRef { } const Affix = forwardRef((props, ref) => { - const { children, content, zIndex, container, offsetBottom, offsetTop, className, style, onFixedChange } = - useDefaultProps(props, affixDefaultProps); + const { + children, + content, + zIndex, + container, + offsetBottom, + offsetTop, + className, + style, + onFixedChange, + ...restProps + } = useDefaultProps(props, affixDefaultProps); const { classPrefix } = useConfig(); - const affixRef = useRef(null); - const affixWrapRef = useRef(null); - const placeholderEL = useRef(null); - const scrollContainer = useRef(null); - - const ticking = useRef(false); - - // 这里是通过控制 wrap 的 border-top 到浏览器顶部距离和 offsetTop 比较 - const handleScroll = useCallback(() => { - if (!ticking.current) { - window.requestAnimationFrame(() => { - // top = 节点到页面顶部的距离,包含 scroll 中的高度 - const { - top: wrapToTop = 0, - width: wrapWidth = 0, - height: wrapHeight = 0, - } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 }; - - // 容器到页面顶部的距离, windows 为0 - let containerToTop = 0; - if (scrollContainer.current instanceof HTMLElement) { - containerToTop = scrollContainer.current.getBoundingClientRect().top; - } - - const calcTop = wrapToTop - containerToTop; // 节点顶部到 container 顶部的距离 - const containerHeight = - scrollContainer.current[scrollContainer.current instanceof Window ? 'innerHeight' : 'clientHeight'] - - wrapHeight; - const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 - - let fixedTop: number | false; - if (calcTop <= offsetTop) { - // top 的触发 - fixedTop = containerToTop + offsetTop; - } else if (wrapToTop >= calcBottom) { - // bottom 的触发 - fixedTop = calcBottom; - } else { - fixedTop = false; - } - - if (affixRef.current) { - const affixed = fixedTop !== false; - let placeholderStatus = affixWrapRef.current.contains(placeholderEL.current); - const prePlaceholderStatus = placeholderStatus; - - if (affixed) { - // 定位 - affixRef.current.className = `${classPrefix}-affix`; - affixRef.current.style.top = `${fixedTop}px`; - affixRef.current.style.width = `${wrapWidth}px`; - affixRef.current.style.height = `${wrapHeight}px`; - - if (zIndex) { - affixRef.current.style.zIndex = `${zIndex}`; - } - - // 插入占位节点 - if (!placeholderStatus) { - placeholderEL.current.style.width = `${wrapWidth}px`; - placeholderEL.current.style.height = `${wrapHeight}px`; - affixWrapRef.current.appendChild(placeholderEL.current); - placeholderStatus = true; - } - } else { - affixRef.current.removeAttribute('class'); - affixRef.current.removeAttribute('style'); - - // 删除占位节点 - if (placeholderStatus) { - placeholderEL.current.remove(); - placeholderStatus = false; - } - } - if (prePlaceholderStatus !== placeholderStatus && isFunction(onFixedChange)) { - onFixedChange(affixed, { top: +fixedTop }); - } - } - - ticking.current = false; + const [affixStyle, setAffixStyle] = useState(); + const [placeholderStyle, setPlaceholderStyle] = useState(); + + const status = useRef(AFFIX_STATUS_NONE); + const placeholderNodeRef = useRef(null); + const fixedNodeRef = useRef(null); + + const scrollContainer = container ?? getDefaultTarget; + + const internalOffsetTop = offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop; + + const measure = () => { + if (!fixedNodeRef.current || !placeholderNodeRef.current || !scrollContainer) { + return; + } + + const targetNode = getScrollContainer(scrollContainer); + if (targetNode) { + const newState: Partial = { + status: AFFIX_STATUS_NONE, + }; + const placeholderRect = getTargetRect(placeholderNodeRef.current); + + if ( + placeholderRect.top === 0 && + placeholderRect.left === 0 && + placeholderRect.width === 0 && + placeholderRect.height === 0 + ) { + return; + } + + const targetRect = getTargetRect(targetNode); + const fixedTop = getFixedTop(placeholderRect, targetRect, internalOffsetTop); + const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom); + let top = 0; + if (fixedTop !== undefined) { + newState.affixStyle = { + position: 'fixed', + top: fixedTop, + width: placeholderRect.width, + height: placeholderRect.height, + zIndex, + }; + newState.placeholderStyle = { + width: placeholderRect.width, + height: placeholderRect.height, + }; + top = fixedTop; + } else if (fixedBottom !== undefined) { + newState.affixStyle = { + position: 'fixed', + bottom: fixedBottom, + width: placeholderRect.width, + height: placeholderRect.height, + zIndex, + }; + newState.placeholderStyle = { + width: placeholderRect.width, + height: placeholderRect.height, + }; + top = fixedBottom; + } + + status.current = newState.status; + setAffixStyle(newState.affixStyle); + setPlaceholderStyle(newState.placeholderStyle); + onFixedChange?.(!!newState.affixStyle, { + top, }); } - ticking.current = true; - }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]); + }; - useImperativeHandle(ref, () => ({ - handleScroll, - })); + const onScroll = useEventCallback(() => { + measure(); + }); - useEffect(() => { - // 创建占位节点 - placeholderEL.current = document.createElement('div'); - }, []); + useResizeObserver(placeholderNodeRef, measure); + + useResizeObserver(fixedNodeRef, measure); useEffect(() => { - scrollContainer.current = getScrollContainer(container); - if (scrollContainer.current) { - handleScroll(); - scrollContainer.current.addEventListener('scroll', handleScroll); - window.addEventListener('resize', handleScroll); + const scrollContainer = getScrollContainer(container); + if (scrollContainer) { + onScroll(); + scrollContainer.addEventListener('scroll', onScroll); + window.addEventListener('resize', onScroll); return () => { - scrollContainer.current.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleScroll); + scrollContainer.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); }; } - }, [container, handleScroll]); + }, [container, onScroll]); + + useImperativeHandle(ref, () => ({ + handleScroll: onScroll, + })); + + const rootCls = classNames(`${classPrefix}-affix`); + + const mergedCls = classNames({ [rootCls]: affixStyle }); return ( -
-
{children || content}
+
+ {affixStyle && ); }); diff --git a/packages/components/affix/__tests__/affix.test.tsx b/packages/components/affix/__tests__/affix.test.tsx index dbfced9398..46e6a0077e 100644 --- a/packages/components/affix/__tests__/affix.test.tsx +++ b/packages/components/affix/__tests__/affix.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, describe, vi, mockTimeout } from '@test/utils'; +import { render, describe, vi, mockTimeout, fireEvent } from '@test/utils'; import Affix from '../index'; describe('Affix 组件测试', () => { @@ -69,6 +69,7 @@ describe('Affix 组件测试', () => {
固钉
, ); + // 默认 expect(onFixedChangeMock).toHaveBeenCalledTimes(0); expect(getByText('固钉').parentNode).not.toHaveClass('t-affix'); @@ -78,6 +79,10 @@ describe('Affix 组件测试', () => { await mockScrollTo(30); await mockScrollTo(10); await mockTimeout(() => false, 200); + + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); + expect(onFixedChangeMock).toHaveBeenCalledTimes(1); expect(getByText('固钉').parentNode).toHaveClass('t-affix'); @@ -88,7 +93,7 @@ describe('Affix 组件测试', () => { const onFixedChangeMock = vi.fn(); const { getByText } = render( - +
固钉
, ); @@ -98,12 +103,13 @@ describe('Affix 组件测试', () => { expect(getByText('固钉').parentElement?.style.zIndex).toBe(''); // offsetBottom - const isWindow = getByText('固钉').parentElement && window instanceof Window; - const { clientHeight } = document.documentElement; - const { innerHeight } = window; - await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40); - await mockScrollTo(isWindow ? innerHeight : clientHeight); + await mockScrollTo(30); + await mockScrollTo(10); await mockTimeout(() => false, 200); + + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); + expect(onFixedChangeMock).toHaveBeenCalledTimes(1); expect(getByText('固钉').parentNode).toHaveClass('t-affix'); @@ -127,17 +133,19 @@ describe('Affix 组件测试', () => { await mockScrollTo(30); await mockScrollTo(10); await mockTimeout(() => false, 200); + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); expect(onFixedChangeMock).toHaveBeenCalledTimes(1); // offsetBottom - const isWindow = typeof window !== 'undefined' && window.innerHeight !== undefined; - const { clientHeight } = document.documentElement; - const { innerHeight } = window; - await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40); - await mockScrollTo(isWindow ? innerHeight : clientHeight); + await mockScrollTo(30); + await mockScrollTo(10); await mockTimeout(() => false, 200); - expect(onFixedChangeMock).toHaveBeenCalledTimes(1); + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); + + expect(onFixedChangeMock).toHaveBeenCalledTimes(2); expect(getByText('固钉').parentNode).toHaveClass('t-affix'); expect(getByText('固钉').parentElement?.style.zIndex).toBe('2'); diff --git a/packages/components/affix/_example/container.tsx b/packages/components/affix/_example/container.tsx index a080f5e226..35a88b2954 100644 --- a/packages/components/affix/_example/container.tsx +++ b/packages/components/affix/_example/container.tsx @@ -13,12 +13,12 @@ export default function ContainerExample() { }; useEffect(() => { - if (affixRef.current) { - const { handleScroll } = affixRef.current; - // 防止 affix 移动到容器外 - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); + function onScroll() { + affixRef.current?.handleScroll(); } + // 防止 affix 移动到容器外 + window.addEventListener('scroll', onScroll); + return () => window.removeEventListener('scroll', onScroll); }, []); const backgroundStyle = { diff --git a/packages/components/affix/utils.ts b/packages/components/affix/utils.ts new file mode 100644 index 0000000000..225253535c --- /dev/null +++ b/packages/components/affix/utils.ts @@ -0,0 +1,14 @@ +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; +} diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index b38639ddc6..7a3b494c65 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -3,7 +3,9 @@ exports[`csr snapshot test > csr test packages/components/affix/_example/base.tsx 1`] = `
-
+
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/alert/_example/base.tsx 1`] = `"
这是一条成功的消息提示
这是一条普通的消息提示
这是一条警示消息
高危操作/出错信息提示
"`; @@ -139357,7 +139367,7 @@ exports[`ssr snapshot test > ssr test packages/components/alert/_example/swiper. exports[`ssr snapshot test > ssr test packages/components/alert/_example/title.tsx 1`] = `"
这是一条普通的消息提示
这是一条普通的消息提示描述,这是一条普通的消息提示描述
相关操作
"`; -exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; +exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; exports[`ssr snapshot test > ssr test packages/components/anchor/_example/container.tsx 1`] = `"
content-1
content-2
content-3
content-4
content-5
"`; @@ -140319,7 +140329,7 @@ exports[`ssr snapshot test > ssr test packages/components/switch/_example/size.t exports[`ssr snapshot test > ssr test packages/components/switch/_example/status.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/async-loading.tsx 1`] = `"
申请人
申请状态
签署方式
邮箱地址
申请时间
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-01-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-02-01
王芳
审批过期
纸质签署
p.cumx@rampblpa.ru
2022-03-01
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-04-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-01-01
正在加载中,请稍后
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index 0658824503..b952e7b64d 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/alert/_example/base.tsx 1`] = `"
这是一条成功的消息提示
这是一条普通的消息提示
这是一条警示消息
高危操作/出错信息提示
"`; @@ -18,7 +18,7 @@ exports[`ssr snapshot test > ssr test packages/components/alert/_example/swiper. exports[`ssr snapshot test > ssr test packages/components/alert/_example/title.tsx 1`] = `"
这是一条普通的消息提示
这是一条普通的消息提示描述,这是一条普通的消息提示描述
相关操作
"`; -exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; +exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; exports[`ssr snapshot test > ssr test packages/components/anchor/_example/container.tsx 1`] = `"
content-1
content-2
content-3
content-4
content-5
"`; @@ -980,7 +980,7 @@ exports[`ssr snapshot test > ssr test packages/components/switch/_example/size.t exports[`ssr snapshot test > ssr test packages/components/switch/_example/status.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/async-loading.tsx 1`] = `"
申请人
申请状态
签署方式
邮箱地址
申请时间
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-01-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-02-01
王芳
审批过期
纸质签署
p.cumx@rampblpa.ru
2022-03-01
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-04-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-01-01
正在加载中,请稍后
"`; From 914f3f8c4caa0747b5693552131e36acc4ac372f Mon Sep 17 00:00:00 2001 From: HaixingOoO <974758671@qq.com> Date: Sat, 6 Sep 2025 23:01:12 +0800 Subject: [PATCH 2/4] chore(dom): resolve conflicts --- packages/components/_util/dom.ts | 8 + packages/components/affix/Affix.tsx | 236 ++++++++++-------- .../components/affix/__tests__/affix.test.tsx | 34 ++- .../components/affix/_example/container.tsx | 10 +- packages/components/affix/utils.ts | 14 ++ test/snap/__snapshots__/csr.test.jsx.snap | 28 ++- test/snap/__snapshots__/ssr.test.jsx.snap | 8 +- 7 files changed, 203 insertions(+), 135 deletions(-) create mode 100644 packages/components/affix/utils.ts diff --git a/packages/components/_util/dom.ts b/packages/components/_util/dom.ts index 04ade3cc4e..12891bc209 100644 --- a/packages/components/_util/dom.ts +++ b/packages/components/_util/dom.ts @@ -77,3 +77,11 @@ export function getWindowSize(): { width: number; height: number } { } return { width: 0, height: 0 }; } + +export type BindElement = HTMLElement | Window | null | undefined; + +export function getTargetRect(target: BindElement): DOMRect { + return target !== window + ? (target as HTMLElement).getBoundingClientRect() + : ({ top: 0, bottom: window.innerHeight } as DOMRect); +} diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index 47d7f8de48..d9b9e931be 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -1,11 +1,31 @@ -import React, { useEffect, forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { isFunction } from 'lodash-es'; -import { StyledProps, ScrollContainerElement } from '../common'; +import React, { useEffect, forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { StyledProps } from '../common'; import { TdAffixProps } from './type'; import useConfig from '../hooks/useConfig'; import { affixDefaultProps } from './defaultProps'; import useDefaultProps from '../hooks/useDefaultProps'; import { getScrollContainer } from '../_util/scroll'; +import useEventCallback from '../hooks/useEventCallback'; +import { getTargetRect } from '../_util/dom'; +import { getFixedBottom, getFixedTop } from './utils'; +import useResizeObserver from '../hooks/useResizeObserver'; + +function getDefaultTarget() { + return typeof window !== 'undefined' ? window : null; +} + +const AFFIX_STATUS_NONE = 0; +const AFFIX_STATUS_PREPARE = 1; + +type AffixStatus = typeof AFFIX_STATUS_NONE | typeof AFFIX_STATUS_PREPARE; + +interface AffixState { + affixStyle?: React.CSSProperties; + placeholderStyle?: React.CSSProperties; + status: AffixStatus; + prevTarget: Window | HTMLElement | null; +} export interface AffixProps extends TdAffixProps, StyledProps {} @@ -14,122 +34,130 @@ export interface AffixRef { } const Affix = forwardRef((props, ref) => { - const { children, content, zIndex, container, offsetBottom, offsetTop, className, style, onFixedChange } = - useDefaultProps(props, affixDefaultProps); + const { + children, + content, + zIndex, + container, + offsetBottom, + offsetTop, + className, + style, + onFixedChange, + ...restProps + } = useDefaultProps(props, affixDefaultProps); const { classPrefix } = useConfig(); - const affixRef = useRef(null); - const affixWrapRef = useRef(null); - const placeholderEL = useRef(null); - const scrollContainer = useRef(null); - - const ticking = useRef(false); - - // 这里是通过控制 wrap 的 border-top 到浏览器顶部距离和 offsetTop 比较 - const handleScroll = useCallback(() => { - if (!ticking.current) { - window.requestAnimationFrame(() => { - // top = 节点到页面顶部的距离,包含 scroll 中的高度 - const { - top: wrapToTop = 0, - width: wrapWidth = 0, - height: wrapHeight = 0, - } = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 }; - - // 容器到页面顶部的距离, windows 为0 - let containerToTop = 0; - if (scrollContainer.current instanceof HTMLElement) { - containerToTop = scrollContainer.current.getBoundingClientRect().top; - } - - const calcTop = wrapToTop - containerToTop; // 节点顶部到 container 顶部的距离 - const containerHeight = - scrollContainer.current[scrollContainer.current instanceof Window ? 'innerHeight' : 'clientHeight'] - - wrapHeight; - const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值 - - let fixedTop: number | false; - if (calcTop <= offsetTop) { - // top 的触发 - fixedTop = containerToTop + offsetTop; - } else if (wrapToTop >= calcBottom) { - // bottom 的触发 - fixedTop = calcBottom; - } else { - fixedTop = false; - } - - if (affixRef.current) { - const affixed = fixedTop !== false; - let placeholderStatus = affixWrapRef.current.contains(placeholderEL.current); - const prePlaceholderStatus = placeholderStatus; - - if (affixed) { - // 定位 - affixRef.current.className = `${classPrefix}-affix`; - affixRef.current.style.top = `${fixedTop}px`; - affixRef.current.style.width = `${wrapWidth}px`; - affixRef.current.style.height = `${wrapHeight}px`; - - if (zIndex) { - affixRef.current.style.zIndex = `${zIndex}`; - } - - // 插入占位节点 - if (!placeholderStatus) { - placeholderEL.current.style.width = `${wrapWidth}px`; - placeholderEL.current.style.height = `${wrapHeight}px`; - affixWrapRef.current.appendChild(placeholderEL.current); - placeholderStatus = true; - } - } else { - affixRef.current.removeAttribute('class'); - affixRef.current.removeAttribute('style'); - - // 删除占位节点 - if (placeholderStatus) { - placeholderEL.current.remove(); - placeholderStatus = false; - } - } - if (prePlaceholderStatus !== placeholderStatus && isFunction(onFixedChange)) { - onFixedChange(affixed, { top: +fixedTop }); - } - } - - ticking.current = false; + const [affixStyle, setAffixStyle] = useState(); + const [placeholderStyle, setPlaceholderStyle] = useState(); + + const status = useRef(AFFIX_STATUS_NONE); + const placeholderNodeRef = useRef(null); + const fixedNodeRef = useRef(null); + + const scrollContainer = container ?? getDefaultTarget; + + const internalOffsetTop = offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop; + + const measure = () => { + if (!fixedNodeRef.current || !placeholderNodeRef.current || !scrollContainer) { + return; + } + + const targetNode = getScrollContainer(scrollContainer); + if (targetNode) { + const newState: Partial = { + status: AFFIX_STATUS_NONE, + }; + const placeholderRect = getTargetRect(placeholderNodeRef.current); + + if ( + placeholderRect.top === 0 && + placeholderRect.left === 0 && + placeholderRect.width === 0 && + placeholderRect.height === 0 + ) { + return; + } + + const targetRect = getTargetRect(targetNode); + const fixedTop = getFixedTop(placeholderRect, targetRect, internalOffsetTop); + const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom); + let top = 0; + if (fixedTop !== undefined) { + newState.affixStyle = { + position: 'fixed', + top: fixedTop, + width: placeholderRect.width, + height: placeholderRect.height, + zIndex, + }; + newState.placeholderStyle = { + width: placeholderRect.width, + height: placeholderRect.height, + }; + top = fixedTop; + } else if (fixedBottom !== undefined) { + newState.affixStyle = { + position: 'fixed', + bottom: fixedBottom, + width: placeholderRect.width, + height: placeholderRect.height, + zIndex, + }; + newState.placeholderStyle = { + width: placeholderRect.width, + height: placeholderRect.height, + }; + top = fixedBottom; + } + + status.current = newState.status; + setAffixStyle(newState.affixStyle); + setPlaceholderStyle(newState.placeholderStyle); + onFixedChange?.(!!newState.affixStyle, { + top, }); } - ticking.current = true; - }, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]); + }; - useImperativeHandle(ref, () => ({ - handleScroll, - })); + const onScroll = useEventCallback(() => { + measure(); + }); - useEffect(() => { - // 创建占位节点 - placeholderEL.current = document.createElement('div'); - }, []); + useResizeObserver(placeholderNodeRef, measure); + + useResizeObserver(fixedNodeRef, measure); useEffect(() => { - scrollContainer.current = getScrollContainer(container); - if (scrollContainer.current) { - handleScroll(); - scrollContainer.current.addEventListener('scroll', handleScroll); - window.addEventListener('resize', handleScroll); + const scrollContainer = getScrollContainer(container); + if (scrollContainer) { + onScroll(); + scrollContainer.addEventListener('scroll', onScroll); + window.addEventListener('resize', onScroll); return () => { - scrollContainer.current.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleScroll); + scrollContainer.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); }; } - }, [container, handleScroll]); + }, [container, onScroll]); + + useImperativeHandle(ref, () => ({ + handleScroll: onScroll, + })); + + const rootCls = classNames(`${classPrefix}-affix`); + + const mergedCls = classNames({ [rootCls]: affixStyle }); return ( -
-
{children || content}
+
+ {affixStyle && ); }); diff --git a/packages/components/affix/__tests__/affix.test.tsx b/packages/components/affix/__tests__/affix.test.tsx index dbfced9398..46e6a0077e 100644 --- a/packages/components/affix/__tests__/affix.test.tsx +++ b/packages/components/affix/__tests__/affix.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, describe, vi, mockTimeout } from '@test/utils'; +import { render, describe, vi, mockTimeout, fireEvent } from '@test/utils'; import Affix from '../index'; describe('Affix 组件测试', () => { @@ -69,6 +69,7 @@ describe('Affix 组件测试', () => {
固钉
, ); + // 默认 expect(onFixedChangeMock).toHaveBeenCalledTimes(0); expect(getByText('固钉').parentNode).not.toHaveClass('t-affix'); @@ -78,6 +79,10 @@ describe('Affix 组件测试', () => { await mockScrollTo(30); await mockScrollTo(10); await mockTimeout(() => false, 200); + + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); + expect(onFixedChangeMock).toHaveBeenCalledTimes(1); expect(getByText('固钉').parentNode).toHaveClass('t-affix'); @@ -88,7 +93,7 @@ describe('Affix 组件测试', () => { const onFixedChangeMock = vi.fn(); const { getByText } = render( - +
固钉
, ); @@ -98,12 +103,13 @@ describe('Affix 组件测试', () => { expect(getByText('固钉').parentElement?.style.zIndex).toBe(''); // offsetBottom - const isWindow = getByText('固钉').parentElement && window instanceof Window; - const { clientHeight } = document.documentElement; - const { innerHeight } = window; - await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40); - await mockScrollTo(isWindow ? innerHeight : clientHeight); + await mockScrollTo(30); + await mockScrollTo(10); await mockTimeout(() => false, 200); + + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); + expect(onFixedChangeMock).toHaveBeenCalledTimes(1); expect(getByText('固钉').parentNode).toHaveClass('t-affix'); @@ -127,17 +133,19 @@ describe('Affix 组件测试', () => { await mockScrollTo(30); await mockScrollTo(10); await mockTimeout(() => false, 200); + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); expect(onFixedChangeMock).toHaveBeenCalledTimes(1); // offsetBottom - const isWindow = typeof window !== 'undefined' && window.innerHeight !== undefined; - const { clientHeight } = document.documentElement; - const { innerHeight } = window; - await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40); - await mockScrollTo(isWindow ? innerHeight : clientHeight); + await mockScrollTo(30); + await mockScrollTo(10); await mockTimeout(() => false, 200); - expect(onFixedChangeMock).toHaveBeenCalledTimes(1); + Object.defineProperty(window, 'scrollTop', { value: 50, writable: true }); + await fireEvent.scroll(window); + + expect(onFixedChangeMock).toHaveBeenCalledTimes(2); expect(getByText('固钉').parentNode).toHaveClass('t-affix'); expect(getByText('固钉').parentElement?.style.zIndex).toBe('2'); diff --git a/packages/components/affix/_example/container.tsx b/packages/components/affix/_example/container.tsx index a080f5e226..35a88b2954 100644 --- a/packages/components/affix/_example/container.tsx +++ b/packages/components/affix/_example/container.tsx @@ -13,12 +13,12 @@ export default function ContainerExample() { }; useEffect(() => { - if (affixRef.current) { - const { handleScroll } = affixRef.current; - // 防止 affix 移动到容器外 - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); + function onScroll() { + affixRef.current?.handleScroll(); } + // 防止 affix 移动到容器外 + window.addEventListener('scroll', onScroll); + return () => window.removeEventListener('scroll', onScroll); }, []); const backgroundStyle = { diff --git a/packages/components/affix/utils.ts b/packages/components/affix/utils.ts new file mode 100644 index 0000000000..225253535c --- /dev/null +++ b/packages/components/affix/utils.ts @@ -0,0 +1,14 @@ +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; +} diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 29cdd300ae..c28c461c81 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -3,7 +3,9 @@ exports[`csr snapshot test > csr test packages/components/affix/_example/base.tsx 1`] = `
-
+
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/alert/_example/base.tsx 1`] = `"
这是一条成功的消息提示
这是一条普通的消息提示
这是一条警示消息
高危操作/出错信息提示
"`; @@ -134792,7 +134802,7 @@ exports[`ssr snapshot test > ssr test packages/components/alert/_example/swiper. exports[`ssr snapshot test > ssr test packages/components/alert/_example/title.tsx 1`] = `"
这是一条普通的消息提示
这是一条普通的消息提示描述,这是一条普通的消息提示描述
相关操作
"`; -exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; +exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; exports[`ssr snapshot test > ssr test packages/components/anchor/_example/container.tsx 1`] = `"
content-1
content-2
content-3
content-4
content-5
"`; @@ -135754,7 +135764,7 @@ exports[`ssr snapshot test > ssr test packages/components/switch/_example/size.t exports[`ssr snapshot test > ssr test packages/components/switch/_example/status.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/async-loading.tsx 1`] = `"
申请人
申请状态
签署方式
邮箱地址
申请时间
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-01-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-02-01
王芳
审批过期
纸质签署
p.cumx@rampblpa.ru
2022-03-01
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-04-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-01-01
正在加载中,请稍后
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index aa6b2a58cd..8aefe985bc 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/base.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; +exports[`ssr snapshot test > ssr test packages/components/affix/_example/container.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/alert/_example/base.tsx 1`] = `"
这是一条成功的消息提示
这是一条普通的消息提示
这是一条警示消息
高危操作/出错信息提示
"`; @@ -18,7 +18,7 @@ exports[`ssr snapshot test > ssr test packages/components/alert/_example/swiper. exports[`ssr snapshot test > ssr test packages/components/alert/_example/title.tsx 1`] = `"
这是一条普通的消息提示
这是一条普通的消息提示描述,这是一条普通的消息提示描述
相关操作
"`; -exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; +exports[`ssr snapshot test > ssr test packages/components/anchor/_example/base.tsx 1`] = `""`; exports[`ssr snapshot test > ssr test packages/components/anchor/_example/container.tsx 1`] = `"
content-1
content-2
content-3
content-4
content-5
"`; @@ -980,7 +980,7 @@ exports[`ssr snapshot test > ssr test packages/components/switch/_example/size.t exports[`ssr snapshot test > ssr test packages/components/switch/_example/status.tsx 1`] = `"
"`; -exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/affix.tsx 1`] = `"
共 38 条数据
请选择
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/async-loading.tsx 1`] = `"
申请人
申请状态
签署方式
邮箱地址
申请时间
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-01-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-02-01
王芳
审批过期
纸质签署
p.cumx@rampblpa.ru
2022-03-01
贾明
审批通过
电子签署
w.cezkdudy@lhll.au
2022-04-01
张三
审批失败
纸质签署
r.nmgw@peurezgn.sl
2022-01-01
正在加载中,请稍后
"`; From bfc0780b42da3afafa408664d9971b4d80a9b9bc Mon Sep 17 00:00:00 2001 From: HaixingOoO <974758671@qq.com> Date: Sat, 6 Sep 2025 23:03:06 +0800 Subject: [PATCH 3/4] chore(affix): rm not use code --- packages/components/affix/Affix.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/components/affix/Affix.tsx b/packages/components/affix/Affix.tsx index d9b9e931be..d87068ec77 100644 --- a/packages/components/affix/Affix.tsx +++ b/packages/components/affix/Affix.tsx @@ -15,15 +15,9 @@ function getDefaultTarget() { return typeof window !== 'undefined' ? window : null; } -const AFFIX_STATUS_NONE = 0; -const AFFIX_STATUS_PREPARE = 1; - -type AffixStatus = typeof AFFIX_STATUS_NONE | typeof AFFIX_STATUS_PREPARE; - interface AffixState { affixStyle?: React.CSSProperties; placeholderStyle?: React.CSSProperties; - status: AffixStatus; prevTarget: Window | HTMLElement | null; } @@ -52,7 +46,6 @@ const Affix = forwardRef((props, ref) => { const [affixStyle, setAffixStyle] = useState(); const [placeholderStyle, setPlaceholderStyle] = useState(); - const status = useRef(AFFIX_STATUS_NONE); const placeholderNodeRef = useRef(null); const fixedNodeRef = useRef(null); @@ -67,9 +60,7 @@ const Affix = forwardRef((props, ref) => { const targetNode = getScrollContainer(scrollContainer); if (targetNode) { - const newState: Partial = { - status: AFFIX_STATUS_NONE, - }; + const newState: Partial = {}; const placeholderRect = getTargetRect(placeholderNodeRef.current); if ( @@ -113,7 +104,6 @@ const Affix = forwardRef((props, ref) => { top = fixedBottom; } - status.current = newState.status; setAffixStyle(newState.affixStyle); setPlaceholderStyle(newState.placeholderStyle); onFixedChange?.(!!newState.affixStyle, { From c75c6f7795685edd74dfb42b14f793504d9912d9 Mon Sep 17 00:00:00 2001 From: HaixingOoO <974758671@qq.com> Date: Sat, 6 Sep 2025 23:09:49 +0800 Subject: [PATCH 4/4] chore(dom): update dom file --- packages/components/_util/dom.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/components/_util/dom.ts b/packages/components/_util/dom.ts index c86faba51e..12891bc209 100644 --- a/packages/components/_util/dom.ts +++ b/packages/components/_util/dom.ts @@ -85,11 +85,3 @@ export function getTargetRect(target: BindElement): DOMRect { ? (target as HTMLElement).getBoundingClientRect() : ({ top: 0, bottom: window.innerHeight } as DOMRect); } - -export type BindElement = HTMLElement | Window | null | undefined; - -export function getTargetRect(target: BindElement): DOMRect { - return target !== window - ? (target as HTMLElement).getBoundingClientRect() - : ({ top: 0, bottom: window.innerHeight } as DOMRect); -}