如何让npm包支持tree-shaking

前言

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

效果对比

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

处理前该npm包体积418.74KB webpack

123

处理后该npm包体积274.19KB git

123

减小的145KB就是该npm包被tree-shaking剔除的代码es6

改造过程

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的能力。github

另外,tree-shaking须要配合压缩工具例如UglyfiJs来使用,UglyfiJs会识别代码中的/*#__PURE__*/标注,并将未被使用的函数移除:web

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:typescript

  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字段指定的低版本。npm

这里有个疑问就是为何不直接让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官方已经不推荐了

相关连接

相关文章
相关标签/搜索