打包器其实只做三件事
工程化的问题有个共同点:会配的人多,能讲清原理的人少。一旦被问到"loader 和 plugin 有什么区别"、"为什么用 contenthash",只背过配置的人就露馅了。下面每一节都从它在流水线的哪个位置讲起。
01先定位,再记细节每个概念先想清楚它在构建流程的哪一步,细节才有地方挂。
02能手写骨架loader 和 plugin 各手写一遍最小的,比看十篇配置文档有用。
03注意版本差异DLL、CommonsChunkPlugin 这些已过时,答出来会显得知识没更新。
01
四种模块规范,打包器在兼容什么★
为什么会有"双包"这种产物
打包器存在的根本原因是:浏览器不认识 CJS 的 require 和 node_modules 的解析规则。它要做两件事——把各种规范的依赖关系读成一个图,再把图输出成浏览器能跑的形态。
CJS 与 ESM 的关键差别
require是同步函数调用,运行时才解析- 导出是值拷贝(活绑定的部分靠 getter 模拟)
- 可以做条件 require,依赖图不确定
- 无法静态分析,所以 tree-shaking 效果差
ESM 的优势
import是静态声明,编译期就能分析- 导出是活绑定,值变了引用方能看到
- 顶层 await、循环引用的行为更明确
- 天然支持 tree-shaking 和静态检查
打包器实际在做的事入口 entry
→ 解析(resolve)把 import 路径换成真实文件,处理 node_modules 查找
→ 加载(load)读文件源码
→ 转换(transform)loader 链把非 JS 变成 JS
→ 分析依赖,递归下去
→ 得到一个模块图
→ 分包、生成 chunk、注入运行时(runtime)
→ 输出文件
面试怎么答浏览器不能直接执行 CJS,也不认识 node_modules 的查找规则,所以需要打包器把依赖关系解析成图再输出。ESM 因为是静态的,能被分析,所以是 tree-shaking 的前提;CJS 是运行时求值,很难静态分析。
02
Babel:转语法和补 API 是两件事★
syntax transform cannot fill a missing runtime
这是最容易答错的一道题:Babel 只负责把新语法翻译成旧语法,不会给你补运行时不存在的 API。
箭头函数、可选链、解构是语法,Babel 能改写成老写法;
但
Promise、Array.prototype.includes、Proxy 是运行时提供的东西,
老浏览器里根本没有这个对象,语法怎么转都没用——只能靠 polyfill 提前往全局挂一个。
Babel 的三个阶段
parse:源码 → AST(抽象语法树)。用
transform:遍历 AST,每个插件针对特定节点类型做替换。这一步是纯数据结构操作,跟字符串替换完全不同。
generate:AST → 字符串 + source map。
所以"Babel 是不是字符串替换"这个问题,答案是不是——它改的是树,所以不会改坏字符串里的内容。
@babel/parser。transform:遍历 AST,每个插件针对特定节点类型做替换。这一步是纯数据结构操作,跟字符串替换完全不同。
generate:AST → 字符串 + source map。
所以"Babel 是不是字符串替换"这个问题,答案是不是——它改的是树,所以不会改坏字符串里的内容。
- Babel 只降语法,core-js 补 API:这是两条独立的线。项目里常见的搭配是
@babel/preset-env(降语法)+core-js(补 API),缺一个都会在新特性上翻车。 - preset-env 靠 browserslist 决定转什么:它读
.browserslistrc或package.json里的 targets,算出"这些浏览器缺哪些特性",只转缺的。不配 targets 就等于把 ES5 全转一遍,产物体积白白变大。 - useBuiltIns 的三个取值:
false(不自动引入 core-js,要自己 import)、entry(只在入口 import 'core-js' 时,按 targets 替换成需要的模块)、usage(按代码里实际用到的 API 自动引入,最省体积,推荐)。 - 有些东西 polyfill 也补不了:
Proxy无法用 ES5 实现(这是 Vue3 不支持 IE11 的根本原因)、WeakMap的弱引用语义做不到、动态import()需要打包器配合。遇到这类特性只能放弃老浏览器或者换方案。 - polyfill 有污染全局的风险:core-js 是往
window上挂东西,如果这是个给别人用的库,会污染使用者的环境。库应该用@babel/plugin-transform-runtime,它从@babel/runtime里按需引入,不碰全局。 - preset 和 plugin 的关系:preset 是一组插件的合集。执行顺序是 plugin 从前往后、preset 从后往前,和书写顺序相反,这一点经常被考。
✕ 以为 Babel 万能
- "配了 Babel 就能用最新语法" —— 语法能转,API 还是要 polyfill
- "用了 core-js 就兼容一切" —— Proxy、WeakRef 这类补不了
- "Babel 慢是因为它做字符串替换" —— 它走的是 AST,慢在解析和多次遍历
还有一个反向的坑:targets 写得太老,会把 async/await 转成 regenerator 状态机,体积暴涨几十 KB。
✓ 实际怎么配
- 应用:
preset-env+useBuiltIns: 'usage'+corejs: 3 - 库:
transform-runtime,不要污染全局 - 现代浏览器优先:
targets: 'last 2 versions, not dead'
Vite 的生产构建默认用 esbuild 转译,需要兼容老浏览器时才用 @vitejs/plugin-legacy 补上 Babel 那一套。
面试这样答
Babel 的工作分三步:用 parser 把源码解析成 AST,用插件遍历 AST 做节点替换,最后 generate 回字符串和 source map,所以它不是字符串替换。但它只转语法,不补 API——箭头函数、可选链这些能被改写成 ES5 的写法,可 Promise、Array.prototype.includes、Proxy 这些是运行时提供的东西,老浏览器里压根没有,只能靠 core-js 这类 polyfill 提前挂到全局。preset-env 会根据 browserslist 的 targets 算出需要转哪些特性,useBuiltIns 设为 usage 时按实际用到的 API 自动引入,产物最省。还有个边界:Proxy 和 WeakRef 这类无法用 ES5 实现,polyfill 也补不了,这也是 Vue3 不支持 IE11 的原因。如果是发 npm 包,要用 transform-runtime 而不是直接引 core-js,避免污染使用者的全局环境。
03
Webpack 的构建流程与钩子★
一条流水线,钩子决定你能插在哪
Webpack 的核心是一条流水线加一套事件系统。理解三个对象(compiler、compilation、module)和两个阶段(make 造模块图、emit 出文件),钩子就都能对上位置了。
初始化读配置,创建 compiler
→
compiler.run开始一次构建
→
make递归解析,造模块图
→
seal分包、优化、生成 chunk
→
emit写文件到 assets
→
done结束,通知插件
最常被问的几个钩子分别在哪compiler.hooks.entryOption 读配置时,改 entry
compiler.hooks.beforeRun 一次构建开始前
compiler.hooks.watchRun watch 模式每次构建前
compilation.hooks.optimizeChunks 改分包结果
compiler.hooks.emit 生成文件之前,可以加资源
compiler.hooks.done 构建完成
compilation.hooks.processAssets (Webpack 5 推荐在这里改产物)
- compiler 是全局的,一次构建过程只有一个;compilation 是一次构建,watch 模式下会反复创建新的。插件如果把状态挂在 compiler 上,watch 时会拿到旧数据。
- sync / async 钩子的区别:用
tap注册同步钩子,用tapAsync或tapPromise注册异步钩子。用错了直接报错。 - Webpack 5 之后改产物推荐用 processAssets:直接操作
compilation.assets的老写法已被标记为不推荐。
面试怎么答Webpack 先初始化读配置创建 compiler,然后 run 进入 make 阶段递归解析依赖构建模块图,seal 阶段分包优化生成 chunk,emit 阶段把产物写到磁盘。插件通过 Tapable 的钩子在这条流水线的各个位置介入,改产物推荐用 processAssets。
04
loader 和 plugin 的区别★
一个处理文件,一个处理流程
loader 是文件到文件的转换函数,执行在模块加载阶段;plugin 是有钩子的对象,能在构建流程任意位置介入,能接触到 compilation 和最终产物。
loader 能做什么
- 把 scss 编译成 css
- 把 TS 编译成 JS
- 把图片转成 base64 内联
- 只能拿到当前文件的内容
plugin 能做什么
- 生成额外的 HTML 文件
- 删除无用产物、注入环境变量
- 分析包体、上传 CDN
- 能遍历所有模块和 chunk
- loader 是链式执行的,从右往左:
use: ['style-loader', 'css-loader', 'sass-loader']实际执行顺序是 sass → css → style。写反了直接报错。 - loader 应该是纯函数:同样的输入给同样的输出,不要在里面做副作用(写文件、发请求)。需要副作用就应该是 plugin。
- loader 也能异步:调用
this.async()拿到 callback,处理完再调。这一步不做,异步逻辑会返回 undefined。
面试怎么答loader 是文件级转换,在模块加载阶段按从右到左的顺序链式执行,输入输出都是文件内容。plugin 通过 Tapable 钩子介入整个构建流程,能访问 compilation、chunk 和最终产物,适合做产物生成、优化和上传这类事情。
05
手写一个 loader 和一个 plugin★
面试让写的时候,写骨架就够了
loader 就是一个签名为
(source) => source 的函数;plugin 就是一个带 apply(compiler) 方法的类。剩下的都是细节。- tap 的第一个参数是插件名,会出现在报错和 profile 里,写成有意义的名字方便排查。
- 别在 apply 里做耗时操作:apply 只是注册钩子,真正的逻辑要放在钩子回调里,否则 watch 模式只会执行一次。
- 改 asset 要用 updateAsset:直接赋值
assets[name] = ...在 Webpack 5 里不生效。
面试怎么答loader 是一个接收源码返回源码的函数,需要异步就调 this.async(),配置通过 getOptions 读取。plugin 是一个带 apply(compiler) 的类,在 apply 里用 tap 注册钩子,逻辑写在回调里,改产物用 compilation.updateAsset。
06
Vite 为什么快★
开发时不打包,生产时换个打包器
Vite 的快分两段:开发时用浏览器原生 ESM + esbuild 预构建依赖,几乎不打包;生产时用 Rollup 打包,因为产物质量和 tree-shaking 更好。
依赖预构建esbuild 把 CJS 依赖转成 ESM 并合成单文件
→
浏览器请求走原生 import,按需编译单个模块
→
生产构建Rollup 打包,tree-shaking 和分包
为什么必须预构建依赖(这一步最容易被忽略)两个原因:
1. 很多包还是 CJS,浏览器不认识 require
→ esbuild 转成 ESM
2. lodash-es 这种包有几百万个小文件
浏览器按 ESM 一个个请求会发出上万次请求
→ 预构建把它们合成一个文件,请求数降下来
- 开发时"不打包"意味着冷启动只要编译入口:改哪个文件就只编译哪个,所以文件再多启动也是秒级。
- 代价是首次请求会多:浏览器要发出大量模块请求,所以 Vite 依赖 HTTP/2 的多路复用。在 HTTP/1.1 下体验会明显变差。
- dev 和 build 的结果可能不一致:开发用 esbuild 转换、生产用 Rollup,两者对某些语法(尤其是装饰器、旧语法降级)处理不完全相同。所以上线前必须验证生产构建。
面试怎么答Vite 开发时利用浏览器原生 ESM,按需编译单个模块,只对依赖做一次 esbuild 预构建,所以冷启动和热更新都很快。生产构建换用 Rollup 以获得更好的 tree-shaking 和产物优化。代价是开发环境依赖 HTTP/2,且 dev 与 build 的转换链不同需要验证。
07
热更新的原理
为什么改了样式不用刷新页面
HMR 的前提是模块能自我替换。构建工具维护一张"模块 → 接受更新的处理函数"的表,文件变了就沿着依赖链往上找谁能接住这个更新。
文件变化watcher 监听到 mtime 变化
→
找依赖边界沿 import 链往上找 accept 的模块
→
推送更新WebSocket 发 hash 和变更列表
→
替换模块执行 accept 回调,不刷新页面
Vite 和 Webpack 的差别Webpack 变更 → 重新编译受影响的 chunk → 生成 hot-update.js/json → WS 推送
Vite 变更 → 只编译这一个文件(esbuild 或直接返回)
→ WS 告诉浏览器"这个模块变了"
→ 浏览器用带时间戳的 URL 重新 import
→ 沿 import 链向上 invalidate,直到有 accept 的边界
- 找不到 accept 边界就整页刷新:这就是为什么改 store、改工具函数常常直接刷新,而改组件和样式是热替换。
- 样式能细粒度更新:CSS 的更新只需要替换
<style>内容,不需要重新执行 JS,所以最快。 - 状态保留是有代价的:HMR 下模块可能被执行多次,如果模块初始化时有副作用(比如注册全局监听),会重复注册。这也是"热更新久了行为异常"的原因。
面试怎么答HMR 是构建工具监听文件变化后,沿着模块依赖图向上查找能接受更新的边界模块,通过 WebSocket 把变更推给浏览器,浏览器替换模块并执行 accept 回调,不刷新页面。找不到边界就退化成整页刷新。
08
Tree-shaking 的三个前提★
少一个,效果就大打折扣
要摇掉未使用的代码,必须同时满足:用 ESM、包标记无副作用、构建处于 production 且开启压缩。缺一个,那些代码就还在包里。
◐ 可交互:勾一勾,看哪些代码真的没进产物
源码里 import 并使用了哪些
模块格式
sideEffects
构建模式
产物
0KB(含 8KB 运行时)
摇不掉的典型原因
- 用了 CJS(require 是运行时求值)
- 包里有顶层副作用(比如 window.foo = 1)
- 写成
import _ from lodash整包引入 - sideEffects 没声明,打包器不敢删
能摇掉
import { debounce } from lodash-es- 纯函数、无顶层副作用
- 包内声明
"sideEffects": false - production 模式加 Terser/SWC 压缩
- 为什么压缩器也需要:tree-shaking 在模块层面标记出"没用到的导出",但真正把死代码删掉要靠 Terser 这类工具的 DCE(无用代码消除)。所以 dev 模式下几乎看不到效果。
- sideEffects: false 是个承诺:如果你写了
import './style.css'这种纯副作用导入,标记为 false 会让它被删掉。所以有 CSS 要单独列出来。 - 顶层 await 会阻止优化:含有顶层 await 的模块被视为有副作用,不能安全删除。
面试怎么答tree-shaking 需要三个条件:使用 ESM 静态语法、包声明 sideEffects 表明无副作用、production 构建并开启压缩做死代码消除。CJS 因为 require 是运行时求值无法静态分析,整包引入和顶层副作用都会让优化失效。
09
代码分割:什么时候拆,拆多细
不是越碎越好
分割的目标是让首屏不需要的代码不下载,不是"文件越小越好"。切太碎会导致请求数暴涨、公共依赖重复打包,反而更慢。
Webpack 的 chunk 分成三类initial chunk 入口直接依赖的,首屏必须加载
async chunk 动态 import 产生的,用到才加载
runtime chunk 模块加载和缓存的胶水代码,很小但常变
<em>关键点:runtime 单独抽出来,业务代码变了它才不会跟着变,
缓存命中率会明显提高。</em>
- 分割粒度的经验值:路由级分割做基本盘,再对"明显重且非首屏"的模块(图表库、编辑器、地图)单独拆。别按文件拆。
- 公共依赖要抽出来:不抽的话,A 页面和 B 页面各自带一份相同的依赖,用户切页面时重复下载。
- 预加载能缓解跳转等待:用
<link rel="modulepreload">或在路由 hover 时预取,让分割不带来看得见的延迟。
面试怎么答代码分割的目的是让首屏不需要的代码不下载,主要手段是动态 import 和路由级懒加载。切分粒度以路由为单位,再对明显重的模块单独拆,同时要抽出公共依赖并单独抽出 runtime chunk 来提升缓存命中率。
10
三种 hash 与长缓存★
改一个字,应该只有一个小文件变
hash 跟整次构建走(任何改动都变,最差);chunkhash 跟 chunk 内容走;contenthash 跟文件内容走(最精确)。想要长缓存,就该用 contenthash。
三者的区别[hash] 同一次编译的产物共享同一个 hash
改任何一个文件,全部文件名都变 → 缓存全废
[chunkhash] 同一个 chunk 共享
但 chunk 里如果含 runtime,引用别的 chunk 的
id 变了它也会变 → 还是会误伤
[contenthash] 只跟这个文件的内容有关
← 静态资源应该用这个
- 模块 ID 会影响 contenthash:Webpack 4 以前用自增数字当模块 ID,插入一个新模块会让后面所有 ID 后移,导致所有 contenthash 变化。Webpack 5 默认用确定性的 ID 算法,这个问题基本消失了。
- runtime 要单独抽:不抽的话 runtime 里的 chunk 映射表一变,引用了它的入口文件 contenthash 也跟着变。
- 入口 HTML 不能用长缓存:它引用的文件名会变,所以必须设置
no-cache,否则用户永远拿不到新的资源引用。
面试怎么答hash 是构建级、chunkhash 是 chunk 级、contenthash 是文件内容级。静态资源要用 contenthash 才能做到"改一个文件只有一个文件名变",配合一年的 max-age 实现长缓存。还要单独抽出 runtime chunk,否则映射表变化会连带影响入口文件。
11
Source Map:调试和泄露的两面
生产环境该给哪种
Source Map 让压缩后的代码能映射回源码。开发用最详细的,生产要谨慎——因为 map 文件里可能包含你的完整源码。
常见模式的取舍eval 最快,重构建最快,但只能映射到模块
开发环境常用,生产不能用
cheap-module-source-map 有行号没列号,够用且快
hidden-source-map 生成 map 但不在 JS 末尾写注释
→ 浏览器不会自动加载,适合上传到错误监控平台
nosources-source-map 有映射关系但<strong>不含源码内容</strong>
→ 泄露风险最小,堆栈还能还原
- 生产别用 inline 或 eval:inline 会把 map 塞进 JS,体积翻倍;eval 会破坏压缩和缓存。
- map 不能公开可访问:很多人把 .map 一起部署到 CDN,等于把源码开源了。要么不上传,要么用
hidden-source-map只给监控平台。 - 要在监控里还原堆栈:错误上报拿到的是压缩后的行列号,必须靠 sourcemap 反解才能定位到源码位置。
面试怎么答Source Map 用于把压缩代码映射回源码。开发建议用 eval-cheap-module-source-map 保证速度,生产用 hidden-source-map 或 nosources-source-map,避免源码泄露同时保留错误堆栈还原能力。绝对不要把 .map 公开部署。
12
环境变量:它其实是字符串替换★
所以它不会在运行时变
DefinePlugin 做的事是编译期文本替换。所以 process.env.NODE_ENV 在产物里已经变成字面量了,改成动态读取是拿不到的。三个后果1. 必须写完整字符串 process.env.XXX
const key = 'NODE_ENV'; process.env[key] ❌ 替换不了
2. 只有被显式声明的变量才会被替换
Vite 里是 import.meta.env.VITE_XXX,必须以 VITE_ 开头
3. 不要塞密钥
产物里的字符串人人在浏览器都能看到
- 这就是"死代码消除"的来源:替换成字面量后,压缩器才能判断哪个分支永远不执行,把整段删掉。所以环境判断必须写成可以直接比较的形式。
- Vite 用 import.meta.env:只有
VITE_前缀的变量会暴露给客户端,这是刻意的安全设计。 - 运行时的配置要另想办法:如果同一份镜像要部署到不同环境,得用
window.__CONFIG__这种运行时注入,不能靠构建期替换。
面试怎么答环境变量在打包时是文本替换,process.env.NODE_ENV 会被替换成字面量,压缩器据此做死代码消除。所以必须写完整的静态属性访问,动态取值替换不了。密钥不能放进去,因为产物对所有人可见。需要运行时配置就得用 window 上的注入。
13
构建性能优化
先测量,再决定要不要上多进程
构建慢的三大来源:重复编译没变的东西、串行处理大量文件、无谓的类型检查和压缩。顺序上先做缓存,再考虑并行。
按收益从高到低1. 持久化缓存
Webpack 5: cache: { type: 'filesystem' }
Vite: 依赖预构建缓存 + esbuild 转换本来就快
2. 缩小处理范围
include / exclude 限定 loader 范围,别把 node_modules 也过一遍
3. 并行
thread-loader(JS 转换)、esbuild-loader 替代 babel
→ 但进程通信有开销,小项目反而更慢
4. 把类型检查挪出构建
fork-ts-checker 单独跑,让 tsc 不阻塞打包
5. 减少无用工作
dev 环境不做压缩、不生成 sourcemap 的完整版
- 先用 speed-measure-webpack-plugin 或 --profile 找瓶颈:多数项目的耗时集中在一两个 loader 上,盲目上多进程收效很小。
- DLL 方案已经过时:Webpack 5 的 filesystem cache 覆盖了它的场景,还更省心。面试里如果还在推荐 DLL,会让面试官觉得知识没更新。
- esbuild/swc 替代 babel 是最大的单点收益:它们用原生代码实现,比 JS 写的 babel 快一到两个数量级。
面试怎么答构建优化先做持久化缓存,再缩小 loader 处理范围,然后才考虑多进程并行。把类型检查挪出构建流程,dev 环境关掉压缩和完整 sourcemap。用 esbuild 或 swc 替代 babel 通常是收益最大的单项改动。DLL 方案已被 Webpack 5 的 filesystem cache 取代。
14
模块联邦:运行时共享代码
和"抽成 npm 包"不是一回事
模块联邦让两个独立部署的应用在运行时互相引用模块,不用重新发版。它解决的是"公共代码怎么共享"里最麻烦的那种:各自独立构建、独立发布。
抽 npm 包的痛点
- 每次改动都要发版 + 所有使用方升级
- 版本不一致时会出现多份实例
- 共享状态(如 React 实例)容易出问题
模块联邦的做法
- 远程应用暴露模块,宿主运行时加载
- 共享依赖声明为 singleton,避免多实例
- 各自独立部署,刷新即生效
核心概念只有三个host 消费方,运行时去加载远程模块
remote 提供方,暴露自己的模块
shared 声明共享依赖(react、vue 等),
配 singleton 保证全局只有一份实例
- 它不是微前端的唯一方案:iframe 隔离最好但体验差,single-spa 更轻但要自己管加载。模块联邦的优势是共享依赖处理得干净。
- 版本协商是难点:host 和 remote 对 React 版本要求不一致时会出问题,所以 shared 里要写清楚 requiredVersion 和 fallback。
- 调试变复杂:运行时才加载意味着本地很难复现线上组合,需要配套的本地联调方案。
面试怎么答模块联邦让独立构建、独立部署的应用在运行时互相加载模块,通过 host、remote、shared 三个概念工作。相比抽 npm 包,它不需要发版就能生效,并且能通过 shared 加 singleton 避免 React 这类依赖出现多实例。代价是版本协商和调试更复杂。
15
速答清单
考前十分钟只看这页
01打包器到底在做什么?
把各种模块规范的依赖解析成一张图,再输出浏览器能执行的 chunk,并注入运行时。
02loader 和 plugin 的区别?
loader 是文件到文件的转换,链式从右往左;plugin 用钩子介入整个构建流程,能碰 compilation 和产物。
03compiler 和 compilation?
compiler 全局唯一,compilation 是单次构建,watch 下会反复创建。状态别挂在 compiler 上。
04Vite 为什么快?
开发时用原生 ESM 按需编译 + esbuild 预构建依赖,几乎不打包;生产用 Rollup。
05HMR 的原理?
沿依赖图向上找能 accept 的边界,通过 WebSocket 推送,浏览器替换模块不刷新。找不到就整页刷新。
06tree-shaking 的前提?
ESM + 声明 sideEffects + production 压缩。CJS 和顶层副作用都会让优化失效。
07三种 hash 怎么选?
contenthash 跟文件内容,静态资源用它;chunkhash 跟 chunk;hash 跟整次构建,最差。
08生产该用哪种 sourcemap?
hidden-source-map 或 nosources-source-map,避免源码泄露同时能还原堆栈。别公开部署 .map。
09环境变量为什么是替换?
DefinePlugin 做编译期文本替换,压缩器才能做死代码消除。所以不能动态取值,也不能放密钥。
10模块联邦解决什么?
运行时共享模块,独立部署即生效,shared 加 singleton 避免依赖多实例。
11Babel 的工作原理是什么?
三步:用 @babel/parser 把源码解析成 AST,用插件遍历 AST 做节点替换,最后 generate 回字符串和 source map。所以它不是字符串替换,改字符串里的内容不会被误伤。
12Babel 能解决所有兼容问题吗?
不能。它只转语法,不补 API。Promise、Array.prototype.includes 这些是运行时提供的,老浏览器里没有这个对象,语法怎么转都没用,要靠 core-js 之类的 polyfill 提前挂到全局。Proxy、WeakRef 这类甚至 polyfill 也补不了。
13preset-env 是怎么决定转什么的?
读 browserslist 的 targets,算出这些目标浏览器缺少哪些特性,只转缺的部分。不配 targets 等于把 ES5 全转一遍,产物体积白白变大。配得太老还会把 async/await 转成 regenerator 状态机,多出几十 KB。
14useBuiltIns 的三个值有什么区别?
false 不自动引入,要自己 import;entry 只在入口 import core-js 时按 targets 替换成需要的模块;usage 按代码里实际用到的 API 自动引入,体积最省,推荐用这个。