
自家项目里存着一张海报模板文案是后台配置的一段产品介绍直接ctx.fillText画上去之后超过画布宽度的文字被安静地裁掉第一行最后一个字还露出半个头。这种问题在 Canvas 里太典型了原生 API 只负责“画字”不负责“排字”所以自动换行这件事本质上得自己实现一套“测量宽度 切行 逐行绘制”的小流程。这篇就把我的封装过程完整拆开讲从基础算法到挂到原型上的自定义扩展方法一步到位。1. 先搞清楚 fillText 为什么不自动换行换行到底难在哪1.1 原生 Canvas 的文本 API 只做“画”这一件事CanvasRenderingContext2D上最常见的文本方法是fillText(text, x, y)它的语义很简单从(x, y)这个坐标开始把字符串沿基线画出来。注意这里有两个关键点。第一y是文本的基线位置不是文字顶部。同样一行字把textBaseline从top改成middle再改成alphabetic画出来的视觉位置完全不同。我们做多行排布时如果只在y上简单加行高还要确认它和当前textBaseline的设置一致否则行与行之间的距离会忽大忽小。第二fillText还有个容易被忽略的第四个参数maxWidth但它的行为不是“超过宽度就换行”而是“超过宽度就把整行文字水平压缩”。这个参数原名其实是用来限制文本显示宽度的在遇到超长单词或者防溢出场景时能用但它绝对不解决换行问题。这一点很多人第一次接触时会被误导。所以结论很明确Canvas 里没有div那种块级布局模型没有“自动换行”“文本块”“行内元素”的概念。它就是一个画布所有文字排版都得由开发者自己把坐标算明白。1.2 换行的本质是“测量 判断 绘制”三段式流程要在 Canvas 上实现任意文本的自动换行绕不开一条流水线测量用ctx.measureText()量出当前这段文字的实际像素宽度。判断累加宽度每加一个字符或词就检查是否超过预设的maxWidth。绘制把切成一行一行的字符串逐行fillText画出来行间累加lineHeight。听起来很简单但实际做起来会碰到一堆细节问题中英文混排怎么切Emoji 会不会被劈成两半文字里有换行符\n怎么处理行数太多要不要省略号字体没加载完的时候测量结果准不准这一系列问题叠加起来就会让“为什么我画出来和量出来不一样”成为 Canvas 新手最常踩的坑。所以比较好的做法不是每次用的时候现场临时拼逻辑而是把这些规则沉淀成一个“自定义扩展方法”以后在任何项目里都能直接调用。1.3 为什么社区里很少有人直接给你一个标准答案你可能会想既然大家都需要换行怎么浏览器不直接提供ctx.fillTextWrap其实不是没人做而是没法做一个通用的标准实现。不同场景对“换行”的诉求差异很大聊天界面可能需要按单词切还要保留长 URL 不拆海报生成器可能需要按字符切让中文语义完整图表标签可能只想截断成一行加省略号导出服务还可能需要支持换行缩进。这些逻辑没法用一套配置全部覆盖所以最好的方案不是“等一个官方 API”而是自己封装一层把可变的规则变成参数暴露出去。2. 换行算法核心先量宽度再决定从哪断开2.1 measureText 才是整个方案的主角在 Canvas 中任何换行判断都离不开ctx.measureText()。这个方法返回一个TextMetrics对象里面最常用的属性是width它表示当前字体状态下这段文本的渲染宽度单位是 CSS 像素。这里有个容易踩的点measureText的结果和当前ctx.font、ctx.letterSpacing、ctx.fontKerning、ctx.direction都有关系。如果你先量了宽度然后换了个字体再画量出来的数据和画出来的东西就对不上。所以封装函数时要保证“测量用的上下文状态”和“绘制用的上下文状态”完全一致最简单的方法就是测量和绘制都用同一个ctx。另外如果你需要精确的文本边界框TextMetrics里还有actualBoundingBoxLeft/Right/Ascent/Descent这些字段能算出文字相对基线的真实外框适合做“文本到底占了多少空间”的判断。不过日常换行场景里width已经足够了。2.2 按字符切和按单词切应该怎么选不同语言的断行策略不一样。英文和大多数欧洲语言是“按空格分隔的单词为单位”断行中文则是“按字符为单位”断行因为中文词之间没有天然空格一行里每个汉字都可能成为断点。社区里常见的第一版实现是遍历字符串中的每一个字符累加单个字符的宽度超过maxWidth就换行。这个方案的优点是简单、通用、对中英文混排都有效缺点是某个特别长的英文单词会被硬生生拆开比如internationalization这种二十多个字母的单词。更合理的策略是“英语按词切中文按字切”或者退一步“先尝试按词放进一行词放不下了再退回按字符拆”。这样既保证了英文排版可读性也不会在遇到超长 token 时死循环。2.3 第一版可用代码逐字符累加宽度先不看那些花哨的优化直接上一版最简单的逐字符换行封装function wrapTextByChar(ctx, text, maxWidth) { const lines []; let line ; let lineWidth 0; for (const ch of text) { const w ctx.measureText(ch).width; if (line lineWidth w maxWidth) { lines.push(line); line ch; lineWidth w; } else { line ch; lineWidth w; } } if (line) lines.push(line); return lines; }这段代码里有个容易被忽略的地方换行时把当前字符作为下一行的开头也就是说第一个字符不算进上一行。这样处理是为了避免某个单独的字符宽度超过maxWidth时导致死循环。理论上极限情况下单字符就会超宽那这一行超了也只能硬画否则永远切不完。这个版本已经能解决大部分中英文混排问题而且for...of迭代字符串可以正确处理 UTF-16 代理对不会把一个 Emoji 直接拆成两个乱码字符。实际项目里如果只是偶尔画几段文案这个函数已经够用。2.4 优化用二分查找减少 measureText 调用逐字符循环有个性能隐患一个字符调用一次measureText画 500 字文案就要调用 500 次。measureText本身并不算便宜尤其是字体复杂或绘制频率高时这会造成明显的卡顿。因为ctx.measureText(text)的宽度是随文本长度单调增长的所以可以用二分查找在[0, text.length]区间里定位“当前行最多能容纳多少个字符”function findMaxChars(ctx, text, maxWidth) { let low 0; let high text.length; while (low high) { const mid (low high 1) 1; if (ctx.measureText(text.slice(0, mid)).width maxWidth) { low mid; } else { high mid - 1; } } return low; } function wrapTextByBinary(ctx, text, maxWidth) { const lines []; let rest text; while (rest.length 0) { const take findMaxChars(ctx, rest, maxWidth); if (take 0) break; lines.push(rest.slice(0, take)); rest rest.slice(take); } return lines; }这个版本的测量次数从“字符总数”降到“每行约log2(行宽/字宽)次”长文本收益非常明显。但要注意二分查找用到的是text.slice()如果切点恰好落在代理对中间就会切断 Emoji。所以如果你明确知道自己要处理大量 Emoji我建议在拿到切点后做一次“安全边界修正”把切点向前移直到不再落在代理对内部。实际项目中我一般会用逐字符遍历处理特殊文案用二分查找处理纯文本长段两种策略配合使用。3. 完整封装 wrapText段落、最大行数、省略号一次到位3.1 设计一个能上线的 API而不是一个一次性函数开发习惯上我倾向把“换行计算”和“Canvas 绘制”拆开。换行计算是一个纯函数输入文本和宽度输出每一行的字符串数组绘制则是在拿到数组之后逐行fillText。这样拆的好处是方便测试也方便后续在导出图片、服务端 Canvas、离屏 Canvas 上复用同一套逻辑。所以我封装了这样一个工具函数function wrapText(ctx, text, maxWidth, options {}) { const { maxLines Infinity, ellipsis } options; const paragraphs String(text).split(\n); const result []; for (const paragraph of paragraphs) { if (paragraph ) { result.push(); continue; } let line ; let lineWidth 0; for (const ch of paragraph) { const w ctx.measureText(ch).width; if (line lineWidth w maxWidth) { result.push(line); line ch; lineWidth w; } else { line ch; lineWidth w; } } if (line) result.push(line); } if (maxLines ! Infinity result.length maxLines) { const visible result.slice(0, maxLines); if (ellipsis) { visible[maxLines - 1] truncateLine(ctx, visible[maxLines - 1], maxWidth, ellipsis); } return visible; } return result; } function truncateLine(ctx, line, maxWidth, ellipsis) { const ellipsisWidth ctx.measureText(ellipsis).width; if (ctx.measureText(line).width maxWidth) { return line; } let end line.length; while (end 0) { const temp line.slice(0, end) ellipsis; if (ctx.measureText(temp).width maxWidth) { return temp; } end--; } return ellipsis; }这段代码里有几个需要考虑的点。split(\n)处理了文案里自带换行符的情况。如果原始文本是多段落的我们肯定希望保留作者手动的换行意图而不是把所有段落重新拼成一行。空段落也保留避免段落之间的空行丢失。maxLines配合ellipsis解决“最多显示 N 行超出部分用省略号”的典型需求。注意省略号不会平白增加这里是在超过最大行数后才把最后一行截断。截断时用truncateLine不断收缩字符串直到“原文截断部分 省略号”的总宽度不超过maxWidth。这个循环在真实长文本上可能比较慢所以如果文本特别长可以换用二分搜索优化但一般海报文案长度撑死几百字这里线性收缩即可。3.2 标点悬挂中文行首的逗号句号要不要处理中文排版里行首出现逗号、句号、问号会显得很业余。比如“你好世界”这段文字如果因为宽度问题在“”处换行下一行开头出现一个逗号看起来就很难受。我一般会在换行前做一个简单的规则判断如果当前字符是中文行首禁则标点如。、》】…并且当前行已经有了内容就把它“挤”到上一行末尾然后再换行。虽然这会让上一行稍微超出maxWidth但视觉上通常是可以接受的而且在大部分 Canvas 文案场景里几乎是必须的。const PUNCT_NO_START new Set(。、》】…); // 在换行判断时加一个条件 if (line lineWidth w maxWidth !PUNCT_NO_START.has(ch)) { result.push(line); line ch; lineWidth w; } else { line ch; lineWidth w; }这个规则要做全其实很复杂比如省略号、破折号连排不同语言标点规则还不一样。但作为第一版处理最常见的“句尾标点不开头”已经能让效果提升一大截。3.3 为什么我建议 return 一个数组而不是只画完就算了很多人封装函数时会直接写成“调用即绘制”返回undefined。这样虽然方便但后续想给文本算高度、想居中、想生成多页文字、想预判是否溢出都得重新调用一次换行逻辑非常浪费。我习惯让纯函数返回string[]绘制方法再拿这个数组去逐个fillText。这样高度计算、行数判断、点击区域判断都能复用同一份结果逻辑也不会因为“绘制副作用”被搞得一团糟。4. 把它变成自定义扩展方法直接在 ctx 上调用4.1 扩展原型还是写独立函数我的选择标准标题里提到“封装自定义扩展方法”这里就得讨论一下到底要不要挂在CanvasRenderingContext2D.prototype上。好处很直观调用自然ctx.wrapFillText(...)就像原生方法一样别人读代码时能马上理解上下文。坏处也很明显修改全局内置对象的原型在严格模式或多人协作的大型项目里可能有冲突风险尤其是你无法控制第三方库是否也做了类似扩展。我的取舍标准是如果这个项目只有一套 Canvas 绘制代码且团队规范允许扩展原型那就挂原型代码最干净如果项目是多 Canvas 封装、跨端运行、要发布 SDK那就不要改原型单独导出工具函数更稳妥。两个方案底层逻辑是一样的所以我这里先讲清楚工具函数再给原型扩展版本。4.2 挂到原型上的参考实现CanvasRenderingContext2D.prototype.wrapFillText function (text, x, y, options {}) { const { maxWidth 200, maxLines Infinity, lineHeight 1.2, ellipsis , align left, } options; const fontSize parseFloat(this.font) || 16; const leading fontSize * lineHeight; const lines wrapText(this, text, maxWidth, { maxLines, ellipsis }); const prevAlign this.textAlign; this.textAlign align; for (let i 0; i lines.length; i) { this.fillText(lines[i], x, y i * leading); } this.textAlign prevAlign; return lines; };使用起来就很直观了ctx.font 16px PingFang SC, Microsoft YaHei, sans-serif; ctx.fillStyle #333; ctx.wrapFillText(这是一段很长的文案用来测试自动换行效果, 24, 40, { maxWidth: 180, lineHeight: 1.5, maxLines: 3, ellipsis: …, align: left, });这里要解释两个容易踩的细节。lineHeight的处理我把lineHeight定义为相对于当前字体大小的倍数然后计算出像素间距leading fontSize * lineHeight。如果你的设计稿里行高是固定像素值直接传lineHeight: 24那在实现上就得区分“倍数”和“像素”否则画出来会差很多。我这里的参考实现采用“倍数”的方式更贴近 CSS 习惯。textAlign的处理对齐我暂时没有手动计算每行的偏移而是直接临时修改this.textAlign因为多行文本左对齐、右对齐、居中对齐时textAlign本身就决定了x是文本的哪个锚点。画完再恢复原值避免影响后续其他绘制逻辑。这个方案最简单代价是你必须接受“同一段多行文本所有行都以同一个x为对齐锚点”不过这正是align想要的语义。4.3 返回值给到行数组方便上层做更多判断wrapFillText会把最终画出来的行数组返回这意味着调用方还能继续做别的事拿lines.length * leading算多行文字总高度用于垂直居中拿lines做点击区域的命中检测拿行数和文本高度判断要不要给背景块加边框。在我实际做海报编辑器时这个返回值帮了大忙。文字下方要放一个渐变色块背景块的高度必须跟着换行结果走没有返回行数组就得再调用一次 wrapText还得保证两次传入的参数完全相同否则行数一旦不一致背景就穿帮了。4.4 TypeScript 项目里如何给 CanvasRenderingContext2D 类型“打补丁”团队里用 TypeScript 的话直接在ctx.wrapFillText上调用会报类型错误。处理方式是在全局声明文件里扩展接口declare global { interface CanvasRenderingContext2D { wrapFillText( text: string, x: number, y: number, options?: { maxWidth?: number; maxLines?: number; lineHeight?: number; ellipsis?: string; align?: CanvasTextAlign; } ): string[]; } }这样在ctx上调用wrapFillText时就能拿到完整类型提示团队协作时别人也不会因为找不到方法定义而困惑。5. 实测避坑清单这些坑不处理线上必翻车5.1 字体没加载完就测量画出来一定错位Canvas 的measureText用的是当前上下文的字体状态。如果你在页面里通过font-face加载了自定义字体但字体文件还没下载完ctx.font设置的自定义字体名会先退回到默认字体测量出来的宽度是 fallback 字体的宽度。等字体真正加载完再画字形变宽变窄换行结果就全乱了。解决方法是绘制前等待字体加载完成async function render() { await document.fonts.ready; // 此时再调用 wrapFillText drawCanvas(); }如果业务上有“首屏进入必须立刻展示海报”的强需求可以结合document.fonts.load(16px YourFont)更精确地等待特定字体而不是等全部字体。实测下来字体加载问题是最隐蔽的痛点因为它只在特定字体、特定系统下出现本地开发时字体往往是系统字体根本复现不出来。5.2 Emoji 被拦腰截断和“看起来没问题”的假象逐字符用for...of遍历字符串时Emoji 会被当做一个完整的码点处理至少不会被劈成两半。但如果你用了二分查找版本text.slice(0, take)的take是按 UTF-16 代码单元计算的遇到占两个代码单元的 Emoji切片位置就可能落在代理对中间产生一个半截乱码字符。规避方法有两种第一种是在二分切点确定后检查切点是否落在代理对中间是则向前修正到安全位置第二种是干脆把二分查找换成逐字符遍历并用Array.from(text)先把字符串转成码点数组。复杂 Emoji 还有组合序列的问题比如家庭成员这种由多个 Emoji 用零宽连接符拼起来的序列逐字遍历也会拆散。如果文本里这类内容很多建议用Intl.Segmenter做字素分割这是目前比较标准的解决方案。const segmenter new Intl.Segmenter(zh, { granularity: grapheme });不过要诚实说一句Canvas 测量宽度的时候对复杂 Emoji 序列的宽度支持和 DOM 并不完全一致实测中我也只能做到“不断裂、不乱码”像素级完美对齐仍然需要依赖具体字体和运行环境的实测。5.3 devicePixelRatio 和高分屏模糊很多项目会做 Canvas 高分屏适配把canvas.width设为cssWidth * devicePixelRatio再通过ctx.scale(dpr, dpr)绘制。这种情况下measureText返回的是当前变换后的逻辑像素宽度还是基于物理像素这取决于你的缩放时机。如果你先setTransform(dpr, 0, 0, dpr, 0, 0)那么后续fillText和measureText都是在 CSS 像素坐标系里工作测量和绘制是一致的。如果你忘了setTransform直接用物理像素绘制文字那不仅测量结果要乘比例文字还会因为放大而发虚。最稳妥的写法是在初始化 Canvas 后固定执行一次ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这样整个绘制流程都用 CSS 像素坐标换行的maxWidth也不用管 DPR代码清爽很多。5.4 性能重复测量和频繁重绘时的缓存策略画布动画里如果每帧都重新计算一段长文本的换行measureText的调用次数会直接拖垮帧率。我的做法是加一层简单缓存以“文本 最大宽度 字体状态”作为 key缓存换行结果const wrapCache new Map(); function wrapTextCached(ctx, text, maxWidth, options {}) { const key ${text}|${maxWidth}|${ctx.font}|${options.maxLines}|${options.ellipsis}; if (wrapCache.has(key)) return wrapCache.get(key); const result wrapText(ctx, text, maxWidth, options); wrapCache.set(key, result); return result; }注意 key 里一定要包含ctx.font因为同一个ctx上字体一变测量结果也就变了。缓存也不用做得多复杂Map 默认就够用如果担心内存占用限制一下条数或只在静态文案场景里使用即可。5.5 换行结果受 textBaseline 影响别只调行高前面提过lineHeight是按字体大小计算的但最终绘制时还要结合textBaseline。如果ctx.textBaseline是默认的alphabetic那么第一行的基线位置是y第二行的基线应该是y leading这个逻辑没毛病。但如果textBaseline是top文字是从y往下画多行叠加时行距计算还得考虑上一行文字的实际高度否则行与行之间会重叠得很厉害。简化方案是多行文本框绘制前统一把textBaseline设成top或middle然后手动按行高递增绘制完再恢复。这样至少你不会在切换文字基准模式时突然发现行距不对。6. 从“能换行”到“排得漂亮”文本排版还能继续扩展什么6.1 首行缩进、悬挂标点和两端对齐上面的封装解决了“换行”这个基本问题但实际做富文本排版时你可能还需要补三类规则。首行缩进中文正文段落喜欢开头空两格这个可以在第一行前面拼两个全角空格或者通过额外参数firstLineIndent在绘制第一行时传入x indent。悬挂标点行首不允许出现某些标点行尾尽量不让标点单独成行。这需要维护一个更大的“禁则表”并在换行的时候把标点归到上一行。规则复杂时建议单独抽一个lineBreakRules.js而不是塞在 wrapText 里。两端对齐Canvas 默认左对齐行尾参差不齐。要做出类似 Word 的两端对齐效果需要对每一行计算“还差多少宽度”然后把这些差值平均分配到行内每个字符的间距上。这个逻辑并不复杂但实测中碰到标点、空格和混合字体时要非常小心否则间距会忽大忽小。6.2 富文本一行里多种颜色、字号、字体怎么画自动换行只是文本排版的第一步。实际项目中经常出现“标题文案里某几个字要高亮”或“价格数字要用大字号”这种需求。这时候不能每次整行fillText因为一行里不同字符有不同样式。我的做法是先把文本拆成若干个“样式片段”每个片段记录text、font、color。换行计算时以片段为单位测量宽度并判断是否跨越行边界绘制时按片段逐个fillText。这样一行可能被拆成三四次绘制但视觉上是连续完整的。这个模式做起来比单行样式复杂但它是 Canvas 富文本落地的必经之路后续我可能会单独写一篇展开讲。6.3 多文案回归测试比任何参数都重要最后分享一个我踩过好几次坑之后的经验Canvas 换行逻辑改一版就一定要做回归测试。因为换行结果依赖字体、宽度、标点规则、Emoji 处理任何一个环节变了都会让输出完全不一样。我在项目里维护了一份固定文案清单包含超长英文单词、中英文混排、emoji 组合、换行符段落、超长数字这几种典型场景。每次改完 wrapText我就会在离屏 Canvas 上一一渲染导出一张测试图用截图对比的方式确认没有出现明显回归。这个方法虽然老土但对比手工肉眼检查高效得多。封装自定义扩展方法这件事本质上就是把你对业务的理解沉淀成可复用的代码。换行规则没有放之四海而皆准的标准答案但上面这套“测量—切行—绘制—扩展原型”的流程已经能覆盖绝大多数 Canvas 文案场景。哪天你遇到更刁钻的排版需求至少知道该从哪一层去改而不是从头硬写。