Bootstrap

AI 问答逐字加载特效

背景

随着人工智能技术的发展,AI 问答系统在各个领域得到了广泛应用。为了提升用户体验,增加互动性和趣味性,很多问答系统引入了逐字加载特效。这种特效模拟了人类打字的过程,使得回答内容的展示更加生动自然。

实现原理

逐字加载特效的核心在于逐步将内容添加到页面上,而不是一次性显示所有内容。这可以通过 JavaScriptsetInterval 函数来实现。具体来说,我们可以在每次定时器触发时,将内容的一部分添加到页面上,直到全部内容加载完毕。

代码示例

1. 创建模拟接口

首先,我们需要创建一个模拟接口,返回需要逐字加载的内容。这里我们使用一个简单的 Promise 来模拟异步请求:

let data = `<h2>《钢铁是怎样炼成的》简介</h2><p>《钢铁是怎样炼成的》是前苏联作家尼古拉·奥斯特洛夫斯基创作的一部长篇小说,首次出版于1934年。这部小说以主人公保尔·柯察金的成长经历为主线,展现了他在革命斗争中的成长历程,以及他如何在艰苦的环境中锻炼成一名坚强的共产主义战士。</p><h2>主要内容</h2><ol><li>少年时期<ul><li>保尔·柯察金出生在一个贫穷的工人家庭,从小经历了许多苦难。他在学校受到不公正的待遇,后来辍学成为一名铁路工人。</li><li>他接触到了革命思想,逐渐认识到社会的不公和阶级压迫。</li></ul></li><li>革命斗争<ul><li>保尔加入了布尔什维克党,积极参与革命活动。他在战斗中表现英勇,多次负伤,但始终坚定地站在革命的最前线。</li><li>他在战争中结识了许多志同道合的战友,共同为推翻旧制度、建立新社会而奋斗。</li></ul></li><li>建设时期<ul><li>革命胜利后,保尔投身于国家的建设事业。他在铁路建设中发挥了重要作用,克服了种种困难,完成了艰巨的任务。</li><li>他在工作中表现出色,被提拔为共青团的重要干部。</li></ul></li><li>伤病与康复<ul><li>由于长期的劳累和战斗,保尔的身体逐渐恶化,最终因病重而无法继续工作。他多次住院治疗,但始终保持着乐观和坚强的精神。</li><li>在病床上,保尔开始写作,最终完成了自传体小说《钢铁是怎样炼成的》。</li></ul></li></ol><h2>主题思想</h2><ul><li>坚韧不拔的精神:保尔·柯察金在面对各种困难和挑战时,始终保持着坚定的信念和顽强的意志。他的故事激励了一代又一代的人,成为无数人心中的英雄。</li><li>革命理想:小说通过保尔的成长历程,展现了革命者的崇高理想和无私奉献精神。保尔为了实现共产主义理想,不惜牺牲个人的一切。</li><li>集体主义:保尔的成功离不开集体的支持和帮助。小说强调了集体的力量和个人奋斗的结合,展示了团结协作的重要性。</li></ul><h2>影响</h2><p>《钢铁是怎样炼成的》不仅在苏联广为流传,也被翻译成多种语言,在世界范围内产生了广泛的影响。它被誉为一部激励人们克服困难、追求理想的经典之作,对许多读者产生了深远的启发和鼓舞。</p><h2>结语</h2><p>《钢铁是怎样炼成的》是一部充满激情和力量的小说,通过保尔·柯察金的故事,展现了一个人在逆境中成长的历程。这部作品不仅是文学上的瑰宝,更是精神上的灯塔,激励着每一个读过它的人在生活的道路上勇往直前。</p>`

export function getAnswer() {
  return new Promise((resolve, reject) => {
    let res = {
      msg: "success",
      code: 0,
      data
    }
    resolve(res);
  }).catch(error => {
    reject(error);
  })
}

2. 创建 Vue 组件

创建一个 Vue 组件来展示问答界面,并实现逐字加载特效。

<template>
  <div>
    <el-dialog width="68%" :visible.sync="isShow" :before-close="close" class="kefu" :show-close="false"
      :close-on-click-modal="false">
      <div class="kefu-con">
        <i class="el-icon-close close" @click="close" />
        <div class="header">
          <img :src="kefuImg" alt="">
          <div class="title">小奶龙智能问答助手</div>
        </div>
        <div class="container" ref="container">
          <div class="content" ref="content">
            <div v-for="item in messageForm">
              <div class="reply-container" v-if="item.type === 'reply'">
                <div class="reply-content">
                  <div class="img-con">
                    <img :src="kefuImg" alt="">
                  </div>
                  <div class="reply" @click="handleProxyClick">
                    <div v-if="item.isXml" v-html="item.content"></div>
                    <p v-else>
                      {{ item.content }}
                    </p>
                  </div>
                </div>
              </div>
              <div class="qs-container" v-if="item.type === 'qs'">
                <div class="qs-content">
                  <div class="qs">
                    <p>
                      {{ item.content }}
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="footer">
          <img :src="kefuImg" alt="">
          <div class="question-con">
            <el-input class="ipt" v-model="question" placeholder="请输入想咨询的问题"></el-input>
            <el-button class="btn" type="warning" round @click="send">发送</el-button>
          </div>

        </div>
      </div>
    </el-dialog>
  </div>
</template>

<script>
import { getAnswer } from '@/api/checkin.js';
export default {
  data() {
    return {
      isShow: false,
      kefuImg: require("@/assets/images/headshot.png"),
      question: '',
      messageForm: [{
        content: "<p>您好, 我是您的小奶龙,你的智能助手。 你可以问我编码相关的问题,也可以一起更高效、更高质量地完成编码工作。比如 “<span class='quick' style='color: #1b6ef3;cursor: pointer;' @click='quick'>动态路由实现</span>”, “<span class='quick' style='color: #1b6ef3;cursor: pointer;' @click='quick'>移动端适配</span>” 等等一些问题。</p>",
        type: "reply", // reply 回答  qs 问题
        isXml: true,
      },
      {
        content: '动态路由实现',
        type: "qs", // reply 回答  qs 问题
        isXml: false,
      }],
    }
  },
  methods: {
    handleProxyClick(event) {
      // 获取触发事件的目标元素  event 事件对象
      const target = event.target;
      // 判断目标元素是否包含指定类名
      if (target.classList.contains('quick')) {
        // 传递目标元素的文本内容
        this.quick(target.outerText);
      }
    },
    quick(text) {
      console.log('quick方法触发了::: ');
      this.question = text;
      // 发送
      this.send();
    },
    show() {
      this.isShow = true;
    },
    close() {
      this.isShow = false;
    },
    send() {
      let NewQuestion = this.question.trim();
      if (NewQuestion === '') {
        return;
      }
      this.messageForm.push({
        content: NewQuestion,
        type: 'qs',
        isXml: false,
      });
      setTimeout(async () => {
        let response = await getAnswer(NewQuestion);
        this.messageForm.push({
          content: "",
          type: 'reply',
          isXml: true,
        });
        // 逐字加载效果
        let content = response.data;
        let index = 0;
        let tempContent = ''; // 用于存储当前加载的部分内容

        const interval = setInterval(() => {
          if (index < content.length) {
            tempContent += content[index];
            index++;

            // 检查是否可以安全地插入到 DOM 中
            try {
              const parser = new DOMParser();
              const doc = parser.parseFromString(tempContent, 'text/html');
              console.log('tempContent::: ', tempContent);

              if (!doc.getElementsByTagName('parsererror').length) {
                this.messageForm[this.messageForm.length - 1] = {
                  content: tempContent,
                  type: 'reply',
                  isXml: true,
                };
                this.$forceUpdate();
              }
            } catch (error) {
              console.error('Error parsing XML:', error);
            }
          } else {
            clearInterval(interval);
            this.question = '';
            this.$nextTick(() => {
              this.scrollToBottom();
            });
          }
        }, 100); // 调整间隔时间以控制加载速度
      })
    },
    scrollToBottom() {
      const content = this.$refs.content;
      this.$refs.container.scrollTo(0, content.scrollHeight);
    }
  },
}
</script>

效果展示
在这里插入图片描述

总结

当用户点击“发送”按钮时,将用户的输入添加到消息列表中,并调用API获取需要渲染的数据。在loadReply 方法中,使用 setInterval 来逐字加载内容。每次加载一个字符后,使用 DOMParser 来解析当前的内容片段,确保其不会导致DOM错误。如果解析成功,将内容更新到消息列表中,并强制 Vue 重新渲染。为了确保用户始终能看到最新的消息,在加载完成后调用 scrollToBottom 方法,将滚动条滚动到底部。

;