ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

piaoyi-citypicker 数据源改造:服务端省市县三级联动数据对接实战

piaoyi-citypicker 数据源改造:服务端省市县三级联动数据对接实战 1. 为什么需要改造 piaoyi-citypicker 的数据源piaoyi-citypicker 这个组件在前端圈子里算是老面孔了做移动端 H5 表单的同行大概率都接触过。它默认内置了一份省市县三级联动数据开箱即用初始化的时候直接new CityPicker()就能跑起来。但问题也恰恰出在这里——内置数据是写死在包里的一旦业务要求行政区划跟服务端保持一致比如后台维护了一套带业务属性的区域表区域编码、是否开通、配送范围、网点归属等内置数据就完全不够用了。我最近接的一个项目就是这种场景。后端有一套自建的行政区划服务返回的 JSON 结构跟 piaoyi-citypicker 默认吃的数据格式对不上字段名不一样层级组织方式也不一样还多了一些业务字段。前端如果继续用内置数据就会出现用户能选到一个后端根本不支持的区域这种尴尬情况提交订单直接报错。所以必须把数据源从内置改成服务端下发。这篇文章就是把这个改造过程完整拆一遍。我会讲清楚 piaoyi-citypicker 到底吃什么格式的数据、服务端数据怎么映射过去、异步加载的时序怎么处理、回显怎么做、以及我踩过的几个坑。适合正在用这个组件、又需要对接自建区域服务的同学参考。哪怕你用的不是这个组件只要涉及三级联动 远程数据源这个模式思路也是通用的。先说结论改造的核心不是改组件源码而是在组件外部把服务端数据转换成组件认识的格式再通过它提供的接口塞进去。理解这一点后面所有操作都是顺理成章的。2. 先搞清楚 piaoyi-citypicker 的数据契约动手之前必须把组件的数据结构摸透否则转换逻辑一定写歪。我当初就是没仔细看直接拿服务端数据往里塞结果选择器打开是空白的排查了半天。2.1 默认数据格式长什么样piaoyi-citypicker 内部维护的数据大致是这样一个嵌套结构不同版本略有差异但核心一致[ { name: 北京市, code: 110000, children: [ { name: 北京市, code: 110100, children: [ { name: 东城区, code: 110101 }, { name: 西城区, code: 110102 } ] } ] } ]关键点有三个第一它是树形嵌套不是扁平数组第二每一级用name做显示、code做唯一标识第三叶子节点第三级通常没有children字段。组件在渲染时就是按这个层级一层层往下钻的。2.2 组件暴露了哪些可用的入口piaoyi-citypicker 一般会提供这么几个能力改造时全靠它们构造函数接收配置对象其中可以传入自定义数据不同版本字段名可能是data、cityData或list需要看你用的版本实例上有setData或类似方法可以在初始化之后动态替换数据有getValue/onChange之类的回调用来拿用户选中的结果。提示不同版本的 piaoyi-citypicker API 命名有出入动手前先翻一下你node_modules里那个包的实际源码看它构造函数里读了哪个字段、有没有暴露 setData。这一步花五分钟能省后面两小时。2.3 服务端数据常见的几种不兼容形态我见过的服务端返回大致分三类处理方式完全不同服务端形态典型结构转换难度已经是嵌套树{label, value, children}低改字段名即可扁平列表带 parentId[{id, parentId, name}]中需要自己建树分接口按级拉取/province、/city?pid、/area?cid高涉及异步时序大部分自建服务是第二种——数据库里存一张带parent_id的表接口直接select *返回扁平数组。这种最需要前端做建树转换也是本文重点。3. 服务端扁平数据转成组件树形结构这是整个改造里最核心的一步逻辑不难但细节多写不好就有性能问题和数据错乱。3.1 建树算法的选择与实现扁平转树有两种常见写法一种是双重循环暴力匹配另一种是用 Map 做一次遍历。数据量小的时候两者没差别但省市县加起来三千多条双重循环就是接近千万次比较在低端机上会明显卡顿。所以我用的是 Map 索引法时间复杂度 O(n)。function buildTree(list, rootId 0) { const map {}; const tree []; // 第一遍把所有节点放进 map顺便初始化 children list.forEach(item { map[item.id] { name: item.areaName, // 映射成组件要的 name code: item.areaCode, // 映射成组件要的 code children: [] }; }); // 第二遍挂载父子关系 list.forEach(item { const node map[item.id]; if (item.parentId rootId) { tree.push(node); } else { const parent map[item.parentId]; if (parent) { parent.children.push(node); } } }); return tree; }这段代码有两个地方值得说。第一name和code的映射写死在建树阶段而不是建完树再遍历改一遍少一次全量遍历。第二parentId rootId判断根节点rootId默认给 0但很多服务端用null或-1表示顶级这个要按你接口实际情况调。3.2 空 children 的处理很关键上面代码里每个节点都初始化了children: []这会导致第三级叶子节点也带一个空数组。piaoyi-citypicker 判断是否还有下一级的逻辑有的版本是看children是否存在有的是看children.length。如果是前者空数组会被当成还有下级点进去就是空白页。所以建完树之后我习惯加一步清理function cleanEmptyChildren(nodes) { nodes.forEach(node { if (node.children node.children.length 0) { delete node.children; } else if (node.children) { cleanEmptyChildren(node.children); } }); return nodes; }注意这一步别偷懒。我第一版就是没清理测试的时候点到区县那一级发现还能继续点点进去一片空白用户直接懵。清理之后层级就正常收住了。3.3 字段映射要留一层配置虽然上面把areaName、areaCode写死了但实际项目里我更推荐抽一层映射配置因为服务端字段名改起来很随意今天叫areaName明天重构可能就叫name了。const FIELD_MAP { id: id, parentId: parentId, name: areaName, code: areaCode };这样接口字段一变只改配置不动算法。团队协作的时候这个配置还能当成前后端的数据契约文档谁改谁知道。4. 异步加载的时序问题与解决方案数据从服务端来就一定是异步的。而 piaoyi-citypicker 初始化是同步的这就产生了时序矛盾组件先初始化了数据后到怎么办4.1 三种时序方案对比我把能想到的方案都试了一遍列个表对比方案做法优点缺点先请求后初始化await 拿到数据再 new逻辑最简单首屏白等用户点了没反应先初始化后 setData先 new数据到了调 setData响应快依赖组件有 setData懒加载用户点击时才请求省流量首次点击有延迟我最终选的是方案二。原因很实际用户从进入页面到点击选择器中间通常有几秒的浏览时间这段时间足够数据请求回来。先初始化保证点击立刻有反馈数据到了再替换体验最顺。4.2 用 Promise 封装请求并做缓存区域数据是低频变更的没必要每次进页面都拉。我用 sessionStorage 做了一层缓存配合版本号校验async function loadAreaData() { const CACHE_KEY area_data_v1; const cached sessionStorage.getItem(CACHE_KEY); if (cached) { return JSON.parse(cached); } const res await fetch(/api/area/list); const raw await res.json(); const tree cleanEmptyChildren(buildTree(raw.data)); sessionStorage.setItem(CACHE_KEY, JSON.stringify(tree)); return tree; }缓存 key 里带版本号v1后端区域数据大改的时候前端把版本号一升旧缓存自动失效不用手动清。这个小技巧在灰度发布的时候特别好用。4.3 数据到达前的兜底交互数据没回来之前用户如果已经点了选择器不能让他对着空白发呆。我的做法是给选择器一个 loading 态或者干脆在数据没准备好时禁用点击let areaReady false; const picker new CityPicker({ // ...其他配置 }); loadAreaData().then(tree { picker.setData(tree); areaReady true; }); // 触发选择的地方 function openPicker() { if (!areaReady) { toast(区域数据加载中请稍候); return; } picker.show(); }提示setData这个方法名要按你实际版本确认。有的版本叫setData有的叫updateData还有的干脆没有这个方法只能销毁重建。如果没有 setData退而求其次就是数据到了再new一次把旧的实例销毁掉。5. 选中值回显与业务字段透传数据能选只是第一步真正麻烦的是编辑场景下的回显和选中后要拿到业务字段。5.1 回显从 code 反查完整路径编辑一个已有地址时数据库里存的是三个 code省 code、市 code、区 code。piaoyi-citypicker 要回显通常需要你告诉它当前选中的是哪几个节点。不同版本 API 不一样常见的是传一个 code 数组或者一个路径数组。我的做法是先在树里做一次深度遍历把三个 code 对应的节点路径找出来function findPath(tree, targetCode, path []) { for (const node of tree) { const currentPath [...path, node]; if (node.code targetCode) { return currentPath; } if (node.children) { const found findPath(node.children, targetCode, currentPath); if (found) return found; } } return null; }拿到路径后把路径上的 code 组成数组传给组件做默认值。这里有个坑如果服务端的区域数据更新过某个历史 code 可能已经不存在了findPath会返回 null。这时候不能直接崩要降级处理——要么清空让用户重选要么回退到上一级。5.2 透传业务字段别只拿 name组件默认回调给你的往往只有 name 和 code。但业务上你可能还需要这个区域是否开通配送网点 ID 是多少这些字段。这些字段在服务端数据里是有的只是建树的时候被我们丢掉了。解决办法是在建树时把业务字段一起挂到节点上map[item.id] { name: item.areaName, code: item.areaCode, children: [], // 业务字段原样带上 bizId: item.bizId, deliverable: item.deliverable, raw: item };这样选中回调里就能通过节点拿到这些字段。不过要注意有些组件在回调时只返回 name/code不返回整个节点对象。这种情况就得在回调里用 code 反查一次树把业务字段捞出来。5.3 一个容易忽略的细节同名区域中国有大量同名区域比如城关区在好几个省都有朝阳区北京和长春都有。如果回显逻辑只按 name 匹配必然出错。所以回显一定要用 code绝不能用 name。这一点我在项目里专门写了注释提醒后来人因为真的有人图省事用 name 匹配测试环境数据少没暴露上线就出问题。6. 常见问题排查与避坑清单改造过程中我踩的坑基本都集中在下面这些整理成速查表遇到问题对着查。6.1 问题速查表现象可能原因排查方向选择器打开空白数据格式不对 / 字段名没映射打印转换后的树对比组件默认数据只能选到第二级叶子节点带了空 children检查 cleanEmptyChildren 是否执行回显不生效code 类型不一致字符串 vs 数字统一转成字符串再比较切换页面后数据丢失缓存没做 / 实例被销毁检查缓存逻辑和组件生命周期选中后拿不到业务字段回调只返回 name/code用 code 反查树低端机卡顿建树用了双重循环换成 Map 索引法6.2 类型不一致这个坑要单独说服务端返回的 code 有时候是数字110000有时候是字符串110000取决于后端序列化方式。而组件内部比较、回显匹配用的可能是字符串。如果不统一就会出现明明数据里有这个 code就是匹配不上的诡异现象。我的处理是在建树阶段强制转字符串code: String(item.areaCode)一行代码省掉无数排查时间。同理parentId和id的比较也要保证类型一致否则建树的时候父子挂不上树就是断的。6.3 数据量大的性能优化三千多条数据建树本身不慢但如果每次打开选择器都重新建一次树累积起来就有感了。所以建树结果一定要缓存而且缓存的是建好树之后的结构不是原始扁平数组。这样下次直接用省掉建树开销。另外如果服务端支持按需返回比如只返回某个省下的市那最好做成分级懒加载首屏只拉省级用户选了省再拉市。这样首屏数据量从三千条降到三十几条加载速度提升非常明显。代价是每次切换级别都有网络延迟需要权衡。我的经验是区域数据总量在两千条以内一次性拉全量 缓存最省心超过五千条考虑懒加载。6.4 版本升级的兼容处理piaoyi-citypicker 这类老组件不同版本 API 差异不小。如果你的项目里同时有多个页面用了不同版本改造时最好把数据转换逻辑抽成一个独立的工具模块跟组件解耦。这样组件升级或者换组件转换逻辑不用动。// areaAdapter.js export function toPickerFormat(serverData) { // 建树 字段映射 清理 return cleanEmptyChildren(buildTree(serverData)); }页面里只管调用toPickerFormat拿到结果塞给组件。哪天组件从 piaoyi-citypicker 换成别的只要新组件的数据格式一致这个适配层几乎不用改。7. 我个人的几点实操体会最后聊几句掏心窝的经验都是文档里不会写、但实际项目里特别值钱的。第一改造前先写一个数据对比脚本。把服务端返回的数据和组件默认数据都打印出来逐字段对比看清楚差异在哪。我见过有人不看数据直接改代码改了半天发现是字段名大小写的问题。第二回显逻辑一定要用真实的历史数据测。测试环境的数据往往是干净的、最新的但生产环境有大量历史订单里面的 code 可能是几年前的老编码。用真实数据跑一遍回显能提前发现 code 失效、区域合并这些问题。第三给数据转换加日志和异常捕获。建树过程中如果遇到孤儿节点parentId 指向一个不存在的父节点不要静默丢弃打个 warn 日志。这种数据问题往往是后端数据不一致的信号早发现早让后端修。第四缓存要设过期策略。sessionStorage 在标签页关闭后就清了但如果用户长时间不关页面缓存可能一直是旧的。我的做法是缓存里存一个时间戳超过 24 小时就重新拉。区域数据虽然变得慢但偶尔也会有调整加个过期时间更稳妥。这套改造方案我在两个项目里都用过一个用的是全量拉取加缓存一个用的是分级懒加载都跑得挺稳。核心思路就一句话组件负责渲染数据转换和加载逻辑全部放在组件外面。这样职责清晰组件升级、接口变更都不会互相牵连。如果你也在做类似的改造希望这篇能帮你少走点弯路。
返回列表