先纠正一个预期:28 讲不是讲服务端。
它讲的是浏览器端的持久化(localStorage)——目的是离线也能用。服务端要到 29 / 30 讲。原文的话:“在用户断网的情况下,这个画图程序还可以正常编辑,
并且在恢复联网的情况下,需要能够把所有离线编辑的内容自动同步到服务端。”
这一讲的代码:
| 你要的东西 | 路径 |
|---|---|
| 28 讲这一版的完整代码 | 代码/qpaint源码/v28/ |
| 27→28 到底改了什么 | diff-dom-v27-v28.txt · diff-view-v27-v28.txt |
| 每个文件的结构地图(标行号) | 带读-源码导航 |
| 本篇配套的可跑演示 | 代码/两个ID.py |
第一部分 · 先建立心智模型
1.1 先说清楚:qpaint 其实是两个程序
这一讲开始会反复出现"服务端"。它不是抽象概念,就是另一台电脑上跑着的另一个程序——
而且作者也写了它,就在同一个仓库里。
你的电脑 服务器(一台一直开着的电脑)
┌──────────────────────┐ ┌─────────────────────────────┐
│ 浏览器 │ │ 一个 Go 程序,一直在跑 │
│ 跑着 dom.js │ │ (paintweb/main.go) │
│ view.js │ ←─── 网络 ───→ │ │
│ rect.js … │ │ 管两件事: │
│ │ │ ① 发文件(index.htm、js) │
│ localStorage │ │ ② 存数据(/api/…) │
│ (你自己的硬盘) │ │ │
└──────────────────────┘ │ MongoDB 数据库(它的硬盘) │
「客户端」 └─────────────────────────────┘
「服务端」
一个容易忽略的事——paintweb/main.go 里这一行:
http.HandleFunc("/", handleDefault) // 你访问 / 就把 www/index.htm 发给你
连
index.htm和那堆.js文件本身,都是服务端发给浏览器的。
所以服务端从第一秒就在参与——只是 26~28 讲里它只干了"把文件发给你"这一件事,没管数据。
那"推到服务端"是什么意思
就是浏览器往那台电脑发一段文本,那台电脑回一段文本。 没有更神秘的。
发过去的: 回来的:
POST /api/drawings HTTP/1.1 HTTP/1.1 200 OK
Host: localhost:8888 Content-Type: application/json
Authorization: QPaintStub 1
{"id":"507f1f77bcf86cd799439011"}
Python 里你可能见过一模一样的事:
import requests
r = requests.post("http://localhost:8888/api/drawings")
print(r.json()) # {"id": "507f1f77bcf86cd799439011"}
代码里那句 callAsync("POST", "/api/drawings", ...) 干的就是这个。
⚠️ 而且第一次那个"推",一个图形都没发上去。 它只是说一句
“帮我建一篇新文档,给我个编号”——服务端在数据库里插一条空记录,把编号返回来:func (p *Document) Add(uid UserID) (...) { id := bson.NewObjectId() drawingColl.Insert(M{"_id": id, "uid": uid, "shapes": []ShapeID{}}) // ╰─ 空的!一个图形都没有 }更准确的说法是"去登记个户口,领个身份证号",不是"把画传上去"。
真正把图形推上去(POST …/shapes、POST …/sync)是 29/30 讲的事。
1.2 这一讲的数字:Controller 层零改动
延续 27 讲的看法——盯着"什么没被迫改"。我把 v27、v28 两个分支 diff 过:
| 文件 | v27 → v28 | |
|---|---|---|
dom.js(Model) |
306 → 497(+191) | 又是它扛了全部重量 |
view.js(ViewModel) |
125 → 131(+6) | 监听 URL 变化 + 启动时调 doc.init() |
| 五个 Controller 文件 | 完全没变,一个字都没改 | |
index.htm |
只把 dom.js 改成 dom.js?v=1(刷缓存) |
不算实质变化 |
"给整个程序加上离线持久化"这么大一个功能,Controller 层一个字都没改。
比 27 讲更强——27 讲三个 Creator 还各加了一行,28 讲是零。为什么能零改动? 因为"存盘"纯粹是文档自己的事,跟"用户能干什么"毫无关系。
用户还是那样画、那样选、那样删——他不需要知道背后多了一套存储。
1.3 两个预备概念
localStorage:浏览器给每个网站的一块「永远存在硬盘上的字典」
# Python 类比:想象一个 dict,但它存在硬盘上,关机重启都还在
d = {}
d["dg:10001"] = '{"id":"10001","shapes":["1","2"]}' # ← 【只能存字符串】
d.get("dg:10001")
API 就四个:setItem / getItem / removeItem / clear。
为什么 qpaint 需要它:26/27 讲里画的东西存在 doc._shapes 这个内存里的数组,
刷新页面 = 内存清空 = 全没了。要"关掉再打开还在",就得存到一个活得比页面久的地方。
| 存哪 | 活多久 | 能装多少 | qpaint |
|---|---|---|---|
| 内存(JS 变量) | 刷新就没 | 随便 | 26/27 讲 |
| localStorage | 永久 | 5~10M | ✅ 28 讲 |
| Cookie | 久,但每次请求都带上 | 4K,太小 | ✗ |
| 服务端 | 永久,还能跨设备 | 随便 | 29/30 讲 |
为什么不直接存服务端?——为了断网也能用。没网时服务端够不着,localStorage 还在。
两个性质,解释了后面两段话:
| 性质 | 后果 |
|---|---|
按「源」隔离:localhost:8888 存的,example.com 看不见 |
同一网站下的多个用户帐号共用一份→ 你登出后别人还能看到你的文档(第五部分 · 安全) |
同步阻塞:setItem 当场写盘,写完才返回 |
每次改颜色都全量重写 500 个图形 → 拖动时每秒几十次,立刻卡死(第三部分 · 分层) |
序列化:localStorage 只能存字符串
import json
文本 = json.dumps({"x": 10, "y": 20}) # 对象 → 文本 (序列化)
对象 = json.loads(文本) # 文本 → 对象 (反序列化)
JS 里是 JSON.stringify / JSON.parse,一模一样。但有个关键限制:
内存里的 QRect: { x, y, width, height, style } + bound() hitTest() move() onpaint()
╰────── 数据 ──────╯ ╰────────── 行为 ──────────╯
↓ JSON.stringify
存进去的: {"x":10,"y":10,"width":50,"height":40,"style":{...}}
╰─ 只剩数据,方法全没了 ─╯
JSON 只能存数据,存不了行为。 所以读回来必须重新
new一个对象。
而"该 new 哪个类",得自己额外存一个type字段——v28/dom.js:128的loadShape就在干这事:
switch (o.type) { // ← 靠这个字段决定造哪个类
case "QLine": return new QLine(o.pt1, o.pt2, style)
case "QRect": return new QRect(o, style)
case "QEllipse": return new QEllipse(o.x, o.y, o.radiusX, o.radiusY, style)
case "QPath": return new QPath(o.points, o.close, style)
}
注意这里出现了一个
switch。 带读-01 说"办法 A 能消掉if/elif"——但那是画的时候。
"从一坨 JSON 造回对象"这件事天生消不掉:你手上只有数据,它自己不会说该造什么。
多态能让"已经是对象的东西自己干活",但"从零造出对象"必须有一处集中的分派。
1.4 从需求推:程序必须回答哪些新问题
新需求:关掉浏览器再打开,画的东西还在;断网也能继续编辑。
按 27 讲学的方法问自己:程序必须能回答哪些它现在答不上来的问题?
| 要做的事 | 得回答 | 26/27 版能答吗 |
|---|---|---|
| 把文档存进 localStorage | 它存在哪个 key 下? | ✗ 文档没有名字 |
| 只改一个图形时只重写它 | 这个图形存在哪个 key 下? | ✗ 图形没有名字 |
| 什么时候写盘 | 数据什么时候变了?变的是哪一级? | ✗ 没有变更通知 |
| localStorage 满了 | 淘汰谁? | ✗ |
前两条就是本讲标题:对象 ID。 带读-01 说 Model 三要素还缺"有身份",在这里兑现。
为什么单机版不需要 ID? 因为你想改哪个图形,指针指着它就行。
一旦要把它写进 localStorage(或发给服务端),你就得在那头说出它的名字。
指针只在内存里有效,出了这个进程就得靠名字。
第二部分 · 对象 ID
2.1 三个名字,各归各位
最容易混的地方,先摆清楚——这是三样不同的东西:
displayID "507f1f77" ← 一个【名字】(服务端认的、URL 里那串)
│
│ local:507f1f77 → 10001 ←【这一条才是映射/桥】(localStorage 里的一条记录)
▼
localID "10001" ← 另一个【名字】(本机认的)
│
├── dg:10001
├── 10001:1
└── 10001:2 ← 本地所有数据都挂在这个名字下
localID |
displayID |
|
|---|---|---|
| 值 | 10001 |
同步前 t10001 → 同步后 507f1f77 |
| 谁发的 | 这台浏览器 | 同步前自己编,同步后服务端发 |
| 在哪唯一 | 本机唯一 | 全世界唯一 |
| 用来干什么 | 拼 localStorage 的 key | 放 URL 里、发给别人、给服务端认 |
| 会变吗 | 永不变 | 会变(转正那一刻) |
| 类比 | 本单位工号——换个单位就变 | 身份证号——走到哪都是它 |
displayID本身是一个值,不是一个对应关系。 把两个名字连起来的那条
local:xxx → yyy记录,才是映射。
2.2 dg:10001 这种 key 是什么
"dg:10001" → {"id":"10001","shapeBase":3,"shapes":["1","2","3"]}
╰┬╯ ╰─┬─╯
│ └── localID
└── 前缀:表示"这是一篇文档"(drawing)
为什么要前缀? 因为 localStorage 是一个大平铺的字典,所有东西混在一起,只能靠前缀分类:
| key | 是什么 |
|---|---|
dgBase |
全局计数器:本机的文档号发到几了 |
dg:10001 |
一篇文档的"目录" |
10001:1 |
一个图形的内容(localID : 图形号) |
local:507f1f77 |
桥(正式 ID → 本地 ID) |
在扁平的 key-value 存储里"分表",办法就是给 key 加前缀。 Redis 也是这么干的。
2.3 服务端为什么非要另发一个号
根本原因:10001 只在你这台浏览器上唯一。 看它怎么发出来的:
function _getNextID(key) {
let base = localStorage.getItem(key) // ← 从【本机的】localStorage 读
base = (base == null) ? 10000 : parseInt(base)
return (base + 1).toString()
}
每台浏览器都从 10000 开始数:
你的浏览器 我的浏览器 他的浏览器
10001 10001 10001 ← 各自本机唯一,互相根本不知道
│ │ │
└──────────────┴──────────────┘
▼
服务端:三篇完全不同的文档,都叫 "10001" ✗ 撞死
只有服务端看得到全局,所以只有它能发全局唯一的号(paintdom/drawing.go):
func (p *Document) Add(uid UserID) (drawing *Drawing, err error) {
id := bson.NewObjectId() // ← 时间戳+机器+进程+计数器,全世界不重复
drawingColl.Insert(M{"_id": id, "uid": uid, "shapes": []ShapeID{}})
}
另外两个理由:② 它要当分享链接(/api/drawings/507f1f77…,同事打开就是你那篇);
③ 不能被猜到(ID 要是顺着排,改一下地址栏数字就能翻别人的文档)。
这跟 localID/displayID 是同一个道理,只是升了一级
| 谁的 ID | 谁发的 | 在哪唯一 | 怎么发的 |
|---|---|---|---|
图形的 id(1,2,3…) |
文档自己发 | 一篇文档内唯一 | _idShapeBase++ |
localID(10001) |
这台浏览器发 | 一台浏览器内唯一 | dgBase++ |
| 服务端 ID | 服务端发 | 全世界唯一 | bson.NewObjectId() |
每一级只保证自己那一级唯一,因为它只看得见自己那一级。
谁看得见更大的范围,谁才能发更大范围唯一的号。
2.4 displayID 就是「URL 里那串东西」
你打开程序 → URL: …/index.htm#t10001
╰──────╯
displayID
为什么 URL 里不能直接放 localID?因为 URL 是要发给别人的:
你把 #10001 发给同事
→ 他打开,程序读到 10001
→ 去【他自己】的 localStorage 找 dg:10001
→ 找到的是【他机器上的第一篇文档】—— 完全另一张图!
10001 只在你这台机器上有意义。它是门牌号,不是地址。
那同步前那个 t10001 是干嘛的
① URL 里总得有东西——不然刷新页面,程序不知道刚才画的是哪一篇。
还没联网时服务端没给名字,只能自己先编一个。
② 那个 t 是给程序看的路标:
function _isTempDoc(displayID) {
return displayID.charAt(0) == 't' // ← 就看第一个字符
}
if (_isTempDoc(displayID)) {
this._loadTempDoc(displayID) // 带 t:本地读 + 顺便推到服务端去转正
} else {
this._loadRemote(displayID) // 不带 t:正式 ID,去服务端拉
}
它不只是个装饰,是「离线队列」的最小实现——
标记"这篇还欠服务端一次注册",下次打开时补上。
2.5 完整链路:一篇文档从出生到被分享
| 阶段 | 发生了什么 | displayID |
localID |
桥 local:507f1f77 |
|---|---|---|---|---|
| 1 · 出生 | 第一次打开,URL 没 hash → _loadBlank() |
t10001 |
10001 |
— |
| 2 · 画图 | 每 addShape 写两条 key |
t10001 |
10001 |
— |
| 3 · 转正 | 服务端回话 → _newDoc() 的回调 |
507f1f77 |
10001 |
架桥 → 10001 |
| 4 · 刷新 | 打开 #507f1f77 → _loadRemote() |
507f1f77 |
10001 |
查它找回 10001 |
| 5 · 同事打开 | 他机器上查不到桥 → 发自己的号 | 507f1f77 |
10007 |
→ 10007(他的) |
| 6 · 断网 | 查桥 → 读本地 → 画出来 | 507f1f77 |
10001 |
查它照样读本地 |
逐阶段展开:
阶段 1 · 出生
URL: /index.htm(没有 #)
↓ init() 看到 hash == "" → _loadBlank()
localID = _makeLocalDrawingID() → "10001"
displayID = "t" + localID → "t10001"
URL 改成 #t10001
_newDoc() → 发 POST 去服务端(异步,不等它)
阶段 2 · 画三个图形
每 addShape 一次:
_initShape(shape) → 发文档内编号:1、2、3
shapeChanged(shape) → 写 "10001:1"
documentChanged(doc) → 写 "dg:10001"
localStorage:dgBase / dg:10001 / 10001:1 / 10001:2 / 10001:3
╰──── ★ 全部 key 都是拿 localID 拼的 ────╯
阶段 3 · 转正(POST 有结果了,回调执行)
① doc.displayID = "507f1f77" ← 换名字
② 存 local:507f1f77 → 10001 ← 架桥
③ URL 改成 #507f1f77
★ 原来那四条 localStorage 记录【一个字没动】,只多了一条桥
阶段 4 · 刷新页面
URL: #507f1f77
↓ _isTempDoc → false → _loadRemote("507f1f77")
查桥:local:507f1f77 → "10001" ★ 找到了
_load("10001") → 读 dg:10001 → loadShape 逐个造回对象
【画面已经出来了】 ← 本地缓存,秒开
再发 GET 去拉最新的(异步)
★ 先用本地缓存显示,再去服务端拉——这是"离线优先",不让用户等网络
阶段 5 · 分享给同事(他打开 #507f1f77)
查桥:local:507f1f77 → null ★ 他机器上没这条
localID = _makeLocalDrawingID() → "10007" ← 发【他自己的】号
GET /api/drawings/507f1f77 → 拿到你那篇图
写进【他的】localStorage:dg:10007、10007:1…
存桥:local:507f1f77 → 10007
阶段 6 · 断网打开
查桥 → 10001 → _load("10001") → 从本地读出来,画出来 ✓ 能看能编
GET 失败(没网)→ 拉不到最新的,但不影响已显示的
整条链只有一件事在变:
displayID在阶段 3 从临时名换成正式名。
localID从出生到死都是10001——所以本地那几百条数据一次都不用改名。
动手看一遍:代码/两个ID.py(纯 Python,拿一个 dict 假装 localStorage)。
python3 两个ID.py 会把「只有一个 ID」和「两个 ID」两种做法各演一遍——
前者要给每条记录改名(3 个图形就 8 次读写,500 个就 1002 次),后者只多写一条映射。
2.6 通用模式:对内的名字 vs 对外的名字
只要一个标识符「可能被外部世界改掉」,就不要拿它当内部引用的 key。
| 对内的名字 | 对外的名字 | |
|---|---|---|
| 谁定的 | 自己定,只要本机/本库唯一 | 要跟外部协商,可能被外部改 |
| 会变吗 | 永不变 | 会变 |
| 用来干什么 | 内部一切引用、存储的 key | 展示、对外交换 |
你到处会碰到这一对:
| 场景 | 对内 | 对外 |
|---|---|---|
| 数据库 | 自增主键 id |
业务单号、用户名 |
| 文件系统 | inode 号 | 文件名(可以随便改名) |
| Git | commit 的 SHA | 分支名、tag(可以移动) |
| 本讲 | localID |
displayID |
2.7 那数据本身是怎么走的?
这个问题很自然,但答案属于 29 / 30 讲——v28 里还没有任何数据上下行的代码。
一句话预告:图形内容和图层顺序要上传;dgBase、localID、桥这三样永远不出这台机器。
判据是:换台设备还需要的就上传,只对这台机器有意义的就不传。
完整的数据流(含"什么时候第一次上传"、“之后怎么只发变更”、“断网了怎么攒着重试”),
见 带读-06 · 五。
第三部分 · 分层存储与变更分级
3.1 朴素做法的问题
localStorage["dg:10001"] = JSON.stringify(整篇文档) // 一坨全存
你只是把一个矩形从黑改成红——却要把整篇 500 个图形全序列化一遍再写盘。
每拖一次、每改一次颜色都这样,加上 localStorage 是同步阻塞的,很快就卡了。
3.2 v28 的做法:拆成两层
"dg:10001" → { id:"10001", shapeBase:3, shapes:["1","2","3"] }
╰──── 只存 ID 列表 ────╯
"10001:1" → { type:"QRect", x:10, y:10, width:50, height:40, style:{...} }
"10001:2" → { type:"QLine", pt1:{...}, pt2:{...}, style:{...} }
"10001:3" → { type:"QEllipse", ... }
dg:10001 → 目录:我有 1、2、3 号图形
│
┌─────────┼─────────┐
▼ ▼ ▼
10001:1 10001:2 10001:3 ← 正文:内容各存各的
“把一个矩形改成红色” = 只重写 10001:2 这一条,其余一个字节都不用动。
3.3 存储怎么分层,变更就怎么分级
因为存储分了两层,"数据变了"也必须分成两种——不然你不知道该重写哪一条:
| 用户动作 | 发什么 | 重写哪条 | 为什么 |
|---|---|---|---|
| addShape | 两个都发 | 图形那条 + 文档那条 | 图形是新的,文档的 ID 列表也变长了 |
| setProp 改样式 | shapeChanged |
只写图形那条 | 文档的 ID 列表没变 |
| move 移动 | shapeChanged |
只写图形那条 | 同上 |
| deleteShape | documentChanged |
只写文档那条 | 图形从 ID 列表里没了 |
原文还预告了一种:将来支持调整图层顺序(Z-Order)时,图形数量没变,
但 shapes 数组的内容变了 → 也要发 documentChanged。
记住这个因果:存储怎么分层,变更就怎么分级。
变更事件的粒度是被存储粒度决定的,不是拍脑袋定的。
3.4 代码上落地得非常小
每个图形类只加了三行:
class QLine {
constructor(point1, point2, style) {
...
this.id = "" // ← 新增①
}
move(dx, dy) {
this.pt1.x += dx; ...
shapeChanged(this) // ← 新增②
}
setProp(key, val) {
this.style.setProp(key, val)
shapeChanged(this) // ← 新增③
}
}
addShape(shape) {
this._shapes.push(this._initShape(shape)) // 发一个 ID 给它
shapeChanged(shape) // 存图形
documentChanged(this) // 存文档
}
deleteShape(shape) {
deleteItem(this._shapes, shape)
documentChanged(this) // 只存文档
}
一处小泄漏:
deleteShape没把那个图形从 localStorage 删掉——
"10001:2"那条还留着,只是文档的 ID 列表不再引用它。
要等整篇文档被淘汰时(removeSomeCache)才一起清掉。
第四部分 · 作者的一次自我批评
原文里有一段很重要,容易读过去:
“在前面 26 讲、27 讲中,我们并没有引入数据变更事件,而是 Controller 变更完数据后,
就自己主动调用qview.invalidateRect来通知 View 层重新绘制。这样做比较简单,
虽然它并不符合标准的 MVC 架构。因为从 MVC 架构来说,界面更新并不是由 Controller 触发,
而应该由 Model 层的数据变更(DataChanged)事件触发。”
4.1 先把那张协同图翻译成人话
原文给的场景图是这样的:
Client B 操作 → B 的 DOM 变更 → 服务端数据变更 → Client A 收到数据变更
→ A 的 DOM 变更 → A 的 View 更新
⚠️ 这里的 “DOM” 是
dom.js那棵树,也就是 Model——不是浏览器的document(2.1 讲过这个撞名)。
换成人话:
同事画了一个矩形
→ 同事那边的 Model 变了
→ 推到服务端
→ 你这边收到一条消息
→ 你这边的 Model 变了
→ 你的屏幕要把那个矩形画出来
场景:你和同事同时开着 #507f1f77 这张图(像腾讯文档、Figma 那样)。他画一笔,你屏幕上也该冒出来。
4.2 关键问题:你屏幕上那次重画,是谁触发的?
回顾 26/27 讲,重画是怎么触发的:
// creator/rect.js
onmouseup(e) {
this.view.doc.addShape(this.buildShape()) // ① 改 Model
this.reset() // ② reset 里调 view.invalidateRect()
} // ↑【Controller 顺手叫 View 重画】
完整的链是:
你的鼠标 → 你的 Controller → 改 Model → 【同一个 Controller】顺手叫 View 重画
↑
这条链必然经过"你的 Controller"
现在看协同场景:
同事的鼠标 → 同事的 Controller → 服务端
↓
你这边收到一条网络消息
↓
你的 Model 变了
↓
你的 View 要重画
↑
❓ 谁来叫它重画?
你这边根本没有 Controller 被触发过。
你的鼠标没动、键盘没按,View 的五个事件盒子一个都没被调用——你在喝咖啡。
数据是从网络回调里进来的:
socket.onmessage = function(msg) {
doc.applyChange(msg) // 改了 Model
??? // 现在谁来叫 View 重画?
}
4.3 两种出路
出路一 · 网络回调自己叫一次
socket.onmessage = function(msg) {
doc.applyChange(msg)
qview.invalidateRect(null) // ← 网络层直接叫 View
}
能跑。但问题是:现在"改完数据要重画"这件事,有两个地方在做了。
将来再来第三个数据源呢?撤销/重做、定时自动整理、导入文件……
每来一个新的"数据修改者",都得记得叫一次重画。忘了就是 bug——
而且是那种"数据对了但界面不刷新"的诡异 bug。
出路二 · Model 自己发事件(标准 MVC)
// Model 里
addShape(shape) {
this._shapes.push(shape)
this.fireDataChanged() // ← 我变了,谁关心谁听
}
// View 启动时挂上去(一次)
doc.onDataChanged = function() {
qview.invalidateRect(null)
}
现在不管数据是被谁改的——你的 Controller、网络回调、撤销栈、定时任务——View 都会更新。
4.4 一个比喻:水箱上的浮子
想象一个显示水箱水位的仪表盘:
| 做法 | 一个人加水时 | 多了一根自动进水管之后 |
|---|---|---|
| 每个加水的人,加完记得去拨一下仪表 | 没问题 | 仪表不准了——管子不会去拨仪表 |
| 在水箱上装个浮子,水位一变仪表自己动 | 没问题 | 照样准 |
DataChanged 就是那个浮子。
4.5 对照表
| 26/27 讲的做法 | 标准 MVC | |
|---|---|---|
| 谁叫 View 重画 | 改数据的那个人(Controller) | Model 自己(发事件) |
| 数据源只有一个(本地鼠标) | 好用,而且更简单 | 也好用 |
| 数据源有多个(+网络、+撤销栈…) | 每个数据源都要记得叫一次 | 一处都不用改 |
深一层的道理:「谁改了数据」和「界面要更新」是两件事。
把它们绑在一起(Controller 改完顺手叫),等于假设了"数据只会被 Controller 改"。
这个假设在单机版成立,一联网就不成立了。
这正好呼应 27 讲讲 onControllerReset 时那句:
事件是唯一一种「我通知你,但我不认识你」的通信方式。
Model 发 DataChanged 时,它不知道听众是谁,也不需要知道数据是被谁改的。
这就是 22 讲那条"Model 层要发 DataChanged 事件"的真正理由。
22 讲给的理由是"独立性",比较抽象;28 讲给出了一个具体到无法回避的场景。
26/27 讲能偷这个懒,是因为单机;一旦联网就偷不了。
一个诚实的补充:我查了 v28 的代码——
DataChanged事件还没实现。
shapeChanged/documentChanged目前只用来写 localStorage,没有通知任何人。
原文这一段是预告,是给 29/30 讲埋的伏笔。
第五部分 · 工程细节
5.1 容量:localStorage 会满
大部分浏览器 5~10M,同一浏览器下多篇文档共用。作者的做法是把所有写入收进一个口子:
function localStorage_setItem(key, val) { // ← 全程序只走这一个函数写盘
try {
localStorage.setItem(key, val)
} catch (e) {
if (e.name == 'QuotaExceededError') { // 满了
removeSomeCache() // 淘汰最早创建的一篇文档
localStorage.setItem(key, val) // 再试一次
}
}
}
这个手法你见过一次了——跟带读-01 · 2.6 说的"大家都走
addShape这扇门"是同一个道理。
只要所有写入都走这个函数,"空间满了怎么办"就有一个地方可以做。
满程序都是裸的localStorage.setItem(...)的话,这个逻辑没有任何地方可以安放。
5.2 安全:localStorage 是明文的
风险场景:多个用户在同一台电脑上轮流登录,你登出之后,别人还能看到你的文档
(因为 localStorage 按"源"隔离,同一个网站共用一份)。
作者给的最简单解法:登出时清空。
5.3 顺带看一眼:一处小 hack
function shapeChanged(shape) {
let parent = shape.type // ① 取出"我属于哪个文档"
shape.type = shape.constructor.name // ② 临时把 type 换成类名 "QRect"
let val = JSON.stringify(shape) // ③ 序列化(这时 type 是类名)
shape.type = parent // ④ 换回去
localStorage_setItem(parent.localID + ":" + shape.id, val)
}
同一个字段 type 被当成两个东西用:平时是指向所属文档的父指针,
序列化那一瞬间临时变成类名字符串(给 loadShape 的 switch 用)。
值得学的是"为什么需要类名"(JSON 存不了行为,读回来要靠它决定 new 哪个类);
不值得学的是"用同一个字段兼两职"——一个叫type的东西居然存着父指针。71 讲要讲"如何阅读别人的代码"——读到能分清哪些该学、哪些是作者赶工的将就,才算读进去了。
5.4 可以跳过的
| 内容 | 为什么可跳 |
|---|---|
removeSomeCache 里那个 for (i=0; i<32; i++) |
一个凑合的淘汰实现,不是好设计也不重要 |
window.location.hash 的读写细节 |
就是改 URL 里 # 后面那段 |
_stringify / _load 的具体字段 |
知道"文档那条只存 ID 列表"就够了 |
第六部分 · 验收
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 28 讲加了这么大一个功能,五个 Controller 文件改了多少行?为什么能这样? | 1.2 |
| 2 | 为什么单机版的图形不需要 ID,一要存盘就必须有? | 1.4 |
| 3 | localID 和 displayID 为什么要分成两个?只留一个会怎样? |
2.1 · 2.5 |
| 4 | 为什么变更要分 shapeChanged / documentChanged 两级? |
3.3 |
| 5 | 26/27 讲"Controller 改完数据顺手叫 View 重画",为什么一联网就不行了? | 第四部分 |
答案
1. 零行。 五个 Controller 文件完全没变,view.js 只 +6 行,全部重量落在 dom.js(+191)。
因为**"存盘"纯粹是文档自己的事,跟"用户能干什么"毫无关系**——这正是"Model 越厚越好"的实际收益。
2. 因为指针只在内存里有效。单机版你想改哪个图形,指针指着它就行;
一旦要写进 localStorage(或发给服务端),你就得在那头说出它的名字。
3. 因为 displayID 会变(同步后换成服务端给的),而 localStorage 里
每一条 key 都是用 localID 拼的。只留一个会变的 ID,同步一次就得给这篇文档的
每一条记录改名(500 个图形 = 1002 次读写)。分成两个之后,只多存一条映射。
4. 因为存储分了两层。改一个图形的颜色只需重写图形那条;加/删图形才需要重写文档那条。
存储怎么分层,变更就怎么分级。
5. 因为协同场景下是 Client B 操作 → 服务端 → Client A 收到 → A 的 Model 变 → A 的 View 要更新,
A 这边根本没有 Controller 参与。必须换成 Model 发事件 → View 自己刷新。
(v28 里这个事件还没实现,是给 29/30 埋的伏笔。)
下一步
第 6 步 · 29 讲:客户端和服务端怎么对话
新需求:把本地存的东西真的发到服务端去
↓ 会碰到的新东西
网络协议设计 —— 用什么 URL、什么方法、传什么格式
版本升级 —— 协议改了,老客户端怎么办
【异步】 —— 网络请求不是立刻返回的,代码形状会变
提前打个招呼:29 讲会碰到异步(发一个请求,等一会儿才有结果)。
本篇 1.1 里那个 callAsync 就是它——那是一小块新的前端知识,到时候单独补,不像 26 讲那么大。
