核心思路:资源按 “是否首屏可见” 做边界拆分 + 异步懒加载非首屏代码 + 配合交互调度优化 INPINP(交互下延迟)这里从 320ms→262ms,本质是主线程减少首屏长任务,用户点击 / 输入时主线程不被大 JS 解析执行阻塞
一、先明确概念边界
- 首屏模块:首视口内渲染、初始化必须立刻执行的代码(顶部导航、首屏核心图文、首屏交互按钮)
- 次屏模块:首视口以外、滚动后才可见的模块(下方列表、弹窗、tab、图表、次要组件)
- 模块拆包:把打包产物从单 bundle 拆成
main.[hash].js(首屏 bundle) + 多个异步 chunk(次屏模块) - INP 优化关键:首屏 bundle 越小,解析 / 编译 / 执行耗时越短,主线程空闲更早,用户交互响应更快
二、整体实现方案(Webpack / Vite 两套主流)
✅ 核心原则
只把首屏必须立刻执行的代码打进主 bundle;其余全部异步分包,不阻塞首屏渲染。
1. 代码层面:静态 import → 动态 import () 拆分次屏模块
import()是 ES 标准动态导入,打包器会自动单独生成 chunk,不阻塞主线程解析
<template> <!-- 次屏占位容器 --> <div class="recommend-wrap"> <component v-if="RecommendComp" :is="RecommendComp" /> <div v-else class="skeleton">加载中...</div> </div> </template> <script setup> import { ref, onMounted } from 'vue' const RecommendComp = ref(null) // 动态加载组件 const loadRecommendList = async () => { // 动态导入,打包会单独分包 const { default: Comp } = await import('./RecommendList.vue') RecommendComp.value = Comp } onMounted(() => { const wrapEl = document.querySelector('.recommend-wrap') if (!wrapEl) return const observer = new IntersectionObserver((entries) => { //遍历已注册监听的dom entries.forEach(entry => { if (entry.isIntersecting) { loadRecommendList() observer.unobserve(entry.target) } }) }) observer.observe(wrapEl)//一次注册一个dom监听 }) onUnmounted(() => { observer?.disconnect() // 销毁观察器 }) </script>const {default: Comp} = await import ('./RecommendList.vue') //等价于 const mod = await import('./RecommendList.vue') const Comp = mod.defaultdefault:模块默认导出: Comp:把default属性的值,赋值给变量名叫Comp
👉 不仅延迟加载 JS,还可以配合预加载策略:空闲时预加载次屏资源(
requestIdleCallback),兼顾首屏速度 + 滚动后体验
2. 打包配置:分包策略(重点控制首屏 bundle 体积)
场景 A:Webpack
// webpack.config.js module.exports = { optimization: { // 代码分割 splitChunks: { chunks: 'all', cacheGroups: { // 第三方公共包单独拆(vue/react、axios等,长期缓存) vendor: { test: /[\\/]node_modules[\\/]/, priority: 10 }, // 次屏业务模块单独分包,不打入main secondary: { test: /src\/views\/secondary\//, priority: 5 } } }, runtimeChunk: 'single' // runtime单独抽离,避免chunk hash变更导致缓存失效 } }字段拆解
optimizationwebpack 的优化相关配置,专门用来做打包产物优化、代码分割、压缩等,不属于基础五大项。splitChunks内置的公共代码分割能力,自动把复用的代码抽成独立 chunk,实现按需加载 + 浏览器长缓存
chunks: 'all':同时对同步代码 和 异步 import 懒加载代码都做分割(可选值:initial只同步、async只异步、all全部)cacheGroups:分包规则组,自定义怎么划分 chunk,优先级高的规则先匹配vendortest: /[\\/]node_modules[\\/]/:匹配 node_modules 里的文件(第三方库 vue/axios 等)priority:10:优先级 10,数字越大越优先匹配
secondarytest: /src\/views\/secondary\//:匹配src/views/secondary下的业务页面模块priority:5:优先级低于 vendor ✅ 效果:secondary 页面代码会单独打包成独立 chunk,不会打包进 main 主包,一般配合路由懒加载 / 前面写的 IntersectionObserver 懒导入使用
runtimeChunk: 'single'把 webpackruntime 运行时代码(模块加载、模块映射的胶水代码)单独抽成一个独立 runtime chunk。
坑点:如果 runtime 和业务代码打包在一起,业务 chunk 内容改动时,runtime 的 hash 会跟着变,浏览器缓存直接失效。抽离 runtime 可以稳定第三方包的 hash,充分利用长效缓存。
- 模块命中
/node_modules/→ vendor - 否则,命中
src/views/secondary/→ secondary - 其他异步模块 → 自动单独拆成独立 chunk
- 其他同步业务代码 → main
打包产物大概会生成:
runtime.[hash].js
vendor.[hash].js
secondary.[hash].js
main.[hash].js
场景 B:Vite(更简单,内置 esbuild 分包)
// vite.config.js export default defineConfig({ build: {//生产环境 rollupOptions: {//Rollup 配置 output: { manualChunks: { vendor: ['vue', 'axios'], secondary: ['./src/RecommendList.js'] // 次屏模块手动拆包 } } } } })manualChunks可以传对象: key = chunk 名称,value = 模块数组
manualChunks: { // 打包出 vendor-[hash].js,里面包含 vue 和 axios vendor: ['vue', 'axios'], // 打包出 secondary-[hash].js,里面包含 ./src/RecommendList.js secondary: ['./src/RecommendList.js'] }✅ 产物:
vendor.xxxx.js→ vue + axiossecondary.xxxx.js→ RecommendList.js- 剩下代码 → main.xxxx.js
⚠️ 重点:只写这个数组,只会把数组里的模块放进对应 chunk别的异步模块不会自动进 secondary!不像 webpack splitChunks 可以正则批量匹配目录。
如果想实现类似 webpack 正则批量分包(比如整个 src/views/secondary 下所有文件都进 secondary)
对象写法做不到正则,要改成函数形式的manualChunks(面试常考)
manualChunks(id) { // id 是文件绝对路径 if (id.includes('node_modules')) { return 'vendor' } if (id.includes('src/views/secondary/')) { return 'secondary' } }重要坑点(高频面试)
manualChunks对象写法不能写正则!只能精确指定模块- Vite 默认动态
import()本身就会自动分包,manualChunks是强制归类合并 - Rollup 没有 webpack 那种 runtimeChunk,不存在 “runtime 代码污染 hash” 的问题
- 如果多个入口都引入 vue,写
vendor:['vue']会把 vue 提取到公共 vendor
指标达成逻辑:原本所有业务代码都在 main 包,拆分后次屏代码移出 main →首屏 JS 体积下降 25%
3. 分层加载时机精细化(3 种可选策略)
- 可视懒加载(推荐):IntersectionObserver,元素进入视口才加载次屏 JS(最稳妥,严格分层)
const wrapEl = document.querySelector('.recommend-wrap') if (!wrapEl) return const observer = new IntersectionObserver((entries) => { //遍历已注册监听的dom entries.forEach(entry => { if (entry.isIntersecting) { loadRecommendList() observer.unobserve(entry.target) } }) }) observer.observe(wrapEl)//一次注册一个dom监听- 浏览器空闲预加载:
requestIdleCallback,首屏渲染完成、主线程空闲时提前拉次屏 chunk,滚动过来直接渲染 <script setup> import { ref, onMounted, onUnmounted } from 'vue' const RecommendComp = ref(null) // 预加载函数 const preloadChunk = async () => { try { const { default: Comp } = await import('./RecommendList.vue') RecommendComp.value = Comp } catch (e) { console.error('预加载失败', e) } } onMounted(() => { // 浏览器空闲时预加载,timeout兜底2s,防止永远不执行 if ('requestIdleCallback' in window) { requestIdleCallback(preloadChunk, { timeout: 2000 }) } else { // 兼容不支持requestIdleCallback的浏览器 preloadChunk() } }) </script> <template> <!-- 你自己控制时机渲染组件,比如滚动判断等 --> <div v-if="RecommendComp"> <component :is="RecommendComp" /> </div> </template>- 交互触发加载:点击 tab / 展开弹窗时才加载对应代码(适合非默认展示的组件)
<script setup> import { ref } from 'vue' // 标记组件是否已经加载 const RecommendComp = ref(null) const showTab = ref(false) // 点击tab触发加载 const handleTabClick = async () => { showTab.value = true // 已经加载过就不再请求 if (RecommendComp.value) return const { default: Comp } = await import('./RecommendList.vue') RecommendComp.value = Comp } </script> <template> <button @click="handleTabClick">切换推荐Tab</button> <div v-if="showTab"> <component v-if="RecommendComp" :is="RecommendComp" /> <div v-else>加载中...</div> </div> </template>
4. INP 专项优化(P75 320ms → 262ms 核心)
INP 衡量用户交互时主线程是否被长任务阻塞(你点一下 / 敲键盘,页面多久才给出视觉反应),单纯拆包不够,还要配套:
- 首屏 JS 拆解后,减少长任务:大 JS 解析、编译、执行属于主线程长任务,体积降 25% 直接缩短长任务耗时
- 非紧急任务延后:首屏初始化逻辑里,把统计、埋点、次要初始化丢到
requestIdleCallback - 大计算逻辑 Web Worker:次屏如果有数据格式化、筛选计算,丢 Worker,不占用主线程
- 避免长任务抢占:如果次屏 chunk 加载后执行逻辑很重,用
setTimeout/queueMicrotask分片执行,防止阻塞用户点击输入
// 示例:首屏初始化,非核心逻辑空闲执行 requestIdleCallback(() => { initBuriedPoint() preloadSecondaryChunk() // 空闲预加载次屏chunk })三、配套优化(放大收益,防止踩坑)
- 资源压缩:开启
gzip/brotli(服务端开启),进一步降低传输体积(分包后 gzip 收益更明显),搭配terser使用效果更好
// webpack terser 常用配置 const TerserPlugin = require('terser-webpack-plugin') module.exports = { optimization: { minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: true, // 删除console drop_debugger: true }, mangle: true // 变量名混淆 } }) ] } }terser 干什么
用来压缩、混淆 JS 代码:
- 删除空格、换行、注释
- 变量名短命名(
const userInfo→const a) - 删除无效代码(死代码)
- 移除 console、debugger(可配置)
Gzip(底层 Deflate:LZ77+Huffman)
- 时机:CDN/Nginx,网络传输阶段
- 特点:兼容性最强;滑动窗口最大 32KB
- 适用:文本类资源 js/css/html/json
- 等级 1~9,生产常用 6
Brotli(br)
- 时机:同 gzip,传输层
- 原理:增强 LZ77 + 上下文 Huffman+Web 内置静态字典
- 特点:比 gzip 小 10%~20%,小文件收益极高,解压快
- 策略:请求头
Accept-Encoding: br,gzip,优先返回 br,降级 gzip
顺序:源码 → terser 压缩 → 得到小 js 文件 → nginx/gzip 再压缩传输 → 浏览器解压执行
- 预加载 / 预连接:
<link rel="preload">按需预加载高优先级次屏 chunk,不要滥用(会抢占首屏带宽)
preload的比较少,只用非常紧急的才会用,一般的用prefetch,都是只下载不使用
静态 import(首屏必须立刻执行) > preload 预下载 + 后续动态 import 执行 > 普通动态 import(交互 / 可视才触发) > prefetch 预下载 + 后续动态 import > rIC 里的 import(完全闲时才加载执行)
- 缓存策略:分包 chunk contenthash,长期缓存,用户二次访问不用重复下载
// webpack.config.js const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { entry: './src/index.js', output: { path: path.resolve(__dirname, 'dist'), // 主文件 contenthash filename: 'js/[name].[contenthash].js', // 异步分包 chunk contenthash chunkFilename: 'js/[name].[contenthash].chunk.js', clean: true, // 打包自动清除旧dist }, optimization: { // 代码分割,分包 vendor、公共模块 splitChunks: { chunks: 'all' }, // 单独提取 runtime 防止 vendor hash 意外变化(高频坑!) runtimeChunk: 'single' }, plugins: [ new HtmlWebpackPlugin({ template: './index.html' // 自动注入带hash的js/css,不用手动写script }) ] }// vite.config.js import { defineConfig } from 'vite' export default defineConfig({ build: { rollupOptions: { output: { // 自定义输出文件名(可选,vite默认已经带hash) chunkFileNames: 'js/[name]-[contenthash].js', assetFileNames: 'assets/[name]-[contenthash].[ext]' } } } })已经配了 contenthash,用户依旧加载旧资源,详细排查清单
原理前置: contenthash 的逻辑是:新代码 → 新 hash 文件名 → html 里引用新地址。 如果用户拿到的 html 还是旧版本,里面写的还是旧 hash 的 js 地址,浏览器自然加载旧缓存文件。 所以第一优先级:html 被强缓存
F12 → Network → 勾选「Disable cache」关掉缓存刷新对比;看 html 请求的 Response Header
- 如果
Cache-Control: max-age=31536000(一年强缓存)→ 实锤问题 - 或者 Status 是
200 (from disk cache)/200 (from memory cache)
正确方案
html禁止长期强缓存,推荐两种方案二选一:
- 协商缓存(ETag / Last-Modified):浏览器每次发请求,服务器判断 html 有没有更新,更新才返回新内容,否则 304
- 短 max-age:
Cache-Control: max-age=60(1 分钟)
❗ 重点区分:js/css/img 等带 contenthash 的静态资源 → 长 max-age;html 绝对不能长缓存
CDN 层面缓存问题(非常多线上踩坑)
即使 html 配置没问题,CDN 也可能卡住旧资源:
- CDN 缓存了旧的 html源站 header 改了,但 CDN 节点还存着旧 html,回源不及时 ✅ 排查:看请求的
X-Cache字段(HIT = 命中 CDN 缓存,MISS = 回源) ✅ 处理:CDN 强制刷新 html,配置 html 不缓存 / 短缓存 - CDN 缓存 key、忽略参数、缓存规则异常
- CDN 回源策略异常、预缓存旧包
runtimeChunk 是干嘛的(重点高频面试点)
什么是 runtime
webpack 打包后会生成一段运行时代码: 作用是管理模块的加载、解析、异步 chunk 的加载逻辑(比如__webpack_require__、异步引入的 jsonp 加载逻辑),默认这段 runtime 代码会打包进主 chunk(main.js)里面。
问题:不抽 runtime 会发生什么?
vendor 是第三方库(vue/react 等),正常我们希望 vendor 长期缓存、尽量不更新。 但是:runtime 内嵌在 main 里 → 修改业务 main 代码 → runtime 代码会跟着重新生成 → 导致 vendor 的 hash 跟着变化
明明第三方代码一行没改,但是 vendor 文件名 hash 变了,用户要重新下载巨大的 vendor 包,缓存白做!
runtimeChunk: 'single'的作用
把 webpack 的运行时代码单独抽成一个独立的 runtime.[contenthash].js,和业务代码、vendor 代码彻底隔离。
- 修改业务代码 → 只有 main 的 hash 变
- 更新第三方库 → 只有 vendor 的 hash 变
- runtime 只有webpack 构建逻辑本身变更时 hash 才会变 完美实现:不变的包永久走缓存
可选值补充
runtimeChunk: true/'multiple':每个入口单独生成 runtimeruntimeChunk: 'single':所有入口共用一份 runtime(绝大多数项目推荐)
配套 splitChunks 补充
splitChunks: { chunks: 'all' }chunks: 'all'会把公共依赖、第三方库抽成独立的 vendor chunk,只有抽出来 vendor,runtime 的价值才体现出来,如果所有代码都打包在一个 main 里,抽 runtime 意义不大。
- 骨架屏:次屏区域放骨架,JS 未加载完成前占位,感知上更流畅
- 避免过度分包:chunk 太小会造成大量 HTTP 请求,一般单个 chunk 建议 >10kb(gzip 后)
optimization: { runtimeChunk: 'single', splitChunks: { chunks: 'all', minSize: 20 * 1024, // 原始20kb,gzip后大致10kb上下(核心控制) minChunks: 2, // 至少被2个模块引用才抽成公共chunk maxAsyncRequests: 6, // 异步请求最大并发数,防止一次性请求太多 maxInitialRequests: 4 // 首页初始化并行加载chunk上限 } }解释:设置原始minSize:20kb,压缩后基本落在 10kb 以上,刚好符合这个最佳实践。
很多人误区:HTTP2 多路复用,随便拆小 chunk。 ❌ 不是。 HTTP2 消除了队头阻塞,但是:
- 每个请求依然有少量协议、服务端、CDN 开销
- 过多极小文件会增加浏览器解析、模块实例化开销(js 解析执行成本) 所以 HTTP2 可以放宽标准(比如底线 5kb),但依然不建议大量 1~3kb 的碎片 chunk。
四、指标验证与埋点(怎么确认达到:首屏 JS-25%,P75 INP 下降)
- 首屏 JS 体积:
- Webpack:
webpack-bundle-analyzer看 main bundle 大小 - Vite:
rollup-visualizer - 对比拆分前后首屏加载的 JS 总大小(不含异步后续加载的 chunk)
- Webpack:
- INP 指标采集:使用 web-vitals 上报真实用户指标,统计 P75 分位值
import { onINP } from 'web-vitals' onINP((metric) => { // 上报 metric.value 到监控平台 })onINP回调里的metric.value ≠ p75
web-vitals 上报的是当前这个用户本次页面会话内的 INP 值(近似 p98 / 最坏交互,少量交互直接取最大值)P75 是 CrUX(谷歌后台聚合全量用户数据之后)才算的分位数
- web-vitals onINP(前端 SDK 单用户采集)
- 收集当前用户本次页面所有有效交互
- 交互少于 50 次 → INP = 最慢那一次交互耗时
- 交互≥50 次 → 近似p98(剔除极端异常值,每 50 个丢掉 1 个最差值)
- 这个值是单用户单次页面的指标,不是分位数
- CrUX / Google Search Console(CWV 达标判定标准)
- 把成千上万不同用户上报的 INP 数据聚合
- 然后计算p75作为评估基准:要求 p75 ≤200ms 才算绿色合格
很多人混淆:SDK 采集单用户 INP,你自己的监控后台拿到大量样本之后,需要你自己再聚合算 p75,才和谷歌 CWV 口径对齐
前端只需要通过 web-vitals 采集原始指标并上报原始数据,p75 这类分位数聚合不需要前端实现,由后端或者 RUM 监控平台基于全量上报数据统一计算。前端不适合做跨用户的数据聚合,缺少全量样本还会带来性能和数据丢失问题。
五、常见坑
- ❌ 动态 import 但是提前预加载时机过早,反而抢占首屏带宽,INP 变差
INP 衡量的是用户交互(点击 / 按键)到浏览器真正响应的延迟,瓶颈分两类:网络阻塞、主线程阻塞,预加载过早两个坑都会踩。
正确时机:首屏核心资源加载完毕后,再去预加载后续才会用到的异步 chunk(比如非首屏弹窗、二级路由)
满足其中一个再触发import()预加载:
- 首屏核心资源、LCP 资源加载完成后(可以监听
load/web-vitals LCP 回调) - 用户有预判行为:hover 下一步按钮、滚动即将进入下一视图(IntersectionObserver)
- 浏览器空闲时:
requestIdleCallback里面再预加载(最优,不抢占关键时间片)
- ❌ 分包粒度太碎,大量小 chunk,HTTP 开销抵消收益
分包初衷:不变的包长期缓存,只更新改动代码; 过度碎包后果:缓存省下的流量,抵不上多请求 + 多次 JS 解析的耗时。
- ❌ 次屏 JS 加载后同步执行超大渲染逻辑,依然产生长任务,INP 没改善
- ❌ 把首屏依赖的组件也拆出去,导致首屏白屏、水合延迟
六、落地实施步骤(可直接排期)
- 梳理页面模块:标记【首屏必渲染】/【次屏懒加载】清单
- 代码改造:次屏组件改为
import()+ IntersectionObserver 触发 - 修改打包配置,配置 splitChunks/manualChunks 拆分 bundle
- 增加空闲预加载(可选,提升滚动体验)
- 首屏非核心逻辑移到 requestIdleCallback,消除长任务
- 上线前 bundle 体积验证,上线后监控 P75 INP 数据