前言
随着DeepSeek的火爆,公司前段时间也接了一个有关AI的项目。其中有一个人机交付对话框页面。逻辑也挺简单,通过websocket建立长连接。后端调用AI接口给前端推消息。

需求
产品希望对话的效果能像市面的AI产品一样呈现出一个个出的那种打字效果!
技术实现
1.CSS+动画
js
// css
@keyframes typing {
from {
width: 0;
}
to {
width: 100%;
}
}
.typing-effect {
overflow: hidden;
white-space: nowrap;
animation: typing 6s steps(50, end);
}
//html
<div class="typing-effect">
日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。
</div>
效果

效果虽然出来了,但是存在一个比较大的弊端:无法多行呈现 只要文本较多,超过了一行的情况就出现文字展示不全的情况。
2.js+定时器
js
function typeWriter(elementId, message, speed) {
const element = document.getElementById(elementId);
let i = 0; // 当前字符索引
const interval = setInterval(() => {
if (i < message.length) {
element.textContent += message.charAt(i); // 逐字符添加文本
i++;
} else {
clearInterval(interval); // 完成打字后停止间隔调用
}
}, speed); // 控制打字速度,例如50毫秒/字符
}
// 使用函数
typeWriter('typewriter', '日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。', 100); // 100毫秒/字符速度
效果与上面是一样的,解决了多行展示。但是熟悉JS定时器的小伙伴们都知道,定时器做动画有时候会出现卡顿。所以还得优化一下(把定时器替换成动画帧)。
3.优化后
js
function typeWriter(elementId, message, speed) {
const element = document.getElementById(elementId);
let i = 0;
let startTime = null;
function animate(currentTime) {
if (!startTime) startTime = currentTime;
if (currentTime - startTime >= i * speed) {
if (i < message.length) {
element.textContent += message.charAt(i);
i++;
requestAnimationFrame(animate);
}
} else {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
}
// 使用示例:
typeWriter('typewriter', '日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。', 100);
经过一系列优化,效果也达到了,但是用JS来做的话页面的dom频繁的在改变。如果对话比较多对性能感觉不那么友好。有什么办法能既不频繁的对dom进行修改又能多行展示呢?
经过一系列思考,还是回到第一步吧! 用css的动画~
但是第一步的弊端已经说过了,就是如果文字是多行就失效了。那此时我们就想到了一个办法,把文字截取变为2行呢?或者3行?然后每一行的动画执行完再执行下一行呢?
于是乎要写2个方法:
根据div的宽度计算一行能显示多少个文字
js
function calculateCharactersPerLine(divElement, fontSize, text) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = `${fontSize}px sans-serif`; // 设置字体大小和样式
const metrics = context.measureText(text);
const textWidth = metrics.width;
const divWidth = divElement.offsetWidth; // 获取div的宽度
// 计算一行可以容纳多少个字符
const charactersPerLine = Math.floor(divWidth / textWidth) * text.length;
if (charactersPerLine == 0) {
return calculateCharactersPerLine(divElement, fontSize, text.slice(0, -1))
}
return charactersPerLine;
}
calculateCharactersPerLine('id',16,'日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。')
根据calculateCharactersPerLine方法的返回值对整个文本进行切片
js
function splitIntoChunks(str, chunkSize) {
const regexPattern = new RegExp(`.{1,${chunkSize}}`, 'g');
return str.match(regexPattern);
}
整体思路就是假如div宽度是100px,那么可能整个文本会显示3行,那么切片出来就是3段文字,然后把这3段文字创建一个div包裹,每个div身上再定义动画就行了
直接用vue3指令封装一下吧~完整代码如下:
js
export function typewriter(app) {
app.directive('typewriter', (el, binding) => {
console.log(binding)
if (binding.oldValue) return;
renderText(el,binding)
});
}
function renderText(el, binding) {
const arg = binding.arg || 1
const style = document.createElement('style');
style.textContent = `
@keyframes width {
0% {
width: 0;
}
100% {
width: 100%;
}
}
`;
document.head.appendChild(style);
const textLen = calculateCharactersPerLine(el, 16, binding.value);
const divList = splitIntoChunks(binding.value, textLen)
divList.forEach((row, index) => {
const oDiv = document.createElement('div');
oDiv.innerText = row
oDiv.style.cssText = `
position: relative;
overflow: hidden;
width: 0;
white-space: nowrap;
animation: width ${arg}s steps(50) forwards;
animation-delay: ${index*arg}s
el.appendChild(oDiv)
})
}
//计算一行的文字字数
function calculateCharactersPerLine(divElement, fontSize, text) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = `${fontSize}px sans-serif`; // 设置字体大小和样式
const metrics = context.measureText(text);
const textWidth = metrics.width;
const divWidth = divElement.offsetWidth; // 获取div的宽度
// 计算一行可以容纳多少个字符
const charactersPerLine = Math.floor(divWidth / textWidth) * text.length;
if (charactersPerLine == 0) {
return calculateCharactersPerLine(divElement, fontSize, text.slice(0, -1))
}
return charactersPerLine;
}
//切片
function splitIntoChunks(str, chunkSize) {
const regexPattern = new RegExp(`.{1,${chunkSize}}`, 'g');
return str.match(regexPattern);
}
js
<template>
<div v-typewriter:[2]="`回环(文)诗、剥皮诗、离合诗、宝塔诗、字谜诗、辘轳诗、八音歌诗、藏头诗、打油诗、诙谐诗、集句诗、联句诗、百年诗、嵌字句首诗、绝弦体诗、神智体诗等40多种。这些杂体诗各有特点,虽然均有游戏色彩,但有些则具有一定的思想性和艺术性,所以深受人们的喜爱`"></div>
</template>
效果

总结:
好多人会误以为把简单的问题在复杂化。其实写代码就是各种思路的碰撞,如果单纯从实现功能的角度出发,用什么方法都行!怎么样兄弟们,如果你们项目中也有这种需求。你会怎么做?欢迎留言讨论~