mirror of
https://github.com/kuaifan/dootask.git
synced 2026-10-29 22:10:42 +00:00
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:
parent
c75d7a95f5
commit
cb35aaca2b
@ -1 +1 @@
|
||||
Subproject commit 8380cb7310f69303c984706a72c1759c72b94f24
|
||||
Subproject commit c7123998555f0aab6d3f56a289bdf4d6cef49f01
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
26
resources/assets/js/utils/markdown.js
vendored
26
resources/assets/js/utils/markdown.js
vendored
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user