Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单聊一聊一个 BackTop 组件 #11

Open
hacker0limbo opened this issue Sep 28, 2020 · 0 comments
Open

简单聊一聊一个 BackTop 组件 #11

hacker0limbo opened this issue Sep 28, 2020 · 0 comments
Labels
react react 笔记整理

Comments

@hacker0limbo
Copy link
Owner

给了一个很简单的需求, 某个页面需要做一个 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:

disableHysteresis_true

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

disableHysteresis_false

另: 我翻了一下 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 事件的事件流吧(红色以上不执行...):

scroll-event-flow

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

参考

@hacker0limbo hacker0limbo added the react react 笔记整理 label Sep 28, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant