base64 数据,导致 C 端详情页样式错乱且体积过大;实现基于白名单的粘贴清洗(只保留约定的标签与属性,其余一律剥离),图片统一拦截并转存到自有存储后替换地址,base64 图片在粘贴时即转为上传;XSS 上明确前端清洗只为体验、服务端过滤才是防护,两侧共用同一份白名单配置;保存前做内容体积与图片数量校验并给出定位提示,避免详情页在 C 端弱网下拖慢首屏。上线后 C 端详情页的样式错乱反馈不再出现,单个详情内容体积从数百 KB 量级降到几十 KB 量级。
需求听起来很简单:商品详情要能富文本编辑。选个成熟的编辑器接进来,一天就能跑通。但真正的问题在上线之后才出现,而且每一个都不是编辑器本身的问题,是「运营实际怎么用它」的问题。
一是从 Word 粘贴进来的内容把 C 端搞乱了。运营的商品文案都在 Word 里写好,直接复制粘贴。粘进来带着一大堆内联样式——固定的字号、固定的宽度、微软雅黑字体、甚至 mso- 开头的 Office 私有属性。在后台的编辑器里看着还行,到了 C 端手机上就是灾难:文字溢出屏幕、字号一会儿大一会儿小、图片撑破容器。
二是图片是外链或者 base64。从别的网站复制的内容,图片地址是别人的域名——对方哪天删了图,我们的商品详情就是一片裂图,而且还有防盗链的问题。从 Word 粘贴的图片更糟,是 base64 内联在 HTML 里,一张图就能让内容体积涨几百 KB。
三是安全扫描报了存储型 XSS。富文本天然允许 HTML,而我一开始以为「编辑器会帮我处理」。安全同学用一个带 onerror 的图片标签直接弹了框,而且因为内容是存库的、会展示给所有访客,这是存储型的,比反射型严重。
四是详情内容太大拖慢 C 端首屏。有个商品的详情 HTML 有一百多 KB,里面有二十几张没压缩的大图。C 端在弱网下打开这个详情页要好几秒,而后台编辑时完全感觉不到问题——因为办公网很快。
所以四个改动:粘贴时按白名单清洗(不是等保存时才处理,粘贴那一刻就要干净)、图片统一拦截转存(外链和 base64 都转成自有地址)、服务端做二次过滤(前端清洗只是体验,防护必须在服务端)、保存前校验体积和图片数量并给出可定位的提示。
这个模块最想说的一句话是:接入编辑器是一天的活,治理它产出的内容是这个需求真正的工作量。而这些治理需求,全都来自「运营实际会怎么用」——他会粘贴、会从别处复制、会传大图。把用户的真实行为想清楚,需求才完整。
script、去掉 onerror)永远漏——攻击方式会变,编码方式会变。白名单的逻辑是「只有明确允许的标签和属性才能留下,其余全剥」,这样新出现的攻击方式默认就被挡了。这一条是这个模块最重要的原则。mso- 前缀的样式、<o:p> 这类命名空间标签,通用清洗库不一定覆盖。而 Word 恰好是运营最常用的来源,所以这块要专门测。base64 要在粘贴时转成 Blob 走上传(不能留在 HTML 里);粘贴的外链要服务端代抓取转存。外链不转存的三个风险:对方删图就裂、可能有防盗链、以及对方可能把图换成别的内容——最后一个最隐蔽也最危险。max-width: 100%、表格外层加横向滚动、字号用相对单位继承。这一条是后台清洗之外的兜底——即使有脏样式漏过来,容器层能压住大部分溢出问题。用成熟编辑器而不是自己造,但清洗逻辑自己写。编辑器自己造是明显的过度设计(光是光标和选区处理就是个深坑)。但清洗规则不能完全依赖编辑器自带的——各家编辑器的默认清洗策略不同,而且它们的目标是「尽量保留用户格式」,我们的目标是「只保留我们能渲染好的格式」,目标不一致。所以清洗这一层我们自己实现并自己维护白名单。
白名单定得偏严,接受运营的抱怨。比如不允许自定义字号和颜色——运营一开始很不适应,觉得「我想强调一下都不行」。但允许自定义字号的代价是 C 端字号完全失控,而且不同商品的详情看起来风格不统一。折中方案是提供几个预设样式(小标题、重点、提示块),运营选样式而不是选字号。这样既满足了表达需求,又保证了 C 端可控。这个取舍的原则是:给受控的选择,而不是给自由的属性。
外链图片选择服务端代抓取,而不是直接拒绝粘贴。直接拒绝更简单也更安全,但运营从供应商网站复制商品介绍是真实且高频的工作流,堵死它他会去手动一张张下载再上传,效率极低。代抓取的代价是要处理抓取失败、超时、防盗链、以及潜在的 SSRF 风险(服务端要限制可抓取的域名和协议、禁止内网地址)。SSRF 这一点我最初没想到,是安全评审时被指出的,这也是为什么这个功能必须由服务端做而不是前端直接 fetch。
踩过的坑一:以为编辑器会处理 XSS,安全扫描直接弹框。用一个带 onerror 的图片标签就绕过了。更值得说的是我当时的第一反应是错的——我在前端加了一层过滤就以为修好了。安全同学直接用接口调试工具绕过前端提交,还是能存进去。这件事让我理解了「前端校验和服务端校验的职责完全不同」:前端是为了体验(让用户立刻知道不行),服务端是为了防护(因为它是唯一无法绕过的关口)。这个认知后来在做任何表单校验时都在用。
踩过的坑二:清洗把运营排好的表格弄没了。白名单初版忘了加 table 相关标签,运营辛苦排的规格参数表粘进来全变成了纯文本。他找过来的时候我才意识到白名单的风险是「漏掉合法的东西」,而这类问题不会有报错,只会有用户抱怨。修法之外的教训是:白名单要和实际使用场景对齐,不能凭想象列——我后来的做法是先收集一批运营真实粘贴的内容,统计里面出现的标签,再定白名单。
踩过的坑三:base64 图片在粘贴时没转,保存时才发现内容超限。运营粘贴了一段带十几张图的 Word 内容,编辑器里显示正常,点保存时被体积校验拦下,而他压根不知道问题在哪、也无法删除那些图(在他看来图片就是内容的一部分)。修法是把转存提到粘贴那一刻,粘贴时就显示上传进度。这一条印证了「拦在入口」这个原则:越早处理,用户的心智负担越小。
没做的部分:没做协同编辑和版本历史。商品详情通常一个人编辑,冲突概率低,做协同是过度设计。但做了一个轻量的替代:保存时如果检测到内容被别人改过(比对版本号),提示并给出对比,而不是直接覆盖。这是用很小的成本覆盖了主要风险。
内容体积的对比:取改造前一批真实的商品详情,统计 HTML 字节数的分布(中位数和最大值),改造后同样统计。我的口径是「从数百 KB 量级降到几十 KB 量级」而不是精确数字,因为它高度依赖商品类型(服装详情图多、标品图少)。说量级并说明样本来源,比给一个精确平均值可信——精确值一被追问「你怎么统计的、样本多少个」就容易说不清。
体积下降的归因要拆开说。下降来自三处:base64 转成外链(这一项贡献最大)、内联样式被剥离、图片压缩。能拆开说明你知道钱花在哪,而不是笼统地说「优化了体积」。
清洗效果的验证用真实样本回归。收集一批运营真实粘贴过的内容(Word、网页、其他后台),建成固定的测试样本集,每次改白名单都跑一遍,断言输出里不含被禁的标签和属性、且合法标签没有被误删。后半句是关键——我踩过表格被清掉的坑,所以正反两面都要断言。
XSS 的验证不用统计,用固定的攻击样本集。收集常见的注入形式(事件属性、javascript: 协议、编码绕过、SVG 内嵌脚本),断言清洗后都无害。而且要在服务端接口层面测,不能只测前端——我踩过的坑就是只测了前端。这类用例进 CI 不允许失败。
C 端样式错乱「不再出现」怎么说:这是个可核对的事实而不是指标——用反馈工单数从有到无来陈述,并说明改造前是每周几例。不要编一个「样式正确率 99%」,那个数字没有测量方法。
不要报什么:不要报「彻底防住了 XSS」——白名单能挡住已知形式,但安全是持续的事。该说的是「用白名单而不是黑名单、且服务端是防护主体」这个设计,机制比结论可信。也不要报「压缩率 80%」,压缩率取决于原图质量。
mso- 私有属性,后台看着没事,C 端手机上文字溢出、字号错乱。二是图片——粘贴的图可能是 base64(一张就几百 KB)或者外链(对方删图我们就裂,而且对方可以把图换成别的内容)。三是安全——富文本天然允许 HTML,这是存储型 XSS 的天然入口,而且内容会展示给所有访客。这三个问题里没有一个是编辑器能替我解决的,因为编辑器的目标是「尽量保留用户的格式」,我的目标是「只保留我能渲染好的格式」,目标不一致。想清楚这一点之后,工作量就从「选个编辑器」变成了「定一套内容规范并在两端强制它」。
javascript: 协议放在 href 里、SVG 里内嵌脚本、style 里用表达式、大小写混写、HTML 实体编码绕过、以及各种畸形标签让解析器产生意外结果。你每次都是在补上一次被发现的漏洞,永远落后一步。白名单反过来——只有明确列出的标签和属性能留下,新出现的攻击方式默认就被挡了,因为它不在白名单里。代价是白名单可能漏掉合法的东西(我踩过表格被清掉的坑),但这类错误是用户能立刻发现并反馈的,而黑名单漏掉的攻击是悄无声息的。两种错误的可发现性完全不对称,这是选白名单的根本理由。
第一版的筛选态放在组件的 data 里,最直觉的写法。功能上没问题,但运营用了两周提了四个问题,每一个都来自我没预料到的真实使用方式。
一是链接发出去是空白的。运营筛出「近 7 天、待发货、金额大于 500」的订单,想让同事一起看,直接复制地址栏发过去。同事打开看到的是默认的全量列表。他们的解决办法是在群里打字描述「你筛一下…」——这件事每天发生,而它本来只需要一个链接。
二是刷新就丢。运营筛好条件、翻到第 3 页、点开一个订单处理完,回到列表,所有条件都没了要重新选一遍。他一天要处理几十单,这个成本是累积的。
三是筛完之后看似没数据。他在第 5 页,改了筛选条件,结果新条件只有 2 页数据,而页码还停在 5,列表显示空。他以为是没有符合条件的订单,实际是有的。这个 bug 被报成「筛选功能坏了」。
四是重置按钮的行为和他的预期不符。他理解的「重置」是回到刚进页面的样子(有个默认的时间范围),而我实现的是清空所有条件。清空之后查询范围变成全量,接口直接慢了下来。
四个问题的根源是同一个:我把筛选态当成了「组件的内部状态」,而它实际上是「用户当前正在看的数据视图」——这是一个应该被地址栏表达的东西。
所以核心改动是把筛选、排序、分页统一序列化到路由 query,地址栏成为唯一的状态来源。这一个改动同时解决了前两个问题:链接自带条件所以可分享、刷新时从 query 恢复所以不丢。第三个问题靠明确三者的联动语义解决,第四个靠把「重置」定义清楚。
这个模块最想说的一句话是:需求不是「实现筛选功能」,是「让运营能把他看到的东西传给别人」。前者一天就做完了,后者才是真实的痛点,而它只有在观察真实使用行为之后才会浮现。
"2",参与加法变成 "21"。做法是给每个筛选字段声明类型(数字、布尔、数组、日期区间),序列化和反序列化都走这份声明。状态放 query 而不是放全局 store,这是这个模块的核心决定。放 store 也能解决「刷新丢失」(配持久化插件),而且不用处理序列化。但它解决不了「链接可分享」——而这恰恰是运营最痛的那个需求。放 query 的代价是要写序列化和反序列化、要处理类型转换、URL 可能很长。判据是「这个状态需不需要被别人看到」:需要就必须进 URL,因为只有 URL 是可传递的。反过来,滚动位置这类不需要传递的状态就不该进 query。
默认值不入参,代价是「链接里看不出完整条件」。有人会觉得这是缺点——打开一个链接,不知道时间范围是什么。但实际上默认值是产品定义的、所有人一致的,写出来是冗余信息;而且写进去之后默认值就被冻住了,改产品默认值会让所有旧链接的语义偷偷变化。我认为「默认值可演进」这个收益大于「链接自解释」的损失,而且界面上筛选表单会显示当前的实际值,用户看界面就知道。
参数压缩只对超长部分做,不对整个 query 做。整个压缩成一个 ?q=xxxx 最简单,但链接完全不可读了——运营和开发都没法一眼看出这个链接筛的是什么,排查问题时很难受。折中是主要条件(时间、状态)保持明文,多选类目这种可能很长的压缩成短参数。这个取舍偏向了可维护性而不是极简。
踩过的坑一:页码没重置导致的空列表被报成 bug,而我第一反应是去查接口。运营说「筛选功能坏了,筛出来是空的」,我拿他的条件调接口,有数据。反复沟通了很久才发现他当时在第 5 页。这件事的教训不只是「要重置页码」,更是「用户描述的现象和真实原因往往差很远」——所以后来我们在空列表的提示里加上了当前的完整条件(含页码),让现象自己说话。这个改动让后续同类问题的沟通成本大幅下降。
踩过的坑二:用了 push 导致返回键失效。运营调了七八次筛选条件,想点返回回到上一个页面,结果点了十几次都还在这个列表里。他以为浏览器卡了。改成 replace 之后正常。这个坑说明「每次状态变化都记一条历史」听起来合理,实际上会破坏浏览器最基本的交互——历史记录应该对应「用户认为的页面切换」,而不是对应「状态变化」。
踩过的坑三:滚动位置恢复得太早,滚不到位。从详情返回,恢复了筛选条件、发请求、然后立刻 scrollTo——但此时列表还没渲染,页面高度不够,滚动被截断在最大高度。修法是等数据渲染完(nextTick 之后,必要时等图片占位撑起高度)再恢复。这类「时序问题」在前端很常见,判据是「我依赖的东西此刻真的存在了吗」。
没做的部分:没做筛选条件的保存与命名(「我的常用筛选」)。运营提过这个需求,确实有价值,但它需要后端存储和管理界面,超出了当时的排期。临时替代方案是告诉运营「把链接收藏到浏览器书签」——因为筛选态已经在 URL 里了,这个替代方案零成本,而且是前面那个设计带来的意外收益。这一点在面试里值得提,它说明一个好的设计会自然长出别的能力。
「刷新后筛选丢失」这类问题的验证用断言型用例,不是统计。构造场景:设置一批筛选条件、刷新页面,断言恢复后的请求参数与刷新前完全一致。同类用例还有:改筛选断言页码回到 1、改排序断言页码不变、从详情返回断言条件和滚动位置都在、从菜单进入断言回到默认态。这五条是这个模块的核心行为,全部可以写成自动化断言,报法是「行为用例全部通过」而不是编百分比。
竞态的验证要构造慢请求。用工具把第一个请求延迟几秒,然后立刻改条件发第二个请求,断言最终列表显示的是第二个请求的结果。不构造延迟是测不出竞态的(本地网络太快,返回顺序恰好正确)。这一点要说明,否则「我测过竞态」不可信。
URL 长度:报典型场景和最坏场景的字符数(比如常规筛选几十个字符、全选类目时压缩后多少)。要说清为什么关心这个——部分浏览器和服务端网关对 URL 长度有限制,超了会直接失败。报最坏场景比报平均值有意义,因为出问题的是最坏情况。
「可传链接对齐视图」的效果不好量化,用可核对的事实陈述。比如「运营群里从原来打字描述筛选条件,变成直接发链接」——这是行为的改变,可以观察到。不要编一个「协作效率提升 N%」。
不要报什么:不要报「性能优化了多少」——这个模块本身不是性能优化,它的价值在正确性和可用性。硬凑性能数字反而会暴露你不清楚自己做的是什么。该报的是行为用例的覆盖和几个具体问题的消失。
看板的第一版是最直觉的写法:页面上放六个图表组件,每个组件自己 watch 筛选条件、自己请求、自己渲染。跑通很快,问题也来得很快。
一是看板会处于不一致状态。运营改一次时间范围,六个图表并发发六个请求。其中一个失败了或者慢了,结果是有的图显示的是新时间范围的数据、有的还是旧的,而界面上完全看不出区别。运营对着这个看板做判断,得出的结论是错的。这是最严重的问题——它不是「不好用」,是「会给出错误的信息」。
二是空数据被当成了业务归零。后端返回的日期序列里,没有订单的那天压根不返回。我为了让折线图连续,在前端把缺失的日期补成了 0。运营看到某天的曲线掉到 0,以为出了事故,紧急拉群。实际情况是那天的数据还没同步完。「没有数据」和「数据是零」是两件完全不同的事,我把它们混成了一件。
三是空态一片空白让人以为坏了。筛选条件比较窄的时候图表区域什么都没有,运营的反应是刷新页面、然后来问「看板是不是挂了」。一个没有空态设计的图表,和一个加载失败的图表在视觉上是一样的。
四是包体积。图表库整包引入,首屏 JS 明显变大,而这个看板只有数据运营会用,其他人打开后台也要下载这些代码。
所以四个改动:取数统一编排(一次筛选变化对应一次完整的数据更新,要么都是新的要么都不动)、四种数据状态显式化并区分空值与零值、按需引入加路由懒加载、以及顺带加上运营真正需要的导出图片。
这个模块最想说的一句话是:图表的难点不在画图,在于「数据不完整的时候显示什么」。画图是图表库的事,一行配置;而加载中、无数据、部分缺失、请求失败这四种状态各自该显示什么、以及怎么不让用户误读,这才是要自己想清楚的部分。而我踩的最大的坑(补零)恰好就是在这上面判断错了。
null 而不是省略或者 0。这一条是前端要主动提的接口需求。统一编排的代价是「最慢的那个图决定整体感知速度」。各自请求的话,快的图先出来,用户觉得快;统一编排要等一批数据都回来(或超时)才渲染,感知上会慢一些。但我认为一致性优先于感知速度——因为看板的用途是辅助决策,显示矛盾的信息比慢一秒的危害大得多。缓解办法是给每个图独立的加载态:先都显示骨架图,谁的数据回来了谁先渲染,但渲染的一定是当前批次的数据;这样既保证了一致性,又不用等最慢的那个。
缺失值断线 vs 补零,选断线,接受图不好看。补零的折线是连续的、好看的;断线的图有缺口,运营一开始还问「这里怎么断了」。但断线是诚实的,补零是撒谎。而且断线引起的疑问会带来一次正确的对话(「那天数据还没同步」),补零引起的误判会带来一次错误的决策。这个取舍的原则是:可视化的第一要求是不误导,其次才是好看。
采样放前端还是后端,按量级分。几千个点前端采样就行(实现简单、后端不用改);上万个点应该让后端按粒度聚合返回。判据是「传输这些数据本身是否已经成为问题」——如果传输就已经很慢了,前端采样是无意义的,因为代价已经付了。
踩过的坑一:补零那次差点引发误判。运营看到曲线掉到 0,以为支付链路挂了,紧急拉群。排查了二十分钟才发现是数据同步延迟,而前端把缺失补成了 0。这件事让我理解了「前端的显示选择会直接影响业务决策」——我当时的想法只是「让图好看点」,完全没意识到它在传递一个业务事实。这是我在这个项目里最深的一个教训。
踩过的坑二:看板不一致状态被发现得很晚。因为它不报错、不白屏,只是几个图的时间范围不一致,而界面上没有任何提示。是运营对着看板算数发现「订单数和金额对不上」才报出来。教训是:这类「静默的错误」必须靠设计避免,而不是靠测试发现——测试的时候网络很快,六个请求都秒回,压根复现不出来。所以我后来的做法是用工具把某个接口延迟,专门测这种不一致场景。
踩过的坑三:图表实例没销毁,看板开半天浏览器卡死。运营习惯把看板挂在一个标签页里一整天。切换筛选条件时我每次都创建新图表,旧的没销毁,实例和监听器一直累积。修法是统一在卸载和重建前销毁。这类问题短时测试完全测不出来,只能靠长时间挂机加看内存曲线。
没做的部分:没做图表之间的联动(点柱状图的某一天,其他图跟着筛到那天)。交互上很酷,但需要定义清楚联动的传播规则(点了之后筛选条件要不要变、URL 要不要变、怎么撤销),复杂度不低而运营也没提这个需求。没有明确需求的交互增强,我倾向不做——它会增加维护成本和出 bug 的面积。
不一致状态「不再出现」怎么验证:用工具把其中一个接口延迟数秒,改筛选条件,断言在延迟接口返回之前,那个图显示的是加载态而不是旧数据;以及断言延迟接口返回后,它渲染的是当前批次的数据。不构造延迟是测不出来的——这一点必须说明,否则「我测过」不可信。
空值处理的验证用构造数据:造一个含 null、含 0、含缺失日期的数据集,断言渲染结果里 0 画在零位置、null 是断线、缺失日期不被补成 0。这是纯前端可测的,写成快照或断言都行。
包体积:报看板路由的 chunk 大小以及首屏 JS 的变化(按需引入 + 懒加载之后,不进看板的人少下载多少)。要说清是压缩后还是压缩前的体积,这两个数差很多,不说清容易被追问。
渲染性能:报数据点数量和渲染耗时的对应关系(几百点、几千点、采样后),而不是报一个固定值。因为它完全取决于数据量,给对应关系才能说明采样阈值是怎么定的。
内存泄漏的验证只能靠长时间运行:反复切换筛选条件几十次、或者挂机若干小时,看堆内存曲线是否持续增长。这类问题短时测试测不出来,必须说明测法,否则「没有内存泄漏」是没有依据的。
不要报什么:不要报「看板加载速度提升 N%」——统一编排实际上可能让整体感知略慢,硬说提速是不诚实的,而且一被追问「你等所有请求都回来才渲染,怎么会更快」就穿了。该报的是「不一致状态消失」「空值不再被误读」「包体积下降」这三件事,它们都是可核对的。
没有匹配的内容,换个关键词试试。
项目拆解 · 后台常用交互模块(Vue · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据