跳到主要内容

Canvas 里一串文字横向排列变为竖向排列,如何操作性能最好?

面试速答(30 秒版 TL;DR)

  • 这题先不要急着说 rotate,因为“横排变竖排”至少有 3 种需求:
    1. 整串文字整体旋转 90 度
    2. 逐字竖排,一字一行
    3. 真正的东亚竖排排版,涉及标点、英文、数字朝向规则
  • 如果只是视觉上“整串转过来”,性能最好的通常是整体坐标系旋转一次,再 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 建缓存:

  • text
  • font
  • color
  • lineHeight
  • direction

示意:

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 应用里,比每次现算现画稳定得多。


六、真正的东亚竖排排版,不要低估复杂度

如果你要的是严格的竖排规则,比如:

  • 中文标点位置要正确
  • 英文和数字要决定是否旋转
  • 括号、破折号、引号要做竖排适配
  • 不同字体的字面框和基线差异要处理

那它已经不是“简单把横排变竖排”了,而是在做一套文本排版系统。

这时更稳的建议是:

  1. 如果不是强依赖 Canvas 混合渲染,优先用 DOM/CSS 的 writing-mode
  2. 如果必须放进 Canvas,尽量先在离屏层完成排版和缓存
  3. 如果规则复杂到接近专业排版,考虑专门的文本布局方案,而不是手写大量临时逻辑

面试里可以这样讲:

Canvas 2D 并没有像 CSS 那样直接提供成熟的竖排文本布局能力,所以简单效果可以自己画,复杂排版则要把“性能成本”和“实现成本”一起考虑。


七、怎么选实现方案?

需求推荐方案性能特点
整串只想转个方向整体 rotate 后一次 fillText最省调用次数
中文一字一行拆字后直接逐个定位简单、稳定、好控
同一段竖排文字反复出现离屏 Canvas 缓存后 drawImage高频场景最划算
大量重复标签文本缓存池 / 位图缓存减少重复排版与测量
严格东亚竖排排版优先 DOM/CSS 或专业排版方案维护成本更可控

典型面试题 & 标准答法

Q1:横排文字变竖排,直接 rotate 就行吗?

  • 不一定,要先分清楚是整体旋转还是逐字竖排。
  • 整体旋转适合“整串方向变化”,逐字竖排更适合中文一字一行。

Q2:哪种方式性能最好?

  • 如果只是视觉旋转,整体旋转一次再 fillText 一次通常最好。
  • 如果是逐字竖排且会重复绘制,离屏缓存后 drawImage 往往更优。

Q3:为什么不建议每个字都旋转?

  • 因为会引入更多 save/restoretranslaterotate 和状态切换,高频场景更容易产生额外开销。

Q4:什么时候不该继续硬写 Canvas?

  • 当需求已经变成完整的竖排文本排版,且你又不依赖 Canvas 特效混合时,DOM/CSS 往往更自然。

常见追问

  • 中文和英文混排怎么办?
    • 这是竖排排版里的难点之一,要决定英文是保持横向还是旋转,复杂度明显高于“拆字逐个画”。
  • 为什么离屏缓存会更快?
    • 因为排版和文本绘制只做一次,后续渲染只需要位图拷贝。
  • 逐字 fillText 会不会也很多调用?
    • 会,但如果文本内容固定、先缓存成位图,就能把多次文本绘制变成一次缓存生成加多次 drawImage

易错点 / 坑

  • 把“整体旋转”和“逐字竖排”混为一谈。
  • 每个字符都 save/restore + rotate,导致状态切换过多。
  • 在渲染循环里反复 measureText,让文本测量成为隐藏瓶颈。
  • 忽略字体、标点、英文数字的竖排规则,最后效果不自然。
  • 明明只是排版问题,却硬塞进 Canvas,结果实现成本远高于收益。

速记要点(可背诵)

  • 先分需求:整体旋转、逐字竖排、真正竖排排版。
  • 整串转向最省的是:旋转一次,fillText 一次。
  • 逐字竖排最稳的是:提前布局,顺序绘制。
  • 高频场景最优通常是:离屏缓存后 drawImage
  • 复杂竖排不要只看运行性能,还要看实现和维护成本。