如何不使用setTimeout()实现带可折叠溢出按钮的响应式Toolbar
解决Tiptap Toolbar首次加载自适应问题,抛弃setTimeout的优化方案
问题背景
我在给Tiptap做自适应Toolbar,需求是把超出编辑器宽度的按钮塞进「更多」菜单里。一开始用useLayoutEffect加useRef测宽度实现,但首次加载时mainToolbar.children转数组居然是空的,非得拉一下窗口大小才触发适配。后来用setTimeout凑活能用,但代码又乱又冗余,想找个不用定时器的靠谱方案。
Toolbar核心结构简化后是这样:
return ( <nav className={cn("flex flex-grow gap-1 rounded-md bg-white p-1", className)} ref={mainToolbarRef} > <TooltipProvider> {/* 颜色选择器按钮 */} {isButtonVisible("color") && ( <Tooltip> {/* ... 按钮内容 */} </Tooltip> )} {/* 标题按钮 */} {isButtonVisible("heading") && ( <Popover> {/* ... 按钮内容 */} </Popover> )} {/* 更多按钮:放所有没显示的按钮 */} {showMore && ( <Popover> {/* ... 遍历渲染!isButtonVisible的按钮 */} </Popover> )} </TooltipProvider> </nav> );
问题出在哪
首次跑doAdapt的时候,明明mainToolbar.children是有11个元素的HTMLCollection,但Array.from转完就是空数组。原因很简单:
useLayoutEffect是DOM更新后同步执行,但此时visibleButtons初始是空的,那些靠isButtonVisible控制的按钮还没渲染出来- 代码里先调用
setVisibleButtons(button_name_list),但React状态更新是异步的,DOM不会立刻更新,所以这时候mainToolbar下面根本没子元素
优化方案(不用定时器)
核心就是等所有按钮都渲染到DOM里再测宽度,用状态更新后的钩子触发适配,同时避免重复绑定resize事件。
完整代码
import { useRef, useState, useLayoutEffect, useCallback, useEffect } from 'react'; // 假设这是你定义的所有按钮名称 const BUTTON_NAMES_LIST = ['color', 'heading', /* ... 其他按钮 */]; type ButtonName = typeof BUTTON_NAMES_LIST[number]; const EditorToolbar = ({ className }: { className?: string }) => { const mainToolbarRef = useRef<HTMLElement>(null); // 初始直接渲染所有按钮,确保DOM里有完整的按钮列表 const [visibleButtons, setVisibleButtons] = useState<ButtonName[]>([...BUTTON_NAMES_LIST]); const [showMore, setShowMore] = useState(false); // 判断按钮是否完全在Toolbar可视范围内 const isFullyVisible = useCallback((element: HTMLElement): boolean => { const rect = element.getBoundingClientRect(); const mainToolbarRect = mainToolbarRef.current?.getBoundingClientRect(); if (!mainToolbarRect) return false; // 给「更多」按钮留40px宽度 return rect.right <= mainToolbarRect.right - 40; }, []); const doAdapt = useCallback(() => { const mainToolbar = mainToolbarRef.current; if (!mainToolbar) return; // 把Toolbar子元素转成数组,过滤掉「更多」按钮本身 const buttonsArray = Array.from(mainToolbar.children) as HTMLElement[]; const functionalButtons = buttonsArray.filter(el => !el.dataset.isMoreButton); let isOverflowing = false; const tempVisibleButtons: ButtonName[] = []; // 逐个判断按钮是否可见,收集能显示的按钮 functionalButtons.forEach((button, index) => { if (isFullyVisible(button)) { tempVisibleButtons.push(BUTTON_NAMES_LIST[index]!); } else { isOverflowing = true; } }); setVisibleButtons(tempVisibleButtons); setShowMore(isOverflowing); }, [isFullyVisible]); // 首次渲染:所有按钮都挂载后,触发适配计算 useEffect(() => { doAdapt(); }, [visibleButtons]); // 依赖初始的全量visibleButtons,确保按钮都渲染完了 // 监听窗口resize,重置为显示所有按钮,再重新计算 useLayoutEffect(() => { const handleResize = () => { setVisibleButtons([...BUTTON_NAMES_LIST]); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // 当visibleButtons被重置为全量时,再次触发适配 useEffect(() => { if (visibleButtons.length === BUTTON_NAMES_LIST.length) { doAdapt(); } }, [visibleButtons, doAdapt]); // 判断按钮是否要显示在主Toolbar上 const isButtonVisible = (buttonName: ButtonName) => { return visibleButtons.includes(buttonName); }; return ( <nav className={cn("flex flex-grow gap-1 rounded-md bg-white p-1", className)} ref={mainToolbarRef} > <TooltipProvider> {/* 颜色选择器 */} {isButtonVisible("color") && ( <Tooltip> {/* ... 按钮具体内容 */} </Tooltip> )} {/* 标题按钮 */} {isButtonVisible("heading") && ( <Popover> {/* ... 按钮具体内容 */} </Popover> )} {/* 更多按钮:加个data属性方便过滤 */} {showMore && ( <Popover data-is-more-button> {BUTTON_NAMES_LIST.filter(name => !isButtonVisible(name)).map(name => ( // ... 渲染对应按钮 ))} </Popover> )} </TooltipProvider> </nav> ); }; export default EditorToolbar;
关键优化点
- 初始全量渲染按钮:把
visibleButtons初始值设为所有按钮,这样首次渲染时所有功能按钮都能挂载到DOM,解决children为空的问题。 - 分阶段触发适配:
- 首次渲染完所有按钮后,通过
useEffect触发doAdapt计算哪些按钮能显示。 - 窗口resize时,先重置显示所有按钮,再重新计算,避免resize过程中按钮逐步隐藏的奇怪视觉效果。
- 首次渲染完所有按钮后,通过
- 排除「更多」按钮干扰:给「更多」按钮加
data-is-more-button属性,计算宽度时过滤掉它,避免影响判断。 - 清理重复监听:只绑定一次resize事件,原版本里重复绑定的问题也解决了。
为啥这么管用
- 初始渲染时所有按钮都在DOM里,
Array.from(mainToolbar.children)能正确拿到所有元素,不会空。 doAdapt遍历按钮判断可见性,更新状态后自动隐藏超出的按钮。- resize时先重置全量按钮,再计算,每次都用完整的按钮集合做基准,不会有累积误差。
内容的提问来源于stack exchange,提问作者Karol Studniarek
相关产品推荐
相关产品推荐

