Canvas 里一串文字横向排列变为竖向排列,如何操作性能最好?
面试速答(30 秒版 TL;DR)
- 这题先不要急着说
rotate,因为“横排变竖排”至少有 3 种需求:- 整串文字整体旋转 90 度
- 逐字竖排,一字一行
- 真正的东亚竖排排版,涉及标点、英文、数字朝向规则
- 如果只是视觉上“整串转过来”,性能最好的通常是整体坐标系旋转一次,再
fillText一次,不要按字符逐个旋转。 - 如果要的是“一字一行”的竖排效果,最稳的做法是提前拆字、预计算行高和位置,然后顺序
fillText;高频重绘场景再加 离屏缓存,后续直接drawImage。 - 如果文本样式固定、内容重复出现很多次,最佳实践往往是:先在离屏 Canvas 渲染一次竖排文本,再在主画布重复贴图。
- 如果你的核心诉求只是文本排版,不依赖 Canvas 的混合绘制能力,DOM/CSS 的
writing-mode往往比自己在 Canvas 里造排版系统更省性能也更省维护成本。
先把题目拆清楚:你到底想要哪种“竖向排列”?
先分需求:整串旋转、逐字竖排、真正竖排排版。
很多人回答这题时,会直接说:
ctx.rotate(Math.PI / 2)
这个回答不一定错,但经常答偏。
因为:
- 整体旋转 和 逐字竖排 不是一回事
- 中文竖排、英文竖排、标点竖排的规则也不一样
所以更好的答法是先分类,再讲性能最优实现。
一、如果只是“整串文字整体转 90 度”,直接整体旋转最省
这种场景最常见:
- 水印文字
- 坐标轴标题
- 柱状图的竖向类目标签
- 只求视觉方向变化,不要求逐字竖排语义
示例:
function drawRotatedText(ctx, text, x, y) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(Math.PI / 2)
ctx.fillText(text, 0, 0)
ctx.restore()
}
为什么这通常是性能最好的?
- 只做一次
rotate - 只调用一次
fillText - 不需要拆字
- 不需要逐字符测量和排版
所以如果面试官说的“横排变竖排”本质只是 整行方向变化,答案应优先是:
整体旋转一次坐标系,再一次性绘制整串文字,性能通常优于逐字符旋转。
二、如果要的是“一字一行”,不要每个字都 rotate
很多人会写成这样:
for (const ch of text) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(Math.PI / 2)
ctx.fillText(ch, 0, 0)
ctx.restore()
y += 20
}
这类写法的问题是:
- 每个字都
save/restore - 每个字都
translate/rotate - 高频场景下状态切换太多
如果需求是“每个字保持正向,一字一行往下排”,其实根本不需要旋转,直接算位置即可:
function drawVerticalChars(ctx, text, x, y, lineHeight) {
for (let i = 0; i < text.length; i++) {
ctx.fillText(text[i], x, y + i * lineHeight)
}
}
这才是更适合中文逐字竖排的基础方案。
为什么它更省?
- 不做多余的坐标变换
- 每个字只负责一次
fillText - 行高可以提前算好并复用
适用场景:
- 中文按钮文案
- 海报边栏文案
- 画布里少量但经常出现的竖向标签
三、性能真正拉开的关键:把“排版”从“绘制”里拆出来
如果竖排文本会反复出现,真正的优化重点不是 rotate 还是 fillText,而是:
- 不要每帧重复拆字
- 不要每帧重复
measureText - 不要每帧重新算布局
推荐做法是提前产出布局结果:
function layoutVerticalText(text, x, y, lineHeight) {
const glyphs = new Array(text.length)
for (let i = 0; i < text.length; i++) {
glyphs[i] = {
char: text[i],
x,
y: y + i * lineHeight,
}
}
return glyphs
}
function drawLaidOutGlyphs(ctx, glyphs) {
for (const glyph of glyphs) {
ctx.fillText(glyph.char, glyph.x, glyph.y)
}
}
这类思路在大量标签场景里很常见:
- 第一步做布局缓存
- 第二步渲染时只读缓存结果
这样能把“排版计算成本”和“绘制提交成本”分开管理。
四、高频场景的最优解,通常是离屏缓存
如果竖排文字内容固定、样式固定、会重复绘制很多次,最佳实践通常不是每次都重新 fillText,而是缓存成位图。
function createVerticalTextCache(text, font, color, lineHeight) {
const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d')
offCtx.font = font
offCtx.fillStyle = color
offCtx.textBaseline = 'top'
const width = Math.ceil(Math.max(...Array.from(text, (ch) => offCtx.measureText(ch).width)))
const height = lineHeight * text.length
offscreen.width = width
offscreen.height = height
offCtx.font = font
offCtx.fillStyle = color
offCtx.textBaseline = 'top'
for (let i = 0; i < text.length; i++) {
offCtx.fillText(text[i], 0, i * lineHeight)
}
return offscreen
}
const textBitmap = createVerticalTextCache('前端面试', '16px sans-serif', '#111827', 20)
function render() {
ctx.drawImage(textBitmap, 40, 40)
}
这个方案为什么常常更好?
- 文本测量只做一次
- 逐字绘制只做一次
- 后续主画布只要
drawImage
特别适合:
- 图表标签反复出现
- 水印批量铺设
- 地图标注、拓扑节点名、编辑器辅助标记
五、如果文本会重复很多次,再进一步做文本缓存池
例如一个大图里会反复出现几十种固定竖排标签,可以按下面的 key 建缓存:
textfontcolorlineHeightdirection
示意:
const textCache = new Map()
function getVerticalTextBitmap(text, font, color, lineHeight) {
const key = `${text}|${font}|${color}|${lineHeight}`
if (!textCache.has(key)) {
textCache.set(key, createVerticalTextCache(text, font, color, lineHeight))
}
return textCache.get(key)
}
然后主循环里:
const bitmap = getVerticalTextBitmap(label, font, color, 20)
ctx.drawImage(bitmap, x, y)
这类做法在文本较多的 Canvas 应用里,比每次现算现画稳定得多。
六、真正的东亚竖排排版,不要低估复杂度
如果你要的是严格的竖排规则,比如:
- 中文标点位置要正确
- 英文和数字要决定是否旋转
- 括号、破折号、引号要做竖排适配
- 不同字体的字面框和基线差异要处理
那它已经不是“简单把横排变竖排”了,而是在做一套文本排版系统。
这时更稳的建议是:
- 如果不是强依赖 Canvas 混合渲染,优先用 DOM/CSS 的
writing-mode - 如果必须放进 Canvas,尽量先在离屏层完成排版和缓存
- 如果规则复杂到接近专业排版,考虑专门的文本布局方案,而不是手写大量临时逻辑
面试里可以这样讲:
Canvas 2D 并没有像 CSS 那样直接提供成熟的竖排文本布局能力,所以简单效果可以自己画,复杂排版则要把“性能成本”和“实现成本”一起考虑。
七、怎么选实现方案?
| 需求 | 推荐方案 | 性能特点 |
|---|---|---|
| 整串只想转个方向 | 整体 rotate 后一次 fillText | 最省调用次数 |
| 中文一字一行 | 拆字后直接逐个定位 | 简单、稳定、好控 |
| 同一段竖排文字反复出现 | 离屏 Canvas 缓存后 drawImage | 高频场景最划算 |
| 大量重复标签 | 文本缓存池 / 位图缓存 | 减少重复排版与测量 |
| 严格东亚竖排排版 | 优先 DOM/CSS 或专业排版方案 | 维护成本更可控 |
典型面试题 & 标准答法
Q1:横排文字变竖排,直接 rotate 就行吗?
- 不一定,要先分清楚是整体旋转还是逐字竖排。
- 整体旋转适合“整串方向变化”,逐字竖排更适合中文一字一行。
Q2:哪种方式性能最好?
- 如果只是视觉旋转,整体旋转一次再
fillText一次通常最好。 - 如果是逐字竖排且会重复绘制,离屏缓存后
drawImage往往更优。
Q3:为什么不建议每个字都旋转?
- 因为会引入更多
save/restore、translate、rotate和状态切换,高频场景更容易产生额外开销。
Q4:什么时候不该继续硬写 Canvas?
- 当需求已经变成完整的竖排文本排版,且你又不依赖 Canvas 特效混合时,DOM/CSS 往往更自然。
常见追问
- 中文和英文混排怎么办?
- 这是竖排排版里的难点之一,要决定英文是保持横向还是旋转,复杂度明显高于“拆字逐个画”。
- 为什么离屏缓存会更快?
- 因为排版和文本绘制只做一次,后续渲染只需要位图拷贝。
- 逐字
fillText会不会也很多调用?- 会,但如果文本内容固定、先缓存成位图,就能把多次文本绘制变成一次缓存生成加多次
drawImage。
- 会,但如果文本内容固定、先缓存成位图,就能把多次文本绘制变成一次缓存生成加多次
易错点 / 坑
- 把“整体旋转”和“逐字竖排”混为一谈。
- 每个字符都
save/restore + rotate,导致状态切换过多。 - 在渲染循环里反复
measureText,让文本测量成为隐藏瓶颈。 - 忽略字体、标点、英文数字的竖排规则,最后效果不自然。
- 明明只是排版问题,却硬塞进 Canvas,结果实现成本远高于收益。
速记要点(可背诵)
- 先分需求:整体旋转、逐字竖排、真正竖排排版。
- 整串转向最省的是:旋转一次,
fillText一次。 - 逐字竖排最稳的是:提前布局,顺序绘制。
- 高频场景最优通常是:离屏缓存后
drawImage。 - 复杂竖排不要只看运行性能,还要看实现和维护成本。