记录一次react导航栏吸顶hook(useNav)的设计实现

1,995 阅读8分钟

目的功能

  • 支持最基本的滚动切换激活元素
  • 支持配置可激活模块(例如五个元素中只有三个需要可激活)
  • 支持吸顶配置
    • navBar应该是在某些模式下才需要吸顶
    • 各种模块顶部偏移量配置
  • 支持配置滚动结束通知
    • 例如在页面有十个模块,第五个模块滚动结束后要结束吸顶
  • 支持resize事件和模块高度变化时自动纠正数据

api设计

api设计其实特别重要,好的api可以让用户在较少配置的情况下,获得更佳的使用体验。本hook如果只要实现简单功能,返回给用户的数据仅需要activeIndex,setActiveIndex两个prop即可,前者告知用户当前激活元素是第几个,后者让用户可以设置当前激活元素。
除了返回给用户的prop外,hook也需要一个最基本的参数配置,一个id列表idList,告知需要监听哪些dom元素。 所以最精简版api如下:

const idList = ['id1', 'id2']
const [activeIndex, setActiveIndex] = useNav(idList)

但如若想要满足目的功能板块所有需求,还需要补充亿点点参数和返回值:

  • 顶部偏移量相关的问题,即具体模块滚动到页面什么位置才算是激活,例如滚动到距离顶部50px等。这个主动权应该留给hook的使用者,所以要再添加一个顶部偏移量offsetTop的配置项。
  • 除了考虑相关可激活元素外,还要考虑navBar相关问题,上面目的中说的是否吸顶等相关功能都是和navBar相关的。所以需要navBarId参数,以找到这个navBar元素。
  • navBar在滚动过程中会出现吸顶的效果,这里同样有个顶部偏移量的问题,例如在navBar滚动到距离顶部50px时开始触发吸顶。那这里就要补充一个navBarOffsetTop参数来传递该信息,同时返回给用户一个isPinned的prop,告知navBar是否处于吸顶状态。这里有必要多说一句的是,吸顶已经完全可以通过css来实现了,不过吸顶开始后还是有些交互需要js参与的,如移除原始顶部,或者navBar添加className做个动效等,所以仍然要有这么一个prop。
  • 最后是滚动结束距离相关的。参数为scrollEndIdscrollEndOffsetTop,prop为isScrollEndedscrollEndDistance
    • scrollEndId: 需要监听的滚动结束元素,记作scrollEndNode。
    • scrollEndOffsetTop: navBar吸顶时,其底部距离视口顶部的距离,主要是为了计算scrollEnd元素和吸顶导航栏底部重合时页面滚动的距离,也许叫pinnedNavBarBottomOffsetTop更好理解点,不过这样就和上面的命名不统一了。
    • isScrollEnded: scrollEndNode是否滚动到了底部和navBar底部重合的位置
    • scrollEndDistance: scrollEndNode底部和navBar底部重合时,页面滚动的距离。

最终api如下:

const { activeIndex, setActiveIndex, isPinned, isScrollEnded, scrollEndDistance } = useNav({
  idList: ['blockId1', 'blockId2', 'blockId3'],
  offsetTop: 0,
  navBarId: 'navBarId',
  navBarOffsetTop: 0,
  scrollEndId: 'blockId3',
  scrollEndOffsetTop: 100,
})

useNav函数ts类型如下:

type useNav=(
  config:{
    idList: string[]
    offsetTop?: number
    navBarId?: string
    navBarOffsetTop?: number
    scrollEndId?: string
    scrollEndOffsetTop?: number
  }
)=>{
    activeIndex: number
    setActiveIndex: (activeIndex: number, behavior: 'auto' | 'smooth') => void
    isPinned: boolean
    isScrollEnded: boolean
    scrollEndDistance: number
}

前置知识

MutationObserver

侦测dom元素变化,并异步调用回调函数。具体可以侦测dom元素属性变化,文本内容变化,子元素数量变化等,也能选择是否将该侦测用到所有后代节点。上文中说的模块内容变化以后,数据自动纠偏就是靠该api实现的。细节参考MutationObserver详细介绍

函数组件事件监听函数如何获取最新的状态

在react中启用事件监听,这里主要是指通过addEventListener启动的事件监听,如监听页面滚动,要写在useEffect中,示例代码如下:

const Demo = () => {
 const [obj, setObj] = useState({ a: { num: 1 } })

 useEffect(() => {
   window.addEventListener('scroll', () => {
     console.log(obj, obj.a.num)
   })
 }, [])
 const onClick = () => {
   const nObj = { a: { num: obj.a.num + 1 } }
   setObj(nObj)
 }
 return <button onClick={onClick}>click</button>
}

上述示例中,不论按钮是否被点击,滚动打印的num永远是0。这主要是因为useEffect依赖数组为空时,事件绑定只执行一次,其用到的变量会作为一个闭包保存在绑定函数上下文中。数据变化(setObj({ a: { num: obj.a.num + 1 } }))时,因为是给setObj函数传入了一个新值nObj,没有修改原始数据obj,而绑定函数闭包中存的就是原始数据obj,所以绑定函数拿不到最新的num

解决方案有两种

  1. 副作用函数传入依赖项,每次依赖项变化时,都重新绑定事件监听。
  2. 使用useRef对用到的变量生成一个reference(复杂数据类型)。绑定函数闭包中保存的reference其实是reference在js内存中的地址,每次修改reference.current,只会修改内存中reference的值,并不会修改其内存地址。这样绑定函数通过闭包中的地址就可以拿到新的值啦。

这两个方案各有优劣,方案1每次都要执行事件监听函数的解绑和重新绑定,有一定的性能消耗,而且从需求初衷上来看,我们只需要绑定一次就可以了。方案2则是要把用到的部分数据做映射,增加代码量和内存占用。其实这两个方案可以综合下,本hook实现上,对滚动中依赖的prop采用了方案一,对用到的state采用了方案二。

关于state和ref的选择

  • 内容改变需要引起ui变化的数据,要做成state
  • 内容改变并不会直接引起ui变化,但是数据内容需要在函数组件多次执行间同步的,做成ref。大部分情况下,不做成state的数据都做成ref即可。

功能实现及思路讲解

核心思路其实就是滚动监听元素所处的位置是否达到active的状态,到了以后修改activeIndex。滚动函数简略代码如下:

  const handleScroll = () => {
    const scrollTop = document.documentElement.scrollTop
    setActiveIndex(getNextActiveIndex(scrollTop, idList))
  }

上述getNextActiveIndex方法,在滚动时监听判断元素之间位置关系,但每次都通过idList拿到dom元素,再读取其位置关系做判断并不太合适,因为一般来说dom彻底加载完成以后,其位置关系就不会再变化了,所以最好是找到加载完成的时间节点,缓存dom元素的位置关系。该位置关系可以放到一个叫nodesRef的reference中(这里把nodes做成ref,主要原因就是nodes内保存的位置信息变化,并不会直接导致ui的变化),而nodesRef的生成和更新则主要由MutationObserver和监听resize事件来实现,简略代码如下:

const observerCallback = () => {
  nodesRef.current = generateNodes(idListRef.current, offsetTop)
}
  
useEffect(() => {
  const MutationObserver = window.MutationObserver
  const observer = new MutationObserver(observerCallback)
  // observer是异步的,所以直接观测最大的container即可
  observer.observe(document.body, {
    childList: true,
    subtree: true,
    characterData: true,
    attributes: true,
  })
  window.addEventListener('resize', observerCallback)
  return () => {
    observer.disconnect()
    window.removeEventListener('resize', observerCallback)
  }
}, [])

接下来是nodes分析,通过node自身高度,距顶高度和模块顶部偏移量(即offsetTop这个prop),计算出其占用的位置区间node.minnode.max,然后判断当前页面滚动距离scrollTop是否处于该区间,来确定具体激活哪个模块,其中nodes生成函数generateNodes代码如下:

interface NodeItem {
  min: number
  max: number
}

const generateNodes = (idList: string[], offsetTop: number) => {
  const nodes: NodeItem[] = []
  idList.map(id => {
    const node = document.getElementById(id)
    if (node) {
      nodes.push({
        min: node.offsetTop - offsetTop,
        max: node.offsetTop - offsetTop + node.offsetHeight,
      })
    }
  })
  return nodes
}

有了nodesRef以后,添加一些数据的初始化,以及修改handleScroll中更新activeIndex的逻辑

const [activeIndex, setActiveIndex] = useState(-1)
const activeIndexRef = useRef(activeIndex)
const lastActiveIndexRef = useRef(0)

// idList这个prop比较特殊,因为是复杂类型,所以可能触发hook的rerender,要做个shouldUpdate
const [ids, setIds] = useState(idList)

const nodesRef = useRef<NodeItem[]>([])
const lastScrollTopRef = useRef(0)

const handleScroll = useCallback(() => {

const scrollTop =
  document.documentElement.scrollTop ||
  window.pageYOffset ||
  document.body.scrollTop

  if (!nodesRef.current.length) nodesRef.current = generateNodes(ids, offsetTop)

  // 如果nodes还未渲染,不再执行后续逻辑
  if (!nodesRef.current.length) return 

  const target = binarySearch(nodesRef.current, scrollTop, {
    [lastScrollTopRef.current > scrollTop ? 'right' : 'left']:
      lastActiveIndexRef.current,
  })
  lastScrollTopRef.current = scrollTop

  const nActiveIndex = target ? (lastActiveIndexRef.current = target[1]) : -1
  if (activeIndexRef.current !== nActiveIndex) {
    setActiveIndex(nActiveIndex)
    activeIndexRef.current = nActiveIndex
  }
}, [offsetTop, ids])

上述代码中的binarySearch是查找具体激活元素的方法,该方法在这里不算重点,不再赘述,可以在demo中查看。目前为止,查找和切换activeIndex的功能已经实现了,后续是navBar吸顶和scrollEnd相关逻辑。
navBar相关state为是否吸顶isPinned,scrollEnd相关state为是否滚动超过了scrollEnd元素isScrollEnded。这两个数据都是需要在handleScroll中更新维护,为了保证这两个数据的正确性,还要添加一些辅助的ref,这些辅助ref主要是各种dom位置关系,需要在observerCallback中维护。需要添加的state和ref代码如下:

const [isPinned, setIsPinned] = useState(false)
const isPinnedRef = useRef(isPinned)

const [isScrollEnded, setIsScrollEnded] = useState(false)
const isScrollEndedRef = useRef(isScrollEnded)

// scrollEnd数据对应的node
const scrollEndNodeRef = useRef<HTMLElement | null>(null)
// scrollEnd元素底部距离页面顶部的距离
const scrollEndNodeBottomOffsetTopRef = useRef(0)
// 记录navBar在static定位时,距离顶部的距离
const staticNavBarOffsetTopRef = useRef(0)

为了获得staticNavBarOffsetTopRef需要在navBar前面添加一个高度为0的兄弟节点,该节点的offsetTop其实就是navBar在没有吸顶时的距顶高度,即staticNavBarOffsetTopRef,初始化并向dom结构中添加该节点:

// 辅助记录staticNavBarOffsetTopRef的元素
const navBarLastSiblingRef = useRef<HTMLDivElement>(document.createElement('div'))
  
useEffect(() => {
  if (navBarId) {
    const navBar = document.getElementById(navBarId)!
    const parentElement = navBar.parentElement!
    // 在顶部增加元素
    parentElement.insertBefore(navBarLastSiblingRef.current, navBar)
    staticNavBarOffsetTopRef.current = navBarLastSiblingRef.current.offsetTop
  }
}, [navBarId])

observerCallback中添加对scrollEndNodeBottomOffsetTopRefstaticNavBarOffsetTopRef的维护

const observerCallback = useCallback(() => {
  // 距离的计算,因为情况比较多,无法直接和初始值比较判断,所以每次要重新获取
  if (!scrollEndNodeRef.current) {
    scrollEndNodeRef.current = document.getElementById(scrollEndId)
  } else {
    const { offsetTop, offsetHeight } = scrollEndNodeRef.current
    scrollEndNodeBottomOffsetTopRef.current = offsetTop + offsetHeight
  }
  staticNavBarOffsetTopRef.current = navBarLastSiblingRef.current.offsetTop
  nodesRef.current = generateNodes(ids, offsetTop)
}, [scrollEndId, offsetTop, ids])

handleScroll中添加对isPinnedisScrollEnded的判断与更新

const handleScroll = useCallback(() => {
...
if (navBarId && staticNavBarOffsetTopRef.current) {
  const nPinned = scrollTop >= staticNavBarOffsetTopRef.current - navBarOffsetTop
  if (isPinnedRef.current !== nPinned) {
    setIsPinned(nPinned)
    isPinnedRef.current = nPinned
  }
}

if (scrollEndNodeRef.current && scrollEndNodeBottomOffsetTopRef.current) {
  const nScrollIsEnded = scrollTop > scrollEndNodeBottomOffsetTopRef.current - scrollEndOffsetTop
  if (isScrollEndedRef.current !== nScrollIsEnded) {
    setIsScrollEnded(nScrollIsEnded)
    isScrollEndedRef.current = nScrollIsEnded
  }
}
...
},[navBarId, offsetTop, navBarOffsetTop, scrollEndOffsetTop, ids])

至此,主要功能已经完成,剩余一些像idList的shouldUpdate操作,scrollEndNodeRef的赋值等可以在demo中查看。

demo

源码及效果演示demo地址: codesandbox.io/s/dark-plat…