博客 / 詳情

返回

淺談 AI 搜索前端打字機效果的實現方案演進

作者:vivo 互聯網前端團隊 - He Yanjun

在當代前端開發領域,打字機效果作為一種極具創造力與吸引力的交互元素,被廣泛運用於各類網站和應用程序中,為用户帶來獨特的視覺體驗和信息呈現方式,深受廣大用户的喜愛。

本文將深入介紹在AI搜索輸出響應的過程中,打字機效果是怎樣逐步演進的。力求以通俗的語言和嚴謹的思路深入剖析打字機效果在不同階段的關鍵技術難點和優劣勢。

1分鐘看圖掌握核心觀點👇

圖片

一、前言

在如今基於AI搜索的對話舞台上,如果一段文字像老式打字機一樣逐字逐句展現在屏幕上,那將是一種具有獨特魅力的吸引力。

話不多説,先來看下最終的實現效果。

圖片

二、引言

在AI搜索場景中,由於大模型基於流式輸出文本,需要多次響應結果到前端,因此這種場景十分適合使用打字機效果。

打字機效果是指在生成內容的場景中,文字逐字符動態顯示,模擬人工打字的過程,主要是出於提升用户體驗、優化交互邏輯和增強心理感知等方面的考量:

緩解等待焦慮,降低“無反饋”的負面體驗。

內容是逐步響應的,打字機效果可以很好地提供“實時反饋”,用户可以感知到系統正在工作,從而減少了等待過程中的不確定性和焦慮感。

模擬自然交互,增強“類人對話”的沉浸感。

對話交流具有停頓、強調等節奏感,通過實時打字的模擬,跟容易拉近與用户的心理距離,增強對話感和沉浸感。

優化信息接收效率,避免“信息過載”。

如果一次性展示大量密密麻麻的文字,用户需要花時間篩選重點,容易產生牴觸,通過打字機效果可以緩和閲讀節奏,減少視覺和認知負擔。

強化“AI生成”的感知,降低對“標準答案”的預期。

使用户感知到是AI實時計算結果,而非預存的標準答案,有助於用户理性客觀地使用工具。

三、早期實現方案——純文本逐字符打字效果

最開始的產品功能,需要根據用户輸入的搜索詞,流式輸出並逐字符展示到頁面上,這可以説是打字機效果的入門級實現了,不依賴任何複雜的技術,其流程圖大致如下所示。

圖片

3.1 詳細説明

前端會定義一個字段用來緩存全量的markdown文本,每次服務端流式響應markdown文本到前端時,前端都會將其追加到這個緩存字段後,然後基於marked依賴庫將全量的markdown文本轉換為html片段。

要實現逐字符渲染的動畫效果,就需要定時更新文本。定時功能一般採用setTimeout或setInterval來實現,而更新文本可以考慮innerHTML和appendChild的方式,這裏採用的innerHTML方式插入文本,核心代碼如下所示。

let fullText = 'test text';// 全量的html文本
let index = 0;// 當前打印到的下標
let timer = window.setInterval(() => {
  ++index;
  $dom.innerHTML = fullText.substring(0, index);
}, 40);

3.2 innerHTML與appendChild的核心區別對比

圖片

為什麼選擇innerHTML而非appendChild?

由於服務端是流式返回markdown文本,因此每次返回的markdown文本可能不是完整的。

舉個例子如下。

先返回下面一段markdown文本

** 這是一個
再返回下面一段markdown文本

標題 **
先返回的文本會當作純文本展示,再返回的文本會與先返回的文本結合生成html片段如下

<strong>這是一個標題</strong>

如果使用appendChild的話,就不好處理上述場景。

3.3 小結

這種方式的優點就是簡單易懂,很容易上手實現,也沒有任何依賴。

但是,它的缺點也是顯而易見的。比如,我們無法方便的添加一些額外的動畫效果來增強視覺體驗,如光標閃爍效果;對於一些複雜文本內容,或者需要更加靈活地控制展示細節時也會顯得捉襟見肘;並且每次通過innerHTML渲染文本時,都觸發了dom的銷燬與創建,性能消耗大。

四、需求難度進一步提升

隨着產品的迭代,業務要求打字內容不僅是純文本,還需要穿插展示卡片等複雜樣式效果,如下圖所示。

卡片的類型包括應用、股票、影視等,需要可擴展、可配置,並且還會包括複雜的交互效果,如點擊、跳轉等。

圖片

很明顯,基於早期的實現方案已經遠遠不能滿足日益增強的業務訴求了,必須考慮更加靈活高效的技術方案。

五、現代框架下的實現——基於Vue虛擬dom動態更新

通過上述的分析,打字內容中要穿插展示卡片,顯然需要使用單例模式,否則如果每次打字都重新創建元素的話,不僅性能低下,而且數據和狀態還無法保持一致。

而要使用單例模式,就必須根據現有數據對已插入節點進行插入、更新、移除等操作以保持數據的一致性,這就很自然地會想到使用現代前端框架來對打字機效果進行改進。

Vue是基於虛擬dom的漸進式javascript框架,僅在數據變化時計算差異並更新必要的部分,因此可以藉助其數據驅動開發、組件化開發等特性,輕鬆地構建一個可複用的打字機效果組件。

5.1 設計思路

要實現打字正文中穿插卡片的效果,首先需要定義好返回的數據結構,它需要具備可擴展,方便解析,兼容markdown等特性,所以使用html標籤是一種比較合適的方式,例如要展示一個應用卡片,可以下發如下所示數據。

<app id="" />

從下發的數據中可以獲取到標籤名和屬性鍵值對,這樣就可以通過標籤名來渲染關聯到的組件模板,通過屬性鍵值對去服務端加載對應的數據,於是就可以水到渠成的把應用卡片展示出來,其流程圖如下圖所示。

圖片

5.2 詳細説明

組件模板文件按照一定規則組織在特定的目錄下,在構建時打包到資源裏,關鍵代碼如下所示。

privateinit(){  
    let fileList = require.context('@/components/common/box', true, /\.vue$/);  
    fileList.keys().forEach((filePath) => {  
        let startIndex = filePath.lastIndexOf('/');  
        let endIndex = filePath.lastIndexOf('.');  
        let tagName = filePath.substring(startIndex + 1, endIndex);  
        this.widgetMap[tagName] = fileList(filePath).default;  
    });  
}

之前版本在每次接收到服務端下發的markdown文本時,都會做一次轉換成html的操作,如果多次響應之間的間隔時間很短,則會出現略微卡頓的現象,因此這裏轉換為html時再增加一個防抖功能,可以很有效的避免卡頓。

每次定時截取到待渲染的html文本以後,會基於ultrahtml庫將其轉換為dom樹,並過濾掉註釋、腳本等標籤,核心代碼如下。

let toRenderHtml = this.rawHtml.substring(0, this.curIndex);  
let dom = {  
    type: ELEMENT_NODE,  
    name: 'p',  
    children: parse(toRenderHtml).children  
};

最後就是全局註冊一個遞歸組件用來渲染轉換後的dom樹,核心代碼如下。

render(h: any) {  
    // 此處省略若干代碼

    // 處理子節點
    let children = this.dom['children'] || [];  
    let renderChildren = children.map((child: any, index: number) => { 
        return h(CommonDisplay, {  
            props: {  
                dom: child,  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine && 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 && this.displayCursor ? [h(commonCursor)] : []);
    }

    // 處理html原始標籤
    return h(tagName, {  
        attrs: {  
            displayCursor: this.displayCursor,  
            lastLine: this.lastLine,  
            ...this.dom['attributes']  
        }  
    }, renderChildren);  
}

5.3 問題整理和解決

打字機功能終於正常運行了,流暢度還是不錯的,但是在體驗的過程中,也發現了一些細節問題

①打字文本中如果存在標籤,如 <p>xxx</p> ,會出現先展示 < ,再展示 <p ,最後展示空的效果,也就是字符回退,極大影響閲讀體驗。

原因分析

定時截取待渲染文本時是通過定義一個下標遞增逐字符截取的,這就導致標籤並沒有作為一個原子結構被整體截取,於是就出現了字符回退的現象。

解決方案

當下標指向的字符為 < 時,則往後截取到 \> 的位置,核心代碼如下。

if (curChar === '<') {  
    let lastGtIndex = this.rawHtml.indexOf('>', this.curIndex);
    if (lastGtIndex > -1) {
        this.curIndex = lastGtIndex + 1;
        returnfalse;
    }
}

② 打字文本中如果存在轉義字符,如 " ,則會依次出現這些字符,最後再展示 " ,也就是字符閃爍,也十分影響閲讀體驗。

原因分析

原因同上述字符回退一樣,也是沒有把轉義字符當作一個整體截取。

解決方案

當下標指向的字符為 & 時,則往後截取到轉義字符結束的位置,核心代碼如下。

// 大模型大概率只下發有限類別的轉義字符,做成配置動態下發,不僅解析方便,定製下發也很及時  
if (curChar === '&') {  
    let matchEscape = this.config['writer']['escapeArr'].find((item: any) => {  
        returnthis.rawHtml.indexOf(item, this.curIndex) === this.curIndex;  
    });  
    if (matchEscape) {  
        this.curIndex += matchEscape.length;  
        returnfalse;  
    }  
}

③ 打字過程中的速度是固定的,缺少一點抑揚頓挫的節奏感,不夠自然。

原因分析

定時器的間隔時間是固定的一個數值,所以表現為一個固定不變的打字節奏。

解決方案

可以根據未打印字符數來動態調整每次打字的速度,一種可選的實現方案如下。

假設未打印字符數為 N ,速度平滑指數為 a ,實際打字速度為 Vcurrent ,邏輯應達到的打字速度為 Vnew 。

if N <= 10 , Vnew = 100 ms / 1字符

if 10 < N <= 20 , Vnew = 100 - 8 * ( N - 10 ) ms / 1字符

if 20 < N , Vnew = 20 ms / 4字符

Vcurrent = a Vcurrent + ( 1 - a ) Vnew

上述策略可能會比較多,而且上線以後還有可能更換數值對照效果,因此為了支持配置化,我們可以對Vnew進行表達式歸納,如下所示。

Vnew = Vinit - w * ( N - min ) + b

Vinit 為默認初始打字速度,w 為每條策略的權重值,N 為未打印字符數,min 為每條策略的最小字符數量比較值,b 為每條策略的偏置。關鍵代碼如下所示。

privatespeedFn({curSpeed, curIndex, totalLength}: any){  
    let leftCharLength = Math.max(0, totalLength - curIndex);  
    let matchStrategy = this.config['writer']['strategy'].find((item: any) => {  
        return (!item['min'] || item['min'] < leftCharLength)  
            && (!item['max'] || item['max'] >= 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;  
}

④ 打字過程中,會時不時的回退到之前字符的位置重新開始打字,例如當前展示 a = b + c ,等到下一次渲染時會從 a 開始重新打完這一段。

原因分析

由於markdown文本結合會生成html標籤,從而導致字符數量增多,那麼當前下標指向的字符就相對之前落後了。

let curIndex = 5;// 當前下標
let prevMarkdown = '**hello';// 上一次打印時的全量markdown文本
let prevHtml = '<p>**hello</p>';// 上一次打印時的全量html片段
let prevRenderHtml = '<p>**<p>';// 上一次打印到頁面上的html片段
// 頁面上會渲染 **

// 當服務端繼續下發了 ** 的markdown文本時,curIndex會遞增1變為6
let curMarkdown = '**hello**';// 當前打印時的全量markdown文本
let curHtml = '<p><strong>hello</strong></p>';// 當前打印時的全量html片段
let curRenderHtml = '<p><strong></strong><p>';// 當前打印到頁面上的html片段
// 頁面上會渲染空標籤,然後重新開始打字,尤其是在數學公式場景中非常容易復現

解決方案

解決這個問題,需要分兩步走。

首先需要判斷打印到頁面上的html片段是否有變化,因為只有變化時才會出現這種情況,而判斷是否有變化只需要記錄一下上一次的html片段和這一次的html片段是否不同即可,比較好處理。

其次就是需要重新定位下標到上一次打印到的位置,這裏相對比較難處理,因為html的結構和內容都在變化,很難準確的定位到下標應該移動到什麼位置。雖然我們不能準確定位,但是隻要能夠使當前打印到頁面上的字符比上一次的字符多,就可以滿足訴求了。於是我想到了textContent這個屬性,它可以獲取當前節點及其後代的所有文本內容。那麼問題就轉化為:找到一個下標,使得當前截取的html片段的textContent長度要比上一次的textContent長度大。

綜上所述,可以得到核心代碼如下所示。

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 <= 0) {  
            break;  
        }  
    } while (this.curIndex < this.rawHtml.length);  
}

5.4 小結

通過現代前端框架構建打字機組件,不僅減少了不必要的渲染和性能消耗,而且還能高效靈活的穿插各種酷炫的樣式效果,實現更多複雜的產品功能。

六、未來展望

本文詳細介紹了AI搜索中前端打字機效果的實現方案演進過程,從最初的純文本逐字符打字效果,到藉助現代前端框架實現靈活可複用的打字機組件,每一個技術難點的技術突破無不體現了前端技術的持續進步和產品不斷追求卓越的態度。同時我也希望本文可以拋磚引玉,為讀者打開思路,提供借鑑。

隨着人工智能和前端技術的不斷髮展和創新生態的日益完善,未來一定會不斷涌現大量的新技術和新理念。我相信只要時刻保持積極學習和不斷嘗試的探索精神,就能開拓出更多精彩創新的實現方案和應用場景。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.