perf(dialog): 消息列表滚动不再触发全部消息重渲染,Markdown 转换增加缓存

滚动与渲染区间变化时 Wrapper 及所有可见消息被反复重渲染:
- 已读判断收拢到 DialogWrapper.onVisibleRead,scrollIng、readEnabled 不再经 extraProps 下发
- 模板改读 scrollTailFar / scrollOffsetNear 布尔值,仅越过阈值时才触发渲染
- positionMsg 改依赖 unreadOneAbove,startMsgId 随区间变化不再带动 Wrapper 重渲染

Markdown:
- MarkdownConver 结果按原文做 LRU 缓存(200 条)
- 未标注语言的代码块只在常用语言子集中自动识别

移动端 4 倍降速滚动 8 秒实测:消息组件更新 1700 → 25 次,Wrapper 重渲染约 90 → 10 次,平均帧率 12 → 22。
This commit is contained in:
kuaifan 2026-10-28 16:00:18 +00:00
parent c75d7a95f5
commit cb35aaca2b
4 changed files with 77 additions and 53 deletions

@ -1 +1 @@
Subproject commit 8380cb7310f69303c984706a72c1759c72b94f24
Subproject commit c7123998555f0aab6d3f56a289bdf4d6cef49f01

View File

@ -120,14 +120,6 @@ export default {
type: Number,
default: 0
},
scrollIng: {
type: Number,
default: 0
},
readEnabled: {
type: Boolean,
default: false
},
multiSelectMode: {
type: Boolean,
default: false
@ -149,10 +141,6 @@ export default {
return this.simpleView || this.msgId === this.source.id
},
isNoRead() {
return this.isRightMsg || this.source.read_at
},
isUnreadStart() {
return this.unreadOne === this.source.id && this.source.id > 0
},
@ -189,36 +177,7 @@ export default {
},
},
watch: {
readEnabled() {
this.msgRead();
},
windowActive() {
this.msgRead();
},
scrollIng() {
this.msgRead();
},
},
methods: {
msgRead() {
if (this.isNoRead) {
return;
}
if (!this.readEnabled) {
return;
}
if (!this.windowActive) {
return;
}
if (!this.$el?.parentNode.classList.contains('item-enter')) {
return;
}
// 标记已读
this.$store.dispatch("dialogMsgRead", this.source);
},
formatTodoUser(data) {
if ($A.isJson(data)) {
const {userids} = data

View File

@ -225,7 +225,7 @@
<template #header v-if="!isChildComponent">
<div class="dialog-item head-box">
<div v-if="loadIng > 0 || prevId > 0" class="loading" :class="{filled: allMsgs.length === 0}">
<span v-if="scrollOffset < 100"></span>
<span v-if="scrollOffsetNear"></span>
</div>
<div v-else-if="allMsgs.length === 0" class="describe filled">{{$L('暂无消息')}}</div>
</div>
@ -249,7 +249,7 @@
<div v-if="!isStaticMode" v-show="!multiSelectMode" ref="footer" class="dialog-footer" @click="onClickFooter">
<!--滚动到底部-->
<div
v-if="scrollTail > 500 || (msgNew > 0 && allMsgs.length > 0)"
v-if="scrollTailFar || (msgNew > 0 && allMsgs.length > 0)"
class="dialog-goto"
v-touchclick="onToBottom">
<Badge :overflow-count="999" :count="msgNew">
@ -892,6 +892,8 @@ export default {
scrollTail: 0,
scrollOffset: 0,
scrollTailFar: false, // 距底部超过 500(模板只依赖此布尔值,避免每次滚动整体重渲染)
scrollOffsetNear: true, // 距顶部小于 100
replyListShow: false,
replyListId: 0,
@ -933,7 +935,6 @@ export default {
scrollDirection: null,
scrollAction: 0,
scrollTmp: 0,
scrollIng: 0,
scrollGroup: null,
observers: [],
@ -941,6 +942,7 @@ export default {
unreadOne: 0, // 最早未读消息id
startMsgId: 0, // 可见区域第一个消息id
unreadOneAbove: false, // 最早未读消息在可见区域上方
topPosLoad: 0, // 置顶跳转加载中
positionLoad: 0, // 定位跳转加载中
positionShow: false, // 定位跳转显示
@ -991,8 +993,8 @@ export default {
computed: {
msgExtraProps() {
const {dialogData, pointerMouse, isMyDialog, msgId, unreadOne, scrollIng, readEnabled, multiSelectMode, selectedMsgIdsSet} = this;
return {dialogData, pointerMouse, isMyDialog, msgId, unreadOne, scrollIng, readEnabled, multiSelectMode, selectedMsgIdsSet}
const {dialogData, pointerMouse, isMyDialog, msgId, unreadOne, multiSelectMode, selectedMsgIdsSet} = this;
return {dialogData, pointerMouse, isMyDialog, msgId, unreadOne, multiSelectMode, selectedMsgIdsSet}
},
...mapState([
@ -1340,11 +1342,16 @@ export default {
return null
},
positionMsg({msgNew, dialogData, allMsgs, startMsgId}) {
// 不直接放进 positionMsg:startMsgId 随渲染区间频繁变化,模板依赖它会让整个 Wrapper 跟着重渲染
unreadOneAboveCheck({dialogData, startMsgId}) {
return !!dialogData.unread_one && dialogData.unread_one < startMsgId
},
positionMsg({msgNew, dialogData, allMsgs, unreadOneAbove}) {
const {unread, unread_one, mention, mention_ids} = dialogData
const not = unread - msgNew
const array = []
if (unread_one && unread_one < startMsgId) {
if (unreadOneAbove) {
array.push({
type: 'unread',
label: this.$L('未读消息(*)条', not),
@ -1393,6 +1400,13 @@ export default {
},
watch: {
unreadOneAboveCheck: {
handler(above) {
this.unreadOneAbove = above
},
immediate: true
},
dialogId: {
handler(dialog_id, old_id) {
if (this.isStaticMode) {
@ -1578,7 +1592,12 @@ export default {
}
},
readEnabled(enabled) {
enabled && this.onVisibleRead()
},
windowActive(active) {
active && this.onVisibleRead()
if (active && this.autoFocus) {
const lastDialog = $A.last(this.dialogIns)
if (lastDialog && lastDialog.uid === this._uid) {
@ -1675,6 +1694,8 @@ export default {
this.startMsgId = 0
this.scrollTail = 0
this.scrollOffset = 0
this.scrollTailFar = false
this.scrollOffsetNear = true
this.searchShow = false
this.positionShow = false
this.msgPrepared = false
@ -3232,6 +3253,8 @@ export default {
const {offset, tail} = this.scrollInfo();
this.scrollOffset = offset;
this.scrollTail = tail;
this.scrollTailFar = tail > 500;
this.scrollOffsetNear = offset < 100;
if (tail <= 10) {
this.msgNew = 0;
this.scrollToBottomRefresh && this.onReGetMsg()
@ -3241,8 +3264,7 @@ export default {
this.scrollDirection = this.scrollTmp <= this.scrollAction ? 'down' : 'up';
setTimeout(_ => this.scrollTmp = this.scrollAction, 0);
//
this.scrollIng++;
setTimeout(_=> this.scrollIng--, 100);
this.onThrottleVisibleRead();
},
onThrottleScroll: throttle(function (target) {
@ -3251,6 +3273,27 @@ export default {
}
}, 100),
// 将进入可视区的消息标记已读(统一在此处理,不经 extraProps 下发,避免滚动、渲染区间变化时所有消息重渲染)
onVisibleRead() {
const root = this.$refs.scroller?.$el
if (!this.readEnabled || !this.windowActive || !root) {
return
}
const ids = new Set(Array.from(root.querySelectorAll('div[role="listitem"].item-enter'), el => Number(el.getAttribute('unique'))))
if (ids.size === 0) {
return
}
this.allMsgs.forEach(msg => {
if (ids.has(msg.id) && !msg.read_at && msg.userid != this.userId) {
this.$store.dispatch("dialogMsgRead", msg)
}
})
},
onThrottleVisibleRead: throttle(function () {
this.onVisibleRead()
}, 100),
onRange(range) {
if (this.preventRangeLoad > 0) {
return

View File

@ -11,6 +11,13 @@ const MarkdownUtils = {
mdi: null,
mds: null,
// MarkdownConver 结果缓存(LRU,Map 按插入顺序淘汰最旧项)
converCache: new Map(),
converCacheMax: 200,
// 代码块未标注语言时参与自动识别的语言
autoLanguages: ['javascript', 'typescript', 'json', 'xml', 'css', 'scss', 'bash', 'shell', 'python', 'java', 'go', 'php', 'sql', 'yaml', 'markdown', 'c', 'cpp', 'csharp', 'kotlin', 'swift', 'rust', 'ruby', 'dockerfile', 'nginx', 'ini'],
/**
* 处理 AI 建议操作按钮语法
* 格式: :::ai-action{type="xxx" task="123" msg="456" userid="789" related="123" status="applied"}:::
@ -438,19 +445,34 @@ export function MarkdownConver(text) {
const lang = language ?? ''
return MarkdownUtils.highlightBlock(hljs.highlight(code, {language: lang}).value, lang)
}
return MarkdownUtils.highlightBlock(hljs.highlightAuto(code).value, '')
// 未标注语言时只在常用语言子集中识别(全量 192 种语言识别单个代码块可达数百毫秒)
return MarkdownUtils.highlightBlock(hljs.highlightAuto(code, MarkdownUtils.autoLanguages).value, '')
},
})
MarkdownUtils.mdi.use(mila, {attrs: {target: '_blank', rel: 'noopener noreferrer'}})
MarkdownUtils.mdi.use(mdKatex, {blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000'})
MarkdownPluginUtils.initReasoningPlugin(MarkdownUtils.mdi);
}
// 结果只取决于原文,缓存后消息被虚拟列表回收再挂载时无需重新解析
const cache = MarkdownUtils.converCache
const cacheKey = text
if (cache.has(cacheKey)) {
const html = cache.get(cacheKey)
cache.delete(cacheKey)
cache.set(cacheKey, html)
return html
}
text = MarkdownPluginUtils.clearEmptyReasoning(text);
text = mergeConsecutiveToolUse(text);
text = MarkdownUtils.processAiAction(text);
text = MarkdownUtils.processDeepLinks(text);
text = MarkdownUtils.mdi.render(text);
return MarkdownUtils.formatMsg(text)
const html = MarkdownUtils.formatMsg(text)
cache.set(cacheKey, html)
if (cache.size > MarkdownUtils.converCacheMax) {
cache.delete(cache.keys().next().value)
}
return html
}
export function MarkdownPreview(text) {