
先说一个我自己的经历之前做一套可视化大屏展示系统里面用 Canvas 画了各种图表和标注文字。开发机上一切正常等部署到客户那边的 Windows 一体机上所有文字直接糊成一团部分文字甚至被截断。后来排查发现客户的系统设置了 125% 的显示缩放而我的画布压根没考虑这层缩放关系。从那以后我就明白了一个道理画布里的文字在缩放屏幕上做归一化不是可选项而是必选项。这里说的归一化本质就是让文字在不同设备、不同缩放级别下保持视觉尺寸一致、清晰锐利、位置正确。它涉及三个层面的问题一是设备像素比devicePixelRatio导致的高分屏模糊二是地图或编辑器类场景中画布内部 transform 变化后文字被拉伸三是浏览器或系统级缩放对 CSS 像素和 Canvas 坐标的干扰。这篇文章我会把这三种情况逐个拆开讲直接给能用的方案。如果你正在做 Canvas 可视化、地图引擎、绘图工具或者纯属在网页里画点带字的交互图形这篇文章应该能帮你在文字缩放这件事上少走不少弯路。1. 为什么画布文字遇到缩放就失控三个容易混淆的坐标系先理清一个最常见的误区很多人以为 Canvas 里的文字模糊是字体没选好或者分辨率不够其实大概率是坐标系混乱导致的。1.1 CSS 像素、物理像素和画布内部的绘图坐标要理解 Canvas 文字为什么会在缩放屏幕上出问题得先把三个坐标系分开CSS 像素你在 CSS 里写的width: 500px这是逻辑尺寸浏览器会根据设备缩放比例把它映射到物理屏幕上。物理像素屏幕实际发光的点。一台 1920x1080 的显示器在 100% 缩放下CSS 像素和物理像素是 1:1在 125% 缩放下1 个 CSS 像素实际占用 1.25 个物理像素。Canvas 绘图坐标你在代码里调用ctx.fillText(你好, 100, 100)时这个(100, 100)是 Canvas 内部的绘图坐标系它由canvas.width和canvas.height决定和 CSS 里设置的显示宽高没有直接关系。这三者一旦错位问题就来了。经典场景是Canvas 的 CSS 宽度是 500px但canvas.width也设成了 500。在 100% 缩放下没问题换到 125% 缩放的屏幕上浏览器会把 500 个物理像素宽的画布拉大到 625 个物理像素文字自然就糊了。同理在高分屏如 MacBook 的 Retina 屏上devicePixelRatio 2相当于 2 个物理像素显示 1 个 CSS 像素如果你不把canvas.width设为 1000画布内容就要被浏览器强行放大字必糊无疑。1.2 画布内部 transform 带来的拉伸感第二类常见问题出在画布内部的缩放变换上。比如做地图应用时你调用ctx.scale(2, 2)把整个画面放大两倍这时候画布上的文字如果不做处理也会跟着放大两倍。表面看没问题字大了而已但实际上如果放大不是等比缩放比如 x 方向放大 1.5 倍、y 方向放大 2 倍文字会被压扁或拉长这就是典型的文字变形。更重要的是很多场景里我们的需求恰恰相反画面放大了但文字要保持原始大小。比如地图上的标注名称地图缩放时名称应该始终清晰可读不能跟着地图一起变得巨大或微小。这才是归一化最核心的应用场景。1.3 文字测量与渲染时机容易被忽略的抗锯齿问题还有一个隐蔽因素Canvas 绘制文字时浏览器是按字体大小做抗锯齿的。在低分辨率的 backing store 上绘制抗锯齿效果差文字边缘发虚在高分辨率 backing store 上绘制抗锯齿质量明显更高。如果你用ctx.measureText()测量文字宽度时坐标系和实际绘制坐标系不一致还会出现文字被截断、居中对齐偏移等一连串问题。所以归一化的第一步不是写代码而是先把你的坐标系现状搞清楚。我建议每个项目开始前先画一条测试文字在不同缩放下截图对比肉眼确认问题属于哪一类——是整体模糊DPI 问题、大小不随预期变缩放逻辑问题还是文字变形非等比缩放问题。定位准了再动手效率会高很多。2. devicePixelRatio 归一化高分屏和系统缩放的核心解法这是画布文字归一化里最基础也最容易踩坑的一环。核心思路一句话让 Canvas 的 backing store 分辨率匹配设备的物理像素分辨率然后所有绘制逻辑按 CSS 像素的坐标系来。2.1 基本初始化代码把分辨率顶满先看一个最标准的初始化写法function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); // 将画布的实际像素大小设置为 CSS 尺寸乘以 dpr canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); // 获取绘图上下文并缩放坐标系 const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }这段代码做了三件事读取window.devicePixelRatio得到当前设备的像素比普通屏是 1Retina 屏可能是 2Windows 125% 缩放下是 1.25。把 Canvas 的属性宽度和高度设为 CSS 尺寸乘以 dpr确保 backing store 有足够的物理像素。调用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把整个绘图坐标系放大 dpr 倍这样后续所有绘制代码仍然按 CSS 像素的坐标书写不用一个个地去乘比例。有了这个初始化文字在 Retina 屏和 125% 缩放的 Windows 系统上绘制质量就和 100% 缩放下基本一致了。注意一个细节ctx.scale(dpr, dpr)和ctx.setTransform(dpr, 0, 0, dpr, 0, 0)不要混用。scale()是叠加在已有变换之上的如果多次调用会导致变换矩阵累乘越乘越乱。setTransform()是直接覆盖初始化时用它是安全的。2.2 动态响应系统缩放变化监听 resize 和设备像素比变化光初始化一次是不够的。用户可能会拖动浏览器窗口、切换显示器比如笔记本外接一个 2K 屏、或者按 Ctrl 加减号缩放页面。这些操作都会改变 dpr 或 CSS 尺寸画布需要重建。我常用的是一个监听方案function bindCanvasResize(canvas, drawCallback) { const updateSize () { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); drawCallback(ctx); }; // 用 ResizeObserver 监听画布尺寸变化 const ro new ResizeObserver(updateSize); ro.observe(canvas); // 监听 dpr 变化常见于浏览器缩放、系统缩放设置变更 window.addEventListener(resize, updateSize); window.matchMedia((resolution: ${window.devicePixelRatio}dppx)).addEventListener(change, updateSize); updateSize(); return () { ro.disconnect(); window.removeEventListener(resize, updateSize); }; }这里有个不算太常见但很值得留意的 APImatchMedia监听resolution变化可以捕获系统字体缩放比例调整时 dpr 的变化。只监听window.resize是不够的因为 dpr 变化可能发生在显示器设置更改时而窗口尺寸没有变化。2.3 注意 canvas.width 赋值对上下文的坑特别提醒每次给canvas.width赋值都会清空整个画布并重置上下文的 transform 和 state。这意味着你不能在用户缩放过程中偷懒只改canvas.width然后接着画——画布内容已经被清掉了所有图像、文字、路径都需要重绘。这正是 Canvas 和 DOM 的典型差异DOM 元素缩放是浏览器帮你重新排版Canvas 是彻底失忆。所以任何涉及尺寸变化的操作都要触发一次完整的重绘流程。如果你有大量静态内容建议把绘制逻辑拆成背景层和前景层两个 Canvas缩放时只重绘前景层能省不少性能开销。3. 地图和编辑器场景画布内部缩放时文字的归一化策略如果说 DPI 归一化解决的是画布整体与屏幕的关系那这一节要解决的则是画布内部内容与视口的关系。在地图、思维导图、设计工具这类应用中画布内容会经历频繁的缩放平移这种情况下文字归一化要复杂得多。3.1 区分两种需求文字随缩放放大与文字恒常大小先明确需求。画布内部缩放时文字有两种截然不同的处理方式文字随世界缩放比如图表里的坐标轴标签缩放时文字跟着坐标系一起变大变小这符合大多数图表工具的直觉。但这种做法在极端缩放下体验很差——地图缩小到全国范围时城市名会小到看不清楚。文字恒常大小反向补偿地图上的地名、标记注释、编辑器里的节点文字缩放时文字始终保持屏幕上的固定字号。这种做法的实现方式就是对缩放因子做逆变换补偿。绝大多数归一化的话题指的是第二种。因为第一种实现起来太简单了——你只是把文字放进世界坐标系里缩放是自动的。真正考验功夫的是地图在缩放标签却要保持清晰可读。3.2 文字反向补偿公式与实现假设你的画布内容有一个缩放系数worldScale你希望文字永远显示为fixedFontSize屏幕像素那么绘制时的实际字体大小应该是const fontSize fixedFontSize / worldScale; ctx.font ${fontSize}px sans-serif;这里的关键是你把世界坐标系的缩放视为一个变换而文字要保持屏幕视口坐标系的大小所以在世界坐标系下绘制文字时需要用1/worldScale抵消掉缩放的影响。如果你的变换是矩阵形式的比如用了ctx.setTransform(a, b, c, d, e, f)可以通过求逆矩阵来得到补偿。一个更简单但同样有效的做法是在执行变换时把文字绘制单独拆出来。// 伪代码重绘循环 function render() { // 1. 保存当前状态 ctx.save(); // 2. 应用世界变换缩放和平移 ctx.translate(view.x, view.y); ctx.scale(view.scale, view.scale); // 3. 绘制所有图形但跳过文字 drawShapes(); // 4. 把文字的反向补偿算进去 drawLabels(view.scale); ctx.restore(); } function drawLabels(scale) { const compensatedFontSize 12 / scale; ctx.font ${compensatedFontSize}px sans-serif; // 逐个绘制文字 }但这种方法有个严重的隐患当缩放比例非常小比如缩小到 0.1 倍时12 / 0.1 120px文字会变得非常大不仅遮挡视野还可能出现性能问题因为浏览器在渲染超大字体时的开销比想象中高很多。反过来放大到 20 倍时补偿字号只有 0.6px文字完全无法渲染直接消失。我实测时发现当字号小于 1px 时浏览器干脆不渲染了。所以正常项目里我会加上一个可读性区间限制。3.3 引入经验系数限制可读性区间我的处理方法是定义一个最小和最大可读字号区间在这个区间之外就切换为占位符比如圆形图标或省略号等缩放回到区间内再渲染文字。这是地图应用的常规做法const MIN_FONT_SIZE 8; const MAX_FONT_SIZE 24; function getAdjustedFontSize(scale) { const rawSize 12 / scale; if (rawSize MIN_FONT_SIZE) { return null; // 不渲染文字用占位符 } if (rawSize MAX_FONT_SIZE) { return MAX_FONT_SIZE; // 限制最大字号避免遮蔽画面 } return rawSize; }这样处理之后文字在绝大多数缩放级别下都能保持可读。占位符和文字的切换要做好过渡不然画面会显得很突兀。这个没有统一标准完全取决于你的业务场景对观感的要求有多高。3.4 多层级缩放的归一化分级切换的策略除了连续缩放还有一种离散缩放的场景即地图或知识图谱的分级显示level 1 显示省名level 2 显示市名level 3 显示区县名。这种场景下归一化不只是字号补偿还涉及文字内容的显隐。我的经验是把缩放范围映射到一个整数级别每个级别有自己的字号和显示内容集合缩放级别显示内容基准字号补偿方式level 1省/州级别名称14px按 dpr 修正level 2城市名称13px按 dpr 缩放修正level 3区县名称12px按 dpr 缩放修正level 4街道/POI11px按 dpr 缩放修正这样做的优势是你把归一化从纯数学问题提升到了产品策略层面。用户的感知是字一直清楚了而且内容组织得很合理而不是简单的大字/小字切换。这个设计对地图类产品尤为重要也是很多图表类应用忽略的地方。4. 从实践出发一套可复用的文字归一化绘制工具概念说再多不如把工具做出来。这一节我会给出一套我在项目中实际使用过的完整方案它整合了 DPI 自适应和画布内部缩放补偿两套逻辑可以直接抄进项目里跑起来。4.1 完整的坐标归一化工具类class CanvasTextRenderer { constructor(canvas) { this.canvas canvas; this.ctx canvas.getContext(2d); this.dpr window.devicePixelRatio || 1; this.worldScale 1; this.baseFontSize 12; this._resizeObserver null; this._bindEvents(); } // 初始化时调用设置 backing store 与 CSS 尺寸的映射 _bindEvents() { const resize () { const dpr window.devicePixelRatio || 1; const rect this.canvas.getBoundingClientRect(); this.dpr dpr; this.canvas.width Math.max(1, Math.round(rect.width * dpr)); this.canvas.height Math.max(1, Math.round(rect.height * dpr)); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.draw(); }; this._resizeObserver new ResizeObserver(resize); this._resizeObserver.observe(this.canvas); window.addEventListener(resize, resize); resize(); } // 设置画布内部世界的缩放比例 setWorldScale(scale) { this.worldScale scale || 1; this.draw(); } // 归一化后绘制文字 drawText(text, x, y, options {}) { const { maxFontSize 24, minFontSize 8 } options; const ctx this.ctx; const dprScale this.dpr; // 对世界缩放进行补偿让文字保持屏幕上固定大小 let fontSize this.baseFontSize / this.worldScale; // 限制可读区间 if (fontSize minFontSize) { // 太小时不绘制文字可画占位符 ctx.beginPath(); ctx.arc(x, y, 3 / this.worldScale * dprScale, 0, Math.PI * 2); ctx.fillStyle options.placeholderColor || #333; ctx.fill(); return; } if (fontSize maxFontSize) { fontSize maxFontSize; } ctx.save(); ctx.font ${fontSize * dprScale}px ${options.fontFamily || sans-serif}; ctx.fillStyle options.color || #333; ctx.textBaseline options.textBaseline || middle; ctx.textAlign options.textAlign || center; ctx.fillText(text, x, y); ctx.restore(); } // 供外部调用的重绘入口 draw() { const ctx this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 你的自定义绘制逻辑写在这里或者通过回调注入 if (this.onDraw) { this.onDraw(ctx); } } destroy() { this._resizeObserver?.disconnect(); window.removeEventListener(resize, this._bindEvents); } }使用方式很简单实例化之后在onDraw回调里调用drawText()每次窗口尺寸或缩放级别变化都会自动重绘。画布内部的图形部分该怎么 transform 就怎么 transform只有文字走drawText()这个口。4.2 几个容易被忽略的细节上面代码里有几个细节值得反复确认第一为什么font设置里要再乘一次 dpr因为ctx.setTransform(dpr, 0, 0, dpr, 0, 0)只影响绘图坐标的映射字号的大小是在变换后的坐标系里解释的吗答案是字体大小遵循当前变换矩阵。但为了保险起见我仍然习惯在设置字号时显式把 dpr 乘进去这样即使某个环节忘了调用setTransform文字清晰度也有保障。你可以理解为双保险。第二为什么clearRect清空的是整个画布物理尺寸而不是 CSS 尺寸因为清空是基于当前变换矩阵的变换矩阵已经把坐标系放大了 dpr 倍所以clearRect的参数也要用物理像素尺寸。这里容易出错的是如果你用clearRect(0, 0, rect.width, rect.height)只能清掉左上角四分之一的区域dpr 2 时。我早期就在这里栽过跟头画面越画越花就是因为残留的旧帧没清干净。第三关于ctx.textBaseline的归一化。多数字体在不同字号下基线位置会有微小的变化。如果你的文字需要和某个图形严格对齐建议统一设置textBaseline middle并配合textAlign center这样对齐逻辑最直观也最容易做偏移补偿。4.3 性能优化缓存离屏 Canvas 避免重复渲染在地图或编辑器场景里每次缩放都重绘全部文字性能损耗很可观。尤其是当文字数量上千时频繁调用fillText会造成明显的卡顿。我的优化方案是分级缓存将不常变的文字层绘制到一个离屏 Canvas 上缩放时先快速平移缩放离屏 Canvas只有缩放级别跨过临界点时才重新绘制离屏 Canvas 上的文字。// 离屏缓存示例 const offscreen document.createElement(canvas); offscreen.width 2000; offscreen.height 2000; const offCtx offscreen.getContext(2d); function renderLabelsToOffscreen() { offCtx.clearRect(0, 0, offscreen.width, offscreen.height); offCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); // 把当前缩放级别下的所有文字绘制到离屏画布上 drawAllLabels(offCtx, currentLevel); }主画布绘制时先drawImage(offscreen, ...)把缓存贴上去再绘制需要频繁更新的动态元素。这样文字渲染的开销从每一帧都做降为每跨一个缩放级别做一次。实测下来在 800 个文字标签的场景下帧率从 20fps 提升到了 55fps 以上效果非常明显。5. 页面级缩放与系统级缩放的交叉影响调试方法与避坑手册最后一部分聊聊我踩过的一些坑以及对应的调试方法。这些坑的共同点是表面症状各不相同但根因都指向坐标系没归一化完全。5.1 坑一canvas.width 被写为字符串或浮点数导致模糊JavaScript 的canvas.width属性要求是整数。如果你写的是canvas.width rect.width * dpr而rect.width是浮点数比如 375.5计算结果就是浮点数。浏览器会尝试取整但不同浏览器取整的方式不完全一样可能导致画布尺寸和 CSS 尺寸有个微小的 0.5px 偏差长期累积就会出现文字边缘发虚、线段不直。解决方法赋值前统一用Math.round()取整我的工具类里用的就是Math.round(rect.width * dpr)。另外怀疑这个问题的直接检查方法是打开 DevTools 的 Rendering 面板勾选Layer Borders观察画布线框是否和 CSS 尺寸完全贴合。5.2 坑二zoom 事件导致的 dpr 变化没有重新初始化浏览器的页面缩放Ctrl 加减号或触控板捏合实际上会改变window.devicePixelRatio的值。很多人的初始化代码只在页面加载时执行一次用户缩放页面后就完全不更新了。画布文字在浏览器缩放后要么变得模糊未同步 backing store要么变得错位CSS 尺寸改变但画布属性尺寸没变。我在 2.2 节已经给出了方案用matchMedia监听 dpr 变化。但它有个兼容性细节——不是所有浏览器都支持在matchMedia上直接addEventListener有些旧版本只支持addListener。为了稳妥我在项目里做了一层兼容const mql window.matchMedia((resolution: ${window.devicePixelRatio}dppx)); const listener () { /* resize 逻辑 */ }; if (mql.addEventListener) { mql.addEventListener(change, listener); } else if (mql.addListener) { mql.addListener(listener); }这个细节平时没人提但一旦遇到老版本 WebView 或旧内核浏览器会影响兼容性。5.3 坑三文字位置归一化正确旋转缩放组合后出现漂移如果你的画布应用里同时存在旋转和缩放比如设计工具里旋转一个文本框组件单靠fontSize fontSize / scale是不够的因为旋转之后文字的基线和坐标轴的夹角会变化单纯补偿字号只能保证字的大小不变不能保证字的位置贴合锚点。我的处理方法是针对这种场景把文字绘制从世界变换中彻底提出来——先ctx.save()重置变换然后用屏幕坐标系直接绘制文字。也就是说文字的位置要经过一次世界坐标 → 屏幕坐标的转换但文字的绘制本身不套用任何世界变换// 假设 worldX, worldY 是世界坐标view 是视口变换 const screenX worldX * view.scale view.x; const screenY worldY * view.scale view.y; ctx.save(); ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); ctx.font 12px sans-serif; ctx.fillText(text, screenX, screenY); ctx.restore();这种方式牺牲了一点点性能需要手动做坐标变换但胜在逻辑最清晰、不会出现旋转漂移。如果你需要大量绘制这种文字建议把坐标变换封装成函数避免散落到处都是。5.4 调试建议建立一个缩放自检用例最后分享一个我在项目里常驻的调试模块。我会在画布角落画几组自检文字一组固定字号 12px 的普通文字用来检查 DPI 归一化是否生效一组标注了当前dpr、worldScale、compensatedFontSize的调试信息实时显示归一化计算的中间值一组位于画布四个角落的文字用来检查画布尺寸变化后边缘位置的文字是否被截断或模糊。每次改完渲染逻辑我都会用浏览器 DevTools 的设备模拟器切换不同 dpr再手动缩放画布观察自检文字是否正常。这个方法帮我抓到了很多肉眼很难注意到但截图对比很明显的偏移问题。你可以在自己的工具类里加一个debug: true开关需要时打开生产环境关闭成本很低但收益很高。