编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

      • README
      • 待办清单事件驱动
      • 异步订阅流式解析
      • 视频播放器自实现
      • 图表看板全栈开发
        • 渐进学习节奏
        • 案例背景信息
          • 项目目录结构
          • 一条命令启动
        • 目录快速导航
        • 01.项目需求和功能
          • 1.1 需求介绍说明
          • 1.2 功能矩阵总览
          • 1.3 模块依赖图详解
          • 1.4 知识点速查
        • 02.项目骨架与 Vite
          • 2.1 创建项目骨架
          • 2.2 为什么要 Vit
          • 2.3 写第一行代码
        • 03.Reactive 响应式核心
          • 3.1 响应式的本质
          • 3.2 reactive
          • 3.3 track /
          • 3.4 故意造 bug
          • 3.5 嵌套对象的深度响
        • 04.Effect / Computed / Watch
          • 4.1 技术灵魂三问
          • 4.2 effect 嵌
          • 4.3 computed
          • 4.4 watch 深度
          • 4.5 schedule
        • 05.EventEmitter + Plugin 协议
          • 5.1 技术灵魂三问
          • 5.2 手写 Event
          • 5.3 Plugin 抽
          • 5.4 把插件挂到主体
        • 06.Canvas 图表引擎
          • 6.1 技术灵魂三问
          • 6.2 LineChar
          • 6.3 dpr 修复
          • 6.4 BarChart
          • 6.5 饼图(极坐标)
          • 6.6 接通 react
        • 07.数据流水线
          • 7.1 技术灵魂三问
          • 7.2 HttpSour
          • 7.3 WsSource
          • 7.4 IndexedD
          • 7.5 Pipeline
        • 08.Worker 与调度
          • 8.1 技术灵魂三问
          • 8.2 朴素 Worke
          • 8.3 Transfer
          • 8.4 rAF 渲染调度
        • 09.模板引擎 + Devtools
          • 9.1 技术灵魂三问
          • 9.2 模板引擎 &#1
          • 9.3 Devtools
          • 9.4 Inspecto
        • 10.项目总结分析
          • 10.1 最终目录结构
          • 10.2 八阶段成长曲线
          • 10.3 卷一 15 章
        • 11.项目技术思考
          • 11.1 Map vs W
          • 11.2 Proxy vs
          • 11.3 EventTar
          • 11.4 ArrayBuf
          • 11.5 跨端能力地图
        • 12.衔接与延伸
          • 12.1 与上一案例的差异
          • 12.2 后续的递进方向
          • 12.3 五个延伸挑战
    • 专栏博客

  • CodeX
  • JavaScript入门
  • 综合案例
杨充
2026-06-11
目录

图表看板全栈开发

# 第四章: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 计数 / 网络火焰图
1
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 必须做的三件事:

  1. 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
  2. 写一小段代码就在浏览器跑一次(看到 🧪 标志立刻动手)
  3. 看到预期输出再写下一个 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            # 模拟数据
1
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
1
2
3

# 目录快速导航

点击以下条目即可跳转。

  • 01. 项目需求和功能
    • 1.1 需求介绍说明
    • 1.2 功能矩阵总览
    • 1.3 模块依赖图详解
    • 1.4 知识点速查
  • 02. 项目骨架与 Vite
    • 2.1 创建项目骨架
    • 2.2 为什么要 Vit
    • 2.3 写第一行代码
  • 03. Reactive 响应式核心
    • 3.1 响应式的本质
    • 3.2 reactive
    • 3.3 track /
    • 3.4 故意造 bug
    • 3.5 嵌套对象的深度响
  • 04. Effect / Computed / Watch
    • 4.1 技术灵魂三问
    • 4.2 effect 嵌
    • 4.3 computed
    • 4.4 watch 深度
    • 4.5 schedule
  • 05. EventEmitter + Plugin 协议
    • 5.1 技术灵魂三问
    • 5.2 手写 Event
    • 5.3 Plugin 抽
    • 5.4 把插件挂到主体
  • 06. Canvas 图表引擎
    • 6.1 技术灵魂三问
    • 6.2 LineChar
    • 6.3 dpr 修复
    • 6.4 BarChart
    • 6.5 饼图(极坐标)
    • 6.6 接通 react
  • 07. 数据流水线
    • 7.1 技术灵魂三问
    • 7.2 HttpSour
    • 7.3 WsSource
    • 7.4 IndexedD
    • 7.5 Pipeline
  • 08. Worker 与调度
    • 8.1 技术灵魂三问
    • 8.2 朴素 Worke
    • 8.3 Transfer
    • 8.4 rAF 渲染调度
  • 09. 模板引擎 + Devtools
    • 9.1 技术灵魂三问
    • 9.2 模板引擎 &#1
    • 9.3 Devtools
    • 9.4 Inspecto
  • 10. 项目总结分析
    • 10.1 最终目录结构
    • 10.3 卷一 15 章
  • 11. 项目技术思考
    • 11.1 Map vs W
    • 11.2 Proxy vs
    • 11.3 EventTar
    • 11.4 ArrayBuf
    • 11.5 跨端能力地图
  • 12. 衔接与延伸
    • 12.1 与上一案例的差异
    • 12.2 后续的递进方向
    • 12.3 五个延伸挑战

# 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)
1
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 加载"管道               │
└────────────────────────────────────────────────────┘
1
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
1
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>
1
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'
1
2
3
4
5
6
7
8
9

🧪 立刻启动验证(阶段 ① 验收):

npm run dev
# 浏览器打开 http://localhost:5173
1
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 启动开发服务器                         │
│    • &lt;script type="module"> 加载 main.js                     │
│    • 一次性 touch 出 20+ 个 ES 模块占位(看到最终边界)         │
│    • 热模块替换(HMR)调试体验                                 │
│  ⏸ 还没碰的(下阶段才会做):                                  │
│    • Reactive / Effect 响应式核心(阶段 ②③)                  │
│    • Canvas 图表引擎(阶段 ⑤)                                │
│    • 数据流水线 / Worker(阶段 ⑥⑦)                           │
│  📌 进入下阶段前务必:                                         │
│    git init &amp;&amp; git add . &amp;&amp; git commit -m "stage1: skeleton"  │
└────────────────────────────────────────────────────┘
1
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 修复(教学高峰)          │
└────────────────────────────────────────────────────┘
1
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(); } // 又得调
1
2
3
4
5

问题:

  1. 每个改数据的地方都要记得手动 render——漏一个就 UI 不一致
  2. 多个地方依赖 count(标题、徽章、图表)时,改一处要改 N 处
  3. 跨模块更糟:A 模块改了,B 模块怎么知道?只能 emit 事件

❓ 有了响应式会怎样?

// ✅ 声明式:写数据,UI 自动同步
const state = reactive({ count: 0 });
effect(() => {
  document.querySelector('#n').textContent = state.count;
});
state.count++;   // UI 自动更新,不需要 render()
1
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;
    },
  });
}
1
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);
1
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
1
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 是后加的)
1
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());
}
1
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;
    },
  });
}
1
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 = '响应式数据看板';
1
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)
1
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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

🧪 第三次刷新 + 打开 DevTools Memory 面板:

操作步骤:

  1. F12 打开 DevTools → 切到 Memory 面板
  2. 点 "Take heap snapshot" 录第一张快照
  3. 刷新页面,等 5 秒,再录第二张快照
  4. 对比:会发现 Map 内部持有上万个 Object + Array(1000) 没被回收

坏的现象:

heap size before:  20 MB
heap size after:   95 MB    ← 涨了 75MB 不下来
targetMap.size:    10000    ← 永远不会 0
1
2
3

🚨 这就是真实工程师天天遇到的内存泄漏:响应式框架"好心"记录了所有被代理对象的依赖图——但框架不知道用户什么时候不再需要某个对象了,于是 Map 就一直持有 → GC 永远回收不掉 → 内存涨上去就下不来。

🛠 修复:把 Map 换成 WeakMap

📁 src/reactivity/effect.js 改一行:

// const targetMap = new Map();    ❌ 旧版
const targetMap = new WeakMap();   // ✅ 弱引用,target 没人用就自动回收
1
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)
1
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;
}
1
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

两个升级点:

  1. 嵌套懒响应式:get 时判断子值是不是对象,是就递归 reactive 包一层——懒字很关键,深层属性没被读到就不递归,省内存
  2. 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;
1
2
3
4

预期输出:

[嵌套] Alice - 18
[嵌套] Bob - 18
[嵌套] Bob - 20
1
2
3

✅ 深度响应式 + 缓存 = 阶段 ② 完成。

┌─ 📌 阶段 ② 小结 ──────────────────────────────────────┐
│  ✅ 你刚刚完成的事:                                          │
│    • Step 2.1 reactive(obj) 用 Proxy 拦截读写                 │
│    • Step 2.2 effect + track + trigger 三件套,依赖图  │
│      数据结构 WeakMap&lt;target, Map&lt;key, Set&lt;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 . &amp;&amp; git commit -m "stage2: reactive core"        │
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "Proxy 拦截 + WeakMap 依赖图 = Vue 3 100 行核心算法"       │
│    亲眼看到 Map 内存泄漏 → 才真正理解 WeakMap 的存在意义       │
└────────────────────────────────────────────────────┘
1
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 缓存 / 旧值新值对比  │
└────────────────────────────────────────────────────┘
1
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 时还得读普通变量,无响应式
1
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
1
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();
  });
}
1
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

两个核心升级:

  1. effectStack:嵌套 effect 时入栈;外层 effect 跑完了从栈顶弹出,恢复正确的 activeEffect
  2. cleanup 双向记账:每个 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;     // 应该只内层跑
1
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    ← 只有内层跑
1
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;
}
1
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 应自动跑
1
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 自动重跑
1
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;
}
1
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);
1
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 变了
1
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 次
1

🛠 修复:把 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;
  }
}
1
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);     // ⭐ 默认走微任务队列
});
1
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('--- 同步代码结束 ---');
1
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 次重跑
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 . &amp;&amp; git commit -m "stage3: effect/computed/watch"│
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "computed 和 watch 不是新东西——它们都是 effect 加上一    │
│     点 scheduler 钩子。理解这一点,就读懂了响应式框架"        │
└────────────────────────────────────────────────────┘
1
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&lt;string, Set&lt;fn>> 装监听器;插件用 class │
│           extends EventEmitter,每个插件有 install/dispose 钩子│
└────────────────────────────────────────────────────┘
1
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 } }));
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;
  }
}
1
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 跑
1
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
1
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');
  }
}
1
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();
  }
}
1
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();
  }
}
1
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);
1
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

🧪 第二次刷新(端到端验证):

预期现象:

  1. 页面工具栏出现两个按钮:📷 导出 PNG / 📊 导出 CSV
  2. Console 显示 [Chart] 插件 ExportPng 注册,id=Symbol(plugin-0)
  3. 点击 PNG 按钮 → 浏览器下载 fake.png
  4. 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&lt;name, Set&lt;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 . &amp;&amp; git commit -m "stage4: emitter + plugin"     │
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "插件协议 = 抽象基类 + 生命周期钩子 + 自动清理。           │
│     这套思路在 Babel / Webpack / Vue 全都一致"                │
└────────────────────────────────────────────────────┘
1
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 接通响应式            │
└────────────────────────────────────────────────────┘
1
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 变就自动画
1
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,
  };
}
1
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();
    });
  }
}
1
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);
1
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();
}
1
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);
  // ...其余代码不变
}
1
2
3
4
5
6
7

🧪 第二次刷新(验证 dpr 修复):

预期:折线图边缘锐利,文字清晰,Retina 屏上和普通屏一样好看。

对比验证:

  1. 打开 DevTools → 切到响应式设计模式(手机模拟)
  2. 选 iPhone 13(dpr = 3)
  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(); }
}
1
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;
    });
  }
}
1
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 },
]);
1
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);
1
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

🧪 第四次刷新(响应式 + 图表联动):

预期现象:

  1. 立刻渲染折线图(6 个点)
  2. 1 秒后自动画出第 7 个点(不需要手动调 render)
  3. 2 秒后自动首点降到 10 的位置(线条形状变化)

Console 输出:

[render] 数据点数 = 6
[render] 数据点数 = 7
[render] 数据点数 = 7
1
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 . &amp;&amp; git commit -m "stage5: charts"               │
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "Canvas dpr 修复 = 物理像素 / CSS 像素 / setTransform 三   │
│     步走。亲眼看到模糊才记得住——这是 ECharts 第一行代码"      │
└────────────────────────────────────────────────────┘
1
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                  │
└────────────────────────────────────────────────────┘
1
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 持久化
1
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 }
] }
1
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;
  }
}
1
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);
1
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; }
}
1
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);
1
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);
    });
  }
}
1
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));
1
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);
}
1
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 });
1
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)
1
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 . &amp;&amp; git commit -m "stage6: data pipeline"        │
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "三源合一的本质是'统一出口 reactive state'——               │
│     上层只订阅 state,不关心数据从哪来。这就是 Grafana 的灵魂"│
└────────────────────────────────────────────────────┘
1
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 修复│
└────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

# 8.1 技术灵魂三问

❓ 不开 Worker 会怎样?

// ❌ 主线程排序 10 万条
state.series.sort((a, b) => a.ts - b.ts);   // 几百 ms 主线程冻结
1
2

JS 是单线程——主线程一旦卡 50ms,用户就能感到"页面僵住"。Worker 是 JS 唯一的真并行——独立线程跑代码,主线程毫不阻塞。

❓ Worker 和主线程怎么通信?

主线程                     Worker
  ──postMessage(msg)──→   onmessage = (ev) => {...}
  onmessage = (ev) => {} ←──postMessage(reply)──
1
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 }); // ⚠️ 同样克隆传回来
};
1
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(); }
}
1
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);
1
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 序列化卡住
1
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]);
1
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]);
  });
}
1
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]);
};
1
2
3
4
5
6
7
8
9
10
11
12

🧪 第二次跑(验证修复):

预期输出(好的):

worker total v2: 145ms        ← 总耗时 145ms(含传输)
30 帧耗时 500ms                ← 主线程满帧
1
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
1
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; }
    });
  }
}
1
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 控制
);
1
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);
1
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 . &amp;&amp; git commit -m "stage7: worker + raf"         │
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "Worker 不等于不卡——postMessage 也走主线程。              │
│     Transferable 才是真正的零拷贝,6 倍速差距就在这一行"      │
└────────────────────────────────────────────────────┘
1
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 表          │
└────────────────────────────────────────────────────┘
1
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 模板引擎 &#1

📁 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}
1
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 种用途:

  1. 动态代码:编译模板、JSON 表达式
  2. 沙箱:隔离全局作用域(虽然不安全,需配合 Proxy)
  3. 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);
1
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; }
}
1
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++;
1
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();
  }
}
1
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);
1
2
3
4

预期现象:右下角浮动小面板,每秒刷新一次:

📊 jschart Devtools
FPS: 60               ← 绿色(>= 55)
Jank: 0 帧
effect 重跑: 1234
trigger 触发: 567
内存: 24.3 MB
1
2
3
4
5
6

故意制造卡顿验证:

// 主线程跑 200ms 死循环
const start = Date.now(); while (Date.now() - start < 200) {}
// 看 jank 计数 +12(200ms / 16.6ms)
1
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 . &amp;&amp; git commit -m "stage8: template + devtools"  │
│                                                            │
│  💡 本阶段最大领悟:                                           │
│    "Devtools 是响应式系统的内省(reflection)——              │
│     用响应式去观察响应式,递归优雅地完成自举"                  │
└────────────────────────────────────────────────────┘
1
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
1
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>
1
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 元编程)
    上次更新: 2026/06/16, 14:18:46
    视频播放器自实现
    README

    ← 视频播放器自实现 README→

    最近更新
    01
    信号崩溃快速排查
    06-15
    02
    CoreDump破案
    06-15
    03
    perf火焰图实战
    06-15
    更多文章>
    Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式