项目开发时越来越卡?多半是桶文件用多了!
前言
无论是开发性能优化还是生产性能优化如果你想找资料那真是一抓一大把,而且方案万变不离其宗并已趋于成熟,但是有一个点很多人没有关注到,在铺天盖地的性能优化文章中几乎很少出现它的影子,它就是桶文件
(barrel files),今天我们就来聊一聊。
虽然大家都没怎么提及过,但是你肯定都或多或少地在项目中使用过,而且还对你的项目产生不小的影响!
那么什么是桶文件?
桶文件 barrel files
桶文件是一种将多个模块的导出汇总到一个模块中的方式。具体来说,桶文件本身是一个模块文件,它重新导出其他模块的选定导出。
原始文件结构
// demo/foo.ts
export class Foo {}
// demo/bar.ts
export class Bar {}
// demo/baz.ts
export class Baz {}
不使用桶文件时的导入方式:
import { Foo } from '../demo/foo';
import { Bar } from '../demo/bar';
import { Baz } from '../demo/baz';
使用桶文件导出(通常是 index.ts)后:
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
使用桶文件时的导入方式:
import { Foo, Bar, Baz } from '../demo';
是不是很熟悉,应该有很多人经常这么写吧,尤其是封装工具时 utils/index
。
还有这种形式的桶文件:
// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';
这都是大家平常很常用到的形式,那么用桶文件到底怎么了?
桶文件的优缺点
先来说结论:
优点:
- 集中管理,简化代码
- 统一命名,利于多人合作
缺点:
- 增加编译、打包时间
- 增加包体积
- 不必要的性能和内存消耗
- 降低代码可读性
嗯,有没有激起你的好奇心?我们一个一个来解释。
增加编译、打包时间
桶文件对打包工具的影响
我们都知道 tree-shaking
,他可以在打包时分析出哪些模块和代码没有用到,从而在打包时将这些没有用到的部分移除,从而减少包体积。
以 rollup
为例,tree-shaking 的实现原理(其他大同小异)是:
1.静态分析
- Tree-shaking 基于 ES Module 的静态模块结构进行分析
- 通过分析 import/export 语句,构建模块依赖图
- 标记哪些代码被使用,哪些未被使用
- 使用 /#PURE/ 和 /@NO_SIDE_EFFECTS/ 注释来标记未使用代码
- 死代码消除
- 移除未使用的导出
- 移除未使用的纯函数
- 保留有副作用的代码
tree-shaking 实现流程
- 模块分析阶段
// 源代码
import { a, b } from './module'
console.log(a)
// 分析:b 未被使用
- 构建追踪
// 构建依赖图
module -> a -> used
module -> b -> unused
- 代码生成
// 最终只保留使用的代码
import { a } from './module'
console.log(a)
更多细节可以看我的另一篇文章关于tree-shaking,这不是这篇文章的重点 。
接着说回来,为什么桶文件会增加编译、打包时间?
如果你使用支持 tree-shaking 的打包工具,那么在打包时打包工具需要分析每个模块是否被使用,而桶文件作为入口整合了模块并重新导出,所以会增加分析的复杂度,你重导出的模块越多,它分析的时间就越长。
那有聪明的小伙伴就会问,既然 tree-shaking 分析、标记、删除无用代码会降低打包效率,那我关闭 tree-shaking 功能怎么样?
我只能说,不怎么样,有些情况你关闭 tree-shaking 后,打包时间反而更长。为啥?
关闭 Tree Shaking 意味着 Rollup 会直接将所有模块完整打包,即使某些模块中的代码未被使用。结果是:
- 打包体积增大:更多的代码需要进行语法转换、压缩等步骤。
- I/O 操作增加:较大的输出文件需要更多时间写入磁盘。
- 模块合并工作量增加:Rollup 在关闭 Tree Shaking 时仍会尝试将模块合并到一个文件中(尤其是 output.format 为 iife 或 esm 时)。
所以,虽然 Tree Shaking 的静态分析阶段可能较慢,但其最终生成的 bundle 通常更小、更优化,反而会减少后续步骤(如 压缩 和 代码生成)的负担。
又跑题了,我其实想说的是,问题不在于是否开启 tree-shaking,而在于你使用了桶文件,导致打包工具苦不堪言。
这个很好理解,你就想下面的桶文件重导出了100个模块,相当于这个文件里包含了100个模块的代码,解析器肯定一视同仁每一行代码都得照顾到,但其实你就用了其中一个方法 import { Foo } from '../demo';
,想想都累...
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...
下面这两种形式,比上面的稍微强点
// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
假设 ./Button 文件导出多个具名导出和一个默认导出,那么这段代码意味着只使用其中的默认导出,而 export *
则是照单全收。
export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';
同理,假设 ./foo
中有100个具名导出,这行代码就只使用了其中的 foo
。
即使这比export *
强,但是当重导出的模块较多较复杂时对打包工具依然负担不小。
好难啊。。。,那到底要怎么样打包工具才舒服?
最佳建议
- 包或者模块支持通过具体路径引入即所谓的“按需导入” 如:
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
不知道有没有人用过 babel-plugin-import
,它的工作原理大概就是
import { Button, Divider } from 'antd';
帮你转换为
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
- 减少或避免使用桶文件,将模块按功能细粒度分组,且要控制单个文件的导出数量
例如:
import {formatTime} from 'utils/timeUtils';
import {formatNumber} from 'utils/numberUtils';
import {formatMoney} from 'utils/moneyUtils';
...
而不是使用桶文件统一导出
import { formatTime, formatNumber, formatMoney } from 'utils/index';
其实这和生产环境的代码拆分一个意思,你把一个项目的所有代码都放在一个文件里搞个几M,浏览器下载和解析肯定是慢的
另外,不止打包阶段,本地开发也是一样的,无论是 vite
还是 webpack
,桶文件都会影响解析编译速度,你的桶文件搞得很多很复杂页面初始加载时间就会很长。
这一点 vite 的官方文档中也有说明。
增加包体积
有的小伙伴可能想,桶文件只影响开发和打包时的体验?那没事,我不差那点时间。
肯定没那么简单,桶文件也会影响打包后产物的体积,这就切实影响到用户侧的体验了。
如果你在打包时没有刻意关注 treeshaking 的效果,或者压根就没开启,那么你无形之中就打包了很多无用代码进最终产物里去了,这就是桶文件带来的坑。
如果你有计划的想要优化打包体积,那么桶文件会额外给你带来很多心智负担,你要一边看着打包产物一边调试打包工具的各种配置,以确保打包结果符合你的预期。
// components/utils/index.ts (桶文件)
export * from './chart'; // 依赖 echarts
export * from './format'; // 纯工具函数
export * from './i18n'; // 依赖 i18next
export * from './storage'; // 浏览器 API
// 使用桶文件
import { formatDate } from 'components/utils';
// 可能导致加载所有依赖
上面的代码,即使开启了 tree-shaking ,打包工具也无能为力。
好在较新版本的 Rollup 已针对export * from
进行了优化,只要最终代码路径中没有实际使用的导出项,它仍会尝试移除这些未使用的代码。但在以下场景下仍可能有问题:
- 模块间有副作用:如果重新导出的模块执行了副作用代码(如修改全局变量),Rollup 会保留这些模块。
- 与 CommonJS 混用:如果被导入模块是 CommonJS 格式,Tree Shaking 可能会受到限制。
想了解完整的影响 treeshaking 的场景点这里传送 Rollup 的 Tree Shaking
不仅 vite,rollup官网也说明了使用桶文件导入的弊端。
总之就是,使用桶文件如果不开 treeshaking,那么打包产物体积大,开了treeshaking也没办法做到完美(目前),你还得多花很多心思去分析优化,就没必要嘛。
不必要的性能和内存消耗
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...
这点就很好理解了,即使你只 import {foo} from 'demo/index'
使用了一个模块,其他模块也是被初始化了的,这些初始化是没有任何意义的,但是却可能拖累你的初始加载速度、增加内存占用。
同理,他也会影响你的IDE的性能,例如代码检查、补全等,或者测试框架 jest 等。
降低代码可读性
这一点见仁见智,我个人觉得桶文件增加了追踪实现的复杂性,当然大部分情况我们使用IDE是可以直接跳转到对应文件或者搜索的,不然用桶文件真的很抓狂。
// 使用桶文件
import { something } from '@/utils';
// 难以知道 something 的具体来源
// 直接导入更清晰
import { something } from '@/utils/atool';
总结
看到这里快去你的项目里检查一下,你可能做一个很小的改动就能让旁边小伙伴刮目相看:你做了what?这个项目怎么突然快了这么多?
桶文件实际上产生的影响并不小,只有少量桶文件在您的代码中通常是没问题的,但当每个文件夹都有一个时,问题就大了。
如果的项目是一个广泛使用桶文件的项目,现在可以应用一项免费的优化,使许多任务的速度提高 60-80%,让你的IDE和构建工具减减负:
删除所有桶文件!
来源:juejin.cn/post/7435492245912551436