React下拉菜单滚动时无法固定于触发按钮,求解决方案
问题描述
在React中构建下拉菜单时,点击触发按钮打开菜单显示正常,但滚动页面时,菜单会随页面滚动,无法保持在触发按钮旁,不符合预期。
相关代码
import { createContext, useContext, useState, useEffect } from "react"; import { createPortal } from "react-dom"; import { HiEllipsisVertical } from "react-icons/hi2"; import styled from "styled-components"; const Menu = styled.div` display: flex; align-items: center; justify-content: flex-end; `; const StyledToggle = styled.button` background: none; border: none; padding: 0.4rem; border-radius: var(--border-radius-sm); transform: translateX(0.8rem); transition: all 0.2s; &:hover { background-color: var(--color-grey-100); } & svg { width: 2.4rem; height: 2.4rem; color: var(--color-grey-700); } `; const StyledList = styled.ul` position: absolute; /* Should be relative to the button */ background-color: var(--color-grey-0); box-shadow: var(--shadow-md); border-radius: var(--border-radius-md); left: ${(props) => props.$position.x}px; top: ${(props) => props.$position.y}px; `; const StyledButton = styled.button` width: 100%; text-align: left; background: none; border: none; padding: 1.2rem 2.4rem; font-size: 1.4rem; transition: all 0.2s; display: flex; align-items: center; gap: 1.6rem; &:hover { background-color: var(--color-grey-50); } & svg { width: 1.6rem; height: 1.6rem; color: var(--color-grey-400); transition: all 0.3s; } `; const MenusContext = createContext(); function Menus({ children }) { const [openId, setOpenId] = useState(""); const [position, setPosition] = useState(null); const close = () => setOpenId(""); const open = (id) => setOpenId(id); return ( <MenusContext.Provider value={{ openId, close, open, position, setPosition }}> {children} </MenusContext.Provider> ); } function Toggle({ id }) { const { openId, close, open, setPosition } = useContext(MenusContext); function handleClick(e) { const rect = e.target.closest("button").getBoundingClientRect(); setPosition({ x: rect.x, // Position relative to the button's left corner y: rect.y + rect.height + 8, // Below the button }); openId === "" || openId !== id ? open(id) : close(); } return ( <StyledToggle onClick={handleClick}> <HiEllipsisVertical /> </StyledToggle> ); } function List({ id, children }) { const { openId, position, setPosition } = useContext(MenusContext); useEffect(() => { function updatePosition() { const toggleButton = document.querySelector(`button[data-id="${id}"]`); if (toggleButton) { const rect = toggleButton.getBoundingClientRect(); setPosition({ x: rect.x, y: rect.y + rect.height + 8, }); } } if (openId === id) { window.addEventListener("scroll", updatePosition); window.addEventListener("resize", updatePosition); updatePosition(); // Initial position update } return () => { window.removeEventListener("scroll", updatePosition); window.removeEventListener("resize", updatePosition); }; }, [openId, id, setPosition]); if (openId !== id) return null; return createPortal( <StyledList $position={position}>{children}</StyledList>, document.body ); } function Button({ children }) { return ( <li> <StyledButton>{children}</StyledButton> </li> ); } Menus.Menu = Menu; Menus.Toggle = Toggle; Menus.List = List; Menus.Button = Button; export default Menus;
问题
- 如何确保下拉菜单在页面滚动时仍固定于触发按钮旁?
- 在React中使用styled-components实现该行为,有哪些替代方法?
解决方案
问题1:修复滚动时菜单位置偏移
当前代码存在两个核心问题:触发按钮的选择器无效、位置坐标计算错误。
步骤1:给触发按钮添加标识属性
List组件中通过button[data-id="${id}"]查找按钮,但Toggle组件的按钮未设置该属性,导致无法获取按钮元素更新位置。修改Toggle组件:
function Toggle({ id }) { // ... 其他代码 return ( <StyledToggle onClick={handleClick} data-id={id}> <HiEllipsisVertical /> </StyledToggle> ); }
步骤2:修正坐标计算逻辑
getBoundingClientRect()返回的是元素相对于视口的位置,而挂载在document.body上的position: absolute元素是相对于文档定位的。需要将视口坐标转换为文档坐标,加上页面滚动偏移量:
// Toggle组件的handleClick函数 setPosition({ x: rect.x + window.scrollX, y: rect.y + rect.height + 8 + window.scrollY, }); // List组件的updatePosition函数 setPosition({ x: rect.x + window.scrollX, y: rect.y + rect.height + 8 + window.scrollY, });
步骤3:验证事件监听
修复上述问题后,scroll和resize事件会正确触发位置更新,菜单将随按钮滚动保持相对位置。
问题2:styled-components实现该行为的替代方法
方法1:使用position: fixed定位
将菜单的position改为fixed,直接使用视口坐标(无需添加滚动偏移量),因为fixed元素始终相对于视口定位,滚动时自动保持位置:
const StyledList = styled.ul` position: fixed; background-color: var(--color-grey-0); box-shadow: var(--shadow-md); border-radius: var(--border-radius-md); left: ${(props) => props.$position.x}px; top: ${(props) => props.$position.y}px; `;
注意:需额外处理菜单超出视口的边界问题。
方法2:取消Portal,利用父元素相对定位
将菜单放在触发按钮的父元素内,给父元素设置position: relative,菜单使用position: absolute即可自动相对于父元素定位,无需手动计算坐标:
// 修改Menu组件的样式 const Menu = styled.div` display: flex; align-items: center; justify-content: flex-end; position: relative; /* 添加相对定位 */ `; // 修改List组件,去掉Portal和位置计算 function List({ id, children }) { const { openId } = useContext(MenusContext); if (openId !== id) return null; return <StyledList>{children}</StyledList>; }
优点:无需手动处理坐标和滚动事件,代码更简洁;缺点:菜单会受父元素溢出隐藏的影响。
方法3:集成Popper.js
使用专门处理浮动元素定位的库(如@popperjs/core),自动处理滚动、边界检测等场景,结合styled-components使用:
// 安装依赖:npm install @popperjs/core import { createPopper } from '@popperjs/core'; import { useRef } from 'react'; function List({ id, children }) { const { openId } = useContext(MenusContext); const [popperElement, setPopperElement] = useState(null); const toggleRef = useRef(null); useEffect(() => { if (openId === id && toggleRef.current && popperElement) { const popper = createPopper(toggleRef.current, popperElement, { placement: 'bottom-start', // 设置菜单位置 }); return () => popper.destroy(); } }, [openId, id]); if (openId !== id) return null; return ( <StyledList ref={setPopperElement}>{children}</StyledList> ); } // 在Toggle组件中添加ref function Toggle({ id, ref }) { // ... 其他代码 return ( <StyledToggle ref={ref} onClick={handleClick} data-id={id}> <HiEllipsisVertical /> </StyledToggle> ); }
优点:专业处理各种复杂场景(如滚动、视口边界、动态元素),无需手动维护位置逻辑。
内容的提问来源于stack exchange,提问作者Yonathan Berhanu

