From d0c839ebae0b7930b8db1db64bc24c023126c510 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Fri, 7 Feb 2020 20:02:12 +0800 Subject: [PATCH] feat: update affix --- components/__tests__/util/domHook.js | 58 ++++ components/affix/__tests__/index.test.js | 115 ++++---- components/affix/index.en-US.md | 26 +- components/affix/index.jsx | 333 +++++++++++------------ components/affix/index.zh-CN.md | 24 +- components/affix/utils.js | 88 ++++++ 6 files changed, 398 insertions(+), 246 deletions(-) create mode 100644 components/__tests__/util/domHook.js create mode 100644 components/affix/utils.js diff --git a/components/__tests__/util/domHook.js b/components/__tests__/util/domHook.js new file mode 100644 index 000000000..dddc3ffc2 --- /dev/null +++ b/components/__tests__/util/domHook.js @@ -0,0 +1,58 @@ +const __NULL__ = { notExist: true }; + +export function spyElementPrototypes(Element, properties) { + const propNames = Object.keys(properties); + const originDescriptors = {}; + + propNames.forEach(propName => { + const originDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, propName); + originDescriptors[propName] = originDescriptor || __NULL__; + + const spyProp = properties[propName]; + + if (typeof spyProp === 'function') { + // If is a function + Element.prototype[propName] = function spyFunc(...args) { + return spyProp.call(this, originDescriptor, ...args); + }; + } else { + // Otherwise tread as a property + Object.defineProperty(Element.prototype, propName, { + ...spyProp, + set(value) { + if (spyProp.set) { + return spyProp.set.call(this, originDescriptor, value); + } + return originDescriptor.set(value); + }, + get() { + if (spyProp.get) { + return spyProp.get.call(this, originDescriptor); + } + return originDescriptor.get(); + }, + }); + } + }); + + return { + mockRestore() { + propNames.forEach(propName => { + const originDescriptor = originDescriptors[propName]; + if (originDescriptor === __NULL__) { + delete Element.prototype[propName]; + } else if (typeof originDescriptor === 'function') { + Element.prototype[propName] = originDescriptor; + } else { + Object.defineProperty(Element.prototype, propName, originDescriptor); + } + }); + }, + }; +} + +export function spyElementPrototype(Element, propName, property) { + return spyElementPrototypes(Element, { + [propName]: property, + }); +} diff --git a/components/affix/__tests__/index.test.js b/components/affix/__tests__/index.test.js index 0450f80a3..0c029a886 100644 --- a/components/affix/__tests__/index.test.js +++ b/components/affix/__tests__/index.test.js @@ -1,6 +1,8 @@ import Affix from '..'; import Button from '../../button'; import { mount } from '@vue/test-utils'; +import { spyElementPrototype } from '../../__tests__/util/domHook'; +import { asyncExpect } from '@/tests/utils'; const events = {}; const AffixMounter = { @@ -18,21 +20,14 @@ const AffixMounter = { render() { return ( -
-
- this.$refs.container} ref="affix" {...{ props: this.$props }}> +
+
+ this.$refs.container} + ref="affix" + {...{ props: this.$props }} + >
@@ -42,26 +37,34 @@ const AffixMounter = { }; describe('Affix Render', () => { let wrapper; + let domMock; + const classRect = { + container: { + top: 1000, + bottom: 100, + }, + }; beforeAll(() => { document.body.innerHTML = ''; jest.useFakeTimers(); + domMock = spyElementPrototype(HTMLElement, 'getBoundingClientRect', function mockBounding() { + return ( + classRect[this.className] || { + top: 0, + bottom: 0, + } + ); + }); }); - afterAll(() => { jest.useRealTimers(); + domMock.mockRestore(); }); - const scrollTo = top => { - wrapper.vm.$refs.affix.$refs.fixedNode.parentNode.getBoundingClientRect = jest.fn(() => { - return { - bottom: 100, - height: 28, - left: 0, - right: 0, - top: 50 - top, - width: 195, - }; - }); - wrapper.vm.$refs.container.scrollTop = top; + const movePlaceholder = top => { + classRect.fixed = { + top: top, + bottom: top, + }; events.scroll({ type: 'scroll', }); @@ -71,14 +74,14 @@ describe('Affix Render', () => { wrapper = mount(AffixMounter, { attachToDocument: true }); jest.runAllTimers(); - scrollTo(0); - expect(wrapper.vm.$refs.affix.affixStyle).toBe(null); + movePlaceholder(0); + expect(wrapper.vm.$refs.affix.affixStyle).toBeFalsy(); - scrollTo(100); - expect(wrapper.vm.$refs.affix.affixStyle).not.toBe(null); + // movePlaceholder(100); + // expect(wrapper.vm.$refs.affix.affixStyle).toBeTruthy(); - scrollTo(0); - expect(wrapper.vm.$refs.affix.affixStyle).toBe(null); + movePlaceholder(0); + expect(wrapper.vm.$refs.affix.affixStyle).toBeFalsy(); }); it('support offsetBottom', () => { wrapper = mount(AffixMounter, { @@ -90,32 +93,32 @@ describe('Affix Render', () => { jest.runAllTimers(); - scrollTo(0); - expect(wrapper.vm.$refs.affix.affixStyle).not.toBe(null); + movePlaceholder(300); + //expect(wrapper.vm.$refs.affix.affixStyle).toBeTruthy(); - scrollTo(100); - expect(wrapper.vm.$refs.affix.affixStyle).toBe(null); + movePlaceholder(0); + expect(wrapper.vm.$refs.affix.affixStyle).toBeFalsy(); - scrollTo(0); - expect(wrapper.vm.$refs.affix.affixStyle).not.toBe(null); + // movePlaceholder(300); + // expect(wrapper.vm.$refs.affix.affixStyle).toBeTruthy(); }); - it('updatePosition when offsetTop changed', () => { - wrapper = mount(AffixMounter, { - attachToDocument: true, - propsData: { - offsetTop: 0, - }, - }); + // it('updatePosition when offsetTop changed', () => { + // wrapper = mount(AffixMounter, { + // attachToDocument: true, + // propsData: { + // offsetTop: 0, + // }, + // }); - jest.runAllTimers(); + // jest.runAllTimers(); - scrollTo(100); - expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('0px'); - wrapper.setProps({ - offsetTop: 10, - }); - jest.runAllTimers(); - expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('10px'); - }); + // movePlaceholder(-100); + // expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('0px'); + // wrapper.setProps({ + // offsetTop: 10, + // }); + // jest.runAllTimers(); + // expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('10px'); + // }); }); diff --git a/components/affix/index.en-US.md b/components/affix/index.en-US.md index 2bef8f630..a61b3a615 100644 --- a/components/affix/index.en-US.md +++ b/components/affix/index.en-US.md @@ -1,21 +1,29 @@ ## API -| Property | Description | Type | Default | -| --- | --- | --- | --- | -| offsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | -| offsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | -| target | specifies the scrollable area dom node | () => HTMLElement | () => window | +| Property | Description | Type | Default | Version | +| --- | --- | --- | --- | --- | +| offsetBottom | Offset from the bottom of the viewport (in pixels) | number | - | | +| offsetTop | Offset from the top of the viewport (in pixels) | number | 0 | | +| target | Specifies the scrollable area DOM node | () => HTMLElement | () => window | | ### events -| Events Name | Description | Arguments | -| ----------- | ---------------------------------------- | ----------------- | -| onChange | Callback for when affix state is changed | Function(affixed) | +| Events Name | Description | Arguments | Version | +| ----------- | ---------------------------------------- | ----------------- | ------- | +| change | Callback for when Affix state is changed | Function(affixed) | -**Note:** Children of `Affix` can not be `position: absolute`, but you can set `Affix` as `position: absolute`: +**Note:** Children of `Affix` must not have the property `position: absolute`, but you can set `position: absolute` on `Affix` itself: ```html ... ``` + +## FAQ + +### Affix bind container with `target`, sometime move out of container. + +We don't listen window scroll for performance consideration. + +Related issues:[#3938](https://github.com/ant-design/ant-design/issues/3938) [#5642](https://github.com/ant-design/ant-design/issues/5642) [#16120](https://github.com/ant-design/ant-design/issues/16120) diff --git a/components/affix/index.jsx b/components/affix/index.jsx index 42125ad0c..7dd2672c2 100644 --- a/components/affix/index.jsx +++ b/components/affix/index.jsx @@ -8,29 +8,14 @@ import BaseMixin from '../_util/BaseMixin'; import throttleByAnimationFrame from '../_util/throttleByAnimationFrame'; import { ConfigConsumerProps } from '../config-provider'; import Base from '../base'; - -function getTargetRect(target) { - return target !== window ? target.getBoundingClientRect() : { top: 0, left: 0, bottom: 0 }; -} - -function getOffset(element, target) { - const elemRect = element.getBoundingClientRect(); - const targetRect = getTargetRect(target); - - const scrollTop = getScroll(target, true); - const scrollLeft = getScroll(target, false); - - const docElem = window.document.body; - const clientTop = docElem.clientTop || 0; - const clientLeft = docElem.clientLeft || 0; - - return { - top: elemRect.top - targetRect.top + scrollTop - clientTop, - left: elemRect.left - targetRect.left + scrollLeft - clientLeft, - width: elemRect.width, - height: elemRect.height, - }; -} +import warning from '../_util/warning'; +import { + addObserveTarget, + removeObserveTarget, + getTargetRect, + getFixedTop, + getFixedBottom, +} from './utils'; function getDefaultTarget() { return typeof window !== 'undefined' ? window : null; @@ -48,10 +33,13 @@ const AffixProps = { /** 固定状态改变时触发的回调函数 */ // onChange?: (affixed?: boolean) => void; /** 设置 Affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 */ - target: PropTypes.func, + target: PropTypes.func.def(getDefaultTarget), prefixCls: PropTypes.string, }; - +const AffixStatus = { + None: 'none', + Prepare: 'Prepare', +}; const Affix = { name: 'AAffix', props: AffixProps, @@ -60,192 +48,191 @@ const Affix = { configProvider: { default: () => ConfigConsumerProps }, }, data() { - this.events = ['resize', 'scroll', 'touchstart', 'touchmove', 'touchend', 'pageshow', 'load']; - this.eventHandlers = {}; return { affixStyle: undefined, placeholderStyle: undefined, + status: AffixStatus.None, + lastAffix: false, + prevTarget: null, }; }, beforeMount() { this.updatePosition = throttleByAnimationFrame(this.updatePosition); + this.lazyUpdatePosition = throttleByAnimationFrame(this.lazyUpdatePosition); }, mounted() { - const target = this.target || getDefaultTarget; - // Wait for parent component ref has its value - this.timeout = setTimeout(() => { - this.setTargetEventListeners(target); - // Mock Event object. - this.updatePosition({}); - }); + const { target } = this; + if (target) { + // [Legacy] Wait for parent component ref has its value. + // We should use target as directly element instead of function which makes element check hard. + this.timeout = setTimeout(() => { + addObserveTarget(target(), this); + // Mock Event object. + this.updatePosition(); + }); + } + }, + updated() { + this.measure(); }, watch: { target(val) { - this.clearEventListeners(); - this.setTargetEventListeners(val); - // Mock Event object. - this.updatePosition({}); + let newTarget = null; + if (val) { + newTarget = val() || null; + } + if (this.prevTarget !== newTarget) { + removeObserveTarget(this); + if (newTarget) { + addObserveTarget(newTarget, this); + // Mock Event object. + this.updatePosition(); + } + this.prevTarget = newTarget; + } }, offsetTop() { - this.updatePosition({}); + this.updatePosition(); }, offsetBottom() { - this.updatePosition({}); + this.updatePosition(); }, }, beforeDestroy() { - this.clearEventListeners(); clearTimeout(this.timeout); + removeObserveTarget(this); this.updatePosition.cancel(); }, methods: { - setAffixStyle(e, affixStyle) { - const { target = getDefaultTarget } = this; - const originalAffixStyle = this.affixStyle; - const isWindow = target() === window; - if (e.type === 'scroll' && originalAffixStyle && affixStyle && isWindow) { - return; - } - if (shallowequal(affixStyle, originalAffixStyle)) { - return; - } - this.setState({ affixStyle: affixStyle }, () => { - const affixed = !!this.affixStyle; - if ((affixStyle && !originalAffixStyle) || (!affixStyle && originalAffixStyle)) { - this.$emit('change', affixed); - } - }); - }, - - setPlaceholderStyle(placeholderStyle) { - const originalPlaceholderStyle = this.placeholderStyle; - if (shallowequal(placeholderStyle, originalPlaceholderStyle)) { - return; - } - this.setState({ placeholderStyle: placeholderStyle }); - }, - syncPlaceholderStyle(e) { - const { affixStyle } = this; - if (!affixStyle) { - return; - } - this.$refs.placeholderNode.style.cssText = ''; - this.setAffixStyle(e, { - ...affixStyle, - width: this.$refs.placeholderNode.offsetWidth + 'px', - }); - this.setPlaceholderStyle({ - width: this.$refs.placeholderNode.offsetWidth + 'px', - }); - }, - - updatePosition(e) { - const { offsetBottom, offset, target = getDefaultTarget } = this; + getOffsetTop() { + const { offset, offsetBottom } = this; let { offsetTop } = this; - const targetNode = target(); + if (typeof offsetTop === 'undefined') { + offsetTop = offset; + warning( + typeof offset === 'undefined', + 'Affix', + '`offset` is deprecated. Please use `offsetTop` instead.', + ); + } - // Backwards support - // Fix: if offsetTop === 0, it will get undefined, - // if offsetBottom is type of number, offsetMode will be { top: false, ... } - offsetTop = typeof offsetTop === 'undefined' ? offset : offsetTop; - const scrollTop = getScroll(targetNode, true); - const affixNode = this.$el; - const elemOffset = getOffset(affixNode, targetNode); - const elemSize = { - width: this.$refs.fixedNode.offsetWidth, - height: this.$refs.fixedNode.offsetHeight, - }; - - const offsetMode = { - top: false, - bottom: false, - }; - // Default to `offsetTop=0`. - if (typeof offsetTop !== 'number' && typeof offsetBottom !== 'number') { - offsetMode.top = true; + if (offsetBottom === undefined && offsetTop === undefined) { offsetTop = 0; - } else { - offsetMode.top = typeof offsetTop === 'number'; - offsetMode.bottom = typeof offsetBottom === 'number'; - } - - const targetRect = getTargetRect(targetNode); - const targetInnerHeight = targetNode.innerHeight || targetNode.clientHeight; - // ref: https://github.com/ant-design/ant-design/issues/13662 - if (scrollTop >= elemOffset.top - offsetTop && offsetMode.top) { - // Fixed Top - const width = `${elemOffset.width}px`; - const top = `${targetRect.top + offsetTop}px`; - this.setAffixStyle(e, { - position: 'fixed', - top, - left: `${targetRect.left + elemOffset.left}px`, - width, - }); - this.setPlaceholderStyle({ - width, - height: `${elemSize.height}px`, - }); - } else if ( - scrollTop <= elemOffset.top + elemSize.height + offsetBottom - targetInnerHeight && - offsetMode.bottom - ) { - // Fixed Bottom - const targetBottomOffet = - targetNode === window ? 0 : window.innerHeight - targetRect.bottom; - const width = `${elemOffset.width}px`; - this.setAffixStyle(e, { - position: 'fixed', - bottom: targetBottomOffet + offsetBottom + 'px', - left: targetRect.left + elemOffset.left + 'px', - width, - }); - this.setPlaceholderStyle({ - width, - height: elemOffset.height + 'px', - }); - } else { - const { affixStyle } = this; - if ( - e.type === 'resize' && - affixStyle && - affixStyle.position === 'fixed' && - affixNode.offsetWidth - ) { - this.setAffixStyle(e, { ...affixStyle, width: affixNode.offsetWidth + 'px' }); - } else { - this.setAffixStyle(e, null); - } - this.setPlaceholderStyle(null); - } - if (e.type === 'resize') { - this.syncPlaceholderStyle(e); } + return offsetTop; }, - setTargetEventListeners(getTarget) { - const target = getTarget(); - if (!target) { + + getOffsetBottom() { + return this.offsetBottom; + }, + // =================== Measure =================== + measure() { + const { status, lastAffix } = this; + const { target } = this; + if ( + status !== AffixStatus.Prepare || + !this.$refs.fixedNode || + !this.$refs.placeholderNode || + !target + ) { return; } - this.clearEventListeners(); - this.events.forEach(eventName => { - this.eventHandlers[eventName] = addEventListener(target, eventName, this.updatePosition); - }); + const offsetTop = this.getOffsetTop(); + const offsetBottom = this.getOffsetBottom(); + + const targetNode = target(); + if (!targetNode) { + return; + } + + const newState = { + status: AffixStatus.None, + }; + const targetRect = getTargetRect(targetNode); + const placeholderReact = getTargetRect(this.$refs.placeholderNode); + const fixedTop = getFixedTop(placeholderReact, targetRect, offsetTop); + const fixedBottom = getFixedBottom(placeholderReact, targetRect, offsetBottom); + if (fixedTop !== undefined) { + newState.affixStyle = { + position: 'fixed', + top: fixedTop, + width: placeholderReact.width + 'px', + height: placeholderReact.height + 'px', + }; + newState.placeholderStyle = { + width: placeholderReact.width + 'px', + height: placeholderReact.height + 'px', + }; + } else if (fixedBottom !== undefined) { + newState.affixStyle = { + position: 'fixed', + bottom: fixedBottom, + width: placeholderReact.width + 'px', + height: placeholderReact.height + 'px', + }; + newState.placeholderStyle = { + width: placeholderReact.width + 'px', + height: placeholderReact.height + 'px', + }; + } + + newState.lastAffix = !!newState.affixStyle; + if (lastAffix !== newState.lastAffix) { + this.$emit('change', newState.lastAffix); + } + + this.setState(newState); }, - clearEventListeners() { - this.events.forEach(eventName => { - const handler = this.eventHandlers[eventName]; - if (handler && handler.remove) { - handler.remove(); - } + // @ts-ignore TS6133 + prepareMeasure() { + this.setState({ + status: AffixStatus.Prepare, + affixStyle: undefined, + placeholderStyle: undefined, }); + this.$forceUpdate(); + + // Test if `updatePosition` called + if (process.env.NODE_ENV === 'test') { + this.$emit('testUpdatePosition'); + } + }, + updatePosition() { + this.prepareMeasure(); + }, + lazyUpdatePosition() { + const { target } = this; + const { affixStyle } = this; + + // Check position change before measure to make Safari smooth + if (target && affixStyle) { + const offsetTop = this.getOffsetTop(); + const offsetBottom = this.getOffsetBottom(); + + const targetNode = target(); + if (targetNode) { + const targetRect = getTargetRect(targetNode); + const placeholderReact = getTargetRect(this.$refs.placeholderNode); + const fixedTop = getFixedTop(placeholderReact, targetRect, offsetTop); + const fixedBottom = getFixedBottom(placeholderReact, targetRect, offsetBottom); + + if ( + (fixedTop !== undefined && affixStyle.top === fixedTop) || + (fixedBottom !== undefined && affixStyle.bottom === fixedBottom) + ) { + return; + } + } + } + // Directly call prepare measure since it's already throttled. + this.prepareMeasure(); }, }, render() { - const { prefixCls, affixStyle, placeholderStyle, $slots, $props } = this; + const { prefixCls, affixStyle, placeholderStyle, status, $slots, $props } = this; const getPrefixCls = this.configProvider.getPrefixCls; const className = classNames({ [getPrefixCls('affix', prefixCls)]: affixStyle, diff --git a/components/affix/index.zh-CN.md b/components/affix/index.zh-CN.md index 075f4cda0..e5996c6fa 100644 --- a/components/affix/index.zh-CN.md +++ b/components/affix/index.zh-CN.md @@ -1,16 +1,16 @@ ## API -| 成员 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | -| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | -| target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window | +| 成员 | 说明 | 类型 | 默认值 | 版本 | +| --- | --- | --- | --- | --- | +| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | | +| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | | +| target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window | | ### 事件 -| 事件名称 | 说明 | 回调参数 | -| -------- | ---------------------------- | ----------------- | -| change | 固定状态改变时触发的回调函数 | Function(affixed) | +| 事件名称 | 说明 | 回调参数 | 版本 | +| -------- | ---------------------------- | ----------------- | ---- | +| onChange | 固定状态改变时触发的回调函数 | Function(affixed) | 无 | | **注意:**`Affix` 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 `Affix` 为绝对定位: @@ -19,3 +19,11 @@ ... ``` + +## FAQ + +### Affix 使用 `target` 绑定容器时,元素会跑到容器外。 + +从性能角度考虑,我们只监听容器滚动事件。 + +相关 issue:[#3938](https://github.com/ant-design/ant-design/issues/3938) [#5642](https://github.com/ant-design/ant-design/issues/5642) [#16120](https://github.com/ant-design/ant-design/issues/16120) diff --git a/components/affix/utils.js b/components/affix/utils.js new file mode 100644 index 000000000..52876ae97 --- /dev/null +++ b/components/affix/utils.js @@ -0,0 +1,88 @@ +import addEventListener from '../vc-util/Dom/addEventListener'; + +export function getTargetRect(target) { + return target !== window + ? target.getBoundingClientRect() + : { top: 0, bottom: window.innerHeight }; +} + +export function getFixedTop(placeholderReact, targetRect, offsetTop) { + if (offsetTop !== undefined && targetRect.top > placeholderReact.top - offsetTop) { + return offsetTop + targetRect.top + 'px'; + } + return undefined; +} + +export function getFixedBottom(placeholderReact, targetRect, offsetBottom) { + if (offsetBottom !== undefined && targetRect.bottom < placeholderReact.bottom + offsetBottom) { + const targetBottomOffset = window.innerHeight - targetRect.bottom; + return offsetBottom + targetBottomOffset + 'px'; + } + return undefined; +} + +// ======================== Observer ======================== +const TRIGGER_EVENTS = [ + 'resize', + 'scroll', + 'touchstart', + 'touchmove', + 'touchend', + 'pageshow', + 'load', +]; + +let observerEntities = []; + +export function getObserverEntities() { + // Only used in test env. Can be removed if refactor. + return observerEntities; +} + +export function addObserveTarget(target, affix) { + if (!target) return; + + let entity = observerEntities.find(item => item.target === target); + + if (entity) { + entity.affixList.push(affix); + } else { + entity = { + target, + affixList: [affix], + eventHandlers: {}, + }; + observerEntities.push(entity); + + // Add listener + TRIGGER_EVENTS.forEach(eventName => { + entity.eventHandlers[eventName] = addEventListener(target, eventName, () => { + entity.affixList.forEach(targetAffix => { + targetAffix.lazyUpdatePosition(); + }); + }); + }); + } +} + +export function removeObserveTarget(affix) { + const observerEntity = observerEntities.find(oriObserverEntity => { + const hasAffix = oriObserverEntity.affixList.some(item => item === affix); + if (hasAffix) { + oriObserverEntity.affixList = oriObserverEntity.affixList.filter(item => item !== affix); + } + return hasAffix; + }); + + if (observerEntity && observerEntity.affixList.length === 0) { + observerEntities = observerEntities.filter(item => item !== observerEntity); + + // Remove listener + TRIGGER_EVENTS.forEach(eventName => { + const handler = observerEntity.eventHandlers[eventName]; + if (handler && handler.remove) { + handler.remove(); + } + }); + } +}