refactor: state 管理优化 - 结构化浅比较 + update/setIn/batch API

- state.js: 新增 shallowEqual、update()、setIn()、batch() 方法
- input-area.js: messages.push() 全部改为 state.update(),确保引用变化触发通知
- model-bar.js: 直接属性赋值改为 state.update(),修复 model 切换不触发变更检测
This commit is contained in:
thzxx
2026-04-05 01:27:24 +08:00
parent e519631bee
commit b802c896cf
3 changed files with 177 additions and 29 deletions
+33 -12
View File
@@ -345,15 +345,24 @@ export async function sendMessage() {
msgsToAdd.push(msg); msgsToAdd.push(msg);
} }
// 更新会话标题 // 更新会话标题 + 追加消息(通过 update 确保引用变化,触发浅比较)
if (currentSession.messages.length === 0) { const isFirstMsg = currentSession.messages.length === 0;
const titleText = text state.update(KEYS.CURRENT_SESSION, session => ({
|| (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'); ...session,
currentSession.title = truncate(titleText, 30); ...(isFirstMsg && {
currentSession.model = model; 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(); enableAutoScroll();
renderMessages(); renderMessages();
@@ -391,7 +400,7 @@ export async function sendMessage() {
let modelName = ''; let modelName = '';
try { try {
const apiMessages = buildApiMessages(currentSession.messages); const apiMessages = buildApiMessages(freshSession.messages);
const chatParams = { const chatParams = {
model, model,
@@ -502,7 +511,11 @@ ${ragContext}
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }), ...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
...(ragSources && { ragSources }) ...(ragSources && { ragSources })
}; };
currentSession.messages.push(assistantMsg); state.update(KEYS.CURRENT_SESSION, session => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
}));
if (finalStats) { if (finalStats) {
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null); updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null);
@@ -549,7 +562,11 @@ ${ragContext}
...(thinkContent && { think: thinkContent }), ...(thinkContent && { think: thinkContent }),
stopped: true stopped: true
}; };
currentSession.messages.push(partialMsg); state.update(KEYS.CURRENT_SESSION, session => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
if (contentDiv) { if (contentDiv) {
let html = ''; let html = '';
@@ -584,7 +601,11 @@ ${ragContext}
content: assistantContent + '\n\n`[已中断]`', content: assistantContent + '\n\n`[已中断]`',
timestamp: Date.now(), 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) + '<p><code>[已中断]</code></p>'; contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
await saveCurrentSession(); await saveCurrentSession();
} else { } else {
+1 -2
View File
@@ -24,8 +24,7 @@ export function initModelBar() {
const model = modelSelectEl.value; const model = modelSelectEl.value;
if (db()) await db().saveSetting('selectedModel', model); if (db()) await db().saveSetting('selectedModel', model);
state.set('_defaultModel', model); state.set('_defaultModel', model);
const session = state.get(KEYS.CURRENT_SESSION); state.update(KEYS.CURRENT_SESSION, session => session ? ({ ...session, model }) : session);
if (session) session.model = model;
if (model) { if (model) {
await checkModelCapability(model); await checkModelCapability(model);
} else { } else {
+143 -15
View File
@@ -1,34 +1,166 @@
/** /**
* State - 轻量级响应式状态管理 * 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 { class AppState {
constructor() { constructor() {
this._state = {}; this._state = {};
this._listeners = new Map(); // key → Set<callback> this._listeners = new Map();
this._batching = false;
this._batchQueue = null;
} }
/** /**
* 设置状态值并通知订阅者 * 设置状态值,使用结构化浅比较判断变更
* @param {string} key - 状态键名
* @param {*} value - 状态值
*/ */
set(key, value) { set(key, value) {
const old = this._state[key]; const old = this._state[key];
this._state[key] = value; this._state[key] = value;
if (old !== value && this._listeners.has(key)) {
for (const cb of this._listeners.get(key)) { if (!shallowEqual(old, value)) {
try { cb(value, old); } catch (e) { console.error(`[State] 监听器错误 (${key}):`, e); } 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) { get(key, defaultValue = undefined) {
return key in this._state ? this._state[key] : defaultValue; 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} 取消订阅函数 * @returns {function} 取消订阅函数
*/ */
on(key, callback) { on(key, callback) {
@@ -50,7 +180,6 @@ class AppState {
/** /**
* 批量设置初始状态 * 批量设置初始状态
* @param {Object} initial - 初始状态对象
*/ */
init(initial) { init(initial) {
Object.assign(this._state, initial); Object.assign(this._state, initial);
@@ -60,7 +189,6 @@ class AppState {
// 单例 // 单例
export const state = new AppState(); export const state = new AppState();
// ── 状态键名常量 ──
export const KEYS = { export const KEYS = {
DB: 'db', DB: 'db',
API: 'api', API: 'api',