Created
November 12, 2025 05:31
-
-
Save Wxh16144/e2498888fc009d5bd3fd071fc54493b1 to your computer and use it in GitHub Desktop.
基于 React 和 react-intersection-observer 实现的滚动监听(Scroll Spy)组件示例,支持菜单与内容区块的联动高亮
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useRef, useState, useContext, createContext, useCallback, useMemo } from "react"; | |
| import { useInView } from "react-intersection-observer"; | |
| // 1. Create a single context to hold everything | |
| type SpyContextValue = { | |
| root: HTMLElement | null; | |
| onRatioChange: (key: string, ratio: number) => void; | |
| }; | |
| const SpyContext = createContext<SpyContextValue>({ | |
| root: null, | |
| onRatioChange: () => {}, | |
| }); | |
| type SectionMeta = { key: string; label: string }; | |
| /** | |
| * 1. A simpler hook that only manages scroll-spy logic. | |
| * It receives an `onUpdate` callback from the parent to report the new active key. | |
| */ | |
| function useScrollSpy( | |
| sectionKeys: string[], | |
| onUpdate: (key: string) => void | |
| ) { | |
| const suppressRef = useRef(false); | |
| const ratiosRef = useRef<Record<string, number>>({}); | |
| const handleRatioChange = useCallback((key: string, ratio: number) => { | |
| ratiosRef.current[key] = ratio; | |
| if (suppressRef.current) return; | |
| let newActiveKey = sectionKeys[0] || ''; | |
| for (const k of sectionKeys) { | |
| if ((ratiosRef.current[k] ?? 0) > 0.2) { | |
| newActiveKey = k; | |
| break; | |
| } | |
| } | |
| onUpdate(newActiveKey); | |
| }, [sectionKeys, onUpdate]); | |
| // Expose a function to temporarily disable the scroll-spy | |
| const suppressWhile = useCallback((action: () => void) => { | |
| suppressRef.current = true; | |
| action(); | |
| setTimeout(() => { | |
| suppressRef.current = false; | |
| }, 800); // Adjust timeout to match scroll animation | |
| }, []); | |
| return { handleRatioChange, suppressWhile }; | |
| } | |
| // 4. Update InViewSection to consume the single context | |
| function InViewSection({ | |
| id, | |
| children, | |
| }: { | |
| id: string; | |
| children: React.ReactNode; | |
| }) { | |
| const { root, onRatioChange } = useContext(SpyContext); // Get both from one context | |
| const { ref } = useInView({ | |
| root, | |
| threshold: [0, 0.2, 0.4, 0.6, 0.8, 1.0], | |
| rootMargin: "0px 0px -20% 0px", | |
| onChange: (inView, entry) => { | |
| if (entry) { | |
| onRatioChange(entry.target.id, entry.intersectionRatio ?? 0); | |
| } | |
| }, | |
| }); | |
| // The wrapper now applies the id and ref | |
| return ( | |
| <div id={id} ref={ref}> | |
| {children} | |
| </div> | |
| ); | |
| } | |
| // --- Your Custom Components --- | |
| const ComponentA = () => ( | |
| <div style={{ background: 'lightcyan', padding: 20, height: 400, marginBottom: 20 }}> | |
| <h3>Component A</h3> | |
| </div> | |
| ); | |
| const ComponentB = () => ( | |
| <div style={{ background: 'lightgoldenrodyellow', padding: 20, height: 500, marginBottom: 20 }}> | |
| <h3>Component B</h3> | |
| </div> | |
| ); | |
| const ComponentC = () => ( | |
| <div style={{ background: 'lightpink', padding: 20, height: 600, marginBottom: 20 }}> | |
| <h3>Component C</h3> | |
| </div> | |
| ); | |
| // This array is for the menu and the hook, not for rendering | |
| const mySections: SectionMeta[] = [ | |
| { key: "comp-a", label: "Component A" }, | |
| { key: "comp-b", label: "Component B" }, | |
| { key: "comp-c", label: "Component C" }, | |
| ]; | |
| /** | |
| * 2. The parent component now holds all the state and primary logic. | |
| */ | |
| export default function LinkedMenu() { | |
| const [container, setContainer] = useState<HTMLDivElement | null>(null); | |
| const [activeKey, setActiveKey] = useState<string>(mySections[0]?.key ?? ""); | |
| const { handleRatioChange, suppressWhile } = useScrollSpy( | |
| mySections.map(s => s.key), | |
| (key) => setActiveKey(key) // The hook calls this to update parent's state | |
| ); | |
| const handleMenuClick = (key: string) => { | |
| const el = document.getElementById(key); | |
| if (!el) return; | |
| // Use the suppress function from the hook | |
| suppressWhile(() => { | |
| setActiveKey(key); // Parent updates its own state | |
| el.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }); | |
| }; | |
| // Memoize the context value to prevent unnecessary re-renders | |
| const contextValue = useMemo(() => ({ | |
| root: container, | |
| onRatioChange: handleRatioChange, | |
| }), [container, handleRatioChange]); | |
| return ( | |
| <div style={{ display: "flex", gap: 16 }}> | |
| <nav style={{ width: 200 }}> | |
| <ul style={{ listStyle: "none", padding: 0, margin: 0 }}> | |
| {mySections.map((s) => ( | |
| <li | |
| key={s.key} | |
| onClick={() => handleMenuClick(s.key)} | |
| style={{ | |
| padding: "8px 12px", | |
| cursor: "pointer", | |
| background: s.key === activeKey ? "#e6f7ff" : undefined, | |
| fontWeight: s.key === activeKey ? 600 : 400, | |
| borderRadius: 4, | |
| }} | |
| > | |
| {s.label} | |
| </li> | |
| ))} | |
| </ul> | |
| </nav> | |
| <div | |
| ref={setContainer} // A simple state setter is enough here | |
| style={{ | |
| height: 600, | |
| overflow: "auto", | |
| flex: 1, | |
| paddingRight: 8, | |
| border: "1px solid #eee", | |
| }} | |
| > | |
| {/* 3. Provide the single context value */} | |
| <SpyContext.Provider value={contextValue}> | |
| <InViewSection id="comp-a"> | |
| <ComponentA /> | |
| </InViewSection> | |
| <InViewSection id="comp-b"> | |
| <ComponentB /> | |
| </InViewSection> | |
| <InViewSection id="comp-c"> | |
| <ComponentC /> | |
| </InViewSection> | |
| </SpyContext.Provider> | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment