滚动吸顶 + 点击回到顶部 + 点击nav居中高亮 + 上啦加载 + 缓存scrollTop

1,774 阅读5分钟

楼主项目里没用ts ,赶时间写代码, 命名等不足请忽略, 不足之处请见谅!跪谢各位大佬给我点赞呀!!笔芯❤️

到顶部

/**
 * constructor:
 * distance 要滚动的距离
 * step 每一步要走的距离
 * delay 每一步要走的时间
 *
 * methods:
 * scrollTo : (distance :每次要滚动的距离) : void =>  滚动到顶部
 * getTimer : 返回当前定时器 判断是否正在滚动中
 * getInstance : 返回当前实例
 *
 * @class Scroll 滚动到顶部
 */
export class Scroll {
	constructor(distance, step, delay) {
		this.originDistance = distance
		this.distance = distance
		this.step = step
		this.delay = delay
		this.timer = null
	}

	static getInstance(distance, step = 40, delay = 15) {
		if (!this.instance) {
			this.instance = new Scroll(distance, step, delay)
		}
		return this.instance
	}

	scrollTo = (distance) => {
		if (typeof distance === 'undefined') {
			distance = 0
		}
		const nextDistance =
			distance + this.step <= this.originDistance
				? distance + this.step
				: this.originDistance
		this.timer = setTimeout(() => {
			document.documentElement.scrollTop = nextDistance
			document.body.scrollTop = nextDistance

			if (nextDistance === this.originDistance) {
				clearTimeout(this.timer)
				this.timer = null
				return
			} else {
				return this.scrollTo(nextDistance)
			}
		}, this.delay)
	}

	get getTimer() {
		return this.timer
	}
}

// 调用
 const topHeight = this.topRef.getBoundingClientRect().height
      const scrollToTop = Scroll.getInstance(topHeight)
      if (scrollToTop.getTimer) {
        return false
      }
      scrollToTop.scrollTo()

滚动到顶部 使用Window.requestAnimationFrame 更加顺滑 没有卡顿

export class Scroll {
	constructor(distance, step) {
		this.originDistance = distance
		this.distance = distance
		this.step = step
		this.timer = null
	}

	static getInstance(distance, step = 50) {
		if (!this.instance) {
			this.instance = new Scroll(distance, step)
		}
		return this.instance
	}

	scrollTo = () => {
		requestAFrame(()=>this._AnimationCb())
	}

  _AnimationCb = (distance) => {
		if (typeof distance === "undefined") {
			distance = 0
		}
		const nextDistance =
			distance + this.step <= this.originDistance
				? distance + this.step
				: this.originDistance
		document.documentElement.scrollTop = nextDistance
    document.body.scrollTop = nextDistance
		if (nextDistance !== this.originDistance) {
			requestAFrame(() => this._AnimationCb(nextDistance))
		}
	}
	get getTimer() {
		return this.timer
	}
}

滚动吸顶

import React, { useRef, useEffect, useState, useCallback } from "react"
// import PropTypes from "prop-types"
import classNames from "classnames"
import { debounce, getScrollTop, getOffsetTop } from "./utils"
import "./index.less"

export const StickyTab = props => {
  const tabRef = useRef(null)
  const [ shouldSticky, setShouldSticky ] = useState(false)
  const [ inintialOffsetTop, setInintialOffsetTop ] = useState(0)
  const [ emptyDivHeight, setEmptyDivHeight ] = useState(0)
  
  useEffect(() => {
    if (tabRef.current) {
      const height = tabRef.current.getBoundingClientRect().height
      setEmptyDivHeight(height)
      setInintialOffsetTop(getOffsetTop(tabRef.current))
      handleScroll()
    }
    return () => {}
  }, [])

  useEffect(
    () => {
      const handle = debounce(handleScroll, 100)
      window.addEventListener("scroll", handle)
      return () => {
        window.removeEventListener("scroll", handle)
      }
    },
    [ shouldSticky ]
  )

  const handleScroll = useCallback(
    function() {
      const scrollTop = getScrollTop()
      const DIST = 10
      if (!shouldSticky && scrollTop >= getOffsetTop(tabRef.current) - DIST) {
        setShouldSticky(true)
      } else if (shouldSticky && scrollTop < inintialOffsetTop - DIST) {
        setShouldSticky(false)
      }
    },
    [ shouldSticky ]
  )

  const cls = classNames("sticky-tab", {
    "sticky-tab-fixed": shouldSticky,
  })
  return (
    <div>
      <div ref={tabRef} className={cls}>
        {props.children}
      </div>
      <div style={{ height: emptyDivHeight + "px", display: shouldSticky ? "block" : "none" }} />
    </div>
  )
}

StickyTab.defaultProps = {
  
}
StickyTab.PropTypes = {}

export default StickyTab


// ./utils
// 获取屏幕的宽度
export const SW =
  window.innerWidth ||
  document.documentElement.clientWidth ||
  document.body.clientWidth

// 获取屏幕的高度
export const SH =
  window.innerHeight ||
  document.documentElement.clientHeight ||
  document.body.clientHeight

// 获取容器滚动的距离
export const getScrollTop = node => {
  let scrollTop
  if (typeof node === "undefined") {
    scrollTop =
      window.pageYOffset ||
      document.documentElement.scrollTop ||
      document.body.scrollTop
  } else {
    scrollTop = node.scrollTop
  }
  return scrollTop
}


// 获取元素距离可视区域顶部的 offsetTop
export const getOffsetTop = target => {
  let top = target.offsetTop
  let parent = target.offsetParent
  while (parent) {
    top += parent.offsetTop
    parent = parent.offsetParent
  }
  return top
}


// 防抖
export function debounce(func, time) {
  let timer = null
  return function() {
    const that = this
    const argus = arguments
    clearTimeout(timer)
    timer = setTimeout(function() {
      func.apply(that, argus)
    }, time)
  }
}
// ./index.less
.sticky-tab {
  
}

.sticky-tab-fixed{
  position: fixed;
  top: 0;
  left: 0;
}



点击nav居中


import React, {
	useEffect,
	useRef,
	useState,
	useMemo,
	useCallback,
	memo,
} from "react"
import classNames from "classnames"
import PropTypes from "prop-types"
import { findShouldMoveToCenterMinIndex, clientWidth } from "./config"
import { debounce } from "../../utils"
import "./index.less"

export const NavBar = (props) => {
	const [ width, setWidth ] = useState(null)
	const [ activeIndex, setActiveIndex ] = useState(
		props.originActiveIndex || 0
	)
	const [ shouldOverflowHidden, setShouldOverflowHidden ] = useState(false)
	const [ translateX, setTranslateX ] = useState(0)
	const contentRef = useRef(null)

	useEffect(
		() => {
			if (!props.navs.length) return
			const singleWidth = 72
			let width = props.navs.length * singleWidth
			if (width < clientWidth) {
				width = clientWidth
				setShouldOverflowHidden(true)
			}
			setWidth(width)
			const onScroll = debounce(() => {
				setTranslateX(0)
			}, 100)
			if (contentRef.current) {
				contentRef.current.addEventListener("scroll", onScroll, false)
			}
			return () => {
				contentRef.current.removeEventListener(
					"scroll",
					onScroll,
					false
				)
			}
		},
		[ props.navs, translateX ]
	)

	const handleClick = useCallback(
		(e, index, value) => {
			if (index !== activeIndex) {
				props.onClick(value)
			}
			setActiveIndex(index)
			if (shouldOverflowHidden) return false
			const ElWidth = e.target.getBoundingClientRect().width
			const center = findShouldMoveToCenterMinIndex(
				clientWidth,
				ElWidth
			)()
			const min = center
			const max = props.navs.length - center - 1
			const contentScrollLeft = contentRef.current.scrollLeft
			if (index <= min) {
				// 开头区间的 点击元素的left < center 则需要移动到center
				const leftDistance = 0 - contentScrollLeft
				setTranslateX(-leftDistance)
			} else if (index > min && index < max) {
				// 只要是中间区间的 都要移动
				const distance = ElWidth * index - clientWidth / 2
				const leftDistance = distance - contentScrollLeft
				setTranslateX(-leftDistance)
			} else if (index >= max) {
				const distance = ElWidth * props.navs.length - clientWidth
				const leftDistance = distance - contentScrollLeft
				setTranslateX(-leftDistance)
			}
		},
		[ activeIndex ]
	)

	const navBarCls = useMemo(
		() =>
			classNames("newcomer_zone-navbar", {
				"newcomer_zone-navbar-overflow": shouldOverflowHidden,
			}),
		[ shouldOverflowHidden ]
	)

	const contentCls = useMemo(
		() =>
			classNames("content", {
				"content-overflow": shouldOverflowHidden,
			}),
		[ shouldOverflowHidden ]
	)

	return (
		<div className={navBarCls} ref={contentRef}>
			{width ? (
				<div
					style={{
						width: width + "px",
						transform: `translate(${translateX}px, 0px)`,
					}}
					className={contentCls}
				>
					{props.navs.map((nav, index) => {
						const barCls = classNames({
							active: activeIndex === index,
							"bar-overflow": shouldOverflowHidden,
							bar: !shouldOverflowHidden,
						})
						const style = {
							width: width / props.navs.length + "px",
						}
						return (
							<div
								key={`${nav.value}-${index}`}
								className={barCls}
								style={style}
								onClick={(e) =>
									handleClick(e, index, nav.value)}
							>
								{nav.label}
							</div>
						)
					})}
				</div>
			) : null}
		</div>
	)
}

NavBar.defaultProps = {
	originActiveIndex: 0,
}
NavBar.propTypes = {
	navs: PropTypes.array.isRequired,
	originActiveIndex: PropTypes.oneOfType([
		PropTypes.string,
		PropTypes.number,
	]),
	onClick: PropTypes.func.isRequired,
}

export default memo(NavBar)


// ./utils
function findShouldMoveToCenter(clientWidth, ElWidth) {
  let index = 0
  return function loop() {
    if (index * ElWidth < clientWidth / 2) {
      index++
      return loop()
    } else {
      return index - 1
    }
  }
}

 
const clientWidth =
  window.innerWidth ||
  document.documentElement.clientWidth ||
  document.body.clientWidth

export {findShouldMoveToCenter, clientWidth}
 
 // ./index.less
 
.midou-navbar {
  width: 100vw;
  overflow-x: auto;
  height: 40 * @rex;
  background: rgba(255, 101, 118, 1);
  .content {
    display: flex;

    .bar {
      flex-grow: 0;
      flex-shrink: 0;
      text-align: center;
      width: 54 * @rex;
      height: 40 * @rex;
      font-size: 13 * @rex;
      font-weight: 400;
      color: rgba(255, 255, 255, 1);
      line-height: 40 * @rex;
    }

    .bar-overflow {
      flex-grow: 0;
      flex-shrink: 0;
      text-align: center;
      height: 40 * @rex;
      font-size: 13 * @rex;
      font-weight: 400;
      color: rgba(255, 255, 255, 1);
      line-height: 40 * @rex;
    }

    .active {
      background: rgba(240, 57, 78, 1);
    }
  }

  .content-overflow {
    justify-content: space-between;
  }
}


.midou-navbar-overflow {
  overflow: hidden;
}

上啦加载


import React, { memo } from "react"
import loading from "./loading.png"
import { useScroll } from "./useScroll"
import "./index.less"

const text = {
  LOADING: {
    text: "正在拼命加载",
    imgSrc: loading,
  },
  LOAMORE: { text: "上拉加载更多..." },
  NOMORE: { text: "我也是有底线的" },
}

const LoadMore = props => {
  useScroll(props)
  let { loading } = props

  loading = loading.toUpperCase()
  const config = text[loading] || text["LOAMORE"]
  return (
    <div className="loading-midou">
      <div className="loading-center">
        {config && config.imgSrc ? (
          <img src={config.imgSrc} alt="" className="loading-img" />
        ) : null}
        <div className="loading-text">{config.text}</div>
      </div>
    </div>
  )
}

export default memo(LoadMore)


// ./useScroll
import { useEffect, useRef } from "react"
import { debounce } from "../../utils"
// 获取容器滚动的距离
const getScrollTop = node => {
  let scrollTop
  if (typeof node === "undefined") {
    scrollTop =
      window.pageYOffset ||
      document.documentElement.scrollTop ||
      document.body.scrollTop
  } else {
    scrollTop = node.scrollTop
  }
  return scrollTop
}
// 获取屏幕的高度
const SH =
  window.innerHeight ||
  document.documentElement.clientHeight ||
  document.body.clientHeight
const DIST = 100

export const useScroll = ({ onLoad, loading }) => {
  const disable = useRef(false)

  useEffect(() => {
    function handleScroll(event) {
      if (disable.current) return
      const scrollTop = getScrollTop()
      const scrollHeight =
        document.documentElement.scrollHeight || document.body.scrollHeight
      if (scrollTop >= scrollHeight - SH - DIST) {
        disable.current = true
        onLoad()
      }
    }
    const onScroll = debounce(handleScroll, 200)
    window.addEventListener("scroll", onScroll, false)
    return () => {
      window.removeEventListener("scroll", onScroll, false)
    }
  }, [])

  useEffect(() => {
    if (loading === "LOADMORE") {
      disable.current = false
    }
  }, [loading])
}



// ./index.less 

.loading-midou {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #999;

  .loading-center {
    margin: 10* @rex 0;
  }

  .loading-text {
    display: inline-block;

  }

  .loading-img {
    display: inline-block;
    width: 18 * @rex;
    height: 18 * @rex;
    animation: load 1.1s infinite linear;
    margin-right: 4* @rex;
  }

  @keyframes load {
    0% {
      transform: rotate(0);
      opacity: 1;
    }

    50% {
      transform: rotate(180);
      opacity: 0.5;
    }

    100% {
      transform: rotate(360deg);
      opacity: 1;
    }
  }
}


多导航时候 缓存scrollTop

// 判断点击是否需要吸顶(最小高度)  不设置为单例 所以记得unmount的时候销毁
// pravite
// 1. 缓存旧的 scrolltop ✅
// 2. 设置当前点击 active 的 scrolltop ✅
// 3. 判断是否吸顶
// 4. 设置列表的height min-height 为 100vh

// public
// 1. getScrollTop
// 2. onChange
// 3. setMinScrollTop
export class CacheListScrollTop {
	constructor(...rest) {
		this._init(...rest)
	}
	_init(minScrollTop, activeIndex, node) {
		this.minScrollTop = minScrollTop
		this.shouldSetMinScrollTop = false
		this.activeIndex = activeIndex || 0 // 设置起始值
		this.node = node
		this.scrollTop = new Map()
		this.DIST = 1
	}

	// 保存scrolltop
	_cachePreviousScrollTop() {
		const prevoisActiveIndex = this.activeIndex
		const body = document.documentElement.scrollTop
			? document.documentElement
			: document.body
		const node = this.node || body
		const prevoisTop = Math.abs(node.getBoundingClientRect().top)
		const scrollTop = new Map(this.scrollTop)
		scrollTop.set(prevoisActiveIndex, prevoisTop)
		this.scrollTop = scrollTop
	}
	_setNextScrollTop(index) {
		this._cachePreviousScrollTop()
		this.prevoisIndex = this.activeIndex
		this.activeIndex = Number(index)
		const activeNavScrollTop = this.scrollTop.get(Number(index)) || 0

		// 设置最小值 scrollTop <= 最小高度 => 设置最小吸顶量
		if (
			activeNavScrollTop <= this.minScrollTop &&
			this.shouldSetMinScrollTop
		) {
			this._setScrollTop(this.minScrollTop + this.DIST)
			return false
		}

		// 设置最小值 scrollTop > 最小高度  => 设置 scrollTop
		if (
			activeNavScrollTop > this.minScrollTop &&
			this.shouldSetMinScrollTop
		) {
			this._setScrollTop(activeNavScrollTop)
			return false
		}
		// 不设置最小值  统一设置 scrollTop
		const body = document.documentElement.scrollTop
			? document.documentElement
			: document.body
		const node = this.node || body
		const prevoisTop = Math.abs(node.getBoundingClientRect().top)
		const keys = this.scrollTop.keys()
		const scrollTop = new Map()
		;([ ...keys ] || []).forEach((key) => {
			scrollTop.set(key, prevoisTop)
		})
		this.scrollTop = scrollTop
		this._setScrollTop(prevoisTop)
		return false
	}
	_setScrollTop(scrollTop) {
		if (this.node) {
			this.node.scrollTop = scrollTop
			return
		}
		document.documentElement.scrollTop = scrollTop
		document.body.scrollTop = scrollTop
	}

	// 获取scrollTop 集合
	get getScrollTop() {
		return this.scrollTop
	}

	// scrollTop是否设置 最小值
	setMinScrollTop(shouldSetMinScrollTop ) {
		this.shouldSetMinScrollTop = shouldSetMinScrollTop 
	}

	onChange(index) {
		this._setNextScrollTop(index)
	}
}


调用


// 如何调用
componentDidUpdate() {
    super.componentDidUpdate()
    if(!this.CacheListScrollTop && this.topRef){
      const topHeight = this.topRef.getBoundingClientRect().height
      this.CacheListScrollTop = new CacheListScrollTop(topHeight)
    }
  }

  componentWillUnmount() {
    super.componentWillUnmount()
    this.CacheListScrollTop = null;
  }