if 组件是否存在_简单聊一聊一个 BackTop 组件

2b1ad11ec513b0dc3889a2ad6b8e5f3a.png

给了一个很简单的需求, 某个页面需要做一个 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 the trigger 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) 一定是小于之前的, 同时 disableHysteresisfalse, 那么 trigger 直接就是 false 了, 根本不会再走 store.current > threshold 这个逻辑来判断...

这就造成了两种 UI 效果

  • disableHysteresisfalse 的时候, 即使 BackTop 组件在最底部, 用户只要往上滚动一点点, BackTop 组件立马消失, 因为他根本不会再比较你当前的滑动条长度和给定的 threshold 了(即使你是超过的...)
  • disableHysteresistrue 的时候, 老老实实比较当前滑动条长度和指定的 threshold. 所以往上滑的时候, 没超过 threshold 是不会显示 BackTop 组件的

最后来看下效果:

首先是 disableHysteresis = true, 也就是不存在 Hysteresis 现象, 可以看到自始至终只有一个 Hysteresis not triggered 这条 log:

da550c464d2e2503e0b61ea63301d0c4.gif

接着来看 disableHysteresis = false, 也就是存在 Hysteresis 现象, 可以发现会有两条 log, 同时只要往上滑, 一定会是出现 Hysteresis not triggered 这条 log:

083e9f9f3aaafbe8821f705184898c86.gif

另: 我翻了一下 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, 那么此时事件只有从 documentdiv 的捕获阶段以及 div 的冒泡阶段.

也就是说, 尝试在父级监视 scroll 的冒泡阶段监视这一事件是无效的..., 而 addEventListener 里第三个参数默认是 false 指定在了冒泡阶段. 在这里做事件委托是失败的.

给一张 scroll 事件的事件流吧(红色以上不执行...):

426514bc2606105e0448c13af2d47b08.png

当时因为这个问题卡了很久, 直到搜到了这个相关问题才得以解决: 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 -

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值