跳到主要内容

文字动画

文字动画和图形动画的区别在于:改 x / y 只是换个位置画, 改 text() 却要重新测量、重新换行、重新算每一行的宽度。 它贵得多,写法上的讲究也因此更多。

打字机:必须按字素切分​

最直觉的写法是 full.slice(0, n)——这个写法对中文没问题,对 emoji 会出事。

String.prototype.slice 按 UTF-16 码元切分。一个 emoji 往往由多个码元组成 (👨‍👩‍👧 是三个人物 emoji 加两个零宽连接符,共 11 个码元), 从中间切开会得到半个字符,渲染成乱码或替换符。

正确做法是先按字素(grapheme)切成数组:

const full = '你好,Konva 👨‍👩‍👧 世界!';

const graphemes = Intl.Segmenter
? Array.from(
new Intl.Segmenter('zh', { granularity: 'grapheme' }).segment(full),
(s) => s.segment,
)
: Array.from(full); // 退路:至少按码点切,比 slice 好

const text = new Konva.Text({ x: 20, y: 20, text: '', fontSize: 22 });
layer.add(text);

const anim = new Konva.Animation((frame) => {
const n = Math.floor(frame.time / 160);
if (n >= graphemes.length) { anim.stop(); return; }
text.text(graphemes.slice(0, n).join(''));
}, layer);
anim.start();

Intl.Segmenter 在现代浏览器里都有; Array.from(str) 按码点切,能正确处理单个 emoji,但仍会拆开 ZWJ 组合。

顺带一提,Konva 自己的排版从 10.4.0 起已经是字素感知的—— 国旗 emoji 和 ZWJ 组合在换行和 letterSpacing 时不会被拆开。 但那是 Konva 内部的绘制逻辑,你传给它的字符串怎么切,得你自己负责。

逐字跳动:charRenderFunc​

charRenderFunc 让你接管每个字素的绘制。它只接收一个对象参数:

const bouncing = new Konva.Text({
x: 20, y: 60, text: 'Konva 动画 Bounce', fontSize: 26, fill: '#c06040',
charRenderFunc: ({ char, index, x, y, context }) => {
const dy = Math.sin(phase + index * 0.6) * 6;
context.fillText(char, x, y + dy);
},
});

let phase = 0;
new Konva.Animation((frame) => {
phase = frame.time / 200;
layer.batchDraw();
}, layer).start();

参数字段见 Text 文字 里的完整表格。 两个容易踩的点:回调签名不是 (ctx, info)(写错了会报 Cannot read properties of undefined), 以及 index 是跨行累加的全局序号,要按行内位置做效果得用 column。

常见问题​

为什么文字动画特别卡?​

因为每次 text() 赋值都会让 Konva 重新做一遍排版: 测量每个字素的宽度、计算换行位置、算出每行的对齐偏移。 文本越长,这个过程越贵。

对比一下:改 x 只是改一个数字,下一帧用新的变换矩阵画; 改 text 要重新走一遍完整的文字布局流程。

Konva 10.4.0 对此做过优化——charRenderFunc 和长文本换行 此前的复杂度是文本长度的平方,现在已经修正。 但即便是线性的,长文本每帧重排依然不便宜。

能用 Tween 做打字机吗?​

不能。Konva.Tween 只对数值型属性插值, text 是字符串,没法在 '你好' 和 '你好世' 之间取中间值。

这和渐变色标的情况一样——凡是数组型或结构化的属性, 都只能用 Konva.Animation 每帧自己算,见 复杂补间动画。

理论上可以用 Tween 驱动一个无关的数值属性,再在 onUpdate 里把它换算成字数。 但 Tween 必须绑定在节点的某个属性上,为此借用一个不影响显示的属性 既绕又难读——这种场景直接用 Animation 就好, 它本来就是为"每帧自己算"设计的。

中英文混排时文字在抖动?​

因为每加一个字,整段文字的宽度就变一次, 如果你每帧根据 text.width() 重新居中,位置就会跟着跳。

解法是固定容器宽度,用 align 而不是每帧重算:

new Konva.Text({
x: 20, y: 20,
width: 300, // 固定宽度
align: 'center', // 由 Konva 在这个宽度内对齐
text: '',
});

这样文字在固定的框里居中,加字时不会整体位移。

文字淡入淡出用什么?​

这个可以用 Tween——opacity 是数值:

text.opacity(0);
new Konva.Tween({ node: text, duration: 0.5, opacity: 1 }).play();

逐字淡入则要用 charRenderFunc,在回调里根据 index 和当前进度设 context.globalAlpha。

性能提示​

文字动画的开销集中在重排上,优化思路都是围绕"少排版"。

把已完成的部分固化。 打字机效果如果文本很长, 每帧都在重排整段。更好的结构是:已经打完的行做成一个独立的、 不再变化的 Konva.Text 并 cache(), 只让当前正在打的那一行保持活动状态。 排版成本从"整段"降到"一行"。

限制更新频率。 打字机不需要 60fps—— 每秒 6~10 个字就已经很快了。在 Animation 回调里判断字数有没有变化, 没变就直接返回,不要每帧都赋值 text():

let last = -1;
const anim = new Konva.Animation((frame) => {
const n = Math.floor(frame.time / 160);
if (n === last) return; // 关键:字数没变就什么都不做
last = n;
text.text(graphemes.slice(0, n).join(''));
}, layer);

这一条的收益最大——它把每秒 60 次重排降到每秒 6 次。

文字动画单独放一层。 正在动的文字和静态内容放在同一个图层, 意味着每次重排都要重绘整层。拆到独立的 Layer 上, 每帧只重绘这一层,见图层管理。

charRenderFunc 有额外成本。 启用它之后 Konva 必须逐字素绘制, 没法用浏览器一次性绘制整行的快路径。 只在确实需要逐字控制时才用,做整体的移动、缩放、淡入用普通属性即可。