资讯详情

资讯详情

短平快前端项目实战复盘:从三行需求到一周交付的完整经验

我几个月前参加了一个内部小项目任务卡上就写了三个字母rea。问了一圈没人知道全称后来我们索性把它解读成 Requirements Experimental App——需求驱动的实验性应用。这个项目周期很短从需求分析到前端落地再到联调上线总共不到十天但很多经验特别值得记下来。这篇文章就是我对rea这个项目的完整复盘。它讲的不只是某个技术方案更是一套可复用的做事方式怎么把一个模糊到只有三句话的需求收敛成可交付的闭环怎么在选型上不追新、只求稳怎么用几十行代码把状态管明白以及联调阶段真正会遇到的几个坑。适合正在做小型内部工具、管理短平快前端项目的朋友参考也适合刚带小项目的同学避雷。1. 代号rea的来历从三行需求到一周交付的边界收敛1.1 需求只有三行为什么我们追问了四个小时rea最初的需求原话大概是这样做一个登记页面能录数据能查数据要落库最好一周内上线。三句话听着简单但真动起手来处处都是问题。我记得那天下午我们四五个人围在一起对着这三行字问了一轮又一轮。第一轮是问使用场景谁在用大概多少人数据量一个月有多少条回答是内部约二十人使用高峰期并发也就个位数一年几千条记录。听完这个我就放心了这不是一个需要高并发架构的项目一切从简是没问题的。第二轮问业务规则有哪些字段需要登记谁能修改删除要不要权限区分要不要导出要不要按角色隔离数据原需求一句都没提但这些才是决定页面长什么样的关键。最后确认下来的字段是名称、来源、状态、负责人、备注、创建时间权限上全员可查看管理员可编辑删除导出需要做统计报表先不碰。第三轮问的是边界有没有审批流要不要附件上传需不需要操作日志这轮我们的策略很明确凡是没人能说清具体场景的一律砍掉或者标记为暂不实现。比如审批流问了一圈实际业务里根本没这个流程纯粹是猜想出来的需求。附件上传要做也不难但会引入存储策略和文件预览的复杂度一周内没必要碰。1.2 边界收敛之后得到的最小闭环四小时追问之后rea的范围就非常清楚了录入表单、列表展示与筛选、详情查看、导出。就这四个功能。我们把这个结果写进任务卡对比图大概是这样。范围需求描述交付优先级录入表单登记记录包含名称、来源、状态、负责人、备注必须列表与筛选列表展示支持关键字、状态、时间范围筛选必须导出将当前筛选结果导出为表格文件必须详情查看点击列表项查看完整信息必须附件上传原需求未明确业务方无法说清场景暂不实现审批流原需求未明确实际无此流程不做统计报表有价值但一周内做不完二期再说做完这张表我心里是踏实的。因为所有模糊地带都有了明确结论后续开发不会出现这个东西要不要做的反复扯皮。动手之前我还做了一件小事画数据流草图。就一张纸箭头从表单指向接口从接口指向列表从筛选条件拉一条线到列表查询逻辑。画完之后至少提前发现了两个问题一个是状态这个字段录入时和筛选时的取值到底用同一套枚举还是两套另一个是列表的排序规则没有定义后来补了一条默认按创建时间倒序。这些问题在草图上发现成本几乎为零等代码写一半才发现就得返工了。2. 技术骨架选型在稳定与快速交付之间找平衡2.1 选型三条约束团队熟悉度、复用、分层rea项目最不缺的就是诱惑。有人提议上最新的组件方案有人建议换一套响应式流式框架还有人想自己搭一套微前端骨架。我作为实际负责人一条条全否了。原因很简单这个项目只有一周时间团队核心成员就两三个人目标是交付稳定可用的工具不是做新技术试验田。我给大家定了三条约束。第一条团队熟悉程度优先于新特性。我们当前用的组件化技术栈已经跑了好几个项目日常维护的人闭着眼都能写没必要为了新而换。第二条复用大于创造。公司内部已经有一套成熟的工程脚手架直接拿来改造构建配置一个字都不用动。第三条分层可替换。UI 是 UI数据是数据中间加一层数据提供者将来换接口、换存储都不至于推倒重来。这三条约束说白了就是用最熟的东西做最快的事同时给未来留一条不疼的退路。实际跑下来我认为这个决策是正确的。项目从搭建到核心功能跑通只用了不到四个工作日其中一半时间花在业务逻辑梳理上技术本身的阻力很小。2.2 目录结构按功能域组织而不是按文件类型rea的工程目录我要求按功能域划分而不是按组件、页面、工具这种类型堆在一起。rea/ mock/ records.js src/ features/ records/ components/ pages/ services/ shared/ components/ utils/ store/ index.js scripts/ dev.sh build.sh按功能域组织的最大好处是改需求时只进一个目录不会把无关文件卷进来。比如records这个业务域的所有东西表单、列表、详情、筛选相关代码都在一起换人接手时打开一个目录就够了。按类型组织的方案往往一个需求要改四个目录跨域牵连特别多。分享一个细节services 目录里不直接写接口请求而是封装业务方法。比如 getRecords(params)、createRecord(data)、exportRecords(params)。页面只跟这些方法打交道不关心它们在背后是请求了远程接口还是读的本地 mock。这个习惯帮了大忙后期联调时页面代码几乎没动。2.3 先用本地数据层把业务跑通再接真实接口为了让前端不被后端开发阻塞rea在第一天就搭了一个本地 mock 层。方法很简单mock 目录下放一份模拟返回数据数据提供者根据环境开关决定走 mock 还是走真实接口。// mock/records.js export const mockRecords [ { id: r-001, title: 年度预算表, source: 财务, owner: 张敏, status: done, createdAt: 2024-03-18 }, { id: r-002, title: 项目周报, source: 研发, owner: 李维, status: processing, createdAt: 2024-03-17 }, { id: r-003, title: 客诉汇总, source: 客服, owner: 王雨, status: todo, createdAt: 2024-03-16 }, ];为什么非要加这一步因为很多前端项目死磕在联调阶段要么等接口等到天荒地老要么接口一返回就想改数据结构。先把本地数据跑通页面的正常态、空态、加载态、错误态都能提前调一遍等到联调时只需要验证字段映射是否正确不用再分心处理 UI 问题。这里有一个我踩过的坑mock 数据不要写得太完美。一定得在测试用例里放几笔异常数据比如字段缺失的记录、超长文本、空字符串状态值。你猜到了这些就是后续联调最容易出问题的地方提前在 mock 里暴露出来比上线前才发现要好得多。3. 核心交互与数据流几十行 store 足够管好一个小型应用3.1 极简 store 的实现思路与适用边界rea规模很小所以状态管理我完全没有考虑重型方案直接写了一个几十行的极简 store。// src/store/index.js const state { records: [], filter: { keyword: , status: , dateRange: null, }, loading: false, }; const listeners []; export function getState() { return state; } export function setState(partial) { Object.assign(state, partial); listeners.forEach((fn) fn(state)); } export function subscribe(fn) { listeners.push(fn); return () { const idx listeners.indexOf(fn); if (idx -1) listeners.splice(idx, 1); }; }它的核心就是发布订阅模式setState 更新数据并通知所有订阅者页面通过 subscribe 监听变化。用起来很直白没有任何魔法。为什么不用重型状态管理因为rea是单页应用、状态层级浅、没有跨页面协同编辑重型方案在这里纯属负担。新同事看这份代码一分钟就能看懂不需要读一大篇文档。我并不是否定重型方案它确实适合复杂场景。但得先判断项目规模小项目强行引入一套复杂的架构维护成本比收益大得多。适用边界我通常这样判断如果项目里共享状态超过三个模块、多角色权限、有复杂缓存或回滚需求再考虑升级方案否则一个几十行的 store 完全够用。3.2 表单、列表、筛选如何串成闭环有了 store核心交互就变成了数据怎么流的问题。rea的结构是单向的表单提交之后调用服务方法拿到返回结果后 setState 更新 records列表组件因为 subscribe 了 store自动渲染新数据。筛选的逻辑稍微绕一点。我做了两件事第一筛选条件也放进 store 的 filter 里第二列表展示的数据不是直接读 store.records而是通过一个计算函数 deriveVisibleRecords()它同时读取 records 和 filter然后进行过滤和排序。这样做的收益是筛选条件变了只要 setState 更新 filter列表自然跟着变不需要手动再调一次查询接口。当数据量不大时前端过滤体验比每次请求后端都要快因为不用等网络往返。表单校验这块我也踩出了一些心得。校验规则放在提交前统一执行校验失败的字段用统一的错误文案格式提示。错误提示要指出具体字段而不是笼统地说提交失败。我还顺手加了两个额外校验名称不能只填空格状态值必须在枚举列表里。这些看似小事实际是后面边界输入问题的第一道防线。3.3 本地数据与真实接口的平滑切换本地 mock 与真实接口的切换我设计了一个 dataProvider 来做统一出口。// src/services/dataProvider.js const useMock import.meta.env.VITE_USE_MOCK true; export async function fetchRecords(params) { if (useMock) return mockFetchRecords(params); return httpFetch(/api/records, params); } export async function createRecord(data) { if (useMock) return mockCreateRecord(data); return httpFetch(/api/records, { method: POST, data }); }环境开关放在构建时而不是运行时用环境变量注入。构建时固定下来之后每个环境跑什么模式是可预期的不会出现在界面上手动切来切去导致环境不一致的问题。联调开始之后把测试环境的 VITE_USE_MOCK 置为 false本地开发保持 true互不干扰。切换过程中很容易忽略一个细节mock 的返回结构要跟真实接口保持一致。如果 mock 返回 { code: 0, data: [...] }真实接口返回一个裸数组联调当天肯定翻天。所以我在 mock 层也套了同一套包装结构这在后面联调时省了大量时间。4. 实测踩坑记录状态不同步、边界输入和联调字段变动4.1 详情页显示旧数据一次状态不同步的完整排查这个 bug 是测试阶段第一个被报出来的列表里先打开一条 A 记录关闭把筛选条件换成另一组再打开一条 B 记录结果详情页显示的仍旧是 A 的数据。第一次复现我以为是缓存问题清缓存没用重启没用最后只能走代码排查。排查的链路我完整走了一遍。先加日志在打开详情页的方法里打印出传入的记录 id 和详情页实际渲染的字段值。日志显示页面拿到的是 B 的 id但渲染时读的却是一个名叫 selectedRecord 的独立状态它的值还停留在上一次的 A。问题一下就清楚了selectedRecord 在打开详情页时被赋值了一份记录快照但这个快照不会随筛选结果变化而更新所以每次打开详情页它都拿的是缓存下来的旧对象。修复方案也很简单详情页不存快照对象只存 currentRecordId渲染前从 store.records 里通过 id 实时查找最新记录。这样只要 store 里的 records 是对的详情页永远不会显示旧数据。注意派生数据不要作为独立状态保存。凡是能通过现有数据计算出来的内容一律实时计算否则容易出现两个源数据不一致的经典问题。这次排查给我的教训很深不要迷信缓存不要怀疑框架先检查数据来源是不是唯一的。4.2 空字符串与超长文本边界输入的两个典型案例rea的边界输入问题是在内部试用时暴露的。第一个案例是空字符串。录入表单的名称字段用户输入了几个空格前端校验没拦住结果列表里出现了一条未命名记录。原因很简单trim 操作没做空格被当成了合法名称。修复思路是在接入层统一做归一化。前端校验做一个 trim 和必填检查数据提供者在写入前再 trim 一遍双层保险。另外空字符串状态值也不安全筛选逻辑里 status 和 status 会同时触发条件分支所以我把状态值强制映射为预设枚举不在枚举范围内的值统一记成 unknown。第二个案例是超长文本。用户从别处粘贴了一整段带换行、带高亮的富文本到备注字段列表卡片直接撑破了布局。这个不能简单截断因为详情页还是要看到完整内容的。我的做法是存储层保留原始文本不做改动列表展示层使用统一的截断函数显示两行省略号即可。export function clampText(text, max 60) { const normalized String(text ?? ).replace(/\s/g, ).trim(); if (normalized.length max) return normalized; return normalized.slice(0, max) …; }这两个案例的共同点是用户不会按你的规范输入所以不能靠使用者自觉必须由接入层把脏数据挡住。4.3 接口字段说改就改用映射层挡住页面改动联调第三天后端发来一条消息接口返回字段改了之前的 title 改成了 namecreated_at 改成了 createTime状态字段从字符串改成了嵌套对象。如果页面代码直接消费接口字段这基本等于把前端的列表、表单、详情全部翻一遍。幸好rea在服务层做了一层字段映射我在 dataProvider 里加了 normalizeRecord 函数接口的数据先进这里统一转换。function normalizeRecord(raw) { return { id: raw.record_id ?? raw.id, title: raw.name ?? raw.title, source: raw.source_name ?? raw.source, owner: raw.owner_name ?? raw.owner, status: raw.state?.code ?? raw.status, createdAt: raw.create_time ?? raw.created_at ?? raw.createTime, }; }页面只消费 normalize 之后的标准字段后端以后怎么改我只动这一个函数。这次字段变更实际只花了一小时就全部搞定其中大半时间还是在跟后端确认旧数据的兼容逻辑。这个经验后来被我用到了所有项目里不管后端是刚开始定义接口还是已经相对稳定前端一律不直接消费原始字段。映射层虽然多写几行代码但它是缓冲垫能挡住前后端契约频繁抖动带来的连锁改动。5. 项目复盘三条能直接抄走的团队约定5.1 验收清单与回归清单rea上线前我列了一份验收清单不是走形式而是每一行都能落到具体操作上。功能点验证方式通过标准是否通过录入表单录入一条完整数据提交列表出现新记录数据正确是表单校验空名称、非法状态提交报错提示定位到具体字段是列表筛选按关键字、状态、时间组合筛选列表结果与条件一致是详情查看从筛选后列表点开详情展示当前记录完整内容是导出导出当前筛选结果文件记录数与列表一致是边界输入空串、超长文本、字段缺失页面不报错展示不破版是回归清单主要针对踩过的坑详情页在切换筛选和多次开关后必须确保显示最新记录边界输入样例在回归时必须再跑一遍接口字段映射层修改后需要跑全量用例防止漏改。5.2 三条约定派生数据不落 store、接口字段过映射层、输入边界统一归一化项目总结会上我把这次的经验浓缩成了三条约定不是三条空话是三条可以直接执行的标准。第一条派生数据不落 store。状态里只放原始数据凡是能通过现有数据计算出来的内容一律通过函数实时计算。这样从根上避免了数据不同步问题。第二条接口字段必须过映射层。前端代码禁止直接消费接口原始字段所有外部数据必须先过 normalize 函数再进入页面逻辑。第三条输入边界在接入层统一归一化。空串、非法枚举、超长文本的处理逻辑集中在数据接入处不散落在页面代码里。这三条约定后来直接写进了我们小组的工程文档后续几个小项目都按照这个套路执行。踩坑的人少了代码 review 的讨论也少了很多。我在实际带这个项目的过程中最大的体会是短平快的项目成败往往不在技术而在于边界定义得是否清楚、数据流设计得是否干净。技术选型只要守住稳定和熟悉这两条基本不会出大问题真正的坑全藏在那些不起眼的状态同步、边界输入和字段变化里。越是时间紧越要在这些地方多花半小时因为返工的成本远大于预防的成本。如果让我再提一个最后的小建议那就是找一张纸写下项目里最大的三个风险点贴在工位上。做完一件事划掉一个。专注地盯着风险点推进才能保证短周期项目不崩溃。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →