第 31 章

包体积与加载性能:Tree-shaking 机制、代码分割与预加载策略

第31章:包体积与加载性能——Tree-shaking 机制、代码分割与预加载策略

Vue 3 在设计之初就刻意将所有 API 设计为具名导出(named exports)而不是挂载在全局 Vue 对象上,这个架构决策让 Tree-shaking 成为可能——一个只用了 refcomputed 的应用,打包产物中不会包含 TransitionKeepAliveTeleport 的任何代码。这个决策将 Vue 3 的最小运行时从 Vue 2 的约 33KB 压缩到了约 16KB(gzip)。

本章核心问题:如何从包体积和加载策略两个维度系统性地优化 Vue 3 应用的性能,使首屏加载时间从数秒降低到 1 秒以内?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

1.1 为什么包体积是性能的第一道门槛

网络下载是加载性能的最大变量。在 4G 网络(平均 10 Mbps 下行)条件下:

包大小(gzip) 下载时间 用户感知
< 50KB < 40ms 几乎无感
100KB ~80ms 尚可接受
300KB ~240ms 明显等待
1MB ~800ms 用户开始流失
2MB ~1.6s 严重问题

**首屏资源(Critical Path)**是最敏感的:用户打开页面,浏览器必须下载、解析并执行完首屏的所有 JavaScript,页面才能可交互(TTI,Time to Interactive)。每多 100KB 的首屏 JS,在中端手机上约增加 300ms 的 TTI。

1.2 Vue 3 的 Tree-shaking 友好设计

Vue 3 的所有 API 都是具名导出

// Vue 3:按需导入
import { ref, computed, reactive, watch, onMounted } from 'vue';

// Vue 2:从全局对象使用
import Vue from 'vue';
Vue.observable({ count: 0 }); // 这种方式无法 Tree-shake

打包工具(Rollup/Vite)在静态分析时,发现某个导出未被引用,会将其标记为"dead code"并从产物中移除。

实际效果对比

// 一个只使用 ref 的应用
import { ref } from 'vue';
const count = ref(0);

打包产物中只包含 ref 的实现代码,不包含 reactivecomputedwatchTransitionKeepAlive 等未用到的功能。

Vue 3 各部分的大小参考(gzip 后):

1.3 Vite 的代码分割策略

路由级代码分割(最重要的分割策略):

// router/index.js
import { createRouter } from 'vue-router';

const routes = [
  {
    path: '/',
    component: () => import('./views/Home.vue'),       // 懒加载
  },
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue'), // 懒加载
  },
  {
    path: '/settings',
    component: () => import('./views/Settings.vue'),  // 懒加载
  },
];

Vite 会将每个动态 import() 分割为独立的 chunk(块),用户访问哪个路由,才下载那个路由的代码。

组件级代码分割(大型低频组件):

import { defineAsyncComponent } from 'vue';

// 适合:大型图表组件、富文本编辑器、PDF 阅读器等
const RichEditor = defineAsyncComponent({
  loader: () => import('./components/RichEditor.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,      // 200ms 后显示 loading(防止短暂闪烁)
  timeout: 10000,  // 10s 超时
});

第三方库分割manualChunks 配置):

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将大型第三方库单独分包
          'vendor-echarts': ['echarts'],
          'vendor-lodash': ['lodash-es'],
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 这样浏览器可以缓存这些稳定的库,应用更新时无需重新下载
        }
      }
    }
  }
});

1.4 预加载策略:preload vs prefetch

<link rel="preload">:声明当前页面必须使用的资源,浏览器优先下载:

<!-- 预加载当前页面的主要 JS 包(最高优先级) -->
<link rel="preload" href="/assets/main.js" as="script">

<!-- 预加载当前页面用到的 Web Font -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>

<link rel="prefetch">:提示浏览器可能在未来需要的资源,在空闲时下载:

<!-- 预取下一个可能访问的路由 -->
<link rel="prefetch" href="/assets/Dashboard.js">

精确语义对比

特性 preload prefetch
优先级 高(当前页面需要) 低(未来可能需要)
时机 立即开始下载 浏览器空闲时下载
适用场景 当前页必需资源 下一页面的资源
缓存 存入浏览器缓存 存入浏览器缓存
不使用的影响 警告(浪费带宽) 无(被静默丢弃)

Vite 自动注入 modulepreload

Vite 在构建时会自动分析模块依赖图,为每个入口点的直接依赖生成 <link rel="modulepreload"> 标签。这是 ES 模块版本的 preload,让浏览器提前下载被依赖的模块:

<!-- Vite 自动生成(index.html) -->
<link rel="modulepreload" crossorigin href="/assets/vue.xxx.js">
<link rel="modulepreload" crossorigin href="/assets/pinia.xxx.js">
<script type="module" src="/assets/main.xxx.js"></script>

1.5 Bundle 分析工具

使用 rollup-plugin-visualizer 生成可视化的 Bundle 体积报告:

npm install rollup-plugin-visualizer --save-dev
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    visualizer({
      filename: './dist/stats.html',  // 输出报告文件
      open: true,                     // 构建完自动打开浏览器
      gzipSize: true,                 // 显示 gzip 后的大小
      brotliSize: true,               // 显示 brotli 压缩后的大小
      template: 'treemap',            // treemap 视图最直观
    }),
  ],
});

运行 npm run build 后,会自动打开 stats.html,以矩形树图的形式展示每个依赖的体积占比。

常见的体积异常来源

1.6 首屏性能指标

核心 Web 指标(Core Web Vitals)

测量工具

// 在 main.js 中监听性能事件
import { onCLS, onFID, onLCP, onFCP, onTTFB } from 'web-vitals';

onFCP(metric => console.log('FCP:', metric.value));
onLCP(metric => console.log('LCP:', metric.value));

// 上报到分析平台
onLCP(metric => {
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
  }));
});

Level 2 · 它是怎么运行的(3-5年经验)

2.1 Tree-shaking 的静态分析原理

Rollup/Vite 的 Tree-shaking 基于 ES Module 的静态结构——import/export 的依赖关系在代码运行前就可以确定:

模块依赖图分析:

entry.js
  └── import { ref, computed } from 'vue'
         └── vue/index.js (导出 ref, reactive, computed, watch, Transition...)
                ├── ref.js ← 被引用,保留
                ├── computed.js ← 被引用,保留
                ├── reactive.js ← 未被引用,标记为 dead code
                ├── watch.js ← 未被引用,标记为 dead code
                └── Transition.js ← 未被引用,标记为 dead code

Rollup 的标记算法(Mark-Sweep)

1. Mark(标记阶段):
   - 从入口文件开始,沿 import 链追踪
   - 标记所有被引用的导出为"alive"
   - 未被标记的导出为"dead code"

2. Shake(摇除阶段):
   - 移除所有标记为 dead code 的代码
   - 更新模块的导出列表

3. 合并(Bundle):
   - 将所有 alive 的代码合并到输出文件

什么会阻止 Tree-shaking?

问题1:副作用(Side Effects)

如果一个模块在导入时就执行了操作(不只是定义函数/值),打包工具必须保留它:

// bad-module.js(有副作用)
export const PI = 3.14;

// 模块顶层执行代码(副作用):
console.log('Module loaded'); // 这行在 import 时会执行
window.__myLib = {}; // 修改全局变量

// 即使只 import PI,打包工具也必须保留整个模块(因为副作用)
import { PI } from './bad-module.js';

在 package.json 中声明 sideEffects

// package.json(库的配置)
{
  "sideEffects": false
  // 告诉打包工具:这个包中所有模块都没有副作用,可以安全地 Tree-shake
}

// 或者白名单
{
  "sideEffects": [
    "*.css",
    "./src/polyfills.js"  // 这些有副作用,其他的没有
  ]
}

Vue 3 的 package.json 声明了 "sideEffects": false,这是 Vue 3 能被高效 Tree-shake 的关键配置之一。

问题2:动态导入(运行时决定)

// 阻止 Tree-shaking:导入的模块是运行时决定的
const featureName = getFeatureName(); // 运行时才知道
import(`./${featureName}.js`);         // 无法静态分析

问题3:CommonJS 模块

// CommonJS:无法静态分析
const lib = require('some-lib');
lib.someFunction(); // 打包工具不知道用了哪些

2.2 代码分割的权衡与策略

代码分割不是越多越好——每个额外的 HTTP 请求都有固定开销(约 50-100ms 的往返延迟)。需要在"并行下载"和"请求数量"之间找到平衡。

Vite 的默认分割策略

入口 chunk:main.js → 包含 Vue + Router + Pinia + App.vue
动态 chunk:每个 () => import() 的路由/组件 → 独立 chunk
vendor chunk:Vite 4+ 会自动将 node_modules 中的依赖分组

优化后的 manualChunks 策略

// vite.config.js
import { splitVendorChunkPlugin } from 'vite';

export default defineConfig({
  plugins: [vue(), splitVendorChunkPlugin()],
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // 根据模块路径分组
          if (id.includes('node_modules')) {
            // 大型单独包
            if (id.includes('echarts')) return 'vendor-echarts';
            if (id.includes('three')) return 'vendor-three';
            
            // 中型包归组
            if (id.includes('@vueuse')) return 'vendor-vueuse';
            if (id.includes('lodash')) return 'vendor-utils';
            
            // 核心框架
            if (id.includes('vue') || id.includes('pinia')) {
              return 'vendor-vue';
            }
          }
        }
      }
    }
  }
});

最佳实践:Echarts(~1MB 未压缩)单独分包,用户在首次访问图表页面时才下载。

2.3 图片优化:懒加载与格式选择

图片通常是 LCP 的主要来源。

IntersectionObserver 实现图片懒加载

<!-- 在 Vue 3 中自定义懒加载指令 -->
<script setup>
const imgRef = ref(null);
const isVisible = ref(false);

onMounted(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) {
        isVisible.value = true;
        observer.disconnect();
      }
    },
    { rootMargin: '200px' }  // 提前 200px 开始加载
  );
  observer.observe(imgRef.value);
});
</script>

<template>
  <img
    ref="imgRef"
    :src="isVisible ? actualSrc : placeholder"
    :alt="alt"
  />
</template>

现代图片格式的体积对比

同一张图片的体积(参考值):
- JPEG:100KB
- WebP:60KB(节省 40%)
- AVIF:40KB(节省 60%)

HTML 中使用 <picture> 提供多格式支持

<picture>
  <source type="image/avif" srcset="/image.avif">
  <source type="image/webp" srcset="/image.webp">
  <img src="/image.jpg" alt="描述" loading="lazy" decoding="async">
</picture>

浏览器会选择它支持的最优格式。

2.4 字体加载优化

Web Font 是首屏渲染的常见阻塞因素:

<!-- 1. 预加载最常用的字体变体 -->
<link rel="preload" href="/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>

<!-- 2. 使用 font-display: swap 防止 FOIT(不可见文本闪烁) -->
<style>
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-400.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;  /* 先显示系统字体,自定义字体加载完再切换 */
}
</style>

<!-- 3. 使用可变字体(Variable Font)减少请求数 -->
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-weight: 100 900;  /* 一个文件支持所有字重 */
}

2.5 import.meta.env:控制开发与生产行为

Vite 提供了 import.meta.env 用于在代码中区分构建环境:

// 只在开发环境运行的代码(生产构建时被摇掉)
if (import.meta.env.DEV) {
  console.log('开发环境:当前状态', state);
  installDevtools(app);
}

// 只在生产环境运行的代码
if (import.meta.env.PROD) {
  initMonitoring();
  enableAnalytics();
}

// 环境变量(从 .env 文件读取)
const API_URL = import.meta.env.VITE_API_URL;

在 Rollup Tree-shaking 中,import.meta.env.DEV 在生产构建中会被替换为 false,整个 if (false) { ... } 块会被移除,不进入产物。


Level 3 · 设计文档与源码(资深开发者)

3.1 Vue 3 的 Tree-shaking 架构设计

Vue 3 的 Tree-shaking 友好性不是偶然的,而是经过刻意的架构重构实现的。RFC 文档(vuejs/rfcs)中有详细说明:

Vue 2 的问题

// Vue 2:所有功能挂载在 Vue 对象上
import Vue from 'vue';

Vue.component('MyComponent', ...);  // 全局注册
Vue.directive('my-directive', ...); // 全局指令
Vue.filter('myFilter', ...);        // 全局过滤器

// 问题:即使应用不使用 Vue.filter,
// 打包工具无法从 Vue 对象中移除 filter 功能
// 因为无法静态分析 "Vue.xxx" 中哪些 xxx 被使用了

Vue 3 的解决方案

// Vue 3:所有功能都是具名导出
import {
  createApp,
  ref, reactive, computed,
  watch, watchEffect,
  onMounted, onUnmounted,
  // 按需导入,未导入的不进产物
} from 'vue';

// 内置组件也是具名导出(可选导入)
import { Transition, TransitionGroup, KeepAlive, Teleport } from 'vue';

Vue 3 的 package.json 关键配置

{
  "name": "vue",
  "sideEffects": false,
  "exports": {
    ".": {
      "import": "./dist/vue.esm-bundler.js",
      "require": "./dist/vue.cjs.js"
    }
  }
}

"sideEffects": false 是告诉 Webpack/Rollup 这个包可以安全 Tree-shake 的关键声明。

3.2 Vite 的预加载注入机制

Vite 在构建产物中通过 @rollup/plugin-dynamic-import-vars 和自定义插件自动注入 modulepreload 指令。

Vite 的模块预加载注入源码packages/vite/src/node/plugins/importAnalysisBuild.ts):

// 简化版:Vite 如何为动态 import 注入预加载
function generatePreloadCode(chunk: OutputChunk, modules: string[]) {
  // 生成预加载代码片段
  return `
    const deps = ${JSON.stringify(modules)};
    // __vitePreload 会在运行时将这些模块预取到浏览器缓存
    return __vitePreload(
      () => import(${JSON.stringify(chunk.fileName)}),
      true ? deps : void 0
    );
  `;
}

// __vitePreload 的运行时实现:
// 1. 检查哪些依赖还没有缓存
// 2. 创建 <link rel="modulepreload"> 标签
// 3. 等待依赖预加载完成
// 4. 执行实际的 import()

这意味着当用户执行 () => import('./views/Dashboard.vue') 时,Vite 编译的代码实际上会:

  1. 先注入 <link rel="modulepreload" href="/assets/some-dep.js"> 预加载 Dashboard 的依赖
  2. 再执行 import('/assets/Dashboard.js')

这大大减少了动态路由切换时的瀑布式请求。

3.3 HTTP/2 对代码分割策略的影响

HTTP/2 的多路复用(Multiplexing)消除了 HTTP/1.1 的连接限制,允许在同一 TCP 连接上并行请求多个文件,这改变了代码分割的最优策略:

HTTP/1.1 时代

HTTP/2 时代

检查服务器是否支持 HTTP/2

curl -I --http2 https://yourdomain.com
# 响应头应包含 HTTP/2 200

Nginx 启用 HTTP/2

server {
  listen 443 ssl http2;  # 添加 http2 参数
  # ...
}

3.4 关键 CSS 内联(Critical CSS)

对于首屏渲染最关键的 CSS,可以直接内联到 <head> 中,避免额外的 CSS 文件请求:

// Vite 插件:自动提取关键 CSS
import { createPlugin } from 'vite-plugin-critical';

export default defineConfig({
  plugins: [
    createPlugin({
      criticalConfig: {
        inline: true,           // 内联关键 CSS
        width: 1300,            // 视口宽度
        height: 900,            // 视口高度
        extract: true,          // 从原始 CSS 中移除已内联的部分
      }
    })
  ]
});

Level 4 · 边界与陷阱(全体适用)

陷阱 1:import * as 导入阻断 Tree-shaking

错误代码

// 错误:命名空间导入,打包工具无法知道用了哪些
import * as VueRouter from 'vue-router';

const { createRouter, useRoute } = VueRouter;
// 打包产物:整个 vue-router 都被打入包中!

正确代码

// 正确:具名导入,打包工具可以 Tree-shake
import { createRouter, useRoute } from 'vue-router';

特殊情况:即使用了具名导入,如果后续通过动态属性访问,也会阻止 Tree-shaking:

import { computed, ref, reactive } from 'vue';

// 错误:通过变量动态访问
const apis = { computed, ref, reactive };
const apiName = getApiName(); // 运行时才知道
apis[apiName]();              // 打包工具必须保留所有三个

陷阱 2:defineAsyncComponent 的 chunk 命名不可控

问题:Vite 默认以模块的哈希值命名 chunk,导致文件名不稳定:

// 动态 import 产生的 chunk 文件名:
// views/Dashboard-8f3a2b.js(哈希会随内容变化)
const Dashboard = () => import('./views/Dashboard.vue');

每次代码变化,即使 Dashboard.vue 本身没变,整个 chunk 的哈希也可能改变(因为依赖图变了),导致浏览器缓存失效。

解决方案:使用 Rollup 的 magic comment 指定 chunk 名称:

const Dashboard = () => import(
  /* webpackChunkName: "dashboard" */
  /* @vite-ignore */
  './views/Dashboard.vue'
);

// 或者在 vite.config.js 中配置 chunkFileNames
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[name]-[hash].js',
        // [name] 来自 manualChunks 的 key 或模块的文件名
      }
    }
  }
});

陷阱 3:preload 使用不当导致带宽浪费

错误代码

<!-- 错误:对所有路由的 chunk 都 preload -->
<link rel="preload" href="/assets/Home.js" as="script">
<link rel="preload" href="/assets/Dashboard.js" as="script">
<link rel="preload" href="/assets/Settings.js" as="script">
<link rel="preload" href="/assets/UserProfile.js" as="script">
<!-- 用户可能只访问其中一个路由,但所有文件都被下载了 -->

正确策略:只 preload 当前页面必需的资源,使用 prefetch 预取可能需要的资源:

// 在路由导航守卫中动态添加 prefetch
router.afterEach((to, from) => {
  // 根据当前路由,预取下一个最可能访问的路由
  if (to.name === 'home') {
    // 主页访问后,用户最可能访问 Dashboard
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = '/assets/Dashboard.js';
    document.head.appendChild(link);
  }
});

陷阱 4:manualChunks 循环依赖导致构建失败

问题场景

manualChunks: {
  'vendor-a': ['module-a'],
  'vendor-b': ['module-b'],
}

// 如果 module-a 依赖了 module-b,
// 而 module-b 被分配到另一个 chunk,
// Rollup 可能报错:循环依赖无法正确处理

排查方法

// 使用函数形式的 manualChunks 更灵活
manualChunks(id) {
  if (id.includes('module-a') || id.includes('module-b')) {
    // 将相互依赖的模块放到同一个 chunk
    return 'vendor-ab';
  }
}

陷阱 5:图片 loading="lazy" 与 LCP 的冲突

错误场景:对首屏的大图(LCP 候选元素)使用了 loading="lazy"

<!-- 错误:首屏 Hero 图片不应该懒加载 -->
<img 
  src="/hero-image.jpg" 
  loading="lazy"     <!-- 这会延迟 LCP! -->
  alt="产品展示"
>

loading="lazy" 会推迟图片的加载,直到图片进入视口。对于首屏的大图,这会直接推迟 LCP,让 LCP 指标恶化。

正确策略:只对折叠线以下的图片使用懒加载:

<!-- 首屏图片:主动预加载 -->
<link rel="preload" as="image" href="/hero-image.jpg">
<img src="/hero-image.jpg" fetchpriority="high" alt="产品展示">

<!-- 折叠线以下的图片:懒加载 -->
<img src="/below-fold.jpg" loading="lazy" alt="更多内容">

本章小结

  1. Tree-shaking 依赖静态分析:具名导入(import { ref })可以被 Tree-shake,命名空间导入(import * as Vue)和 CommonJS require() 无法被 Tree-shake;package.json 中的 "sideEffects": false 是库能被高效摇树的必要声明。

  2. 三层代码分割策略:路由级分割(动态 import())是必选,将初始包体积减少 60-80%;组件级分割(defineAsyncComponent)用于大型低频组件;manualChunks 将稳定的第三方库单独分包,最大化浏览器缓存利用率。

  3. preload 给当前页,prefetch 给下一页:滥用 preload 会浪费带宽(浏览器会警告未使用的 preload 资源);Vite 自动注入的 modulepreload 消除了动态 import 的瀑布式请求;首屏 Hero 图片要显式 preload,不能 loading="lazy"

  4. Bundle 分析是必要的工程习惯rollup-plugin-visualizer 的 treemap 视图能直观展示包体积分布,moment.js/lodash/core-js 是最常见的体积异常来源,发现后用 day.js/lodash-es/精确 targets 替代。

  5. HTTP/2 改变了分割粒度的最优解:HTTP/1.1 时代合并代码以减少请求数;HTTP/2 时代按缓存粒度分割(稳定库 vs 频繁变化的业务代码),充分利用浏览器缓存,降低发布时的缓存失效范围。

本章评分
4.6  / 5  (3 评分)

💬 留言讨论