如何讓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官方已經不推薦了

相關連接

相關文章
相關標籤/搜索