
上一篇把这个网站的问题从头到尾摊开讲了一遍评论区不少人说自己也遇到过同款。这篇不抱怨了直接讲动手。一个能打开、能点、但就是让人用得浑身难受的网站改造它和从零写一个完全是两码事——前者更像给一栋住着人的老楼换水管你不能把楼拆了还得保证住户每天有水用。下面这套流程是我在几个类似项目里反复用过的顺序先体检、再分级、然后按资源链路逐层动刀最后用灰度和监控兜底。它适合手上正接手一个历史包袱重、又不敢大改的网站的同学也适合自己项目越写越乱、想系统梳理一遍的人。文中所有具体数字和配置都来自通用实践你按自己项目的实际情况取改别照抄。1. 先别急着删库重来给这个网站做一次完整体检我见过太多团队在第一次开会时就抛出重写两个字。重写是最贵的方案没有之一需求要重新对齐、边界要重新踩、线上跑了三年的隐性逻辑要重新发现而且新代码一样会腐化。真正理性的做法是先花两三天做一次结构化体检把感觉很难用翻译成可量化的指标再决定动哪里。体检的价值不在于给网站打个分而在于让后面每一次改动都有依据——你改完之后拿什么证明变好了靠感觉是不行的。1.1 体检清单五个维度而不是一个分数只盯性能跑分是新手最容易犯的错。一个 Lighthouse 拿 95 分的站可能键盘用户根本没法完成下单一个首屏一秒打开的站可能控制台里每分钟报几十条错误。我通常按五个维度铺开每个维度只取两三个最关键指标避免陷入数据海洋。维度关键指标采集方式常见及格线参考性能LCP、INP、CLS、TTFBRUM 上报 LighthouseLCP 2.5sINP 200ms可用性JS 错误率、接口 5xx 占比前端错误采集 网关日志错误率 0.5%可访问性对比度、键盘可达、语义标签自动化扫描 人工走查关键流程全键盘可完成内容与收录title/description、canonical、sitemap爬虫工具抽查核心页无重复标题代码健康首包体积、重复依赖、构建耗时构建产物分析首包 gzip 200KB这五项里前两项决定用户用不用得下去后三项决定网站能不能被找到、能不能长期维护。为什么要把可访问性单列出来因为它是最容易被忽略、又最影响真实体验的一项。很多点不到按钮看不清灰字的抱怨本质上都是可访问性问题而不是设计问题。1.2 用真实用户数据代替实验室分数Lighthouse 跑出来的是实验室数据一台干净机器、一个稳定网络、一份空缓存。真实用户手里是三四年前的手机、地铁里的弱网、装了十几个插件的浏览器。两者能差出三到五倍所以我更信 RUM真实用户监控。采集不用搞得很复杂核心指标的官方库直接引入就行import { onLCP, onINP, onCLS, onTTFB } from web-vitals; function report(metric) { const body JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, page: location.pathname }); // 优先用 sendBeacon避免页面卸载时丢数据 if (navigator.sendBeacon) { navigator.sendBeacon(/api/rum, body); } else { fetch(/api/rum, { body, method: POST, keepalive: true }); } } onLCP(report); onINP(report); onCLS(report); onTTFB(report);看数据的时候有个关键细节看 p75不要看平均值。平均值会被少数极快用户拉低掩盖掉那 25% 体验极差的人。p75 的含义是四分之三的用户比这个数更好它天然对准了长尾。另外一个细节是分页面、分设备看移动端首页的 LCP 和桌面端后台页的 LCP 混在一起统计等于什么都没说。2. 问题分级哪些必须动刀哪些可以先忍着体检报告出来通常是一长串问题几十上百条。这时候最危险的动作是按列表从上往下改——改到第十条的时候团队已经没耐心了而真正影响转化的问题可能排在第六十条。我的做法是给每条问题打两个分影响面和修复成本然后扔进四象限。2.1 按影响面×修复成本排优先级影响面看三个数受影响的用户比例、受影响的页面数量、是否卡在核心转化路径上。修复成本看人天和回归测试范围。落到象限里大概是这样象限特征处理策略典型例子高影响、低成本立刻做本轮迭代内解决图片未压缩、缺 loading 态高影响、高成本排期做先出方案拆成小步分批上线首屏架构调整、路由重构低影响、低成本顺手做搭车改掉文案错别字、缺少 alt低影响、高成本先记着冻结写进技术债清单全站视觉改版、老模块重写这个表真正的作用是帮你理直气壮地拒绝。总有人提这个动画不够丝滑那个圆角不够圆把它放进第四象限写清楚成本大家就都冷静了。我做过的一个项目里首页图片总计 12MB全部是设计直接导出的 PNG 原图压缩加转格式只花了半天首屏直接少了两秒多——这就是典型的高影响低成本先干它。2.2 一个容易被忽略的维度改动的连锁反应除了影响面和成本还有一个维度经常被漏掉这次改动会连带影响多少地方。一个按钮组件的样式调整可能牵动登录页、下单页、个人中心四十多个位置一个工具函数的返回值微调可能让三个调用方悄悄出错。动手前先跑一遍依赖分析把调用关系拉出来看。# 以常见构建工具为例查看某个模块被谁引用 npx madge src/components/Button --extensions ts,tsx --warning如果输出里出现十几个引用点那这次改动就不是改个样式而是一次小型重构需要独立分支、独立回归。我踩过的最疼的一次坑是把一个全局的间距变量从 12px 改成 16px看着无害结果所有依赖这个变量的卡片布局在窄屏上集体溢出测试环境没发现因为测试用的都是大屏。从那以后我给自己定了条规矩碰全局变量和公共组件必须在小屏真机上过一遍。3. 首屏从八秒压到一秒半资源链路的拆解顺序性能优化最忌讳东一榔头西一棒子。正确的顺序是沿着请求链路走先看请求数量再看单个资源大小最后看缓存能不能兜住重复访问。这个顺序不能颠倒因为一个页面发两百个请求的时候你把每张图压到极致也救不回来。3.1 先砍请求数再谈压缩率打开网络面板按瀑布图从上往下看重点找三类东西阻塞渲染的 CSS、同步加载的 JS、以及串在关键路径上的接口。首页动辄引入五六个 UI 库、三四个图表库、再加一堆图标字体这是很常见的死法。处理思路是代码分割加按需引入路由级懒加载重组件用动态导入图标直接从字体换成按需的 SVG。// 路由级懒加载首屏只加载当前页需要的代码 const routes [ { path: /, component: () import(./pages/Home) }, { path: /detail/:id, component: () import(./pages/Detail) }, { path: /report, component: () import(./pages/Report) } ];还有两个小动作收益很高但经常被忘给关键域名加preconnect给首屏关键资源加preload。前者省掉 DNS 和握手的往返后者让浏览器提前发现那些藏在 CSS 或 JS 里的资源。注意preload别滥用超过五六个就等于没加还会挤占带宽。3.2 图片与字体这两个隐形大户图片通常占首屏体积的一半以上。三件事按顺序做换现代格式、按显示尺寸出图、非首屏懒加载。现代格式在同等画质下能比传统格式小三四成配合picture做兼容回退一行都不用改业务逻辑。picture source srcset/img/hero.avif typeimage/avif source srcset/img/hero.webp typeimage/webp img src/img/hero.jpg alt首屏主图 width1200 height600 loadingeager /picture注意width和height一定要写这是防 CLS 最省事的办法——浏览器提前知道占位比例图片加载完不会把下面的内容顶下去。字体是另一个隐形大户一个完整中文字体动辄好几 MB。实操上做子集化只保留页面实际用到的字符集再配合font-display: swap让文字先用系统字体显示别让用户对着空白等字体下载。3.3 缓存策略分层的具体配置缓存是唯一能让第二次访问接近零成本的武器。策略要按资源类型分开定一刀切是错的资源类型Cache-Control 建议原因带哈希的 JS/CSSpublic, max-age31536000, immutable内容变则文件名变可永久缓存图片、字体public, max-age2592000变更频率低一个月足够HTML 入口no-cache必须每次校验否则发版不生效接口数据按业务定多数no-store避免脏数据敏感信息不落缓存location ~* \.(js|css)$ { add_header Cache-Control public, max-age31536000, immutable; } location ~* \.(png|jpg|webp|avif|woff2)$ { add_header Cache-Control public, max-age2592000; } location /index.html { add_header Cache-Control no-cache; }这里有个高频翻车点HTML 一旦被强缓存用户访问到的永远是旧版本新发的包根本加载不到现象就是我这边明明改了用户说没变。所以入口 HTML 必须是no-cache让它每次回源校验。另外一个细节是发版时旧哈希文件不要立刻删保留一两个版本避免用户停留在旧页面时动态加载 chunk 失败。4. 不报错但让人恼火的交互细节性能解决的是等多久交互解决的是用起来顺不顺。后者不产生任何报错监控也看不出来但对用户情绪的杀伤力更大。这类问题只能靠人肉走查我的习惯是把核心流程从头到尾用手机走三遍一遍正常网络一遍弱网一遍飞行模式。4.1 表单与提交反馈的常见反模式表单是最容易积累烂体验的地方。几个高频反模式提交按钮不防重复点击用户手快点两下就生成两条订单校验时机选在输入过程中用户刚敲第一个字符就红字警告错误提示只写在字段旁边不滚动定位长表单里用户根本不知道哪里错了提交成功后没有任何反馈用户以为没提交上又点一次。正确的做法其实很朴素提交瞬间禁用按钮并显示进度、校验放在失焦和提交时、错误汇总到顶部并可点击跳转、成功后给出明确的下一步动作。防重复这件事前端禁用只是第一层后端还得有幂等键因为网络重试和用户刷新都能绕过前端。我一般让前端在进入表单时生成一个唯一 ID 随请求带上后端短期内相同 ID 直接返回上次结果这比任何前端技巧都可靠。4.2 列表滚动、分页与骨架屏的取舍无限滚动和分页按钮吵了很多年我的判断标准很简单用户是否需要回到之前的位置。信息流类可以无限滚动但要解决滚了五百条想找刚才那条的问题后台管理、搜索结果这类必须分页因为用户需要定位和对比。选错了用户每次返回都要重新滚一遍火气自然上来。骨架屏也有个常见的用力过猛首屏数据其实两百毫秒就回来了骨架屏还硬要显示一个最短展示时间结果用户看到的是闪一下的灰块。骨架屏的价值是填补明显等待等待本来就不明显的时候它反而是噪音。我的经验阈值是接口超过 300ms 才显示骨架低于这个数直接渲染别做无谓的过渡动画。5. 重构途中最容易翻车的三个节点改造老站最怕的不是改不动是改完之后出问题还找不到原因。下面三个节点是我复盘多次后总结的高危区每一个都值得单独拉一次评审。5.1 样式覆盖的雪崩老项目里最常见的是全局样式和层层叠加的选择器。你想给某个新组件写样式发现怎么写都不生效于是一路加!important最后整个样式表变成一锅粥。根因通常不是某一条规则写错了而是优先级体系已经失控。处理办法是渐进式的先引入局部作用域方案新代码一律走作用域老代码不动但给每个老页面加一层容器类名做隔离全局变量逐步迁移成设计令牌迁移一个删一个。迁移过程中最容易忽略的是第三方组件库的默认样式。你改了自己的变量组件库还在用它的默认值两边打架。实操上先把组件库的主题变量对齐再动业务样式顺序反了会白干半天。5.2 接口字段的隐性依赖前后端并行开发时最危险的动作是这个字段没人用删了吧。你永远不知道有多少地方在偷偷读它老版本 App、缓存里的旧页面、某个报表脚本、甚至是运营手工导出的模板。删字段前至少做三件事查网关日志里这个字段的返回频率、全仓搜索字段名、和客户端团队确认发版计划。新增字段相对安全但也有坑新增必填字段会让老客户端提交失败新增枚举值会让没做默认分支的前端渲染出空白。我的习惯是新增字段一律给默认值后端做兼容读取前端做未知值兜底显示。接口契约这东西宽松一点总比严格一点活得久。5.3 第三方脚本的加载时序统计、客服、地图、分享这些第三方脚本是性能和安全的重灾区。它们通常由对方提供一段同步插入的代码直接塞在 head 里一加载就阻塞渲染。处理原则是非关键脚本一律异步、全部加超时、全部做失败降级。!-- 关键脚本用 defer 保证顺序且不阻塞解析 -- script src/js/app.js defer/script !-- 非关键第三方脚本异步加载加载失败不影响主流程 -- script (function () { var s document.createElement(script); s.src https://third-party.example.com/widget.js; s.async true; s.onerror function () { console.warn(widget failed); }; setTimeout(function () { s.remove(); }, 8000); document.body.appendChild(s); })(); /script那个setTimeout删节点看着粗糙但很有用某些第三方脚本在网络差的时候会挂起很久占着连接不放。超过八秒还没加载完基本可以认为这次访问它不会成功了直接放弃别拖累主流程。6. 上线不是终点灰度、监控与回滚的实操配置改造类的改动风险天然比新增功能高因为它动的是已经跑通的路径。所以发布策略必须比平时更保守。我见过太多本地测得好好的一上线全站白屏的案例根因往往不是代码本身而是发布过程缺少缓冲。6.1 灰度发布的粒度选择最简单的灰度是按比例放量但比例灰度有个问题同一个用户可能一会儿看到新版一会儿看到旧版体验割裂问题也难复现。更稳的做法是按用户标识哈希分桶保证同一个用户始终落在同一侧。// 按用户 ID 稳定分桶同一用户始终命中同一版本 function inExperiment(userId, percent) { let hash 0; for (let i 0; i userId.length; i) { hash (hash * 31 userId.charCodeAt(i)) 0; } return hash % 100 percent; }放量节奏我的习惯是 1%、5%、20%、50%、100%每档至少观察半小时重点看错误率、核心接口耗时、转化漏斗有没有异常波动。任何一档指标抖了立刻停在那档排查别急着往上推。另外一定要准备一个开关能在一分钟内把新逻辑关掉而不是回滚整个包——回滚包要重新构建、重新发布五分钟起步开关是秒级的。6.2 前后端监控指标的取舍监控不在多在于能不能在用户投诉之前发现问题。前端至少要抓三类全局 JS 错误、未处理的 Promise 拒绝、资源加载失败。白屏是最需要单独处理的因为白屏时页面看起来正常错误可能根本没抛出来。window.addEventListener(error, function (e) { // 资源加载失败不会冒泡到 window需要捕获阶段 if (e.target (e.target.tagName SCRIPT || e.target.tagName LINK)) { report({ type: resource, url: e.target.src || e.target.href }); } }, true); window.addEventListener(unhandledrejection, function (e) { report({ type: promise, reason: String(e.reason) }); }); // 简易白屏检测根节点长时间没有内容 setTimeout(function () { var root document.getElementById(app); if (!root || root.children.length 0) { report({ type: blank-screen, path: location.pathname }); } }, 5000);告警阈值别设太敏感。错误率设成 0.1% 就告警结果每天半夜被几个爬虫触发的报错叫醒一周之后所有人都不看告警了这比不设告警更危险。我的经验是分批设致命错误白屏、核心接口全挂设低阈值立即通知普通错误按小时聚合看趋势。监控的意义是让人相信它而不是让人关掉它。最后分享一个我在改造项目里坚持了很多年的小习惯每动完一个模块就在真机上用弱网把它完整走一遍边角情况都点一点。这个过程很枯燥但它抓到的问题比任何自动化工具都多——按钮点下去没反应、返回后滚动位置丢了、输入框在键盘弹起时被挡住这些都不会出现在任何报告里但用户每天都在经历。改造一个让人恼火的网站本质上不是把它改成满分作品而是把这些日常的小摩擦一个一个磨平。