news 2026/9/6 7:34:17

落地首屏与次屏分层优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
落地首屏与次屏分层优化

核心思路:资源按 “是否首屏可见” 做边界拆分 + 异步懒加载非首屏代码 + 配合交互调度优化 INPINP(交互下延迟)这里从 320ms→262ms,本质是主线程减少首屏长任务,用户点击 / 输入时主线程不被大 JS 解析执行阻塞

一、先明确概念边界

  1. 首屏模块:首视口内渲染、初始化必须立刻执行的代码(顶部导航、首屏核心图文、首屏交互按钮)
  2. 次屏模块:首视口以外、滚动后才可见的模块(下方列表、弹窗、tab、图表、次要组件)
  3. 模块拆包:把打包产物从单 bundle 拆成main.[hash].js(首屏 bundle) + 多个异步 chunk(次屏模块)
  4. 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.default
  • default:模块默认导出
  • : 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变更导致缓存失效 } }
字段拆解
  1. optimizationwebpack 的优化相关配置,专门用来做打包产物优化、代码分割、压缩等,不属于基础五大项。
  2. splitChunks内置的公共代码分割能力,自动把复用的代码抽成独立 chunk,实现按需加载 + 浏览器长缓存
  • chunks: 'all':同时对同步代码 和 异步 import 懒加载代码都做分割(可选值:initial只同步、async只异步、all全部)
  • cacheGroups分包规则组,自定义怎么划分 chunk,优先级高的规则先匹配
    • vendor
      • test: /[\\/]node_modules[\\/]/:匹配 node_modules 里的文件(第三方库 vue/axios 等)
      • priority:10:优先级 10,数字越大越优先匹配
    • secondary
      • test: /src\/views\/secondary\//:匹配src/views/secondary下的业务页面模块
      • priority:5:优先级低于 vendor ✅ 效果:secondary 页面代码会单独打包成独立 chunk,不会打包进 main 主包,一般配合路由懒加载 / 前面写的 IntersectionObserver 懒导入使用
  1. runtimeChunk: 'single'把 webpackruntime 运行时代码(模块加载、模块映射的胶水代码)单独抽成一个独立 runtime chunk。

坑点:如果 runtime 和业务代码打包在一起,业务 chunk 内容改动时,runtime 的 hash 会跟着变,浏览器缓存直接失效。抽离 runtime 可以稳定第三方包的 hash,充分利用长效缓存

  1. 模块命中/node_modules/→ vendor
  2. 否则,命中src/views/secondary/→ secondary
  3. 其他异步模块 → 自动单独拆成独立 chunk
  4. 其他同步业务代码 → 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 + axios
  • secondary.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' } }

重要坑点(高频面试)

  1. manualChunks对象写法不能写正则!只能精确指定模块
  2. Vite 默认动态import()本身就会自动分包,manualChunks强制归类合并
  3. Rollup 没有 webpack 那种 runtimeChunk,不存在 “runtime 代码污染 hash” 的问题
  4. 如果多个入口都引入 vue,写vendor:['vue']会把 vue 提取到公共 vendor

指标达成逻辑:原本所有业务代码都在 main 包,拆分后次屏代码移出 main →首屏 JS 体积下降 25%

3. 分层加载时机精细化(3 种可选策略)

  1. 可视懒加载(推荐):IntersectionObserver,元素进入视口才加载次屏 JS(最稳妥,严格分层)
  2. 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监听
  3. 浏览器空闲预加载requestIdleCallback,首屏渲染完成、主线程空闲时提前拉次屏 chunk,滚动过来直接渲染
  4. <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>
  5. 交互触发加载:点击 tab / 展开弹窗时才加载对应代码(适合非默认展示的组件)
  6. <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 衡量用户交互时主线程是否被长任务阻塞(你点一下 / 敲键盘,页面多久才给出视觉反应,单纯拆包不够,还要配套:

  1. 首屏 JS 拆解后,减少长任务:大 JS 解析、编译、执行属于主线程长任务,体积降 25% 直接缩短长任务耗时
  2. 非紧急任务延后:首屏初始化逻辑里,把统计、埋点、次要初始化丢到requestIdleCallback
  3. 大计算逻辑 Web Worker:次屏如果有数据格式化、筛选计算,丢 Worker,不占用主线程
  4. 避免长任务抢占:如果次屏 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 代码

  1. 删除空格、换行、注释
  2. 变量名短命名(const userInfoconst a
  3. 删除无效代码(死代码)
  4. 移除 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禁止长期强缓存,推荐两种方案二选一:

  1. 协商缓存(ETag / Last-Modified):浏览器每次发请求,服务器判断 html 有没有更新,更新才返回新内容,否则 304
  2. 短 max-age:Cache-Control: max-age=60(1 分钟)

❗ 重点区分:js/css/img 等带 contenthash 的静态资源 → 长 max-age;html 绝对不能长缓存

CDN 层面缓存问题(非常多线上踩坑)

即使 html 配置没问题,CDN 也可能卡住旧资源:

  1. CDN 缓存了旧的 html源站 header 改了,但 CDN 节点还存着旧 html,回源不及时 ✅ 排查:看请求的X-Cache字段(HIT = 命中 CDN 缓存,MISS = 回源) ✅ 处理:CDN 强制刷新 html,配置 html 不缓存 / 短缓存
  2. CDN 缓存 key、忽略参数、缓存规则异常
  3. 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':每个入口单独生成 runtime
  • runtimeChunk: '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 下降)

  1. 首屏 JS 体积
    • Webpack:webpack-bundle-analyzer看 main bundle 大小
    • Vite:rollup-visualizer
    • 对比拆分前后首屏加载的 JS 总大小(不含异步后续加载的 chunk)
  2. INP 指标采集:使用 web-vitals 上报真实用户指标,统计 P75 分位值
import { onINP } from 'web-vitals' onINP((metric) => { // 上报 metric.value 到监控平台 })

onINP回调里的metric.value ≠ p75

web-vitals 上报的是当前这个用户本次页面会话内的 INP 值(近似 p98 / 最坏交互,少量交互直接取最大值)P75 是 CrUX(谷歌后台聚合全量用户数据之后)才算的分位数

  1. web-vitals onINP(前端 SDK 单用户采集)
  • 收集当前用户本次页面所有有效交互
  • 交互少于 50 次 → INP = 最慢那一次交互耗时
  • 交互≥50 次 → 近似p98(剔除极端异常值,每 50 个丢掉 1 个最差值)
  • 这个值是单用户单次页面的指标,不是分位数
  1. CrUX / Google Search Console(CWV 达标判定标准)
  • 成千上万不同用户上报的 INP 数据聚合
  • 然后计算p75作为评估基准:要求 p75 ≤200ms 才算绿色合格

很多人混淆:SDK 采集单用户 INP,你自己的监控后台拿到大量样本之后,需要你自己再聚合算 p75,才和谷歌 CWV 口径对齐

前端只需要通过 web-vitals 采集原始指标并上报原始数据,p75 这类分位数聚合不需要前端实现,由后端或者 RUM 监控平台基于全量上报数据统一计算。前端不适合做跨用户的数据聚合,缺少全量样本还会带来性能和数据丢失问题。

五、常见坑

  • ❌ 动态 import 但是提前预加载时机过早,反而抢占首屏带宽,INP 变差

INP 衡量的是用户交互(点击 / 按键)到浏览器真正响应的延迟,瓶颈分两类:网络阻塞主线程阻塞,预加载过早两个坑都会踩。

正确时机:首屏核心资源加载完毕后,再去预加载后续才会用到的异步 chunk(比如非首屏弹窗、二级路由)

满足其中一个再触发import()预加载:

  1. 首屏核心资源、LCP 资源加载完成后(可以监听load/web-vitals LCP 回调)
  2. 用户有预判行为:hover 下一步按钮、滚动即将进入下一视图(IntersectionObserver)
  3. 浏览器空闲时:requestIdleCallback里面再预加载(最优,不抢占关键时间片)
  • ❌ 分包粒度太碎,大量小 chunk,HTTP 开销抵消收益

分包初衷:不变的包长期缓存,只更新改动代码; 过度碎包后果:缓存省下的流量,抵不上多请求 + 多次 JS 解析的耗时。

  • ❌ 次屏 JS 加载后同步执行超大渲染逻辑,依然产生长任务,INP 没改善
  • ❌ 把首屏依赖的组件也拆出去,导致首屏白屏、水合延迟

六、落地实施步骤(可直接排期)

  1. 梳理页面模块:标记【首屏必渲染】/【次屏懒加载】清单
  2. 代码改造:次屏组件改为import()+ IntersectionObserver 触发
  3. 修改打包配置,配置 splitChunks/manualChunks 拆分 bundle
  4. 增加空闲预加载(可选,提升滚动体验)
  5. 首屏非核心逻辑移到 requestIdleCallback,消除长任务
  6. 上线前 bundle 体积验证,上线后监控 P75 INP 数据
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 7:33:37

基于LiveKit与Grok构建实时语音智能体的完整指南

语音智能体是今年智能体方向上热度上升最快的一条线&#xff0c;核心原因是交互方式变了——从“打字对话”变成“开口对话”&#xff0c;用户门槛低了很多&#xff0c;应用想象空间也大了很多。这次我们来看一条工程上能直接落地的集成路线&#xff1a;用 LiveKit 承载实时音视…

作者头像 李华
网站建设 2026/8/31 19:38:52

蓝桥杯单片机大赛备赛指南:从基础外设到系统设计的实战解析

1. 从零到一&#xff1a;我眼中的蓝桥杯单片机大赛如果你是一名电子信息、自动化、计算机或者相关工科专业的学生&#xff0c;并且对嵌入式开发、硬件编程有那么一点兴趣&#xff0c;那么“蓝桥杯单片机大赛”这个名字&#xff0c;你大概率不会陌生。它就像是我们这个圈子里的一…

作者头像 李华
网站建设 2026/8/31 16:06:54

皮尔逊相关系数:从数学原理到实战应用,避开数据分析中的常见陷阱

1. 项目概述&#xff1a;从“感觉相关”到“量化相关”在数据分析、机器学习甚至是日常的业务决策中&#xff0c;我们经常听到“这两个变量看起来有关系”这样的说法。比如&#xff0c;广告投入和销售额是不是正相关&#xff1f;用户活跃时长和付费意愿有没有关联&#xff1f;气…

作者头像 李华
网站建设 2026/8/31 18:17:52

数据拟合与插值:从原理到实战,掌握两大核心数据分析工具

1. 从“猜”到“算”&#xff1a;数据拟合与插值的本质分野在数据处理和模型构建的路上&#xff0c;我们常常会拿到一堆离散的数据点。比如&#xff0c;每隔一小时记录的温度、不同广告投入下的销售额、或者实验仪器在不同参数下采集的读数。面对这些散点&#xff0c;我们脑子里…

作者头像 李华
网站建设 2026/8/31 22:38:53

CD-ROM老软件如何在Windows 11上运行:虚拟机重建运行环境实战

打开柜子深处那个积灰的快递盒时&#xff0c;我没想到里面会躺着一张 1998 年的 CD-ROM 世界地图集光盘。盒子上印着 Windows 95/98 的字样&#xff0c;封面的世界地图渲染风格还带着那个年代独有的鲜艳色块。家里早就没有带光驱的旧电脑了&#xff0c;手边只有一台 Windows 11…

作者头像 李华