《TypeScript编程实战》15.2 代码分割与 tree-shaking

首屏体积的敌人不是代码总量,而是「用不到的代码被提前加载」。本节先讲动态 import() 在类型层面如何标注模块边界,再拆解 tree-shaking 真正生效的三个前提——ESM 静态语法、sideEffects 声明、压缩器的死代码消除;最后给出 manualChunks 分包策略与 React.lazy、路由懒加载的落地写法,并列出五类让优化静默失效的场景。

本节目标:掌握把「必须加载的代码」与「可以晚点加载的代码」分开的方法。你会知道动态 import() 的类型如何推导、tree-shaking 在什么条件下才真正删掉代码、sideEffects 字段为什么关键、分包策略该怎么定,以及哪些写法会让优化静默失效。

15.2 代码分割与 tree-shaking

上一节讲了 Vite 怎么把 TS 转成 JS。这一节换个方向:转出来的 JS 有多少是用户当下必须下载的。

体积优化的第一原则是:首屏的敌人不是代码总量,而是「用不到的代码被提前加载」。一个 2 MB 的后台管理页面如果只在点开报表时才需要图表库,那 400 KB 的图表库就不该出现在首屏 chunk 里。代码分割解决「什么时候加载」,tree-shaking 解决「加载时带不带用不到的部分」,两者配合才能把首屏压下来。

15.2.1 静态导入与动态导入

普通的 import 是静态的:Rollup 在构建期就能看到完整的模块图,因此可以把它和入口合进同一个 chunk。

// 静态导入:进入入口 chunk,随首屏一起下载
import { formatDate } from './date';

import() 是表达式,返回 Promise,Rollup 会以它为边界切出一个新 chunk,直到运行到这行才发起网络请求:

const { renderChart } = await import('./chart');
renderChart(canvas, data);

这两行的差别在产物里非常直观:

dist/assets/index-Cq3x8K.js      182.44 kB │ gzip: 58.21 kB
dist/assets/chart-Dk92mP.js      412.08 kB │ gzip: 132.77 kB

chart 被独立出来,首屏只付 182 KB。

15.2.2 动态 import 的类型推导

import() 返回的是模块命名空间对象,TS 会完整推导它的类型,不需要手写断言:

// chart.ts 导出 renderChart(canvas: HTMLCanvasElement, data: Point[]): void
const mod = await import('./chart');
// mod: typeof import("./chart")
mod.renderChart(canvas, data); // 参数类型与返回值都被检查

写错方法名会立刻报错:

src/report.ts:8:9 - error TS2339: Property 'rendarChart' does not exist
on type 'typeof import("./chart")'. Did you mean 'renderChart'?

解构写法同样保留类型,并且更常用:

const { renderChart } = await import('./chart');

如果只想拿类型、不想触发运行时加载,仍然用 import type——它不产生 chunk:

import type { Point } from './chart';

function prepare(data: unknown[]): Point[] {
  return data.map((d) => d as Point);
}

一个容易忽略的点:类型本身不会跨 chunk 传递运行时开销。把 Point 拆到独立文件再两边 import type,不会多出任何字节。

15.2.3 tree-shaking 生效的三个前提

tree-shaking 不是「自动去掉没用的代码」,它依赖三个前提同时成立:

前提含义破坏方式
ESM 静态语法import/export 可静态分析用 CJS 的 require/module.exports
无副作用声明模块可安全删除模块顶层有副作用代码
压缩器死代码消除删除被标记的死代码关闭 minify 或只在 dev 构建

第一条最关键。Rollup 只能分析 ESM 的静态结构,遇到 require() 就只能整包保留。判断一个依赖是否可摇,先看它的 package.json:

{
  "name": "some-lib",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } }
}

如果只有 main 指向 CJS,import { pick } from 'some-lib' 会把整个库打进来——因为 CJS 的对象是动态构造的,Rollup 无法判断哪个属性被用到。

15.2.4 sideEffects:让删除成为可能

即使全是 ESM,Rollup 仍需知道「删掉这个模块是否安全」。默认它是保守的:只要模块顶层有代码,就假定有副作用。

// utils/legacy.ts —— 顶层执行了注册,Rollup 不敢删
import { registry } from './registry';

registry.register('legacy', () => {});

export function unusedHelper() {}

如果这个模块只被 import './legacy' 的形式引用,或者被一个 barrel 文件整体 re-export,那么即使 unusedHelper 从没被调用,整块代码也会留在产物里。解决办法是在库自己的 package.json 里声明:

{
  "name": "my-ui-lib",
  "sideEffects": ["*.css", "./src/polyfill.ts"]
}

"sideEffects": false 表示「所有模块都可安全删除」,只保留数组里列出的例外。注意 CSS 一定要列进去,否则 import './button.css' 会被误删,样式直接消失。

这条声明对你自己的应用同样有效。很多应用把 src 拆成 index.ts barrel:

// src/components/index.ts
export * from './Button';
export * from './Modal';
export * from './Chart';

只要每个组件文件都无副作用、且应用侧声明了 sideEffects: false,import { Button } from '@/components' 就只会带进 Button。反过来,barrel 文件里只要有一行 console.log 或全局注册,整包就会被拖进来。

15.2.5 让代码不可摇的四种写法

// 1) 顶层副作用:模块一旦被引用就整体保留
window.__APP_VERSION__ = '1.0.0';

// 2) enum 会生成运行时对象,无法逐成员摇掉
export enum Status { Idle, Loading, Done }

// 3) 动态属性访问让静态分析失效
export const handlers = { onClick: fn1, onBlur: fn2 };
const key = getUserInput();
handlers[key]();

// 4) 桶文件里用 export default 聚合
export default { Button, Modal, Chart };

第 2 条在工程里最常见。enum 编译后会生成一个 IIFE 对象,即使用到其中一个成员,整个对象也得留下。多数场景可以用 as const 联合类型替代,既有同样的类型约束,又完全没有运行时开销:

export const STATUS = {
  Idle: 'idle',
  Loading: 'loading',
  Done: 'done',
} as const;

export type Status = (typeof STATUS)[keyof typeof STATUS];
// type Status = "idle" | "loading" | "done"

第 4 条也值得强调:export default 一个对象字面量,Rollup 无法确定你只用了 Button,只能整体保留。用命名导出才能逐项摇。

15.2.6 manualChunks:按策略分包

默认策略下,chunk 边界等于动态 import() 边界。但「所有第三方依赖都进一个 vendor chunk」这类需求需要显式配置:

import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (/react|react-dom|scheduler/.test(id)) return 'react';
            if (id.includes('echarts')) return 'charts';
            return 'vendor';
          }
        },
      },
    },
  },
});

manualChunks 返回 string 表示归入该 chunk,返回 undefined 表示交给默认策略。三个实用原则:

  • 按更新频率分组:框架与图表库更新频率天差地别,混在一个 chunk 里会让用户每次发版都重下 400 KB。
  • 别切太碎:HTTP/2 下多路复用缓解了请求数问题,但每个 chunk 都有独立开销,切到几十个反而更慢。
  • 公共依赖单独提:被多个懒加载路由共享的模块应放进共享 chunk,否则会被复制进每个路由 chunk。

分包策略与浏览器缓存的关系,在下一节会与 chunk 命名一起展开。

15.2.7 框架层懒加载

React 用 lazy + Suspense,lazy 要求模块提供 default 导出:

import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart'));

export function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <Chart data={data} />
    </Suspense>
  );
}

如果 ./Chart 只有命名导出,会直接报类型错误:

src/Dashboard.tsx:5:33 - error TS2322: Type 'Promise<typeof import("./Chart")>'
is not assignable to type 'Promise<{ default: ComponentType<any>; }>'.
Property 'default' is missing in type 'typeof import("./Chart")'.

两种修法:给组件文件加 export default,或在 import() 后包一层:

const Chart = lazy(() =>
  import('./Chart').then((m) => ({ default: m.Chart })),
);

路由级懒加载是收益最大的一处。React Router 的 lazy 字段、Vue 的 defineAsyncComponent、Next.js App Router 的按路由自动分割,本质都是把动态 import() 下沉到路由边界。Next.js 的做法见 《TypeScript编程实战》12.2 Next.js App Router 类型(Server Actions / Route Handlers) ,Vue 侧的异步组件见 《TypeScript编程实战》12.1 Vue 3 组合式 API 类型 。

懒加载组件一旦加载失败(网络抖动、发版后旧 chunk 404),需要错误边界兜底,见 《TypeScript编程实战》3.2 全局错误边界与未捕获异常 。

15.2.8 预加载与 modulePreload

切分之后还有一个问题:下载开始得太晚。用户点了「报表」,浏览器才发现需要 chart chunk,于是等一个 RTT 才发请求。预加载就是把这段时间省掉。

Vite 在构建时会自动为动态 chunk 的静态依赖注入 <link rel="modulepreload">,浏览器解析 HTML 时就开始下载。相关配置在 build.modulePreload:

export default defineConfig({
  build: {
    modulePreload: { polyfill: true }, // 为不支持该特性的浏览器注入 polyfill
  },
});

polyfill 大约 1 KB,只在目标浏览器缺失该特性时才注入。如果你自己已经在入口用 import 静态引用了框架代码,它同样会被 preload——这也是「首屏只该有一个入口 chunk」的原因。

更高阶的做法是在用户悬停时预取。关键是复用同一个 loader 函数,让模块缓存命中:

import { lazy } from 'react';

const chartLoader = () => import('./Chart');
const Chart = lazy(chartLoader);

export function ReportButton() {
  return (
    <button
      onMouseEnter={() => void chartLoader()}
      onClick={() => setOpen(true)}
    >
      打开报表
    </button>
  );
}

chartLoader 的类型是 () => Promise<typeof import("./Chart")>。在 onMouseEnter 里直接调用它,等于提前把 chunk 下载并执行完;等真正渲染 <Chart /> 时,lazy 拿到的 Promise 已经 resolved。不要写成另一个 () => import('./Chart')——虽然类型相同,但 Rollup 会把它识别成同一个 chunk,运行时也共享模块缓存,看似无害;真正的问题在于容易漏掉某一处而让预取失效,统一引用一个 loader 更不容易写错。

路由级别的预取可以结合 requestIdleCallback 在空闲时预热下一个可能访问的路由,但这属于「锦上添花」——首屏该加载的东西没有正确切分时,预取只会让带宽竞争更严重。

15.2.9 五类静默失效场景

一、依赖是 CJS。 摇不掉,只能换包或改用按需引入。

二、忘了声明 sideEffects。 尤其是自研组件库,缺这一行会让 barrel 导入退化成全量引入。

三、用了 enum。 改成 as const 对象加联合类型。

四、export default 聚合对象。 改成命名导出。

五、只在 dev 下看产物。 dev 阶段 Vite 不做 tree-shaking,必须 vite build 后再分析。

15.2.10 与本书其它章节的衔接

动态 import() 切出的 chunk 里如果包含数据请求,预取与缓存策略见 《TypeScript编程实战》14.3 分页、无限滚动与预取 ;组件 props 的类型设计见 《TypeScript编程实战》11.1 组件 props 与泛型组件 。

站内延伸阅读:Vite tree-shaking 深度剖析 、Vite 模块图内部原理 、esbuild 原理 、前端构建工具选型指南 。

小结

代码分割与 tree-shaking 是两个独立的问题。分割靠动态 import() 划边界,类型系统会完整推导模块命名空间,写错导出名当场报错;lazy 与路由级懒加载只是把这条边界下沉到组件与路由层。摇树则依赖三个前提同时成立:ESM 静态语法、模块无副作用、压缩器执行死代码消除。其中最容易失控的是第二项——一个顶层 console.log、一个 enum、一个 export default 聚合对象,都足以让整包代码留在产物里。

判断是否生效只有一个可靠办法:跑 vite build,看产物大小与 chunk 划分,而不是凭直觉。下一节就把这套「看产物」的方法做成可重复的诊断流程,并给出把体积写进 CI 门禁的做法。

阅读导航:上一节:15.1 Vite 与 TS 集成 · 下一节:15.3 构建性能诊断与包体积治理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes