☰
前端Mock实战:模拟快递查询接口从零到一完整复盘
2026/10/20 6:15:50 网站建设 项目流程

做前端开发的同学,多少都遇到过这种情况:订单列表、商品详情、物流详情这些页面要提前开发,而后端查询接口还在排期。物流详情页尤其典型,产品要看交互效果,测试要写用例,前端要调样式,如果连一个能跑的模拟快递单号查询都没有,整个开发节奏都会卡住。这篇文章就是我把这个模拟模块从零到一实现出来的完整复盘,包括单号格式怎么定、物流状态机怎么设计、轨迹数据怎么生成、前端查询交互怎么做,以及几个真实踩过的坑。内容适合准备做前端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; charset=utf-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 class="time">${item.time}</div> <div class="location">${item.location}</div> <div class="desc">${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做得越接近真实,后面联调就越省事。数据结构、状态机、异常分支这些如果能在模拟阶段全部理清,真实接口接入时几乎只是改个地址的事。最后再分享一个小技巧:生成轨迹时用单号做随机种子,保证同一个单号任何时候查询都返回同样的结果。这个设计在演示、测试、问题复现时都特别有用,可以说是整个模拟项目里性价比最高的一行准备。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询