1. 为什么这个练手项目值得写一篇复盘
1.1 需求边界是怎么被确认下来的
上个月我接了个很普通的需求:给团队内部做一个轻量级的任务登记面板。放在平时,这种需求我可能一个下午就甩个单页出去,能增能删能过滤,就算交差了。但这次我决定换个玩法——项目代号定为rea,不再用“能跑就行”的套路,而是全程模拟一个真正要交付给外部用户的产品:有明确的需求边界、有工程约束、有性能预算、有部署排错记录。
先说需求本身。团队里大概几十号人,需要一个公共页面来登记临时任务:谁、什么时候、做什么事、优先级是什么、现在到哪一步了。听起来很简单,但如果直接上手写页面,很容易被各种“顺手加一下”的需求带偏。我花了一个下午跟实际使用的人聊,把需求收敛成了三条:第一,登记的字段固定为任务名称、负责人、截止时间、优先级、状态;第二,核心操作是筛选和改状态,不需要协同编辑;第三,数据量级在一千条以内,不需要后端实时推送。这三条边界直接决定了后面所有技术选型的方向——规模不大,但要做得干净、可维护、打开快。
1.2 给自己定的三条工程约束
需求确认之后,我没有立刻写代码,而是先给自己立了三条约束,并且每条约束都写明了理由。
第一条:前端不依赖真实后端,先用本地存储把全流程跑通。理由很简单,团队后端资源紧张,如果一上来就等接口,这个练手项目大概率烂尾。先把数据层抽象成接口,后面接真实接口时只换实现、不动界面。
第二条:所有界面元素必须拆成可复用组件,组件内部不允许出现面向业务的硬编码文案。这条约束是为了防止“写着写着组件全变成了页面”的常见失控情况。组件只负责“长什么样”和“怎么交互”,不负责“业务规则是什么”。
第三条:构建产物的首屏体积控制在可接受范围以内,具体做法是把“全量引入”视为红线,所有依赖必须按需加载。这条约束最折磨人,但也是后面收获最大的一条——当你把包体从1.2MB压到280KB之后,再回头看那些“多几KB无所谓”的依赖,你会变得极度敏感。
这三条约束看起来都是常识,真正执行起来才发现,每一项都对应着具体的取舍和坑。
2. "rea"的核心模块拆分:从需求到组件的映射
2.1 页面结构如何拆成六个区域
需求确认之后,我没有立刻写代码,而是先把页面在脑子里过了一遍,划分成六个区域。这步做完再动手,效率完全不一样。
| 区域 | 职责 | 核心交互 | 依赖数据 |
|---|---|---|---|
| 状态统计条 | 展示各状态的任务数量 | 点击状态标签快速筛选 | 全部任务 + 当前筛选条件 |
| 登记表单 | 新增/编辑任务 | 校验、提交、取消 | 无(纯输入) |
| 筛选栏 | 关键字、负责人、优先级筛选 | 输入防抖、条件组合 | 筛选条件对象 |
| 任务表格 | 展示当前筛选结果 | 行内改状态、行内快捷编辑 | 筛选后的任务列表 |
| 批量操作条 | 批量变更状态/删除 | 多选、操作确认 | 选中的任务集合 |
| 设置抽屉 | 管理负责人清单、默认视图 | 开关、增删选项 | 本地偏好设置 |
这个划分的关键不是区域数量,而是每个区域的“职责”和“交互”要能一句话说清楚。如果某个区域你需要两句话以上才能说明白它是干什么的,说明它还得拆。
以任务表格为例,我没有直接做成一整块,而是再拆成表头、行、状态单元格、优先级徽标四个子组件。状态单元格单独抽出来,是因为它有“点击后弹出状态菜单”的交互,如果再算上“改状态后的乐观更新”,复杂度足够撑起一个独立组件。
2.2 组件通信的最小化设计
组件拆完,紧接着的问题就是数据怎么传。我之前见过太多项目,组件拆得挺漂亮,但通信方式乱七八糟:父子组件互相改对方的内部数据、全局事件总线满天飞、一个状态变量在十几个组件里被同时读写。这种代码短期跑得通,只要需求一变,立刻变成一团乱麻。
rea项目里我定了一条铁律:数据只能朝一个方向流动。页面级状态放在最外层,往下逐层传给子组件;子组件要改数据,不能自己直接改,而是向父组件抛出一个描述“想做什么”的回调。听起来很死板,但实际写起来非常省心。比如状态单元格要修改任务状态,做的事情就是调用从父组件传下来的onChangeStatus任务ID,新状态,至于父组件怎么更新数据、要不要弹确认框,单元格完全不需要关心。
这套模式牺牲了一点点“写起来灵活”的短期快感,换来的是“改起来好找”的长期确定性。后面加权限、加审计日志、加撤销重做,都因为这条铁律变得特别顺。尤其是撤销重做,你只需要在数据入口处记录快照就行,不需要去每个组件里堵乱改数据的口子。
2.3 关于虚拟列表的取舍
还有一个取舍值得一提:到底要不要上虚拟列表。我明确说过数据量级在一千条以内,理论上根本不需要虚拟列表,但我还是在表格组件里预留了一个“分片渲染”的开关。原因是这类内部工具很可能过几个月就有人往里灌上万条数据,与其到时候重构表格,不如先在渲染策略上留好后路。
具体做法是:默认一屏渲染全部筛选结果,但当数据量超过某个阈值时,自动切换到分片渲染,也就是每帧只渲染固定数量的行,滚动时再补充。这不算真正的虚拟列表,但应对“几千条数据渲染不卡”已经够了。真要上万条,那时候第一反应应该是加服务端分页,而不是跟浏览器渲染斗气。
这个取舍给我的教训是:不要为了“看起来高级”而用复杂方案,但也不能因为“现在够用”就彻底封死扩展路径。折中的方式就是让代码留出策略接口,让数据量来决定到底走哪条渲染路径。
3. 状态管理方案反复横跳之后的选择
3.1 从全局Store回归到局部状态
状态管理这一节,我自己都觉得自己挺折腾的。一开始按照社区的主流思路,计划一上来就建一个全局Store,把所有任务数据、筛选条件、UI状态全塞进去。理由也很“正当”:以后需求多了,全局状态肯定用得上。
但写到第三天,我发现情况不对。全局Store里的状态确实多,但大部分状态只在某一个组件内部被使用,比如设置抽屉的开合状态、登记表单的校验信息、批量操作条的选择集合,这些统统塞进全局Store的结果是:任何一次操作都要走一遍“派发动作、更新Store、再派发给所有订阅组件”的流程。代码量翻了一倍,调试难度也跟着翻倍,还完全没有带来任何收益。
我最后做了一个决定:把状态按“使用范围”分两级——页面级任务数据和筛选条件放在顶层Store里,组件自己私有的交互状态留在组件内部。这个回退让代码量直接少了一大截,而且可读性明显提高。全局Store不是不能用,而是不该把所有东西都塞进去。
3.2 当前状态树的实际形态
现在rea项目的状态树长这样:
// 页面顶层状态结构(示意) const pageState = { // 任务数据:核心领域数据 tasks: { items: [], // 任务列表 nextId: 1001, // 自增ID lastUpdated: 0, // 用于缓存判断 }, // 筛选条件:影响列表展示的数据 filters: { keyword: '', assignee: 'all', priority: 'all', status: 'all', }, // 派生信息:不直接存储,由上面两段计算 } // 组件内部状态(示意) const cellState = { menuOpen: false, // 状态菜单是否展开 editing: false, // 是否处于行内编辑 tempValue: '', // 编辑中的草稿值 }把状态拆成这两类之后,我最大的感受是:状态管理方案的优劣,很多时候不取决于用了什么库,而取决于你肯不肯花时间想清楚“哪些状态值得被全局管理”。task和filters因为会被很多组件同时读取,值得放在顶层;而menuOpen、editing这种状态,一旦放出去就会成为噪声,留在组件内部反而干净。
3.3 派生数据的缓存策略
状态拆完之后,又出现了一个新问题:任务列表的统计数据。统计条需要展示“各状态的任务数量”,这些数量可以实时计算。问题在于,当任务数量达到几千条,每次筛选变化都重新遍历所有任务计算数量,虽然也不至于卡死,但会给浏览器带来无谓的压力。
我的做法是:把统计数据设计成“派生数据”,只在tasks或filters变化时重新计算一次,然后缓存下来。这里用到了类似计算属性的机制,核心思路是声明“当哪些数据变化时,重新计算结果”,而不是在渲染过程中临时算。
这套策略踩过一个小坑:刚开始缓存的时候,我只监听了tasks变化,没监听filters变化。结果筛选条件改了,统计数据没有跟着变,页面上出现了“筛选后明明没有待办了,统计条还显示有3个”的诡异状态。排查半天才发现是缓存依赖漏了。这个问题的根因就是“派生数据的依赖没声明完整”,后续凡是写缓存,我都会先列一遍数据依赖关系,再动手写代码。
4. 打包体积从1.2MB降到280KB的实测记录
4.1 第一版为什么那么大
功能开发完成之后,我顺手打了一次生产包,看到产物体积1.2MB的那一刻,我整个人是懵的。按这个项目的功能量级,1.2MB无论如何都不合理。当时判断是哪里配错了,后面一步查下来才发现,问题不是配错,而是默认行为的“叠加效应”。
第一层叠加是UI组件库全量引入。我当时图省事,在入口文件里直接引入了整个组件库,虽然只用了其中十几个组件,但打包出来的代码包含了库里几百个组件。第二层叠加是日期处理工具。项目里只用到日期格式化和日期差计算,但我引入了整套日期工具库,光这一项就占了小两百KB。第三层叠加是构建工具没有开启依赖预检,不少依赖库把浏览器环境之外的代码也打了进来。
这三层问题在编译阶段都不会报错,运行时也能跑,你可能压根注意不到,但产物体积就悄悄膨胀起来了。
4.2 三个立竿见影的压缩手段
针对上面三个问题,我按见效速度排了序,逐个解决。
第一个动作是给UI组件库开按需引入。具体做法是修改组件库的引入方式,让框架插件在编译阶段自动把用到的组件单独打包进产物。这个操作最立竿见影,改完包体直接少了四百多KB。第二个动作是换掉整套日期工具库,改成自己写一个只有日期格式化和日期差计算的小模块。这个动作需要动一点手,但换来的是体积再降三百多KB。第三个动作是打开构建工具的依赖预检和摇树优化,让编译器主动剪掉那些“引入了但没执行到的代码”以及“明确标记为副作用的外围代码”。
这三个动作做完,生产包从1.2MB降到了大概457KB。再往下压,就需要动更细的拆分策略了。
4.3 尺寸与加载速度的实测对比表
把体积压下来之后,我又做了按路由拆分和公共依赖缓存拆分,最终产物稳定在约280KB。我记录了每个阶段的实测数据,这里直接放出来:
| 优化阶段 | 产物大小 | 首屏资源加载量 | 首屏可交互时间 |
|---|---|---|---|
| 初始版本 | 1.2MB | 1.1MB | 约2.8秒 |
| 按需引入组件库 | 约740KB | 约680KB | 约1.9秒 |
| 替换日期工具 | 约457KB | 约420KB | 约1.4秒 |
| 开启摇树+依赖预检 | 约330KB | 约300KB | 约1.1秒 |
| 路由拆分+缓存拆分 | 约280KB | 约240KB | 约0.9秒 |
这些数字是在我本机模拟慢速网络的条件下测出来的,不算严谨的实验室数据,但横向对比已经足够说明问题。我最想提醒的一点是:不要迷恋某一个单一优化手段,真正靠谱的做法是“多个手段叠加”。每一项看起来都只是省了一点,叠起来就会产生质变。
还有一个细节:压缩之后记得看一眼各资源的依赖占比,如果某个依赖在产物里占比异常高,多半是引了不该引的子模块。这个检查操作频率不用太高,每次发版本前瞄一眼就行。
5. 部署阶段遇到的三个"幽灵"问题与排查思路
5.1 刷新后白屏的根因不仅仅是路由模式
功能开发、体积优化都搞定之后,我以为剩下的事情就是扔到服务器上收工。没想到部署阶段才是真正的“开箱体验稀碎”环节。
第一个问题是:用户从根路径点进详情页,一切正常,但只要一刷新,页面就白屏。第一反应是路由模式选错了。因为我们用的是不带哈希的路由模式,直接访问子路径时页面会带着一个不存在的路径去请求服务器,服务器返回的不是首页,而是一个未匹配的响应,白屏自然就来了。
常规解法是在静态服务器上配一个“所有未匹配路径都重定向到首页”的规则。我确实配了,也确认规则生效了,但白屏依旧。排查到这里,我才发现根子不在路由模式,而在静态资源的引用路径。构建产物里资源引用的是绝对路径,比如/assets/index-xxx.js,当页面从域名根目录访问时没问题,但如果整个系统被部署在某个子路径下,比如/tools/rea/,那么请求资源时就变成了/assets/index-xxx.js,服务器在这个绝对路径下找不到文件,于是白屏。
这个坑的本质是“路径基准”没对上。修复方法是让构建产物自动识别部署路径,或者显式配置一个统一的资源基准路径。从那之后,我再部署类似项目,第一件事就是确认资源路径和部署路径是否匹配,而不是等到白屏了再来猜。
5.2 静态资源缓存引发的"旧版本"幻觉
第二个问题更隐蔽:发布新版本之后,用户那边还是显示旧界面。一开始我以为是浏览器缓存,让人清一下浏览器缓存,发现没用。又怀疑是服务器缓存,把服务器缓存规则改了,还是没用。
最后用开发者工具看到页面实际加载的资源文件名,才意识到问题出在“文件名没变”上。我们当时给静态资源用的缓存策略是“长时间缓存”,也就是说浏览器会把index-xxx.js这个文件老老实实地缓存很久。这本来是对的,因为文件名带哈希,内容一变哈希就变,旧名字的缓存不会影响新文件的加载。
但偏偏那次发布,构建工具有一个新特性“当代码内容没变时保持文件名不变”被触发了,导致部分文件的哈希没有变化。hash没变,浏览器自然认为还是旧文件,于是继续用本地缓存。这个问题的根子在于“缓存策略假设文件名一定要内容变化而变,但工具不这么认为”。
排查链路走完后,我做了一个简单粗暴的修复:构建配置里强制关闭“保持文件名不变”这个特性,确保只要有代码变更,文件名必然变化。从那之后,缓存导致的“旧版本幻觉”再也没有出现过。
5.3 浏览器兼容性报错在哪个环节才暴露
第三个问题发生在某位同事的电脑上:页面打开后功能一切正常,但只要点开登记表单,页面直接就卡死崩溃。控制台报错指向一个很底层的语法特性报错,那位同事的浏览器版本比较老,不支持这个新特性。
当时代码是这样的:
// 触发崩溃的简化示意 const rows = items.map((item) => item?.owner ?? '未指派')这里的空值合并运算符在旧浏览器里无法识别,直接抛出语法错误。项目开发时,我的浏览器版本比较新,构建时也没有把兼容目标下调,于是在我这边完全没问题,一到旧浏览器就崩。
这个问题让我意识到两件事:第一,构建的兼容目标必须显式定义,不能依赖开发机器默认值;第二,团队内部工具也得考虑同事的浏览器环境,不能假设所有人都用最新版本。
修复方式是统一配置兼容目标,并引入一个自动降级插件,让构建产物对旧环境的适应性更强。从那之后,我在项目起步时就会把兼容目标写清楚,不再指望“反正大家都是新浏览器”。
6. 可以抄走的目录结构与关键配置
6.1 一套不太会出错的目录划分
如果你也想照着这套思路做类似的项目,我建议直接按下面的目录结构来划分。这套结构是rea项目里反复调整之后定下来的,核心原则是:职责清晰、找文件快、后续拆包方便。
rea/ ├─ src/ │ ├─ app/ # 应用级逻辑:路由、全局状态、布局 │ ├─ components/ # 通用组件:按钮、弹窗、表格、输入框 │ ├─ features/ # 业务模块:登记、筛选、统计、批量操作 │ ├─ lib/ # 无框架依赖的纯工具函数 │ ├─ hooks/ # 可复用的逻辑封装 │ ├─ styles/ # 全局样式与设计变量 │ └─ main.js # 入口文件 ├─ build/ # 构建配置、路径常量 ├─ public/ # 不需要参与构建的静态资源 └─ docs/ # 项目决策记录、踩坑记录这里最关键的一点是:components目录里只放“不知道业务是什么”的通用组件,而features目录里放跟业务紧耦合的功能模块。如果你把业务组件和通用组件混在一起,过两个月你就会发现,没有一个组件敢随便改,因为你分不清它到底被多少业务场景依赖了。
6.2 构建脚本里那几个容易被忽略的参数
构建配置这块,我不想铺开写一整篇配置文件,只挑几个实测中容易踩坑的参数说。
第一个是“产物资源路径”,直接决定你部署到子路径时资源能不能加载成功。第二个是“兼容目标”,直接决定你能不能用最新的语法特性。第三个是“自定义分包”,这个参数可以把体积大、更新频率低的依赖单独打包,优先利用浏览器缓存。第四个是“代码压缩级别”,有时候为了追求极致的压缩率,构建工具会选择一些不那么稳妥的压缩策略,反而把某些动态导入的逻辑压缩坏。
我用一个表格把建议值列出来:
| 配置项 | 建议值 | 踩坑提示 |
|---|---|---|
| 资源路径 | 明确指定,不用绝对根路径 | 子路径部署必备,否则白屏 |
| 兼容目标 | 按团队实际浏览器版本设置 | 不设就默认按构建机环境来,必出问题 |
| 独立分包 | 固定打包核心依赖 | 让依赖缓存最大化 |
| 代码压缩 | 平衡模式,不追极限 | 极限压缩可能破坏导入逻辑 |
这些参数看起来都是小事,但每一个都能在部署阶段给你一个“惊喜”。我的习惯是写一个构建参数清单,每次新建项目直接复制过去,再按项目情况微调。
6.3 留给自己的后续优化清单
项目上线稳定运行一周后,我没有急着开香槟,而是整理了一份后续优化清单,准备在下一轮迭代里继续啃。
第一项是用IndexedDB替换本地存储。现在的数据量还小,localStorage够用,但考虑到后续可能会导入外部任务清单,数据量一旦上来,localStorage的同步读写性能和空间上限都会成为瓶颈。
第二项是把筛选逻辑中“关键字搜索”部分抽象成更通用的模糊检索模块。现在用的还是简单的字符串匹配,等数据量变大或者是需要用中文拼音首字母搜索时,这个模块肯定要重写。
第三项是给批量操作加上撤销机制。目前批量删除是直接操作数据,没有撤销。要做撤销,需要引入操作记录栈,这块我已经想好了方案,准备在下一版里落地。
第四项是将统计条组件进一步抽象,做成可插拔的“数据卡片”组件。现在统计条只是展示任务数量,以后很可能要展示“今日完成率”、“本周趋势”这些指标。提前把展示和数据解耦,到时候接新指标就是配置项而不是改代码。
这份清单不会立刻全部做完,但列出来之后,每次新需求进来,我都会先看一眼清单,能顺手带掉的就顺手做掉,避免功能补丁越打越多、代码越来越乱。这种“小步快走、持续重构”的节奏,我觉得挺适合rea这种内部工具项目的。
最后再分享一个这次复盘里我自己体会最深的一点:很多问题在开发阶段不暴露,是因为开发环境和部署环境之间存在隐性差异——路径、缓存、兼容性、资源体积,都是如此。与其等到部署阶段被这些问题轮番折磨,不如在项目立项那天就把这些因素考虑进去。练手项目也一样,你越早以“要交付”的标准要求自己,后面能省的麻烦就越多。