尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

落地首屏与次屏分层优化

落地首屏与次屏分层优化 核心思路资源按 “是否首屏可见” 做边界拆分 异步懒加载非首屏代码 配合交互调度优化 INPINP交互下延迟这里从 320ms→262ms本质是主线程减少首屏长任务用户点击 / 输入时主线程不被大 JS 解析执行阻塞一、先明确概念边界首屏模块首视口内渲染、初始化必须立刻执行的代码顶部导航、首屏核心图文、首屏交互按钮次屏模块首视口以外、滚动后才可见的模块下方列表、弹窗、tab、图表、次要组件模块拆包把打包产物从单 bundle 拆成main.[hash].js首屏 bundle 多个异步 chunk次屏模块INP 优化关键首屏 bundle 越小解析 / 编译 / 执行耗时越短主线程空闲更早用户交互响应更快二、整体实现方案Webpack / Vite 两套主流✅ 核心原则只把首屏必须立刻执行的代码打进主 bundle其余全部异步分包不阻塞首屏渲染。1. 代码层面静态 import → 动态 import () 拆分次屏模块import()是 ES 标准动态导入打包器会自动单独生成 chunk不阻塞主线程解析template !-- 次屏占位容器 -- div classrecommend-wrap component v-ifRecommendComp :isRecommendComp / div v-else classskeleton加载中.../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() // 销毁观察器 }) /scriptconst {default: Comp} await import (./RecommendList.vue) //等价于 const mod await import(./RecommendList.vue) const Comp mod.defaultdefault模块默认导出: Comp把default属性的值赋值给变量名叫Comp 不仅延迟加载 JS还可以配合预加载策略空闲时预加载次屏资源requestIdleCallback兼顾首屏速度 滚动后体验2. 打包配置分包策略重点控制首屏 bundle 体积场景 AWebpack// 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].jsvendor.[hash].jssecondary.[hash].jsmain.[hash].js场景 BVite更简单内置 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-ifRecommendComp component :isRecommendComp / /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 clickhandleTabClick切换推荐Tab/button div v-ifshowTab component v-ifRecommendComp :isRecommendComp / div v-else加载中.../div /div /template4. 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底层 DeflateLZ77Huffman时机CDN/Nginx网络传输阶段特点兼容性最强滑动窗口最大 32KB适用文本类资源 js/css/html/json等级 1~9生产常用 6Brotli(br)时机同 gzip传输层原理增强 LZ77 上下文 HuffmanWeb 内置静态字典特点比 gzip 小 10%~20%小文件收益极高解压快策略请求头Accept-Encoding: br,gzip优先返回 br降级 gzip顺序源码 → terser 压缩 → 得到小 js 文件 → nginx/gzip 再压缩传输 → 浏览器解压执行预加载 / 预连接link relpreload按需预加载高优先级次屏 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-age31536000一年强缓存→ 实锤问题或者 Status 是200 (from disk cache)/200 (from memory cache)正确方案html禁止长期强缓存推荐两种方案二选一协商缓存ETag / Last-Modified浏览器每次发请求服务器判断 html 有没有更新更新才返回新内容否则 304短 max-ageCache-Control: max-age601 分钟❗ 重点区分js/css/img 等带 contenthash 的静态资源 → 长 max-agehtml 绝对不能长缓存CDN 层面缓存问题非常多线上踩坑即使 html 配置没问题CDN 也可能卡住旧资源CDN 缓存了旧的 html源站 header 改了但 CDN 节点还存着旧 html回源不及时 ✅ 排查看请求的X-Cache字段HIT 命中 CDN 缓存MISS 回源 ✅ 处理CDN 强制刷新 html配置 html 不缓存 / 短缓存CDN 缓存 key、忽略参数、缓存规则异常CDN 回源策略异常、预缓存旧包runtimeChunk 是干嘛的重点高频面试点什么是 runtimewebpack 打包后会生成一段运行时代码 作用是管理模块的加载、解析、异步 chunk 的加载逻辑比如__webpack_require__、异步引入的 jsonp 加载逻辑默认这段 runtime 代码会打包进主 chunkmain.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只有抽出来 vendorruntime 的价值才体现出来如果所有代码都打包在一个 main 里抽 runtime 意义不大。骨架屏次屏区域放骨架JS 未加载完成前占位感知上更流畅避免过度分包chunk 太小会造成大量 HTTP 请求一般单个 chunk 建议 10kbgzip 后optimization: { runtimeChunk: single, splitChunks: { chunks: all, minSize: 20 * 1024, // 原始20kbgzip后大致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 体积Webpackwebpack-bundle-analyzer看 main bundle 大小Viterollup-visualizer对比拆分前后首屏加载的 JS 总大小不含异步后续加载的 chunkINP 指标采集使用 web-vitals 上报真实用户指标统计 P75 分位值import { onINP } from web-vitals onINP((metric) { // 上报 metric.value 到监控平台 })onINP回调里的metric.value ≠ p75web-vitals 上报的是当前这个用户本次页面会话内的 INP 值近似 p98 / 最坏交互少量交互直接取最大值P75 是 CrUX谷歌后台聚合全量用户数据之后才算的分位数web-vitals onINP前端 SDK 单用户采集收集当前用户本次页面所有有效交互交互少于 50 次 → INP 最慢那一次交互耗时交互≥50 次 → 近似p98剔除极端异常值每 50 个丢掉 1 个最差值这个值是单用户单次页面的指标不是分位数CrUX / Google Search ConsoleCWV 达标判定标准把成千上万不同用户上报的 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里面再预加载最优不抢占关键时间片❌ 分包粒度太碎大量小 chunkHTTP 开销抵消收益分包初衷不变的包长期缓存只更新改动代码 过度碎包后果缓存省下的流量抵不上多请求 多次 JS 解析的耗时。❌ 次屏 JS 加载后同步执行超大渲染逻辑依然产生长任务INP 没改善❌ 把首屏依赖的组件也拆出去导致首屏白屏、水合延迟六、落地实施步骤可直接排期梳理页面模块标记【首屏必渲染】/【次屏懒加载】清单代码改造次屏组件改为import() IntersectionObserver 触发修改打包配置配置 splitChunks/manualChunks 拆分 bundle增加空闲预加载可选提升滚动体验首屏非核心逻辑移到 requestIdleCallback消除长任务上线前 bundle 体积验证上线后监控 P75 INP 数据
返回列表