总结

这个组件比想象中的难啃很多

一开始甚至以为就是通过 position: sticky 的方式来控制目标元素定位的,揣测可能是由于兼容性原因放弃使用这种方式?

该组件实现的功能是:元素在滚动到某位置时固定在页面上。

其实现方法是:通过设置目标元素target(默认是 window)和offsetTop/offsetBottom的属性值来控制当前元素滚动到距离目标元素指定位置时,变为固定定位。

1
2
3
4
5
6
| 成员 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | |
| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | |
| target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window |
| onChange | 固定状态改变时触发的回调函数 | Function(affixed) | 无 |

代码分析

  1. 组件外层包裹ConfigConsumer组件 ,该组件注入的上下文中提供 getPrefixCls 方法用于计算类名

  2. children组件外层包裹三个div,前两个分别是placeholder和负责固定定位的affixplaceholder 是用于计算children的尺寸,一方面提供固定定位时脱离文档流的 affix 大小,一方面保持原有占位大小不变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Affix {
render () {
// 该组件利用 context 给子元素注入 getPrefixCls 方法
<ConfigConsumer>
{
({getPrefixCls}) => {
const className = getPrefixCls(...)
return (
<div {...props} style={mergedPlaceholderStyle} ref={this.savePlaceholderNode}>
<div className={className} ref={this.saveFixedNode} style={this.state.affixStyle}>
<ResizeObserver onResize={this.updatePosition}>{children}</ResizeObserver>
</div>
</div>
)
}
}
</ConfigConsumer>
}
}
  1. 第三个包裹的div就是ResizeObserver,其负责在元素resize时触发对应的 onResize 回调来更新子元素位置,可以看看该组件的写法,使用了比较新的 API:ResizeObserver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class ReactResizeObserver extends React.Component<ResizeObserverProps, {}> {
resizeObserver: ResizeObserver | null = null;

componentDidMount() {
this.onComponentUpdated();
}

componentDidUpdate() {
this.onComponentUpdated();
}

componentWillUnmount() {
this.destroyObserver();
}

onComponentUpdated() {
const { disabled } = this.props;
const element = findDOMNode(this) as DomElement;
if (!this.resizeObserver && !disabled && element) {
// Add resize observer
this.resizeObserver = new ResizeObserver(this.onResize);
this.resizeObserver.observe(element);
} else if (disabled) {
// Remove resize observer
this.destroyObserver();
}
}

onResize = () => {
const { onResize } = this.props;
if (onResize) {
onResize();
}
};

destroyObserver() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}

render() {
const { children = null } = this.props;
return children;
}
}
  1. 监听target元素的事件:

    • 组件在mount的时候会在target(被观察者) 上将Affix组件推入观察者列表中,并绑定被观察者的resize、scroll等事件,触发lazyUpdatePosition方法

    • 该方法会将affix的固定定位取消,进行组件重绘,从而触 measure方法,重计算affix与目标元素的相对位置以更新定位样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
componentDidMount() {
const { target } = this.props;
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(() => {
// 观察者列表的维护在 utils.ts 文件
addObserveTarget(target(), this);
// Mock Event object.
this.updatePosition({} as Event);
});
}
}

componentWillUnmount() {
clearTimeout(this.timeout);
//
removeObserveTarget(this);
(this.updatePosition as any).cancel();
}

  1. 另外值得一提的是这个组件用到的装饰器:throttleByAnimationFrameDecorator

    装饰器方法的实现文档可以参考文档,对类方法装饰时,三个参数分别是 target(class 本身)、key(方法名)、descriptor = Object.getOwnPropertyDescriptor(class, key) 属性描述符

    装饰器的目的主要是在 被装饰的方法上注入 requestAnimationFrame() 方法,并利用 raf 的机制进行 节流,raf 返回的 requestId 只有在内部重绘方法被浏览器调用后才会重置为 null,才有可能进行下一次调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default function throttleByAnimationFrame(fn: (...args: any[]) => void) {
let requestId: number | null;

const later = (args: any[]) => () => {
requestId = null;
fn(...args);
};

const throttled = (...args: any[]) => {
if (requestId == null) {
requestId = raf(later(args));
}
};

(throttled as any).cancel = () => raf.cancel(requestId!);

return throttled;
}

export function throttleByAnimationFrameDecorator() {
return function(target: any, key: string, descriptor: any) {
// target 是修饰的 class 对象
// key 修饰的方法名
// descriptor 该自有属性方法对应的属性描述符
...
}
}