You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何不使用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;

关键优化点

  1. 初始全量渲染按钮:把visibleButtons初始值设为所有按钮,这样首次渲染时所有功能按钮都能挂载到DOM,解决children为空的问题。
  2. 分阶段触发适配:
    • 首次渲染完所有按钮后,通过useEffect触发doAdapt计算哪些按钮能显示。
    • 窗口resize时,先重置显示所有按钮,再重新计算,避免resize过程中按钮逐步隐藏的奇怪视觉效果。
  3. 排除「更多」按钮干扰:给「更多」按钮加data-is-more-button属性,计算宽度时过滤掉它,避免影响判断。
  4. 清理重复监听:只绑定一次resize事件,原版本里重复绑定的问题也解决了。

为啥这么管用

  • 初始渲染时所有按钮都在DOM里,Array.from(mainToolbar.children)能正确拿到所有元素,不会空。
  • doAdapt遍历按钮判断可见性,更新状态后自动隐藏超出的按钮。
  • resize时先重置全量按钮,再计算,每次都用完整的按钮集合做基准,不会有累积误差。

内容的提问来源于stack exchange,提问作者Karol Studniarek

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 09:44:52