本篇要回答的问题:在正式连服务端之前,先让画图程序支持浏览器端持久化(localStorage)——为了离线可编辑、联网自动同步,DOM 树在内存与在 localStorage 中该怎样分层存储、怎样设计对象 ID 和数据变更粒度?
前两讲(实战一、二)我们让画图程序具备了创建/选择/改样式/删除/移动等基本可用功能,但仍是纯单机内存版。本讲是连接服务端的前半步:先在浏览器端做持久化,以获得更好的离线体验。技术选型上不用 PWA,而用兼容性更好的 localStorage。
关键判断:技术选型首先要考虑兼容性。虽然 PWA 很关注离线体验,但本讲基于更传统、兼容性更好的 localStorage 来实现离线。
对象 ID
为支持持久化,给根 QPaintDoc 引入两个 ID,每个 Shape 也引入 ID:
| ID | 含义 | 是否会变 |
|---|---|---|
| displayID | 用户可见 ID(如 URL #t10001);前缀 t 表示从未与服务器同步过的临时文档 |
会变:首次同步到服务端后改用服务端返回的 ID |
| localID | 本地 ID(如 10001);QPaintDoc.init 后固定,同一浏览器下唯一 | 不变 |
关键洞见:为什么要两个 ID?因为 localStorage 分层存储——QPaintDoc 的 shapes 数组只存 shapeID,每个 shape 单独存
shapeID => shapeJsonData(实际 key 是localID + ":" + shape.id,保证全局唯一)。若只有一个会变的 ID,那么 ID 一变,这篇文档在 localStorage 里所有图形对象的 key 都得跟着改。引入不变的 localID 就把存储 key 钉死了。
所以首次访问 localhost:8888/ 自动跳 #t10001,第二次跳 #t10002——同一浏览器下不会有两个相同的 localID。
数据变更
数据变更分两级,对应两级存储更新:
| 级别 | 触发场景 | 变更后动作 |
|---|---|---|
| shapeChanged | addShape / setProp(改样式)/ move(移动) | 更新 shapeID => shapeJsonData |
| documentChanged | addShape(图形数 +1)/ deleteShape(图形数 -1)/ 未来改 Z-Order(shapes 数组内容变) | 更新 localID => documentJsonData |
注意 addShape 同时触发两级(既改了 shape,又改了文档图形数量)。
关键洞见:数据变更不只来自本地交互。多人协同编辑场景下变更也来自其他浏览器端,链路是:
Client B 操作 → B 的 DOM 变更 → 服务端数据变更 → A 收到变更 → A 的 DOM 变更 → A 的 View 更新。
关键判断:26、27 讲里我们图省事,是 Controller 改完数据后自己调
qview.invalidateRect通知重绘——这并不符合标准 MVC。标准 MVC 中界面更新应由 Model 层的 DataChanged 事件触发,而非 Controller 触发。本讲引入两级数据变更事件,正是向标准 MVC 回归。
存储的容量限制与安全
localStorage 容量有限(多数浏览器 5~10M),且同一浏览器下多个 QPaintDoc 共享,占用会越来越大:
| 问题 | 对策 |
|---|---|
| 容量超限 | 用 localStorage_setItem 统一接管 setItem,捕获 QuotaExceededError 时淘汰最早创建的一篇文档;只要及时联网同步,数据就不丢 |
| 隐私安全 | localStorage 数据可被查看;多租户登录登出时不同用户文档同存一处,登出后他人仍可见 → 最简单方案是用户登出时清空所有 localStorage 文档 |
总结
本讲为画图程序加上离线持久化:核心是 DOM 树在内存与在 localStorage 中的差异——为避免每次整篇文档重存,存储分 shape / document 两级,数据变更事件相应分 shapeChanged / documentChanged 两级;用 localID(不变)与 displayID(可变)解决"ID 变化导致所有存储 key 连带改"的问题;并处理了容量淘汰与登出隐私。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| displayID | 用户可见、会变的文档 ID;t 前缀表临时未同步 |
| localID | 本地不变的文档 ID,钉死 localStorage 的存储 key |
| 分层存储 | shapes 数组只存 shapeID,shape 单独存,key 为 localID:shape.id |
| shapeChanged / documentChanged | 两级数据变更,分别更新 shape 与 document 存储 |
| 容量淘汰 | QuotaExceededError 时淘汰最早创建的文档 |
一句话速记
离线持久化的关键是分层存储 + 两级变更事件:shapes 数组只存 ID、shape 单独存,用不变的 localID 钉死存储 key(displayID 可变),变更分 shapeChanged/documentChanged;并向"由 DataChanged 触发重绘"的标准 MVC 回归。
几条值得记住的判断
- 技术选型先看兼容性:离线用 localStorage 而非 PWA。
- 用不变的 localID 隔离"可变的 displayID",避免 ID 变化引发连锁的存储 key 修改。
- 引入两级 DataChanged 事件,是从"Controller 主动重绘"回归标准 MVC,也为多人协同打底。
思考题
文中用"不变的 localID + 可变的 displayID"把存储 key 与展示 ID 解耦。回到你的系统:你有没有把一个"会变的对外 ID"直接当成了内部主键/存储 key?一旦它需要变更(如对外编号规则调整),会引发多大范围的连锁修改?

