为了实现AI对话的打字效果,我封装一个vue3自定义指令

前言

随着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>
效果

总结:

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

相关推荐
你的人类朋友17 分钟前
🤔Token 存储方案有哪些
前端·javascript·后端
烛阴18 分钟前
从零开始:使用Node.js和Cheerio进行轻量级网页数据提取
前端·javascript·后端
liuyang___34 分钟前
日期的数据格式转换
前端·后端·学习·node.js·node
潘小磊1 小时前
高频面试之5Kafka
面试·职场和发展
Frankabcdefgh1 小时前
Python基础数据类型与运算符全面解析
开发语言·数据结构·python·面试
贩卖纯净水.2 小时前
webpack其余配置
前端·webpack·node.js
码上奶茶2 小时前
HTML 列表、表格、表单
前端·html·表格·标签·列表·文本·表单
抹茶san2 小时前
和 Trae 一起开发可视化拖拽编辑项目(1) :迈出第一步
前端·trae
蒟蒻小袁2 小时前
力扣面试150题--除法求值
算法·leetcode·面试