返回文章

菜单文本渲染截取合理的字符数

借助一个隐藏的测量元素与二分查找,让菜单文字在有限宽度内“尽量多显示、且不切断最后一个字符”。

本文由 AI 根据一次真实的技术讨论整理生成,代码与结论均来源于该讨论。

菜单里放一段长名称,是前端再常见不过的场景。一个侧边栏条目、一个工具栏按钮,文字稍长就会挤出容器,要么换行破坏布局,要么被 text-overflow: ellipsis 裁成一句带省略号的残句。

这篇文章要解决的是一个更细的问题:如何按菜单项实际可用的宽度,把文本截取到「能完整显示的字符数」——既不浪费空间,也不把最后一个字符切成一半。

比如菜单可用宽度 200px,原始名称是「这是一个非常非常长的菜单名称」。理想结果是:

[ 图标 ] 这是一个非常长的菜单名称 [按钮]

而不是:

[ 图标 ] 这是一个非常长的菜单名… [按钮]

一、整体思路

先说明一个关键事实:字符数和像素宽度之间没有固定比例。

中文和英文宽度不同,iiiiiiii 和 WWWWWWWW 同样 8 个字符但宽度差出一截,再加上字号、字重、字体的影响,靠「一个汉字 16px」这类经验值去估算,结果一定不准。

所以最可靠的办法只有一个:让浏览器自己渲染,然后问它「这串文字实际有多宽?」

围绕这一点,整个方案分成四步:

  1. 算空间 —— 算出菜单留给文字的可用像素宽度;
  2. 做尺子 —— 创建一个隐藏元素,让它与真实文字使用完全相同的字体;
  3. 二分试文字 —— 用二分查找找出「放得下的最大字符数」;
  4. 截断文字 —— 按这个字符数截取,并销毁测量元素。

二、函数入口与前置校验

export function fitMenuText(label, text) {
    if (!label || !label.parentElement) return;

    const parent = label.parentElement;
    // ...
}

函数接收两个参数:

参数 含义
label 页面上承载文字的 DOM 元素
text 这段文字原本应该显示的完整内容

对应的页面结构大致是这样:

<div class="menu">
    <i class="icon"></i>
    <span class="label">这是一个非常非常长的菜单名称</span>
    <button>删除</button>
</div>

进入函数后的第一件事是防御性检查:如果 label 不存在,或者它没有父元素,直接返回。父元素是必需的,因为后续所有宽度计算都依赖它——我们需要知道「这个文字所在的整个菜单到底有多宽」,以及除文字之外还有哪些元素在抢占横向空间。

const parent = label.parentElement;

三、创建一把隐藏的「尺子」

const measure = document.createElement('span');
const style = window.getComputedStyle(label);

measure.style.cssText = `
    position:absolute;
    visibility:hidden;
    white-space:nowrap;
    font:${style.font};
    font-size:${style.fontSize};
    font-weight:${style.fontWeight}
`;

document.body.appendChild(measure);

这几行的作用是创建一个不给用户看、只用于测量的 <span>,并让它的字体生效值与真实文字完全一致:

  • window.getComputedStyle(label) 取的是「最终实际生效」的 CSS,而不是样式表里写的那条规则,因此继承来的字号、字重也能一并拿到;
  • font 简写属性负责字体族、字号、行高等,后面再单独补上 font-size、font-weight 是为了防止简写被覆盖或解析不全;
  • position: absolute 让尺子脱离文档流,不会挤动页面已有布局;
  • visibility: hidden 让用户看不见它;
  • white-space: nowrap 保证文字不换行,测到的才是「单行完整宽度」。

这里有个容易被忽略的点:尺子必须真的挂到 document.body 上。 游离的 DOM 节点不会参与渲染,也就测不出宽度;只有进入文档后,getBoundingClientRect().width 才会返回真实结果。

四、计算「别人占掉了多少宽度」

const siblingsWidth = Array.from(parent.children)
    .filter(child => child !== label)
    .reduce((width, child) => {
        const childStyle = window.getComputedStyle(child);
        return width
            + child.getBoundingClientRect().width
            + parseFloat(childStyle.marginLeft)
            + parseFloat(childStyle.marginRight);
    }, 0);

这一段虽然语法密度高,但拆开看并不复杂。

parent.children 拿到所有子元素,例如 [i.icon, span.label, button]。

.filter(child => child !== label) 把 label 自己排除掉——因为我们此刻要算的是「除了文字,其他东西占了多少」。剩下 [i.icon, button]。

.reduce(...) 把这一串元素逐个累加成一个数。可以先用一个更简单的例子理解它:

[10, 20, 30].reduce((sum, item) => sum + item, 0);
// 0 + 10 + 20 + 30 = 60

这里的累加项是三项之和:

child.getBoundingClientRect().width   // 元素自身宽度
+ parseFloat(childStyle.marginLeft)   // 左外边距
+ parseFloat(childStyle.marginRight)  // 右外边距

注意 getBoundingClientRect().width 不包含 margin,所以必须手动加上外间距,得到的才是这个兄弟元素真正吃掉的横向空间。比如按钮宽 50px、左右各有 10px margin,实际占用就是 70px。

parseFloat 则负责把 "10px" 这类字符串转成数字 10。

五、计算文字的可用宽度

const parentStyle = window.getComputedStyle(parent);
const textIndent = parseFloat(parentStyle.textIndent) || 0;

let parentWidth = parent.clientWidth;
let widthSource = parent;

while (!parentWidth && widthSource.parentElement) {
    widthSource = widthSource.parentElement;
    parentWidth = parseFloat(window.getComputedStyle(widthSource).width) || 0;
}

const availableWidth = Math.max(
    0,
    parentWidth
    - siblingsWidth
    - parseFloat(parentStyle.paddingLeft)
    - parseFloat(parentStyle.paddingRight)
    - textIndent
    - 2
);

核心公式就是最后那段减法:

文字可用宽度 = 菜单总宽度
             - 其他元素占用宽度
             - 左右 padding
             - 文本缩进 text-indent
             - 2px 安全余量

举一个具体例子:菜单总宽 300px,图标 30px、按钮 40px,左右 padding 各 10px,无缩进,安全余量 2px,那么文字可用宽度为:

300 - 30 - 40 - 10 - 10 - 0 - 2 = 208px

这里的两点值得单独说一说。

text-indent 为什么要减? 因为它不体现在 padding 里,却实实在在地把首行文字向右推,属于被占用掉的空间。

- 2 是什么? 是一个经验性的安全余量。不同浏览器在亚像素舍入上会有微小差异,getBoundingClientRect() 返回的是浮点数,留 2px 可以避免「算出来刚好放得下、渲染时却挤掉半个字符」这种临界问题。最后再套一层 Math.max(0, ...),防止极端情况下算出负数。

那段 while 循环是做什么的?

正常路径下 parent.clientWidth 就能拿到内容区宽度。但有些场景拿不到——元素尚未完成布局、处于隐藏状态、或者宽度计算异常,此时 clientWidth 会返回 0。这段循环的处理方式是:如果当前元素宽度为 0,就沿着祖先链往上找,直到找到一个有宽度的元素。

body
 └── page
      └── menu   ← 当前 parent,宽度为 0
           └── label

先试 menu,为 0 就试 page,再为 0 就试 body,&& widthSource.parentElement 则保证找到根节点后会停止,不会无限循环。

六、二分查找「最多能放几个字符」

const characters = Array.from(text || '');
let left = 0;
let right = characters.length;

while (left < right) {
    const middle = Math.ceil((left + right) / 2);
    measure.textContent = characters.slice(0, middle).join('');

    if (measure.getBoundingClientRect().width <= availableWidth) {
        left = middle;
    } else {
        right = middle - 1;
    }
}

label.textContent = characters.slice(0, left).join('');
measure.remove();

为什么用 Array.from 而不是 split('')?

因为 Array.from 按 Unicode 码点切分,对 emoji、代理对字符更友好;split('') 会把一个代理对拆成两半,导致截断出乱码。既然这里要「按字符截」,用 Array.from 更稳妥。

left / right 的语义,是当前搜索区间的两端。假设共 20 个字符,那么一开始 left = 0、right = 20,表示答案在 0 ~ 20 之间——即「最多能显示几个字符」。

为什么不一个一个试? 当然可以写成 for 循环从 1 试到 20,但这里存在一个非常适合二分的性质:

如果前 5 个字符放得下,那么前 4、3、2、1 个一定也放得下;如果前 10 个放不下,那么 11、12、13 个一定也放不下。

也就是说,「放得下」与「放不下」之间存在唯一的分界点,前面连续成立、后面连续不成立,形状是这样的:

能放下 能放下 能放下 能放下 | 放不下 放不下 放不下
                              ↑ 要找的边界

于是每一轮都可以直接试中间值 Math.ceil((left + right) / 2),把候选区间砍掉一半:

  • 把前 middle 个字符塞进尺子,量出 middle 个字符的实际宽度;
  • 若宽度 <= availableWidth,说明 middle 可行,答案一定不小于它,收缩为 left = middle;
  • 若超宽,说明 middle 及其之后都不行,收缩为 right = middle - 1。

这个过程就是一遍遍缩小范围,直到 left === right,此时 left 就是放得下的最大字符数。

七、收尾:写回文本并销毁尺子

label.textContent = characters.slice(0, left).join('');
measure.remove();

得到 left 之后,取其前 left 个字符拼回字符串写进 label,截断完成。

最后一行 measure.remove() 很容易被漏掉:尺子是每次调用都新建并挂到 body 上的,用完必须销毁,否则每调用一次就留下一个隐藏的 span,高频调用下会持续污染 DOM。

八、把整个函数翻译成「人话」

抛开语法细节,这个函数就是四件事:

function fitMenuText(label, text) {
    // 1. 没有文字元素就不处理
    if (!label || !label.parentElement) return;
    const parent = label.parentElement;

    // 2. 创建一把隐藏的尺子,并复制真实字体
    const measure = createHiddenSpan();
    copyFontStyle(measure, label);

    // 3. 算空间:其他元素占了多少钱,文字还剩多少
    const siblingsWidth = calculateOtherElementsWidth(parent, label);
    const availableWidth = parentWidth - siblingsWidth - padding - textIndent - 2;

    // 4. 二分试文字:放得下就往大试,放不下就往小收
    const characters = Array.from(text || '');
    let left = 0;
    let right = characters.length;

    while (left < right) {
        const middle = Math.ceil((left + right) / 2);
        const width = measureWidth(characters.slice(0, middle));
        if (width <= availableWidth) {
            left = middle;
        } else {
            right = middle - 1;
        }
    }

    // 5. 截断并用完即弃
    label.textContent = characters.slice(0, left).join('');
    measure.remove();
}

整个流程可以画成一条链路:

fitMenuText()
     │
     ▼
算出菜单给文字留下的可用宽度
     │
     ├── 整个菜单宽度
     └── 其他元素占用宽度
     │
     ▼
availableWidth(文字能用的像素宽度)
     │
     ▼
创建隐藏尺子,复制真实字体
     │
     ▼
用二分查找不断测量「前 N 个字符的宽度」
     │
     ├── 放得下 → left = middle
     └── 放不下 → right = middle - 1
     │
     ▼
得到最大可显示字符数
     │
     ▼
label.textContent = 前 N 个字符
     │
     ▼
measure.remove()

九、读这类代码的方法

这段代码容易劝退,往往不是因为 JavaScript 不熟,而是因为习惯从第一行开始逐句理解语法。面对 reduce、getBoundingClientRect、getComputedStyle 混在一起的代码,这种读法很容易中途迷失。

更高效的顺序是从结果倒推:

  1. 先找最终产出。 label.textContent = characters.slice(0, left).join('') —— 无论中间做了什么,最后都是改 label 的文字。
  2. 问 left 从哪来。 它来自 while (left < right) 的循环,说明这是一个被反复调整的数字。
  3. 问这个数字代表什么。 看 middle = Math.ceil((left + right) / 2) 和 characters.slice(0, middle) —— 它代表「要显示的字符个数」。
  4. 问它为什么要找这个数。 看 if (measure.getBoundingClientRect().width <= availableWidth) —— 要找的是「宽度不超过可用空间」的最大字符数。
  5. 最后再看 availableWidth 怎么算。 parentWidth - siblingsWidth - padding - textIndent - 2,逻辑就全部串上了。

整个过程只需要盯住四个关键变量:

变量 含义
parent 整个菜单容器
siblingsWidth 其他兄弟元素占掉的宽度
availableWidth 留给文字的宽度
left / right 二分查找的字符数区间

小结

这段代码的本质,可以压缩成一句话:

拿一个隐藏的 <span> 当尺子,算出菜单给文字留下了多少像素,再用二分查找找出「最多能塞下多少个字符」,把多余的砍掉。

它解决的是一个典型的「DOM 测量 + 二分查找」问题。以后再遇到类似写法,不必被密集的 API 吓到,先问自己一句:「这段代码最终想算出什么东西?」 从答案往回推,通常比逐行翻译快得多。

一点补充:二分查找依赖「宽度随字符数单调不减」这一前提。在常见排版下它成立,但如果涉及连字(ligature)、字距调整等会随上下文改变宽度的特性,结果可能略有偏差——工程上那 2px 的安全余量正是为此留的缓冲。