图表看板全栈开发
# 第四章:JS 响应式数据看板(毕业设计 🎓)
本章是卷二综合案例的第四关·毕业设计——前面三个案例分别教会你 jstodo 的 DOM 与事件、jsfeed 的异步与流、jsplayer 的宿主 API 与多媒体;本案例把它们全部串起来,再叠加响应式系统这一现代前端的灵魂特性。
本案例会做四件升级:
1.手写 mini-Vue 响应式核心:Proxy + Reflect + WeakMap + Set 实现 reactive / effect / computed / watch 四件套——100 行代码就能写出 Vue 3 响应式的雏形。这是其他三个案例没有的认知断层。
2.复用前 3 个案例的产物:用 jstodo 的事件委托做配置面板、用 jsfeed 的 pLimit + AbortController 做数据拉取、用 jsplayer 的 Canvas 高频渲染做图表引擎——形成"积木拼装"的工程闭环,亲眼看到前面学的每一个轮子如何被组合成产品。
3.全 15 章一锅端:本案例是卷一唯一一个把 15 章特性全部用到的案例。Symbol 做 effect 唯一 ID、迭代器让数据集可枚举、模板字符串手写模板引擎、Intl 做数字本地化——每一章都不是"顺带提一下",而是有真实落地点。
4.Web Worker + IndexedDB 跨端能力:图表引擎处理万级数据点时,主线程必须保持 60 FPS。本案例第一次用 Worker 卸载排序/聚合,用 IndexedDB 做离线缓存——这就是真实数据可视化产品(Grafana / 网易有数)的入门级架构。
学习方式:本案例严格按"阶段目标 → Step 渐进 → 编译运行 → 看到输出 → 进下一步"的真实工程师节奏推进。总共 8 大阶段【§02 是阶段①、§03 是阶段②……§09 是阶段⑧】、约 16 小时,建议分 4-5 天完成(响应式核心 · 图表引擎 · 数据流水线 · Worker 调度)。
# 渐进学习节奏
先读这段,再开始敲代码!本案例严格按照真实前端工程师的开发节奏推进,不会一上来甩你 3500 行代码让你直接抄。我们的节奏是这样的:
阶段 ① 项目骨架(§02) · 30 min
└ Step 1.1: index.html + main.js 跑通 ESM 加载
└ Step 1.2: 看到 hello jschart 控制台输出
阶段 ② Reactive 核心(§03) · 90 min 【高阶决策】
└ Step 2.1: 100 行手写 reactive(obj) → 能 console.log 拦截
└ Step 2.2: 写最简版 effect → track/trigger 跑通
└ Step 2.3: 故意把 deps 存 Map → 内存泄漏现场 → 改 WeakMap 修复
阶段 ③ Effect / Computed / Watch(§04) · 90 min
└ Step 3.1: effect 嵌套问题 + activeEffect 栈
└ Step 3.2: computed 惰性求值 + 脏标记
└ Step 3.3: watch 含 deep 选项 + cleanup 钩子
└ Step 3.4: queueMicrotask 批量调度
阶段 ④ EventEmitter + Plugin 协议(§05) · 60 min
└ Step 4.1: 自实现 EventEmitter(on/off/once/emit)
└ Step 4.2: Plugin 抽象基类 + 生命周期钩子
└ Step 4.3: Symbol 做 effect 唯一 ID
阶段 ⑤ Canvas 图表引擎(§06) · 120 min 【高峰:故意造 bug】
└ Step 5.1: 折线图骨架(最简单)
└ Step 5.2: 故意按 CSS 像素画 → 高清屏模糊 → dpr 修复
└ Step 5.3: 柱状图复用坐标系
└ Step 5.4: 饼图(极坐标)
└ Step 5.5: 接通 reactive:数据变 → 自动重绘
阶段 ⑥ 数据流水线(§07) · 90 min
└ Step 6.1: fetch 拉历史数据(复用 jsfeed pLimit)
└ Step 6.2: WebSocket 实时推送 + 心跳重连
└ Step 6.3: IndexedDB Promise 化封装
└ Step 6.4: 三源合一:history → ws → cache
阶段 ⑦ Worker + 调度(§08) · 60 min
└ Step 7.1: 海量数据排序甩到 Worker
└ Step 7.2: 故意 JSON.stringify 传大数据 → 卡顿 → Transferable 修复
└ Step 7.3: rAF 渲染调度 + 微任务批量
阶段 ⑧ 模板引擎 + Devtools(§09) · 60 min
└ Step 8.1: { { expr } } 模板字符串解析
└ Step 8.2: Devtools:FPS / effect 计数 / 网络火焰图
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就在浏览器跑一次(看到 🧪 标志立刻动手)
- 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)
⚠️ 本案例独有的"故意造 bug → 修复"升级:阶段 ② Step 2.3 会让你故意把依赖图存 Map,然后用 Devtools Memory 面板亲眼看到内存"涨上去就下不来"——这是 Vue 3 源码选 WeakMap 的真正理由。读 100 篇博客都不如踩一次坑记得牢。
✅ 每个阶段的结构(你在正文里会反复看到):
┌─ 🎯 阶段目标 ──────────────┐ ← 阶段开头:明确做什么/不做什么 │ 完成什么、不做什么、验收标准 │ └──────────────────────────────┘ Step X.1:先写最小可跑版(5-30 行) Step X.2:浏览器刷新 → 看到输出 ✅ Step X.3:再加一个小功能(10-50 行) Step X.4:浏览器刷新 → 看到新输出 ✅ ... ┌─ 🧪 运行验证 ─────────────┐ ← 阶段结尾:完整命令 + 预期输出 + 排错 │ 打开方式 / 预期输出 / 排错指南 │ └──────────────────────────┘ ┌─ 📌 阶段小结 ─────────────┐ ← 阶段结尾:今天学到了什么 │ ✅ 已掌握 / ⏸ 暂未涉及 │ └────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 案例背景信息
| 项目 | 说明 |
|---|---|
| 难度 | ★★★★★(卷二最难,毕业设计) |
| 预估时长 | 16 小时(建议分 4-5 天,每天 3-4 小时) |
| 前置案例 | 必须完成 01 jstodo / 02 jsfeed / 03 jsplayer |
| 前置章节 | 卷一全部 15 章 |
| 覆盖知识点 | Proxy + Reflect 元编程 / WeakMap + Set 依赖图 / Symbol 唯一标识 / class 私有字段 + 继承 / EventTarget 自实现版 / Canvas 2D + dpr / fetch 流式 + WebSocket + IndexedDB / Web Worker + Transferable / requestAnimationFrame + queueMicrotask / 模板引擎手写 |
| 设计亮点 | 手写 mini-Vue 响应式 100 行 + 5 容器(targetMap/depsMap/effectStack/jobQueue/plugins)演化 + 图表引擎与响应式联动 |
| ⚠ 已知局限 | 模板引擎只支持 {{ expr }} 不支持 v-if / v-for(挑战题留作扩展) |
| 最终产物 | 可运行的看板 SPA + Vite 工程 + 一键 npm run build 出 dist |
| 代码规模 | 约 3500 行 / 20+ 个 ES 模块 |
# 项目目录结构
jschart/
├── index.html # SPA 入口:挂载点 + ESM 引导
├── package.json # 仅 vite 一个 devDep
├── vite.config.js # Vite 5 最简配置
├── src/
│ ├── main.js # 应用入口
│ ├── reactivity/ # 阶段 ② ③ ⑤ 核心
│ │ ├── reactive.js # Proxy + track/trigger
│ │ ├── effect.js # effect / activeEffect 栈
│ │ ├── computed.js # 惰性求值 + 脏标记
│ │ ├── watch.js # 深度观察 + cleanup
│ │ └── scheduler.js # 微任务批量调度
│ ├── events/ # 阶段 ④
│ │ ├── EventEmitter.js
│ │ └── Plugin.js
│ ├── chart/ # 阶段 ⑤
│ │ ├── Renderer.js # Canvas 主渲染器
│ │ ├── LineChart.js
│ │ ├── BarChart.js
│ │ ├── PieChart.js
│ │ └── Coordinate.js # 坐标系工具
│ ├── data/ # 阶段 ⑥
│ │ ├── HttpSource.js # 复用 jsfeed pLimit
│ │ ├── WsSource.js
│ │ ├── IdbCache.js
│ │ └── Pipeline.js # 三源合一
│ ├── worker/ # 阶段 ⑦
│ │ ├── sort.worker.js
│ │ └── WorkerPool.js
│ ├── template/ # 阶段 ⑧
│ │ └── compile.js
│ ├── devtools/ # 阶段 ⑧
│ │ ├── FpsMeter.js
│ │ └── Inspector.js
│ └── plugins/ # 第三方插件目录
│ ├── ExportPng.js
│ └── ExportCsv.js
└── public/
└── mock/*.json # 模拟数据
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 一条命令启动
npm create vite@latest jschart -- --template vanilla
cd jschart && npm install && npm run dev
# 浏览器打开 http://localhost:5173
2
3
# 目录快速导航
点击以下条目即可跳转。
- 01. 项目需求和功能
- 02. 项目骨架与 Vite
- 03. Reactive 响应式核心
- 04. Effect / Computed / Watch
- 05. EventEmitter + Plugin 协议
- 06. Canvas 图表引擎
- 07. 数据流水线
- 08. Worker 与调度
- 09. 模板引擎 + Devtools
- 10. 项目总结分析
- 11. 项目技术思考
- 12. 衔接与延伸
# 01.项目需求和功能
# 1.1 需求介绍说明
设想一个真实场景:运维同学要看一个机房集群的监控大屏——上百台机器的 CPU / 内存 / 网络流量实时刷新;用户能拖拽布局保存到 URL;切换机房时图表不重新创建只重新喂数据;导出 PNG / CSV 报表。
这就是本案例要造的产品。它不是 ECharts 替代品——只覆盖 ECharts 1% 的特性,但每一行都是你自己写的、原理通透的。学完后你会知道:
- Vue 3 的响应式、Solid 的 signal、React 的 Hook 各自的核心算法
- ECharts / Chart.js 的渲染流水线、为什么数据变化能精准重绘
- Grafana 的数据源插件机制、为什么"换数据源不换面板"
# 1.2 功能矩阵总览
| 模块 | 功能 | 实现要点 | 阶段 |
|---|---|---|---|
| 响应式 | reactive(obj) 自动追踪依赖 | Proxy + WeakMap | ② |
| 响应式 | effect(fn) 副作用自动重跑 | activeEffect 栈 | ③ |
| 响应式 | computed(getter) 惰性求值 | dirty 脏标记 | ③ |
| 响应式 | watch(src, cb, opt) 观察 | deep / immediate / cleanup | ③ |
| 响应式 | 批量更新 | queueMicrotask 合并 | ③ |
| 事件 | EventEmitter on/off/once/emit | 自实现版 | ④ |
| 插件 | Plugin 协议 + 生命周期 | class extends EventEmitter | ④ |
| 图表 | 折线 / 柱状 / 饼图 | Canvas 2D + dpr | ⑤ |
| 图表 | 数据变化自动重绘 | effect 包裹 render | ⑤ |
| 图表 | 容器 resize 自动适配 | ResizeObserver | ⑤ |
| 数据 | REST 历史拉取 | fetch + pLimit(复用 jsfeed) | ⑥ |
| 数据 | WebSocket 实时推送 | 心跳 + 指数退避 | ⑥ |
| 数据 | IndexedDB 离线缓存 | Promise 化封装 | ⑥ |
| 调度 | Worker 排序 / 聚合 | Transferable 零拷贝 | ⑦ |
| 调度 | rAF 渲染节流 | 一帧合并多次 mutation | ⑦ |
| 模板 | {{ expr }} 替换 | 简单 AST + Function 求值 | ⑧ |
| Devtools | FPS / effect 数 / 网络火焰图 | performance.now() | ⑧ |
# 1.3 模块依赖图详解
┌────────────────────┐
│ main.js │ ← 入口
└─────────┬──────────┘
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ reactivity/ │ │ chart/ │ │ data/ │
│ reactive │◀──────│ Renderer │ │ Pipeline │
│ effect │ │ Line/Bar/Pie│ │ ├ HttpSource │
│ computed │ │ Coordinate │ │ ├ WsSource │
│ watch │ │ │ │ └ IdbCache │
│ scheduler │ └──────────────┘ └─────────────────┘
└────────────────┘ │
▲ │
│ ┌──────────────┐ │
└────────────────│ events/ │◀──────────────┘
│ EventEmitter│
│ Plugin │
└──────────────┘
▲
│
┌─────────────┼─────────────┐
▼ ▼ ▼
ExportPng ExportCsv Inspector
(插件) (插件) (devtools)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关键观察:
reactivity/是最底层模块,不依赖任何业务模块——可以单独发布为一个迷你库chart/通过effect订阅数据变化——Renderer 不知道数据从哪来(解耦)data/通过reactive暴露状态——它不关心谁在用(解耦)events/是横切关注点,所有模块都可以extends EventEmitter
✅ 这种"分层 + 解耦"的架构,正是 Vue / React / Solid 等现代框架的设计思路。
# 1.4 知识点速查
| 卷一章节 | 在本案例的位置 | 落地点举例 |
|---|---|---|
| 第 02 章 数据类型 | 全章节 | WeakMap<target, depsMap> 依赖图 |
| 第 03 章 运算符 | 全章节 | ?? ?. 防空、解构展开 immutable 更新 |
| 第 04 章 函数 | §03 §04 | effect 闭包、scheduler 高阶函数 |
| 第 05 章 面向对象 | §05 §06 | Plugin 抽象基类、私有字段 #dirty |
| 第 06 章 标准库 | §06 | Date / Math / Intl / JSON 全用上 |
| 第 07 章 异步操作 | §07 | WebSocket / Promise / async iter |
| 第 08 章 事件设计 | §05 | 自实现 EventEmitter |
| 第 09 章 错误机制 | §07 | 自定义 Error + Boundary |
| 第 10 章 模块开发 | 全章节 | ESM 拆分 20+ 文件 |
| 第 11 章 字符串 | §09 | 模板引擎 {{ expr }} |
| 第 12 章 迭代器 | §07 | 数据集 Symbol.iterator |
| 第 13 章 Symbol | §05 | effect 唯一 ID |
| 第 14 章 DOM | §06 §09 | Canvas + ResizeObserver |
| 第 15 章 网络请求 | §07 | fetch + WS + IndexedDB |
结论:每一章都至少出现 1 次,且在最合适的位置——这就是"毕业设计"的含义。
# 02.项目骨架与 Vite
┌─ 🎯 阶段 ① 目标 ──────────────────────────────────────┐
│ 完成什么:跑通"Vite 启动 → main.js 输出 hello jschart" │
│ 不做什么:不写任何业务逻辑、不引第三方库 │
│ 验收标准:浏览器控制台看到 hello + 修改 main.js 自动热更 │
│ 预计耗时:30 分钟 │
│ 关键思路:先打通"开发服务器 + ESM 加载"管道 │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
# 2.1 创建项目骨架
我们先把项目目录和所有空文件一次性创建好——但只有 main.js 和 index.html 有内容,其他全是空文件占位,让你能一眼看到整个项目最终的模块边界:
npm create vite@latest jschart -- --template vanilla
cd jschart
# 删除 Vite 模板自带的演示文件
rm -rf counter.js style.css javascript.svg public/vite.svg
# 一次性创建所有 ES 模块占位文件
mkdir -p src/reactivity src/events src/chart src/data src/worker src/template src/devtools src/plugins public/mock
touch src/reactivity/{reactive,effect,computed,watch,scheduler}.js
touch src/events/{EventEmitter,Plugin}.js
touch src/chart/{Renderer,LineChart,BarChart,PieChart,Coordinate}.js
touch src/data/{HttpSource,WsSource,IdbCache,Pipeline}.js
touch src/worker/{sort.worker,WorkerPool}.js
touch src/template/compile.js
touch src/devtools/{FpsMeter,Inspector}.js
touch src/plugins/{ExportPng,ExportCsv}.js
touch public/mock/cpu.json
npm install
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
📌 新手提示:20+ 个空文件看起来吓人,但不是一次性写完的——我们会按 8 个阶段、每次只填 2-4 个文件的节奏推进,每填完一组就刷新浏览器验证一次。这和 C++ 案例 touch 14 个 .cpp/.h 文件是同一种节奏。
# 2.2 为什么要 Vit
❓ 为什么不直接打开 index.html? 答:浏览器原生 ESM 加载相对路径必须走 HTTP(不允许 file://),且没有热更新——改一行刷一次太痛苦。
❓ 为什么不上 Webpack / Rollup? 答:Vite 5 启动 < 200ms,依赖原生 ESM + esbuild 预构建——和我们"零运行时依赖"的哲学一致:仅在开发期使用,不污染产物。
❓ 第一步先做什么? 答:让 main.js 能加载 + console.log 一行——这是后续所有模块的根,先把"开发服务器 + 热更新 + ESM 路径解析"管道打通。
🔑 教学要点:和 C++ 案例 §2.2 完全相同的"先打通最底层管道"思路。
# 2.3 写第一行代码
📁 index.html(SPA 入口):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jschart · 响应式数据看板</title>
<style>
body { margin: 0; font-family: -apple-system, sans-serif; background: #1e1e2e; color: #cdd6f4; }
#app { padding: 20px; }
.card { background: #313244; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
</style>
</head>
<body>
<div id="app">
<div class="card">
<h1>jschart 启动中...</h1>
<p id="status">等待 main.js 加载</p>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
⚠️ 关键:<script type="module"> 让浏览器走 ESM 加载链——这是本卷所有 JS 文件互相 import / export 的前提。
📁 src/main.js(阶段①骨架版):
// 阶段 ①:只验证 ESM 加载链路,先什么业务都不做
console.log('[jschart] hello, 毕业设计启动!');
const status = document.querySelector('#status');
status.textContent = '✅ ESM 加载成功,时间: ' + new Date().toLocaleTimeString();
// TODO(阶段 ②): import { reactive } from './reactivity/reactive.js'
// TODO(阶段 ⑤): import { LineChart } from './chart/LineChart.js'
// TODO(阶段 ⑥): import { Pipeline } from './data/Pipeline.js'
2
3
4
5
6
7
8
9
🧪 立刻启动验证(阶段 ① 验收):
npm run dev
# 浏览器打开 http://localhost:5173
2
预期输出:
- 页面显示 "✅ ESM 加载成功,时间: 21:35:12"
- DevTools Console 显示
[jschart] hello, 毕业设计启动! - 修改 main.js 末尾文字 → 保存自动热更新(不需要手动刷新)
✅ 看到热更新生效 = ESM 加载链 + Vite HMR 双双跑通。
排错指南:
| 现象 | 原因 |
|---|---|
Failed to resolve module | script 没写 type="module" 或路径错 |
| 控制台空白没输出 | 检查浏览器是否禁用了 console(F12 切到 Console 面板) |
| 热更新不生效 | 检查文件是否在 src/ 目录下(Vite 默认监听) |
┌─ 📌 阶段 ① 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚掌握了: │
│ • Vite 5 + 原生 ESM 启动开发服务器 │
│ • <script type="module"> 加载 main.js │
│ • 一次性 touch 出 20+ 个 ES 模块占位(看到最终边界) │
│ • 热模块替换(HMR)调试体验 │
│ ⏸ 还没碰的(下阶段才会做): │
│ • Reactive / Effect 响应式核心(阶段 ②③) │
│ • Canvas 图表引擎(阶段 ⑤) │
│ • 数据流水线 / Worker(阶段 ⑥⑦) │
│ 📌 进入下阶段前务必: │
│ git init && git add . && git commit -m "stage1: skeleton" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
# 03.Reactive 响应式核心
┌─ 🎯 阶段 ② 目标 ──────────────────────────────────────┐
│ 完成什么:手写 reactive(obj) → 读写自动追踪/触发 │
│ 不做什么:不做 effect 嵌套、不做 computed、不做 deep │
│ 验收标准:obj.count++ → 控制台自动打印 effect 重新执行 │
│ 预计耗时:90 分钟 │
│ 关键思路:3 步走 —— Proxy 拦截 → track 收集依赖 → │
│ trigger 触发副作用。容器选型故意先用 Map → 看到│
│ 内存泄漏 → 改 WeakMap 修复(教学高峰) │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
本节是本案例的灵魂之一——Vue 3 / Solid / MobX 这些"响应式"框架,核心算法都是一样的:读取时收集依赖、写入时触发更新。Vue 3 源码里 reactive.ts + effect.ts 加起来不到 500 行——本节用 120 行写出 80% 的能力。
# 3.1 响应式的本质
❓ 没有响应式可以吗? 来看反例:
// ❌ 命令式:每次改数据后手动 render
let count = 0;
function render() { document.querySelector('#n').textContent = count; }
function increment() { count++; render(); } // 必须手动调
function reset() { count = 0; render(); } // 又得调
2
3
4
5
问题:
- 每个改数据的地方都要记得手动 render——漏一个就 UI 不一致
- 多个地方依赖
count(标题、徽章、图表)时,改一处要改 N 处 - 跨模块更糟:A 模块改了,B 模块怎么知道?只能 emit 事件
❓ 有了响应式会怎样?
// ✅ 声明式:写数据,UI 自动同步
const state = reactive({ count: 0 });
effect(() => {
document.querySelector('#n').textContent = state.count;
});
state.count++; // UI 自动更新,不需要 render()
2
3
4
5
6
核心思想:让"读"和"写"两个动作变成可观察的——读时记下"谁读了我",写时通知"那些人"。这正是 Proxy 的杀手级用法。
❓ 第一步先做什么? 答:先把 reactive(obj) 写出来,让 get/set 能 console.log 拦截到——这是后续 track/trigger 的基础。
🔑 教学要点:和 C++ 案例 §3.2 User 抽象类一样——先打通最底层抽象,再叠加上层逻辑。
# 3.2 reactive
📁 src/reactivity/reactive.js(第一版:只拦截,不追踪):
// 阶段 ② Step 2.1:先验证 Proxy 能拦截读写
export function reactive(target) {
if (target === null || typeof target !== 'object') return target;
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver);
console.log(`[GET] ${String(key)} = ${result}`);
// TODO(Step 2.2): track(obj, key)
return result;
},
set(obj, key, value, receiver) {
const ok = Reflect.set(obj, key, value, receiver);
console.log(`[SET] ${String(key)} = ${value}`);
// TODO(Step 2.2): trigger(obj, key)
return ok;
},
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
JS 知识点 · 为什么用 Reflect?
Reflect.get(obj, key, receiver)是obj[key]的"标准化版本"——它会正确处理 receiver(保证 getter 内的this指向 Proxy 而非原始对象)- 不用 Reflect 的话,对象里的
get name() { return this.firstName + ... }这类 getter 在嵌套场景会读到原始对象,深层响应式失效
JS 知识点 · Proxy 的 13 个 trap:本案例只用 get / set,但 Proxy 还能拦截 has / deleteProperty / ownKeys / construct / apply 等——这是元编程的瑞士军刀。
📁 src/main.js 改成:
import { reactive } from './reactivity/reactive.js';
const state = reactive({ count: 0, name: 'jschart' });
console.log('[Test 1] 读 count:');
console.log(state.count);
console.log('[Test 2] 写 count:');
state.count = 100;
console.log('[Test 3] 读 name:');
console.log(state.name);
2
3
4
5
6
7
8
9
10
11
12
🧪 立刻刷新浏览器(Step 2.1 验收):
预期 Console 输出:
[Test 1] 读 count:
[GET] count = 0
0
[Test 2] 写 count:
[SET] count = 100
[Test 3] 读 name:
[GET] name = jschart
jschart
2
3
4
5
6
7
8
✅ 看到 GET / SET 日志 = Proxy 拦截链路通了。
反例对照:你可能想用 Object.defineProperty(Vue 2 的方案)——
// ❌ Vue 2 风格:必须为每个 key 单独设置
Object.defineProperty(obj, 'count', {
get() { /* track */ },
set(v) { /* trigger */ },
});
// 致命缺陷:obj.newField = 1 时无法拦截(key 是后加的)
2
3
4
5
6
Proxy 完胜的根因:它代理整个对象而不是某个 key——新增、删除属性都能拦截。这就是为什么 Vue 3 全面拥抱 Proxy 重写响应式系统。
# 3.3 track /
现在让 Proxy 不只 console.log,而是真的记下"哪个副作用读了哪个 key"。
📁 src/reactivity/effect.js(第一版,超简版):
// 当前正在执行的 effect("谁在读")—— 全局只有一个,effect 跑完置空
let activeEffect = null;
export function effect(fn) {
activeEffect = fn;
fn(); // 立刻跑一次:触发 get → 收集依赖
activeEffect = null;
}
// targetMap: 全局依赖图
// target obj → Map<key, Set<effect>>
// {state} → { count: Set[fnA, fnB], name: Set[fnC] }
const targetMap = new Map(); // ⚠️ 故意先用 Map(Step 2.3 会改 WeakMap)
export function track(target, key) {
if (!activeEffect) return; // 不在 effect 内的读取不需要追踪
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect);
}
export function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
// 复制一份 Set 再遍历——避免 effect 执行时再触发同 dep 导致死循环
[...dep].forEach((fn) => fn());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
JS 知识点 · 为什么 Set 而不是 Array?
- 同一个 effect 多次读同一个 key 不应该重复存(数组要
if (!arr.includes)自查重,Set 自动去重) - 删除某 effect 时 Set 是 O(1),数组是 O(n)
JS 知识点 · 为什么遍历前 [...dep]?
某些 effect 在执行时会修改自己依赖的数据(比如 effect(() => { state.a = state.b * 2 }))。如果直接遍历原 Set,新触发的 trigger 会修改正在迭代的 Set,行为未定义。克隆一份再遍历是 Vue 3 源码的标准做法。
📁 更新 src/reactivity/reactive.js:
import { track, trigger } from './effect.js';
export function reactive(target) {
if (target === null || typeof target !== 'object') return target;
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver);
track(obj, key); // ⭐ 收集依赖
return result;
},
set(obj, key, value, receiver) {
const oldVal = obj[key];
const ok = Reflect.set(obj, key, value, receiver);
if (oldVal !== value) trigger(obj, key); // ⭐ 值变了才触发
return ok;
},
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
📁 src/main.js 改成跑 effect:
import { reactive } from './reactivity/reactive.js';
import { effect } from './reactivity/effect.js';
const state = reactive({ count: 0, name: 'jschart' });
effect(() => {
console.log(`[副作用 1] count 现在是 ${state.count}`);
});
effect(() => {
console.log(`[副作用 2] name = ${state.name}, count = ${state.count}`);
});
console.log('--- 改 count ---');
state.count = 1;
state.count = 2;
console.log('--- 改 name ---');
state.name = '响应式数据看板';
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🧪 第二次刷新(Step 2.2 验收):
预期 Console 输出:
[副作用 1] count 现在是 0
[副作用 2] name = jschart, count = 0
--- 改 count ---
[副作用 1] count 现在是 1 ← 副作用 1 自动重跑
[副作用 2] name = jschart, count = 1 ← 副作用 2 也跑(依赖了 count)
[副作用 1] count 现在是 2
[副作用 2] name = jschart, count = 2
--- 改 name ---
[副作用 2] name = 响应式数据看板, count = 2 ← 只有副作用 2 跑(副作用 1 不依赖 name)
2
3
4
5
6
7
8
9
🎉 响应式核心跑通了——这正是 Vue 3 / Solid 的核心算法。只有真正依赖某个 key 的 effect 才会重跑,这种"精确依赖追踪"是 React useEffect(() => ..., [deps]) 手动写依赖数组的现代化解法。
# 3.4 故意造 bug
我们的 targetMap 用的是 Map——这看起来很合理:target → depsMap。但真实工程里这会引发严重的内存泄漏。下面亲眼看到。
📁 src/main.js 写一段会泄漏的测试代码:
import { reactive } from './reactivity/reactive.js';
import { effect } from './reactivity/effect.js';
console.log('[Memory Test] 创建 1 万个临时对象,每个都被 reactive 包过');
console.time('create');
for (let i = 0; i < 10000; i++) {
const tempObj = reactive({ id: i, payload: new Array(1000).fill(i) });
effect(() => tempObj.id); // 让 targetMap 记下这个对象
// tempObj 这个变量出了循环作用域就"看似"应该被回收
}
console.timeEnd('create');
setTimeout(() => {
console.log('[Memory Test] 5 秒后看 Memory 面板...');
console.log('targetMap.size = ?(如果还很大说明泄漏了)');
}, 5000);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
🧪 第三次刷新 + 打开 DevTools Memory 面板:
操作步骤:
- F12 打开 DevTools → 切到 Memory 面板
- 点 "Take heap snapshot" 录第一张快照
- 刷新页面,等 5 秒,再录第二张快照
- 对比:会发现
Map内部持有上万个Object+Array(1000)没被回收
坏的现象:
heap size before: 20 MB
heap size after: 95 MB ← 涨了 75MB 不下来
targetMap.size: 10000 ← 永远不会 0
2
3
🚨 这就是真实工程师天天遇到的内存泄漏:响应式框架"好心"记录了所有被代理对象的依赖图——但框架不知道用户什么时候不再需要某个对象了,于是 Map 就一直持有 → GC 永远回收不掉 → 内存涨上去就下不来。
🛠 修复:把 Map 换成 WeakMap
📁 src/reactivity/effect.js 改一行:
// const targetMap = new Map(); ❌ 旧版
const targetMap = new WeakMap(); // ✅ 弱引用,target 没人用就自动回收
2
JS 知识点 · WeakMap 三大特性(卷一第 02 章):
| 特性 | Map | WeakMap |
|---|---|---|
| key 类型 | 任意 | 必须是对象 |
| 引用强度 | 强引用(GC 不会回收 key) | 弱引用(key 没别人引用时可被 GC) |
| 可遍历 | ✅ size / keys / for...of | ❌ 不可遍历(防止泄漏 key 的引用) |
关键点:WeakMap 的 key 是"弱"的——没人引用 tempObj 时,WeakMap 不会拦着 GC。tempObj 被回收的同时,WeakMap 里那条记录也自动消失。这就是为什么 Vue 3 源码用 WeakMap 而不是 Map。
🧪 第四次刷新(修复验证):
预期现象:
heap size before: 20 MB
heap size after: 22 MB ← 几乎没涨(GC 回收了那 1 万个 tempObj)
2
✅ 内存回到正常水平 = bug 修复。
💡 教学要点:很多博客说"Vue 3 用 WeakMap 是为了内存",但只有亲眼看到 Map 版的内存泄漏,你才会真正记住。这和 C++ 案例 §6.4 故意写错
reserveRoom是同一种教学手法——踩过坑才会刻进骨头。
# 3.5 嵌套对象的深度响
到现在,reactive({ user: { name: 'A' } }) 改 state.user.name 不会触发 effect——因为 state.user 返回的是原始对象,不是 Proxy。
📁 src/reactivity/reactive.js 加一行 + 加缓存:
import { track, trigger } from './effect.js';
const reactiveMap = new WeakMap(); // ⭐ 同一个 obj 多次 reactive 复用同一个 Proxy
export function reactive(target) {
if (target === null || typeof target !== 'object') return target;
if (reactiveMap.has(target)) return reactiveMap.get(target); // 缓存命中
const proxy = new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver);
track(obj, key);
// ⭐ 关键升级:嵌套对象懒递归
return typeof result === 'object' && result !== null
? reactive(result)
: result;
},
set(obj, key, value, receiver) {
const oldVal = obj[key];
const ok = Reflect.set(obj, key, value, receiver);
if (oldVal !== value) trigger(obj, key);
return ok;
},
});
reactiveMap.set(target, proxy);
return proxy;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
两个升级点:
- 嵌套懒响应式:
get时判断子值是不是对象,是就递归reactive包一层——懒字很关键,深层属性没被读到就不递归,省内存 - Proxy 缓存:同一个对象多次
reactive(o)应该返回同一个 Proxy(否则 effect 追踪会指向不同 Proxy 导致 trigger 失败)
🧪 第五次刷新(验证嵌套响应式):
const state = reactive({ user: { name: 'Alice', age: 18 } });
effect(() => console.log(`[嵌套] ${state.user.name} - ${state.user.age}`));
state.user.name = 'Bob'; // 应该触发 effect
state.user.age = 20;
2
3
4
预期输出:
[嵌套] Alice - 18
[嵌套] Bob - 18
[嵌套] Bob - 20
2
3
✅ 深度响应式 + 缓存 = 阶段 ② 完成。
┌─ 📌 阶段 ② 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 2.1 reactive(obj) 用 Proxy 拦截读写 │
│ • Step 2.2 effect + track + trigger 三件套,依赖图 │
│ 数据结构 WeakMap<target, Map<key, Set<effect>>> │
│ • Step 2.3 故意用 Map 看到内存泄漏 → 改 WeakMap 修复 │
│ • Step 2.4 嵌套对象懒递归 + Proxy 缓存 │
│ │
│ 📊 现在 reactivity/ 目录下有 2 个文件 ~ 80 行: │
│ reactive.js (Proxy + 嵌套 + 缓存) │
│ effect.js (track + trigger + WeakMap) │
│ → 这就是 Vue 3 / Solid 响应式的核心骨架 │
│ │
│ ⏸ 还没碰的(下阶段才会做): │
│ • effect 嵌套问题(activeEffect 栈) │
│ • computed 惰性求值 │
│ • watch 深度观察 │
│ • 微任务批量调度 │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage2: reactive core" │
│ │
│ 💡 本阶段最大领悟: │
│ "Proxy 拦截 + WeakMap 依赖图 = Vue 3 100 行核心算法" │
│ 亲眼看到 Map 内存泄漏 → 才真正理解 WeakMap 的存在意义 │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 04.Effect / Computed / Watch
┌─ 🎯 阶段 ③ 目标 ──────────────────────────────────────┐
│ 完成什么:响应式三件套 effect + computed + watch + 调度 │
│ 不做什么:不做插件协议、不做 Canvas(阶段 ④⑤ 才做) │
│ 验收标准:effect 嵌套不串味、computed 惰性、watch 触发 cb│
│ 且多次 mutation 在一帧内只重跑一次 │
│ 预计耗时:90 分钟 │
│ 关键思路:每个特性各自都是阶段 ② effect 的"加强版"—— │
│ 栈式 activeEffect / dirty 缓存 / 旧值新值对比 │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 4.1 技术灵魂三问
❓ 阶段 ② 的 effect 已经能跑,为什么还要做 computed 和 watch?
来看痛点:
// ❌ 用 effect 模拟 computed
let total = 0;
effect(() => { total = state.price * state.qty; }); // 每次 effect 跑都算
console.log(total); // 想读 total 时还得读普通变量,无响应式
2
3
4
问题:total 不是响应式的——别处 effect(() => useTotal()) 时不会触发。computed 的本质是"既能被 effect 当依赖追踪、又能惰性缓存的函数"。
❓ watch 不就是 effect 吗? 答:不是!effect 关心"代码块依赖了什么"(隐式),watch 关心"我要观察这个东西的变化"(显式)——后者必须给 cb(newVal, oldVal),用于"值变了之后做异步操作"(保存接口、网络请求等)。
❓ 第一步先做哪个? 答:先解决 effect 嵌套问题——computed 和 watch 都建立在 effect 之上,嵌套问题不解决会导致后续两者全错。
# 4.2 effect 嵌
阶段 ② 的 activeEffect 是单变量——遇到嵌套 effect 会出问题:
effect(() => {
effect(() => { // 内层 effect 把 activeEffect 改了
console.log(state.a);
});
console.log(state.b); // ⚠️ 此时 activeEffect 还是内层那个!
}); // state.b 错误地被收集到内层 effect
2
3
4
5
6
🛠 修复:用栈结构
📁 src/reactivity/effect.js(升级版):
const effectStack = []; // ⭐ 栈:记录嵌套层级
let activeEffect = null;
export function effect(fn, options = {}) {
const runner = () => {
try {
effectStack.push(runner);
activeEffect = runner;
cleanup(runner); // 重跑前清掉旧依赖(解决条件分支问题)
return fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1] ?? null;
}
};
runner.deps = []; // 这个 runner 被哪些 dep 收录了
runner.options = options; // scheduler / lazy 等高级选项
if (!options.lazy) runner();
return runner;
}
function cleanup(runner) {
// 反向移除:让所有持有 runner 的 dep 把 runner 删掉
runner.deps.forEach((dep) => dep.delete(runner));
runner.deps.length = 0;
}
const targetMap = new WeakMap();
export function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep); // ⭐ 双向记账:runner 也记得自己被哪些 dep 收录
}
}
export function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
[...dep].forEach((runner) => {
// ⭐ scheduler 钩子:让 computed / watch / batch 接管"什么时候真正跑"
if (runner.options.scheduler) runner.options.scheduler(runner);
else runner();
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
两个核心升级:
effectStack:嵌套 effect 时入栈;外层 effect 跑完了从栈顶弹出,恢复正确的 activeEffectcleanup双向记账:每个 runner 自己记得"我被哪些 dep 收录"——重跑前先把这些 dep 里的自己删掉,保证条件分支响应正确(比如effect(() => state.show ? state.a : state.b)切 show 时旧分支依赖能被清理)
JS 知识点 · try/finally + 栈:finally 里弹栈是保证异常时也能恢复 activeEffect——这是 Vue 3 源码原话。
🧪 第一次验证(嵌套不串味):
import { reactive } from './reactivity/reactive.js';
import { effect } from './reactivity/effect.js';
const state = reactive({ a: 1, b: 2 });
effect(() => {
console.log('外层读 b =', state.b);
effect(() => {
console.log('内层读 a =', state.a);
});
});
console.log('--- 改 b ---');
state.b = 20; // 应该外层跑(且会再触发一次内层重建)
console.log('--- 改 a ---');
state.a = 10; // 应该只内层跑
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
预期输出:
外层读 b = 2
内层读 a = 1
--- 改 b ---
外层读 b = 20
内层读 a = 1 ← 外层重跑导致内层重建
--- 改 a ---
内层读 a = 10 ← 只有内层跑
2
3
4
5
6
7
# 4.3 computed
📁 src/reactivity/computed.js:
import { effect } from './effect.js';
import { track, trigger } from './effect.js';
export function computed(getter) {
let cachedValue;
let dirty = true; // ⭐ 脏标记:true 表示需要重新求值
// 把 getter 包成 effect,但 lazy 不立即执行
// 它依赖的 reactive 数据变 → scheduler 把 dirty 改回 true
const runner = effect(getter, {
lazy: true,
scheduler: () => {
if (!dirty) {
dirty = true;
trigger(obj, 'value'); // ⭐ 通知"读 c.value 的 effect"重跑
}
},
});
const obj = {
get value() {
if (dirty) {
cachedValue = runner(); // 重新求值
dirty = false;
}
track(obj, 'value'); // ⭐ 让"读 c.value"也能被外层 effect 收集
return cachedValue;
},
};
return obj;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
核心机制:
| 阶段 | 行为 |
|---|---|
| 创建 computed | lazy: true 不跑 getter,dirty=true |
第一次读 c.value | dirty 为 true → 跑 getter → 缓存 → dirty=false |
再次读 c.value | dirty 还是 false → 直接返回缓存 |
| 依赖数据变化 | scheduler 触发 → dirty=true → trigger 通知外层 |
| 外层 effect 重读 c.value | dirty=true → 重新求值 |
这就是"惰性求值 + 脏标记"——和 React 的 useMemo 算法一致。
🧪 第二次验证:
import { reactive } from './reactivity/reactive.js';
import { effect } from './reactivity/effect.js';
import { computed } from './reactivity/computed.js';
const cart = reactive({ price: 10, qty: 3 });
const total = computed(() => {
console.log(' [computed] 重新求值');
return cart.price * cart.qty;
});
console.log('--- 第一次读 ---');
console.log(total.value); // 触发求值
console.log(total.value); // 命中缓存,不再求值
console.log('--- 改 qty ---');
cart.qty = 5;
console.log(total.value); // 重新求值
console.log('--- 用在 effect 里 ---');
effect(() => console.log(`[effect] total = ${total.value}`));
cart.price = 20; // total 应自动重算 + effect 应自动跑
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
预期输出:
--- 第一次读 ---
[computed] 重新求值
30
30 ← 第二次没重新求值
--- 改 qty ---
[computed] 重新求值
50
--- 用在 effect 里 ---
[effect] total = 50 ← 立刻跑一次
[computed] 重新求值
[effect] total = 100 ← price 变 → computed 脏 → effect 自动重跑
2
3
4
5
6
7
8
9
10
11
🎉 computed 三特性齐活:惰性 / 缓存 / 可被 effect 追踪。
# 4.4 watch 深度
📁 src/reactivity/watch.js:
import { effect } from './effect.js';
export function watch(source, cb, options = {}) {
const { immediate = false, deep = false } = options;
// 1. 把 source 转换为 getter
const getter = typeof source === 'function'
? source
: () => deep ? traverse(source) : source; // 默认只观察一层
let oldValue;
let cleanupFn;
// 用户可在 cb 里 onCleanup(fn) 注册清理(比如取消旧请求)
const onCleanup = (fn) => { cleanupFn = fn; };
const job = () => {
if (cleanupFn) cleanupFn(); // ⭐ 上一次的副作用先清理
const newValue = runner();
cb(newValue, oldValue, onCleanup);
oldValue = newValue;
};
const runner = effect(getter, {
lazy: true,
scheduler: job, // ⭐ 数据变 → 跑 job 而不是直接重跑 getter
});
if (immediate) job();
else oldValue = runner();
}
// 递归读取所有属性,触发 track —— 这就是 deep 的实现
function traverse(value, seen = new Set()) {
if (value === null || typeof value !== 'object' || seen.has(value)) return value;
seen.add(value);
for (const key in value) traverse(value[key], seen);
return value;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
JS 知识点 · seen Set 防循环引用:a.b = a 这种环状对象遍历时会无限递归,用 Set 记录访问过的节点。
🧪 第三次验证(深度 + cleanup):
import { reactive } from './reactivity/reactive.js';
import { watch } from './reactivity/watch.js';
const state = reactive({ user: { name: 'Alice', age: 18 } });
watch(
() => state.user,
(newUser, oldUser, onCleanup) => {
console.log(`[watch] user 变了`);
const ctrl = new AbortController();
onCleanup(() => {
console.log(`[watch] 取消上次的请求`);
ctrl.abort();
});
// 模拟保存到后端
fetch('/api/user', { method: 'POST', body: JSON.stringify(newUser), signal: ctrl.signal })
.catch((e) => e.name === 'AbortError' && console.log(' ↳ 旧请求已取消'));
},
{ deep: true }
);
state.user.name = 'Bob';
setTimeout(() => state.user.age = 20, 100);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
预期输出:
[watch] user 变了
(100ms 后)
[watch] 取消上次的请求
↳ 旧请求已取消
[watch] user 变了
2
3
4
5
🎉 onCleanup 取消旧请求模式 = 真实工程的"竞态请求"标准解法——这正是 jsfeed §02 学的 AbortController 在响应式场景的延伸。
# 4.5 schedule
现在还有一个性能问题:连续改 5 次值,effect 跑 5 次。
state.a = 1; state.a = 2; state.a = 3; // effect 跑 3 次
🛠 修复:把 scheduler 改成"放进微任务队列,本帧合并执行"
📁 src/reactivity/scheduler.js:
const queue = new Set(); // 用 Set 自动去重
let isFlushing = false;
export function queueJob(job) {
queue.add(job);
if (!isFlushing) {
isFlushing = true;
Promise.resolve().then(flush); // ⭐ 微任务(比 setTimeout 早)
}
}
function flush() {
try {
[...queue].forEach((job) => job());
} finally {
queue.clear();
isFlushing = false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
JS 知识点 · 微任务 vs 宏任务(卷一第 7 章):
| 维度 | 微任务 (Promise.then / queueMicrotask) | 宏任务 (setTimeout / MessageChannel) |
|---|---|---|
| 执行时机 | 当前同步代码跑完后立即执行 | 下一个事件循环 |
| 渲染前后 | 本帧内执行(在浏览器渲染前) | 下一帧 |
| 用途 | 数据更新合并(Vue / React 都用) | 真正"延迟" |
Vue 3 / React Concurrent 都用微任务调度——本帧内多次 setState 合并成一次渲染。
📁 修改 effect.js trigger:
import { queueJob } from './scheduler.js';
// trigger 内部改成:
[...dep].forEach((runner) => {
if (runner.options.scheduler) runner.options.scheduler(runner);
else queueJob(runner); // ⭐ 默认走微任务队列
});
2
3
4
5
6
7
🧪 第四次验证(批量调度):
const state = reactive({ count: 0 });
let runCount = 0;
effect(() => {
runCount++;
console.log(`[effect 第 ${runCount} 次] count = ${state.count}`);
});
console.log('--- 连续改 5 次 ---');
state.count = 1;
state.count = 2;
state.count = 3;
state.count = 4;
state.count = 5;
console.log('--- 同步代码结束 ---');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
预期输出:
[effect 第 1 次] count = 0
--- 连续改 5 次 ---
--- 同步代码结束 ---
[effect 第 2 次] count = 5 ← 5 次 mutation 合并成 1 次重跑
2
3
4
🎉 5 → 1 的优化——这就是 Vue 3 模板更新永远是"下一个 tick"的原因。
┌─ 📌 阶段 ③ 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 3.1 effectStack 解决嵌套 + cleanup 双向记账 │
│ • Step 3.2 computed 惰性求值 + dirty 脏标记 │
│ • Step 3.3 watch + onCleanup 取消上次副作用 │
│ • Step 3.4 微任务批量调度(5 次 mutation → 1 次执行) │
│ │
│ 📊 现在 reactivity/ 长成 5 个文件 ~ 200 行: │
│ reactive.js / effect.js / computed.js │
│ watch.js / scheduler.js │
│ → 这就是 Vue 3 reactivity 模块的核心子集 │
│ │
│ ⏸ 还没碰的: │
│ • EventEmitter + Plugin 协议(阶段 ④) │
│ • Canvas 图表引擎(阶段 ⑤) │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage3: effect/computed/watch"│
│ │
│ 💡 本阶段最大领悟: │
│ "computed 和 watch 不是新东西——它们都是 effect 加上一 │
│ 点 scheduler 钩子。理解这一点,就读懂了响应式框架" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 05.EventEmitter + Plugin 协议
┌─ 🎯 阶段 ④ 目标 ──────────────────────────────────────┐
│ 完成什么:自实现 EventEmitter + Plugin 抽象基类 │
│ 不做什么:不写 Canvas、不接数据源(阶段 ⑤⑥ 才做) │
│ 验收标准:on/off/once/emit 四个方法跑通;自定义两个插件 │
│ (ExportPng / ExportCsv)注册即用 │
│ 预计耗时:60 分钟 │
│ 关键思路:用 Map<string, Set<fn>> 装监听器;插件用 class │
│ extends EventEmitter,每个插件有 install/dispose 钩子│
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 5.1 技术灵魂三问
❓ 浏览器原生有 EventTarget,为什么自己写一个?
// 原生方案
class Foo extends EventTarget {}
foo.addEventListener('change', e => console.log(e.detail));
foo.dispatchEvent(new CustomEvent('change', { detail: { x: 1 } }));
2
3
4
对比:
| 维度 | 原生 EventTarget | 自实现 EventEmitter |
|---|---|---|
| API 风格 | 浏览器风格冗长(addEventListener / CustomEvent) | Node 风格简洁(on / emit) |
once | 需 { once: true } 选项 | 直接 once(name, fn) |
| 链式调用 | 不支持 | 支持 bus.on().on().on() |
| Node 兼容 | 浏览器独有 | 同一份代码 Node 也能跑 |
| 体感 | 像写 DOM | 像写后端 |
实际项目根据团队偏好选——面试常考 EventEmitter 手写,本案例选择手写更教学化。
❓ Plugin 协议是干嘛的? 答:让用户在不修改本体代码的情况下扩展功能——比如"导出 PNG"是 Chart 的可选能力,本体不应该硬编码这个按钮,而是让 PNG 插件自己往工具栏 push 一个按钮,并在 Chart beforeDestroy 时自动清理。这就是 Vue / Babel / ESLint / Webpack 都用的扩展机制。
❓ 第一步先做哪个? 答:先把 EventEmitter 写出来——Plugin 类要 extends EventEmitter 才能 emit install/dispose 生命周期事件。
# 5.2 手写 Event
📁 src/events/EventEmitter.js:
export class EventEmitter {
// 用 Map<string, Set<fn>> 装监听器
// 私有字段(卷一第 5 章)—— 外部不可访问
#listeners = new Map();
on(name, fn) {
if (!this.#listeners.has(name)) this.#listeners.set(name, new Set());
this.#listeners.get(name).add(fn);
return this; // ⭐ 链式
}
off(name, fn) {
if (!fn) {
this.#listeners.delete(name); // 不传 fn 就清光这个事件
} else {
this.#listeners.get(name)?.delete(fn);
}
return this;
}
once(name, fn) {
const wrapper = (...args) => {
this.off(name, wrapper);
fn.apply(this, args);
};
return this.on(name, wrapper);
}
emit(name, ...args) {
const set = this.#listeners.get(name);
if (!set) return false;
[...set].forEach((fn) => { // ⭐ 同样克隆再遍历,防 fn 内 off 自己
try {
fn.apply(this, args);
} catch (e) {
// ⭐ 一个 listener 出错不影响其他 listener
console.error(`[EventEmitter] listener of "${name}" threw:`, e);
}
});
return true;
}
// 调试用:返回某事件 listener 数量
listenerCount(name) {
return this.#listeners.get(name)?.size ?? 0;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
JS 知识点 · 私有字段 #(卷一第 5 章):
- ES2022 新语法,真私有(不是约定的
_prefix) - 子类访问不到
#listeners——这是真正的封装 - 早期方案
WeakMap+ 闭包能模拟私有,但语法繁琐
JS 知识点 · [...set].forEach 而不是 set.forEach:
和 effect 中一样,遍历时回调可能修改集合——克隆一份再遍历是健壮性保证。
🧪 立刻验证:
import { EventEmitter } from './events/EventEmitter.js';
const bus = new EventEmitter();
const onA = (x) => console.log('listener A:', x);
const onB = (x) => console.log('listener B:', x);
bus.on('data', onA).on('data', onB);
bus.emit('data', 1);
bus.once('data', (x) => console.log('once C:', x));
bus.emit('data', 2);
bus.emit('data', 3); // once C 不会再跑
bus.off('data', onA);
bus.emit('data', 4); // 只 B 跑
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
预期输出:
listener A: 1
listener B: 1
listener A: 2
listener B: 2
once C: 2
listener A: 3
listener B: 3
listener B: 4
2
3
4
5
6
7
8
✅ on/off/once/emit 四件套跑通。
# 5.3 Plugin 抽
📁 src/events/Plugin.js:
import { EventEmitter } from './EventEmitter.js';
// 卷一第 5 章:抽象基类(JS 没有 abstract 关键字,约定子类必须重写 install)
export class Plugin extends EventEmitter {
// ⭐ 用 Symbol 做唯一 ID(卷一第 13 章)
// 不同插件实例的 id 永远不会冲突(即使 name 相同)
static #idCounter = 0;
id = Symbol(`plugin-${Plugin.#idCounter++}`);
/** 插件名(必填,子类覆盖) */
static name = 'AbstractPlugin';
/** 注册时调用(必须重写) */
install(host) {
throw new Error(`${this.constructor.name}.install() must be implemented`);
}
/** 卸载时调用(默认空) */
dispose() {
this.#listeners?.clear?.();
this.emit('dispose');
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
JS 知识点 · static #idCounter:
- ES2022 静态私有字段——所有 Plugin 实例共享一个计数器
- 子类不能访问父类的
#idCounter(私有字段不会继承)
📁 src/plugins/ExportPng.js(一个真实插件示范):
import { Plugin } from '../events/Plugin.js';
export class ExportPngPlugin extends Plugin {
static name = 'ExportPng';
install(host) {
this.host = host;
// host 是 Renderer 实例,提供 toolbar / canvas 等接口
this.btn = document.createElement('button');
this.btn.textContent = '📷 导出 PNG';
this.btn.onclick = () => this.export();
host.toolbar.appendChild(this.btn);
host.on('beforeDestroy', () => this.dispose());
}
export() {
const url = this.host.canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = `chart-${Date.now()}.png`;
a.click();
this.emit('exported', { url });
}
dispose() {
this.btn?.remove();
super.dispose();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
📁 src/plugins/ExportCsv.js(镜像,只改导出格式):
import { Plugin } from '../events/Plugin.js';
export class ExportCsvPlugin extends Plugin {
static name = 'ExportCsv';
install(host) {
this.host = host;
this.btn = document.createElement('button');
this.btn.textContent = '📊 导出 CSV';
this.btn.onclick = () => this.export();
host.toolbar.appendChild(this.btn);
host.on('beforeDestroy', () => this.dispose());
}
export() {
const data = this.host.getData();
const csv = data.map((row) => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `data-${Date.now()}.csv`; a.click();
URL.revokeObjectURL(url); // ⭐ 防内存泄漏
this.emit('exported');
}
dispose() {
this.btn?.remove();
super.dispose();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
JS 知识点 · URL.createObjectURL 必配 revokeObjectURL:每次 createObjectURL 浏览器都会持有 Blob 直到页面卸载——不 revoke 等于内存泄漏。这是 jsplayer §03 学过的同类陷阱。
# 5.4 把插件挂到主体
📁 临时建 src/main.js 测试:
import { EventEmitter } from './events/EventEmitter.js';
import { ExportPngPlugin } from './plugins/ExportPng.js';
import { ExportCsvPlugin } from './plugins/ExportCsv.js';
// 模拟 Chart 主体(阶段 ⑤ 才会真的写 Renderer)
class MockChart extends EventEmitter {
toolbar = document.querySelector('#status').parentElement;
canvas = { toDataURL: () => 'data:image/png;base64,FAKE' };
getData() { return [['x', 'y'], [1, 10], [2, 20]]; }
use(PluginClass) {
const plugin = new PluginClass();
plugin.install(this);
console.log(`[Chart] 插件 ${PluginClass.name} 注册,id=${plugin.id.toString()}`);
return plugin;
}
destroy() {
this.emit('beforeDestroy');
console.log('[Chart] 已销毁');
}
}
const chart = new MockChart();
chart.use(ExportPngPlugin);
chart.use(ExportCsvPlugin);
// 5 秒后销毁,验证插件 dispose
setTimeout(() => chart.destroy(), 5000);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
🧪 第二次刷新(端到端验证):
预期现象:
- 页面工具栏出现两个按钮:📷 导出 PNG / 📊 导出 CSV
- Console 显示
[Chart] 插件 ExportPng 注册,id=Symbol(plugin-0) - 点击 PNG 按钮 → 浏览器下载 fake.png
- 5 秒后两个按钮消失(dispose 把 DOM 节点 remove 了)
✅ 插件协议跑通——这就是 Babel @babel/plugin-* 系列、Webpack loader / plugin、Vite plugins: [] 的统一入门版。
为什么"开闭原则"在 JS 里这么重要:JS 没有 C++ protected / Java abstract——开闭原则全靠约定 + 钩子。Plugin 协议把"扩展点"显式化到 install/dispose 两个钩子,让所有插件遵守同一份契约。
┌─ 📌 阶段 ④ 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 4.1 EventEmitter(私有字段 # + Map<name, Set<fn>>) │
│ • Step 4.2 Plugin 抽象基类(Symbol id + install 必须重写) │
│ • Step 4.3 ExportPng / ExportCsv 两个真实插件 │
│ │
│ 📊 现在 events/ + plugins/ 共 4 个文件 ~ 100 行: │
│ EventEmitter.js / Plugin.js │
│ ExportPng.js / ExportCsv.js │
│ │
│ ⏸ 还没碰的: │
│ • Canvas 图表引擎(阶段 ⑤) │
│ • 数据流水线(阶段 ⑥) │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage4: emitter + plugin" │
│ │
│ 💡 本阶段最大领悟: │
│ "插件协议 = 抽象基类 + 生命周期钩子 + 自动清理。 │
│ 这套思路在 Babel / Webpack / Vue 全都一致" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 06.Canvas 图表引擎
┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────────┐
│ 完成什么:折线 + 柱状 + 饼图三件套,数据变 → 自动重绘 │
│ 不做什么:不做 SVG / WebGL / 3D 图、不做动画过渡 │
│ 验收标准:Renderer 接通 reactive,state.data 改变自动重绘 │
│ 且高清屏不模糊、容器 resize 自动适配 │
│ 预计耗时:120 分钟 │
│ 关键思路:先 LineChart(最简)→ 故意按 CSS 像素画看到模糊 │
│ → dpr 修复 → 复用坐标系做 BarChart → 极坐标 Pie │
│ → 最后 effect 包裹 render 接通响应式 │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
本节是 jsplayer §03 的延伸——同样是 Canvas 高频渲染,那一节服务弹幕(动态、对象池),本节服务图表(静态、数据驱动)。两者底层都是同一套 requestAnimationFrame + dpr 思路。
# 6.1 技术灵魂三问
❓ 图表为什么用 Canvas 不用 SVG?
| 维度 | Canvas | SVG |
|---|---|---|
| DOM 节点数 | 1 个(整张图共享) | N 个(每条数据一个 path) |
| 性能 | 万级数据点 60fps | 千级开始卡顿 |
| 交互 | 自己实现命中检测 | DOM 事件直接用 |
| 可访问性 | 差 | 好(屏幕阅读器) |
| 选用 | 数据量大 / 高频更新(本案例) | 数据量小 / 静态展示 |
ECharts / Chart.js 都是 Canvas,D3 / Highcharts 都是 SVG——选型按数据量和交互需求。
❓ 图表引擎怎么和响应式系统配合?
// 朴素做法:手动同步
state.data.push(newPoint);
chart.render(); // 必须手动调
// 响应式做法:声明式订阅
effect(() => chart.render(state.data)); // 只要 state.data 变就自动画
2
3
4
5
6
这正是阶段 ② 留下的空头支票——effect 包住 render = 数据驱动 UI 的最简范式。
❓ 第一步先做哪个? 答:LineChart——折线图只要画 N-1 条线段,最简单。坐标系工具一旦写好,柱状/散点 80% 复用。
# 6.2 LineChar
📁 src/chart/Coordinate.js(坐标系工具):
/** 把数据空间的点 (x, y) 映射到画布像素空间 */
export function makeScale({ data, width, height, padding = 40 }) {
const xs = data.map((p) => p.x);
const ys = data.map((p) => p.y);
const xMin = Math.min(...xs), xMax = Math.max(...xs);
const yMin = Math.min(...ys), yMax = Math.max(...ys);
const innerW = width - padding * 2;
const innerH = height - padding * 2;
return {
xScale: (x) => padding + ((x - xMin) / (xMax - xMin || 1)) * innerW,
yScale: (y) => height - padding - ((y - yMin) / (yMax - yMin || 1)) * innerH,
xMin, xMax, yMin, yMax, padding, innerW, innerH,
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
📁 src/chart/LineChart.js(第一版:故意按 CSS 像素画):
import { makeScale } from './Coordinate.js';
export class LineChart {
constructor(container) {
this.container = container;
this.canvas = document.createElement('canvas');
// ❌ 故意:直接按 CSS 像素 600 × 300 设置画布
this.canvas.width = 600;
this.canvas.height = 300;
this.canvas.style.width = '600px';
this.canvas.style.height = '300px';
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
}
render(data) {
const ctx = this.ctx;
const { width, height } = this.canvas;
ctx.clearRect(0, 0, width, height);
const { xScale, yScale, padding } = makeScale({ data, width, height });
// 画坐标轴
ctx.strokeStyle = '#6c7086';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, height - padding);
ctx.lineTo(width - padding, height - padding);
ctx.stroke();
// 画折线
ctx.strokeStyle = '#89b4fa';
ctx.lineWidth = 2;
ctx.beginPath();
data.forEach((p, i) => {
const x = xScale(p.x);
const y = yScale(p.y);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
// 画数据点
ctx.fillStyle = '#f9e2af';
data.forEach((p) => {
ctx.beginPath();
ctx.arc(xScale(p.x), yScale(p.y), 3, 0, Math.PI * 2);
ctx.fill();
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
📁 src/main.js 测试:
import { LineChart } from './chart/LineChart.js';
const card = document.querySelector('.card');
const chart = new LineChart(card);
const mockData = [
{ x: 1, y: 30 }, { x: 2, y: 50 }, { x: 3, y: 45 },
{ x: 4, y: 80 }, { x: 5, y: 65 }, { x: 6, y: 90 },
];
chart.render(mockData);
2
3
4
5
6
7
8
9
10
🧪 第一次刷新:
预期现象:折线图显示出来。但是——如果你用的是高清屏(MacBook Retina / iPhone):
🚨 图变得模糊——线条边缘有"毛边",文字也虚。在普通 1080p 屏上看不出问题。
根因:
| 概念 | 含义 |
|---|---|
| CSS 像素 | 浏览器逻辑像素,不依赖屏幕 |
| 物理像素 | 屏幕真实点 |
devicePixelRatio (dpr) | 物理像素 / CSS 像素的比值 |
- 普通屏 dpr = 1(1 CSS = 1 物理)
- Retina dpr = 2(1 CSS = 2 物理 = 4 个点)
- 部分手机 dpr = 3
我们设置 canvas.width=600 让画布位图只有 600 像素宽,但 CSS 又把它显示到 600 CSS 像素 = 1200 物理像素——浏览器只能拉伸位图,1 像素被拉成 4 像素,自然糊。
# 6.3 dpr 修复
🛠 修复:物理像素 = CSS 像素 × dpr,再用 ctx.scale(dpr, dpr) 把绘制坐标拉回 CSS 单位
📁 src/chart/LineChart.js 改 constructor:
constructor(container) {
this.container = container;
this.canvas = document.createElement('canvas');
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
this.cssW = 600;
this.cssH = 300;
this.resize(this.cssW, this.cssH);
// 监听容器 resize → 重新 setup(卷一第 14 章 ResizeObserver)
this.ro = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
this.resize(width, height);
if (this.lastData) this.render(this.lastData);
});
// 让父容器决定大小
this.canvas.style.width = '100%';
this.canvas.style.maxWidth = '600px';
this.ro.observe(container);
}
resize(cssW, cssH) {
const dpr = window.devicePixelRatio || 1;
// ⭐ 1. 位图大小 = CSS × dpr
this.canvas.width = Math.floor(cssW * dpr);
this.canvas.height = Math.floor(cssH * dpr);
// ⭐ 2. CSS 显示大小不变
this.canvas.style.width = cssW + 'px';
this.canvas.style.height = cssH + 'px';
// ⭐ 3. 缩放绘图坐标系 → 后续按 CSS 单位画
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.cssW = cssW; this.cssH = cssH;
}
destroy() {
this.ro?.disconnect(); // ⭐ 卸载 observer 防内存泄漏
this.canvas.remove();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
render 内的 width/height 也改用 CSS 单位:
render(data) {
this.lastData = data;
const ctx = this.ctx;
const width = this.cssW, height = this.cssH; // ⭐ 改用 CSS 单位
ctx.clearRect(0, 0, width, height);
// ...其余代码不变
}
2
3
4
5
6
7
🧪 第二次刷新(验证 dpr 修复):
预期:折线图边缘锐利,文字清晰,Retina 屏上和普通屏一样好看。
对比验证:
- 打开 DevTools → 切到响应式设计模式(手机模拟)
- 选 iPhone 13(dpr = 3)
- 看图表:修复前每条线都是 3 像素糊感,修复后边缘锐利如直线
✅ dpr 适配 + ResizeObserver 自适应 = 现代图表库的最低标准。
💡 教学要点:Chart.js / ECharts 的
setOption内部第一步就是 dpr 计算——这不是可选优化,而是必须步骤。只有亲眼看到模糊,你才会真正记住。
# 6.4 BarChart
折线图的坐标系工具 90% 适用于柱状图,只是把"线段"换成"矩形"。
📁 src/chart/BarChart.js:
import { makeScale } from './Coordinate.js';
export class BarChart {
constructor(container) {
this.container = container;
this.canvas = document.createElement('canvas');
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
this.resize(600, 300);
this.ro = new ResizeObserver(() => this.lastData && this.render(this.lastData));
this.ro.observe(container);
}
resize(cssW, cssH) {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = cssW * dpr;
this.canvas.height = cssH * dpr;
this.canvas.style.width = cssW + 'px';
this.canvas.style.height = cssH + 'px';
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.cssW = cssW; this.cssH = cssH;
}
render(data) {
this.lastData = data;
const ctx = this.ctx;
const width = this.cssW, height = this.cssH;
ctx.clearRect(0, 0, width, height);
const { xScale, yScale, padding, innerW } = makeScale({ data, width, height });
const barWidth = (innerW / data.length) * 0.6;
// 坐标轴
ctx.strokeStyle = '#6c7086';
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, height - padding);
ctx.lineTo(width - padding, height - padding);
ctx.stroke();
// 柱子
data.forEach((p) => {
const x = xScale(p.x) - barWidth / 2;
const y = yScale(p.y);
const h = (height - padding) - y;
ctx.fillStyle = '#a6e3a1';
ctx.fillRect(x, y, barWidth, h);
});
}
destroy() { this.ro?.disconnect(); this.canvas.remove(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
👀 注意:constructor / resize / destroy 三个方法和 LineChart 几乎一样——这就是抽出基类的信号。挑战题里我们会让你提取
BaseChart。
# 6.5 饼图(极坐标)
📁 src/chart/PieChart.js:
export class PieChart {
constructor(container) {
this.container = container;
this.canvas = document.createElement('canvas');
container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
this.resize(400, 400);
this.colors = ['#89b4fa', '#a6e3a1', '#f9e2af', '#fab387', '#cba6f7', '#f38ba8'];
}
resize(cssW, cssH) {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = cssW * dpr;
this.canvas.height = cssH * dpr;
this.canvas.style.width = cssW + 'px';
this.canvas.style.height = cssH + 'px';
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.cssW = cssW; this.cssH = cssH;
}
render(data) {
const ctx = this.ctx;
const cx = this.cssW / 2, cy = this.cssH / 2;
const radius = Math.min(cx, cy) * 0.8;
ctx.clearRect(0, 0, this.cssW, this.cssH);
const total = data.reduce((s, d) => s + d.value, 0);
let startAngle = -Math.PI / 2; // 从 12 点钟方向开始
data.forEach((d, i) => {
const angle = (d.value / total) * Math.PI * 2;
ctx.fillStyle = this.colors[i % this.colors.length];
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + angle);
ctx.closePath();
ctx.fill();
// 标签
const midAngle = startAngle + angle / 2;
const tx = cx + Math.cos(midAngle) * radius * 0.7;
const ty = cy + Math.sin(midAngle) * radius * 0.7;
ctx.fillStyle = '#1e1e2e';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${d.label} ${(d.value / total * 100).toFixed(1)}%`, tx, ty);
startAngle += angle;
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
JS 知识点 · 极坐标三件套:
Math.cos(angle) * r→ x 偏移Math.sin(angle) * r→ y 偏移Math.PI * 2 = 360°,1° = π/180
🧪 第三次刷新(三种图都画出来):
import { LineChart } from './chart/LineChart.js';
import { BarChart } from './chart/BarChart.js';
import { PieChart } from './chart/PieChart.js';
const app = document.querySelector('#app');
const lineCard = document.createElement('div'); lineCard.className = 'card'; app.appendChild(lineCard);
new LineChart(lineCard).render([
{ x: 1, y: 30 }, { x: 2, y: 50 }, { x: 3, y: 45 },
{ x: 4, y: 80 }, { x: 5, y: 65 }, { x: 6, y: 90 },
]);
const barCard = document.createElement('div'); barCard.className = 'card'; app.appendChild(barCard);
new BarChart(barCard).render([
{ x: 1, y: 30 }, { x: 2, y: 50 }, { x: 3, y: 45 }, { x: 4, y: 80 },
]);
const pieCard = document.createElement('div'); pieCard.className = 'card'; app.appendChild(pieCard);
new PieChart(pieCard).render([
{ label: '前端', value: 35 },
{ label: '后端', value: 28 },
{ label: '运维', value: 12 },
{ label: '产品', value: 25 },
]);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
✅ 三种图同时在页面上 = 阶段 ⑤ 静态部分完成。
# 6.6 接通 react
到现在 chart.render(data) 还要手动调——让 effect 包住它,数据变就自动重绘。
📁 src/main.js:
import { reactive } from './reactivity/reactive.js';
import { effect } from './reactivity/effect.js';
import { LineChart } from './chart/LineChart.js';
const state = reactive({
series: [
{ x: 1, y: 30 }, { x: 2, y: 50 }, { x: 3, y: 45 },
{ x: 4, y: 80 }, { x: 5, y: 65 }, { x: 6, y: 90 },
],
});
const card = document.createElement('div'); card.className = 'card';
document.querySelector('#app').appendChild(card);
const chart = new LineChart(card);
// ⭐ 灵魂操作:effect 包住 render
effect(() => {
console.log('[render] 数据点数 =', state.series.length);
// 注意:不能直接传 state.series(深响应式 Proxy)
// 要展开成普通数组让 Canvas 拿到原始值
chart.render(state.series.map((p) => ({ x: p.x, y: p.y })));
});
// 模拟 1 秒后追加一条数据
setTimeout(() => {
state.series.push({ x: 7, y: 75 });
}, 1000);
// 再过 1 秒改首点
setTimeout(() => {
state.series[0].y = 10;
}, 2000);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
🧪 第四次刷新(响应式 + 图表联动):
预期现象:
- 立刻渲染折线图(6 个点)
- 1 秒后自动画出第 7 个点(不需要手动调 render)
- 2 秒后自动首点降到 10 的位置(线条形状变化)
Console 输出:
[render] 数据点数 = 6
[render] 数据点数 = 7
[render] 数据点数 = 7
2
3
🎉 响应式 + Canvas 终于联动——这正是 Vue 3 + ECharts 集成时 watch data 自动 setOption 的入门版。
💡 进阶细节:连续多次 mutation 在阶段 ③ 已经合并成一帧——所以即使
for (let i = 0; i < 100; i++) state.series.push(...),render 也只跑一次。这是性能保证的根基。
┌─ 📌 阶段 ⑤ 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 5.1 LineChart 第一版(故意按 CSS 像素画) │
│ • Step 5.2 dpr 修复 + ResizeObserver 自适应 │
│ • Step 5.3 BarChart 复用坐标系工具 │
│ • Step 5.4 PieChart 极坐标 + 标签定位 │
│ • Step 5.5 effect 包 render → 数据驱动 UI 闭环 │
│ │
│ 📊 现在 chart/ 共 5 个文件 ~ 250 行: │
│ Coordinate.js / LineChart.js / BarChart.js │
│ PieChart.js / Renderer.js(待阶段⑥重构提取) │
│ │
│ ⏸ 还没碰的: │
│ • 数据从哪来(fetch / WebSocket / IndexedDB)—— 阶段 ⑥ │
│ • 海量数据排序卸载 Worker —— 阶段 ⑦ │
│ • 模板引擎 + Devtools —— 阶段 ⑧ │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage5: charts" │
│ │
│ 💡 本阶段最大领悟: │
│ "Canvas dpr 修复 = 物理像素 / CSS 像素 / setTransform 三 │
│ 步走。亲眼看到模糊才记得住——这是 ECharts 第一行代码" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 07.数据流水线
┌─ 🎯 阶段 ⑥ 目标 ──────────────────────────────────────┐
│ 完成什么:三源合一(HTTP 历史 / WebSocket 实时 / IDB 缓存)│
│ 不做什么:不做 OAuth、不做后端服务(用 mock + 公共 echo)│
│ 验收标准:首屏从 IDB 秒出 + HTTP 拉历史补齐 + WS 实时刷 │
│ 断网自动重连 + 关闭页面写回 IDB │
│ 预计耗时:90 分钟 │
│ 关键思路:3 个 Source 类,每个独立可测;最后 Pipeline 串 │
│ 成一条链路喂给 reactive state │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 7.1 技术灵魂三问
❓ 为什么要三源?只用 WebSocket 不行吗?
| 场景 | 单 WS 的问题 | 三源解法 |
|---|---|---|
| 首屏加载 | WS 还没握手完成,UI 空白 | IDB 立刻给上次缓存 |
| 断网恢复 | WS 重连后只有未来数据 | HTTP 拉过去 5 分钟补齐 |
| 历史回看 | WS 不会推过去的数据 | HTTP 按时间段查询 |
| 离线浏览 | WS 完全没用 | IDB 全离线可用 |
这就是 Grafana / Prometheus / 网易有数等产品的标准架构——没有一个单一数据源能覆盖所有场景。
❓ 三源的优先级谁先谁后?
首屏(页面打开):
IDB(毫秒级)→ 立刻渲染
↓
HTTP(拉过去 N 分钟)→ 补齐到现在
↓
WS(建立长连接)→ 持续推送未来
数据写入:
WS 推送 → 进 reactive state → 触发图表重绘
↓
定时(每 30s)写 IDB 持久化
2
3
4
5
6
7
8
9
10
11
关键点:HTTP 和 WS 接合处必须去重——HTTP 给的最后一条数据和 WS 推送的第一条会重叠。用时间戳作主键去重即可。
❓ 第一步先做哪个? 答:HttpSource——它最简单(复用 jsfeed §02 已写好的 pLimit + AbortController),跑通后再加 WS 和 IDB。
# 7.2 HttpSour
📁 public/mock/cpu.json(造一份模拟数据):
{ "series": [
{ "ts": 1720000000, "value": 35 },
{ "ts": 1720000060, "value": 42 },
{ "ts": 1720000120, "value": 38 },
{ "ts": 1720000180, "value": 51 }
] }
2
3
4
5
6
📁 src/data/HttpSource.js:
// 👉 假设 jsfeed 已经写好了 pLimit;本案例直接 inline 一个最简版
function pLimit(n) {
let active = 0;
const queue = [];
const next = () => {
if (active >= n || !queue.length) return;
active++;
const { fn, resolve, reject } = queue.shift();
fn().then(resolve, reject).finally(() => { active--; next(); });
};
return (fn) => new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject }); next();
});
}
export class HttpSource {
#limit = pLimit(5); // 最多 5 个并发
async fetchRange(metric, from, to, signal) {
const url = `/mock/${metric}.json?from=${from}&to=${to}`;
return this.#limit(async () => {
const res = await fetch(url, { signal });
if (!res.ok) throw new HttpError(`HTTP ${res.status}`, res.status);
const json = await res.json();
// 按时间戳过滤(mock 文件没有真分页)
return json.series.filter((p) => p.ts >= from && p.ts <= to);
});
}
}
// 卷一第 9 章:自定义 Error 体系
export class HttpError extends Error {
constructor(msg, status) {
super(msg);
this.name = 'HttpError';
this.status = status;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
🧪 立刻验证:
import { HttpSource } from './data/HttpSource.js';
const http = new HttpSource();
const ctrl = new AbortController();
http.fetchRange('cpu', 1720000000, 1720000200, ctrl.signal)
.then((arr) => console.log('[HTTP] 拿到', arr.length, '条'))
.catch((e) => console.error('[HTTP] 失败:', e.name, e.message));
// 演示取消
setTimeout(() => ctrl.abort(), 50);
2
3
4
5
6
7
8
9
10
✅ HttpSource 跑通——和 jsfeed §02 同款思路。
# 7.3 WsSource
📁 src/data/WsSource.js:
import { EventEmitter } from '../events/EventEmitter.js';
export class WsSource extends EventEmitter {
#url;
#ws = null;
#retries = 0;
#heartbeatTimer = null;
#closed = false;
constructor(url) { super(); this.#url = url; }
connect() {
this.#closed = false;
try {
this.#ws = new WebSocket(this.#url);
} catch (e) {
this.#scheduleReconnect();
return;
}
this.#ws.onopen = () => {
this.#retries = 0;
this.emit('open');
this.#startHeartbeat();
};
this.#ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
// 服务端可能回 pong,过滤
if (data.type === 'pong') return;
this.emit('data', data);
} catch (e) {
this.emit('error', new WsParseError(ev.data));
}
};
this.#ws.onerror = (e) => this.emit('error', e);
this.#ws.onclose = () => {
this.#stopHeartbeat();
this.emit('close');
if (!this.#closed) this.#scheduleReconnect();
};
}
send(payload) {
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#ws.send(JSON.stringify(payload));
}
}
close() {
this.#closed = true;
this.#stopHeartbeat();
this.#ws?.close();
}
#startHeartbeat() {
this.#stopHeartbeat();
this.#heartbeatTimer = setInterval(() => {
this.send({ type: 'ping', ts: Date.now() });
}, 25_000);
}
#stopHeartbeat() {
clearInterval(this.#heartbeatTimer);
this.#heartbeatTimer = null;
}
#scheduleReconnect() {
// ⭐ 指数退避 + jitter(卷一第 7 章异步实战)
const delay = Math.min(1000 * 2 ** this.#retries, 30_000) + Math.random() * 1000;
this.#retries++;
this.emit('reconnecting', { retries: this.#retries, delayMs: delay });
setTimeout(() => !this.#closed && this.connect(), delay);
}
}
export class WsParseError extends Error {
constructor(raw) { super('WebSocket message parse failed'); this.name = 'WsParseError'; this.raw = raw; }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
JS 知识点 · 指数退避(jsfeed §02 学过):第 1 次 1s、第 2 次 2s、4s、8s、16s、30s 上限。+ random 是 jitter —— 防止"惊群效应"(千人同时重连把服务端打挂)。
🧪 验证(用公共 echo 服务):
import { WsSource } from './data/WsSource.js';
const ws = new WsSource('wss://echo.websocket.events');
ws.on('open', () => { console.log('[WS] 已连接'); ws.send({ hello: 'jschart' }); });
ws.on('data', (d) => console.log('[WS] 收到', d));
ws.on('reconnecting', (i) => console.log(`[WS] 第 ${i.retries} 次重连,等 ${i.delayMs}ms`));
ws.on('close', () => console.log('[WS] 关闭'));
ws.connect();
// 5 秒后断开看重连
setTimeout(() => ws.close(), 5000);
2
3
4
5
6
7
8
9
10
11
✅ WS + 心跳 + 指数退避完整链路通。
# 7.4 IndexedD
IndexedDB 原生 API 全是回调,必须先 Promise 化才能 await。
📁 src/data/IdbCache.js:
const DB_NAME = 'jschart';
const STORE = 'metrics';
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
if (!db.objectStoreNames.contains(STORE)) {
// 主键 (metric, ts) 用复合键
const store = db.createObjectStore(STORE, { keyPath: ['metric', 'ts'] });
store.createIndex('byMetric', 'metric', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function tx(db, mode = 'readonly') {
return db.transaction(STORE, mode).objectStore(STORE);
}
export class IdbCache {
#db = null;
async ready() { return (this.#db ??= await openDb()); }
async putBatch(metric, points) {
const db = await this.ready();
const store = tx(db, 'readwrite');
return new Promise((resolve, reject) => {
points.forEach((p) => store.put({ metric, ts: p.ts, value: p.value }));
store.transaction.oncomplete = () => resolve(points.length);
store.transaction.onerror = () => reject(store.transaction.error);
});
}
async getRange(metric, from, to) {
const db = await this.ready();
return new Promise((resolve, reject) => {
const result = [];
const range = IDBKeyRange.bound([metric, from], [metric, to]);
const req = tx(db).openCursor(range);
req.onsuccess = () => {
const cur = req.result;
if (!cur) return resolve(result);
result.push({ ts: cur.value.ts, value: cur.value.value });
cur.continue();
};
req.onerror = () => reject(req.error);
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
JS 知识点 · IDBKeyRange:
bound(low, high):闭区间lowerBound(low):≥upperBound(high):≤
复合键 [metric, ts] 自动按字典序排序——单 metric 内按 ts 升序。
🧪 验证:
import { IdbCache } from './data/IdbCache.js';
const idb = new IdbCache();
await idb.putBatch('cpu', [
{ ts: 1, value: 30 }, { ts: 2, value: 40 }, { ts: 3, value: 50 },
]);
console.log('[IDB] 范围查询 1~2:', await idb.getRange('cpu', 1, 2));
2
3
4
5
6
7
预期:[{ts:1,value:30}, {ts:2,value:40}]。
# 7.5 Pipeline
📁 src/data/Pipeline.js:
import { reactive } from '../reactivity/reactive.js';
import { HttpSource } from './HttpSource.js';
import { WsSource } from './WsSource.js';
import { IdbCache } from './IdbCache.js';
export class Pipeline {
state = reactive({ series: [], status: 'idle' });
#http = new HttpSource();
#idb = new IdbCache();
#ws = null;
#metric;
#abortCtrl = null;
constructor(metric, wsUrl) {
this.#metric = metric;
if (wsUrl) this.#ws = new WsSource(wsUrl);
}
async start({ from, to }) {
this.state.status = 'loading';
// ① IDB 秒出(首屏体验)
const cached = await this.#idb.getRange(this.#metric, from, to);
if (cached.length) {
this.state.series = cached;
this.state.status = 'fromCache';
console.log('[Pipeline] 缓存命中', cached.length, '条');
}
// ② HTTP 补齐(精确范围)
this.#abortCtrl = new AbortController();
try {
const fresh = await this.#http.fetchRange(
this.#metric, from, to, this.#abortCtrl.signal,
);
this.state.series = mergeUniq(this.state.series, fresh);
this.state.status = 'loaded';
// 异步写回 IDB
this.#idb.putBatch(this.#metric, fresh).catch(console.warn);
} catch (e) {
if (e.name !== 'AbortError') {
console.warn('[Pipeline] HTTP 失败,使用缓存', e);
this.state.status = 'fallback';
}
}
// ③ WS 实时
if (this.#ws) {
this.#ws.on('data', (point) => {
this.state.series = mergeUniq(this.state.series, [point]);
});
this.#ws.connect();
}
}
stop() {
this.#abortCtrl?.abort();
this.#ws?.close();
}
}
// 按 ts 去重 + 排序
function mergeUniq(a, b) {
const map = new Map();
[...a, ...b].forEach((p) => map.set(p.ts, p));
return [...map.values()].sort((x, y) => x.ts - y.ts);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
关键设计:
state是 reactive——外部effect(() => chart.render(state.series))自动接收所有源的更新mergeUniq用Map<ts, point>实现 O(n) 去重start是 async,但 effect 会在每次 state 变化时增量重绘
🧪 端到端验证:
import { Pipeline } from './data/Pipeline.js';
import { effect } from './reactivity/effect.js';
import { LineChart } from './chart/LineChart.js';
const card = document.createElement('div'); card.className = 'card';
document.querySelector('#app').appendChild(card);
const chart = new LineChart(card);
const pipeline = new Pipeline('cpu');
effect(() => {
console.log(`[render] status=${pipeline.state.status}, n=${pipeline.state.series.length}`);
chart.render(pipeline.state.series.map((p) => ({ x: p.ts, y: p.value })));
});
pipeline.start({ from: 1720000000, to: 1720000300 });
2
3
4
5
6
7
8
9
10
11
12
13
14
15
预期 Console:
[render] status=idle, n=0
[render] status=loaded, n=4 ← HTTP 拉到 4 条数据
(IDB 命中时还会先打印 status=fromCache)
2
3
✅ 响应式 + 三源 + 图表三件套大融合 = 阶段 ⑥ 完成。
┌─ 📌 阶段 ⑥ 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 6.1 HttpSource(复用 jsfeed pLimit + Abort) │
│ • Step 6.2 WsSource(心跳 + 指数退避 + jitter) │
│ • Step 6.3 IdbCache Promise 化(复合键 + 范围查询) │
│ • Step 6.4 Pipeline 三源合一 + state 暴露给 effect │
│ │
│ 📊 现在 data/ 共 4 个文件 ~ 280 行: │
│ HttpSource / WsSource / IdbCache / Pipeline │
│ │
│ 🎯 关键一刻:Pipeline.state = reactive({...})—— │
│ 上层 effect 不关心数据来自哪个源,全自动重绘 │
│ │
│ ⏸ 还没碰的: │
│ • 海量数据排序 / Worker —— 阶段 ⑦ │
│ • 模板引擎 + Devtools —— 阶段 ⑧ │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage6: data pipeline" │
│ │
│ 💡 本阶段最大领悟: │
│ "三源合一的本质是'统一出口 reactive state'—— │
│ 上层只订阅 state,不关心数据从哪来。这就是 Grafana 的灵魂"│
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 08.Worker 与调度
┌─ 🎯 阶段 ⑦ 目标 ──────────────────────────────────────┐
│ 完成什么:万级数据排序甩到 Worker;rAF 渲染节流 │
│ 不做什么:不做 SharedWorker、不做 Atomics(卷三才讲) │
│ 验收标准:10 万点排序时主线程仍 60FPS(FpsMeter 可见) │
│ Transferable 对比 JSON.stringify 性能差异显著 │
│ 预计耗时:60 分钟 │
│ 关键思路:先朴素 Worker → 故意 JSON.stringify 大数据 │
│ → 看到主线程卡顿 → Transferable ArrayBuffer 修复│
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 8.1 技术灵魂三问
❓ 不开 Worker 会怎样?
// ❌ 主线程排序 10 万条
state.series.sort((a, b) => a.ts - b.ts); // 几百 ms 主线程冻结
2
JS 是单线程——主线程一旦卡 50ms,用户就能感到"页面僵住"。Worker 是 JS 唯一的真并行——独立线程跑代码,主线程毫不阻塞。
❓ Worker 和主线程怎么通信?
主线程 Worker
──postMessage(msg)──→ onmessage = (ev) => {...}
onmessage = (ev) => {} ←──postMessage(reply)──
2
3
关键限制:消息传递是结构化克隆(深拷贝)——传 100MB 数据 = 拷 100MB,比序列化 JSON 快但仍然慢。Transferable 可以"零拷贝转移所有权"——后面会演示。
❓ 第一步先做什么? 答:先把最朴素的 Worker 写出来,故意用 JSON.stringify 传大数据,看到性能塌方再修复。
# 8.2 朴素 Worke
📁 src/worker/sort.worker.js:
// Worker 是独立线程,没有 window / document
// 用 ESM Worker(modules: type='module')支持 import
self.onmessage = (ev) => {
const t0 = performance.now();
const arr = ev.data.arr; // ⚠️ 第一版:结构化克隆传整个数组
arr.sort((a, b) => a.ts - b.ts);
const elapsed = performance.now() - t0;
self.postMessage({ arr, elapsed }); // ⚠️ 同样克隆传回来
};
2
3
4
5
6
7
8
9
📁 src/worker/WorkerPool.js(最简单的封装):
export class SortWorker {
#worker;
constructor() {
// ⭐ Vite 5 内置支持 import.meta.url + new Worker,自动构建
this.#worker = new Worker(
new URL('./sort.worker.js', import.meta.url),
{ type: 'module' }
);
}
sort(arr) {
return new Promise((resolve, reject) => {
const onMsg = (ev) => {
this.#worker.removeEventListener('message', onMsg);
resolve(ev.data);
};
this.#worker.addEventListener('message', onMsg);
this.#worker.addEventListener('error', reject, { once: true });
this.#worker.postMessage({ arr }); // ⚠️ 第一版
});
}
destroy() { this.#worker.terminate(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
📁 src/main.js 测试:
import { SortWorker } from './worker/WorkerPool.js';
// 造 10 万条乱序数据
const big = Array.from({ length: 100_000 }, (_, i) => ({
ts: Math.random() * 1e9,
value: i,
}));
const w = new SortWorker();
console.time('worker total');
const t0 = performance.now();
const { arr, elapsed } = await w.sort(big);
console.timeEnd('worker total');
console.log(`Worker 内排序耗时: ${elapsed.toFixed(1)}ms`);
console.log(`总耗时(含传输): ${(performance.now() - t0).toFixed(1)}ms`);
// 同时主线程跑 30 帧 rAF 看是否卡
let frames = 0;
const startFps = performance.now();
function tick() {
frames++;
if (frames < 30) requestAnimationFrame(tick);
else console.log(`30 帧耗时 ${(performance.now() - startFps).toFixed(1)}ms(< 500ms 才算 60FPS)`);
}
requestAnimationFrame(tick);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
🧪 第一次跑(看到性能塌方):
预期输出(坏的):
worker total: 850ms
Worker 内排序耗时: 120.3ms ← 真正排序很快
总耗时(含传输): 850.2ms ← 但传输占了 730ms!
30 帧耗时 720ms ← 主线程也被 postMessage 序列化卡住
2
3
4
🚨 真相:
postMessage(big)时浏览器要对 10 万对象做结构化克隆——这一步在主线程跑,比 JSON.stringify 还重。"开了 Worker"≠"主线程不卡"!
# 8.3 Transfer
🛠 修复思路:用 ArrayBuffer 装数据,传输时 transfer 转移所有权——主线程那份 ArrayBuffer 立刻 detach(不可访问),Worker 直接拿到内存指针,零拷贝。
📁 改 src/main.js:
// 重构数据:把 [{ts, value}] 编码成 Float64Array
// 每个点占 16 字节(2 × float64)
function encode(points) {
const buf = new Float64Array(points.length * 2);
points.forEach((p, i) => { buf[i * 2] = p.ts; buf[i * 2 + 1] = p.value; });
return buf;
}
function decode(buf) {
const out = [];
for (let i = 0; i < buf.length; i += 2) out.push({ ts: buf[i], value: buf[i + 1] });
return out;
}
const buf = encode(big);
console.time('worker total v2');
const result = await w.sortBuffer(buf.buffer); // ⭐ 传 ArrayBuffer,不传对象
console.timeEnd('worker total v2');
const sorted = decode(new Float64Array(result));
console.log('排序后第一条:', sorted[0]);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
📁 改 src/worker/WorkerPool.js:
sortBuffer(arrayBuffer) {
return new Promise((resolve, reject) => {
const onMsg = (ev) => {
this.#worker.removeEventListener('message', onMsg);
resolve(ev.data);
};
this.#worker.addEventListener('message', onMsg);
this.#worker.addEventListener('error', reject, { once: true });
// ⭐ 第 2 个参数:transfer 列表
this.#worker.postMessage(arrayBuffer, [arrayBuffer]);
});
}
2
3
4
5
6
7
8
9
10
11
12
📁 改 src/worker/sort.worker.js:
self.onmessage = (ev) => {
const buf = new Float64Array(ev.data);
const n = buf.length / 2;
// 把 (ts, value) 二元组按 ts 排序——用 typed array 索引交换
// 简单做法:转成 [{ts, value}] 排序再写回
const arr = new Array(n);
for (let i = 0; i < n; i++) arr[i] = { ts: buf[i * 2], value: buf[i * 2 + 1] };
arr.sort((a, b) => a.ts - b.ts);
for (let i = 0; i < n; i++) { buf[i * 2] = arr[i].ts; buf[i * 2 + 1] = arr[i].value; }
// ⭐ 传回也用 transfer
self.postMessage(buf.buffer, [buf.buffer]);
};
2
3
4
5
6
7
8
9
10
11
12
🧪 第二次跑(验证修复):
预期输出(好的):
worker total v2: 145ms ← 总耗时 145ms(含传输)
30 帧耗时 500ms ← 主线程满帧
2
🎉 从 850ms → 145ms,6 倍提速。Transferable 的本质是所有权转移——主线程那个 ArrayBuffer 立刻"被掏空"(length=0),Worker 拿到原始内存。
JS 知识点 · 哪些类型支持 Transferable?
| 类型 | 可 Transfer |
|---|---|
ArrayBuffer | ✅ |
MessagePort | ✅ |
OffscreenCanvas | ✅ |
ImageBitmap | ✅ |
| 普通对象 / 数组 | ❌(必须结构化克隆) |
这是为什么 jsplayer §03 也用 ArrayBuffer 喂 MSE——浏览器二进制数据通道全是 ArrayBuffer。
# 8.4 rAF 渲染调度
阶段 ③ 已经做了"微任务批量"——但仍然是同步合并:
state.series.push(point); // 微任务结束触发 effect
state.series.push(point); // 同 tick → 合并
// 但下一秒又来 1 个 → 又触发一次 effect
2
3
如果 WebSocket 每秒推 30 条数据 → 30 次微任务 → 30 次 effect → 30 次 render——浪费。
🛠 进阶调度:把 render 这种"屏幕级"操作放进 requestAnimationFrame
📁 src/reactivity/scheduler.js 增强:
const microQueue = new Set();
const rafQueue = new Set();
let microFlushing = false;
let rafScheduled = false;
export function queueJob(job) {
microQueue.add(job);
if (!microFlushing) {
microFlushing = true;
Promise.resolve().then(() => {
try { [...microQueue].forEach((j) => j()); }
finally { microQueue.clear(); microFlushing = false; }
});
}
}
// ⭐ 新增:屏幕级任务(render / layout)走 rAF
export function queueRender(job) {
rafQueue.add(job);
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
try { [...rafQueue].forEach((j) => j()); }
finally { rafQueue.clear(); rafScheduled = false; }
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
📁 使用方式:在 effect 里用 scheduler 选项让 render 走 rAF:
import { effect } from './reactivity/effect.js';
import { queueRender } from './reactivity/scheduler.js';
effect(
() => chart.render(pipeline.state.series.map((p) => ({ x: p.ts, y: p.value }))),
{ scheduler: queueRender } // ⭐ render 由 rAF 控制
);
2
3
4
5
6
7
🧪 第三次跑(30 条/秒推送下的 FPS 验证):
// 模拟 WS 高频推送
let count = 0;
const timer = setInterval(() => {
for (let i = 0; i < 30; i++) {
pipeline.state.series.push({ ts: Date.now() + i, value: Math.random() * 100 });
}
count++;
if (count >= 60) clearInterval(timer);
}, 100);
2
3
4
5
6
7
8
9
预期:每 100ms 推 30 条 → 累计 1800 条 → render 仍然按 60FPS(每 16.6ms 一帧)执行,多次 mutation 在同一帧内合并成一次 render。
✅ rAF 调度 + Worker 异步排序 + Transferable 零拷贝 = 阶段 ⑦ 完成。
┌─ 📌 阶段 ⑦ 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 7.1 朴素 Worker(结构化克隆传大数据 → 850ms 卡) │
│ • Step 7.2 Transferable ArrayBuffer 零拷贝 → 145ms │
│ • Step 7.3 rAF 渲染调度(屏幕级任务一帧一次) │
│ │
│ 📊 现在 worker/ + scheduler 共 3 个文件 ~ 100 行: │
│ sort.worker.js / WorkerPool.js / scheduler.js(增强) │
│ │
│ ⏸ 还没碰的: │
│ • 模板引擎 + Devtools FPS 计 + 网络火焰图 —— 阶段 ⑧ │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage7: worker + raf" │
│ │
│ 💡 本阶段最大领悟: │
│ "Worker 不等于不卡——postMessage 也走主线程。 │
│ Transferable 才是真正的零拷贝,6 倍速差距就在这一行" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 09.模板引擎 + Devtools
┌─ 🎯 阶段 ⑧ 目标 ──────────────────────────────────────┐
│ 完成什么:手写最简模板引擎({ { expr } })+ Devtools 面板 │
│ 不做什么:不做 v-if / v-for(留作挑战) │
│ 验收标准:模板字符串 + state → 能渲染出 HTML 字符串 │
│ Devtools 显示实时 FPS / effect 重跑次数 / 内存│
│ 预计耗时:60 分钟 │
│ 关键思路:模板引擎 = 字符串扫描 + new Function 求值; │
│ Devtools = effect 计数器 + rAF FPS 表 │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
# 9.1 技术灵魂三问
❓ 既然有 Vue 为什么自己写模板引擎? 答:就是为了看清"Vue 模板编译做了什么"——{{ expr }} 替换其实是正则扫描 + new Function 两件事。Vue 模板编译比这复杂 20 倍(生成 render 函数、vnode、diff),但起点是同一行 new Function。
❓ Devtools 为什么要做? 答:响应式系统是黑盒——你不知道某个变量改一下到底触发了多少 effect、render 跑了几次、内存涨了多少。Devtools 把这些"运行时元数据"显式化,是排查性能问题的唯一手段。Vue Devtools / React Profiler 的灵感来源都在此。
❓ 第一步先做哪个? 答:模板引擎——它最简单,输出是字符串;Devtools 要依赖响应式系统的 hooks 才能计数。
# 9.2 模板引擎 
📁 src/template/compile.js:
/**
* 把模板字符串编译成函数
* 输入:'<h1>{{ user.name }}</h1>'
* 输出:(scope) => `<h1>${scope.user.name}</h1>`
*/
export function compile(template) {
// 1. 扫描所有 {{ expr }} → 替换成 ${(expr)}
const code = template.replace(/\{\{\s*([\s\S]+?)\s*\}\}/g, (_, expr) => {
return '${(' + expr.trim() + ')}';
});
// 2. 用 new Function 把模板字符串变成箭头函数
// 注意:scope 用 with 块——能直接写 user.name 不写 scope.user.name
// ⚠️ 严格模式不支持 with,所以这里拼 sloppy 函数体
const body = `with (scope) { return \`${code}\`; }`;
// eslint-disable-next-line no-new-func
return new Function('scope', body);
}
/** 渲染并自动 escape(XSS 防护,安全第一)*/
export function render(template, scope) {
return compile(template)(scope);
}
// 简易 escape(真实项目用 DOMPurify)
export function escape(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
JS 知识点 · new Function 的 3 种用途:
- 动态代码:编译模板、JSON 表达式
- 沙箱:隔离全局作用域(虽然不安全,需配合 Proxy)
- JIT 优化点:每次 new Function 都生成一个独立闭包,V8 会单独优化
安全提示 ⚠️:
new Function(userInput)=eval——绝对不允许直接喂用户输入!本案例的 template 来自开发者代码,不是用户输入。生产环境用模板编译框架(Vue Compiler / Mustache)规避这个风险。
🧪 验证:
import { compile, render, escape } from './template/compile.js';
const tpl = `
<div class="card">
<h2>{{ title }}</h2>
<p>状态:{{ loaded ? '✅ 已加载' : '⏳ 加载中' }}</p>
<p>数据点数:{{ points.length }}</p>
<p>最新值:{{ points[points.length - 1]?.value ?? '-' }}</p>
</div>
`;
const html = render(tpl, {
title: 'CPU 使用率',
loaded: true,
points: [{ value: 35 }, { value: 42 }, { value: 38 }],
});
document.querySelector('#app').insertAdjacentHTML('beforeend', html);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
✅ {{ ... }} 模板插值跑通——支持任意 JS 表达式(三元、可选链、空值合并)。
# 9.3 Devtools
📁 src/devtools/FpsMeter.js:
import { reactive } from '../reactivity/reactive.js';
export class FpsMeter {
state = reactive({ fps: 0, frame: 0, jank: 0 });
#lastTs = performance.now();
#frames = 0;
#running = false;
start() {
this.#running = true;
const tick = (now) => {
if (!this.#running) return;
this.#frames++;
this.state.frame++;
const dt = now - this.#lastTs;
if (dt > 33) this.state.jank++; // > 33ms = 一帧丢了 = 卡顿
if (now - this.#lastTs >= 1000) {
this.state.fps = this.#frames;
this.#frames = 0;
this.#lastTs = now;
}
requestAnimationFrame(tick);
};
requestAnimationFrame((t) => { this.#lastTs = t; tick(t); });
}
stop() { this.#running = false; }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 9.4 Inspecto
为了让 Devtools 知道每次 effect 跑了几次,要在 effect.js 暴露一个钩子。
📁 src/reactivity/effect.js 加:
// 在文件顶部加:
export const stats = { effectRuns: 0, triggers: 0 };
// 在 runner 内 fn() 调用前 +1:
stats.effectRuns++;
return fn();
// 在 trigger 内 dep 遍历前 +1:
stats.triggers++;
2
3
4
5
6
7
8
9
📁 src/devtools/Inspector.js:
import { stats } from '../reactivity/effect.js';
import { FpsMeter } from './FpsMeter.js';
import { reactive } from '../reactivity/reactive.js';
import { effect } from '../reactivity/effect.js';
import { render } from '../template/compile.js';
export class Inspector {
state = reactive({
fps: 0, frame: 0, jank: 0,
effectRuns: 0, triggers: 0,
memMB: 0,
});
#fpsMeter = new FpsMeter();
#panel;
mount(host) {
this.#panel = document.createElement('div');
this.#panel.style.cssText = `
position:fixed; right:10px; bottom:10px; z-index:9999;
background:#11111b; color:#cdd6f4; padding:10px 14px;
border:1px solid #45475a; border-radius:6px;
font:12px/1.5 ui-monospace, monospace; min-width:220px;
`;
host.appendChild(this.#panel);
this.#fpsMeter.start();
// 每秒采样一次(独立 setInterval,不和 reactive 耦合)
setInterval(() => {
this.state.fps = this.#fpsMeter.state.fps;
this.state.frame = this.#fpsMeter.state.frame;
this.state.jank = this.#fpsMeter.state.jank;
this.state.effectRuns = stats.effectRuns;
this.state.triggers = stats.triggers;
// performance.memory 是 Chrome 私有 API
this.state.memMB = performance.memory
? +(performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(1)
: 0;
}, 1000);
// 用 effect + 模板渲染面板
const tpl = `
<div style="font-weight:bold;margin-bottom:6px">📊 jschart Devtools</div>
<div>FPS: <b style="color:{{ fps >= 55 ? '#a6e3a1' : '#f38ba8' }}">{{ fps }}</b></div>
<div>Jank: {{ jank }} 帧</div>
<div>effect 重跑: {{ effectRuns }}</div>
<div>trigger 触发: {{ triggers }}</div>
<div>内存: {{ memMB }} MB</div>
`;
effect(() => { this.#panel.innerHTML = render(tpl, this.state); });
}
destroy() {
this.#fpsMeter.stop();
this.#panel?.remove();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
关键设计:
- Inspector 自己也是 reactive 数据 → 用
effect + render自动刷新面板(自举:Devtools 用响应式系统观察响应式系统) - FPS 用独立 rAF 计数;effect / trigger 通过 stats 全局计数器
performance.memory是 Chrome 独有,其他浏览器降级为 0
🧪 端到端验证:
import { Inspector } from './devtools/Inspector.js';
const ins = new Inspector();
ins.mount(document.body);
2
3
4
预期现象:右下角浮动小面板,每秒刷新一次:
📊 jschart Devtools
FPS: 60 ← 绿色(>= 55)
Jank: 0 帧
effect 重跑: 1234
trigger 触发: 567
内存: 24.3 MB
2
3
4
5
6
故意制造卡顿验证:
// 主线程跑 200ms 死循环
const start = Date.now(); while (Date.now() - start < 200) {}
// 看 jank 计数 +12(200ms / 16.6ms)
2
3
✅ Devtools 跑通 = 阶段 ⑧ 完成 = 整个项目大功告成!
┌─ 📌 阶段 ⑧ 小结 ──────────────────────────────────────┐
│ ✅ 你刚刚完成的事: │
│ • Step 8.1 模板引擎(正则 + new Function) │
│ • Step 8.2 FpsMeter(rAF 计帧 + 33ms jank 阈值) │
│ • Step 8.3 Inspector(自举:用响应式监控响应式) │
│ │
│ 📊 现在 template/ + devtools/ 共 3 个文件 ~ 100 行: │
│ compile.js / FpsMeter.js / Inspector.js │
│ │
│ 📌 进入下阶段前务必: │
│ git add . && git commit -m "stage8: template + devtools" │
│ │
│ 💡 本阶段最大领悟: │
│ "Devtools 是响应式系统的内省(reflection)—— │
│ 用响应式去观察响应式,递归优雅地完成自举" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 10.项目总结分析
# 10.1 最终目录结构
jschart/ 总计 ~3500 行 / 20+ 文件
├── index.html SPA 入口
├── package.json 仅 vite 一个 devDep
├── vite.config.js
└── src/
├── main.js 应用启动器
├── reactivity/ 响应式核心 (~250 行)
│ ├── reactive.js Proxy + WeakMap 缓存
│ ├── effect.js 栈 + cleanup + stats
│ ├── computed.js dirty 缓存
│ ├── watch.js deep + onCleanup
│ └── scheduler.js micro + rAF 双队列
├── events/ 事件 / 插件 (~100 行)
│ ├── EventEmitter.js 私有字段 #
│ └── Plugin.js Symbol id + install/dispose
├── chart/ 图表引擎 (~250 行)
│ ├── Coordinate.js
│ ├── LineChart.js
│ ├── BarChart.js
│ ├── PieChart.js
│ └── Renderer.js (挑战题:抽 BaseChart)
├── data/ 数据流水线 (~280 行)
│ ├── HttpSource.js pLimit + Abort
│ ├── WsSource.js 心跳 + 指数退避
│ ├── IdbCache.js Promise 化 IDB
│ └── Pipeline.js 三源合一 → reactive
├── worker/ 并行 (~80 行)
│ ├── sort.worker.js Transferable
│ └── WorkerPool.js
├── template/ (~30 行)
│ └── compile.js new Function
├── devtools/ (~70 行)
│ ├── FpsMeter.js
│ └── Inspector.js
└── plugins/ (~80 行)
├── ExportPng.js
└── ExportCsv.js
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 10.2 八阶段成长曲线
| 阶段 | 节点 | 核心产出 | 累计代码 |
|---|---|---|---|
| ① §02 | 项目骨架 | Vite + ESM 入口 | 50 行 |
| ② §03 | Reactive 核心 | Proxy + WeakMap 依赖图 | 130 行 |
| ③ §04 | Effect 三件套 | computed / watch / scheduler | 330 行 |
| ④ §05 | EventEmitter + Plugin | 私有字段 + Symbol id | 430 行 |
| ⑤ §06 | Canvas 图表 | dpr + ResizeObserver + 三种图 | 680 行 |
| ⑥ §07 | 数据流水线 | HTTP + WS + IDB + Pipeline | 960 行 |
| ⑦ §08 | Worker + rAF | Transferable 6 倍速 | 1060 行 |
| ⑧ §09 | 模板 + Devtools | new Function + 自举 | 1160 行 |
📝 实际完成本案例的代码量约 1500 ~ 3500 行,取决于挑战题完成度(如插件、Worker 池扩展、模板支持 v-if/v-for)。
# 10.3 卷一 15 章
| 卷一章节 | 在 jschart 的真实落地 |
|---|---|
| 02 数据类型 | WeakMap 依赖图、Set 副作用集合、Map<name, Set<fn>> 监听器 |
| 03 运算符 | ?? ?. 防空(pieChart 标签)、解构、扩展运算符 |
| 04 函数 | effect 闭包、scheduler 高阶函数、模板 new Function |
| 05 面向对象 | Plugin extends EventEmitter、私有字段 #listeners #queue |
| 06 标准库 | Date Math URL JSON 全都用上 |
| 07 异步操作 | Promise 化 IDB、async iterator、rAF 调度、微任务批量 |
| 08 事件设计 | EventEmitter on/off/once/emit + EventTarget 对比 |
| 09 错误机制 | HttpError WsParseError 自定义 + Plugin 捕获 |
| 10 模块开发 | ESM 拆 20+ 文件、Vite 5 模块构建、Worker module |
| 11 字符串 | 模板引擎手写、escape XSS、模板字符串拼 HTML |
| 12 迭代器 | Pipeline.state.series 可 for...of、Set/Map 默认迭代 |
| 13 Symbol | Plugin.id = Symbol(...) 唯一标识 |
| 14 DOM | Canvas + ResizeObserver + dpr |
| 15 网络请求 | fetch + WebSocket + IndexedDB + AbortController |
✅ 15 章全部命中——这就是"毕业设计"的字面含义。
# 11.项目技术思考
# 11.1 Map vs W
| 维度 | Map | WeakMap |
|---|---|---|
| key 引用 | 强(GC 不回收) | 弱(key 没人用就回收) |
| 可遍历 | ✅ | ❌ |
| size 属性 | ✅ | ❌ |
| 用例 | 缓存配置 / 用户状态 | 元数据附加(响应式依赖图、DOM 节点扩展) |
响应式系统的 targetMap 必须 WeakMap 的根因:框架不知道用户的对象什么时候不再需要。Map 会持有所有 target 阻止 GC——就是 §3.4 我们亲眼看到的内存泄漏。
记忆口诀:
- "我管引用的对象生死" → Map
- "我只是给别人贴张便签" → WeakMap
# 11.2 Proxy vs
| 维度 | defineProperty (Vue 2) | Proxy (Vue 3) |
|---|---|---|
| 拦截范围 | 单个 key | 整个对象(含未来 key) |
| 数组索引 | 不能拦截 arr[i] = x | ✅ 全拦截 |
| 新增属性 | ❌(必须 Vue.set) | ✅ |
| 删除属性 | ❌ | ✅(trap deleteProperty) |
| 嵌套对象 | 启动时全递归(性能差) | 懒递归(读到才包) |
| 兼容性 | ES5(IE9+) | ES6(不支持 IE) |
结论:Proxy 是更彻底、更现代的方案,唯一代价是放弃 IE。Vue 3 / Solid / Mobx 6 全员转向 Proxy。
# 11.3 EventTar
| 场景 | 推荐 | 理由 |
|---|---|---|
| 浏览器 DOM 节点扩展 | EventTarget | 和原生事件系统一致 |
| Node.js 后端 | EventEmitter | Node 标准库就是这套 API |
| 跨端框架(本案例) | EventEmitter | 同一套代码 Node + 浏览器都能跑 |
| 想用 once / 链式 | EventEmitter | 原生 EventTarget 没有这些糖 |
| 想用 capture / bubbling | EventTarget | 自实现要补整个事件流 |
本案例选自实现版的另一个原因:手写一遍才知道事件系统的内部结构是 Map<name, Set<fn>>——以后再用 RxJS / mitt / EventEmitter3 都心里有数。
# 11.4 ArrayBuf
postMessage 三种数据传递方式速度对比(10 万对象):
| 方式 | 拷贝行为 | 时间(10w 点) |
|---|---|---|
JSON.stringify 后传 | 序列化 + 反序列化 | ~1500ms |
| 普通对象(结构化克隆) | 深拷贝 | ~700ms |
| Float64Array.buffer + transfer | 零拷贝 | ~5ms |
学习这一节的关键不是"用 Transferable",而是要理解:Worker 的瓶颈从来不是计算,而是数据搬运。规划数据流时优先用二进制(ArrayBuffer / TypedArray),避免在主线程和 Worker 间频繁传普通对象。
# 11.5 跨端能力地图
本案例每个核心模块都设计成"宿主无关"——这意味着可以平移到其他平台:
| 模块 | 浏览器 | Node.js | 微信小程序 | React Native | Electron |
|---|---|---|---|---|---|
| reactivity/ | ✅ | ✅ | ✅ | ✅ | ✅ |
| events/ | ✅ | ✅ | ✅ | ✅ | ✅ |
| chart/ | ✅ Canvas | ❌ | ✅ canvas 2d | ✅ react-native-skia | ✅ |
| data/HttpSource | ✅ | ✅ node-fetch | ✅ wx.request | ✅ | ✅ |
| data/WsSource | ✅ | ✅ ws | ✅ wx.connectSocket | ✅ | ✅ |
| data/IdbCache | ✅ | ❌(用 sqlite) | ✅ wx.setStorage | ✅ AsyncStorage | ✅ |
| worker/ | ✅ | ✅ worker_threads | ❌ | ✅ JsiWorklet | ✅ |
| template/ | ✅ | ✅ | ✅ | ✅ | ✅ |
结论:reactivity / events / template 是100% 跨端的——这就是为什么 Vue / Quasar 能"一份代码跑 5 个平台"。本案例学到的响应式核心直接可平移到 mpvue / Taro / Hippy / Kuikly 等跨端框架。
# 12.衔接与延伸
# 12.1 与上一案例的差异
| 维度 | 03 jsplayer | 04 jschart |
|---|---|---|
| 主导能力 | Web 平台 API(MSE / Canvas / PiP) | 响应式 + 数据驱动 |
| 数据规模 | 视频流(连续) | 时序点(离散,万级) |
| 渲染源 | 视频帧(浏览器自己解) | 自己 Canvas 画 |
| 状态管理 | 显式状态机 | 隐式 Proxy 追踪 |
| 跨端价值 | 偏浏览器 | 响应式核心 100% 跨端 |
| 篇幅 | 2000 行 | 3500 行(毕业设计) |
# 12.2 后续的递进方向
毕业设计之后,你可以走这几条路:
A. 框架卷:直接读 Vue 3 源码——你已经知道 targetMap / activeEffect / dep.cleanup 的来龙去脉了
B. 工程卷:把 jschart 改造为 npm 包发布,加 TypeScript 类型、Rollup 打 UMD/ESM、写 Vitest 单测
C. 卷三专栏:深挖 V8 引擎的 GC、Hidden Class、JIT,理解为什么 WeakMap 用三色标记 + 弱引用表实现
# 12.3 五个延伸挑战
挑战 A(基础)· 提取 BaseChart 抽象基类
LineChart / BarChart / PieChart 的 constructor / resize / destroy 几乎一样——抽出 BaseChart 让三个类只各自实现 render(data)。要求继承 EventEmitter 让图表能 emit beforeRender / afterRender 钩子。
挑战 B(进阶)· 模板支持 v-for 和 v-if
把 §09 模板编译扩展为:
<ul>
<li v-for="item in points" v-if="item.value > 50">{{ item.value }}</li>
</ul>
2
3
提示:v-for 转成 ${arr.map(item =>
).join('')},v-if 转成三元表达式。 挑战 C(性能)· Worker 池 + 任务调度
当前 SortWorker 是单 Worker。改造为 WorkerPool(n=navigator.hardwareConcurrency),自动分发任务给空闲 Worker,参考 Node piscina 的 API 设计。
挑战 D(架构)· 路由化看板
集成 history.pushState 让看板布局可分享:URL = ?layout=line:cpu;bar:mem,刷新还原。要求把 layout 也做成 reactive。
挑战 E(生态)· 写一个 ECharts 适配插件
实现 EChartsPlugin extends Plugin——install 时把 reactive state 喂给 ECharts,state 变化自动 chart.setOption。完成这个挑战 = 真正能在公司项目用上 jschart 的响应式 + ECharts 的视觉。
- ⬅ 上一案例:03.视频播放器自实现
- ⬆ 卷二目录:综合案例总导读
- ➡ 下一卷:卷三 · JS 原理专栏(V8 / Promise 实现 / Proxy 元编程)