如何让npm包支持tree-shaking

6,008 阅读5分钟

前言

npm包支持tree-shaking可以实现按需引入,对生产环境优化有重要意义,本次实践参考antd,对移动端组件库@fx-ui/jdy-design-mobile进行了改造

效果对比

在阐述理论之前,先看一下该npm包处理前后的体积对比,用实力说话💪

处理前该npm包体积418.74KB

123

处理后该npm包体积274.19KB

123

减少的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:

  1. package.json需要设置sideEffects: false,或者指定一个无法剔除的目录,此时只有当项目引用sideEffects之外的文件时,才会应用tree-shaking
    {
      "sideEffects": [
        "dist/*",
        "es/components/**/style/*",
        "lib/components/**/style/*",
        "*.less"
      ]
    }
    
  2. 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个原因:

  1. 大部分的开发者在使用babel的时候都会避开node_modules来提高编译速度,此时如果使用es6的包则需要配置复杂的编译规则来将该npm包加入白名单。

  2. 有些开发者可能会在nodejs环境中引用该npm包,比如lodash,此时es6就不适合了

gulp的魔法

虽然webpack的功能更强大,但gulp可以更好的控制整个打包流程,相比于项目的开发,gulp和rollup更适合库的开发。

  • lib/es/

    参考antd来说,一般具有tree-shaking机制的包都会有lib/es/两个文件夹,gulp会通过gulp-typescriptgulp-babelsrc目录下的.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个目录libes,然后在项目中手动引入business/lib或者business/es,此时是否使用tree-shaking是由开发者决定的。

    biz -> business/(lib|es) 和 src -> (es|lib)的步骤是一样的,但前者因为目录结构发生了很大变化,需要对import语句做一些处理:

    1. 修正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')
      
    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官方已经不推荐了

相关链接

原文地址

www.lqcreate.cn/npm-tree-sh…