有学有练才叫学习:学而不思则罔,思而不学则殆:学而不习,纸上谈兵,习而不进,画地为牢!

rollup external(npm 如何才能有效减包)

node.js 炮渣日记 3周前 (11-19) 31次浏览 已收录 0个评论 扫描二维码

导语 我是如何把一个 npm 包的接入成本从 500kb 降低到几乎为 0kb 和 关于 npm 减包你所需要知道的大部分信息

前言

十月份的某一天,有同学找到我说, slide 接入统一顶部栏后,整个站点体积增大了将近 500kb?导致合流被阻断了,需要将体积增长控制在 30kb以下才能允许合入主干。当时我就震惊了,要知道这可是 gzip 后的体积啊,不少网站整站都没有500kb,统一顶部栏是什么玩意竟有如此威力?

腾讯文档各个品类的顶部工具栏在之前都是品类独立维护的,经常会发现各品类之间存在着不少的 功能/文案 差异,最痛的点还是在于如果需要新增一个功能,需要五六个品类都各自重复开发一遍,效率非常低,而且交付标准很容易不一致。所以有了统一顶部栏服务(@
tencent/docs-titlebar-service)(下称为 TB),使用一个 npm 包将顶部栏的大部分功能收归起来统一开发管理,这样只需要一次开发,各品类只需要升级就好了。

从这个 npm 包的功能可以知道,基本上没有什么新的功能,只是将之前散落在品类中的功能收归到 npm 中而已,到底为什么会导致如此巨大的体积增长呢?

罪魁祸首是什么?

产物分析工具选择

要回答这个问题,就需要来分析 npm 包产物的产物到底是由什么东西组成,提起产物分析就绕不开 Webpack Bundle Analyzer 这个 webpack 插件。其原理是通过分析 webpack 构建过程产物 state.json 来分析产物组成,网上大多数文章也是介绍的用这个插件来分析产物组成,但是实践过程中发现其分析结果并不准确,导致走了不少弯路,而且其强绑定了 webpack 这个构建工具,如果是使用 rollup 或者其他构建的就无能为力了(当然 rollup 也有自己的体积分析插件rollup-plugin-visualize)。一番搜索后发现了 source-map-explorer 这个工具,只需要产出 sourcemap 就能够分析产物组成,所以可以兼容任何构建工具和构建方式,其分析结果也比提到的更准确详细一些,当然最好还是综合对比多个工具来进行分析.

npm 包的产物体积分析

在没有经过任何优化的情况下,使用 webpack 构建 titlebar,然后使用 source-map-explorer 对其进行产物分析,得到了这样一张触目惊心的体积图,gzip 前的体积达到了 5MB….

rollup external(npm 如何才能有效减包)

其源码体积甚至都没有排上号显示出来,大量的 node_modules 中的依赖被打包进了产物中,右边的 PcHeaderBadge 也是其一个依赖项,只是因为是本地包所以被没有显示在 node_modules 中。

如何解决依赖问题

external 就能够解决问题吗

如何解决这个问题呢?其实说起来也简单,将所有的依赖 external 掉就好了,简单来说就是不「打包」依赖,在产物中保留对依赖的引用语句,这样产物就只有源码的代码。所以在构建的时候,获取其 package.json 文件,将 dependencies 与 peerDependencies 中的依赖都写入 external 配置中,产物最终就是这个样子的:

rollup external(npm 如何才能有效减包)

来到 mirrors 源上查看一下体积,从 5MB 降低到了 64KB~

rollup external(npm 如何才能有效减包)

那这样问题就解决了吗,其实远没有,因为 TB 从一开始就是这样做的,但就是这样一个看起来体积数据比较优秀的 npm 包,却给整站带来了将近 500kb 的体积增长,到底是为什么呢?

我们不得不再次回过头来,仔细查看 ppt 提供的体积增长图,其实信息都藏在这个图里面

rollup external(npm 如何才能有效减包)

可以发现新增项和增大项主要可以分成两部分,一部分是 TB及其node_modules(~300kb),另一部分则是看起来没有直接依赖关系的依赖(~200kb),通过进一步的查看详细的 ppt 的产物分析图可以得知 TB 部分的体积增长全部都是来自于依赖部分(此处图显示有误差),也就是 node_modules。等等上文不是把依赖都 「external」掉了吗,为什么还有这么大的体积呢,要回答这个问题,我们得了解一下「external」到底是如何减少包大小的

为什么 external 可以减包

关于 external 如何降低 npm 包的体积大小上文已经提到过,很容易理解(不将依赖直接打包进产物,而是利用 import / require 这样的语句保持对依赖的引用),那么 npm 包最终在 ppt 中会是如何进行打包的呢?那些 require 语句是如何找到依赖的。

当 ppt 下载 TB 这个 npm 包的时候,会同时下载其`dependencies 到子node_modules中,而 peerDependencies 中的依赖则会被下载到和TB同级的node_modules中,devDependencies 中的依赖则会忽略,比如当 package.json 中是如下的依赖关系时:

    "`dependencies`": {
        "a": "1.0.0",
        "b": "1.0.0"
    },
    "devDependencies": {},
    "`peerDependencies`": {
        "c": "1.0.0"
    },

当下载到 ppt 的时候,会形成如下的目录结构:

--src
--node_modules(1)
----c
----TB
------dist
------node_modules(2)
--------a
--------b

TB dist 中的引入语句在 ppt 构建的时候,会首先去同级的`node_modules(2)中寻找依赖,如果找不到的话就会向上寻找找到 node_modules(1)中,那这不是 external 了个寂寞吗,一样的是会下载依赖和进行打包,别急,上面提到的目录结构是只有 TB 一个包存在的情况,如果有多个 npm 存在,npm 有一个概念叫做 npm dedupe —— 当有「符合版本范围」的依赖多次出现时,会被提升到依赖树的上层,实现共享依赖

a
+-- b <-- depends on c@1.0.x
|   `-- c@1.0.3
`-- d <-- depends on c@~1.0.9
    `-- c@1.0.10

在这种依赖关系下,npm dedupe 会将依赖树简化成这样,因为 c@1.0.10 符合 b 和 d 对其的版本需求。

a
+-- b
+-- d
`-- c@1.0.10

这样依赖,依赖树中就只存在一份 c了,从而降低「重复打包」,没错 external 并不能神奇的把依赖去除掉,而是通过保留引用语句,然后通过 npm dedupe机制来降低重复打包的次数。如果没有 external,在 npm 构建的时候,依赖就已经被打包进产物中了,那就没有丝毫优化空间了。

external 没有彻底

了解了 external 减包的原理,回过头来看 TB 到底是有什么问题,跑了一下 source-map-explorer 很直观的可以看到还有一些依赖被打包了

rollup external(npm 如何才能有效减包)

排查发现主要是以下几个原因导致的

  • 引用了 common 部分的代码,而这部分代码的依赖没有并写到TB的 package.json 中,导致 external 不了
  • 有部分使用到的依赖,并没有写在 package.json 中,而是直接写在了根目录中的 package.json 中
  • 因为 external 是从 package.json 中获取的,所以字段中的只是@tencent/dui,而实际使用中用到的是 import Modal from ‘@tencent/dui/lib/components/Modal’这种写法,导致没有匹配上,external 没有生效

common 目录的直接引用问题

第一个问题其实是由于历史原因,之前这个仓库所有的包都是共用一份依赖,在上半年改造成了 pnpm workspace 的仓库,每个子包具有了自己的 package.json,有自己的依赖,但是 common 这个目录因为工作量比较大的原因并没有进行改造,所以还存在着大量的包「直接引用 common 源码」这种不符合 workspace 规范的行为,common 目录中的依赖都还是直接写在根目录的,导致没有 external 。这里选择直接将 common 目录也改造成一个 npm 包,将依赖写入子包中,这样在 commom 这个包构建的时候依赖就会被 external 掉,其他 npm 包通过 npm 包的方式引用 common 中的代码,这样 common 包也就被 external 掉了,这样彻底解决了 common 目录中的依赖打包问题,而且降低了 common 目录的重复编译次数,提高了构建速度。

使用了不在子包 package.json 中的依赖

同样是由于历史原因,之前的依赖都是写在根目录的,或者新写代码的同学会直接将依赖安装在根目录,这样子代码是能够运行的,因为依赖是会一层一层的向上进查找,但是就会导致 external 失效。于是写了一个脚本通过 depcheck 这个 npm 包,批量扫描源码中使用到的依赖,然后去根目录下的 package.json 中查找版本,最终写入到子包的 package.json 中

带路径的依赖包需要单独的 external

第三个问题好解决,为有类似这种用法的依赖包,单独写上一条正则匹配,匹配上所有以其包名为开头的的引入语句就好了。

// 从 package.json 中获取依赖, 用于 rollup 的 external
const getExternal = (path: string) => {
    const packageJson = require(path);

    return [
        ...Object.keys(packageJson.`dependencies` || {}),
        ...Object.keys(packageJson.`peerDependencies` || {}),
        ...Object.keys(packageJson.devDependencies || {}),
        /@babel/runtime/,
        /^@tencent/docs-design-resources/,
        /@tencent/dui/
    ];
};

这样几个方向的改造过后,所有依赖都被 external 了,为后续的优化打下了坚实的基础。

不规范的依赖写法

在经过上节的一些改造之后,在查看 TB 的产物分析图可以发现确实没有相关依赖被打包进来了,但是在查看 ppt 的产物分析图会发现,还是有一些包最终被重复打包了,分析发现问题还是出在依赖的写法上:

  • 存在一个依赖同时写在 dependencies 与 peerDependencies中,在 npm 中,其会被视为 dependencies 下载到 ts 的 node_moduls 中,失去了 peerDependencies 的意义,可能造成重复打包。而在 pnpm@7.9.2 以上的版本才会被优先视为 peerDependencies
  • peerDependencies 写了具体的版本,导致在 npm 判断已有的依赖不符合其版本需求,下载了多份依赖,造成多版本的重复打包
  • dependencies 中使用到的依赖版本与其他 npm 包或者 ppt 自己使用到的依赖版本不一致,导致依赖没有被 dedupe,造成多版本打包
  • dui 系列包一直没有发布正式版本(1.0.0),在 npm 的理解中,1.0.0 版本之前每一个 patch 版本都有可能是不兼容的,所以对这种包是不会使用 npm dedupe 策略来复用的,所以有大量 dui 的重复打包

要解决这些问题,也只能规范写法

  • package.json 中 dependencies 的依赖版本号应该带上 ^(没有已知的兼容问题下),让其能够接受的版本比较宽泛,提高 npm dedupe 的可能性
  • 依赖不要重复写在 peerDependencies 和 dependencies 中,dependencies 加上 dedupe 机制能够解决大部分重复打包的问题
  • 像 dui 这种包可以使用 “@tencent/dui”: “>=0.108.0 <1.0.0” 这种版本写法,强制 dedupe 生效
  • 如果你明确的知道需要写 peerDependencies 依赖,也请不要写具体的版本号,可以使用类似 1.x 这种写法,最大程度的复用外部的依赖。除非你的包确实只能在 “react”: “>=16.14.0” 这个版本之上运行。不然在能够自动下载 peerDependencies的包管理器中就会被下载多版本的依赖,导致重复打包

npm7 以上的版本会自动安装 peerDependencies,而 pnpm 在截止 7.14.0 的 latest 版本中都不会自动安装,可以通过配置开启

common js 格式导致依赖 tree shaking 失效

ppt 增大的体积中,不少都不是 TB 的直接依赖,通过分析发现其实是其依赖 workbench 的子依赖,为什么子依赖的体积会增多,通过经验分析应该是由于 TB 产出的是 cjs 产物,而 trees haking 只有在 esm 格式下才能生效,导致全量引入了 workbench

esm 是“EcmaScript module”的缩写,cjs 是“CommonJS module”的缩写,各个模块之前的定义和详细区别可以看 各模块类型详解,这里不赘述了。简而言之,早期各个端使用不同的模规范,而CJS 是node原有使用的规范。ESM 是为了统一各端模块标准而推出的新标准,但是在各端的支持度不太一样。现代浏览器中基本可以无脑使用,不过为了兼容老旧的浏览器webpack 等构建工具一般都会将其转化为 CJS 再供浏览器使用,而 vite 在开发模式下就是原生输出 ESM 文件给浏览器使用。最重要的是 tree shaking 只有才 ESM 中才会生效。而node环境下的 ESM 支持 仍然有一些问题,所以为了通用性考虑,目前仍然建议大家库产出 CJS 产物。 – – 来自「应该如何打包一个 ts 库」

由于涉及到 less文件的处理,所以选择使用了 rollup 来打包 esm 产物,支持同时产出 esm 与 cjs 格式的产物(因为 bundless 的打包方式不太好处理 less文件)。同时将 package.json 中 main 字段指向 cjs 产物,module 字段指向 esm 产物。如果是没有样式文件的包,更加推荐使用 bundless 的构建工具(tsc,father),进行 file to file 的构建,保持目录结构,同时在 package.json 中配置上 sideEffects,提供最佳的 tree shaking 效果。

再来看下产物分析结果,猜测果然正确,与workbench相关的依赖全部都去除掉了~

rollup external(npm 如何才能有效减包)

处理一些巨石依赖

通过前面 external 的章节我们可以得知,external 并不是银弹,并不是把依赖 external 之后就万事大吉了,应该有一些还是会在最终构建中被打入产物中,所以一些非常典型的巨石依赖同样也需要处理。

  • lodash 很容易被全量引入 ,可以切换成使用 lodash-es,esm 版本的lodash,可以完美的 tree-shaking 掉没有使用到的函数,如果存量的代码太多难以修改的话,也可以通过配置 babel-plugin-lodash 来实现「按需引用」,并不推荐使用 lodash.get 这种子包,原因 官方有提到,且在下个大版本中将会被删除
  • dui 系列包并没有 esm 格式的产物,无法被 tree shaking,所以同样推荐使用 babel-plugin-import 插件来实现「按需引用」
{
    plugins: [
        ['import', {
            libraryName: '@tencent/dui',
            libraryDirectory: 'lib/components',
            camel2DashComponentName: false,
        }, 'dui'],
        ['import', {
            libraryName: '@tencent/dui-mobile',
            libraryDirectory: 'lib/components',
            camel2DashComponentName: false,
        }, 'dui-mobile']
    ]
}
  • moment 这个库很大,且没有提供很好的维护,导致没法减包,可以切换成 dayjs 来降低体积,其体积降低了98%以上,且api完全兼容
rollup external(npm 如何才能有效减包)

Dynamic import

通过上文的一些手段,将绝大部分的外部依赖都去除掉了(这个图并不是完全按照文章顺序来优化的,所有还有一些 dui 的问题

rollup external(npm 如何才能有效减包)

剩下的大头主要是 @tencent/docs-scenario-components-certification `@tencent/docs-scenario-components-folder-selector 与 @tencent/docs-scenario-components-pc-header-badge 这几个内部依赖包,所以对他们进行了上述的类似改造,优化了一波体积,但是还是有 100 多kb体积,因为这两个包全局确实只有一份,没办法 dedupe(去重)。

不过这几个包之前也有用到,按道理来说不是新增的才对,后来询问得知,ppt 的产物体积对比只是分析了同步js,而这几个依赖在以前是通过 Dynamic imports 导入的,所以没有记录在体积数据中。而 TB在之前接入过程中发现使用 Dynamic imports 的时候(webpack4 cjs),会导致 ppt 构建报错(wenpack4),一时没有排查到原因,所以只能同步的引用这几个包。 知道原因了就很简单,因为现在已经切换成了 rollup 构建的 esm 产物,理论上是完美支持 Dynamic imports 的,果然很顺利的切换成了 Dynamic imports,可以看到基本上只留下了一些零碎的依赖。

rollup external(npm 如何才能有效减包)

css 文件应该单独打包成文件吗

我的结论是应该独立成css文件,让外部单独引用,理由如下:

  • 降低 js 大小,同时使得外部可以通过工具去除未使用的 css,压缩效果也更好
  • 使得 app 可以通过 head style 提前加载 css,避免样式跳动
  • 降低 js 运行的耗时

还有一些构建时容易出现的体积问题

  • ts 会使用一些「兼容性语法」来把你的高级语法转变成低级语法,默认是会直接写入到代码中,开启 importHelpers 这个选项之后可以变成从 tslib 中导入,避免「兼容性语法」被重复打包多次(文件级),tslib 同样可以被 dedupe 降低重复打包次数
  • 作为 npm 包,可以不需要将 corejs 打包进产物中,不过这一块相当的复杂,本文也不详细探讨了,作为参考,可以看看 antd 的构建配置,谨慎调整。
// 简化后的兼容性相关配置如下
{
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
        loose: true,
        targets: ['last 2 versions', 'Firefox ESR', '> 1%', 'ie >= 11'],
      },
    ],
  ],
  plugins: [
    [
      '@babel/plugin-transform-runtime',
      {
        version: `^${require('@babel/runtime/package.json').version}`,
      },
    ],
  ],
};

如何防止仓库持续腐坏

在这次减包过程中,处理了非常多的问题,但是这个仓库参与开发人数比较多,没办法向每一个开发同学来宣导这些注意事项,如果没有办法告诉大家要怎么做,没几个星期就会被打回原形,所以需要配合工具来避免仓库持续腐坏掉:(其中的 eslint 检查都是增量检查,仅避免问题增多)

  1. 通过 @nrwl/eslint-plugin-nx 这个 eslint 包来禁止包之间直接直接通过相对路径进行引用
  2. 通过 eslint-plugin-import 这个包,禁止使用没有在 package.json 中声明的依赖
  3. pre-commit 阶段使用 lint-stage 进行检查
  4. 在仓库的不规范用法收敛性之后,通过配置 pnpm,直接禁止子包使用根目录的依赖,这时候可以直接把根目录下的依赖相关字段删除,且添加检查不允许新增
  5. 新增一系列规范性的检查,在构建阶段就可以进行提醒,在mr阶段进行阻断性检查,阻止一些坏写法被合入主干

总结

经过上文我们可以发现,关于减包,我们应该站在品类的视角来看待。只关注 npm 包的大小是不够的,因为此时这个 js 的体积是不完整的(还保留着对依赖的引用关系),最终应该以品类中的「打包大小」来衡量才是更加真实的数据,不然就会造成TB这种看起来只有几十kb,最终导致品类增大了几百kb的“事故”。经过一系列的优化,最终将接入统一顶部栏对于 ppt 品类的体积影响降低到了几乎为 0

rollup external(npm 如何才能有效减包)

回顾一下减包要注意的一些地方

  • 产出 esm 产物,使品类可以 tree shaking,避免依赖被全量引入
    • 使用了样式文件
      • rollup 产出 esm && cjs 产物,因为 bundless 的打包方式不能很好的处理 less文件
    • 纯 ts 包
      • bundless 打包,同时配置上 sideEffects,提供最佳的 tree shaking 效果
  • external 所有依赖,是 dedupe 机制生效避免重复打包的必要条件
    • 注意依赖版本的写法,没有兼容性问题存在的情况下,可以使用版本要求比较宽松的 ^ 写法
    • 带路径使用的依赖需要单独使用正则写 external 语句
    • 不要混用 dep 和 peerdep,不要滥用 peerdep,合理配置的 dep 就可以实现减包,peerdep 只会让依赖安装更加的复杂
    • 确定要用 peerdep 的时候,也不要写死版本,请用1.x这种写法
  • lodash / dui 这种用到的内容占总体积比较小的依赖,优先使用其 esm 格式的产物,利用好 tree shaking 去除没有用到的代码,没有 esm 产物的配合 babel 插件实现按需引用
  • 改不了的顽固分子就换,典型的就是 moment->dayjs
  • css 文件总是应该单独打包,优点上文提到过了,这也是 vite 默认的打包行为
  • 开启 tsconfig 的 importHelpers 选项,可以不要打包 corejs

全文终,感谢阅读,有任何错误欢迎指出交流~

喜欢 (0)
炮渣日记
关于作者:
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址