加载中...

先纠正一个预期: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 …/shapesPOST …/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:128loadShape 就在干这事:

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 里还没有任何数据上下行的代码。

一句话预告:图形内容和图层顺序要上传;dgBaselocalID、桥这三样永远不出这台机器。
判据是:换台设备还需要的就上传,只对这台机器有意义的就不传。

完整的数据流(含"什么时候第一次上传"、“之后怎么只发变更”、“断网了怎么攒着重试”),
带读-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 localIDdisplayID 为什么要分成两个?只留一个会怎样? 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 讲那么大。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关