1. 在项目中使用IndexDB缓存多类型数据(对话、题目、会话状态)时,你是如何设计对象存储空间(Object Store)的?为什么不采用单-store存储所有类型数据?请结合数据特点说明设计思路。
参考答案 :
设计思路是按数据类型拆分3个独立的Object Store ,分别为conversations
(对话)、questions
(题目)、sessionStates
(会话状态),核心设计逻辑如下:
-
主键策略差异化:
- 对话数据(
conversations
):需唯一标识单条对话,且需按时间排序,故主键设为复合结构{ sessionId: string, timestamp: number }
(会话ID+时间戳),避免同一会话内重复时间戳的冲突; - 题目数据(
questions
):题目ID全局唯一(如后端生成的UUID),故主键直接设为questionId: string
,便于快速查询单题详情; - 会话状态(
sessionStates
):一个会话对应一个状态,主键设为sessionId: string
,保证"会话-状态"的1:1映射。
- 对话数据(
-
索引需求不同:
conversations
需支持"按会话ID筛选所有对话",故基于sessionId
单独建立索引;questions
需支持"按题目类型(如单选/多选)筛选",故基于questionType
建立索引;sessionStates
仅需按sessionId
查询,无需额外索引。
不采用单-store的原因:
- 单-store存储会导致主键冲突(如对话的
timestamp
与题目的questionId
格式不同,无法共用主键); - 索引冗余:为满足多类型数据的查询需求,单-store需建立大量无关索引,降低读写性能;
- 数据隔离性差:增删改某类数据时(如删除过期对话),需额外过滤类型字段,易误操作其他数据,且事务无法保证"仅操作目标类型"的原子性。
2. 处理JSON序列化的深拷贝兼容问题时,你遇到了哪些具体场景(比如特殊数据类型、循环引用等)?分别采用了什么解决方案?如何验证序列化/反序列化后的完整性?
参考答案:
一、核心问题场景与解决方案
-
特殊数据类型丢失/变形:
- 场景1:Date对象 :JSON序列化会将
new Date()
转为"2024-05-20T12:00:00.000Z"
字符串,反序列化后无法恢复为Date类型;
方案 :自定义JSON.stringify
的replacer
函数,将Date对象标记为{ __type: 'Date', value: date.toISOString() }
;反序列化时通过reviver
函数识别__type: 'Date'
,调用new Date(value)
重建。 - 场景2:RegExp对象 :序列化后会丢失
flags
(如/abc/g
变为{}
);
方案 :replacer阶段记录source
(正则表达式体)和flags
(如g
/i
),格式为{ __type: 'RegExp', source: 'abc', flags: 'g' }
;reviver阶段通过new RegExp(source, flags)
重建。 - 场景3:BigInt精度丢失 :超过
2^53
的BigInt(如后端返回的19位ID)会被转为Number导致精度丢失;
方案 :replacer将BigInt转为{ __type: 'BigInt', value: bigInt.toString() }
,reviver阶段通过BigInt(value)
恢复。
- 场景1:Date对象 :JSON序列化会将
-
循环引用报错:
- 场景 :会话状态中可能包含"当前对话引用",导致
JSON.stringify
直接抛出TypeError: Converting circular structure to JSON
;
方案 :用WeakMap
记录已遍历的对象,replacer函数中检测到循环引用时,标记为{ __type: 'Circular', ref: 唯一标识 }
,反序列化时通过唯一标识重新建立引用;或使用成熟库(如lodash.clonedeep
)的循环引用处理逻辑,但需二次封装适配项目数据类型。
- 场景 :会话状态中可能包含"当前对话引用",导致
二、完整性验证方案
- 单元测试覆盖 :针对每种特殊类型(Date/RegExp/BigInt)和循环引用场景,编写"序列化-反序列化"对比用例,断言:
- 数据类型一致(如反序列化后
instanceof Date
为true
); - 核心属性一致(如RegExp的
source
和flags
、BigInt的数值); - 引用关系一致(如循环引用对象的内存地址指向正确)。
- 数据类型一致(如反序列化后
- 生产环境日志监控:在反序列化失败时,记录原始JSON字符串和错误栈,排查未覆盖的异常类型(如后端新增的特殊字段)。
3. IndexDB是异步操作,在缓存多类型数据时,如何保证"同时写入对话和会话状态"这类关联操作的一致性?如果中途失败(如浏览器崩溃),如何做数据恢复?
参考答案:
一、关联操作一致性保证:基于IndexDB事务(Transaction)
IndexDB的事务是"原子性"的核心,具体实现如下:
- 事务创建 :调用
db.transaction(['conversations', 'sessionStates'], 'readwrite')
,同时指定两个需要操作的store,模式为readwrite
(读写模式); - 批量操作 :在事务内执行"写入对话"和"更新会话状态"的操作(如
conversations.add(convData)
、sessionStates.put(stateData)
); - 结果监听 :
- 事务的
oncomplete
事件:所有操作成功时触发,确认数据一致性; - 事务的
onerror
事件:任意一步失败时触发,事务自动回滚(所有已执行的操作都会撤销,不会出现"对话写入成功、状态更新失败"的中间态)。
- 事务的
二、中途失败的数据恢复:版本控制+操作日志表
-
设计操作日志表 :新增
syncLogs
Object Store,记录每笔关联操作的元数据,格式为:javascript{ logId: string, // 唯一日志ID(UUID) operationType: 'createConvWithState', // 操作类型,标识关联操作 status: 'pending'|'completed'|'failed', // 操作状态 data: { convData, stateData }, // 操作的原始数据 timestamp: number // 操作时间 }
-
崩溃后恢复流程 :
- 页面重新初始化时,通过IndexDB的
versionchange
事件检测数据库连接,优先查询syncLogs
中status: 'pending'
的日志; - 对每笔pending日志,重新执行对应的关联操作(基于日志中的
data
),并更新日志状态为completed
; - 若重试失败(如数据格式已变更),将日志状态改为
failed
,并触发前端告警,由用户手动触发同步或后端介入。
- 页面重新初始化时,通过IndexDB的
4. 项目中"重构同步逻辑覆盖所有增删改场景",具体是如何设计同步策略的?比如本地修改与远程数据冲突时(如A在本地修改,B在远程删除同一条数据),采用什么冲突解决规则?
参考答案:
一、全场景同步策略:基于"版本号+操作标记"的增量同步
-
数据元信息设计:为所有本地数据(对话/题目/会话状态)增加3个核心字段:
version
: number // 数据版本号(初始为1,每次修改+1);lastModifiedTime
: number // 最后修改时间戳;operation
: 'none'|'create'|'update'|'delete' // 本地操作标记(默认none
,增删改后更新为对应类型)。
-
同步流程(本地→远程+远程→本地):
- 步骤1:本地增量上报 :
筛选本地operation !== 'none'
的数据,按类型批量上报给后端,携带version
和lastModifiedTime
;
后端验证版本号(如仅接受比远程当前版本高的数据),处理完成后返回"处理结果+远程最新版本号";
本地收到结果后,将对应数据的operation
改为none
,并更新version
为远程最新版本。 - 步骤2:远程增量拉取 :
本地携带"各类型数据的最大version
"请求后端,后端返回"版本号高于本地的数据"(包含增删改记录);
本地按operation
类型处理:create
→新增、update
→覆盖(基于version
)、delete
→标记本地数据为删除。
- 步骤1:本地增量上报 :
二、冲突解决规则:分层优先级+业务场景适配
针对"本地修改vs远程删除"等冲突,采用"业务优先级+时间戳兜底"的规则:
-
冲突场景1:本地修改 vs 远程删除:
- 若数据为"会话状态"(强关联当前用户):优先保留本地修改,同步时告知后端"恢复删除并应用本地修改",后端更新数据并记录冲突日志;
- 若数据为"公共题目"(多用户共享):优先遵循远程删除,本地标记数据为
isDeleted: true
,并提示用户"该题目已被管理员删除,本地修改未同步"。
-
冲突场景2:本地删除 vs 远程修改:
- 对比本地删除时间戳与远程修改时间戳:
- 若本地删除时间更新(后于远程修改):保留本地删除,同步时请求后端删除远程数据;
- 若远程修改时间更新:放弃本地删除,拉取远程最新数据并覆盖本地,提示用户"该数据已被更新,删除操作已取消"。
- 对比本地删除时间戳与远程修改时间戳:
-
冲突场景3:本地修改 vs 远程修改:
- 基于
version
号:仅接受版本号更高的修改(避免旧数据覆盖新数据); - 若版本号相同(并发修改):按
lastModifiedTime
兜底,保留时间更新的修改,并合并非冲突字段(如对话内容修改 vs 对话标签修改,合并两者字段)。
- 基于
5. 对于IndexDB中的"会话状态"这类高频读写的数据,你做了哪些性能优化?如何避免频繁读写导致的性能瓶颈?
参考答案 :
针对高频读写的"会话状态",核心优化思路是"减少IndexDB直接操作次数+优化读写链路",具体方案如下:
-
增加内存缓存层(Map暂存):
- 初始化时,将所有会话状态从IndexDB加载到内存
sessionStateCache: Map<string, StateData>
(key为sessionId
); - 高频读写操作(如切换会话、更新状态)优先操作内存Map,避免每次都调用IndexDB的
get
/put
; - 批量同步到IndexDB:设置"防抖同步"(如300ms防抖),或在"会话切换完成""页面隐藏"等时机,将内存中变更的状态批量写入IndexDB,减少IO次数。
- 初始化时,将所有会话状态从IndexDB加载到内存
-
优化IndexDB读写链路:
- 避免长事务:会话状态读写单独用短事务(仅操作
sessionStates
store),不与其他慢操作(如批量写入对话)共用事务,防止事务阻塞; - 禁用不必要的索引:
sessionStates
仅需按sessionId
查询,无需额外索引,减少写入时的索引更新开销; - 使用
put
而非delete+add
:更新状态时直接用put
(存在则更新,不存在则新增),避免先删除再新增的两次IO。
- 避免长事务:会话状态读写单独用短事务(仅操作
-
控制数据规模:
- 过期数据清理:定期(如每周)删除"30天未活跃会话"的状态数据,减少IndexDB存储量;
- 状态字段精简:会话状态仅保留核心字段(如
currentStep
、isActive
、lastAccessTime
),避免存储大体积数据(如完整对话列表),降低读写数据量。
-
监控与兜底:
- 监听IndexDB的
onblocked
事件:若其他页面占用数据库连接导致当前操作阻塞,降级为"直接操作内存+页面刷新后重试同步"; - 性能监控:通过
performance.mark
记录"内存缓存命中耗时""IndexDB同步耗时",若某环节耗时超阈值(如同步超500ms),触发告警优化。
- 监听IndexDB的
6. JSON序列化无法处理BigInt类型,但项目中如果有大数字ID(如超过2^53的ID),会导致精度丢失,你是如何解决这个问题的?
参考答案 :
针对BigInt精度丢失问题,项目采用"类型标记+自定义序列化/反序列化"方案,兼顾兼容性和易用性,具体实现如下:
一、核心解决方案:结构化标记转换
-
序列化阶段(JSON.stringify增强) :
自定义
stringifyWithBigInt
函数,通过replacer
识别BigInt类型,转为包含类型标记的对象:javascriptfunction stringifyWithBigInt(data) { return JSON.stringify(data, (key, value) => { if (typeof value === 'bigint') { // 标记BigInt类型,value转为字符串避免精度丢失 return { __type: 'BigInt', value: value.toString() }; } return value; }); }
示例:
12345678901234567890123n
会被序列化为{"__type":"BigInt","value":"12345678901234567890123"}
。 -
反序列化阶段(JSON.parse增强) :
自定义
parseWithBigInt
函数,通过reviver
识别标记对象,重建BigInt类型:javascriptfunction parseWithBigInt(jsonStr) { return JSON.parse(jsonStr, (key, value) => { if (value?.__type === 'BigInt' && typeof value.value === 'string') { return BigInt(value.value); } return value; }); }
示例:上述序列化结果反序列化后,会恢复为
12345678901234567890123n
。
二、方案优化与兼容处理
- 与后端协同 :约定后端返回的BigInt类型字段(如
userId
、conversationId
),在JSON中统一按"字符串+类型标记"格式返回,避免前端单独处理; - 边界情况适配 :
- 若BigInt嵌套在数组中(如
{ ids: [123n, 456n] }
),上述函数可递归处理,无需额外修改; - 若遇到未标记的Number类型大数字(如后端误传为Number),在反序列化后检测"数值是否超过2^53",若超过则提示后端修正数据格式;
- 若BigInt嵌套在数组中(如
- 性能优化 :对超大规模数据(如上万条含BigInt的列表),通过
JSONStream
等流式处理库,避免一次性序列化导致的内存占用过高。
7. 在覆盖"删除场景"的同步逻辑中,如何处理"本地删除后,远程又新增同ID数据"的情况?是否需要设计墓碑(Tombstone)机制?为什么?
参考答案:
一、核心处理方案:必须设计墓碑(Tombstone)机制
针对"本地删除+远程新增同ID"的冲突,直接物理删除本地数据会导致"远程新增数据无法同步到本地"(因为本地无该ID数据,同步时会误判为"新数据",但实际ID已存在),因此必须通过墓碑机制实现"逻辑删除+状态追踪"。
二、墓碑机制的具体设计
-
数据层面:增加逻辑删除标记 :
为所有可删除的数据(对话/题目/会话状态)增加
isDeleted: boolean
字段(默认false
),本地删除时不执行store.delete(id)
,而是执行store.put({ id, isDeleted: true, ...otherFields })
,保留数据的ID和元信息(如version
、lastModifiedTime
)。 -
同步层面:区分"逻辑删除"与"物理删除":
- 本地同步删除操作时,上报数据的
isDeleted: true
和version
,后端标记该ID数据为"逻辑删除",但不物理删除; - 远程新增同ID数据时,后端会检测到"该ID已存在逻辑删除记录",返回"冲突提示"和"远程新增数据的完整信息";
- 本地收到冲突后,按规则处理:
- 若本地删除时间戳 < 远程新增时间戳:说明远程新增是"后操作",本地将
isDeleted
改为false
,并覆盖为远程新增数据; - 若本地删除时间戳 > 远程新增时间戳:说明本地删除是"后操作",保留
isDeleted: true
,并请求后端删除远程新增数据。
- 若本地删除时间戳 < 远程新增时间戳:说明远程新增是"后操作",本地将
- 本地同步删除操作时,上报数据的
-
墓碑清理策略:
- 定期清理:每周执行一次"墓碑清理任务",筛选"
isDeleted: true
且lastModifiedTime
距当前超30天"的数据,执行store.delete(id)
物理删除,减少存储占用; - 同步确认后清理:若本地逻辑删除同步到后端,且后端返回"已确认删除",则立即物理删除本地墓碑(避免冗余)。
- 定期清理:每周执行一次"墓碑清理任务",筛选"
三、不设计墓碑机制的风险
- 数据一致性丢失:本地物理删除后,远程新增同ID数据同步到本地时,会被当作"全新数据"插入,导致同一ID存在两条数据(本地旧删除记录已消失,无法冲突检测);
- 同步链路断裂:后端无法区分"本地未同步过该ID"和"本地已删除该ID",可能重复推送已删除数据,导致前端数据冗余。
8. 对比LocalStorage,为什么选择IndexDB存储多类型数据?在实际使用中,IndexDB的哪些特性对你的场景至关重要?
参考答案 :
选择IndexDB而非LocalStorage的核心原因是LocalStorage的局限性无法满足"多类型数据+复杂操作"的需求,具体对比及IndexDB关键特性如下:
一、LocalStorage与IndexDB的核心差异对比
对比维度 | LocalStorage | IndexDB |
---|---|---|
存储容量 | 约5MB,容量固定且较小 | 无严格上限(依赖浏览器/设备,通常GB级) |
数据类型 | 仅支持字符串(需手动JSON序列化) | 支持复杂对象(Array/Object/Blob等) |
操作方式 | 同步操作(阻塞UI线程,高频读写卡顿) | 异步操作(非阻塞,适合大量数据) |
查询能力 | 仅支持"键-值"精准查询(无索引) | 支持索引、游标、范围查询(如按会话ID筛选) |
事务支持 | 无事务,无法保证多操作原子性 | 支持事务,保证关联操作一致性 |
二、IndexDB对项目场景至关重要的特性
-
大容量存储:项目需缓存"对话记录(可能上万条)+题目库(上千题)+会话状态",总数据量易超5MB,LocalStorage容量不足会导致数据丢失,IndexDB的GB级容量可满足需求。
-
复杂查询能力:
- 场景需求:需"按会话ID查询所有对话""按题目类型筛选题目",LocalStorage需遍历所有数据手动过滤(性能差),IndexDB可通过索引快速定位(如
conversations
基于sessionId
建索引,查询耗时从"秒级"降至"毫秒级")。
- 场景需求:需"按会话ID查询所有对话""按题目类型筛选题目",LocalStorage需遍历所有数据手动过滤(性能差),IndexDB可通过索引快速定位(如
-
异步非阻塞:会话状态是高频读写数据(如每切换一次会话就更新状态),LocalStorage的同步操作会阻塞UI线程(导致页面卡顿),IndexDB的异步操作可避免此问题,保证用户交互流畅。
-
事务与数据一致性:项目需"同时写入对话和更新会话状态",LocalStorage无事务支持,易出现"对话写入成功、状态更新失败"的中间态,IndexDB的事务可确保两者要么同时成功,要么同时回滚,避免数据不一致。
-
Blob类型支持:若后续扩展"缓存对话中的图片/文件",IndexDB可直接存储Blob类型,无需转为Base64字符串(LocalStorage需转码,增加数据体积和性能开销)。
9. 当数据量较大(如上万条对话记录)时,从IndexDB查询特定会话的历史数据,如何保证查询性能?你是如何设计索引的?
参考答案 :
针对"大数据量下查询特定会话的历史数据",核心优化思路是"精准索引设计+高效查询方式",具体方案如下:
一、索引设计:按"查询场景+数据特点"建立分层索引
-
核心索引:会话ID单字段索引(满足基础查询):
-
场景需求:"查询某会话(如
sessionId: 's123'
)的所有历史对话"; -
索引创建:在
conversations
store中,基于sessionId
字段建立单字段索引:javascript// 初始化Object Store时创建索引 const convStore = db.createObjectStore('conversations', { keyPath: ['sessionId', 'timestamp'] }); convStore.createIndex('idx_sessionId', 'sessionId'); // 索引名:idx_sessionId,索引字段:sessionId
-
查询方式:通过索引打开游标,直接筛选目标会话的对话:
javascriptconst transaction = db.transaction('conversations', 'readonly'); const convStore = transaction.objectStore('conversations'); const sessionIndex = convStore.index('idx_sessionId'); // 仅查询sessionId为's123'的对话 const cursorRequest = sessionIndex.openCursor(IDBKeyRange.only('s123'));
-
-
优化索引:会话ID+时间戳复合索引(满足排序+分页):
-
场景需求:"查询某会话的历史对话,并按时间戳倒序(最新对话在前)+分页加载(每次加载20条)";
-
复合索引创建:基于
[sessionId, timestamp]
建立复合索引(利用IndexDB的"复合索引按字段顺序排序"特性):javascriptconvStore.createIndex('idx_sessionId_timestamp', ['sessionId', 'timestamp'], { descending: true }); // descending: true:按时间戳倒序(最新对话在前)
-
分页查询实现:通过游标范围查询,指定"起始时间戳"和"数量限制",避免全量加载:
javascript// 加载sessionId='s123'、时间戳<1716000000000的前20条对话(分页第二页) const range = IDBKeyRange.bound( ['s123', 1716000000000], // 下界:会话ID+起始时间戳 ['s123', 0], // 上界:会话ID+最小时间戳 false, // 不包含下界 true // 包含上界 ); const cursorRequest = sessionIndex.openCursor(range, 'prev'); // 'prev':倒序 let count = 0; cursorRequest.onsuccess = (e) => { const cursor = e.target.result; if (cursor && count < 20) { pushToConversationList(cursor.value); // 加入结果列表 count++; cursor.continue(); // 继续下一条 } };
-
二、其他性能优化手段
-
避免全表扫描 :禁用"先获取所有对话再过滤"的方式(如
convStore.getAll()
),必须通过索引查询,减少IO次数(全表扫描需读取所有数据,索引查询仅读取目标会话数据)。 -
游标复用与批量处理 :使用
cursor.continue()
而非多次get()
,减少数据库连接开销;若需处理大量数据(如导出对话),通过cursor.advance(100)
批量读取(每次读100条),避免一次性加载导致内存溢出。 -
索引维护 :定期清理"过期对话"(如删除3个月前的记录),减少索引数据量;避免建立无关索引(如不为
conversationContent
(对话内容)建索引,因其不用于查询,仅增加写入时的索引更新开销)。 -
预加载与缓存:对"用户频繁访问的会话",将其历史对话缓存到内存Map中,后续查询优先读取内存(如用户连续查看某会话的对话,无需重复查询IndexDB),缓存失效时间设为1小时(平衡性能与数据新鲜度)。
10. 在测试同步逻辑时,你是如何模拟"网络中断""部分数据同步失败"等异常场景的?如何保证同步逻辑的健壮性?
参考答案:
一、异常场景模拟方案:分层模拟(网络层+数据层+业务层)
-
网络中断模拟:
-
工具层面:使用Chrome DevTools的
Network
面板,切换到"Offline"模式,模拟完全断网;或使用Throttling
功能(如"3G""高延迟"),模拟弱网环境下的同步超时。 -
代码层面:封装
fetch
/axios
请求拦截器,在测试环境中手动抛出NetworkError
,或延迟返回响应(模拟超时):javascript// 测试环境请求拦截器 if (process.env.NODE_ENV === 'test') { axios.interceptors.request.use(config => { if (config.url.includes('/sync/data')) { // 模拟10%概率网络中断 if (Math.random() < 0.1) { throw new Error('Network Error: Failed to connect'); } // 模拟500ms延迟(弱网) return new Promise(resolve => setTimeout(() => resolve(config), 500)); } return config; }); }
-
-
部分数据同步失败模拟:
- 后端配合:在测试环境的同步接口中,增加"随机失败"逻辑(如对10%的同步数据返回
{ code: 500, msg: '部分数据处理失败' }
),并携带"失败数据ID列表"。 - 前端构造:手动构造"格式错误的数据"(如缺失
version
字段的对话),调用同步接口,模拟后端因数据非法导致的部分失败。
- 后端配合:在测试环境的同步接口中,增加"随机失败"逻辑(如对10%的同步数据返回
-
并发冲突模拟:
- 双页面测试:打开两个浏览器标签页,同时对同一条对话进行修改,然后分别触发同步,模拟"本地A修改+本地B修改"的并发冲突;
- 时间戳篡改:在测试代码中手动修改数据的
lastModifiedTime
(如将本地时间戳改为1小时前),模拟"本地旧数据同步到远程新数据"的冲突。
二、保证同步逻辑健壮性的核心手段
-
操作幂等设计:
- 为每笔同步操作生成唯一
operationId
(UUID),后端基于operationId
去重(即使前端重试,也不会重复处理); - 同步接口支持"重复调用"(如本地重试时,后端若已处理过该
operationId
,直接返回成功结果,不重复执行逻辑)。
- 为每笔同步操作生成唯一
-
失败重试与退避策略:
- 基础重试:网络中断或部分失败时,触发"立即重试1次"(解决临时网络波动);
- 退避重试:连续失败时,采用"指数退避"策略(如第1次重试间隔1s,第2次2s,第3次4s,最多重试5次),避免频繁请求压垮后端。
-
同步状态可视化与告警:
- 前端增加"同步状态指示器"(如右上角小图标:绿色=同步完成,黄色=同步中,红色=同步失败),让用户感知状态;
- 同步失败(重试5次仍失败)时,触发前端告警(如弹窗提示"同步失败,请检查网络或联系客服"),并记录详细日志(失败
operationId
、数据内容、错误栈),便于排查问题。
-
数据备份与回滚:
- 同步前备份本地数据(如将待同步的数据复制到
backup
store),若同步失败且无法重试,可从备份恢复本地数据,避免同步过程中数据损坏; - 远程同步到本地时,先将数据写入"临时store",验证数据完整性(如字段校验、版本号检查)后,再覆盖正式store,避免脏数据写入。
- 同步前备份本地数据(如将待同步的数据复制到
-
单元测试与E2E测试覆盖:
- 单元测试:针对"冲突解决逻辑""序列化/反序列化"编写测试用例(如模拟10种冲突场景,断言处理结果符合预期);
- E2E测试:使用Cypress/Puppeteer模拟"大数据量同步""网络中断恢复"等场景,验证端到端流程的正确性(如上万条对话同步后,查询性能仍达标)。