本文由 AI 根据一次真实的技术讨论整理生成,代码与结论均来源于该讨论。
菜单里放一段长名称,是前端再常见不过的场景。一个侧边栏条目、一个工具栏按钮,文字稍长就会挤出容器,要么换行破坏布局,要么被 text-overflow: ellipsis 裁成一句带省略号的残句。
这篇文章要解决的是一个更细的问题:如何按菜单项实际可用的宽度,把文本截取到「能完整显示的字符数」——既不浪费空间,也不把最后一个字符切成一半。
比如菜单可用宽度 200px,原始名称是「这是一个非常非常长的菜单名称」。理想结果是:
[ 图标 ] 这是一个非常长的菜单名称 [按钮]
而不是:
[ 图标 ] 这是一个非常长的菜单名… [按钮]
一、整体思路
先说明一个关键事实:字符数和像素宽度之间没有固定比例。
中文和英文宽度不同,iiiiiiii 和 WWWWWWWW 同样 8 个字符但宽度差出一截,再加上字号、字重、字体的影响,靠「一个汉字 16px」这类经验值去估算,结果一定不准。
所以最可靠的办法只有一个:让浏览器自己渲染,然后问它「这串文字实际有多宽?」
围绕这一点,整个方案分成四步:
- 算空间 —— 算出菜单留给文字的可用像素宽度;
- 做尺子 —— 创建一个隐藏元素,让它与真实文字使用完全相同的字体;
- 二分试文字 —— 用二分查找找出「放得下的最大字符数」;
- 截断文字 —— 按这个字符数截取,并销毁测量元素。
二、函数入口与前置校验
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 混在一起的代码,这种读法很容易中途迷失。
更高效的顺序是从结果倒推:
- 先找最终产出。
label.textContent = characters.slice(0, left).join('')—— 无论中间做了什么,最后都是改label的文字。 - 问
left从哪来。 它来自while (left < right)的循环,说明这是一个被反复调整的数字。 - 问这个数字代表什么。 看
middle = Math.ceil((left + right) / 2)和characters.slice(0, middle)—— 它代表「要显示的字符个数」。 - 问它为什么要找这个数。 看
if (measure.getBoundingClientRect().width <= availableWidth)—— 要找的是「宽度不超过可用空间」的最大字符数。 - 最后再看
availableWidth怎么算。parentWidth - siblingsWidth - padding - textIndent - 2,逻辑就全部串上了。
整个过程只需要盯住四个关键变量:
| 变量 | 含义 |
|---|---|
parent |
整个菜单容器 |
siblingsWidth |
其他兄弟元素占掉的宽度 |
availableWidth |
留给文字的宽度 |
left / right |
二分查找的字符数区间 |
小结
这段代码的本质,可以压缩成一句话:
拿一个隐藏的
<span>当尺子,算出菜单给文字留下了多少像素,再用二分查找找出「最多能塞下多少个字符」,把多余的砍掉。
它解决的是一个典型的「DOM 测量 + 二分查找」问题。以后再遇到类似写法,不必被密集的 API 吓到,先问自己一句:「这段代码最终想算出什么东西?」 从答案往回推,通常比逐行翻译快得多。
一点补充:二分查找依赖「宽度随字符数单调不减」这一前提。在常见排版下它成立,但如果涉及连字(ligature)、字距调整等会随上下文改变宽度的特性,结果可能略有偏差——工程上那 2px 的安全余量正是为此留的缓冲。