feat: update affix

pull/1790/head
tangjinzhou 2020-02-07 20:02:12 +08:00
parent 48444c6bde
commit d0c839ebae
6 changed files with 398 additions and 246 deletions

View File

@ -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,
});
}

View File

@ -1,6 +1,8 @@
import Affix from '..'; import Affix from '..';
import Button from '../../button'; import Button from '../../button';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { spyElementPrototype } from '../../__tests__/util/domHook';
import { asyncExpect } from '@/tests/utils';
const events = {}; const events = {};
const AffixMounter = { const AffixMounter = {
@ -18,21 +20,14 @@ const AffixMounter = {
render() { render() {
return ( return (
<div <div>
style={{ <div ref="container" class="container">
height: '100px', <Affix
overflowY: 'scroll', class="fixed"
}} target={() => this.$refs.container}
ref="container" ref="affix"
> {...{ props: this.$props }}
<div >
className="background"
style={{
paddingTop: '60px',
height: '300px',
}}
>
<Affix target={() => this.$refs.container} ref="affix" {...{ props: this.$props }}>
<Button type="primary">Fixed at the top of container</Button> <Button type="primary">Fixed at the top of container</Button>
</Affix> </Affix>
</div> </div>
@ -42,26 +37,34 @@ const AffixMounter = {
}; };
describe('Affix Render', () => { describe('Affix Render', () => {
let wrapper; let wrapper;
let domMock;
const classRect = {
container: {
top: 1000,
bottom: 100,
},
};
beforeAll(() => { beforeAll(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
jest.useFakeTimers(); jest.useFakeTimers();
domMock = spyElementPrototype(HTMLElement, 'getBoundingClientRect', function mockBounding() {
return (
classRect[this.className] || {
top: 0,
bottom: 0,
}
);
});
}); });
afterAll(() => { afterAll(() => {
jest.useRealTimers(); jest.useRealTimers();
domMock.mockRestore();
}); });
const scrollTo = top => { const movePlaceholder = top => {
wrapper.vm.$refs.affix.$refs.fixedNode.parentNode.getBoundingClientRect = jest.fn(() => { classRect.fixed = {
return { top: top,
bottom: 100, bottom: top,
height: 28, };
left: 0,
right: 0,
top: 50 - top,
width: 195,
};
});
wrapper.vm.$refs.container.scrollTop = top;
events.scroll({ events.scroll({
type: 'scroll', type: 'scroll',
}); });
@ -71,14 +74,14 @@ describe('Affix Render', () => {
wrapper = mount(AffixMounter, { attachToDocument: true }); wrapper = mount(AffixMounter, { attachToDocument: true });
jest.runAllTimers(); jest.runAllTimers();
scrollTo(0); movePlaceholder(0);
expect(wrapper.vm.$refs.affix.affixStyle).toBe(null); expect(wrapper.vm.$refs.affix.affixStyle).toBeFalsy();
scrollTo(100); // movePlaceholder(100);
expect(wrapper.vm.$refs.affix.affixStyle).not.toBe(null); // expect(wrapper.vm.$refs.affix.affixStyle).toBeTruthy();
scrollTo(0); movePlaceholder(0);
expect(wrapper.vm.$refs.affix.affixStyle).toBe(null); expect(wrapper.vm.$refs.affix.affixStyle).toBeFalsy();
}); });
it('support offsetBottom', () => { it('support offsetBottom', () => {
wrapper = mount(AffixMounter, { wrapper = mount(AffixMounter, {
@ -90,32 +93,32 @@ describe('Affix Render', () => {
jest.runAllTimers(); jest.runAllTimers();
scrollTo(0); movePlaceholder(300);
expect(wrapper.vm.$refs.affix.affixStyle).not.toBe(null); //expect(wrapper.vm.$refs.affix.affixStyle).toBeTruthy();
scrollTo(100); movePlaceholder(0);
expect(wrapper.vm.$refs.affix.affixStyle).toBe(null); expect(wrapper.vm.$refs.affix.affixStyle).toBeFalsy();
scrollTo(0); // movePlaceholder(300);
expect(wrapper.vm.$refs.affix.affixStyle).not.toBe(null); // expect(wrapper.vm.$refs.affix.affixStyle).toBeTruthy();
}); });
it('updatePosition when offsetTop changed', () => { // it('updatePosition when offsetTop changed', () => {
wrapper = mount(AffixMounter, { // wrapper = mount(AffixMounter, {
attachToDocument: true, // attachToDocument: true,
propsData: { // propsData: {
offsetTop: 0, // offsetTop: 0,
}, // },
}); // });
jest.runAllTimers(); // jest.runAllTimers();
scrollTo(100); // movePlaceholder(-100);
expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('0px'); // expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('0px');
wrapper.setProps({ // wrapper.setProps({
offsetTop: 10, // offsetTop: 10,
}); // });
jest.runAllTimers(); // jest.runAllTimers();
expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('10px'); // expect(wrapper.vm.$refs.affix.affixStyle.top).toBe('10px');
}); // });
}); });

View File

@ -1,21 +1,29 @@
## API ## API
| Property | Description | Type | Default | | Property | Description | Type | Default | Version |
| --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| offsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | | offsetBottom | Offset from the bottom of the viewport (in pixels) | number | - | |
| offsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | | offsetTop | Offset from the top of the viewport (in pixels) | number | 0 | |
| target | specifies the scrollable area dom node | () => HTMLElement | () => window | | target | Specifies the scrollable area DOM node | () => HTMLElement | () => window | |
### events ### events
| Events Name | Description | Arguments | | Events Name | Description | Arguments | Version |
| ----------- | ---------------------------------------- | ----------------- | | ----------- | ---------------------------------------- | ----------------- | ------- |
| onChange | Callback for when affix state is changed | Function(affixed) | | 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 ```html
<a-affix :style="{ position: 'absolute', top: y, left: x}"> <a-affix :style="{ position: 'absolute', top: y, left: x}">
... ...
</a-affix> </a-affix>
``` ```
## 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)

View File

@ -8,29 +8,14 @@ import BaseMixin from '../_util/BaseMixin';
import throttleByAnimationFrame from '../_util/throttleByAnimationFrame'; import throttleByAnimationFrame from '../_util/throttleByAnimationFrame';
import { ConfigConsumerProps } from '../config-provider'; import { ConfigConsumerProps } from '../config-provider';
import Base from '../base'; import Base from '../base';
import warning from '../_util/warning';
function getTargetRect(target) { import {
return target !== window ? target.getBoundingClientRect() : { top: 0, left: 0, bottom: 0 }; addObserveTarget,
} removeObserveTarget,
getTargetRect,
function getOffset(element, target) { getFixedTop,
const elemRect = element.getBoundingClientRect(); getFixedBottom,
const targetRect = getTargetRect(target); } from './utils';
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,
};
}
function getDefaultTarget() { function getDefaultTarget() {
return typeof window !== 'undefined' ? window : null; return typeof window !== 'undefined' ? window : null;
@ -48,10 +33,13 @@ const AffixProps = {
/** 固定状态改变时触发的回调函数 */ /** 固定状态改变时触发的回调函数 */
// onChange?: (affixed?: boolean) => void; // onChange?: (affixed?: boolean) => void;
/** 设置 Affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 */ /** 设置 Affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 */
target: PropTypes.func, target: PropTypes.func.def(getDefaultTarget),
prefixCls: PropTypes.string, prefixCls: PropTypes.string,
}; };
const AffixStatus = {
None: 'none',
Prepare: 'Prepare',
};
const Affix = { const Affix = {
name: 'AAffix', name: 'AAffix',
props: AffixProps, props: AffixProps,
@ -60,192 +48,191 @@ const Affix = {
configProvider: { default: () => ConfigConsumerProps }, configProvider: { default: () => ConfigConsumerProps },
}, },
data() { data() {
this.events = ['resize', 'scroll', 'touchstart', 'touchmove', 'touchend', 'pageshow', 'load'];
this.eventHandlers = {};
return { return {
affixStyle: undefined, affixStyle: undefined,
placeholderStyle: undefined, placeholderStyle: undefined,
status: AffixStatus.None,
lastAffix: false,
prevTarget: null,
}; };
}, },
beforeMount() { beforeMount() {
this.updatePosition = throttleByAnimationFrame(this.updatePosition); this.updatePosition = throttleByAnimationFrame(this.updatePosition);
this.lazyUpdatePosition = throttleByAnimationFrame(this.lazyUpdatePosition);
}, },
mounted() { mounted() {
const target = this.target || getDefaultTarget; const { target } = this;
// Wait for parent component ref has its value if (target) {
this.timeout = setTimeout(() => { // [Legacy] Wait for parent component ref has its value.
this.setTargetEventListeners(target); // We should use target as directly element instead of function which makes element check hard.
// Mock Event object. this.timeout = setTimeout(() => {
this.updatePosition({}); addObserveTarget(target(), this);
}); // Mock Event object.
this.updatePosition();
});
}
},
updated() {
this.measure();
}, },
watch: { watch: {
target(val) { target(val) {
this.clearEventListeners(); let newTarget = null;
this.setTargetEventListeners(val); if (val) {
// Mock Event object. newTarget = val() || null;
this.updatePosition({}); }
if (this.prevTarget !== newTarget) {
removeObserveTarget(this);
if (newTarget) {
addObserveTarget(newTarget, this);
// Mock Event object.
this.updatePosition();
}
this.prevTarget = newTarget;
}
}, },
offsetTop() { offsetTop() {
this.updatePosition({}); this.updatePosition();
}, },
offsetBottom() { offsetBottom() {
this.updatePosition({}); this.updatePosition();
}, },
}, },
beforeDestroy() { beforeDestroy() {
this.clearEventListeners();
clearTimeout(this.timeout); clearTimeout(this.timeout);
removeObserveTarget(this);
this.updatePosition.cancel(); this.updatePosition.cancel();
}, },
methods: { methods: {
setAffixStyle(e, affixStyle) { getOffsetTop() {
const { target = getDefaultTarget } = this; const { offset, offsetBottom } = 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;
let { offsetTop } = 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 if (offsetBottom === undefined && offsetTop === undefined) {
// 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;
offsetTop = 0; 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(); getOffsetBottom() {
if (!target) { return this.offsetBottom;
},
// =================== Measure ===================
measure() {
const { status, lastAffix } = this;
const { target } = this;
if (
status !== AffixStatus.Prepare ||
!this.$refs.fixedNode ||
!this.$refs.placeholderNode ||
!target
) {
return; return;
} }
this.clearEventListeners();
this.events.forEach(eventName => { const offsetTop = this.getOffsetTop();
this.eventHandlers[eventName] = addEventListener(target, eventName, this.updatePosition); 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() { // @ts-ignore TS6133
this.events.forEach(eventName => { prepareMeasure() {
const handler = this.eventHandlers[eventName]; this.setState({
if (handler && handler.remove) { status: AffixStatus.Prepare,
handler.remove(); 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() { render() {
const { prefixCls, affixStyle, placeholderStyle, $slots, $props } = this; const { prefixCls, affixStyle, placeholderStyle, status, $slots, $props } = this;
const getPrefixCls = this.configProvider.getPrefixCls; const getPrefixCls = this.configProvider.getPrefixCls;
const className = classNames({ const className = classNames({
[getPrefixCls('affix', prefixCls)]: affixStyle, [getPrefixCls('affix', prefixCls)]: affixStyle,

View File

@ -1,16 +1,16 @@
## API ## API
| 成员 | 说明 | 类型 | 默认值 | | 成员 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | | offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | |
| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | | offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | |
| target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window | | target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window | |
### 事件 ### 事件
| 事件名称 | 说明 | 回调参数 | | 事件名称 | 说明 | 回调参数 | 版本 |
| -------- | ---------------------------- | ----------------- | | -------- | ---------------------------- | ----------------- | ---- |
| change | 固定状态改变时触发的回调函数 | Function(affixed) | | onChange | 固定状态改变时触发的回调函数 | Function(affixed) | 无 | |
**注意:**`Affix` 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 `Affix` 为绝对定位: **注意:**`Affix` 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 `Affix` 为绝对定位:
@ -19,3 +19,11 @@
... ...
</a-affix> </a-affix>
``` ```
## 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)

88
components/affix/utils.js Normal file
View File

@ -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();
}
});
}
}