From b802c896cf45ffa921d69e1f552b0f08a376c777 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 5 Apr 2026 01:27:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20state=20=E7=AE=A1=E7=90=86=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20-=20=E7=BB=93=E6=9E=84=E5=8C=96=E6=B5=85=E6=AF=94?= =?UTF-8?q?=E8=BE=83=20+=20update/setIn/batch=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state.js: 新增 shallowEqual、update()、setIn()、batch() 方法 - input-area.js: messages.push() 全部改为 state.update(),确保引用变化触发通知 - model-bar.js: 直接属性赋值改为 state.update(),修复 model 切换不触发变更检测 --- js/components/input-area.js | 45 +++++++--- js/components/model-bar.js | 3 +- js/state.js | 158 ++++++++++++++++++++++++++++++++---- 3 files changed, 177 insertions(+), 29 deletions(-) diff --git a/js/components/input-area.js b/js/components/input-area.js index 96dd74d..77c1a10 100644 --- a/js/components/input-area.js +++ b/js/components/input-area.js @@ -345,15 +345,24 @@ export async function sendMessage() { msgsToAdd.push(msg); } - // 更新会话标题 - if (currentSession.messages.length === 0) { - const titleText = text - || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'); - currentSession.title = truncate(titleText, 30); - currentSession.model = model; - } + // 更新会话标题 + 追加消息(通过 update 确保引用变化,触发浅比较) + const isFirstMsg = currentSession.messages.length === 0; + state.update(KEYS.CURRENT_SESSION, session => ({ + ...session, + ...(isFirstMsg && { + title: truncate( + text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), + 30 + ), + model + }), + messages: [...session.messages, ...msgsToAdd], + updatedAt: Date.now() + })); + + // update() 产生新引用,后续必须从 state 读取最新值 + const freshSession = state.get(KEYS.CURRENT_SESSION); - msgsToAdd.forEach(m => currentSession.messages.push(m)); enableAutoScroll(); renderMessages(); @@ -391,7 +400,7 @@ export async function sendMessage() { let modelName = ''; try { - const apiMessages = buildApiMessages(currentSession.messages); + const apiMessages = buildApiMessages(freshSession.messages); const chatParams = { model, @@ -502,7 +511,11 @@ ${ragContext} ...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }), ...(ragSources && { ragSources }) }; - currentSession.messages.push(assistantMsg); + state.update(KEYS.CURRENT_SESSION, session => ({ + ...session, + messages: [...session.messages, assistantMsg], + updatedAt: Date.now() + })); if (finalStats) { updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null); @@ -549,7 +562,11 @@ ${ragContext} ...(thinkContent && { think: thinkContent }), stopped: true }; - currentSession.messages.push(partialMsg); + state.update(KEYS.CURRENT_SESSION, session => ({ + ...session, + messages: [...session.messages, partialMsg], + updatedAt: Date.now() + })); if (contentDiv) { let html = ''; @@ -584,7 +601,11 @@ ${ragContext} content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now(), }; - currentSession.messages.push(partialMsg); + state.update(KEYS.CURRENT_SESSION, session => ({ + ...session, + messages: [...session.messages, partialMsg], + updatedAt: Date.now() + })); contentDiv.innerHTML = safeMarkdown(assistantContent) + '

[已中断]

'; await saveCurrentSession(); } else { diff --git a/js/components/model-bar.js b/js/components/model-bar.js index aba4375..df6c601 100644 --- a/js/components/model-bar.js +++ b/js/components/model-bar.js @@ -24,8 +24,7 @@ export function initModelBar() { const model = modelSelectEl.value; if (db()) await db().saveSetting('selectedModel', model); state.set('_defaultModel', model); - const session = state.get(KEYS.CURRENT_SESSION); - if (session) session.model = model; + state.update(KEYS.CURRENT_SESSION, session => session ? ({ ...session, model }) : session); if (model) { await checkModelCapability(model); } else { diff --git a/js/state.js b/js/state.js index 46b82c9..c2855a6 100644 --- a/js/state.js +++ b/js/state.js @@ -1,34 +1,166 @@ /** * State - 轻量级响应式状态管理 - * 使用发布-订阅模式,状态变更时通知所有订阅者 + * + * 优化点: + * 1. 结构化浅比较:对象按 key 数量 + 逐 key 严格比较,数组按长度 + 逐元素 + * 2. update():函数式更新,自动比较 + * 3. setIn():路径式嵌套更新,沿途浅拷贝确保引用变化 + * 4. batch():批量更新,合并通知 */ +// ── 结构化浅比较 ── +function shallowEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return a === b; + if (typeof a !== 'object' || typeof b !== 'object') return false; + + // 数组:长度 + 逐元素 + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + // 混合类型 + if (Array.isArray(a) !== Array.isArray(b)) return false; + + // 对象:key 数量 + 逐 key + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) return false; + } + return true; +} + class AppState { constructor() { this._state = {}; - this._listeners = new Map(); // key → Set + this._listeners = new Map(); + this._batching = false; + this._batchQueue = null; } /** - * 设置状态值并通知订阅者 - * @param {string} key - 状态键名 - * @param {*} value - 状态值 + * 设置状态值,使用结构化浅比较判断变更 */ set(key, value) { const old = this._state[key]; this._state[key] = value; - if (old !== value && this._listeners.has(key)) { - for (const cb of this._listeners.get(key)) { - try { cb(value, old); } catch (e) { console.error(`[State] 监听器错误 (${key}):`, e); } + + if (!shallowEqual(old, value)) { + if (this._batching) { + this._batchQueue.add(key); + } else { + this._notify(key, value, old); + } + } + } + + /** + * 函数式更新:基于当前值计算新值,自动比较 + * + * state.update('currentSession', session => ({ + * ...session, + * messages: [...session.messages, newMsg], + * updatedAt: Date.now() + * })); + */ + update(key, updater) { + const old = this._state[key]; + const value = updater(old); + this._state[key] = value; + + if (!shallowEqual(old, value)) { + if (this._batching) { + this._batchQueue.add(key); + } else { + this._notify(key, value, old); + } + } + } + + /** + * 路径式嵌套更新:按 path 更新嵌套属性,沿途浅拷贝确保引用变化 + * + * state.setIn('currentSession', ['messages', 0, 'content'], '新内容'); + */ + setIn(key, path, value) { + if (!path || path.length === 0) { + this.set(key, value); + return; + } + + const old = this._state[key]; + if (old == null || typeof old !== 'object') { + console.warn(`[State] setIn 失败: ${key} 不是对象`); + return; + } + + const newRoot = Array.isArray(old) ? [...old] : { ...old }; + let current = newRoot; + + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + const next = current[segment]; + if (next == null || typeof next !== 'object') { + const isIndex = typeof path[i + 1] === 'number'; + current[segment] = isIndex ? [] : {}; + } else { + current[segment] = Array.isArray(next) ? [...next] : { ...next }; + } + current = current[segment]; + } + + current[path[path.length - 1]] = value; + + this._state[key] = newRoot; + if (!shallowEqual(old, newRoot)) { + if (this._batching) { + this._batchQueue.add(key); + } else { + this._notify(key, newRoot, old); + } + } + } + + /** + * 批量更新:合并多次 set 为一次通知 + * + * state.batch(() => { + * state.set('isStreaming', false); + * state.update('currentSession', s => ({ ...s, updatedAt: Date.now() })); + * }); + */ + batch(fn) { + this._batching = true; + this._batchQueue = new Set(); + try { + fn(); + } finally { + const keys = this._batchQueue; + this._batching = false; + this._batchQueue = null; + for (const k of keys) { + this._notify(k, this._state[k], undefined); + } + } + } + + _notify(key, value, old) { + if (!this._listeners.has(key)) return; + for (const cb of this._listeners.get(key)) { + try { cb(value, old); } catch (e) { + console.error(`[State] 监听器错误 (${key}):`, e); } } } /** * 获取状态值 - * @param {string} key - 状态键名 - * @param {*} [defaultValue] - 默认值 - * @returns {*} */ get(key, defaultValue = undefined) { return key in this._state ? this._state[key] : defaultValue; @@ -36,8 +168,6 @@ class AppState { /** * 订阅状态变更 - * @param {string} key - 状态键名 - * @param {function} callback - 回调函数 (newValue, oldValue) => void * @returns {function} 取消订阅函数 */ on(key, callback) { @@ -50,7 +180,6 @@ class AppState { /** * 批量设置初始状态 - * @param {Object} initial - 初始状态对象 */ init(initial) { Object.assign(this._state, initial); @@ -60,7 +189,6 @@ class AppState { // 单例 export const state = new AppState(); -// ── 状态键名常量 ── export const KEYS = { DB: 'db', API: 'api',