AI 消息列表虚拟滚动:这是业务问题,还是组件能力边界?
AI æ¶æ¯åè¡¨èææ»å¨ï¼è¿æ¯ä¸å¡é®é¢ï¼è¿æ¯ç»ä»¶è½åè¾¹çï¼
one_last_FE 2026-08-21 9 é 读10åéAI æ¶æ¯åè¡¨èææ»å¨ï¼è¿æ¯ä¸å¡é®é¢ï¼è¿æ¯ç»ä»¶è½åè¾¹çï¼
åè¨
å¨å AI 对è¯å·¥ä½å°æ¶ï¼æ¶æ¯åè¡¨è¿æ©ä¼éå°ä¸ä¸ªé®é¢ï¼
å岿¶æ¯è¶æ¥è¶å¤ï¼é¡µé¢è¦ä¸è¦ä¸èææ»å¨ï¼
è¿ä¸ªé®é¢çèµ·æ¥å¾èªç¶ã
å ä¸ºä»æ§è½è§åº¦è®²ï¼ä¸å¯è½æ éå¶ææææ¶æ¯é½æ¸²æå¨ DOM éã
ä½çæ£è½å° AI 对è¯åºæ¯éï¼å®åæ²¡ææ®éå表é£ä¹ç®åã
å½å项ç®å·²ç»å®ç°äºåºç¡çåå²å页ï¼
馿¬¡å è½½æè¿ 50 æ¡å岿¶æ¯
ç¨æ·ä¸æ»å°é¡¶é¨éè¿
ä½¿ç¨ lastCreateTime 游æ å è½½æ´æ©æ¶æ¯
ææ´æ©æ¶æ¯ prepend å° messages åé¢
éè¿ scrollHeight å·®å¼ä¿®æ£ scrollTopï¼é¿å
页é¢è·³å¨
è¿ä¸ªé¶æ®µå·²ç»è§£å³äºâä¸è¦ä¸æ¬¡æ§æåææå岿¶æ¯âçé®é¢ã
ä½å®è¿æ²¡æè§£å³âå端 DOM èç¹æ éå¢é¿âçé®é¢ã
æä»¥å¾èªç¶å°ï¼æå¼å§æèä¸ä¸æ¥ï¼
è¦ä¸è¦é©¬ä¸å èææ»å¨ï¼
æåæçç»è®ºæ¯ï¼
å¦æç»§ç»ä½¿ç¨
BubbleListï¼çæ£çèææ»å¨æ´åºè¯¥æ¯ç»ä»¶å±æ¯æçè½åï¼è䏿¯ä¸å¡ä¾§ç¡¬æ¹åºæ¥çåè½ã
è¿ç¯æç« 就记å½ä¸ä¸è¿ä¸ªå¤æè¿ç¨ï¼ä¸ºä»ä¹å½åä¸å¡å±å·²ç»å®æäºè¯¥åçå页å è½½ï¼ä¸ºä»ä¹èææ»å¨ä¸éåç»§ç»å¨ä¸å¡ä»£ç é强è¡è¡¥ï¼ä»¥ååç»æ´åççæ¼è¿æ¹å¼æ¯ä»ä¹ã
å½åå·²ç»åå°äºä»ä¹
ç°å¨æ¶æ¯åè¡¨ä¸æ¯ä¸æ¬¡æ§å è½½ææåå²ã
è¿å ¥æä¸ªä¼è¯æ¶ï¼å å è½½æè¿ä¸é¡µï¼
listAppChatHistory(appId, {
pageSize: WORKBENCH_HISTORY_PAGE_SIZE,
});
ç¶ååå½åè¿ä¸é¡µéææ©ç createTime ä½ä¸ºæ¸¸æ ï¼
target.historyCursor = getEarliestHistoryCursor(result.records);
ç¨æ·ç»§ç»ä¸æ»å°é¡¶é¨éè¿æ¶ï¼åè¯·æ±æ´æ©åå²ï¼
listAppChatHistory(target.appId, {
pageSize: WORKBENCH_HISTORY_PAGE_SIZE,
lastCreateTime: target.historyCursor,
});
æ¿å°æ´æ©æ¶æ¯åï¼ä¸æ¯è¿½å å°åé¢ï¼èæ¯æå°åé¢ï¼
target.messages = mergeOlderMessages(olderMessages, target.messages);
为äºé¿å 页é¢è·³å¨ï¼å è½½åè®°å½æ»å¨é«åº¦ï¼
const previousScrollHeight = scrollElement.scrollHeight;
const previousScrollTop = scrollElement.scrollTop;
æ¶æ¯ prepend 宿åï¼çå¾ DOM æ´æ°ï¼
await nextTick();
åä¿®æ£æ»å¨ä½ç½®ï¼
const nextScrollHeight = scrollElement.scrollHeight;
scrollElement.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
è¿å¥é»è¾è§£å³çæ¯è天æ¶é´çº¿å页æå ³é®çé®é¢ï¼
å è½½æ´æ©å岿¶ï¼ç¨æ·å½åé
读ä½ç½®ä¸è½è·³ã
æä»¥å½åçæ¬ä¸æ¯å纯 demoã
å®å·²ç»æçå®é¡¹ç®çåºç¡éª¨æ¶ï¼
cursor å页
䏿»å è½½æ´æ©æ¶æ¯
prepend åå¹¶
æ¶æ¯å»é
æ»å¨éå®
å è½½é
ä¼è¯åæ¢ä¿æ¤
ä½å®è¿ä¸æ¯èææ»å¨
å页åèææ»å¨è§£å³çæ¯ä¸¤ä¸ªä¸åé®é¢ã
å页解å³çæ¯ï¼
ä¸è¦ä¸æ¬¡æ§ä»å端å è½½ææå岿¶æ¯ã
èææ»å¨è§£å³çæ¯ï¼
ä¸è¦ä¸æ¬¡æ§å¨åç«¯æ¸²æææå·²ç»å è½½çæ¶æ¯ã
å½åå®ç°å±äºåè ã
å¦æç¨æ·æç»ä¸æ»ï¼
å è½½ 50 æ¡
å è½½å° 100 æ¡
å è½½å° 150 æ¡
å è½½å° 300 æ¡
å è½½å° 1000 æ¡
è¿äºæ¶æ¯æç»é½ä¼çå¨ conversation.messagesï¼ä¹ä¼ç»§ç»ä¼ ç» BubbleList 渲æã
ä¹å°±æ¯è¯´ï¼
æ¥å£åå被å页éä½äº
ä½ DOM 渲æååè¿æ²¡æå®å
¨è§£å³
è¿å°±æ¯èææ»å¨åå¨çæä¹ã
为ä»ä¹æ²¡æé©¬ä¸åèææ»å¨
å¦æè¿æ¯ä¸ä¸ªæ®éåè¡¨ï¼æå¯è½ä¼å¾å¿«æ¥å ¥èææ»å¨ã
æ¯å¦ååå表ï¼
æ¯ä¸é¡¹é«åº¦æ¥è¿
ç»æç¸å¯¹åºå®
ä¸ä¼æç»æµå¼åå
ä¸ä¼æ Markdown å¨ææ¸²æ
ä¸ä¼æä»£ç ååè¡¨æ ¼æé«
è¿ç§åºæ¯å¾éåç´æ¥ä½¿ç¨èæå表ã
ä½ AI æ¶æ¯åè¡¨ä¸æ¯è¿æ ·ã
䏿¡ AI æ¶æ¯å¯è½æ¯ï¼
ä¸å¥æ®éåå¤
䏿®µå¾é¿çéæ±åæ
ä¸ä¸ª Markdown è¡¨æ ¼
ä¸å¤§æ®µä»£ç å
ä¸ä¸ªæ£å¨æµå¼è¾åºçåç
ä¸ä¸ªå¸¦æåæºææç卿å
容
è¿äºæ¶æ¯é«åº¦å®å ¨ä¸ç¨³å®ã
æ´éº»ç¦çæ¯ï¼å®ä¸æ¯æ¸²æå®å°±ç»æã
AI åå¤ä¼éç SSE chunk æç»å¢é¿ï¼
content ä»ç©ºå符串å¼å§
æ¯æ¬¡ chunk å°è¾¾å追å
MarkdownRenderer éæ°æ¸²æ
Bubble typing ç»§ç»å±ç¤º
æ¶æ¯é«åº¦æç»åå
è¿ä¼ç»èææ»å¨å¸¦æ¥å¾å¤é¢å¤å¤æåº¦ã
é¾ç¹ä¸ï¼æ¶æ¯é«åº¦ä¸æ¯åºå®ç
èææ»å¨æç®åçæ åµæ¯åºå®é«åº¦ã
æ¯å¦ï¼
itemHeight = 80;
è¿æ ·å表å¯ä»¥å¾å®¹æè®¡ç®ï¼
第 100 æ¡æ¶æ¯åºè¯¥åºç°å¨ 8000px çä½ç½®
ä½ AI æ¶æ¯ä¸å¯è½è¿ä¹ç®ã
åä¸ä¸ªæ¶æ¯å表éï¼å¯è½åºç°ï¼
ç¨æ·çæ¶æ¯ï¼40px
AI æ®éåå¤ï¼160px
AI é¿ææ¹æ¡ï¼1200px
代ç åï¼800px
Markdown è¡¨æ ¼ï¼600px
è¿å°±éè¦å¨æé«åº¦èæå表ã
卿é«åº¦æå³çï¼
æ¯æ¡æ¶æ¯æ¸²æåè¦æµéçå®é«åº¦
é«åº¦è¦ç¼å
å
容åååè¦éæ°æµé
æ»å¨ä½ç½®è¦æ ¹æ®é«åº¦ååä¿®æ£
è¿æ¯æ®éèæåè¡¨å¤æå¾å¤ã
é¾ç¹äºï¼æµå¼è¾åºä¼æç»æ¹åé«åº¦
å½å AI å夿¯æµå¼çã
å端ä¼å æå ¥ä¸æ¡ assistant ç©ºæ¶æ¯ï¼
{
role: 'assistant',
content: '',
typing: { step: 2, interval: 24, suffix: '|' },
}
ç¶åæ¯ä¸ª chunk å°è¾¾å追å å 容ï¼
content: `${item.content}${chunk}`
è¿æå³çæå䏿¡ assistant æ¶æ¯çé«åº¦ä¼ä¸æååã
妿åèææ»å¨ï¼å°±å¿ é¡»å¤çï¼
æ£å¨æµå¼è¾åºçæ¶æ¯é«åº¦åå
èæåè¡¨éæ°æµé
å¦æç¨æ·å¨åºé¨ï¼è¦ç»§ç»è·é
å¦æç¨æ·å¨çåå²ï¼ä¸è½å¼ºè¡æ»å°åºé¨
è¿ä¸æ¯ä¸ä¸ªç®åç virtual: true è½è§£å³çé®é¢ã
é¾ç¹ä¸ï¼Markdown 渲æä¼è®©é«åº¦å次åå
项ç®é AI æ¶æ¯å·²ç»æ¥å ¥äº Markdown 渲æï¼
messageRender: (content: unknown) => h(MarkdownRenderer, {
content: String(content ?? ''),
})
MarkdownRenderer å é¨ä¼åï¼
markdown-it è§£æ Markdown
highlight.js å¤ç代ç é«äº®
DOMPurify åå®å
¨è¿æ»¤
v-html 渲æ HTML
Markdown çé«åº¦ä¸æ¯çº¿æ§å¯æ§çã
æ¯å¦æ¨¡åæ£å¨è¾åºä»£ç åï¼
```ts
const a = 1
代ç åè¿æ²¡éåæ¶æ¯ä¸ç§æ¸²æç»æã
åç»éåå忝ä¸ç§æ¸²æç»æã
妿åå ä¸ä»£ç é«äº®ãè¡¨æ ¼ãå¾çé¢è§ï¼æ¶æ¯é«åº¦ä¼æç»è°æ´ã
èæåè¡¨å¿ é¡»è½æç¥è¿äºé«åº¦ååï¼å¦åä¼åºç°ï¼
æ»å¨æ¡ä½ç½®ä¸å
æ¶æ¯éå
åºé¨çç½
æ»å¨éå®å¤±æ
é¾ç¹åï¼BubbleList æ¬èº«ä¸æ¯èæå表
å½åæ¶æ¯åºä½¿ç¨çæ¯ï¼
<BubbleList
:items="conversation.messages"
:roles="roles"
:auto-scroll="true"
/>
BubbleList çèè´£æ¯ï¼
æ¥æ¶å®æ´ items
æ¸²æææ Bubble
å¤ç roles
å¤ç autoScroll
å¤ç typing
æ´é² onScroll
å®ä¸æ¯èææ»å¨ç»ä»¶ã
èææ»å¨éè¦æ§å¶ï¼
å½ååªæ¸²æåªäºæ¶æ¯
é¡¶é¨å ä½é«åº¦
åºé¨å ä½é«åº¦
æ¯æ¡æ¶æ¯çå®é«åº¦
æ»å¨å®¹å¨
卿æµé
è¿äºå BubbleList ç宿´å表渲æèè´£æå²çªã
妿çè¦åä¸¥æ ¼èææ»å¨ï¼æ´åççç»æå¯è½æ¯ï¼
VirtualMessageList
è´è´£èææ»å¨ãå¯è§åºåã卿é«åº¦ãæ»å¨éå®
Bubble
åªè´è´£åæ¡æ¶æ¯æ°æ³¡å±ç¤º
ä¹å°±æ¯ä»ï¼
<BubbleList :items="messages" />
æ¼è¿å°ï¼
<VirtualList :items="messages">
<template #default="{ item }">
<Bubble
:content="item.content"
:typing="item.typing"
:message-render="renderMarkdown"
/>
</template>
</VirtualList>
è¿ä¸ªæ¹å¨ä¸æ¯å°ä¿®å°è¡¥ï¼èæ¯æ¶æ¯åè¡¨æ¶æå级ã
é¾ç¹äºï¼autoScroll ååå²å页已ç»å¾ææ
ç°å¨æ¶æ¯åè¡¨å·²ç»æä¸¤ç±»æ»å¨çç¥ã
第ä¸ç±»æ¯æ°æ¶æ¯è¾åºï¼
ç¨æ·å¨åºé¨æ¶ï¼AI è¾åºè¦èªå¨è·éå°åºé¨ã
第äºç±»æ¯ä¸æ»å è½½åå²ï¼
ç¨æ·å¨é¡¶é¨éè¿æ¶ï¼å è½½æ´æ©æ¶æ¯ï¼å¹¶ä¿æå½åé
读ä½ç½®ä¸è·³ã
è¿ä¸¤ç±»æ»å¨æ¹åæ¯ç¸åçã
妿åå¼å ¥èææ»å¨ï¼è¿è¦åæ¶å¤çï¼
èæå表æ»å¨èå´åå
䏿¹ prepend å岿¶æ¯
åºé¨ append æ°æ¶æ¯
å½åç¨æ·æ¯å¦å¨åºé¨
å½åç¨æ·æ¯å¦å¨å岿µè§ç¶æ
æ£å¨ typing çæ¶æ¯æ¯å¦è¿å¯è§
è¿å·²ç»ä¸æ¯ä¸ä¸ªåç¹ä¼åï¼èæ¯æ»å¨ç¶ææºã
æä»¥æä¸è®¤ä¸ºç°å¨åºè¯¥ä¸ºäºâå¬èµ·æ¥æ´é«çº§âå°±ç´æ¥æ¹èææ»å¨ã
è¿æ´åç»ä»¶è½åè¾¹ç
çå°ç¤¾åºéä¹æäººè®¨è®º BubbleList æ¯å¦åºè¯¥æ¯æèææ»å¨ï¼æåèæ´ç¡®è®¤äºä¸ç¹ï¼
è¿ä¸æ¯æä¸ä¸ªä¸å¡é¡¹ç®åç¬éå°çé®é¢ï¼èæ¯ AI æ¶æ¯å表ç»ä»¶å¤©ç¶ä¼éå°çè½åè¯æ±ã
妿ä¸ä¸ªç»ä»¶å®ä½æ¯ AI æ¶æ¯å表ï¼å®ä¸ä» è¦è½æ¸²ææ°æ³¡ï¼è¿è¿æ©è¦é¢å¯¹ï¼
é¿ä¼è¯
大éå岿¶æ¯
卿é«åº¦æ¶æ¯
æµå¼è¾åº
èªå¨æ»å¨
䏿»å è½½æ´æ©åå²
è¿äºè½åæ¾å¨ä¸èµ·ï¼èææ»å¨å°±ä¸åªæ¯ä¸ä¸ªæ§è½å°ä¼åï¼èæ¯å表ç»ä»¶æ¶æçä¸é¨åã
妿ä¸å¡ä¾§ä¸ºäºç»§ç»ä½¿ç¨ BubbleListï¼å¼ºè¡å¨å¤é¢å
ä¸å±èææ»å¨ï¼å¾å®¹æåºç°èè´£å²çªï¼
å¤å±èæåè¡¨æ³æ§å¶æ¸²æèå´
BubbleList å
é¨åè¦æ¸²æå®æ´ items
å¤å±æ³æ§å¶æ»å¨å®¹å¨
BubbleList å
é¨ä¹æèªå·±çæ»å¨å®¹å¨
å¤å±è¦å卿é«åº¦æµé
BubbleList å
é¨è¿è¦å¤ç autoScroll å typing
è¿ä¸æ¯ä¸ä¸ªä¼é çæ©å±æ¹å¼ã
æä»¥ææ´å¾åæå®å®ä¹ä¸ºï¼
BubbleList å½åçè½åè¾¹ç
ä¸å¡ä¾§å¯ä»¥å®æåå²åé¡µãæ¸¸æ ãprependãæ»å¨éå®ã
ä½çæ£çèææ»å¨ï¼å¦æè¿æ³æ²¿ç¨ BubbleList ç rolesãtypingãmessageRenderãautoScrollï¼å°±æ´åºè¯¥ç±ç»ä»¶æ¬èº«æä¾ã
æ¯å¦ç»ä»¶å±å¦ææ¯æï¼
<BubbleList
virtual
:items="messages"
:roles="roles"
:estimate-size="120"
/>
æè æä¾æ´åºå±çç»åè½åï¼
BubbleList è´è´£èææ¶é´çº¿
Bubble è´è´£åæ¡æ°æ³¡
å
é¨ç»ä¸å¤ç autoScroll / typing / dynamic height
è¿ä¼æ¯ä¸å¡ä¾§èªå·±ç»å¼ç»ä»¶å®ç°å¯é å¾å¤ã
ä¸å¡ä¾§ç°å¨åºè¯¥åå°åªé
æ¢ç¶èææ»å¨æ´åç»ä»¶è½åï¼é£ä¸å¡ä¾§æ¯ä¸æ¯å°±ä»ä¹é½ä¸ç¨åï¼
ä¹ä¸æ¯ã
ä¸å¡ä¾§åºè¯¥æâæ°æ®æ¶é´çº¿âå好ã
ä¹å°±æ¯ï¼
åå²å页
游æ ç»´æ¤
æ¶æ¯åå¹¶
æ¶æ¯å»é
å è½½ç¶æ
é误å¤ç
æ»å¨éå®
è¿äºæ¯ä¸å¡ç³»ç»å¿ é¡»è´è´£çã
å½å项ç®å·²ç»å®æäºæ ¸å¿é¨åï¼
馿¬¡å è½½æè¿åå²
䏿»å è½½æ´æ©åå²
lastCreateTime 游æ
olderMessages prepend
ææ¶æ¯ key å»é
scrollHeight å·®å¼ä¿æè§å£ç¨³å®
è¿é¨åä¸ä¾èµèææ»å¨ã
å³ä½¿æªæ¥ç»ä»¶æ¯æèææ»å¨ï¼ä¸å¡ä¾§ä»ç¶éè¦è¿äºæ°æ®é»è¾ã
æ¢å¥è¯è¯´ï¼
ä¸å¡ä¾§è´è´£âæåªäºæ¶æ¯â
ç»ä»¶ä¾§è´è´£âæä¹é«æ§è½æ¸²æè¿äºæ¶æ¯â
è¿æ¡è¾¹çè¦åæ¸ æ¥ã
å°ç»
è¿æ¬¡æ¬æ¥æ³ç»§ç»ææ¶æ¯å表åçº§ææ éæ»å¨çè³èææ»å¨ã
使·±å ¥ç䏿¥ï¼AI æ¶æ¯å表çå¤æåº¦æ¯æ®éå表é«å¾å¤ã
主è¦é¾ç¹å¨äºï¼
æ¶æ¯é«åº¦å¨æ
SSE æµå¼è¾åºæç»æ¹åå
容
Markdown 渲æä¼æ¹å DOM é«åº¦
BubbleList æ¬èº«ä¸æ¯èæå表
autoScroll å䏿»å è½½æ¯ä¸¤å¥ç¸åçç¥
æ»å¨ç¶æéè¦æ´ç³»ç»å°ç®¡ç
æ´éè¦çæ¯ï¼å¦æç»§ç»ä½¿ç¨ BubbleListï¼çæ£èææ»å¨æ´åç»ä»¶å±è½åï¼è䏿¯ä¸å¡å±åºè¯¥ç¡¬è¡¥çè½åã
æä»¥å½åæ´åçç夿æ¯ï¼
ä¸å¡ä¾§åå¥½æ¶æ¯æ¶é´çº¿å页忻å¨éå®
èææ»å¨çå¾
ç»ä»¶å±æ¯æ
妿ç»ä»¶é¿æä¸æ¯æä¸ç¡®å®åºç°æ§è½ç¶é¢ï¼åå VirtualMessageList + Bubble çæ¶ææ¿æ¢
è¿ä¸æ¯ææ¯å¦¥åï¼èæ¯å·¥ç¨åèã
好çåç«¯æ¶æä¸æ¯ä¸å¼å§å°±æææé«çº§è½åå¡è¿å»ã
èæ¯å¨æ¯ä¸ªé¶æ®µç¥éï¼
å½åæè¯¥è§£å³ä»ä¹
åªäºè½åå±äºä¸å¡ä¾§
åªäºè½åå±äºç»ä»¶ä¾§
对è¿ä¸ª AI å·¥ä½å°æ¥è¯´ï¼ç°å¨å·²ç»å®æäºæ¶æ¯æ¶é´çº¿åé¡µçæ ¸å¿è½åã
ä¸ä¸æ¥ä¸åºè¯¥å¨ä¸å¡é强è¡ç» BubbleList å¡èææ»å¨ï¼èæ¯ç»§ç»æä¸å¡æ¶é´çº¿å好ï¼åæ¶å
³æ³¨ç»ä»¶æ¬èº«æ¯å¦æä¾æ£å¼çèææ»å¨è½åã
Aitishiku.com