目的功能
- 支持最基本的滚动切换激活元素
- 支持配置可激活模块(例如五个元素中只有三个需要可激活)
- 支持吸顶配置
- 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。 - 最后是滚动结束距离相关的。参数为
scrollEndId和scrollEndOffsetTop,prop为isScrollEnded和scrollEndDistance。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。
解决方案有两种
- 副作用函数传入依赖项,每次依赖项变化时,都重新绑定事件监听。
- 使用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.min和node.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中添加对scrollEndNodeBottomOffsetTopRef和staticNavBarOffsetTopRef的维护
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中添加对isPinned和isScrollEnded的判断与更新
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…