前言
npm包支持tree-shaking可以实现按需引入,对生产环境优化有重要意义,本次实践参考antd,对移动端组件库@fx-ui/jdy-design-mobile进行了改造
效果对比
在阐述理论之前,先看一下该npm包处理前后的体积对比,用实力说话💪
处理前该npm包体积418.74KB

处理后该npm包体积274.19KB

减少的145KB就是该npm包被tree-shaking剔除的代码
改造过程
tree-shaking的原理
Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export.
webpack文档给出的解释中有个关键词就是ES2015,大多数情况下,我们开发的npm包为了更好的浏览器兼容性,会用babel将es6转译成es5或者更低的版本,从而丢失了tree-shaking的能力。
另外,tree-shaking需要配合压缩工具例如UglyfiJs来使用,UglyfiJs会识别代码中的/*#__PURE__*/标注,并将未被使用的函数移除:
var renderContent = function renderContent() {
return (
/*#__PURE__*/
React.createElement("div", {
className: cls,
style: getStyle(),
ref: elRef
}, children)
);
};
你不需要手动添加这些标注,babel会在转化的时候自动帮你加上
package.json的配置
打包工具(Webpack, Rollup)会优先通过package.json来判断一个npm包是否支持tree shaking:
- package.json需要设置sideEffects: false,或者指定一个无法剔除的目录,此时只有当项目引用
sideEffects之外的文件时,才会应用tree-shaking{ "sideEffects": [ "dist/*", "es/components/**/style/*", "lib/components/**/style/*", "*.less" ] } - package.json需要添加除了
main字段的之外的module字段,该字段将指定npm包的es6版本{ "main": "lib/index.js", "module": "es/index.js", }
打包工具优先通过module和sideEffects指定的路径来引入该包的es6版本,并应用tree-shaking,如果发现es6版本不可用,则会使用备选项,即main字段指定的低版本。
这里有个疑问就是为什么不直接让pkg.main指向es6格式的源码呢? 有2个原因:
-
大部分的开发者在使用babel的时候都会避开node_modules来提高编译速度,此时如果使用es6的包则需要配置复杂的编译规则来将该npm包加入白名单。
-
有些开发者可能会在nodejs环境中引用该npm包,比如lodash,此时es6就不适合了
gulp的魔法
虽然webpack的功能更强大,但gulp可以更好的控制整个打包流程,相比于项目的开发,gulp和rollup更适合库的开发。
-
lib/和es/参考antd来说,一般具有tree-shaking机制的包都会有
lib/和es/两个文件夹,gulp会通过gulp-typescript和gulp-babel将src目录下的.ts,.tsx完整的映射到lib或者es目录,当我们在babel配置中添加modules: false时,转换出的就是es6语法的js,如果不添加modules字段,则默认转换出es5:const getBabelConfig = (modules) => ({ presets: [ resolve('@babel/preset-react'), [ resolve('@babel/preset-env'), { modules, targets: { browsers: [ 'last 2 versions', 'Firefox ESR', '> 1%', 'ie >= 9', 'iOS >= 8', 'Android >= 4', ], }, }, ], ], plugins: [] }); -
src的同级目录
到现在为止,一切都很完美,但实际情况却稍微复杂一些,比如说像
jdy-design-mobile这个项目下除了src还有个同级目录biz,打包后生成了business文件夹,项目中可能会直接通过路径来引用business下的模块,比如:import { SearchInput } from '@fx-ui/jdy-design-mobile/business';这个时候就没法通过
package.json中的字段来动态引用了,于是business下面也必须存在2个目录lib和es,然后在项目中手动引入business/lib或者business/es,此时是否使用tree-shaking是由开发者决定的。biz -> business/(lib|es) 和 src -> (es|lib)的步骤是一样的,但前者因为目录结构发生了很大变化,需要对import语句做一些处理:
-
修正biz和src之间的相对引用
biz之前引用的是src,现在business(lib|es)引用(lib|es)
// 针对business/es gulp.src(rawSourceBiz).pipe(replace(/(import.*from.*)\/src(.*)/g, '$1/es$2') // 针对business/lib gulp.src(rawSourceBiz).pipe(replace(/(import.*from.*)\/src(.*)/g, '$1/lib$2') -
修正biz目录下的模块间的相对引用
原来的
buessiness/somefile.js现在变成了buessiness/(lib|es)/somefile.js因为现在的
buessiness/(lib|es)/somefile.js已经是babel处理之后的了,我们没法再通过字符串替换的方式来处理import语句,但是babel提供了自定义插件的方式,允许我们在ast阶段处理字符串,比如ImportDeclaration就对应着源代码中的import语句,这时再做替换就很方便了:function replacePath(path) { if (path.node.source && /\/(lib|es)/.test(path.node.source.value)) { const esModule = path.node.source.value.replace(/\/(lib|es)/, '/../$1'); path.node.source.value = esModule; } } // babel插件,修改import语句的字符串 function replaceLiborEs() { return () => ({ visitor: { ImportDeclaration: replacePath, ExportNamedDeclaration: replacePath, }, }); }
-
在npm包中递归的tree-shaking
如果我们使用的npm包A依赖了包B,那么当我们选择A的es6版本时,它所依赖的包B也应该自动切换到es6版本,在理想情况下,npm的模块机制已经自动实现了这个算法。
但如果就像上面说的,当包B存在src的同级目录时,情况就会变得复杂,如果A在src源码中引用了B的lib版本,比如:
// a.js
import SomeBModule from 'B/business/lib'
那么在A的es6版本代码中则必须将B/business/lib改成B/business/es,才能将tree-shaking的效果发挥到极致,antd也是利用自定义babel插件replaceLib来实现这一替换的:
const { dirname } = require('path');
const fs = require('fs');
const { getProjectPath } = require('./utils/projectHelper');
function replacePath(path) {
if (path.node.source && /\/lib\//.test(path.node.source.value)) {
// 替换import语句中lib为es
const esModule = path.node.source.value.replace('/lib/', '/es/');
// 确保包B的es6版本确实存在
const esPath = dirname(getProjectPath('node_modules', esModule));
if (fs.existsSync(esPath)) {
path.node.source.value = esModule;
}
}
}
function replaceLib() {
return {
visitor: {
// 修改ast的ImportDeclaration节点
ImportDeclaration: replacePath,
ExportNamedDeclaration: replacePath,
},
};
}
总结
npm包的tree-shaking机制是在确保可以使用es5代码的基础上,提供es6代码作为可选项。在改造的过程中,除了在package.json声明字段,还需要注意打包前后的文件引用路径的变化。
它的样式一般采用整体引入(不做tree-shaking),当然如果需要按需引入样式也可以配合babel-plugin-import来做,不过antd官方已经不推荐了