前端Mock实战:模拟快递查询接口从零到一完整复盘
做前端开发的同学多少都遇到过这种情况订单列表、商品详情、物流详情这些页面要提前开发而后端查询接口还在排期。物流详情页尤其典型产品要看交互效果测试要写用例前端要调样式如果连一个能跑的模拟快递单号查询都没有整个开发节奏都会卡住。这篇文章就是我把这个模拟模块从零到一实现出来的完整复盘包括单号格式怎么定、物流状态机怎么设计、轨迹数据怎么生成、前端查询交互怎么做以及几个真实踩过的坑。内容适合准备做前端Mock的新手也适合后端同学在联调前给自己搭一套可用的模拟接口看完就能直接抄作业。1. 先说清楚这个模拟项目到底在模拟什么1.1 一个必须解决的问题接口没就绪页面不能停工当时我接到的需求来自某电商平台的管理后台需要在一个订单详情页面里展示物流追踪信息。需求本身不复杂但难点在于真实快递查询接口至少要等两周才能进入联调环境页面却要按节点完成开发。如果什么都不做干等接口样式、交互、异常状态全都没法验证如果随手写死一个JSON数据塞进组件后面切真实接口时又要返工。于是“模拟快递单号查询”就成了前期开发的标配。说得直白一点就是用一套足够接近真实的假接口把整条查询链路先跑通。我给自己定的目标不是简单返回一份固定数据而是要做到三件事单号符合基本规则输入假单号不会报“格式错误”物流状态能按流程推进不会出现签收了又变回运输中整个查询过程有延迟、有异常分支前端能把Loading态和错误态都开发到位。这三点做完后面接真实接口时几乎不用改页面逻辑。1.2 方案选型三种Mock思路我为什么选了独立模拟服务动手之前我对比过三种实现方案各有适用场景这里列出来给大家参考。第一种写死一个JSON文件页面直接读取。这个方案最快适合只做静态演示产品想看个大概效果可以用。缺点很明显请求逻辑写在组件里后期切真实接口要把这部分代码删掉重写而且没法模拟延迟和异常Loading状态根本看不到。第二种在请求拦截层做替换。前端代码保持真实的请求写法由本地Mock工具或代理插件把特定URL拦截掉返回我们准备好的假数据。好处是前端代码不用改但工具配置对新人有一定理解成本而且模拟数据还是静态的没法按单号动态生成。第三种用Node搭一个独立的模拟服务提供完整的HTTP查询接口。前端写请求代码时就跟真实联调一模一样只是把URL指向Mock服务。这种方式工程量稍大但能模拟延迟、错误码、单号校验还能保持同一个单号多次查询结果一致对整个团队的价值也更高。我当时选的是第三种。原因很直接第一测试同事可以直接拿这个服务写用例不用等真实环境第二页面代码从一开始就是按真实接口的标准来写的切换成本最低第三模拟服务可以把数据生成逻辑独立维护后面想加异常场景只需要改Mock服务本身。虽然前期多花两三个小时搭服务后面省下的时间远不止这些。方案实现成本真实度切换成本适合场景写死JSON最低低高静态演示请求拦截替换中等中低已有拦截工具独立模拟服务中等高最低团队联调前准备1.3 接口返回结构把“数据契约”在Mock阶段定义清楚模拟服务第一件事不是写代码而是把接口返回的数据结构定义清楚。这个数据结构就是前后端之间的数据契约后期真实接口基本会按这个结构来实现。我当时定义的查询响应长这样{ code: 0, message: ok, data: { waybillId: KD1234567890123, status: 2, statusText: 运输中, estimatedTime: 2024-05-20 18:00:00, trajectory: [ { time: 2024-05-16 10:23:00, location: 华东分拨中心, description: 包裹到达【华东分拨中心】, type: TRANSPORT } ] } }字段不多但每个都是后来真实接口里的标准字段。waybillId是快递单号status是状态码数字statusText是给用户看的状态文案estimatedTime是预计送达时间trajectory是轨迹数组按时间正序返回每条轨迹包含时间、地点、描述和类型。我把trajectory里的type也做了枚举定义ORDER_CREATED表示下单、PICKED表示揽收、TRANSPORT表示运输中、DELIVERY表示派送中、SIGNED表示已签收、EXCEPTION表示异常。后面前端渲染时间轴时可以根据type决定圆点的颜色和图标不用去猜状态字符串这也是在Mock阶段就把规范定下来的好处。真实项目里见过太多因为字段含义不明确导致的联调扯皮最好的解法就是在模拟阶段就把每个字段的含义、取值、排序规则白纸黑字定好。2. 物流状态机与模拟数据设计把“假数据”做得像真的2.1 单号校验规则先让用户输错时不会直接打接口真实快递单号都有一定的编码规则不是随便一串数字就能查。模拟项目里如果连校验都不做用户随便输入一位数字也去发起查询体验就会很假。我设计的模拟单号规则是KD开头加上13位数字总共15位字符。其中最后一位是校验位由前14位数字通过加权计算得出。function isValidWaybillId(id) { const regex /^KD\d{13}$/; if (!regex.test(id)) { return false; } const digits id.slice(2).split().map(Number); let sum 0; for (let i 0; i digits.length - 1; i) { sum i % 2 0 ? digits[i] : digits[i] * 3; } const checkDigit sum % 10; return checkDigit digits[digits.length - 1]; }这个校验规则是我自定义的简化版核心目的是让前端形成“先校验再请求”的习惯。校验通过才发请求不通过就直接提示用户检查单号减少无效请求。前端拿到了合法单号模拟服务才能真正进入查询流程。同时我还写了一个生成合法单号的函数避免测试人员自己乱编单号function generateValidWaybillId() { const prefix KD; let payload ; for (let i 0; i 13; i) { payload Math.floor(Math.random() * 10); } const digits payload.split().map(Number); let sum 0; for (let i 0; i digits.length; i) { sum i % 2 0 ? digits[i] : digits[i] * 3; } return prefix payload (sum % 10); }有了生成函数我就可以在页面放一个“随机单号”按钮一键填入合法单号演示和测试都省事很多。不少Mock项目忽略这个细节导致测试人员每次都要自己想一个能过的单号体验很不好。既然要做模拟就把体验做到位。2.2 物流状态机七个节点怎么流转物流查询的核心逻辑是状态机。快递从下单到签收会经过多个阶段。我梳理了五个主状态加一个异常状态定义成一张表后面生成轨迹和前端渲染状态卡片都基于这张表。状态码状态名称状态说明轨迹数量参考0待揽收商家已下单快递尚未接单1条1已揽收快递员已取件2条2运输中干线运输或中转分拨4至6条3派送中包裹到达目的城市快递员配送6至8条4已签收本人签收或代收点签收8至10条-1异常拒收、联系不上收件人、地址错误等3至4条这里有个关键设计原则状态只能向前流转不能回退。从0到1到2再到3和4这是一个单向链路。异常态比较特殊它可以从任意非终态派生也就是说运输中或派送中都可能变成异常但一旦签收就不能再变回异常了。前端渲染时会把每个状态对应成一个大状态卡片展示当前状态文案。后端真实系统通常也是按这个思路设计状态字段不会只存一个“快递在哪”的字符串而是存一个数字状态码文本全都由前端映射这样可以避免中文文案变更时还要动接口。模拟阶段把状态机理清楚后面接真实接口时前端就只需要改映射表不用改渲染逻辑。2.3 轨迹生成算法随机生成但必须符合时间和地点逻辑状态机定了接下来是轨迹生成。这里最需要注意的不是随机而是随机里要有逻辑。如果只是随机塞几条记录就会出现包裹刚到华东分拨中心下一条又变成了商家仓库地点倒着走测试一看就觉得假。我采用的做法是先设定一条完整的地点链路从商家仓库出发经过区域分拨中心、华东分拨中心、某市分拨中心、配送站点最后到收件人地址。根据当前状态从链路里取前N个节点生成轨迹N由状态决定。时间也从下单时间开始往后递增每条轨迹比上一条晚一到几个小时。核心代码如下const STATION_FLOW [ 商家仓库, 区域分拨中心, 华东分拨中心, 某市分拨中心, 配送站, 收件人地址 ]; const STATUS_CONFIG { 0: { count: 1, text: 待揽收 }, 1: { count: 2, text: 已揽收 }, 2: { count: 5, text: 运输中 }, 3: { count: 7, text: 派送中 }, 4: { count: 9, text: 已签收 }, -1: { count: 3, text: 异常 } }; function generateTrajectory(status, seed, createTime) { const config STATUS_CONFIG[status]; const count config.count; const trajectory []; let currentTime new Date(createTime.getTime()); for (let i 0; i count; i) { currentTime new Date(currentTime.getTime() (2 Math.floor(Math.random() * 4)) * 3600 * 1000); const stationIndex Math.min(i, STATION_FLOW.length - 1); trajectory.push({ time: formatTime(currentTime), location: STATION_FLOW[stationIndex], description: generateDescription(status, stationIndex, i, count), type: mapStatusToType(status, i, count) }); } return trajectory; }轨迹条数按状态决定比如运输中一般是5条派送中是7条签收后最多9条。这样生成的轨迹既不会太短显得敷衍也不会太长显得不真实。description我在实际项目里是拼接出来的比如“包裹已到达【华东分拨中心】”“快递员【张师傅】正在派送中”这样的组合文案。模拟阶段不需要接真名我就用“快递员师傅”这种泛称等真实接口回来再替换。这里还有一个重要设计生成轨迹时必须用固定种子。同一个单号每次查询要返回同一条轨迹而不是每次刷新都生成新的不然测试和演示都没法复现问题。实现上我先把单号转换成一个哈希值再用这个值初始一个伪随机数生成器替代直接的Math.random()调用。这个细节看起来不起眼但真实项目中测试同事就是因为这个特性才能放心写断言。3. 核心代码实现从数据生成到页面交互的完整链路3.1 Mock数据服务模块一条完整物流轨迹是怎么生成的我把整个模拟服务拆成两个模块一个是数据生成模块一个是HTTP接口模块。数据生成模块负责根据单号生成轨迹接口模块负责接收请求、校验单号、返回数据。我用的Node内置http模块没有引任何框架目的是让依赖最少任何人拿过去都能跑。先看数据生成模块的完整代码const crypto require(crypto); const STATION_FLOW [ 商家仓库, 区域分拨中心, 华东分拨中心, 某市分拨中心, 配送站, 收件人地址 ]; const STATUS_CONFIG { 0: { count: 1, text: 待揽收, type: ORDER_CREATED }, 1: { count: 2, text: 已揽收, type: PICKED }, 2: { count: 5, text: 运输中, type: TRANSPORT }, 3: { count: 7, text: 派送中, type: DELIVERY }, 4: { count: 9, text: 已签收, type: SIGNED }, -1: { count: 3, text: 异常, type: EXCEPTION } }; function hashSeed(waybillId) { const hash crypto.createHash(md5).update(waybillId).digest(hex); return parseInt(hash.slice(0, 8), 16); } function createRandom(seed) { let state seed || 1; return function () { state (state * 1664525 1013904223) % 4294967296; return state / 4294967296; }; } function formatTime(date) { const y date.getFullYear(); const m String(date.getMonth() 1).padStart(2, 0); const d String(date.getDate()).padStart(2, 0); const h String(date.getHours()).padStart(2, 0); const min String(date.getMinutes()).padStart(2, 0); const s String(date.getSeconds()).padStart(2, 0); return ${y}-${m}-${d} ${h}:${min}:${s}; } function generateTrajectory(waybillId, status) { const rnd createRandom(hashSeed(waybillId)); const config STATUS_CONFIG[status]; const count config.count; const createTime new Date(2024, 4, 14, 9, 0, 0); const trajectory []; let currentTime new Date(createTime.getTime()); for (let i 0; i count; i) { const gapHours 2 Math.floor(rnd() * 5); currentTime new Date(currentTime.getTime() gapHours * 3600 * 1000); const stationIndex Math.min(i, STATION_FLOW.length - 1); const lastIndex count - 1; let description ; if (i 0 status 0) { description 商家已下单等待快递员揽收; } else if (i 0) { description 快递员已揽收包裹; } else if (i lastIndex status 4) { description 包裹已签收感谢使用快递服务; } else if (i lastIndex status 3) { description 快递员正在为您派送请保持电话畅通; } else if (status -1 i lastIndex) { description 物流异常已联系寄件方处理; } else { description 包裹已到达【${STATION_FLOW[stationIndex]}】; } trajectory.push({ time: formatTime(currentTime), location: STATION_FLOW[stationIndex], description, type: i lastIndex status 4 ? SIGNED : config.type }); } return trajectory; } function createMockWaybill(waybillId) { const rnd createRandom(hashSeed(waybillId) 7); let status; if (-1 String(waybillId[2] waybillId[3])) { status -1; } else { const statusPool [0, 1, 2, 3, 4]; status statusPool[Math.floor(rnd() * statusPool.length)]; } const config STATUS_CONFIG[status]; return { waybillId, status, statusText: config.text, estimatedTime: 2024-05-20 18:00:00, trajectory: generateTrajectory(waybillId, status) }; } module.exports { createMockWaybill, isValidWaybillId, generateValidWaybillId };这段代码里hashSeed和createRandom是我做“可控随机”的关键工具。先说为什么用随机数需要种子如果直接用Math.random()同一个单号每次生成的轨迹都不一样测试时查一次一个结果根本无法复现问题。用单号做种子生成伪随机序列后同一单号在任何时刻查询得到的轨迹都完全一致这才符合真实系统的行为。3.2 查询接口封装模拟网络延迟与异常分支数据模块就位后我封装了查询接口。这里要模拟的不只是数据还有网络行为。真实接口有延迟、有失败可能、有单号不存在的错误。如果Mock阶段全都返回200前端永远不会处理超时和错误到联调时必然手忙脚乱。我做了三个层次的模拟第一固定查询延迟在600到1400毫秒之间目的是让Loading动画有时间展示第二有百分之五的概率返回“系统繁忙”这是模拟服务端偶发错误第三未注册的模拟单号返回“单号不存在”错误码。const http require(http); const { createMockWaybill, isValidWaybillId } require(./waybillMock); function queryWaybill(waybillId) { return new Promise((resolve, reject) { const delay 600 Math.floor(Math.random() * 800); setTimeout(() { if (!isValidWaybillId(waybillId)) { reject({ code: 40001, message: 快递单号不存在 }); return; } const randomValue Math.random(); if (randomValue 0.05) { reject({ code: 49999, message: 系统繁忙请稍后重试 }); return; } resolve({ code: 0, message: ok, data: createMockWaybill(waybillId) }); }, delay); }); } http.createServer((req, res) { res.setHeader(Content-Type, application/json; charsetutf-8); res.setHeader(Access-Control-Allow-Origin, *); const url new URL(req.url, http://localhost); const waybillId url.searchParams.get(waybillId); if (url.pathname /api/query req.method GET) { queryWaybill(waybillId) .then((result) { res.statusCode 200; res.end(JSON.stringify(result)); }) .catch((err) { res.statusCode err.code 40001 ? 404 : 500; res.end(JSON.stringify(err)); }); } else { res.statusCode 404; res.end(JSON.stringify({ code: 404, message: Not Found })); } }).listen(3000, () { console.log(Mock物流查询服务已启动: http://localhost:3000); });我把queryWaybill封装成Promise而不是直接用http请求返回是为了后续切换到真实接口时前端只需要替换这个函数内部实现不用改调用方。延迟我这里用的是均等分布随机600毫秒到1400毫秒之间均匀分布。如果想让模拟更精细其实可以用正态分布但实际测试下来均匀分布已经完全够用。3.3 前端查询页面搜索、校验、状态卡片与时间轴模拟服务跑了接下来要做一个最小的前端页面把整条链路串起来。我用了原生JavaScript加简单DOM操作没有引框架因为这个页面本质上只做一件事拿单号、请求接口、渲染结果。前端结构分成三块区域查询区、结果区、历史记录区。查询区有一个输入框、一个查询按钮、一个随机单号按钮。结果区包含状态卡片和时间轴。历史记录区用localStorage保存最近查询过的单号点击历史单号可以重新查询。关键实现如下const form document.getElementById(queryForm); const input document.getElementById(waybillInput); const statusCard document.getElementById(statusCard); const timeline document.getElementById(timeline); const historyList document.getElementById(historyList); form.addEventListener(submit, async (e) { e.preventDefault(); const waybillId input.value.trim(); if (!isValidWaybillId(waybillId)) { showError(单号格式不正确请输入KD开头的13位数字单号); return; } setLoading(true); try { const result await queryWaybill(waybillId); renderResult(result.data); addHistory(waybillId); } catch (err) { showError(err.message); } finally { setLoading(false); } }); function renderResult(data) { statusCard.textContent data.statusText; timeline.innerHTML ; data.trajectory.forEach((item) { const li document.createElement(li); li.innerHTML div classtime${item.time}/div div classlocation${item.location}/div div classdesc${item.description}/div ; timeline.appendChild(li); }); }这段代码里校验、请求、渲染是三个完全独立的函数彼此不耦合。后面真实接口接入时只需要把queryWaybill这个函数的内部实现从Mock延迟改成fetch请求页面其他部分完全不动。这就是分层设计的好处。很多初学者容易把校验和请求写在一起结果校验逻辑一改请求也跟着动维护成本立刻上来。时间轴渲染我把每条轨迹渲染成一个列表项显示时间、地点、描述。视觉上再通过CSS在每条记录左侧画一条竖线和圆点就形成了常见的时间轴效果。状态卡片顶部显示当前状态的大字文案下面再显示预计送达时间。整个页面的设计目标是让用户一眼看清“包裹现在在哪、接下来去哪里、中间经过了哪些地方”信息层级不能乱。历史记录加载逻辑我也做了每次查询成功后把单号存入localStorage数组页面加载时读取并渲染成一个单号列表点击历史单号就自动填入输入框并触发查询。这个功能在Mock阶段看似无关紧要但演示时特别好用不用每次手输单号也方便测试人员对比同一单号在不同时间的查询结果。4. 常见问题与排查技巧实录4.1 轨迹时间线错乱的根因与修复我第一次实现轨迹生成时没有刻意控制时间顺序直接把状态对应的节点填进数组结果前端渲染时出现了一个特别尴尬的现象包裹先显示“已到达某市分拨中心”过两天又显示“正在从商家仓库发往分拨中心”时间戳完全倒流。这放在真实快递里是不可能发生的测试同事一眼就看出来是Mock数据。排查后发现根因我太依赖状态码而不是时间排序。生成轨迹时先写了派送节点再补了揽收节点虽然数组顺序看起来完整但时间戳对不上。修复方案很简单所有轨迹生成完后统一按时间字段排序。trajectory.sort((a, b) new Date(a.time) - new Date(b.time));这条排序代码是后来才补上的但最好的做法是生成时就保证时间单调递增排序只是兜底。我还总结了一条经验所有涉及时间线的模拟数据在设计阶段就要把“时间只能前进”当成硬约束而不是等渲染出问题再补救。另外我让每个站点索引和时间递增同步进行站点越靠后时间越晚这样从源头上规避了倒流问题。4.2 同一个单号多次查询结果不稳定开发到一半我遇到了一个更隐蔽的问题同一单号在不同时间查询返回的轨迹完全不一样第一次查是运输中第二次查变成了已签收第三次查又回到了待揽收。一开始我觉得“反正也是模拟无所谓”后来测试同事反馈说没法写用例因为每次断言的结果都不一致我才意识到这是个必须解决的问题。解决方式是引入状态的可复现性。我把状态选择也改成基于单号种子的伪随机同一个单号永远映射到同一个状态不会每次随机改变。实现时我在createMockWaybill里用了和生成轨迹不同的种子参数这样状态和轨迹都稳定了。如果想演示“同一单号过一会状态更新”的场景可以单独开发一个状态推进接口按时间戳或查询次数推进状态但默认的查询接口一定要保持幂等。我在这个项目里把幂等当作默认行为因为测试用例要求的结果就是确定性的。只有数据可预测自动化测试才谈得上稳定。4.3 Loading一闪而过测试测不出加载动画模拟延迟我最初设置的是200毫秒到500毫秒结果前端Loading组件刚显示就消失了测试同学说根本截不到加载中的截图。后来我调成了600毫秒到1400毫秒依然偏快。原因是Mock服务的延迟只是为了让动画“能出现”而不是要模拟真实网络那么逼真。我最后加了一个config模块里面放一个delayTime变量平时默认1000毫秒调试时手动改成3000毫秒。这样看Loading动画、截测试图都很方便调回正常值只改一个数字。不要小看这个细节测试同事给前端提Loading态需求时如果Mock延迟不够前端只能反复刷新碰运气浪费大量时间。const config { mockDelay: 1000, isMockEnabled: true };4.4 从模拟服务切换到真实接口的避坑顺序模拟服务做得再好最后还是要切换到真实接口。我在项目里提前预留了切换点前端所有查询都走queryWaybill函数函数内部根据isMockEnabled开关决定是走Mock还是走fetch。切真实接口时只需要改一个配置文件页面代码一行不用动。真实接口第一次联调时我遇到最典型的坑是时间格式不一致。Mock接口返回的是“YYYY-MM-DD HH:mm:ss”字符串真实接口返回的是毫秒时间戳。如果我当初在页面里直接用字符串渲染切换后就会显示一串数字。好在我提前写了一个formatTime工具函数统一把各种格式转换成标准字符串切换时只动了这一个函数。还有一个容易被忽略的问题真实接口通常需要鉴权头而Mock阶段没有。如果前端代码里把请求头写死切换后就会报401。我的建议是Mock阶段就把请求头留成扩展对象哪怕值是空的也要把传入位置留出来。否则真实联调时人人都在改公共请求类很容易改出兼容性问题。切换到真实接口后还有一个心态上的转变Mock阶段定义的字段名、状态码、错误码如果和真实接口有出入应该以真实接口为准而不是强行要求后端配合Mock的结构。毕竟Mock的价值是提前暴露问题不是变成业务约束。我见过有的人做Mock时把字段定义得特别“完美”结果真实接口因为历史包袱没法对齐最后前端还是要改所以联调时保持开放心态很重要。做模拟快递查询这个项目我个人最大的体会是Mock做得越接近真实后面联调就越省事。数据结构、状态机、异常分支这些如果能在模拟阶段全部理清真实接口接入时几乎只是改个地址的事。最后再分享一个小技巧生成轨迹时用单号做随机种子保证同一个单号任何时候查询都返回同样的结果。这个设计在演示、测试、问题复现时都特别有用可以说是整个模拟项目里性价比最高的一行准备。