看板的核心界面是一张城市地图,上面要同时显示骑手位置、待派订单、异常订单、商家门店,全城加起来是数万个点位,而且骑手位置是持续变化的。
第一版直接用地图 SDK 的标记点 API:每个点位一个标记对象,本质上是一个 DOM 元素。测试时用某个区的数据(几百个点)完全正常,切到全城视图(几千个点)就开始卡,滚动缩放明显掉帧,开一段时间内存持续上涨。运营的应对是「只看一个区」,等于全城视图这个核心功能不可用。
根因很清楚:几千个 DOM 元素的创建、样式计算、重排重绘,浏览器扛不住;而位置更新时每个标记都要改样式,等于每次推送触发几千次 DOM 操作。
所以第一个决定是放弃逐点 DOM,改用 Canvas 自绘图层:在地图上叠一层 Canvas,所有点位由我们自己画。这样几万个点位只是一次 Canvas 绘制,成本从「点位数量级的 DOM 操作」降到「一次绘制」。代价是交互(点击、悬浮)要自己实现。
但只换渲染方式还不够,因为几万个点位画出来本身也是视觉噪音——全城视图下点位密密麻麻,运营什么也看不出来。所以第二个设计是网格聚合:按缩放层级把点位聚合到网格里,远景显示「这个网格有 120 个骑手」的数字,放大到一定层级才显示单个点。这既解决了视觉噪音,也顺带把要绘制的图元数量降了一个量级。
第三个是视野裁剪:只渲染当前视野内的点位。地图缩放到某个区时,其他区的点位压根不需要参与绘制。这一条实现简单但收益直接。
第四个是更新方式。骑手位置持续推送,第一版是每次推送替换整份数据然后全量重绘。问题有两个:一是位置跳变(上报间隔是几秒,点位会从 A 直接跳到 B,看起来很突兀),二是重绘次数和推送条数同阶(高峰期每秒几十条推送就是每秒几十次重绘)。
改法是增量更新加插值动画加帧内合并:只更新变化的点位;移动做插值让点位平滑滑过去;短时间内的多次更新在一帧内合并成一次重绘,让重绘次数与帧率同阶而不是与消息条数同阶。
最后一个容易被忽略的点:点击命中。用 Canvas 自绘之后没有 DOM 事件了,要自己判断点击到了哪个点位。朴素做法是遍历所有点位算距离,几万个点位每次点击都遍历一遍会卡顿。所以用空间索引(网格或四叉树),点击时只查附近网格里的点。
Canvas 自绘 vs 地图 SDK 标记点,这是这个模块的分水岭决策。SDK 标记点的好处很实在:交互(点击、悬浮、信息窗)、层级管理、动画全部现成,开发快。但它的成本随点位数线性增长,几千个点就到上限。自绘的代价是把交互全部重新实现一遍(点击命中、悬浮高亮、信息窗定位),这是实打实的工作量。判断依据是点位量级:几百个用 SDK,几万个必须自绘,中间的看更新频率——更新越频繁越应该自绘,因为 DOM 方案的更新成本更高。
网格聚合既是性能手段也是可读性手段,而后者更重要。如果只从性能考虑,视野裁剪加 Canvas 已经能撑住几万个点;但几万个点画出来运营压根看不出信息——密密麻麻一片,既看不出哪里骑手多也看不出哪里订单堆积。所以聚合的第一目的是让数据可读,性能收益是附带的。这个认识影响了聚合层级的设计:层级切换的阈值是按「什么粒度对运营有意义」定的,不是按「多少个点会卡」定的。
插值动画是必需的,不是锦上添花。看起来这只是视觉效果,但它解决了一个真实的可信度问题:点位跳变会让运营怀疑数据准确性(「这个骑手怎么一下跳这么远,是不是定位漂了」)。而实际是上报间隔的正常现象。插值让「离散上报」看起来像「连续移动」,减少了不必要的质疑。取舍是插值期间显示的位置是推算的、不是真实上报的,所以点击查看详情时要显示最后一次真实上报的时间,不能让推算位置被当成实测位置。
踩过的坑:用 SDK 标记点,全城视图卡到不可用,运营只看单区。测试时用单区数据(几百点)完全正常,切全城(几千点)就卡,滚动缩放明显掉帧,开一段时间内存持续上涨。修法是 Canvas 自绘加聚合加视野裁剪。教训是:渲染方案的选择必须按生产数据量级验证,而不是按测试数据量级——几百和几千不是量的差别,是要换方案的差别。而且用户会用「少用」绕开性能问题(只看一个区),不会来提工单,所以要看功能使用数据:如果没人用全城视图,说明它不可用。
踩过的坑二:位置推送逐条触发重绘,高峰期每秒几十次全量重绘。页面在午高峰明显卡顿,而单看每次重绘的耗时是可接受的,问题在次数。修法是帧内合并:把短时间内的多次更新合并成一次重绘。教训是:高频数据驱动的渲染,要把「数据更新频率」和「渲染频率」解耦——数据可以来得很密,渲染只需要跟上帧率,这条在图表、地图、列表实时更新上都成立。
踩过的坑三:Canvas 自绘后点击命中用遍历,点击有明显延迟。几万个点位每次点击都遍历算距离,点击后要等一下才弹出信息窗,运营以为没点上会再点一次。修法是建空间索引,点击时只查附近网格。教训是:换自绘方案时要把「交互成本」一起算进去,只算绘制成本会漏掉命中检测这类隐藏工作量。
踩过的坑四:位置跳变引发对数据准确性的质疑。运营反馈「骑手位置乱跳,定位是不是有问题」,实际是上报间隔几秒的正常表现,我们花了时间去排查一个不存在的定位问题。修法是插值动画,同时在详情里显示最后一次真实上报时间。教训是:离散数据的可视化如果不做平滑,用户会把「采样间隔」误读为「数据异常」——而澄清这类误解的沟通成本远高于做插值。
没做的部分:没上 WebGL。Canvas 二维绘制在我们的量级下够用,WebGL 能再提升一个量级但复杂度和兼容成本明显上升,当时判断收益不足。也没做骑手轨迹的回放(在地图上重放某个骑手一段时间的移动),需求存在但需要拉取轨迹数据,工作量主要在服务端。
可流畅渲染的点位量级:最该报的数字。报「在指定机型与浏览器下,从多少点位开始掉帧」,改造前后对比。必须写明机型、浏览器、点位类型构成、是否开启聚合——脱离这些条件的点位数没有意义。
重绘次数:报重绘次数与推送条数的关系变化——改造前与消息条数同阶(每秒几十条就是几十次),改造后与帧率同阶。报这个关系比报一个具体次数更能说明设计,因为它不依赖当时的推送量。
点击响应:确定性验证加耗时——报点击到信息窗弹出的耗时,改造前是遍历几万个点(有可感知延迟),改造后是查附近网格。
内存:报连续运行一段时间后的内存曲线,改造前是持续上涨(几千个标记对象反复创建销毁),改造后平稳。用曲线形态描述而不是峰值数字。
全城视图的使用率:这是个很有说服力的间接指标——改造前运营只看单区(用「少用」绕开性能问题),改造后全城视图被真实使用。用户行为的变化比性能数字更能证明问题被解决。
插值的副作用:要主动说明插值期间显示的是推算位置,并说明详情里会显示最后一次真实上报时间。主动交代这个取舍比等面试官问出来好。
不要报「支持无限点位」或「渲染性能提升 10 倍」。前者不成立(Canvas 也有上限),后者分母不清(对比的是什么、什么条件下)。正确表述是「在什么条件下从多少点位提升到多少、重绘次数与推送条数解耦、内存曲线由上涨变平稳、点击命中从遍历改为空间索引」。
本地生活业务有一个根本特点:强地域性。配送费、起送价、时效承诺、补贴力度这些配置,每个城市不一样,同一城市不同时段不一样,同一时段不同品类还不一样(外卖和跑腿的时效承诺完全不同)。所以配置天然是三个维度的交叉。
第一版是逐条表单:新建一条配置,选城市、选时段、选品类、填值。功能上没问题,但运营用起来完全抓不住整体。
第一个问题是看不出覆盖情况。几十个城市 × 几个时段 × 几个品类,配置条数是几百条,运营在一个列表里翻,压根不知道「哪些组合已经配了、哪些没配」。
第二个问题更严重:规则空洞。某个组合(比如「某新开城市 + 夜间 + 跑腿」)没有任何配置命中,线上就取了系统兜底值——而兜底值往往是保守的默认配置,跟这个城市的实际运营策略不符。这类问题的发现路径通常是「运营发现某个城市的某个时段数据不对」,排查半天才找到是没配。
第三个问题是不知道最终生效的是哪一条。配置有默认值、有城市级、有城市加时段级,层层覆盖之后,运营问「上海晚高峰的外卖起送价到底是多少」,得靠人去推。
所以三个设计。
第一是矩阵化编辑:把「城市 × 时段」摊成一张表格(品类切换),每个单元格就是一个配置值。这样覆盖情况一目了然——哪些格子有值、哪些是继承默认值、哪些是空的,看一眼就知道。而且要可视区分「继承」和「覆盖」(继承的用灰色显示默认值,覆盖的用正常颜色),这一点和连锁菜单的继承标记是同一个思路——用户看不见的继承关系等于不存在。
第二是发布前的冲突与空洞检测:空洞是「某组合无任何规则命中」,冲突是「多条规则重叠且优先级不明确」。这两类都能在发布前算出来。空洞检测是这个模块收益最大的一项——它把一类「上线后才发现」的问题变成了发布前的一个列表。
第三是生效预览:运营输入具体条件(上海、晚高峰、外卖),系统反查「将命中哪一条配置、这条配置来自哪一级」。这解决了「层层覆盖之后不知道最终生效什么」的问题。它的作用和营销规则的试算器完全一样:让配置结果可见。
另外因为城市配置的影响面是全城级的(改错一个配送费,全城所有订单都受影响),所以变更要有影响面确认、灰度比例、定时生效——定时生效尤其必要,因为很多配置调整要在特定时间点生效(比如活动开始前)。
矩阵化编辑 vs 逐条表单,代价是「维度多于二维时矩阵就摊不开了」。我们有三个维度,摊了两个(城市 × 时段)、第三个(品类)用切换。如果再增加一个维度(比如渠道),矩阵就不够用了。这是矩阵方案的天然上限。我们接受这个上限,理由是「三维是业务的稳定形态」——城市、时段、品类是本地生活配置的固有维度,短期不会增加。如果将来真要加维度,正确的做法不是继续叠切换器,而是回到规则列表加上强大的生效预览:矩阵的价值在于「一眼看出覆盖情况」,维度多了这个价值本身就消失了。
空洞检测比冲突检测重要得多,这一点值得单独说。冲突(多条规则重叠)通常有明确的优先级规则兜着,行为虽然可能不符合预期但至少是确定的;而空洞是完全没有配置,系统悄悄用了兜底值——它是静默的、而且兜底值往往和该城市的实际策略差很远。「静默地用了一个错的默认值」比「两条规则冲突」危险,因为前者没有任何信号。这个判断和审批流程里「条件分支必须有默认出口」是同一个思路:要在结构上保证「任何输入都有明确的处理路径」。
生效预览是「反查」而不是「模拟」。营销规则的试算器是模拟一次计算,这里的预览是反查规则命中链路——输入条件,输出「命中了哪一条、这条来自哪一级、上面还有哪些规则因为优先级低没命中」。后半句很重要:只告诉运营「最终值是 X」,他仍然不知道该改哪一层;告诉他「命中的是城市级规则,你改默认值没用」,他才能正确操作。这和试算器「要给明细不能只给总价」是同一条原则。
踩过的坑:新开城市漏配置,线上静默用了兜底值。某个新开城市的「夜间 + 跑腿」组合没有配置,系统用了保守的默认起送价,比该城市实际策略高很多,导致夜间跑腿单量异常低。运营是看数据发现异常,排查了半天才找到是没配。修法是发布前空洞检测,并且新城市开城时强制走一遍完整性检查。教训是:有兜底值的配置系统里,「漏配」是静默故障——它不报错、不告警,只是行为不对,所以必须有独立的完整性检查去发现,不能指望它自己暴露。
踩过的坑二:单元格用行列下标做键,城市列表变化后配置错位。某个城市暂停运营被从列表里移除,后面所有城市的配置整体串位了一行,是运营发现「这个城市的配送费怎么变了」才发现的。修法是用「城市 ID + 时段 ID」做稳定键。教训和 SKU 矩阵、楼层排序完全一样:键不能来自数组位置——这个错误我在三个不同场景里都遇到过,已经成了我写任何列表渲染时的固定检查项。
踩过的坑三:全量提交导致多人编辑互相覆盖。两个运营同时在矩阵里改不同城市的配置,后提交的把先提交的盖掉了,先提交那位以为自己没保存成功。修法是只提交有变化的单元格。教训是:只要一份数据有多个并发编辑者就绝不能整体提交——这和直播中控台的协同覆盖是完全同一个问题,而配置类界面的并发编辑比想象中常见。
踩过的坑四:配置立即生效,一次改错在几分钟内影响了全城订单。运营调整配送费时多输了一位,立即生效,全城订单的配送费瞬间异常,几分钟后才被发现并回滚。修法是影响面量化确认加灰度比例加定时生效。教训是:影响面是全局的配置变更,不应该有「立即全量生效」这个默认行为——默认应该是灰度或定时,立即全量要作为一个显式选择。
没做的部分:没做配置变更的效果归因(改了配送费之后单量的变化有多少是这次改动带来的)。运营很想要,但需要因果推断,不是前端能解决的。也没做配置的版本对比视图(两个版本的矩阵差异高亮),只有变更记录列表,这是个明确的体验缺口。
规则空洞:报空洞检测发现的组合数量,尤其是首次上线检测时发现的历史空洞数。这个数字最有说服力——「首次运行检测发现 N 个历史存在的空洞组合」,说明这类问题一直存在只是没人知道。同时报新城市开城时检测拦下的漏配数。
配置错误的发现阶段:报发现路径的变化而不只是数量——改造前是「运营看数据异常,排查半天定位到是没配」,改造后是「发布前检测列出清单」。路径的变化比数字更能说明问题。
生效预览的使用:报预览功能的使用次数与「预览后修改了配置」的比例。后者说明预览真的在纠正认知——如果运营预览之后经常发现和自己预期不同,正好证明「层层覆盖后靠人推是不可靠的」。
配置错位:确定性验证——增删城市、调整城市列表顺序,检查各单元格配置未串位。这是踩坑后固化的用例。
并发编辑:确定性验证——两个会话分别修改矩阵的不同单元格并提交,检查双方修改都保留。改造前必然互相覆盖。
影响面确认的作用:报确认弹窗出现后被取消的次数,尤其是因为看到「影响多少城市」而取消的。这和商品批量操作的度量方式一致。
不要报「配置准确率 100%」。配置的内容由运营决定,他配的值对不对不是系统能保证的。正确表述是「空洞检测发现了多少历史空洞、配置错误的发现从上线后变为发布前、生效预览的使用与纠正率、错位与并发覆盖由确定性用例保证」——把系统的责任和运营的责任分清。
值班大屏有一个和所有普通页面都不同的前提:它要连续开几天不刷新。挂在调度室的墙上,值班的人换班但页面不换。
这个前提本身就是一整类问题的来源。普通页面里很多问题被「用户会刷新」掩盖掉了——内存泄漏无所谓(用户几分钟就换页面了)、长连接断了无所谓(下次刷新就重连了)、数据陈旧无所谓(刷新一下就好了)。大屏把这些掩盖全部撕掉。
我们踩到的问题按暴露顺序是这样的。
第一是内存持续增长。页面运行几小时后开始明显掉帧,一天之后浏览器基本没法用了。查下来是三类泄漏叠加:定时器没清理(组件卸载了但 setInterval 还在跑)、事件监听没解绑(window 上的 resize、visibilitychange)、时序数据无上限累积(趋势图的数据点只往数组里 push,一天下来几十万个点)。
前两类的解法是封装成「注册即清理」的组合函数——不是靠人记得在 onUnmounted 里清理,而是让清理在封装内部自动发生,业务代码不可能忘。第三类的解法是给时序数据设窗口上限,超出的主动丢弃。
第二是长连接断了没人知道。某次网络抖动后长连接断开,页面上的数字就停在了断开那一刻,看起来完全正常——值班的人以为是真的没有新订单。这个问题特别危险,因为大屏的作用就是让人「看一眼就知道现在什么情况」,而它在撒谎。
解法有三层:长连接指数退避重连(不能断了就不管);重连后做一次全量对齐(不能只依赖后续增量,断开期间的变化要补回来);最关键的是界面上显式展示每个数据块的最后更新时间,超过陈旧阈值就把它置为警示态——让「数据是旧的」这件事本身变成可见信息。
第三是跨天问题。大屏上有很多「今日单量」「今日准时率」这类指标。页面开了三天没刷新,「今日」这个概念在零点应该切换,但第一版是页面加载时算好日期就不动了——结果第二天的大屏还在显示前一天的日期范围,数字停着不涨。
解法是显式处理跨天边界:到零点主动切换统计周期并重新拉数据。而且时间一律以服务端为准——大屏机器的时钟可能漂移,用本地时间判断跨天会在错误的时刻切换。
第四是渲染降级后不会自己恢复。我们做了「帧率过低时自动降级」(减少动画、提高地图聚合层级),但降级之后没有恢复逻辑,一次瞬时卡顿之后大屏就永久停在降级状态,值班的人看到的一直是粗粒度的画面。所以降级必须可逆,指标恢复后要自动升回去。
onUnmounted 里清理——让清理在封装内部自动发生,业务代码不可能忘。「让数据陈旧变成可见信息」是这个模块最重要的一条,它的思路是「宁可显示难看的真相,也不要显示好看的假象」。不做陈旧提示的界面看起来更干净——数字就在那里,没有多余的时间戳和警示色。但它在数据停止更新时会持续给出一个错误的印象,而大屏的唯一价值就是「看一眼知道现状」。取舍是界面上多了一些时间戳和状态色,换来的是界面不会撒谎。这个判断和「乐观更新失败必须回滚告知」「降级期间要明确提示连接不稳定」是完全同一条线:在监控与安全类界面上,假的正常比明确的异常危险得多。
清理逻辑靠封装而不是靠规范。另一种做法是定一个规范「所有 setInterval 必须在 onUnmounted 里清理」并靠代码评审保证。但规范会被忘,尤其是在一个团队里多人开发多个大屏组件时。封装成组合函数(注册时自动登记清理)之后,业务代码写不出泄漏的形态。取舍是团队要接受「不直接用原生 API」这个约束,缓解手段是封装要足够薄、语义要和原生一致,不要变成一个需要学习的新东西。判断依据是:能靠机制保证的事情不要靠规范保证。
自动重载作为最后兜底,但必须限次。连续错误后自动重载能让大屏从很多未知故障里自恢复,这在无人值守的场景下很有价值。但如果故障是持续的(比如接口一直 500),不限次数的重载会变成循环刷新,既解决不了问题又让人无法在页面上看到任何错误信息。所以限制重载次数,超过之后停下来并显示明确的错误信息——让故障可见比让页面一直在尝试自救更有用。
踩过的坑:三类内存泄漏叠加,页面运行一天后浏览器基本没法用。定时器没清理、window 事件监听没解绑、趋势图数据点无上限累积。这三个单独看都不严重,叠加在「连续运行几天」这个前提下就是致命的。修法是注册即清理的封装加数据窗口上限。教训是:泄漏问题的严重性取决于页面的生命周期——同样的代码在一个几分钟就关掉的页面里毫无问题,在常驻页面里是致命的。所以「这个页面会活多久」应该是设计时就明确的输入。
踩过的坑二:长连接断开后大屏显示旧数据,值班以为没有新订单。网络抖动导致连接断开,页面数字停在断开那一刻、看起来完全正常,值班的人一直没意识到,直到有商家打电话问订单为什么没派。这是这个模块里后果最严重的一个坑。修法是退避重连加重连后全量对齐加陈旧提示。教训是:监控界面的「无变化」有两种含义——「真的没变」和「没收到更新」,界面必须能区分这两者,否则它在最需要它的时候提供的是错误信息。
踩过的坑三:跨天不切换,第二天大屏还显示前一天的数据。页面加载时算好「今日」的日期范围就不动了,值班换班后看到的「今日单量」是昨天的,而且数字停着不涨。修法是到零点主动切换统计周期并重新拉数据,且时间以服务端为准。教训是:任何「相对当前时间」的概念在长生命周期页面里都必须动态重算——「今日」「最近一小时」「本周」这些在页面加载时算一次是不够的。
踩过的坑四:降级之后不会恢复,大屏永久停在粗粒度状态。一次瞬时卡顿触发降级(减少动画、提高聚合层级),之后帧率恢复了但降级状态没撤,值班看到的一直是粗粒度画面并且以为大屏就是这样。修法是降级可逆,指标恢复后自动升回。教训是:任何自动降级都必须配自动恢复,只降不升的降级机制会让系统单向劣化到最低配置。
没做的部分:没做大屏的多屏协同(几块屏拼成一个大画面,需要跨屏同步视口与时间轴)。需求提过,但涉及多端同步,复杂度不低。也没做无人值守时的异常自动通知(大屏检测到自身异常时主动通知值班手机),只做了上报到监控系统。
长时运行内存:最该报的数字,而且要报连续运行的时长与内存曲线形态——「连续运行数日内存曲线平稳」,改造前是「运行数小时后明显掉帧、一天后基本不可用」。用曲线形态与可运行时长描述,比给一个内存峰值有说服力。
数据陈旧的暴露:这个用「大屏数字不动但没人知道」这类事件的次数来度量,改造前后对比。最有说服力的是那次具体案例:长连接断开后值班一直没意识到,直到商家打电话问订单为什么没派。
重连与对齐:确定性验证——人为断网后恢复,检查连接自动恢复、断开期间的数据被补齐、陈旧提示在断开期间正确显示并在恢复后消失。这三项要分别验。
跨天切换:确定性验证——把页面挂过零点,检查「今日」指标自动切换到新的一天并重新拉数。这个用例只能靠改系统时间或等真实零点来验,要说明怎么验的。
降级与恢复:确定性验证——人为制造卡顿触发降级,恢复后检查自动升回原有渲染精度。这是踩坑后固化的用例。
自动重载的限次:确定性验证——模拟持续故障,检查重载次数达上限后停止并显示明确错误信息,而不是循环刷新。
不要报「大屏可用性 99.99%」或「零故障运行」。大屏的可用性依赖网络、接口、机器本身,而且它的故障模式很多是「显示了但不对」而不是「打不开」,用可用性数字表达不了。正确表述是「连续运行时长与内存曲线、陈旧数据由显式提示暴露、重连与全量对齐由确定性用例保证、降级可自动恢复、自动重载限次后停止并暴露错误」——这些都是可核查的具体机制。
setInterval 还在跑)、window 事件监听没解绑、时序数据无上限累积(趋势图数据点只 push,一天几十万个点)。这三个单独看都不严重,叠加在「连续运行几天」这个前提下就是致命的——页面运行数小时后明显掉帧、一天后浏览器基本没法用。修法是把定时器与监听封装成「注册即清理」的组合函数(不靠人记得清理,让业务代码写不出泄漏的形态),加上时序数据设窗口上限主动丢弃。教训是:泄漏问题的严重性取决于页面的生命周期——同样的代码在几分钟就关掉的页面里毫无问题,所以「这个页面会活多久」应该是设计时就明确的输入。
没有匹配的内容,换个关键词试试。
项目拆解 · 城市运营与运力看板(Vue)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据