难度:★★☆(需要 JS 函数、对象、数组与 DOM 事件基础;class 的最小用法见主线实验)
教材:关系图/graph-generator-full.html(一个文件 1680 行)
预计时间:讲解 50 分钟 + 练习 30 分钟
学完你能:看懂"数据、界面、控制"如何分工,并把这个结构套到自己的项目上
本课需要的 JS 基础是变量、数组、函数、对象和事件监听。下面的小例子补齐 class 的最小阅读方法;大型关系图里的图表配置先作为对照,不作为初次运行的前提。
双击下载包 assets/主线实验/mvc.html,添加“学习者甲”。预期列表出现一行;重复添加同名应显示“名字已存在”;刷新会清空,因为这个小实验明确只存内存。
Controller 的 submit 监听先阻止表单默认刷新,读取输入,再调用 model.add;Model 整理名字、检查空值和重复、修改 names;调用成功后 View.render 把 names 显示成 li,失败时 View.message 显示错误。
class Model 定义对象的结构,new Model() 创建一个对象,constructor 初始化对象,this.names 是这个对象保存的数组。方法 add(name) 接收参数,和普通函数一样有输入与可能的错误。
| 需求变化 | 首先定位哪层 | 理由 |
|---|---|---|
| 名字不能重复 | Model | 从按钮或调试入口调用都要遵守 |
| 每行前面显示序号 | View | 只改变呈现,不改变 names 的内容 |
| 回车也能提交 | Controller/表单事件 | 改变操作如何触发流程 |
控制台运行 demo.model.add("学习者乙"),再看 demo.model.names。数据已经改变,但列表尚未更新。执行 demo.view.render(demo.model.names) 后才看见乙。
这证明本例采用显式刷新。并不意味着所有 MVC 实现都只能这样,也不能据此断言 React 等于 MVC。React 的 state 更新会安排渲染;直接改普通数组并不自动通知 React。
渲染前要有可用数据,事件回调运行时依赖对象要已准备好。至于先绑定事件还是先加载数据,要看同步/异步流程与按钮是否禁用,没有“先绑事件一定空白”的通用定律。05 课的项目可能采用不同安排,按依赖解释即可。
给 View.render 的每行加序号,并保持重复名字仍被拒绝。然后从控制台直接调用 Model 添加重复名字,仍应报错。这能证明业务规则没有只藏在按钮处理函数里。
排错:控制台 ReferenceError 先检查脚本是否载入;数组改变而界面不变检查 render 是否调用;重复名字没有报错检查是否绕过 Model 直接改数组。验收提交两张状态记录:成功新增、重复拒绝,并解释各自在哪层停止。
下面的关系图源码用于认识这三种职责在更大项目中的对应位置,配色、布局与 ECharts API 是选读扩展。
很多初学者觉得"反正只有一个文件,随便写写就行"。
但「关系图」这个项目告诉你另一个答案:
它只有一个 HTML 文件,作者却在文件里画出了清清楚楚的五层,
每一层只干一件事。打开文件搜 "第一部分",你会看到作者自己写的分段注释:
| 段 | 行号 | 职责 |
|---|---|---|
| 第一部分:常量定义 | 432-467 | 演示数据(DEMO_DATA) |
| 第二部分:Model 模块 | 468-891 | 数据:节点和关系 |
| 第三部分:View 模块 | 892-1324 | 界面:ECharts 图谱和列表 |
| 第四部分:Controller 模块 | 1325-1616 | 控制:事件和流程 |
| 第五部分:应用启动 | 1619-1644 | 组装三层并启动 |
Model - View - Controller(MVC) 是最经典的设计模式。
今天这一课,我们把它拆开来吃。
双击打开 graph-generator-full.html(浏览器直接就能跑)。
依次体验:
graphApp 回车——你会看到三个对象:model、view、controller。这是作者留给你的"后门",调试神器。
graph-generator-full.html
│
├── <style> 样式模块(第 10 行起)——所有外观
│
├── HTML 结构(第 312-400 行)
│ 主容器 / 左侧编辑面板 / 右侧图谱区 / 右键菜单 / Toast 提示
│
└── <script> 逻辑(第 402 行起)
├── 常量:DEMO_DATA(第 441 行)—— 示例数据
├── class GraphModel(第 480 行)—— 只管数据
├── class GraphView(第 906 行)—— 只管显示
├── class GraphController(第 1339 行)—— 只管流程
└── 启动代码(第 1632 行)—— 把三者组装起来
注意文件里第 402-429 行有一大段注释,作者用画图的方式解释了 MVC 是什么——
这本身就是"好代码自带说明书"的例子。
const DEMO_DATA = {
nodes: [ { name: '示例节点', category: 1, symbolSize: 40 }, ... ],
links: [ { source: 'A', target: 'B', label: '关系' }, ... ]
};
为什么单独抽出来?
这就是 单一职责原则 的最小实践:一件事只让一处代码负责。
它管什么? 打开 class GraphModel,看构造函数(第 481-500 行):
this.nodes = []; // 节点数组
this.links = []; // 关系数组
this.categoryColors = { 0: '#66bb6a', 1: '#42a5f5', ... }; // 类别颜色
this.storageKey = 'graphData'; // 本地存储的键名
数据结构先看懂(这是读任何代码的第一突破口):
{ name 名字, category 类别, symbolSize 大小, itemStyle 样式 }{ source 起点, target 终点, label 描述, lineStyle 线条样式 }再看方法,分成四组:
| 分组 | 方法 | 干嘛的 |
|---|---|---|
| 数据访问 | getNodes() / getLinks() | 把数据给外面看 |
| 增删改 | addNode() / removeNode() / addLink() … | 改数据 |
| 业务验证 | 名称唯一性检查 | "规则"归 Model 管 |
| 持久化 | saveToLocalStorage() / loadFromLocalStorage() / exportToJSON() / importFromJSON() | 数据存哪、怎么存 |
注意 addNode 的注释里写着 @throws {Error} 名称为空或已存在时抛出异常——
"名字不能重复"这条业务规则,被放在了 Model 里,而不是界面里。
为什么?因为不管从哪个界面入口添加节点,规则都得生效。规则跟着数据走。
作者在注释里写了设计原则:"View 只负责展示,不包含业务逻辑。"
constructor(chartDomId, model) {
this.model = model; // 拿一份数据引用
this.chartDom = document.getElementById(chartDomId);
this.myChart = null;
this.nodeListPanel = document.getElementById('nodeListPanel'); // 缓存 DOM
...
this.initECharts(); // 初始化图表
}
两个值得学的细节:
document.getElementById 查一次存起来,不每次用每次查。这是最基础的性能意识。
renderGraph、renderNodeList)。数据变了怎么办?等 Controller 叫它 refreshAll()。
看它的构造函数(第 1345-1357 行),只有三件事,顺序很讲究:
this.model.loadFromLocalStorage(); // 1. 先把数据加载出来
this.view.refreshAll(); // 2. 再按数据画界面
this.initEventListeners(); // 3. 最后才接用户的点击
为什么是这个顺序? 渲染需要已准备好的数据,回调执行需要依赖可用;先绑定事件是否有问题取决于加载方式和交互限制,不能仅凭这几行顺序判断。
这个"初始化三连"在无数项目里反复出现,记住它。
Controller 的事件分五类(第 1359-1369 行的注释写得很清楚):
UI 按钮、列表删除(用事件委托)、图表右键、键盘快捷键、窗口变化。
它扮演的角色是:"用户做了什么 → 找 Model 改数据 → 叫 View 重画"。
document.addEventListener('DOMContentLoaded', () => {
const model = new GraphModel();
const view = new GraphView('graphChart', model);
const controller = new GraphController(model, view);
window.graphApp = { model, view, controller };
});
四行代码把三层拼起来,并把它们挂到 window.graphApp——
浏览器控制台里可以直接玩数据,这就是作者给自己(和你)留的调试入口。
以"点击添加节点按钮"为例:
你点击按钮
↓
Controller 的事件监听器收到点击
↓
Controller 从输入框拿到名字和类别
↓
Controller 调用 model.addNode(name, category)
↓
Model:验证(名字空不空?重不重?)→ 改数组 → 存 localStorage
↓
Controller 调用 view.refreshAll()
↓
View:把新数据变成 ECharts 配置 → 图表重绘 → 列表刷新
↓
你看到新节点出现
记住这张图:本例 Controller 读取界面输入并组织调用,把数据规则交给 Model、具体呈现交给 View;它并非绝对不能接触 DOM。职责划分要围绕变化原因解释。
打开 ECharts 官网示例,典型的代码是这样:
option = { series: [{ type: 'graph', data: [...], links: [...] }] };
myChart.setOption(option);
myChart.on('click', function (params) { ... });
数据、配置、事件全混在十几行里。这没有错——官方示例的目标是"让你快速跑起来"。
但项目一旦变大(要新增节点、要保存、要列表联动、要导入导出),
全混在一起就会变成"改一处崩三处"。
教材项目做的是:同样的 ECharts API,外面套了一层 MVC 骨架。
数据从哪来、怎么存、界面怎么刷新,各有各的家。
这就是"示例代码"和"项目代码"的分水岭——也是面试可讲的一个亮点。
你可能会好奇:"为什么 React 里没看到 refreshAll() 这种手动刷新?"
因为 React/Vue 帮你做了:
| 教材的关系图 | React | |
|---|---|---|
| 数据变了 | Controller 手动叫 view.refreshAll() | 自动重新渲染 |
| 思想 | 手动版"数据驱动界面" | 自动版 |
React 采用组件与状态驱动渲染,和 MVC 都涉及关注点分离,但两者不是一一对应的架构。
你今天看懂了手动版,以后学 React 会轻松很多——这是第 11 课的伏笔。
练习 1(热身):用调试后门
打开页面按 F12,控制台里依次输入:
graphApp.model.getNodes() // 看所有节点数据
graphApp.model.getLinks() // 看所有关系数据
然后自己试试调用 addNode(先看它注释里的参数说明),
再输入 graphApp.view.refreshAll(),解释一下:"为什么只调 addNode,界面没变?"
——答得出来,说明你真的理解了 Model 和 View 的分工。
练习 2(核心):给你的图谱换套配色
把 categoryColors(第 490 行)里的颜色换成你喜欢的 4 个颜色,刷新页面看效果。
再进阶一点:把 DEMO_DATA(第 441 行)换成你自己的关系数据
(比如"我 - 学习 - Python"这样的节点和关系)。
练习 3(进阶):加一个"备注"字段
在添加节点时多存一个备注(先找 addNode 的代码看它怎么处理字段),
再在左侧节点列表里显示出来。你会同时改到 Model 和 View 两个地方——
改完想一想:为什么业务规则(验证)应该在 Model,而显示应该在 View?
练习 4(思考题,不用敲代码):
页面刷新后数据还在,是因为存了 localStorage。
如果换成两台电脑想同步,这个方案还行吗?需要改哪里?(这是第 04 课数据库的引子)
| 术语 | 人话解释 |
|---|---|
| MVC | 数据(Model)、界面(View)、控制(Controller)三分的经典架构 |
| 单一职责 | 一个模块只干一类事 |
| 关注点分离 | 数据的事别和界面的事搅在一起 |
| localStorage | 浏览器自带的本地小仓库,刷新页面数据不丢 |
| 事件委托 | 把很多子元素的点击事件统一在一个父元素上监听(省内存、防漏绑) |
| 持久化 | 数据从内存保存到硬盘/浏览器里,下次还在 |
| DOM | 浏览器里网页结构的编程接口,"操作 DOM"= 改页面 |
第 04 课:数据层:SQLite 封装与表设计。
回到「查资料」,这次只盯着一个模块:db() 和 init_db()(第 475-509 行)。
一张表为什么要那样设计字段?PRAGMA 是什么?"外键"和"索引"是什么意思?
顺便回答本课练习 4 的思考题:什么时候该用 localStorage,什么时候必须上数据库。