给了一个很简单的需求, 某个页面需要做一个 BackTop
组件, 在页面滑动向下滑动到一定程度的时候(比如直接滑到底部)显示这个组件, 点击可以直接回到顶部, 该组件同时也消失
基础实现例子
本来想手写的, 但是公司用的 material-ui 这个组件库里直接搜到了例子, 这里简化一下代码:
function ScrollTop(props) {
const { children, window } = props;
const trigger = useScrollTrigger({
target: window ? window() : undefined,
disableHysteresis: true,
threshold: 100,
});
const handleClick = (event) => {
const anchor = (event.target.ownerDocument || document).querySelector('#back-to-top-anchor');
if (anchor) {
anchor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
return (
<Zoom in={trigger}>
<div onClick={handleClick} role="presentation" className={classes.root}>
{children}
</div>
</Zoom>
);
}
function App(props) {
return (
<React.Fragment>
<div id="back-to-top-anchor" />
<Container>
... // content
</Container>
<ScrollTop>
<KeyboardArrowUpIcon />
</ScrollTop>
</React.Fragment>
)
}
ReactDOM.render(<App />, document.querySelector('#root'));
思路
梳理一下思路:
- 首先有一个
useScrollTrigger()
的hook
返回一个trigger
状态, 这个状态用来判断是否显示BackTop
组件, 如果自己写的话可以用这个状态来控制 css 里的display: none
属性, 当然这里直接用了 material ui 里内置的<Zoom>
组件了 - 需要实现
handleClick()
, 具体细节为:- 有一个锚点(
anchor
)用于定位滚动到的地方, 例子里为<div id="back-to-top-anchor" />
- 点击的时候利用
scrollIntoView()
原生 api 滚动到锚点的位置
- 有一个锚点(
- 传入
children
, 可以自定义, 用于显示BackTop
组件的 UI, 这里简单使用<KeyboardArrowUpIcon />
思路可以说非常清晰了
useScrollTrigger()
这个 hooks
令我有些困惑, 他提供的参数 options
有三个属性: disableHysteresis
, target
, threshold
, 主要聊一聊前两个参数:
disableHysteresis
options.disableHysteresis
(Boolean [optional]): Defaults to false. Disable the hysteresis. Ignore the scroll direction when determining thetrigger
value.
hysteresis
这个词我根本不认识...于是直接谷歌, 发现了这么一个问题: What does hysteresis mean and how does it apply to computer science or programming?
该问题里的第一个回答重点如下:
Hysteresis characterizes a system whose behavior (output) does not only depend on its input at time t, but also on its past behavior, on the path it has followed.
也就是说他的值, 可能是根据上一次值来决定的, 这里联系一下源码来看
import * as React from 'react';
function defaultTrigger(store, options) {
const { disableHysteresis = false, threshold = 100, target } = options;
const previous = store.current;
if (target) {
// Get vertical scroll
store.current = target.pageYOffset !== undefined ? target.pageYOffset : target.scrollTop;
}
if (!disableHysteresis && previous !== undefined) {
if (store.current < previous) {
return false;
}
}
return store.current > threshold;
}
const defaultTarget = typeof window !== 'undefined' ? window : null;
export default function useScrollTrigger(options = {}) {
const { getTrigger = defaultTrigger, target = defaultTarget, ...other } = options;
const store = React.useRef();
const [trigger, setTrigger] = React.useState(() => getTrigger(store, other));
React.useEffect(() => {
const handleScroll = () => {
setTrigger(getTrigger(store, { target, ...other }));
};
handleScroll(); // Re-evaluate trigger when dependencies change
target.addEventListener('scroll', handleScroll);
return () => {
target.removeEventListener('scroll', handleScroll);
};
// See Option 3. https://github.com/facebook/react/issues/14476#issuecomment-471199055
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, getTrigger, JSON.stringify(other)]);
return trigger;
}
先提一点, 其中使用 useRef()
做数据存储, 存储当前(之前)所滑动的长度. 为啥不用 useState
是因为 useState
在你 state
发生改变的时候会重新渲染组件, 而 ref
被重新设置是不会触发重新渲染的. 他就是一个存取器用来存一些值, 组件重新渲染啥的和他无关
useRef() = useState({ current: initialValue })[0]
所以如果涉及到 UI 的需要依据某些变化的值来重新渲染的, 考虑用 useState
, 否则用 useRef
回到源码, 关于 disableHysteresis 的逻辑就在于那段 if
:
if (!disableHysteresis && previous !== undefined) {
if (store.current < previous) {
return false;
}
}
解读一下:
- 如果 disableHysteresis 为
false
(说明 Hysteresis 是被允许的) 并且,previous
(之前的滑动长度也是有的) - 并且如果当前的滑动长度小于之前保存的滑动长度, 那么直接返回
false
也就是说, 只要在某个阶段, 滚动条向上移动, 那么当前的滑动条长度(pageYOffset
或者是 scrollTop
) 一定是小于之前的, 同时 disableHysteresis
为 false
, 那么 trigger
直接就是 false
了, 根本不会再走 store.current > threshold
这个逻辑来判断...
这就造成了两种 UI 效果
- 当
disableHysteresis
为false
的时候, 即使BackTop
组件在最底部, 用户只要往上滚动一点点,BackTop
组件立马消失, 因为他根本不会再比较你当前的滑动条长度和给定的threshold
了(即使你是超过的...) - 当
disableHysteresis
为true
的时候, 老老实实比较当前滑动条长度和指定的threshold
. 所以往上滑的时候, 没超过threshold
是不会显示BackTop
组件的
最后来看下效果:
首先是 disableHysteresis = true
, 也就是不存在 Hysteresis
现象, 可以看到自始至终只有一个 Hysteresis not triggered
这条 log:
接着来看 disableHysteresis = false
, 也就是存在 Hysteresis
现象, 可以发现会有两条 log, 同时只要往上滑, 一定会是出现 Hysteresis not triggered
这条 log:
另: 我翻了一下 Ant Design 的 BackTop
组件, 貌似是没有提供这个功能, 当然用起来也是更加简单, 有兴趣可以去看看源码, 这里不做深究
target
options.target (Node [optional]): Defaults to window.
如果 target
是 window, 那没啥好纠结的, 但情况往往需要传入的是一个特定的元素, 比如可能是左侧侧边栏
回顾一下之前的源码:
target.addEventListener('scroll', handleScroll);
return () => {
target.removeEventListener('scroll', handleScroll);
};
对于给定的 target
做事件监听, 也就是说, 你必须非常精准的传入触发 scroll
事件的元素...
我为什么说是精准, 因为在当时项目里我为了传这个 dom 节点花了很大的力气, 如果没有正确传入这个 eventHandler
是根本不会触发的...
所以我当时想, 能不能传入一个不必太精准的 dom 节点, 也就是说做一下事件委托? 于是我尝试改了一下源码:
React.useEffect(() => {
const handleScroll = (e) => {
setTrigger(getTrigger(store, { target: e ? e.target : window, ...other }));
};
handleScroll(); // Re-evaluate trigger when dependencies change
target.addEventListener('scroll', handleScroll);
return () => {
target.removeEventListener('scroll', handleScroll);
};
}, [target, getTrigger, JSON.stringify(other)]);
然后尝试传入一个略微父级的元素, 发现根本不触发 handleScroll
...
遇到了一个之前一直很忽略的知识点: scroll
事件的目标元素是一个元素的话, 比如说是一个 div
, 那么此时事件只有从 document
到 div
的捕获阶段以及 div
的冒泡阶段.
也就是说, 尝试在父级监视 scroll
的冒泡阶段监视这一事件是无效的..., 而 addEventListener
里第三个参数默认是 false
指定在了冒泡阶段. 在这里做事件委托是失败的.
给一张 scroll
事件的事件流吧(红色以上不执行...):
当时因为这个问题卡了很久, 直到搜到了这个相关问题才得以解决: Listening to all scroll events on a page
就像回答里说的: 如果要做事件委托, 请将 addEventListener
的第三个参数改为 true
, 以方便在捕获阶段捕获事件
document.addEventListener('scroll', function(e){ }, true);
关于事件流, 事件委托和 scroll 事件的, 我推荐看这一篇文章: 你所不知道的scroll事件:为什么scroll事件会失效?
最后的最后, 我还是没改源码, 老老实实找到了对应的 dom 节点传过去, 最后项目里的代码大概可能长这样:
<MyScrollTop target={someRef.current ? someRef.current.parentNode : window} />
参考
- https://stackoverflow.com/a/30723677/12733140
- https://stackoverflow.com/a/5357969/12733140
- https://ayase.moe/2018/11/20/scroll-event/
- https://github.com/mui-org/material-ui/blob/master/packages/material-ui/src/useScrollTrigger/useScrollTrigger.js
- https://material-ui.com/components/app-bar/#back-to-top
- END -