苦尘 發表於 2025-11-27 11:05:00

浅谈 AI 搜索前端打字机效果的实现方案演进

<blockquote data-first-child="" data-pid="V9Lu_7ft">作者:vivo 互联网前端团队 - He Yanjun<br>在当代前端开发领域,打字机效果作为一种极具创造力与吸引力的交互元素,被广泛运用于各类网站和应用程序中,为用户带来独特的视觉体验和信息呈现方式,深受广大用户的喜爱。<br>本文将深入介绍在AI搜索输出响应的过程中,打字机效果是怎样逐步演进的。力求以通俗的语言和严谨的思路深入剖析打字机效果在不同阶段的关键技术难点和优劣势。</blockquote>
<p data-pid="Z0LnTaLX">1分钟看图掌握核心观点👇</p>
<div class="GifPlayer css-1isopsn" data-size="normal" data-za-detail-view-path-module="GifItem"><img alt="动图封面" width="640" class="ztext-gif lazyload" data-thumbnail="https://pica.zhimg.com/v2-71e2717ca5faa5e464284f60854c8cf1_720w.jpg?source=d16d100b" data-size="normal" data-src="https://pica.zhimg.com/v2-71e2717ca5faa5e464284f60854c8cf1_720w.jpg?source=d16d100b">
<div class="GifPlayer-icon css-d39tw7">&nbsp;</div>

</div>
<h2>一、前言</h2>
<p data-pid="qS6anT1_">在如今基于AI搜索的对话舞台上,如果一段文字像老式打字机一样逐字逐句展现在屏幕上,那将是一种具有独特魅力的吸引力。</p>
<p data-pid="KRfJMEpB">话不多说,先来看下最终的实现效果。</p>
<div class="GifPlayer css-1isopsn" data-size="normal" data-za-detail-view-path-module="GifItem"><img alt="动图封面" width="450" class="ztext-gif lazyload" data-thumbnail="https://picx.zhimg.com/v2-e5b2872868de30c88a997b04eff96bc8_720w.jpg?source=d16d100b" data-size="normal" data-src="https://picx.zhimg.com/v2-e5b2872868de30c88a997b04eff96bc8_720w.jpg?source=d16d100b">
<div class="GifPlayer-icon css-d39tw7">&nbsp;</div>

</div>
<h2>二、引言</h2>
<p data-pid="IJCdGYPU">在AI搜索场景中,由于大模型基于流式输出文本,需要多次响应结果到前端,因此这种场景十分适合使用打字机效果。</p>
<p data-pid="V7m3qLq8">打字机效果是指在生成内容的场景中,文字逐字符动态显示,模拟人工打字的过程,主要是出于提升用户体验、优化交互逻辑和增强心理感知等方面的考量:</p>
<p data-pid="rA-F-OPV">缓解等待焦虑,降低“无反馈”的负面体验。</p>
<p data-pid="vju-odaD">内容是逐步响应的,打字机效果可以很好地提供“实时反馈”,用户可以感知到系统正在工作,从而减少了等待过程中的不确定性和焦虑感。</p>
<p data-pid="i6QJGg4M">模拟自然交互,增强“类人对话”的沉浸感。</p>
<p data-pid="MP12ZOC7">对话交流具有停顿、强调等节奏感,通过实时打字的模拟,跟容易拉近与用户的心理距离,增强对话感和沉浸感。</p>
<p data-pid="Vr0nSsPY">优化信息接收效率,避免“信息过载”。</p>
<p data-pid="IJbaafDu">如果一次性展示大量密密麻麻的文字,用户需要花时间筛选重点,容易产生抵触,通过打字机效果可以缓和阅读节奏,减少视觉和认知负担。</p>
<p data-pid="1jg8Oydh">强化“AI生成”的感知,降低对“标准答案”的预期。</p>
<p data-pid="0ZprJxqH">使用户感知到是AI实时计算结果,而非预存的标准答案,有助于用户理性客观地使用工具。</p>
<h2>三、早期实现方案——纯文本逐字符打字效果</h2>
<p data-pid="fRbvVcvv">最开始的产品功能,需要根据用户输入的搜索词,流式输出并逐字符展示到页面上,这可以说是打字机效果的入门级实现了,不依赖任何复杂的技术,其流程图大致如下所示。</p>

<img width="611" height="721" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="611" data-rawheight="721" data-original="https://picx.zhimg.com/v2-2de52df821b036dfb586811fbf236d7c_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-2de52df821b036dfb586811fbf236d7c_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-2de52df821b036dfb586811fbf236d7c_720w.webp?source=d16d100b">
<h2>3.1 详细说明</h2>
<p data-pid="7oHD5OFj">前端会定义一个字段用来缓存全量的markdown文本,每次服务端流式响应markdown文本到前端时,前端都会将其追加到这个缓存字段后,然后基于marked依赖库将全量的markdown文本转换为html片段。</p>
<p data-pid="V5HhSZf8">要实现逐字符渲染的动画效果,就需要定时更新文本。定时功能一般采用setTimeout或setInterval来实现,而更新文本可以考虑innerHTML和appendChild的方式,这里采用的innerHTML方式插入文本,核心代码如下所示。</p>
<div class="highlight">
<pre><code class="language-text">let fullText = 'test text';// 全量的html文本
let index = 0;// 当前打印到的下标
let timer = window.setInterval(() =&gt; {
++index;
$dom.innerHTML = fullText.substring(0, index);
}, 40);
</code></pre>
</div>
<h2>3.2 innerHTML与appendChild的核心区别对比</h2>
<img width="581" height="222" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="581" data-rawheight="222" data-original="https://pica.zhimg.com/v2-be9426eaac4efc4aeb42ecd76e3558fa_r.jpg?source=d16d100b" data-actualsrc="https://pic1.zhimg.com/v2-be9426eaac4efc4aeb42ecd76e3558fa_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pic1.zhimg.com/80/v2-be9426eaac4efc4aeb42ecd76e3558fa_720w.webp?source=d16d100b">
<p data-pid="NJj_NkiR">为什么选择innerHTML而非appendChild?</p>
<p data-pid="8HK1fvRs">由于服务端是流式返回markdown文本,因此每次返回的markdown文本可能不是完整的。</p>
<p data-pid="Czggugxs">举个例子如下。</p>
<div class="highlight">
<pre><code class="language-text">先返回下面一段markdown文本

** 这是一个
再返回下面一段markdown文本

标题 **
先返回的文本会当作纯文本展示,再返回的文本会与先返回的文本结合生成html片段如下

&lt;strong&gt;这是一个标题&lt;/strong&gt;
</code></pre>
</div>
<p data-pid="fsfmVno_">如果使用appendChild的话,就不好处理上述场景。</p>
<h2>3.3 小结</h2>
<p data-pid="dHLgTSw2">这种方式的优点就是简单易懂,很容易上手实现,也没有任何依赖。</p>
<p data-pid="Ja6shrzI">但是,它的缺点也是显而易见的。比如,我们无法方便的添加一些额外的动画效果来增强视觉体验,如光标闪烁效果;对于一些复杂文本内容,或者需要更加灵活地控制展示细节时也会显得捉襟见肘;并且每次通过innerHTML渲染文本时,都触发了dom的销毁与创建,性能消耗大。</p>
<h2>四、需求难度进一步提升</h2>
<p data-pid="DhEuigsm">随着产品的迭代,业务要求打字内容不仅是纯文本,还需要穿插展示卡片等复杂样式效果,如下图所示。</p>
<p data-pid="HegDroBK">卡片的类型包括应用、股票、影视等,需要可扩展、可配置,并且还会包括复杂的交互效果,如点击、跳转等。</p>
<img width="355" height="772" class="content_image lazy lazyload" data-caption="" data-size="normal" data-rawwidth="355" data-rawheight="772" data-actualsrc="https://pica.zhimg.com/v2-a15bdf930405426cf07a6d799f81397f_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://pica.zhimg.com/80/v2-a15bdf930405426cf07a6d799f81397f_720w.webp?source=d16d100b">
<p data-pid="C8dEvzWu">很明显,基于早期的实现方案已经远远不能满足日益增强的业务诉求了,必须考虑更加灵活高效的技术方案。</p>
<h2>五、现代框架下的实现——基于Vue虚拟dom动态更新</h2>
<p data-pid="Pet_f6sZ">通过上述的分析,打字内容中要穿插展示卡片,显然需要使用单例模式,否则如果每次打字都重新创建元素的话,不仅性能低下,而且数据和状态还无法保持一致。</p>
<p data-pid="P2SPM6pn">而要使用单例模式,就必须根据现有数据对已插入节点进行插入、更新、移除等操作以保持数据的一致性,这就很自然地会想到使用现代前端框架来对打字机效果进行改进。</p>
<p data-pid="8AWqs3Zb">Vue是基于虚拟dom的渐进式javascript框架,仅在数据变化时计算差异并更新必要的部分,因此可以借助其数据驱动开发、组件化开发等特性,轻松地构建一个可复用的打字机效果组件。</p>
<h2>5.1 设计思路</h2>
<p data-pid="_9VnB61v">要实现打字正文中穿插卡片的效果,首先需要定义好返回的数据结构,它需要具备可扩展,方便解析,兼容markdown等特性,所以使用html标签是一种比较合适的方式,例如要展示一个应用卡片,可以下发如下所示数据。</p>
<div class="highlight">
<pre><code class="language-text">&lt;app id="" /&gt;
</code></pre>
</div>
<p data-pid="MEr1pvwn">从下发的数据中可以获取到标签名和属性键值对,这样就可以通过标签名来渲染关联到的组件模板,通过属性键值对去服务端加载对应的数据,于是就可以水到渠成的把应用卡片展示出来,其流程图如下图所示。</p>
<img width="861" height="581" class="origin_image zh-lightbox-thumb lazy lazyload" data-caption="" data-size="normal" data-rawwidth="861" data-rawheight="581" data-original="https://pica.zhimg.com/v2-79577c3f6860b49dc6988f78c2519bf6_r.jpg?source=d16d100b" data-actualsrc="https://picx.zhimg.com/v2-79577c3f6860b49dc6988f78c2519bf6_720w.jpg?source=d16d100b" data-lazy-status="ok" data-src="https://picx.zhimg.com/80/v2-79577c3f6860b49dc6988f78c2519bf6_720w.webp?source=d16d100b">
<h2>5.2 详细说明</h2>
<p data-pid="gaHU3dsf">组件模板文件按照一定规则组织在特定的目录下,在构建时打包到资源里,关键代码如下所示。</p>
<div class="highlight">
<pre><code class="language-text">privateinit(){
    let fileList = require.context('@/components/common/box', true, /\.vue$/);
    fileList.keys().forEach((filePath) =&gt; {
      let startIndex = filePath.lastIndexOf('/');
      let endIndex = filePath.lastIndexOf('.');
      let tagName = filePath.substring(startIndex + 1, endIndex);
      this.widgetMap = fileList(filePath).default;
    });
}
</code></pre>
</div>
<p data-pid="tBALRWZ5">之前版本在每次接收到服务端下发的markdown文本时,都会做一次转换成html的操作,如果多次响应之间的间隔时间很短,则会出现略微卡顿的现象,因此这里转换为html时再增加一个防抖功能,可以很有效的避免卡顿。</p>
<p data-pid="rKKxEM65">每次定时截取到待渲染的html文本以后,会基于ultrahtml库将其转换为dom树,并过滤掉注释、脚本等标签,核心代码如下。</p>
<div class="highlight">
<pre><code class="language-text">let toRenderHtml = this.rawHtml.substring(0, this.curIndex);
let dom = {
    type: ELEMENT_NODE,
    name: 'p',
    children: parse(toRenderHtml).children
};
</code></pre>
</div>
<p data-pid="51ePtTPj">最后就是全局注册一个递归组件用来渲染转换后的dom树,核心代码如下。</p>
<div class="highlight">
<pre><code class="language-text">render(h: any) {
    // 此处省略若干代码

    // 处理子节点
    let children = this.dom['children'] || [];
    let renderChildren = children.map((child: any, index: number) =&gt; {
      return h(CommonDisplay, {
            props: {
                dom: child,
                displayCursor: this.displayCursor,
                lastLine: this.lastLine &amp;&amp; index === children.length - 1,
                ignoreBoxTag: this.ignoreBoxTag
            }
      });
    });

    // 此处省略若干代码

    // 处理文本节点
    if (this.dom['type'] === TEXT_NODE) {
      returnthis.renderTextNode({h, element: this.dom});
    }

    // 处理自定义组件标签
    let tagName = this.dom['type'] === ELEMENT_NODE ? this.dom['name'] : 'div';
    if (this.$factory.hasTag(tagName)) {
      // 此处省略若干代码
      let widget = this.$factory.getWidget(tagName);
      return h(widget, {
            key: tagId,
            props: {
                displayCursor: this.displayCursor,
                lastLine: this.lastLine,
                text,
                ...attributes
            }
      }, isLastLeaf &amp;&amp; this.displayCursor ? : []);
    }

    // 处理html原始标签
    return h(tagName, {
      attrs: {
            displayCursor: this.displayCursor,
            lastLine: this.lastLine,
            ...this.dom['attributes']
      }
    }, renderChildren);
}
</code></pre>
</div>
<h2>5.3 问题整理和解决</h2>
<p data-pid="3MDzQ1fl">打字机功能终于正常运行了,流畅度还是不错的,但是在体验的过程中,也发现了一些细节问题。</p>
<p data-pid="IGzegkOg">①打字文本中如果存在标签,如 &lt;p&gt;xxx&lt;/p&gt; ,会出现先展示 &lt; ,再展示 &lt;p ,最后展示空的效果,也就是字符回退,极大影响阅读体验。</p>
<p data-pid="yyLsKCsz">原因分析</p>
<p data-pid="cOzjFUpO">定时截取待渲染文本时是通过定义一个下标递增逐字符截取的,这就导致标签并没有作为一个原子结构被整体截取,于是就出现了字符回退的现象。</p>
<p data-pid="NwmfYu6l">解决方案</p>
<p data-pid="zCVeXhBD">当下标指向的字符为 &lt; 时,则往后截取到 &gt; 的位置,核心代码如下。</p>
<div class="highlight">
<pre><code class="language-text">if (curChar === '&lt;') {
    let lastGtIndex = this.rawHtml.indexOf('&gt;', this.curIndex);
    if (lastGtIndex &gt; -1) {
      this.curIndex = lastGtIndex + 1;
      returnfalse;
    }
}
</code></pre>
</div>
<p data-pid="v0mgU2Y7">② 打字文本中如果存在转义字符,如 &amp;quot; ,则会依次出现这些字符,最后再展示 " ,也就是字符闪烁,也十分影响阅读体验。</p>
<p data-pid="n5mym9hS">原因分析</p>
<p data-pid="oTWVPWG4">原因同上述字符回退一样,也是没有把转义字符当作一个整体截取。</p>
<p data-pid="HfV3pk5R">解决方案</p>
<p data-pid="l6nwSt29">当下标指向的字符为 &amp; 时,则往后截取到转义字符结束的位置,核心代码如下。</p>
<div class="highlight">
<pre><code class="language-text">// 大模型大概率只下发有限类别的转义字符,做成配置动态下发,不仅解析方便,定制下发也很及时
if (curChar === '&amp;') {
    let matchEscape = this.config['writer']['escapeArr'].find((item: any) =&gt; {
      returnthis.rawHtml.indexOf(item, this.curIndex) === this.curIndex;
    });
    if (matchEscape) {
      this.curIndex += matchEscape.length;
      returnfalse;
    }
}
</code></pre>
</div>
<p data-pid="Z_VKLkox">③ 打字过程中的速度是固定的,缺少一点抑扬顿挫的节奏感,不够自然。</p>
<p data-pid="p2soTUvp">原因分析</p>
<p data-pid="yUJKxKtl">定时器的间隔时间是固定的一个数值,所以表现为一个固定不变的打字节奏。</p>
<p data-pid="PtVCetBb">解决方案</p>
<p data-pid="zyGSyg1o">可以根据未打印字符数来动态调整每次打字的速度,一种可选的实现方案如下。</p>
<p data-pid="rU993waS">假设未打印字符数为 N ,速度平滑指数为 a ,实际打字速度为 Vcurrent ,逻辑应达到的打字速度为 Vnew 。</p>
<p data-pid="X1JYpmtx">if N &lt;= 10 , Vnew = 100 ms / 1字符</p>
<p data-pid="RQxEKbc3">if 10 &lt; N &lt;= 20 , Vnew = 100 - 8 * ( N - 10 ) ms / 1字符</p>
<p data-pid="QqG8A-eM">if 20 &lt; N , Vnew = 20 ms / 4字符</p>
<p data-pid="iKhpCp5w">Vcurrent = a * Vcurrent + ( 1 - a ) * Vnew</p>
<p data-pid="O_0HBDKW">上述策略可能会比较多,而且上线以后还有可能更换数值对照效果,因此为了支持配置化,我们可以对Vnew进行表达式归纳,如下所示。</p>
<p data-pid="cccsl2Rq">Vnew = Vinit - w * ( N - min ) + b</p>
<p data-pid="fjE5DN9q">Vinit 为默认初始打字速度,w 为每条策略的权重值,N 为未打印字符数,min 为每条策略的最小字符数量比较值,b 为每条策略的偏置。关键代码如下所示。</p>
<div class="highlight">
<pre><code class="language-text">privatespeedFn({curSpeed, curIndex, totalLength}: any){
    let leftCharLength = Math.max(0, totalLength - curIndex);
    let matchStrategy = this.config['writer']['strategy'].find((item: any) =&gt; {
      return (!item['min'] || item['min'] &lt; leftCharLength)
            &amp;&amp; (!item['max'] || item['max'] &gt;= leftCharLength);
    });
    let speed = this.config['writer']['initSpeed'] - matchStrategy['w'] * (leftCharLength - (matchStrategy['min'] || 0)) + matchStrategy['b'];
    returnthis.config['writer']['smoothParam'] * curSpeed + (1 - this.config['writer']['smoothParam']) * speed;
}
</code></pre>
</div>
<p data-pid="eAhHxuAB">④ 打字过程中,会时不时的回退到之前字符的位置重新开始打字,例如当前展示 a = b + c ,等到下一次渲染时会从 a 开始重新打完这一段。</p>
<p data-pid="rUmDE7vB">原因分析</p>
<p data-pid="e9Y-zJJj">由于markdown文本结合会生成html标签,从而导致字符数量增多,那么当前下标指向的字符就相对之前落后了。</p>
<div class="highlight">
<pre><code class="language-text">let curIndex = 5;// 当前下标
let prevMarkdown = '**hello';// 上一次打印时的全量markdown文本
let prevHtml = '&lt;p&gt;**hello&lt;/p&gt;';// 上一次打印时的全量html片段
let prevRenderHtml = '&lt;p&gt;**&lt;p&gt;';// 上一次打印到页面上的html片段
// 页面上会渲染 **

// 当服务端继续下发了 ** 的markdown文本时,curIndex会递增1变为6
let curMarkdown = '**hello**';// 当前打印时的全量markdown文本
let curHtml = '&lt;p&gt;&lt;strong&gt;hello&lt;/strong&gt;&lt;/p&gt;';// 当前打印时的全量html片段
let curRenderHtml = '&lt;p&gt;&lt;strong&gt;&lt;/strong&gt;&lt;p&gt;';// 当前打印到页面上的html片段
// 页面上会渲染空标签,然后重新开始打字,尤其是在数学公式场景中非常容易复现
</code></pre>
</div>
<p data-pid="kfHVeXH-">解决方案</p>
<p data-pid="Q-ZooafS">解决这个问题,需要分两步走。</p>
<p data-pid="xnjCONnQ">首先需要判断打印到页面上的html片段是否有变化,因为只有变化时才会出现这种情况,而判断是否有变化只需要记录一下上一次的html片段和这一次的html片段是否不同即可,比较好处理。</p>
<p data-pid="wTdr8fU4">其次就是需要重新定位下标到上一次打印到的位置,这里相对比较难处理,因为html的结构和内容都在变化,很难准确的定位到下标应该移动到什么位置。虽然我们不能准确定位,但是只要能够使当前打印到页面上的字符比上一次的字符多,就可以满足诉求了。于是我想到了textContent这个属性,它可以获取当前节点及其后代的所有文本内容。那么问题就转化为:找到一个下标,使得当前截取的html片段的textContent长度要比上一次的textContent长度大。</p>
<p data-pid="hRjuBnI3">综上所述,可以得到核心代码如下所示。</p>
<div class="highlight">
<pre><code class="language-text">if (this.isHtmlChanged()) {
    let domRange: any = document.createRange();
    let prevFrag = domRange.createContextualFragment(this.prevRenderHtml);
    let prevTextContent = prevFrag.textContent;
    let diffNum = 1;
    do {
      this.curIndex += diffNum;
      let curHtml = this.rawHtml.substring(0, this.curIndex);
      let curFrag = domRange.createContextualFragment(curHtml);
      let curTextContent = curFrag.textContent;
      diffNum = prevTextContent.length - curTextContent.length;
      if (diffNum &lt;= 0) {
            break;
      }
    } while (this.curIndex &lt; this.rawHtml.length);
}
</code></pre>
</div>
<h2>5.4 小结</h2>
<p data-pid="HisTeafd">通过现代前端框架构建打字机组件,不仅减少了不必要的渲染和性能消耗,而且还能高效灵活的穿插各种酷炫的样式效果,实现更多复杂的产品功能。</p>
<h2>六、未来展望</h2>
<p data-pid="p_dO4YJS">本文详细介绍了AI搜索中前端打字机效果的实现方案演进过程,从最初的纯文本逐字符打字效果,到借助现代前端框架实现灵活可复用的打字机组件,每一个技术难点的技术突破无不体现了前端技术的持续进步和产品不断追求卓越的态度。同时我也希望本文可以抛砖引玉,为读者打开思路,提供借鉴。</p>
<p data-pid="17uvxDsF">随着人工智能和前端技术的不断发展和创新生态的日益完善,未来一定会不断涌现大量的新技术和新理念。我相信只要时刻保持积极学习和不断尝试的探索精神,就能开拓出更多精彩创新的实现方案和应用场景。</p>

</div>
<div id="MySignature" role="contentinfo">
    分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。<br><br>
来源:https://www.cnblogs.com/vivotech/p/19276415
頁: [1]
查看完整版本: 浅谈 AI 搜索前端打字机效果的实现方案演进