ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于localStorage的纯前端物业管理系统设计与实现

2026/10/3 13:19:49 拓冰建站 浏览量
基于localStorage的纯前端物业管理系统设计与实现 1. 项目背景与方案选型1.1 为什么用纯 HTML JavaScript 做物业管理系统接到一个内部练习需求开发一个“物业管理系统”。如果按照常规思路直接上 Vue Spring Boot MySQL对于一个小型社区或培训演示场景来说明显是杀鸡用牛刀。部署要装环境、配数据库、写接口一套流程下来可能得折腾两三天。所以我建议直接采用纯 HTML CSS JavaScript 实现数据存储使用浏览器自带的 localStorage。这套方案的适用场景很明确单机演示、个人学习、小型物业点单机管理、毕业设计、前端课程综合实训。它不需要服务器不需要安装数据库双击index.html就能跑起来真正做到“零部署”。对于功能要求不复杂的场景比如管理几百户业主的缴费记录、报修工单性能完全够用。我实测过3000 条记录级别的数据读写都在毫秒级体验上不会比带后端的差。很多人会觉得纯前端存 localStorage 不真实、安全问题大但要看使用场景。如果是存业主联系方式、缴费金额这类非高敏感数据并且在内部环境演示风险是可控的。真正生产环境当然要换后端 数据库但从学习角度先把前端的交互逻辑、数据流跑通后续迁移到 Vue 后端也只是替换 API 层的事。1.2 这套管理系统解决了什么问题物业管理日常最琐碎的事就那几类业主信息变更、物业费催缴、报修处理、公告发布。用 Excel 表格也能管但问题是多人操作容易错、数据不集中、状态追踪麻烦。一个简易版的 web 系统至少能把下面这些事理清楚房产信息统一登记按楼栋、单元、房号检索。业主资料快速查询办理入住、迁出时有据可查。物业费收费记录欠费一目了然支持按月/按年统计。报修工单从提交到指派、完成的状态流转。公告信息发布业主打开页面就能看到最新通知。我用纯前端实现了一套完整的 CRUD 流程操作上做到了“点一下弹表单、保存即刷新”所有数据刷新页面后不丢失。对于想学前端数据管理的人来说这是一个很好的练手项目。1.3 适合谁来参考这个项目如果你是下面几类人这个项目非常值得参考学完 HTML/CSS/JavaScript 基础想做一个综合实战项目巩固知识的前端初学者。准备毕业设计但时间紧张需要快速搭一个能演示、能交差的前端管理系统的学生。有后端经验但想了解纯前端数据持久化方案、本地存储设计思路的开发者。在小型物业或外包公司需要给客户做轻量化管理工具的从业者。我把整个系统的架构、数据设计、代码思路全部分享出来你不需要完整照搬只需要把核心模块复制过去按自己场景调整字段即可。2. 功能规划与数据模型设计2.1 系统核心模块拆解物业管理系统再小也跳不出“人、房、钱、事、告”这五个字。我把系统拆成了六大模块模块核心功能数据字段登录模拟管理员身份验证区分权限username、password首页概览统计房产数、业主数、待处理报修、本月收费统计值聚合房产管理楼栋/单元/房号增删改查房产状态building、unit、room、ownerId、status业主管理业主信息登记与维护name、phone、idCard、remark缴费管理物业费/水电费记录交费登记欠费提醒roomId、ownerName、feeType、amount、status、payTime报修管理提交报修、修改状态、处理反馈roomId、description、status、createTime、handleTime公告管理发布/删除公告title、content、time登录这个模块我用的是最简单的方式写死一个 admin / 123456 的账号用户名密码正确就放行然后把登录状态存到sessionStorage里刷新页面不会掉登录态。这就是演示用的思路真正要加密校验得走后端。2.2 数据模型设计先定数据结构再写页面写前端项目最容易犯的错就是数据模型没定先写 HTML结果后续改结构改到崩溃。我习惯先定义好每个模块的数据结构用 JavaScript 对象表示。比如房产对象const roomSchema { id: R001, building: 1栋, unit: 2单元, room: 503, area: 89.5, ownerId: , // 关联业主ID可为空代表未售/空置 status: occupied, // occupied 已入住 / vacant 空置 createTime: 2025-01-12 10:30:00 };业主对象const ownerSchema { id: O001, name: 张三, phone: 13800138000, idCard: 110101********0011, roomId: R001, remark: };缴费记录const paymentSchema { id: P001, roomId: R001, ownerName: 张三, feeType: 物业费, // 物业费/水费/电费/停车费 amount: 350.00, status: paid, // paid 已缴 / unpaid 未缴 period: 2025-01, // 费用所属月份 payTime: 2025-01-15 09:00:00 };数据模型里我特别加了status字段这是状态驱动页面的核心。比如报修单的状态有 pending待处理、processing处理中、done已完成页面根据状态值显示不同颜色的标签。这样设计可以让代码更清晰避免在页面里用if硬编码逻辑。2.3 本地存储封装把 localStorage 包装成好用的“数据库”直接用localStorage.setItem没什么不行但在多个模块之间管理数据会很散。我封装了一个简单的存储工具支持读写删除、自动序列化和 JSON 解析。核心代码长这样const Store { // 读取数据如果 key 不存在返回默认值 get(key, defaultValue []) { try { const raw localStorage.getItem(key); return raw ? JSON.parse(raw) : defaultValue; } catch (e) { console.error(读取数据失败, key, e); return defaultValue; } }, // 写入数据自动转 JSON set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { console.error(写入数据失败, key, e); return false; } }, // 删除数据 remove(key) { localStorage.removeItem(key); } };这种封装最大的好处是调用侧不需要关心 JSON.parse 和 JSON.stringify 的细节出错时也有统一日志。另外要注意 localStorage 存储空间一般有 5MB 限制对一个小型物业系统完全够用但如果存了图片或长期堆积大量日志就可能会爆满。我后续会在常见问题里讲怎么处理存储上限。3. 核心技术拆解与关键实现3.1 页面渲染方案模板字符串 数据驱动刷新传统做法是写死表格行然后通过 DOM 操作改内容数据一变页面就很乱。我采用“渲染函数 重新生成”的思路把整个表格、卡片、列表看成数据的映射数据变化后调用 render 函数重新生成对应的 HTML 插入到容器里。比如房产列表渲染函数function renderRoomTable(rooms) { const tbody document.querySelector(#roomTable tbody); if (!rooms.length) { tbody.innerHTML trtd colspan7 classempty-tip暂无数据请添加房产/td/tr; return; } tbody.innerHTML rooms.map(room tr td${room.building}/td td${room.unit}/td td${room.room}/td td${room.area ? room.area.toFixed(2) : -}/td td${room.status occupied ? span classbadge badge-success已入住/span : span classbadge badge-gray空置/span}/td td button classbtn btn-sm onclickeditRoom(${room.id})编辑/button button classbtn btn-sm btn-danger onclickdeleteRoom(${room.id})删除/button /td /tr ).join(); }这里用了模板字符串通过map生成行。要注意一个细节onclick里我传的是room.id字符串字符串要加引号如果不加引号JS 会把它当作变量运行时就报room is not defined。正确写法是onclickeditRoom(${room.id})。这种重新渲染的方式虽然看起来“暴力”但在这个数据量级下性能没有压力。而且代码逻辑极其简单只要 render 函数能把当前状态完整表达出来就不需要维护复杂的 DOM 更新状态非常适合模块化思维。3.2 表单弹窗交互用 dialog 元素代替重复造轮子新增和编辑数据需要弹窗。我用的是原生dialog元素相比自定义 div 弹层它有原生遮罩、Esc 关闭、焦点管理而且浏览器兼容性已经很不错了。用法很简单function openRoomDialog(data {}) { const dialog document.getElementById(roomDialog); document.getElementById(roomBuilding).value data.building || ; document.getElementById(roomUnit).value data.unit || ; document.getElementById(roomRoom).value data.room || ; dialog.dataset.editId data.id || ; dialog.showModal(); } function closeRoomDialog() { const dialog document.getElementById(roomDialog); dialog.close(); }通过dialog.dataset.editId记录当前编辑的 id保存时如果这个值存在就更新原记录否则新增。这样新增和编辑共用一个表单代码量直接少一半。保存逻辑要认真处理数据校验。比如新增房产时楼栋、单元、房号都不能为空房号必须唯一。我给表单加了最基础的校验只要有一个字段为空就提示不写入数据function saveRoom() { const building document.getElementById(roomBuilding).value.trim(); const unit document.getElementById(roomUnit).value.trim(); const room document.getElementById(roomRoom).value.trim(); if (!building || !unit || !room) { alert(请完整填写楼栋、单元和房号); return; } const rooms Store.get(rooms); const editId document.getElementById(roomDialog).dataset.editId; if (editId) { const index rooms.findIndex(r r.id editId); if (index -1) { rooms[index] { ...rooms[index], building, unit, room }; } } else { const newRoom { id: R Date.now(), building, unit, room, area: 0, status: vacant, createTime: formatTime(new Date()) }; rooms.push(newRoom); } Store.set(rooms, rooms); closeRoomDialog(); renderRoomTable(Store.get(rooms)); }这里用Date.now()作为 id 的一部分保证唯一性比自增数字更安全因为删数据后自增会出现重复而时间戳不容易撞。3.3 状态管理与全局数据刷新纯前端没有 React/Vue 那样的响应式系统所以我用了一个全局刷新函数refreshAll()在数据变更后统一调用。这个函数做三件事重新读取全部数据、重渲染各模块的表格和统计卡片、重新渲染公告栏。相当于“手动触发一次全量同步”。function refreshAll() { renderStats(); // 首页统计 renderRoomTable(Store.get(rooms)); renderOwnerTable(Store.get(owners)); renderPaymentTable(Store.get(payments)); renderRepairTable(Store.get(repairs)); renderNoticeList(Store.get(notices)); }这里有个小优化每次不需要全部刷新时比如只操作了业主数据也可以单独调用renderOwnerTable。但为了代码整洁我倾向于先“无脑全量刷新”跑顺之后再按模块做精确刷新。真实项目中如果数据量到了几千条全量刷新会消耗一点性能到时再做差分也不迟。3.4 搜索与筛选一行 filter 搞定物业系统最常见的操作是找“某栋某户”或者“某业主电话”。我提供两个维度的搜索一个是按楼栋/房号搜索房产一个按姓名/电话搜索业主。核心都是数组的 filter 方法document.getElementById(roomSearch).addEventListener(input, function () { const keyword this.value.trim().toLowerCase(); const rooms Store.get(rooms); const filtered rooms.filter(room { return room.building.toLowerCase().includes(keyword) || room.unit.toLowerCase().includes(keyword) || room.room.toLowerCase().includes(keyword); }); renderRoomTable(filtered); });搜索逻辑有两种选择一种是我上面写的“在内存里过滤”还有一种是“直接改 Store 里的数据源”。千万不能直接改数据源否则过滤后原始数据就没了刷新数据会丢。正确做法是先读原数据再 filter 出一个临时数组只用于渲染。4. 完整实现流程与核心代码4.1 项目目录与基础页面结构整个项目我建了两个文件加一个数据文件实际运行只需要index.htmlproperty-manager/ ├── index.html # 主页面包含所有结构、样式、业务逻辑 ├── css/style.css # 如果拆开的话可以放样式我为了演示直接内嵌 ├── js/app.js # 如果拆开的话可以放脚本我直接在 index.html 中写合理拆分文件更利于维护但为了演示方便我把 CSS 和 JS 先写在 HTML 里等代码量变大后再拆成外链。真正的项目建议至少拆app.js出来避免 HTML 里 JS 代码过长。HTML 基础结构如下!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title简易物业管理系统/title style.../style /head body !-- 左侧导航栏 -- aside classsidebar div classlogo物业管理系统/div nav classmenu a>document.querySelector(.menu).addEventListener(click, function (e) { const link e.target.closest(a); if (!link) return; document.querySelectorAll(.menu a).forEach(a a.classList.remove(active)); link.classList.add(active); const page link.dataset.page; document.querySelectorAll(.page).forEach(p p.classList.remove(active)); document.getElementById(page).classList.add(active); });注意e.target.closest(a)这一步非常重要如果点击的是a标签内部的文字e.target不一定就是a本身用closest可以找到最近的祖先元素避免事件丢失。4.2 首页统计卡片实现首页放四个统计卡片房产总数、业主总数、待处理报修、本月收费总额。每次 refreshAll 时调用 renderStatsfunction renderStats() { const rooms Store.get(rooms); const owners Store.get(owners); const repairs Store.get(repairs); const payments Store.get(payments); const pendingRepairs repairs.filter(r r.status ! done).length; const currentMonth new Date().toISOString().slice(0, 7); // 得到 2025-07 格式 const monthPayments payments.filter(p p.period currentMonth p.status paid); const totalAmount monthPayments.reduce((sum, p) sum Number(p.amount), 0); document.getElementById(statRoomCount).textContent rooms.length; document.getElementById(statOwnerCount).textContent owners.length; document.getElementById(statPendingRepair).textContent pendingRepairs; document.getElementById(statMonthIncome).textContent totalAmount.toFixed(2); }这里有两个坑一是日期切片toISOString()返回的是 UTC 时间如果你在中国时区可能会差 8 小时导致月份边界出错。更稳妥的方式是用本地时间拼字符串const now new Date(); const currentMonth ${now.getFullYear()}-${String(now.getMonth() 1).padStart(2, 0)};第二个坑是Number(p.amount)如果存的时候金额是字符串不转数字直接求和会出现字符串拼接比如 “10” “20” “1020”。这个是最容易踩的隐藏 bug我排查时花了很久才发现。4.3 业主管理与房产关联操作业主管理的核心是“登记入住”和“迁出”。在设计表的时候我让业主对象里存了roomId房产对象里也存了ownerId这样两边可以互相查。新增业主保存时还要同步把对应房产的状态改成“已入住”把ownerId写上。function saveOwner() { const name document.getElementById(ownerName).value.trim(); const phone document.getElementById(ownerPhone).value.trim(); const roomId document.getElementById(ownerRoom).value; // 是个 select 下拉 if (!name || !phone || !roomId) { alert(请填写姓名、电话并选择绑定房产); return; } const owners Store.get(owners); const editId document.getElementById(ownerDialog).dataset.editId; if (editId) { const index owners.findIndex(o o.id editId); if (index -1) { owners[index] { ...owners[index], name, phone, roomId }; } } else { owners.push({ id: O Date.now(), name, phone, roomId, createTime: formatTime(new Date()) }); } // 同步更新房产状态 const rooms Store.get(rooms); const room rooms.find(r r.id roomId); if (room) { room.ownerId editId ? room.ownerId : owners[owners.length - 1].id; room.status occupied; } Store.set(owners, owners); Store.set(rooms, rooms); closeDialog(); refreshAll(); }这里要注意如果是编辑业主更换了绑定房产旧房产的 ownerId 要清掉新房产要占住。我在示例中做了简化但完整实现时需要考虑这个联动逻辑。你可以加一步保存前先找到该业主旧 roomId把旧房产 ownerId 清空、status 改回 vacant再做现在的绑定。4.4 缴费管理与欠费筛查缴费模块我做了两个动作登记一笔已收费、查看欠费列表。登记缴费时选择房产后自动带出业主姓名、根据物业费单价自动算出应收金额。这里的核心是费用计算function calcFee() { const roomId document.getElementById(payRoom).value; const rooms Store.get(rooms); const room rooms.find(r r.id roomId); if (!room) return; const owners Store.get(owners); const owner owners.find(o o.roomId roomId); if (owner) { document.getElementById(payOwnerName).value owner.name; } const rate 2.5; // 物业费单价元/㎡/月 const amount (room.area || 0) * rate; document.getElementById(payAmount).value amount.toFixed(2); }这里我简化了自动计算逻辑实际项目里物业费单价应该可以在系统配置里设置甚至可以区分住宅和商铺。欠费筛查的思路是遍历所有已入住房产检查某个月的缴费记录是否存在且 status 为 paid没有记录则视为欠费。function renderUnpaidList() { const payments Store.get(payments); const rooms Store.get(rooms); const currentMonth getCurrentMonth(); const unpaidRooms rooms.filter(room { if (room.status ! occupied) return false; const hasPaid payments.some(p p.roomId room.id p.period currentMonth p.status paid); return !hasPaid; }); // 渲染到表格 }理解了这一块就能扩展出“账龄分析”“催缴记录”等等原理都是数据过滤加聚合。4.5 报修流程管理报修模块强调“状态流转”。我设计了三个状态用下拉框切换select classrepair-status>document.querySelector(#repairTable).addEventListener(change, function (e) { if (e.target.classList.contains(repair-status)) { const id e.target.dataset.id; const status e.target.value; const repairs Store.get(repairs); const repair repairs.find(r r.id id); if (repair) { repair.status status; repair.handleTime formatTime(new Date()); Store.set(repairs, repairs); refreshAll(); } } });这里使用事件委托不用给每个 select 单独绑定事件减少内存占用。报修新增时默认 status 是 pending创建时间用formatTime(new Date())生成。这样做之后首页统计“待处理报修”的数量会自动变化因为 refreshAll 会重新读数据。4.6 数据初始化与预设示例数据首次打开系统时各个模块都是空数据演示效果很差。我在脚本启动时做了一次初始化判断如果localStorage中没有rooms这个 key就写入一份示例数据。这个设计在实际演示时非常加分打开系统就有内容可看。function initData() { if (localStorage.getItem(rooms) null) { const rooms [ { id: R001, building: 1栋, unit: 1单元, room: 101, area: 89.5, ownerId: , status: vacant, createTime: 2025-06-01 10:00:00 }, { id: R002, building: 1栋, unit: 1单元, room: 102, area: 92.0, ownerId: , status: vacant, createTime: 2025-06-01 10:05:00 } ]; const owners [ { id: O001, name: 李强, phone: 13900001111, roomId: R001, createTime: 2025-06-02 09:00:00 }, { id: O002, name: 王芳, phone: 13900002222, roomId: R002, createTime: 2025-06-03 14:30:00 } ]; const payments []; const repairs []; const notices [ { id: N001, title: 欢迎入住本小区, content: 请各位业主及时到物业办公室登记车辆信息。, time: 2025-06-05 08:00:00 } ]; Store.set(rooms, rooms); Store.set(owners, owners); Store.set(payments, payments); Store.set(repairs, repairs); Store.set(notices, notices); } }初始化的判断标准是localStorage.getItem(rooms) null不能写成!localStorage.getItem(rooms)因为如果数据是空数组[]getItem 返回的是字符串[]不是 null!会把它误判为真导致每次刷新都重新写入覆盖掉你自己填的数据。这是非常隐蔽的问题但特别常见。4.7 完整启动与运行流程把index.html放进一个目录里双击用 Chrome 打开就能运行。如果你用 VS Code可以装个 Live Server 插件右键选择 Open with Live Server这样改代码后浏览器自动刷新。运行后的体验逻辑是第一次打开左下角显示“初始化示例数据完成”。左侧菜单点击切换页面右侧内容区显示对应模块。新增/编辑数据通过弹窗表单完成保存后表格刷新。刷新浏览器数据依然存在。如果要迁移到真实项目只需要把Store.get(rooms)这类调用替换成fetch(/api/rooms)把Store.set替换成接口提交其余渲染和交互代码几乎可以全部复用。5. 常见问题与排查技巧实录5.1 localStorage 数据被覆盖或丢失这个是最常见的问题。比如初始化函数写得不严谨每次刷新都会把已有的数据重置。排查方法很简单打开控制台切到 Application 面板找到 Local Storage逐个 key 看数据是否符合预期。如果发现刷新后 key 的值为初始示例那就是initData的判断条件写错了。还有一种情况是多个标签页同时打开同一个页面后打开的标签页执行初始化把先打开页面已经写入的新数据覆盖掉。解决思路是加一层“是否已初始化过”的标识比如使用单独的system_initedkey只有这个 key 不存在时才初始化。function initData() { if (!localStorage.getItem(system_inited)) { // 初始化示例数据... localStorage.setItem(system_inited, true); } }5.2 表格渲染时出现 onclick 字符串语法错误很多新手会在模板字符串里这样写button onclickeditRoom(${room.id})编辑/button如果 room.id 是字符串类型比如R001生成的 HTML 就成了editRoom(R001)JavaScript 解析时会把 R001 当成变量名于是报错R001 is not defined。正确写法要套引号button onclickeditRoom(${room.id})编辑/button但如果 id 里含有单引号还可能继续坑你。最保险的做法是不在 onclick 里拼接参数而是用事件委托 data 属性。我在最新版本里推荐第二种写法button>document.querySelector(#roomTable).addEventListener(click, function (e) { const btn e.target.closest(button); if (!btn) return; const action btn.dataset.action; if (action edit) { editRoom(btn.dataset.id); } else if (action delete) { deleteRoom(btn.dataset.id); } });这种方法彻底规避了字符串拼接带来的引号问题代码也更易维护。5.3 金额相加变成字符串拼接我在 4.2 节已经提过这是纯前端最容易忽略的 bug。凡是输入框读出来的值或者从 JSON 解析出来的值都是字符串。直接做加法10 5结果是105。解决方式是在运算前保证转成数字const amount parseFloat(strAmount) || 0;用|| 0是为了防止 parseFloat 返回 NaN 时参与运算一旦 NaN后面的总金额全变 NaN页面显示“NaN”。5.4 中文乱码问题如果你保存文件后用浏览器打开出现乱码大概率是文件编码不对或者没有声明编码。确保index.html里meta charsetUTF-8存在并且文件保存格式是 UTF-8。VS Code 可以在右下角切换编码旧文件可能是 GBK一定要转成 UTF-8。如果用了外部 js 文件也建议脚本标签加上charsetUTF-8虽然现代浏览器默认按 UTF-8 处理但保险起见加上没毛病。5.5 页面刷新后操作状态丢失但数据还在比如你填了搜索框的关键词刷新页面后搜索关键词没了表格显示全部数据。这是纯前端刷新后 JS 变量重置导致的。对用户体验来说如果需要恢复搜索状态可以在输入搜索词时同步存 sessionStoragedocument.getElementById(roomSearch).addEventListener(input, function () { sessionStorage.setItem(roomSearch, this.value); // ...过滤 }); window.addEventListener(load, function () { const keyword sessionStorage.getItem(roomSearch); if (keyword) { document.getElementById(roomSearch).value keyword; // 执行一次过滤 } });这样刷新后能恢复上次的输入体验会好很多。5.6 localStorage 被清空 / 各浏览器隔离问题浏览器隐私模式、清除缓存、以及换了浏览器后都会导致数据“不见”这是 localStorage 的天然限制。用户问“我数据怎么没了”时先判断是不是环境变化。另外不同域名下 localStorage 不互通如果同一份系统部署在不同域名数据不会共享。要跨设备、跨环境就必须接后端和数据库。这个项目定位是简易版能接受这些限制才有意义。5.7 导航切换后页面显示空白如果切换菜单后内容区什么都没显示先看对应 section 的id和菜单>.page { display: none; } .page.active { display: block; }如果只是display:block却忘了默认隐藏所有.page结果所有模块会竖着排在一起看起来也像“空白”因为被其他内容挤下去了。5.8 事件不生效的高级排查思路当点击按钮没反应、切换 select 没触发时第一步先打开控制台看有没有报错第二步确认元素是否被重新渲染。因为我的方案是每次数据变更就重渲染表格如果某次 render 后的 HTML 里加进去的元素没有绑定事件而你没用事件委托事件自然就丢了。所以我能给的建议是列表渲染尽量用“容器级事件委托”比如给table或div绑定监听再通过e.target.closest定位操作项这样无论列表怎么刷新事件始终在容器上不会丢。6. 一些真实使用中的体会项目写完之后我自己在浏览器里模拟了整整一天的使用流程先录入十套房和五位业主然后给其中两户登记物业费再提交三个报修工单把其中一个改成处理中再发一条停水公告。跑下来最明显的感受是这套系统的数据流非常顺因为所有模块的数据都能通过refreshAll联动改一处其他地方的统计数据同步更新。我个人强烈建议你拿到代码后不要一次把所有模块全抄完而是按“房产 → 业主 → 缴费 → 报修 → 公告”的顺序逐步增加。每加一个模块就把对应的 render、Store 操作跑通再继续这样即使中间出问题也能快速定位。还有一个可以继续扩展的方向给系统加一个“导入导出”功能用 JSON 文件导出全部数据换电脑后导入就能跨设备移动数据。虽然不能完全代替后端但至少比纯本地存储更实用。如果感兴趣后续可以把整个项目上传到某个静态托管平台直接变出一个线上演示版本方便随时展示。说到底这个项目最大的价值不是系统本身有多复杂而是让你彻底理解“数据层面如何驱动页面更新”这件事。掌握了Store封装、渲染函数、数据联动、事件委托这四板斧以后再去看 Vue 的响应式原理、React 的 state 管理会轻松很多。毕竟框架只是工具底层的思路都是通的。