
做前端的人早晚会遇到这样一个需求在活动海报、分享卡片、地铁线路图或者一个简单的网页生成器里把一段文字“画”到 Canvas 上。文字要用实心的不能是细描边那种空心效果文字还有最大宽度限制超长时不能排版溢出得有自动截断或者缩小字号的处理。这个需求看起来不复杂但真正写起来坑不少——尤其是“实心文字”和“最大宽度限制”同时存在时很多人会第一时间想到 strokeText或者硬塞一个 maxWidth 参数进去结果要么文字太单薄要么浏览器兼容性把效果整得支离破碎。这篇文章就把这些坑一个个拆开给出一套能直接落地的小白友好方案并且会把背后的原理一起讲清楚方便你把同一套思路迁移到签名生成器、海报工具、抽奖页面等任意 Canvas 业务场景里。我尽可能用实际项目里验证过的代码来说话不整那些华而不实的封装。全程从零写一个带自适应字号、自动省略号、高分屏适配的 Canvas 实心文字绘制组件把关键参数的坑全部标出来也把面试官最爱问的几个 Canvas 知识点放到最后单独讲。无论你是刚转前端的新人还是被 UI 临时塞了一个“麻烦帮我把文字画到图片上”需求的半路选手这篇都能帮你少走不少弯路。1. 先把“实心文字”这件事说透1.1 fillText 和 strokeText别再用反了Canvas 绘制文字有两套 APIfillText()负责填充实心文字strokeText()负责描边空心文字。字面意思很直白但我见过不少新人在项目里写的是strokeText理由是“感觉 stroke 更像笔画的粗细”出来的效果却是文字中间完全空掉只留下一个瘦轮廓打印到海报上很难看。ctx.fillText(实心文字, 50, 50); // 得到的是一个被颜色填满的完整字形 ctx.strokeText(空心文字, 50, 100); // 得到的是只有外轮廓的空心字中间是透明背景如果你既想要实心的文字又想要一点描边效果正确做法是先fillText再strokeText顺序不能反。先填充后描边描边会叠在实心字的外沿上形成“带边上色”的效果如果反过来先描边再填充填充会把描边盖掉一大半边缘只剩一点点痕迹效果非常怪。还有一个细节fillText和strokeText都是同步绘制操作它们的字形数据来自当前 Canvas 的font设置。很多人踩过“字号改了文字没变”的坑本质就是字体没设置成功或者字号单位不对后面我会重点讲ctx.font的写法规范。1.2 决定文字外观的四个基础参数想要实心文字显示得又稳又漂亮至少要把这四个参数吃透它们也是前端面试里高频出现的基础点。第一个是ctx.font。它的格式和 CSSfont简写基本一致比如bold 32px sans-serif。最容易出错的地方有两个一是字号和字体之间必须有空格写成bold32px sans-serif直接失效二是font不认16这种纯数字必须带单位常见写法是16px。如果不设置fontCanvas 默认是10px sans-serif很多“怎么文字这么小”的困惑就是这么来的。第二个是ctx.textAlign。默认值是start左对齐想居中要设center想靠在右侧设end或right。注意textAlign影响的是 x 坐标的参照点不是文字自身的走向。比如ctx.textAlign center时x就是文字的几何中心横坐标这个在做海报水印时非常常用。第三个是ctx.textBaseline。默认是alphabetic这个基线跟英文书写基线一致中文在这种模式下会略微偏高做精确垂直居中时会歪。实际项目里强烈建议设成middle或者top再配合固定的 y 值可以让多行文字和容器对齐时非常省心。第四个是ctx.direction控制文字方向一般用默认的inherit就够了。当文本中混入数字和中文时某些老版本浏览器会出现数字和汉字字体不一致的现象这一点在抠像素级还原的设计稿时容易踩到需要额外通过设置ctx.font字体族来兜底。这四个参数组合起来其实就构成了“实心文字”的基础底座。你在任何 Canvas 项目里看到一段文字“歪了”“糊了”“位移了”优先怀疑这四兄弟绝大多数问题都能排查出来。2. 最大宽度限制三种方案全对比“最大宽度限制”可以简单理解成虽然 Canvas 是画布文字画上去不会像 DOM 那样自动换行挤压布局但你业务上依然有约束比如图片上某个区域宽度只有 260px文字不能画出去。实现思路不外乎三种直接塞maxWidth参数、手动截断加省略号、按比例缩小字号。三种方案我都实测过它们的表现差异非常大。2.1 maxWidth 参数最简单的写法但没那么理想fillText的第四个参数就是maxWidth用法如下ctx.font bold 32px sans-serif; ctx.fillText(很长很长的一段文字, 50, 100, 200);当文字实际宽度超过 200px 时Canvas 会把这个文本压缩到 200px 内。这句话听起来很美实际渲染结果却很微妙它是横向压缩字符间距而不是等比缩小字号也就是整个字会被压扁出现“竖长字变成扁宽字”的视觉效果中国字符在这种压缩下尤其难看横竖撇捺的比例全被破坏。我在海报场景里用它做过标题字体一旦加上 maxWidth 限制原本端正的字体直接变成“营养不良”的样子视觉负责人看了直接打回。如果你只是做水印、临时占位符这个方案效率最高但如果目标是“高质量展示”我强烈建议不要依赖它。2.2 手动截断加省略号贴合真实业务的默认选择项目里最常用的方案其实是手动截断用ctx.measureText().width去测量文字实际宽度一旦超过最大宽度就从尾部往前删字符最后补一个省略号。这个方案实现起来不复杂效果也最接近大家熟悉的“超出一行显示省略号”的 CSS 行为。function truncateText(ctx, text, maxWidth) { if (ctx.measureText(text).width maxWidth) { return text; } let result text; while (result.length 0 ctx.measureText(result …).width maxWidth) { result result.slice(0, -1); } return result …; }这段代码每次循环都会调用measureText对于超长文本来说性能不算好但胜在直观。更快的方案是用二分查找来确定截断位置不过实际业务里单次绘制的文本就几十个字线性截断完全够用没必要过度设计。需要注意一个细节截断后别忘记把省略号也写进测量逻辑里否则画出来会比限制宽度多出一个省略号的宽度看着像是“差一点就超了”。2.3 字号自适应海报场景下更优雅的方案如果截断不合适比如标题文字必须完整展示那就换成字号自适应。思路也很简单先按初始字号测量文本宽度如果超过最大宽度就按比例缩小字号直到宽度满足要求。function fitFontSize(ctx, text, maxWidth, baseSize) { let fontSize baseSize; ctx.font bold ${fontSize}px sans-serif; while (fontSize 10 ctx.measureText(text).width maxWidth) { fontSize - 1; ctx.font bold ${fontSize}px sans-serif; } return fontSize; }逐像素减字号的写法最直观但在超大字号、超长文本场景下循环次数会变多。优化版本可以直接按宽度比例跳减fontSize Math.floor(fontSize * maxWidth / measuredWidth)一次就能接近目标值再往回微调即可。要注意的是字号不能无限制缩小需要设一个下限否则一段 500 个字符的文案会在极限压缩下变成蚂蚁字根本没法看。下限可以定在 10px 或 12px具体根据业务容错度调整。三种方案没有绝对的好坏我的选择习惯是这样的偏工具类、内部使用的小页面用 maxWidth 省事对外接触用户的 H5 海报生成器优先截断加省略号视觉要求高、标题必须保真时用字号自适应更复杂的业务里可以把截断和字号自适应结合先缩字号缩到下限还超宽就截断能兼顾完整性和美观。3. 从零写一个可以直接复用的自适应文字组件前面讲的是原理和方案这一节直接上完整代码。我把场景设定成一个常见的活动海报生成器画布上有一块文案区域区域宽度 360px文字从接口返回长短不确定需要做到实心绘制、最大宽度限制、超长省略号、保证高清屏不模糊。这些需求列出来其实就是一个最小可用的 Canvas 文字组件。3.1 先理清场景需求和工具选型为什么要把场景定成海报生成器因为这类页面是 Canvas 实心文字使用频率最高的地方而且它同时踩中了文本测量、字体加载、高分屏适配三座大山。如果一个方案能在这三类问题同时存在的场景下跑通迁移到签名墙、公交线路图、优惠券生成器等场景基本就是改改参数的事。工具选型方面我没有任何花哨的选择就是原生 Canvas 2D API不引第三方库。html2canvas可以截取 DOM 节点成图片但它对文字渲染的还原度受限且跨域图片处理麻烦Konva、Fabric.js这类库适合复杂矢量交互场景但为了画个实心文字引入整个依赖对新人来说学习成本过高我想不到这么做的理由。3.2 完整代码实现下面这段代码我会贴完整版然后逐段拆解设计思路。代码本身在 Chrome、Safari、Firefox 里都验证过。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleCanvas 实心文字 最大宽度限制示例/title /head body canvas idposter width600 height400/canvas script const canvas document.getElementById(poster); const ctx canvas.getContext(2d); const cssWidth 600; const cssHeight 400; const dpr Math.max(window.devicePixelRatio || 1, 2); canvas.style.width cssWidth px; canvas.style.height cssHeight px; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 背景底色便于观察 ctx.fillStyle #f5f7fa; ctx.fillRect(0, 0, cssWidth, cssHeight); const maxWidth 360; const baseFontSize 32; const text 这是一段用于验证 Canvas 实心文字最大宽度限制的示例文本; function measureTextWidth(text, fontSize) { ctx.font bold ${fontSize}px sans-serif; return ctx.measureText(text).width; } function truncateText(text, maxWidth, fontSize) { if (measureTextWidth(text, fontSize) maxWidth) { return text; } let result text; while (result.length 0) { const width measureTextWidth(result …, fontSize); if (width maxWidth) { return result …; } result result.slice(0, -1); } return ; } function drawSolidText(text, x, y, maxWidth, baseFontSize) { ctx.textAlign left; ctx.textBaseline middle; // 先按字号自适应处理缩减到 20px 还放不下就截断 let fontSize baseFontSize; while (fontSize 20 measureTextWidth(text, fontSize) maxWidth) { fontSize - 2; } let finalText text; if (measureTextWidth(finalText, fontSize) maxWidth) { finalText truncateText(finalText, maxWidth, fontSize); } ctx.font bold ${fontSize}px sans-serif; ctx.fillStyle #2c3e50; ctx.fillText(finalText, x, y, maxWidth); } drawSolidText(text, 30, 200, maxWidth, baseFontSize); /script /body /html你会注意到我在代码里同时用了字号自适应和截断先尝试把字号从 32px 往 20px 缩如果缩到 20px 仍然超宽再做省略号截断。这样短文本保证完整醒目长文本保证不溢出属于典型的工程折中方案。3.3 关键逻辑为什么这么写代码里有几个点值得展开讲每一个都是实战中实打实的坑。第一ctx.setTransform(dpr, 0, 0, dpr, 0, 0)在高分屏适配里极其重要。移动端设备devicePixelRatio通常是 2 或者 3如果你只用 CSS 尺寸设置 Canvas绘制出来的文字会发虚出现明显的锯齿感。正确做法是设置实际canvas.width cssWidth * dpr再通过setTransform把绘制坐标系放大回 CSS 尺寸这样文字就能以设备物理像素渲染输出图片也足够清晰。第二drawSolidText里我把字体设置集中成一个measureTextWidth函数。这个设计的核心原因是 Canvas 的测量完全依赖当前上下文的状态必须先设置font调用measureText才有意义。如果你在循环外面设了ctx.font为 A循环内部又改成了 B测量出来的宽度很可能跟你记忆中的逻辑不相符排查半天看不出原因。第三ctx.textBaseline middle能让复杂的 X 坐标计算变得简单。我见过很多人在 Canvas 里用 y 坐标做文字垂直居中时习惯写y - 高度/2这样的魔法数字换一行文字就要重新调。改用middle之后y 直接传文字所在的垂直中心线就行代码简洁得多。第四代码里truncateText每次测量的是result …这比先测量result再手动画省略号的做法严谨。因为省略号本身的宽度会计入总宽如果忽略了它最终绘制结果会比限制宽度多几个像素在需要严格对齐的场景下很容易被 UI 检查出来。3.4 动态数据场景重绘和缓存怎么处理如果你只是把文字画一次上面的代码已经够用了。但在海报生成器里文字往往来自用户输入用户每敲一个字符都要实时预览。此时有个容易被忽视的问题measureText和fillText都是同步重绘操作连续触发会导致 CPU 占用升高、低端机上卡顿。我的做法是加一层简单的防抖再结合离屏 Canvas 缓存。防抖可以用 100~200ms 的setTimeout实现只在用户停止输入后再重绘文字层。离屏 Canvas 则适合那些“反复重绘但内容不变”的静态元素比如背景图片、装饰边框可以把它们预先画到一个不显示的 Canvas 上每次重绘时用drawImage秒级复制避免反复执行图片合成。let drawTimer null; function onInput() { clearTimeout(drawTimer); drawTimer setTimeout(() { ctx.clearRect(0, 0, cssWidth, cssHeight); // 先 drawImage 背景缓存再绘制文字 ctx.drawImage(offCanvas, 0, 0, cssWidth, cssHeight); drawSolidText(inputValue, 30, 200, maxWidth, baseFontSize); }, 120); }这样处理后即便输入很快渲染频率也被限制在可接受范围内低端 Android 机也不会掉帧掉得厉害。这里提一句clearRect清空的是绘制内容Canvas 的width或height一旦重新赋值会清空所有绘图状态包括font、fillStyle等需要重新设置很多新人在这里踩过“怎么重绘后字变小了”的坑。4. 常见问题与排查技巧实录这一节我把自己在真实项目里摔过的坑整理成了一份排查清单。每一条都对应一个具体的症状和彻底解决的方法建议收藏起来遇到同样的问题直接对着表格查。4.1 文字模糊、白屏、字体不生效文字模糊 90% 是高分屏适配的问题。检查你有没有把canvas.width乘上devicePixelRatio有没有用ctx.setTransform恢复坐标系。还有一个常见原因是从设计稿拷贝来的ctx.font少写了 px 单位或字号和字体顺序颠倒了比如sans-serif 32px浏览器解析时会把整串当成无效字体文字直接回退到默认 10px。白屏问题一般分两种一类是fillStyle没设置默认黑色但背景也是深色看起来像没字另一类是字体文件还没有加载完成就触发了绘制。中文字体文件普遍偏大需要几百 KB 甚至几 MB首次加载时document.fonts可能还是空的。稳妥的写法是先await document.fonts.ready再绘制或者在字体加载完成后调用一次重绘函数。async function drawWithFontReady() { if (document.fonts document.fonts.ready) { await document.fonts.ready; } drawSolidText(text, 30, 200, maxWidth, baseFontSize); }字体不生效还有一个容易忽略的原因Canvas 里的font解析遵循 CSS 字体简写规则但不支持font-weight: 700这样的长写形式必须写成bold 32px sans-serif这种简写。如果你用模板字符串拼接时少了空格比如写成bold32px字体设置会静默失效Canvas 不会报任何错只会在最终效果上坑你。4.2 测量不准与中英文混排measureText的宽度测量在大多数现代浏览器里都是基于当前字体的实际字形走样的准确性很高。但在中英文混排时由于 Canvas 会按照字体回退规则对每个字符选择一个可用的字体族测量结果可能和视觉感知存在偏差。比如中文用PingFang SC英文用Arial半角空格和全角空格宽度差异极大这些都是正常现象。解决办法如果你的设计稿对“最大宽度”特别敏感你可以先统一字体族再手动修整空格宽度。真正常见的坑反而是把measureText写在font设置之前导致测量宽度和最终绘制宽度不一致。我自己写过一个工具函数把所有文本测量都封装在同一套字体环境下从根源上避免这种不一致。另一个细节measureText返回的width是浮点数它不会自动取整。如果你把这个值拿去和整型maxWidth比较边界情况会偶尔出现“宽度变量都相等了视觉上却超出去 1px”的问题。稳妥的做法是进行比较前给两边都做一次向上取整或者在比较时留出 2~3px 的余量这个余量能吃掉大多数字体渲染器的舍入误差。4.3 数据刷新的性能问题重绘次数过高是 Canvas 文字场景里最常见的隐性性能杀手。一个基础事实是每次fillText都要触发渲染管线的文字栅格化如果你在一个requestAnimationFrame循环里同时绘制几百段文字帧率会被拖到惨不忍睹。优化思路有三层。第一层是减少调用次数能合并的绘制尽量合并比如同一个字体的文字可以连续画不要每画一个就重新设置font第二层是局部重绘只清空文字变化的那块区域而不是每次clearRect整张画布配合save/restore可以显著减少绘制区域第三层才是离屏 Canvas 缓存把不变的大图、背景图提前渲染好动态文字叠在上面。还有一个很容易被忽视的点Canvas 的shadowBlur阴影效果非常消耗性能。如果你给实心文字加了投影又做了高频重绘性能会雪上加霜。非必要不建议给文字加shadowBlur真有投影需求可以用先画一层偏移文字的方式模拟效果往往更可控。4.4 面试题角度这几个知识点很可能被追着问“Canvas 实心文字和最大宽度限制”这个题目本身放在前端面试里就是一道结合手写 API 加工程思维的典型题。面试官通常不会只问你fillText有哪些参数而是会追几个路径问题fillText的maxWidth参数是怎么实现的如果你不假思索地回答“会自动缩放”面试官多半会继续挖“它缩放的是字号还是字间距”。这是一个很好的知识分水岭。什么场景下需要自己实现文本截断考察点在于你是否知道measureText只能测宽度不能帮你做省略号逻辑同时也考察你对maxWidth恶心效果的理解程度。如何保证 Canvas 在 Retina 屏下清晰这里的标准答案是devicePixelRatio配合setTransform实现物理像素级别的渲染。ctx.save()和ctx.restore()是否会影响font会。它们保存的是当前绘图状态栈包括字体、填充色、对齐方式等。如果状态管理不当复用同一段绘制逻辑时会出现“A 页面的字体设置串到 B 页面”的诡异问题。这些问题本质上都在考察两件事你对 Canvas 状态管理的理解以及你是否踩过真实业务里的兼容性坑。所谓“实心文字加最大宽度限制”只是包裹这些知识点的壳把这个壳拆开弄懂了遇到其他 Canvas 文字类需求就不会慌。最后我再分享一个日常调试中的小技巧。Canvas 里看不见的绘制状态是排查的头号难点我在本地开发时经常在绘制前后各打印一次当前上下文状态console.log(ctx.font, ctx.fillStyle, ctx.textAlign);这样一眼就能看出哪些状态被无意修改了。如果你连这个问题排查都省了直接把上面那套代码复制进项目把字体、字号、颜色、最大宽度改成自己的需求今天就能用上。真正把代码写进自己项目里跑一遍比看十篇教程都管用。