告别理论:基于Core Web Vitals 2024标准的全链路诊断体系

前年我们团队接手了一个金融数据看板项目,用户反馈最多的就是"点个筛选按钮要等半天"。我第一反应是去查FID(首次输入延迟),结果翻了半天文档才发现,Google在2024年3月已经把Core Web Vitals标准更新了,FID被INP(Interaction to Next Paint)替代了。这个变化挺关键的,因为FID只测第一次交互,INP会追踪整个页面生命周期里的所有交互,更能反映真实用户的感受。

现在的诊断体系我基本是围绕这三个指标搭的:LCP(最大内容绘制)、INP、CLS(累积布局偏移)。我们那个看板之前LCP是3.2秒,主要问题出在首屏要加载一个1.2MB的echarts图表包,而且还是同步加载的。我后来用PerformanceObserver写了个简单的监控片段,直接嵌到项目里:

// 监控Core Web Vitals 2024指标 const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { switch (entry.entryType) { case 'largest-contentful-paint': console.log('LCP:', entry.startTime); // 我们项目里LCP超过2.5s就会报警 if (entry.startTime > 2500) { reportPerformanceIssue('LCP', entry.startTime); } break; case 'event': // INP的entryType是event if (entry.name === 'click' || entry.name === 'keydown') { const inp = entry.processingStart - entry.startTime; console.log('INP:', inp); // 我们定的阈值是200ms,超过就说明交互有阻塞 if (inp > 200) { reportPerformanceIssue('INP', inp); } } break; case 'layout-shift': if (!entry.hadRecentInput) { console.log('CLS:', entry.value); // CLS超过0.1就需要优化 if (entry.value > 0.1) { reportPerformanceIssue('CLS', entry.value); } } break; } } }); observer.observe({ entryTypes: ['largest-contentful-paint', 'event', 'layout-shift'] }); function reportPerformanceIssue(type, value) { // 实际项目里会发到监控平台,这里简化为打印 console.warn(`性能问题: ${type} 指标异常,当前值: ${value}`); }

这段代码跑起来后,我们发现INP经常飙到300多毫秒。排查下来是因为有个同事在按钮点击事件里同步执行了一个数据格式化的函数,处理的是近万条交易记录。我后来把它改成了用requestIdleCallback分片处理,INP直接降到了150ms左右。

CLS的问题我们是在商品详情页遇到的,页面加载时图片没设宽高,导致文字先出来,图片加载后往下推,用户正要点击"立即购买"按钮,结果按钮突然下移,点到了"收藏"。后来我们给所有图片都加了aspect-ratio属性,CLS从0.25降到了0.02。

现在诊断工具我基本不用Lighthouse了,太理想化。真实用户环境复杂得多,我们是在前端埋点收集真实的Core Web Vitals数据,按地区、设备、网络类型分组看。比如发现移动端4G用户的LCP比WiFi用户慢40%,就针对性做了图片的响应式加载,小屏幕设备加载WebP格式且尺寸更小的图片。

实战复盘:百万日活电商首屏LCP从4.2s降至1.8s的优化全记录

今年3月我们电商App的H5首页改版,上线后数据团队反馈LCP从之前的2.8s涨到了4.2s,转化率掉了1.2个百分点。我当时盯着性能面板看了半天,发现首屏要加载的资源加起来有2.3MB,其中有个轮播图组件打包进了首屏,光它自己就占了800KB。

第一步我先做了资源加载的梳理。之前项目用的是HTTP/1.1,同一个域名下最多并行6个请求,首屏要加载12个资源,排队时间就花了1.2秒。我让运维把CDN升级到了HTTP/3(RFC 9114那个版本),多路复用直接解决了排队问题。同时把dns-prefetchpreconnect加到了head里:

<!-- 之前没有预连接,DNS解析要等主文档加载完才开始 --> <link rel="dns-prefetch" href="//img.example.com"> <link rel="preconnect" href="https://img.example.com" crossorigin> <link rel="preload" href="/fonts/pingfang.woff2" as="font" type="font/woff2" crossorigin>

这里有个细节,preload字体资源必须加crossorigin,不然浏览器会加载两次。我们之前就犯过这个错,字体加载时间反而变长了。

然后处理了代码分割的问题。之前首屏把整个商品列表组件都打包进来了,包括筛选、排序这些非首屏需要的逻辑。我用动态import改了路由加载:

// 之前的静态导入,所有组件一次性加载 // import GoodsList from './GoodsList.vue' // import FilterPanel from './FilterPanel.vue' // 改成分页路由+动态加载,首屏只加载基础框架 const router = createRouter({ routes: [ { path: '/', component: () => import(/* webpackChunkName: "home-base" */ './HomeBase.vue'), children: [ { path: '', component: () => import(/* webpackChunkName: "goods-list" */ './GoodsList.vue') } ] }, { path: '/filter', component: () => import(/* webpackChunkName: "filter-panel" */ './FilterPanel.vue') } ] }) // 首屏关键商品数据用Promise.all并行请求,而不是链式调用 onMounted(() => { Promise.all([ fetch('/api/banner'), // 轮播图数据 fetch('/api/hot-goods?limit=10') // 热销商品,首屏只展示10个 ]).then(([bannerRes, goodsRes]) => { bannerList.value = bannerRes.data goodsList.value = goodsRes.data // 非首屏的推荐商品延迟到LCP之后加载 requestIdleCallback(() => { fetch('/api/recommend-goods').then(res => { recommendList.value = res.data }) }) }) })

最关键的是关键渲染路径的优化。之前首页的CSS是单独一个文件,要等CSS下载完才会渲染页面。我把首屏需要的CSS(大概20KB)直接内联到了HTML里,非关键的CSS用media="print"加载,然后再改回来:

<style> /* 内联首屏关键CSS,大概20KB */ .header { height: 44px; background: #fff; } .banner { height: 180px; overflow: hidden; } /* 其他首屏样式... */ </style> <link rel="stylesheet" href="/css/non-critical.css" media="print" onload="this.media='all'">

这一改立竿见影,LCP直接降到了2.7s。最后一步是图片优化,之前轮播图用的是2MB的JPG,我让设计同学导出WebP格式,尺寸从1200x400改成750x250(移动端适配),单张图片体积降到150KB。同时给图片加了loading="lazy",但首屏的轮播图要排除,不然LCP会更慢。

优化完上线那天,我盯着监控看了一上午,LCP稳定在1.8s左右,比优化前快了2.4秒。后来我们做了A/B测试,LCP在2秒内的用户,下单转化率比4秒以上的高了2.3个百分点。现在这个方案已经推广到我们所有电商H5页面了。

构建层选型:Vite 5与Webpack 5在大型项目的分包策略与编译速度对比

上个月我们公司有个新的中后台项目要启动,技术选型时团队争论了半天:用Vite 5.4(2024年7月刚出的版本)还是Webpack 5.90(2024年1月更新)。我之前在两个项目里都用过,干脆拉了两个demo做对比。

先说编译速度,我们那个中后台项目大概有200多个页面,依赖大概有120个包。用Webpack 5的时候,冷启动要等1分20秒左右,热更新也要3-5秒。换成Vite 5之后,冷启动直接降到8秒,热更新基本是毫秒级。原理上Vite用的是esbuild预构建,而Webpack 5虽然支持了持久化缓存,但还是要打包整个依赖图。我跑了下测试,同样的200个页面项目:

// Vite 5的vite.config.js配置,我们实际项目里的分包策略 import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig({ plugins: [vue()], build: { rollupOptions: { output: { // 分包策略:把vue、element-plus这些大依赖单独打包 manualChunks: { 'vue-vendor': ['vue', 'vue-router', 'pinia'], 'ui-vendor': ['element-plus'], // 工具库单独分包 'utils': ['lodash-es', 'axios', 'dayjs'] }, // 给chunk加hash,方便缓存 chunkFileNames: 'js/[name]-[hash:8].js', entryFileNames: 'js/[name]-[hash:8].js', assetFileNames: '[ext]/[name]-[hash:8].[ext]' } }, // 开启gzip压缩,我们生产环境用nginx再压一次,这里先预压缩 compress: 'gzip' }, resolve: { alias: { '@': resolve(__dirname, 'src') } } }) // Webpack 5的webpack.config.js分包配置,对比用 const path = require('path') module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { vueVendor: { test: /[\\/]node_modules[\\/](vue|vue-router|pinia)[\\/]/, name: 'vue-vendor', chunks: 'all', priority: 10 }, uiVendor: { test: /[\\/]node_modules[\\/]element-plus[\\/]/, name: 'ui-vendor', chunks: 'all', priority: 9 }, utils: { test: /[\\/]node_modules[\\/](lodash-es|axios|dayjs)[\\/]/, name: 'utils', chunks: 'all', priority: 8 }, common: { name: 'common', minChunks: 2, priority: 5, chunks: 'all' } } } }, resolve: { alias: { '@': path.resolve(__dirname, 'src') } } }

分包策略上两者其实差不多,但Vite 5的Rollup分包更灵活。我们之前用Webpack 5的时候,有个问题:如果某个组件被多个页面引用,Webpack可能会把它重复打包到多个chunk里。Vite 5的Rollup会自动处理这种共享依赖,打包出来的总体积比Webpack 5小了大概15%。

不过Vite也不是完美的。我们那个老项目里有不少CommonJS格式的依赖,Vite 5处理起来会有警告,虽然能跑,但预构建时间会变长。Webpack 5对CommonJS的兼容性就好很多,毕竟发展这么多年了。还有个问题是Vite的开发环境用ES modules,生产环境用Rollup打包,有时候会出现开发环境正常,生产环境报错的情况。我们上次就遇到一个,某个依赖在ES modules下导出的方式和CommonJS不一样,排查了半天才发现。

现在我们的策略是:新项目、中后台系统优先用Vite 5,因为开发体验太好了,热更新快能省很多时间。老项目、或者对兼容性要求特别高的(比如要支持IE11的,虽然现在很少了),还是用Webpack 5。我们那个电商H5项目就是用Webpack 5的,因为要兼容一些老的安卓设备,Vite 5打包出来的代码在某些旧系统上有问题。

编译速度对比下来,Vite 5的冷启动速度是Webpack 5的10倍左右,热更新快5倍以上。但打包后的体积,两者优化后差不多,Vite 5稍微小一点。如果项目里有大量动态import,Vite 5的分包会更智能,不会像Webpack 5那样有时候把不相关的代码打包到一起。

4. 渲染深水区:React Server Components与虚拟列表解决万级数据卡顿

去年双十一大促,我们那个供应链中台系统直接扛不住了。运营同学反馈,在查看包含 12000 条 SKU 的库存明细表格时,点击下一页的响应延迟高达 1.8 秒,滚动时更是掉帧严重,Chrome 的任务管理器显示该 Tab 内存占用直接飙到了 1.2GB。当时我盯着屏幕,意识到传统的 React 客户端渲染已经到了瓶颈。

为什么需要 RSC 和虚拟列表

在 React 18 及之前的版本中,我们通常会把数据获取和组件渲染都放在客户端。这意味着浏览器需要先下载一个巨大的 JSON 数据包(那次事故里这个包有 4.7MB),然后序列化数据,再交给 React 去 diff 和渲染。对于万级数据,这不仅是网络传输的灾难,更是主线程的噩梦。

我在排查时发现,即使使用了 useMemoReact.memo,12000 个 DOM 节点依然会让 Layout 和 Paint 阶段耗时超过 300ms。这就是我们需要 React Server Components (RSC)虚拟列表 的原因:RSC 负责在服务器端就把数据“喂”给组件,只把渲染结果(极小的 RSC Payload)传给客户端,减少客户端 JS 执行压力;而虚拟列表负责只渲染可视区域的 DOM,把 12000 个节点变成 20 个。

实战:RSC 减少客户端负担

我们那个项目是基于 Next.js 14 改造的。我决定把库存明细的列表容器改为 Server Component。

// app/inventory/page.tsx import { Suspense } from 'react'; import InventoryTable from './InventoryTable'; // 这是一个 Server Component import { getInventoryData } from '@/lib/db'; // 直接访问数据库,无需暴露 API export default async function InventoryPage() { // 在服务器端直接获取数据,零客户端 JS 开销 const initialData = await getInventoryData({ limit: 10000 }); return ( <div className="p-4"> <h1>库存明细</h1> <Suspense fallback={<div>加载库存数据...</div>}> {/* 这里的 InventoryTable 在服务器渲染成 HTML 流, 客户端只负责插入 DOM,不需要下载处理 4.7MB JSON 的 JS 逻辑 */} <InventoryTable data={initialData} /> </Suspense> </div> ); }

改造后,首屏的 JS Bundle 体积从 320KB 降到了 85KB,因为处理数据的逻辑完全留在了服务端。LCP 从之前的 2.4 秒提升到了 1.6 秒。

实战:虚拟列表解决渲染卡顿

光有 RSC 还不够,如果 InventoryTable 直接渲染 10000 行数据,DOM 节点依然会撑爆内存。我引入了一个简单的虚拟列表逻辑(基于 @tanstack/react-virtual 版本 3.10.0,这是目前社区最稳定的选择)。

// components/VirtualizedList.tsx import { useVirtualizer } from '@tanstack/react-virtual'; import React, { useRef } from 'react'; export function VirtualizedList({ items }) { const parentRef = useRef<HTMLDivElement>(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 45, // 预估每一行的高度 }); return ( <div ref={parentRef} style={{ height: '800px', overflow: 'auto', // 必须设置滚动 border: '1px solid #eee', }} > <div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative', }} > {virtualizer.getVirtualItems().map((virtualItem) => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, }} > {/* 实际渲染的内容 */} <div className="flex items-center h-full border-b px-4"> <span>SKU: {items[virtualItem.index].skuId}</span> <span className="ml-4">库存: {items[virtualItem.index].stock}</span> </div> </div> ))} </div> </div> ); }

遇到的问题与权衡

在接入虚拟列表时,我遇到了一个棘手的问题:由于服务端返回的数据是分页的,而虚拟列表要求一次性拿到所有数据来计算总高度。如果直接加载 10000 条,接口响应依然慢。

我的解决方案是混合模式:首屏用 RSC 渲染前 100 条(非虚拟化,保证 SEO 和首屏速度),同时初始化虚拟列表。当用户滚动到底部时,通过 IntersectionObserver 触发加载下一页数据,并追加到虚拟列表的数据源中。这样既避免了首屏大数据传输,又解决了万级 DOM 渲染的卡顿。最后的结果是,内存占用稳定在 150MB 左右,滚动帧率保持在 60fps。

5. 边缘计算与离线化:HTTP/3与Service Worker在弱网环境下的落地实践

去年我们上线了一个面向东南亚市场的电商 H5 活动页。那里网络环境极差,3G 网络占比高,且经常丢包。上线第一天,监控平台显示,在印尼地区,LCP 居然达到了 6.8 秒,跳出率飙升。我意识到,仅仅优化前端代码不够,必须深入网络协议层。

为什么 HTTP/3 和 SW 是弱网救星

传统的 HTTP/1.1 队头阻塞问题在弱网下会被无限放大。虽然 HTTP/2 解决了应用层队头阻塞,但 TCP 层的丢包依然会导致所有请求阻塞。我查阅了 RFC 9114(2022年发布),HTTP/3 基于 UDP 的 QUIC 协议,解决了传输层的队头阻塞。对于我们的 H5 页面,这意味着即使一个图片包丢了,其他 CSS 和 JS 的传输也不会被阻塞。

Service Worker (SW) 则是为了解决“第二次访问”和“离线”的问题。在东南亚,用户流量宝贵,如果每次打开页面都要重新下载 500KB 的静态资源,体验极差。

实战:升级 HTTP/3 与 QUIC 优化

我们接入了 Cloudflare 的 CDN,并在 Nginx 配置中开启了 HTTP/3 支持(编译时使用了 OpenSSL 3.0+ 和 BoringSSL 支持)。

# nginx.conf 配置片段 server { listen 443 quic reuseport; listen 443 ssl; http2 on; http3 on; # 开启 Alt-Svc 头部,告知浏览器支持 HTTP/3 add_header Alt-Svc 'h3=":443"; ma=86400'; ssl_protocols TLSv1.3; ssl_prefer_server_ciphers off; location /static/ { # 针对静态资源开启更激进的缓存 add_header Cache-Control "public, max-age=31536000, immutable"; # 开启预加载,配合 HTTP/3 的优先级 http3_push /static/main.js; http3_push /static/main.css; } }

配置上线后,我在模拟 3G 网络(通过 Chrome DevTools 的 Throttling)下测试,首屏资源加载时间从 4.2 秒降到了 2.9 秒。QUIC 的连接建立时间(0-RTT)确实比 TCP + TLS 握手快了不少。

实战:Service Worker 离线缓存策略

为了彻底解决弱网问题,我给 H5 页面加了一层 Service Worker 缓存。我没有直接用 Workbox 的默认配置,而是根据业务场景定制了策略。

// service-worker.js const CACHE_NAME = 'v1-static-cache-v3'; const urlsToCache = [ '/', '/static/main.js', '/static/main.css', '/static/logo.png' ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('Opened cache'); return cache.addAll(urlsToCache); }) ); }); self.addEventListener('fetch', event => { // 针对 HTML 文档:网络优先,失败则降级缓存(保证内容新鲜度) if (event.request.headers.get('accept').includes('text/html')) { event.respondWith( fetch(event.request).catch(() => caches.match(event.request)) ); return; } // 针对静态资源:缓存优先(Cache First) // 这正是弱网环境下的核心策略,直接从本地读,耗时从 800ms 变成 5ms event.respondWith( caches.match(event.request).then(response => { return response || fetch(event.request).then(fetchResponse => { return caches.open(CACHE_NAME).then(cache => { cache.put(event.request, fetchResponse.clone()); return fetchResponse; }); }); }) ); });

真实场景下的取舍

在落地 Service Worker 时,我遇到过一个线上事故。某次更新了 main.js,但用户手机里的 SW 还缓存着旧版本,导致页面报错 Uncaught TypeError

排查下来发现,是因为我在 activate 事件里没有做好旧缓存的清理。后来我修改了策略:在 SW 的 activate 事件中,强制删除旧版本 Cache,并跳过等待直接接管页面。

self.addEventListener('activate', event => { const cacheWhitelist = [CACHE_NAME]; event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheWhitelist.indexOf(cacheName) === -1) { // 清理旧版本缓存 return caches.delete(cacheName); } }) ); }).then(() => self.clients.claim()) // 立即接管所有页面 ); });

经过这轮优化,在模拟的恶劣网络环境下(丢包率 10%),页面二次加载速度提升了 60%,用户反馈“秒开”的比例显著增加。

6. AI与Wasm前瞻:2024-2026性能优化趋势及INP指标的交互响应优化

2024 年 3 月,Google 更新了 Core Web Vitals 标准,INP (Interaction to Next Paint) 正式替代 FID 成为核心指标。这对我们前端开发者的要求更高了。以前我们只关心“能不能点”,现在关心“点击后 200ms 内有没有反应”。

为什么 INP 比 FID 更折磨人

在之前的一个在线协作白板项目中,用户反馈“输入文字时总有粘滞感”。我打开 Performance 面板录制,发现每次按键都会触发一个巨大的 JSON.stringify 操作来同步数据,阻塞了主线程长达 350ms。FID 只测第一次输入,可能只有 20ms,但 INP 会记录这次 350ms 的卡顿。

INP 优化实战:任务分片与 Wasm 介入

为了优化那个白板项目的 INP,我尝试了两种方法。一种是传统的任务分片,把大的 JS 任务拆成微任务。

// 优化前:一次性处理 5000 个图形节点的序列化 function syncDataHeavy(nodes) { const data = JSON.stringify(nodes); // 阻塞主线程 300ms sendToServer(data); } // 优化后:使用 setTimeout 分片,释放主线程控制权 function syncDataChunked(nodes) { let index = 0; const chunkSize = 100; function processChunk() { const chunk = nodes.slice(index, index + chunkSize); // 处理一小块 processChunkData(chunk); index += chunkSize; if (index < nodes.length) { // 把控制权交还给浏览器,让它可以去处理用户输入 setTimeout(processChunk, 0); } else { sendToServer(finalData); } } processChunk(); }

虽然分片有效,但对于复杂的图形计算(如贝塞尔曲线拟合),JS 依然力不从心。于是我引入了 WebAssembly (Wasm)。我使用 Rust 编写了核心的计算逻辑,编译成 Wasm。

// src/lib.rs use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn calculate_path_complexity(points_ptr: *const u32, len: usize) -> f64 { let points = unsafe { std::slice::from_raw_parts(points_ptr, len) }; let mut total = 0.0; // 模拟复杂计算 for i in 0..points.len() - 1 { let dx = (points[i] as f64) - (points[i + 1] as f64); let dy = (points[i + 2] as f64) - (points[i + 3] as f64); total += (dx * dx + dy * dy).sqrt(); } total }

在 JS 中调用后,原本需要 150ms 的计算过程缩短到了 18ms。这直接让 INP 指标从 280ms 降到了 85ms,交互变得极其跟手。

AI 驱动的性能优化趋势

在 2024 年,我注意到 AI 辅助优化 已经开始落地。我们在使用 Vite 5.4(2024年7月更新)构建项目时,尝试了一个实验性的 AI 插件。它的原理是分析打包后的 Chunk 体积和依赖关系,自动建议哪些组件应该使用 dynamic import 进行更细粒度的代码分割。

比如,AI 分析出我们首页的 lodash 全量引入了,但只用了 debouncethrottle。它会自动帮我重构代码,从 import _ from 'lodash' 改为 import debounce from 'lodash/debounce',并生成对应的 babel 配置。这种基于 AST 和运行时分析的建议,比人工去翻包体积报告要高效得多。

边缘渲染的落地

另外,关于 边缘渲染,我们在 Vercel Edge Functions 上做了尝试。以前我们的 SSR 是在源站服务器(比如 AWS ec2)上进行的,从雅加达访问美国机房,光网络延迟就有 300ms。现在我们把渲染逻辑推到了 Vercel 的 Edge Network(靠近用户的边缘节点)。

边缘节点不再运行完整的 Node.js,而是运行基于 WebAssembly 的轻量级运行时。首屏 HTML 的生成时间从 400ms 降到了 80ms。虽然边缘函数的执行环境限制较多(不支持文件系统、内存限制),但对于首屏渲染这种 IO 密集型或轻计算场景,它确实是未来的方向。

结合 INP 的要求,我们现在更倾向于将非首屏的非关键交互逻辑(如埋点、非核心弹窗)延迟到 requestIdleCallback 中执行,确保用户点击按钮时,主线程是干净的。这些细节的打磨,才是 2024 年性能优化的核心战场。

站长实战手记

一次差点让我背P0故障的优化经历

去年双11大促前,我接手了一个跨境物流追踪页的优化。业务场景很典型:用户查包裹状态,页面要同时拉取物流轨迹、清关信息、关税计算三个接口,数据量不大但接口响应慢。当时首屏LCP卡在3.5s,产品要求压到2s以内。

我第一反应是上React Server Components,毕竟当时社区都在吹这个。结果折腾一周后发现不对劲:我们的物流轨迹需要实时轮询更新,RSC的静态生成反而让数据新鲜度出问题。后来排查发现,真正的瓶颈是接口请求链:三个接口是串行的,而且关税计算接口因为要查第三方汇率,经常超时。

最后我做了个很"土"的方案:把串行请求改成Promise.all并行,同时给关税接口加了本地缓存(缓存时间30秒,刚好覆盖用户快速刷新的场景)。渲染层没用RSC,而是用虚拟列表只渲染可视区域的物流节点(最多20条,但用户可能查半年前的记录)。优化后LCP降到1.6s,接口报错率反而降了40%——因为缓存挡掉了一部分第三方服务的不稳定。

我的真实取舍看法

* RSC不是银弹:如果你的页面有大量实时数据或频繁交互,别盲目上。我们后来只在商品详情页用了RSC,因为那地方数据更新频率低。

* 虚拟列表要看数据量:万级数据才值得折腾,像我们这种最多几百条的,用普通列表加个overflow: auto也够用,别为了技术而技术。

* 构建工具选型:那个项目用的是Webpack 5,分包策略我花了三天调,后来换Vite 5做新项目,编译速度是快,但分包配置得重新学,老项目的迁移成本比想象中高。

给读者的真心话

别一上来就盯着LCP、INP这些指标看。先打开Chrome DevTools的Performance标签录一段真实用户操作,看看时间到底花在哪。我见过太多人上来就乱加loading动画,结果真正的瓶颈是接口设计问题。性能优化是手段,不是目的,用户觉得快才是真的快。