老网站改造实战:性能体检、问题分级与灰度监控
上一篇把这个网站的问题从头到尾摊开讲了一遍,评论区不少人说自己也遇到过同款。这篇不抱怨了,直接讲动手。一个能打开、能点、但就是让人用得浑身难受的网站,改造它和从零写一个完全是两码事——前者更像给一栋住着人的老楼换水管,你不能把楼拆了,还得保证住户每天有水用。下面这套流程是我在几个类似项目里反复用过的顺序:先体检、再分级、然后按资源链路逐层动刀,最后用灰度和监控兜底。它适合手上正接手一个历史包袱重、又不敢大改的网站的同学,也适合自己项目越写越乱、想系统梳理一遍的人。文中所有具体数字和配置都来自通用实践,你按自己项目的实际情况取改,别照抄。
1. 先别急着删库重来:给这个网站做一次完整体检
我见过太多团队在第一次开会时就抛出"重写"两个字。重写是最贵的方案,没有之一:需求要重新对齐、边界要重新踩、线上跑了三年的隐性逻辑要重新发现,而且新代码一样会腐化。真正理性的做法是先花两三天做一次结构化体检,把"感觉很难用"翻译成可量化的指标,再决定动哪里。体检的价值不在于给网站打个分,而在于让后面每一次改动都有依据——你改完之后拿什么证明变好了?靠感觉是不行的。
1.1 体检清单:五个维度而不是一个分数
只盯性能跑分是新手最容易犯的错。一个 Lighthouse 拿 95 分的站,可能键盘用户根本没法完成下单;一个首屏一秒打开的站,可能控制台里每分钟报几十条错误。我通常按五个维度铺开,每个维度只取两三个最关键指标,避免陷入数据海洋。
| 维度 | 关键指标 | 采集方式 | 常见及格线参考 |
|---|---|---|---|
| 性能 | LCP、INP、CLS、TTFB | RUM 上报 + Lighthouse | LCP < 2.5s,INP < 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" type="image/avif"> <source srcset="/img/hero.webp" type="image/webp"> <img src="/img/hero.jpg" alt="首屏主图" width="1200" height="600" loading="eager"> </picture>注意width和height一定要写,这是防 CLS 最省事的办法——浏览器提前知道占位比例,图片加载完不会把下面的内容顶下去。字体是另一个隐形大户,一个完整中文字体动辄好几 MB。实操上做子集化,只保留页面实际用到的字符集,再配合font-display: swap,让文字先用系统字体显示,别让用户对着空白等字体下载。
3.3 缓存策略分层的具体配置
缓存是唯一能让"第二次访问"接近零成本的武器。策略要按资源类型分开定,一刀切是错的:
| 资源类型 | Cache-Control 建议 | 原因 |
|---|---|---|
| 带哈希的 JS/CSS | public, max-age=31536000, immutable | 内容变则文件名变,可永久缓存 |
| 图片、字体 | public, max-age=2592000 | 变更频率低,一个月足够 |
| HTML 入口 | no-cache | 必须每次校验,否则发版不生效 |
| 接口数据 | 按业务定,多数no-store | 避免脏数据,敏感信息不落缓存 |
location ~* \.(js|css)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } location ~* \.(png|jpg|webp|avif|woff2)$ { add_header Cache-Control "public, max-age=2592000"; } 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% 就告警,结果每天半夜被几个爬虫触发的报错叫醒,一周之后所有人都不看告警了,这比不设告警更危险。我的经验是分批设:致命错误(白屏、核心接口全挂)设低阈值立即通知,普通错误按小时聚合看趋势。监控的意义是让人相信它,而不是让人关掉它。
最后分享一个我在改造项目里坚持了很多年的小习惯:每动完一个模块,就在真机上用弱网把它完整走一遍,边角情况都点一点。这个过程很枯燥,但它抓到的问题比任何自动化工具都多——按钮点下去没反应、返回后滚动位置丢了、输入框在键盘弹起时被挡住,这些都不会出现在任何报告里,但用户每天都在经历。改造一个让人恼火的网站,本质上不是把它改成满分作品,而是把这些日常的小摩擦一个一个磨平。