2026/10/21 6:17:02

Canvas字体大小设置全攻略:从ctx.font到高清屏适配的坑与解

Canvas字体大小设置全攻略:从ctx.font到高清屏适配的坑与解 做Canvas开发这几年我几乎每周都会遇到有人问“Canvas字体大小怎么调”这类问题。大多数人第一反应是给ctx.font赋个值结果要么字没变大要么字变模糊要么直接消失。说真的Canvas文字排版和DOM里的文字完全是两种玩法没有继承没有自动换行没有fontSize属性稍不留神就踩坑。这篇文章我把这几年实际项目中踩过的、帮别人排查过的Canvas字体大小问题一次性整理出来从属性语法、文字对齐、高清屏适配、动态尺寸到自定义字体加载每个坑都带原理和可复用的代码。1. 先掌握font的脾气三个“设了没反应”的惯犯1.1 canvas没有fontSize属性很多人都栽在这里我见过最典型的写法是照着CSS的习惯来ctx.fontSize 20; // 没有任何效果也不会报错 ctx.fontWeight bold; // 同样无效在Canvas里字体样式全部收在ctx.font这一个字符串属性里没有单独的fontSize、fontWeight、fontFamily接口。你写了ctx.fontSize 20浏览器只会把这个自定义属性挂在上下文对象上下一次fillText依然拿默认字体画。默认是10px sans-serif所以很多人会发现“我明明设置了字号字还是小得可怜”。正确的写法是直接操作ctx.font整串ctx.font 20px sans-serif; ctx.fillText(你好, 10, 30);如果你是从某个状态对象里读字号也要拼成完整字符串再赋值。我自己的习惯是写一个小工具统一管理文本样式function applyFont(ctx, { size 16, family sans-serif, weight normal, style normal }) { ctx.font ${style} ${weight} ${size}px ${family}; }这样至少不会漏掉某个子属性。1.2 简写必须同时带大小和字体族缺一即整条失效在CSS里写font: 20px会被浏览器视为非法简写整个font声明被丢弃。Canvas的ctx.font语法完全沿用CSS font简写规则所以下面这种赋值也是无效的ctx.font 20px; // 缺少font-family整条被忽略 ctx.font bold 20px; // 还是缺少font-family赋值失败后canvas保留的是上一次生效的font值而不是变空。这就会造成一个很迷惑的现象代码明明执行了字却还是旧样子。正确的简写需要同时给出大小和字体族ctx.font 20px Arial; ctx.font bold 20px serif; ctx.font italic 20px PingFang SC, sans-serif;属性顺序可以稍微灵活但建议保持和CSS一致style weight size/line-height family。另外值得提醒的是ctx.font一旦赋值会整体替换掉旧值它不是累加器。想只改字号就必须把字体族、字重一起重新拼上。用模板字符串或上面那个applyFont工具会省心很多。1.3 单位换算pt、em在canvas里的真实大小大家最常用的字号单位是px但ctx.font也支持pt、em、rem等CSS单位。换算关系很简单CSS规范里1英寸等于96px1pt等于1/72英寸所以1pt等于96除以72也就是1.333px。而em是相对当前ctx.font里font-size的倍率例如当前字号是20px那2em就是40px。实际操作里我建议除非有特殊需求否则统一用px。原因有两个一是Canvas画布坐标系本身就是以CSS像素为单位的用px最直观二是em的值会依赖上一次的font设置容易在状态复用时不自觉放大缩小。如果确实拿到的是pt值可以乘1.333转成px再拼字符串const pt 12; const px Math.round(pt * 1.333); // 约16px ctx.font ${px}px sans-serif;至于20px/30px Arial这种带行高的写法Canvas会解析它但多行文本的“行高”不会像DOM里那样自动生效。Canvas本身没有排版引擎fillText一次只画一行行距需要你自己用measureText或固定行高去算。所以不要把行高当成控制文字垂直位置的手段老老实实算坐标。2. 文字垂直对齐不准除了字号textBaseline才是关键2.1 各对齐模式的基准位置差异很多初学者把字号调对了却发现文字和背景矩形对不齐或者字跑到矩形上方去了。这十有八九是textBaseline没搞清楚。它决定fillText的y坐标到底对齐文字的哪个位置。textBaseline取值对齐基准典型表现alphabetic字母基线默认值x和英文字母的底边对齐中文整体略偏下topem方框顶部文字顶部贴着y坐标hanging悬挂基线类似top但位置更高拉丁字体很少用middleem方框中间视觉上大致居中但会受字体度量影响ideographic表意文字基线适合中文、日文大约在汉字底部bottomem方框底部文字最低点贴着y坐标这里的“em方框”可以理解为一个虚拟的方格字号多大方框大致就多高。middle对齐的是这个方框的几何中间不是字形实际视觉区域的中间。尤其对于中文和英文混排同一个px字号中文字形通常更满英文字形上下留白大看起来会一高一低。所以我现在的默认习惯是需要精确对齐时不依赖textBaseline: middle而是结合measureText()返回的真实字形边界来计算。2.2 用字体真实度量值做精准垂直居中现代浏览器里measureText()的返回值除了width还包含actualBoundingBoxAscent和actualBoundingBoxDescent。这两个值代表当前这段文字实际字形超出基线的上边界和下边界比em方框更接近真实视觉。要让一段文字的字形中心正好落在某个点代码可以这样写function drawTextCentered(ctx, text, centerX, centerY) { const metrics ctx.measureText(text); const ascent metrics.actualBoundingBoxAscent; const descent metrics.actualBoundingBoxDescent; const baselineY centerY (ascent - descent) / 2; ctx.textAlign center; ctx.textBaseline alphabetic; ctx.fillText(text, centerX, baselineY); }这个函数的推导逻辑很简单字形盒中心相对基线的偏移是(ascent - descent) / 2把基线放到centerY 偏移的位置字形中心就正好对准centerY。如果你还需要同时保证文字不超出某个矩形区域可以在此基础上再做一层边界修正function drawTextInRect(ctx, text, rect) { const metrics ctx.measureText(text); const textHeight metrics.actualBoundingBoxAscent metrics.actualBoundingBoxDescent; const centerY rect.y rect.height / 2; const scale Math.min(1, rect.width / metrics.width, rect.height / textHeight); if (scale 1) { ctx.save(); ctx.translate(rect.x rect.width / 2, centerY); ctx.scale(scale, scale); ctx.textAlign center; ctx.textBaseline alphabetic; ctx.fillText(text, 0, 0); ctx.restore(); } else { ctx.textAlign center; ctx.textBaseline alphabetic; const baselineY centerY (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; ctx.fillText(text, rect.x rect.width / 2, baselineY); } }注意measureText的结果和ctx.font强相关。测量之前必须先把ctx.font设置成最终要用的字体否则测出来的是旧字体的宽度整体都会错位。2.3 顺带处理水平居中与padding水平方向就简单多了ctx.textAlign center配合一个中心x坐标即可。如果想让文字在矩形内左右留白可以手动控制中心点const padding 12; const centerX rect.x padding (rect.width - 2 * padding) / 2;这里唯一要留意的是textAlign会影响fillText里x坐标的语义。center模式下x是文字中心left模式下x是左边界。如果你在循环里画多段文字记得每次把textAlign和textBaseline显式设置因为它们都属于Canvas上下文状态会和fillStyle、font一样被保存不会自动恢复。3. 高清屏下字体“变糊变小”devicePixelRatio补偿方案3.1 原理canvas像素缓冲区和CSS显示尺寸的关系这是让很多人头疼的“为什么我的Canvas字那么糊”问题的核心。Canvas的width和height属性决定的是绘图缓冲区的实际像素数量而CSS样式里的宽高决定它在页面上的显示尺寸。当两者不一致时浏览器会把缓冲区拉伸或压缩到CSS尺寸。在普通屏幕下1个CSS像素对应1个物理像素所以canvas.width 300配style.width 300px刚刚好。但在DPR为2或3的屏幕上1个CSS像素实际要铺满2个或3个物理像素。如果你的canvas.width还是300它只有300个物理像素的缓冲区却被拉伸到600个物理像素的屏幕区域显示浏览器只能插值补点结果就是文字边缘发虚、整体发糊。更隐蔽的是字号感知问题。同样设置ctx.font 16px sans-serif在DPR1的屏幕上16px文字清晰在DPR2的屏幕上如果不做任何处理文字因为被拉伸视觉上可能还是接近16px但边缘模糊。有些人误以为“缩小点就清楚了”结果越来越小。3.2 初始化代码与常见二次坑正确做法是让Canvas缓冲区尺寸等于“CSS显示尺寸乘以DPR”然后用setTransform把坐标系缩放回CSS尺寸。这样所有绘制代码都按CSS像素书写但实际像素密度提升文字清晰锐利。function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); const displayWidth Math.round(rect.width); const displayHeight Math.round(rect.height); // 关键缓冲区用物理像素CSS尺寸保持不变 canvas.width Math.round(displayWidth * dpr); canvas.height Math.round(displayHeight * dpr); canvas.style.width displayWidth px; canvas.style.height displayHeight px; const ctx canvas.getContext(2d); // 坐标系恢复为CSS像素 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }这个方案里有两个常见的二次坑。第一不要用canvas.width canvas.clientWidth * dpr这种写法。clientWidth是整数且包含边框遇到百分比或flex布局时不够准getBoundingClientRect()能拿到带小数的实际渲染尺寸更可靠。第二canvas.width一旦被重新赋值整个画布内容会清空所有上下文状态包括font、fillStyle、lineWidth、textAlign都会重置。如果是在窗口resize后重新执行setupCanvas务必在重绘前重新设置字体样式。我的习惯是把所有绘制逻辑包成一个render()函数状态和绘制都从render()开始统一设置避免状态残留。3.3 字号在这种方案里应该怎么写经过setTransform(dpr, 0, 0, dpr, 0, 0)之后绘制坐标系已经按CSS像素工作所以字号依然写CSS像素值ctx.font 16px sans-serif; ctx.fillText(清晰文字, 10, 30);实际绘制时Canvas内部会把这个16px字符串按当前变换矩阵换算成物理像素相当于用16乘以dpr的分辨率去渲染字形。视觉上文字大小和普通DOM元素一致但清晰度明显提升。这里我要特别提醒一个反直觉的点如果你只把canvas.width和canvas.height乘了dpr却忘了setTransform那么所有坐标也会按物理像素走。你原来写在x: 50的位置会变成离左边50个物理像素也就是25个CSS像素整个图案会缩到左上角。所以“乘dpr”和“缩放坐标系”必须成对出现。4. 画布尺寸变化后字号固定不变动态适配思路4.1 固定设计稿等比缩放还是实时重算字号Canvas适合做数据可视化、海报生成、游戏画面这类需要精确控制布局的场景。当外部容器改变宽高时如果只改canvas.width而不整体缩放字号会显得突兀画布变大但字还是原来大小或者画布变小字直接溢出。有两种常见的适配策略按项目需要选择。第一种策略是“固定设计稿比例整体缩放”。假如你按400乘300的设计稿开发容器变为800乘600可以在setupCanvas之后额外加一层缩放const designWidth 400; const designHeight 300; const scaleX rect.width / designWidth; const scaleY rect.height / designHeight; ctx.setTransform(dpr * scaleX, 0, 0, dpr * scaleY, 0, 0);这种做法下所有文字字号、坐标、线宽全部按设计稿px书写容器变化后自动放大缩小。优点是简单缺点是线宽和字号会一起被拉伸某些风格下会显得不自然。第二种策略是“固定字号基准实时换算”。适合图表类希望字体保持相对比例但边框粗细不变const baseFontSize 14; const scale rect.width / 400; // 参考宽度 ctx.font ${Math.round(baseFontSize * scale)}px sans-serif;实际操作中我倾向做一个统一配置对象把layoutWidth、layoutHeight、titleFontSize、bodyFontSize都放进去渲染时统一乘缩放系数。这样改参数很直观也不容易漏掉某个坐标。无论哪种策略都需要监听容器尺寸变化。推荐用ResizeObserver而不是裸window.resize因为Flex或Grid布局下父容器尺寸变化不一定触发window尺寸变化const observer new ResizeObserver(() { setupCanvas(canvas); render(); }); observer.observe(canvas.parentElement);每次触发都要重新setupCanvas然后整帧重绘。如果你在render()之前依赖某些只初始化一次的状态记得把它们挪到render()内部管理或者用ctx.save()和ctx.restore()保护关键状态。4.2 measureText在自适应宽度和自动换行中的用法自适应字体大小是另一个高频需求文字太长希望自动缩小字号而不是换行。function fitFontSize(ctx, text, maxWidth, baseSize, family) { let size baseSize; while (size 1) { ctx.font ${size}px ${family}; if (ctx.measureText(text).width maxWidth) { return size; } size - 0.5; } return size; }循环从大到小尝试字号每次重新设置ctx.font并测量。为了效率可以降低步长为1px最多也就跑几十次普通绘制场景完全够用。多行文本的自动换行总有人以为Canvas自带word-wrap其实没有。你要自己切分字符串逐行测量宽度function wrapTextToLines(ctx, text, maxWidth) { const chars Array.from(text); const lines []; let currentLine ; for (const char of chars) { const testLine currentLine char; if (ctx.measureText(testLine).width maxWidth currentLine) { lines.push(currentLine); currentLine char; } else { currentLine testLine; } } if (currentLine) { lines.push(currentLine); } return lines; }这个方案对中文按字切分效果不错对英文单词可能从中间断开。如果必须保留英文单词完整则需要改成按空格切分再逐词拼接逻辑会复杂一些。我的建议是先把连续非中文内容看作一个完整单元再混合中文字符做二次切分。简单起见多数场景下按字符切分已经够用。得到行数组后绘制时逐行计算y坐标function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) { const lines wrapTextToLines(ctx, text, maxWidth); lines.forEach((line, index) { ctx.fillText(line, x, y index * lineHeight); }); }注意lineHeight要大于字号建议至少是fontSize * 1.4否则中文行与行之间容易贴在一起。5. 自定义字体加载失败字号正确字形却不对的隐藏原因5.1 Canvas绘制时字体还没加载完会发生什么你设置了ctx.font 16px MyFont, sans-serif字号看起来也生效了但字形就是不对测出来的文字宽度也不对。这不是字号问题而是自定义字体还没加载完成。Page上显示DOM文字时浏览器会等字体加载完再渲染或使用font-display策略。但Canvas的fillText是即时绘制的它不等待任何异步字体加载。如果自定义字体没就绪就默默用后面的回退字体画。不同字体的ascent和descent度量不一样在相同px下画出来的字形高度、文字宽度都不同。所以你会觉得“字号不对”。还有一种情况是企业级管理系统里常见字体文件是从某个平台动态加载的字体变量或子集没有完全覆盖目标字符Canvas画到某个特殊符号时直接用了回退字体。这类问题最难排查因为大多数文字是对的个别字不对。5.2 用document.fonts和FontFace卡住绘制时机最稳妥的办法是确保字体加载完成后再开始Canvas绘制。推荐使用document.fonts接口async function drawWithCustomFont(ctx, text, x, y, fontFamily) { try { await document.fonts.load(16px ${fontFamily}); await document.fonts.ready; } catch (e) { // 字体加载失败时静默使用fallback } ctx.font 16px ${fontFamily}, sans-serif; ctx.fillText(text, x, y); }如果项目使用动态加载的字体文件还可以用FontFace主动注册后再绘制async function loadFont(fontFamily, url) { const face new FontFace(fontFamily, url(${url})); const loadedFont await face.load(); document.fonts.add(loadedFont); await document.fonts.ready; }实际使用中我习惯在应用初始化时就预加载一遍Canvas可能用到的所有字体而不是每次绘制都等。这样后续所有Canvas绘制默认字体已就绪逻辑更简单。5.3 画布回收与状态重置时字体设置要一并处理最后说一个很多人忽略的场景Canvas动画跑完清除画布、或者切换主题时重新初始化上下文。clearRect只清空像素不会重置ctx.font所以下一次复用上下文时字体设置还是上一次的值。如果你在较长的代码路径里动态修改过字号下一次绘制前忘了重新赋值就会继续沿用一个不合适的字号。我的建议是把Canvas文本绘制统一封装成函数内部每次都设置完整的font、textAlign、textBaseline不要指望“之前设置过了”。这样虽然看起来有点啰嗦但能避免大量隐晦的状态残留问题。实际项目中我后来还养成了一个习惯所有Canvas文字前先调用一次measureText做宽度断言发现宽度异常或字体度量异常时提前预警。尤其是自定义字体场景这个步骤帮我发现了不止一次“字体没加载就绘制”的bug。字体加载这类问题出现的频率比想象中高但排查链路其实很固定先确认ctx.font完整再确认字体文件已就绪最后确认Canvas的物理像素和CSS像素没有错位。把这几个点都守住Canvas字体大小相关的坑基本就都避开了。