Vue 异步组件:把不该出现在首屏的重模块请出去

首屏慢不一定是页面内容太多,很多时候只是把“不需要立刻出现的组件”也跟着主包一起塞进来了。用户刚进页面时只想看商品名称、价格和主图,结果浏览器却先去下载一个只有点了“编辑详情”才会用到的富文本编辑器,这种账怎么算都不合适。

这次详情页卡顿就是这么来的。Network 一拉,主包体积直接被一个重编辑器拖上去将近 400KB,问题不在功能本身,而在它出现的时机。组件很重要,但不重要到要在用户进入页面的第一秒就付下载成本。

后面会从这个“重组件被错误放进首屏”的现场出发,把 Vue 异步组件、loading/error、路由懒加载和 chunk 拆分的判断标准一起理清楚。

先看清楚:一个从来不需要马上出现的组件

打开 Network 面板,我先确认了一件事:用户进入详情页的第一秒,到底需要什么。

答案很简单:商品名称、价格、库存、主图、购买按钮。这些是首屏必须立即拿到的内容。至于"图文详情"里那个富文本编辑器,只有商家点击"编辑详情"按钮、要修改图文说明时才会用到——绝大多数打开详情页的人根本不会点这个按钮,他们只是来看商品的。可我们的写法是老实巴交地在页面组件里 import 了编辑器,跟其他基础信息组件注册在了一起,于是它跟着主包一起,无差别地塞给了每一个打开详情页的人。

我把这次事故里冒出来的组件摆在一起看,发现它们有一个共同点:体积不小、首屏不一定会用到、打开频率不高、和核心浏览路径解耦。除了富文本编辑器,详情页里其实还藏着别的同类角色——那个"查看历史价格走势"的小图表,还有"申请退换货"用的复杂弹窗,都是同一批"重要但不一定马上重要"的东西。

这些组件的价值都不小:富文本编辑器很重要,但只有点了"编辑"才需要;价格走势图很重要,但用户进入页面时大概率先看的是价格本身而不是历史曲线;退换货弹窗很重要,但默认根本不显示,没必要拖慢首屏。它们的共性摆在一起,答案就很清楚了:不是这些组件不该存在,是它们不该在首屏就存在。

这不只是一次性能优化,是一次页面结构的重新审视

我把排查结果拿去跟前端负责人汇报,他听完问了我一句:"这个坑我们不是应该在需求评审时就想到吗?"这话点醒了我——很多人(包括上周的我)把异步组件理解成"出了问题再补的性能优化",其实它更应该是需求设计阶段就该想清楚的事。

当你决定某个模块该不该同步加载时,本质上是在回答一个产品级问题:哪些内容必须先到,哪些内容可以后到。这已经不是单纯的打包问题,而是页面结构判断。

如果一个页面所有能力都同步加载,本质上是在说"所有模块同等重要"。真实项目里这通常不成立。首屏商品信息、低频编辑弹窗、价格走势图、导出工具,优先级本来就该不同。我们详情页的问题,说到底就是从来没人在写代码时问过这句话,编辑器功能是顺手加进页面组件的 import 列表里的,没人多想一秒。

动手第一步:把编辑器请出首屏

道理想清楚了,落地很直接。Vue 里的写法:

1components: {
2  RichEditor: () => import('./RichEditor.vue'),
3},

就这一行改动,编辑器从"页面必须携带的行李"变成了"点击时才去取的东西"。写法很简单,但背后的意义很明确:这个组件不是页面一启动就必须拿到的。

这一行改动能生效,靠的是 import() 这个动态导入语法。它跟文件顶部那种 import RichEditor from './RichEditor.vue' 的静态导入是两回事:静态 import 是编译期就确定的依赖,会被无条件打进引用它的那个 chunk 里;而 import() 是一个运行时才执行的函数调用,返回一个 Promise,Webpack 看到它就知道"这是一个分割点",会把这个模块和它的依赖单独切成一个 chunk,等这个函数真正被调用时才发请求去下载。Vue 的异步组件恰好接受"一个返回 Promise 的函数"这种形态,() => import('./RichEditor.vue') 正好对上——组件第一次要渲染时,Vue 才去调用这个函数,触发下载。这个动态 import() 当时还只是 TC39 的一个提案(stage 3),浏览器原生还没普遍支持,但 Webpack 早就把它当成代码分割的入口来识别了,所以我们能放心用。

配合 Webpack 时,我给这个 chunk 起了个稳定名字:

1components: {
2  RichEditor: () =>
3    import(/* webpackChunkName: "rich-editor" */ './RichEditor.vue'),
4},

那段 /* webpackChunkName: "rich-editor" */ 就是 Webpack 的"魔法注释"(magic comment)。它写在 import() 括号内、被当成普通注释,但 Webpack 会专门解析它。除了 webpackChunkName,同一个位置还能塞别的魔法注释,比如后面会用到的 webpackPrefetchwebpackPreload,甚至 webpackMode。它们不是 JS 语法的一部分,纯粹是给打包器看的元信息,换成别的构建工具就不认了。这不是为了好看,而是方便构建分析和线上排查。你能从产物名里看出这个 chunk 属于哪个功能域。

我吃过不写 chunk 名的亏。早期项目里几十个 import() 全用默认命名,打包出来一堆 0.js1.js23.js。这次排查富文本编辑器体积时,我先想确认它到底占了多少,对着 Network 面板里一排数字文件名完全对不上号,只能靠体积和加载时机连蒙带猜。后来全部补上 webpackChunkName,产物变成 rich-editor.a1b2c3.js 这种,一眼就知道是哪个功能。更实际的好处是配合 webpack-bundle-analyzer

1# 打包时生成体积报告
2npx webpack-bundle-analyzer dist/stats.json

打开那张色块图,主包体积暴涨的账一下就对上了——那个富文本编辑器不仅自己体积不小,还顺带把一个完整的图片上传裁剪库也一起打了进来,而详情页里图文说明其实极少用到裁剪功能。这块我先记下来,列进了下一步要单独抠的清单。

还有个小技巧:多个相关的异步组件可以共用同一个 chunk 名,Webpack 会把它们合并打包。比如富文本编辑器和它配套的图片上传面板总是一起用,给它们同一个 rich-editor 名,就能避免拆得太碎、请求太多。命名本身就是一种分组策略。

拆出去的公共依赖,得有人接住

那张色块图还暴露了另一个问题:富文本编辑器这个 chunk 里,除了编辑器本身,还夹带了一份 lodash 和一份日期处理库,而这两个库主包里也有一份。也就是说同一份代码被打进了两个 chunk,用户如果既加载了主包又点开了编辑器,等于把 lodash 下载了两遍。

这就得动到 Webpack 4 的 optimization.splitChunks。Webpack 4 之前这块要靠 CommonsChunkPlugin 手动配,写起来别扭;4 之后换成了 splitChunks,默认策略已经会自动把 node_modules 里的第三方库抽成 vendors chunk,但默认只对"按需加载的 chunk"和"初始 chunk"分别生效,跨类型的公共依赖不一定会被提出来。我按项目情况显式配了一下:

1// vue.config.js 里通过 chainWebpack 或 configureWebpack 调整
2module.exports = {
3  configureWebpack: {
4    optimization: {
5      splitChunks: {
6        chunks: 'all', // 关键:让初始 chunk 和异步 chunk 的公共依赖都参与提取
7        cacheGroups: {
8          // 把体积大、变动少的第三方库单独抽一组,长期缓存
9          vendors: {
10            test: /[\\/]node_modules[\\/]/,
11            name: 'chunk-vendors',
12            priority: 10,
13            chunks: 'initial',
14          },
15          // 被两个以上 chunk 共用的模块,抽成公共 chunk
16          common: {
17            name: 'chunk-common',
18            minChunks: 2,
19            priority: 5,
20            chunks: 'all',
21            reuseExistingChunk: true,
22          },
23        },
24      },
25    },
26  },
27};

chunks: 'all' 是这里的关键。改成 all 之后,编辑器 chunk 里那份重复的 lodash 被提到了公共 chunk,编辑器自己的体积又瘦了一圈。不过这也不是配得越细越好——cacheGroups 分得太碎,页面初始加载要拉的文件数又上去了,跟异步拆分一样存在"拆过头反而变慢"的临界点。我的做法是先按"第三方库/公共业务代码/页面各自的代码"这三层粗粒度切,跑一遍 analyzer 看效果,再决定要不要细调,不上来就堆一堆 cacheGroups

顺带一提,把 chunk-vendors 这类第三方库单独拆出来还有个缓存上的好处:业务代码几乎天天改,但 lodashvue 这些库版本很少动。拆开之后,我们发一次版只有业务 chunk 的 hash 变了,chunk-vendors 的 hash 不变,用户浏览器里缓存的那份第三方库就不用重新下载。合在一起打包时,改一行业务代码就会让整个大包 hash 变化,用户每次都得重下几百 KB,很亏。

光是异步还不够:测试同学的一条反馈

改完发到测试环境,我以为这事就结了。结果测试同学第二天反馈了个新问题:"我点'编辑详情',按钮点了没反应,等了两三秒编辑器才出来,我还以为点击失败了,点了三下。"

这个反馈点出了异步组件最容易被忽视的一面:拆出去只是解决了"首屏别背这个包袱",但用户点击那一刻,总要有个说法——加载中显示什么,加载失败了怎么办。专业一点的异步组件,不应该只写一个 import()

用户点开低频模块时,如果网络慢、chunk 加载失败或部署期间资源缓存不一致,都可能看到空白。Vue 2 支持更完整的异步组件配置:

1import LoadingBlock from './LoadingBlock.vue';
2import LoadError from './LoadError.vue';
3
4function createAsyncComponent(loader) {
5  return () => ({
6    component: loader().catch((error) => {
7      console.error('异步组件加载失败:', error);
8      throw error;
9    }),
10    loading: LoadingBlock,
11    error: LoadError,
12    delay: 200,
13    timeout: 10000,
14  });
15}
16
17export default {
18  components: {
19    RichEditor: createAsyncComponent(() =>
20      import(/* webpackChunkName: "rich-editor" */ './RichEditor.vue')
21    ),
22  },
23};

这几个字段各有各的职责:loading 指定加载过程中先顶上的占位组件,避免用户误以为点击无效;error 是加载失败或超时后展示的组件,给出明确的失败状态;delay 控制"加载超过多少毫秒才显示 loading",避免极快加载时闪一下;timeout 是超过多少毫秒还没加载完就判定失败、切到 error,避免无限等待。这套"高级异步组件"配置是 Vue 2.3 才加进来的,在这之前,异步组件只有最朴素的两种写法。

其中一种是更早的回调式,用 Webpack 的 require.ensure 或者 AMD 风格的 require([...])

1// Vue 2.3 之前的老写法,现在偶尔还能在老项目里见到
2components: {
3  RichEditor: function (resolve) {
4    // Webpack 会把 require 的这个模块单独打成一个 chunk
5    require(['./RichEditor.vue'], resolve);
6  },
7},

另一种就是前面用的 () => import() Promise 写法,它比回调式清爽,也是 2019 年我们默认的选择。但 Promise 写法有个短板:它只能表达"成功/失败",表达不了"加载中显示什么、超时算失败"。所以真要做完整的加载体验,就得回到上面那个返回配置对象的工厂函数——它把 import() 塞进 component 字段,同时把 loadingerrordelaytimeout 一起交给 Vue。理解了这几种写法的演进,就知道那个看着有点绕的工厂函数不是炫技,而是唯一能同时管住"下载、加载态、失败态"这三件事的形态。

delay: 200 这个值我是踩坑之后才认真设的。一开始 delay 用默认的 200ms 我没在意,结果在内网测试环境里 chunk 加载飞快,loading 组件还没来得及显示就被替换掉了,反而出现了一帧的闪烁。本地网络好的时候,"加载中"这个状态如果只存在几十毫秒,对用户来说就是一道闪光,体验比不显示还差。delay 的意思是"加载超过这个时间才显示 loading",所以设大一点(200~300ms),快的时候直接出结果不闪,慢的时候才给反馈,是更舒服的折中。

error 状态这块,最值得做的是给用户一个"重试"按钮,而不是干巴巴一句"加载失败"。chunk 加载失败十有八九是网络抖动或者刚发版导致旧 chunk 的 hash 找不到了,刷新或重试基本能解决:

1// LoadError.vue 里
2methods: {
3  retry() {
4    // 这里偷懒用了整页刷新兜底,不是只重新触发这一个组件的 import
5    // 好处是简单粗暴、必然有效;代价是页面上其它状态也会跟着清空
6    window.location.reload();
7  }
8}

这个 retry 我一开始想做得精致一点——只重新执行那一次失败的 import(),别的组件和状态都保持不动。但真去写才发现没那么简单:光是重新 import() 还不够,因为浏览器和 webpack 对失败过的 chunk 请求有缓存策略,同一个地址再请求一次不一定会真的发出网络请求;要保证一定能拿到最新资源,还得配合改 URL 加时间戳之类的手段绕过缓存,工作量和收益不成正比。对编辑器这种低频弹出的模块,我最后就用了 window.location.reload()——一次整页刷新,简单粗暴,但保证一定管用。这是个明确的取舍:牺牲"只刷新这一小块"的精致,换"重试一定成功"的确定性,用户体验上,弹一次全页刷新总比卡在一个永远加载失败的错误页强。

发版导致的 chunk 404 是个高频问题,值得单独说。我们用 hash 命名 chunk,发新版后旧的 rich-editor.abc.js 文件名变了,老用户页面还停留在旧版本,这时去点"编辑详情",浏览器请求旧 hash 的文件,CDN 上已经没有了,直接报 ChunkLoadError。这种错误光靠 error 组件兜底体验一般,更彻底的做法是全局捕获这类错误后提示用户"页面有更新,请刷新",自动 reload——这跟上面 retry 用的是同一个手段,都是用整页刷新去解决"局部资源对不上"的问题,而不是假装能做到无感的局部恢复。所以我说异步加载是性能优化,也是状态设计——你拆出去的每一个 chunk,都多了一条可能失败的网络请求,这些失败路径都得有人接住,接得住不代表接得完美,有时候诚实地告诉用户"要刷新一下"就是最简单可靠的答案。

路由级和组件级要分清

编辑器这块改完,我心里有点没底:详情页里是不是还藏着别的没懒加载的东西?我干脆把项目的路由表也翻了一遍,发现问题比想象中更普遍——不只是组件级的懒加载没做全,我们几十个页面级路由压根就没拆过,全部同步 import 在一个路由文件里,首屏进来连"从来不会打开的会员等级设置页"这种页面的代码都一起下载了。

这就得先分清两个层级。路由懒加载适合拆整个页面:

1const MemberLevelPage = () =>
2  import(/* webpackChunkName: "member-level" */ '@/views/member/LevelPage.vue');

这是 Vue Router 官方就支持的写法,路由表里的 component 直接换成这种动态 import 函数,跳转到这个路由时才会去下载对应 chunk,跟组件异步加载是同一套 webpack 能力,只是挂载的位置从组件树换成了路由表。而组件异步加载适合拆页面内部的重模块,就像我们的富文本编辑器:

1components: {
2  RichEditor: () =>
3    import(/* webpackChunkName: "rich-editor" */ './RichEditor.vue'),
4},

两者怎么选,我给自己定的标准是看"粒度":一整个页面用户不常去,就在路由层拆;一个页面里只有某个模块不常用,其余部分照常展示,就在组件层拆。商品详情页本身是高频页面,路由不能懒加载,但页面里的编辑器是低频模块,适合组件级异步化;会员等级设置整个页面都是低频功能,直接在路由表里懒加载更省事,没必要进去了再单独抠某个组件。

顺着这条线,我把路由表里明显低频的十几个管理页、设置页全部改成了懒加载写法,主包体积又往下掉了一截——这部分甚至比编辑器那一个组件砍下来的还多,毕竟被忽视的路由比被忽视的组件多得多。

不要把所有小组件都异步化。拆分太细会带来更多请求、更多加载状态和更复杂的错误处理。合理的拆分单位通常是"页面""低频重模块""独立业务能力",而不是每个按钮、每个卡片。

预取和预加载要看用户路径

编辑器和路由都拆完了,我又想到一个问题:商品列表页打开后,用户大概率会点进详情页,那详情页的 chunk 能不能提前偷偷下载好,等用户真点进去时秒开?这就是预取的思路,但预取不是把所有异步资源都提前拉下来。

1const ProductDetailPage = () =>
2  import(
3    /* webpackChunkName: "product-detail", webpackPrefetch: true */
4    '@/views/product/DetailPage.vue'
5  );

预取适合"高概率下一步",不适合"也许某天会用到"。否则异步组件省下来的首屏资源,又会被浏览器空闲时全部拿回来,绕了一圈等于白拆。

这里要分清 webpackPrefetchwebpackPreload 两个魔法注释,它们生成的 <link> 优先级完全不同。prefetch 生成 <link rel="prefetch">,浏览器会等当前页面加载完、空闲时才偷偷把资源拉到缓存里,优先级很低,不抢首屏带宽——适合"下一步可能用"。preload 生成 <link rel="preload">,是高优先级、和当前资源并行加载,适合"当前页面马上就要用、但声明得比较晚"的资源,用错了会直接拖慢首屏。我的默认选择是 prefetch,preload 几乎不用——因为真要马上用的东西,我直接同步引入就好了,没必要异步。

判断"高概率下一步"不能拍脑袋,要看真实数据。我给商品列表的详情 chunk 先加了这个 prefetch,上线后看埋点,才发现列表到详情的点击转化其实只有不到两成,大量用户翻着列表根本不点进去,prefetch 等于白白替八成用户下载了用不上的代码,移动端还费流量。后来我改成只在用户 hover 列表项超过一定时间、或者鼠标移到"查看详情"按钮上时,再用动态创建 <link rel="prefetch"> 的方式按需预取,命中率高多了。所以预取这件事,"该不该预取"先看转化数据,"什么时候触发"再看交互信号,不是加个注释就万事大吉。

什么时候不该异步化

有些组件不适合异步加载:

  • 首屏核心内容
  • 页面骨架和主要导航
  • 用户每次都必须立即操作的组件
  • 体积很小但被频繁使用的基础组件

如果一个组件延迟加载后会让页面出现明显跳动,或者用户必须等它加载完才能完成主流程,那就要谨慎。性能优化不能以破坏关键路径为代价。

这里其实藏着一个"首屏拆分"的根本取舍:异步化省的是首屏要下载和解析的 JS 体积,让第一屏更快出内容;但它换来的是"用户点到那个功能时,得多等一次网络往返"。这两头是矛盾的。把东西都塞进主包,首屏最慢但后续交互零延迟;把东西都拆成异步,首屏最快但每个交互都可能卡一下加载。所以判断标准始终围着"这块内容用户第一秒需不需要、需要的概率有多高"转——首屏必看的、点击后必须立刻响应的,宁可让首屏背着;进来大概率不碰的低频功能,才值得用一次往返的延迟去换首屏体积。我们详情页真正要优化的是"打开就白屏两三秒"这个首屏问题,而不是"编辑器点开慢半秒",所以把编辑器请出首屏是划算的;反过来,如果哪天有个功能是每个用户进来第一件事就要用的,我再心疼首屏体积也不会去异步它。

布局跳动是异步组件最容易被忽视的副作用,我们的编辑器区域一开始就撞上了这个问题。组件没加载出来时占 0 高度,加载完突然撑开,下面的"保存""取消"按钮被推下去一大截,商家正准备点保存,页面一跳,点空了。解决办法是给异步区域一个稳定的占位骨架,loading 组件的尺寸尽量接近真实组件:

1<!-- LoadingBlock.vue:用和编辑器差不多的高度占位 -->
2<template>
3  <div class="editor-skeleton" style="height: 320px;">
4    <div class="skeleton-shimmer"></div>
5  </div>
6</template>

提前把高度占住,加载完成时内容是"填进去"而不是"撑出来",视觉上稳得多。这个细节做不做,体验差很远——测试同学后来又反馈过一次"保存按钮跳来跳去",就是这个问题,补上占位骨架之后才彻底消停。

别拆过头:分寸感这件事

拆完富文本编辑器,我一度上头,动了把详情页里所有子组件都异步化的念头——评价列表组件、店铺信息卡片、甚至价格数字这种巴掌大的展示组件,都想包一层 () => import()。前端负责人看了我的分支提了个醒:"你这页面首屏得发多少个 chunk 请求?"我数了一下,十几个。HTTP/1.1 下浏览器对同域名的并发连接数是有限的,这么多请求一起排队,反而比原来同步打包还慢,各个模块的 loading 状态还此起彼伏地闪,比之前更乱。

我把那些巴掌大的组件全改了回去。异步加载省的是"现在不用的代码的下载和解析时间",但每次拆分都附带固定成本:一次网络往返、一个 loading 状态、一份错误处理。当一个组件体积很小(比如几 KB)时,省下的下载时间还不够付这些成本的。我给自己定的经验阈值大概是:单个 chunk 至少几十 KB、且确实不在首屏关键路径上,异步化才划算。拆分单位停在"页面级路由"和"低频重模块"这两层,绝大多数项目就够了,再往下拆基本是负优化。

有人会说 HTTP/2 的多路复用不是解决了并发连接数的问题吗,拆多少个都能在一个连接上并行传,不用怕请求排队。这话有一半道理——HTTP/2 确实把"同域名并发连接受限"这条约束松开了不少。但我们的实际情况是:一来 CDN 和内网环境是不是全链路都上了 HTTP/2 得逐个确认,不能想当然;二来就算传输层不排队,每个 chunk 该有的成本一个也没少——浏览器还是要为每个模块单独解析、执行、维护它的加载与错误状态,这些和用什么协议无关。所以我没有因为"反正有 HTTP/2"就放开手拆,拆分的分寸感该守还得守。

回到国庆后的那个投诉

这周五我把富文本编辑器、价格走势图、退换货弹窗几个重模块全部改成了异步组件,配好 loading、error、delay、timeout,路由表里十几个低频页面也补上了懒加载,还给列表到详情的跳转按交互信号做了按需 prefetch。周五晚上我在门店那台老电脑上远程连线试了一遍,商品详情页打开的白屏时间从两三秒回到了国庆前的水平,编辑器的加载状态也稳稳当当,没有闪烁和跳动。

我把这次排查过程整理成周报发给了前端负责人,最后写了一句话:**异步组件不该是出了事故才想起来的补救手段,而是写页面时就该问的第一个问题——这个模块,用户进来的第一秒到底需不需要。**下次评审需求时,我打算把"这个功能是否要首屏可见"也列进检查清单,不再等运营的投诉截图来提醒我。