库的打包优化 - childlabor/blog GitHub Wiki

前言

通常情况下组件库会有其他的第三方依赖,比如通用的dayjs、基于vue开发的会依赖vue、element-ui的二次封装库会依赖element-ui。

然而,如果将第三方依赖全部打包到组件库的bundle文件中,会使组件库的体积变得非常臃肿。做为被项目引用的组件库,缩小体积是非常重要的。

优化一

webpack为我们提供了一个很好的解决方案:

外部扩展(externals)

externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。相反,所创建的 bundle 依赖于那些存在于用户环境(consumer's environment)中的依赖。此功能通常对 __library 开发人员__来说是最有用的,然而也会有各种各样的应用程序用到它。

先来看下未配置externals情况下的bundle文件:

// 截取片段
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/
/******/ 		// Check if module is in cache
/******/ 		if(installedModules[moduleId]) {
/******/ 			return installedModules[moduleId].exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = installedModules[moduleId] = {
/******/ 			i: moduleId,
/******/ 			l: false,
/******/ 			exports: {}
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ 		// Flag the module as loaded
/******/ 		module.l = true;
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}

...

/***/ "23e7":
/***/ (function(module, exports, __webpack_require__) {

var global = __webpack_require__("da84");
var getOwnPropertyDescriptor = __webpack_require__("06cf").f;
var createNonEnumerableProperty = __webpack_require__("9112");
var redefine = __webpack_require__("6eeb");
var setGlobal = __webpack_require__("ce4e");
var copyConstructorProperties = __webpack_require__("e893");
var isForced = __webpack_require__("94ca");

/*
  options.target      - name of the target object
  options.global      - target is the global object
  options.stat        - export as static methods of target
  options.proto       - export as prototype methods of target
  options.real        - real prototype method for the `pure` version
  options.forced      - export even if the native feature is available
  options.bind        - bind methods to the target, required for the `pure` version
  options.wrap        - wrap constructors to preventing global pollution, required for the `pure` version
  options.unsafe      - use the simple assignment of property instead of delete + defineProperty
  options.sham        - add a flag to not completely full polyfills
  options.enumerable  - export as enumerable property
  options.noTargetGet - prevent calling a getter on target
*/
module.exports = function (options, source) {
  var TARGET = options.target;
  var GLOBAL = options.global;
  var STATIC = options.stat;
  var FORCED, target, key, targetProperty, sourceProperty, descriptor;
  if (GLOBAL) {
    target = global;
  } else if (STATIC) {
    target = global[TARGET] || setGlobal(TARGET, {});
  } else {
    target = (global[TARGET] || {}).prototype;
  }
  if (target) for (key in source) {
    sourceProperty = source[key];
    if (options.noTargetGet) {
      descriptor = getOwnPropertyDescriptor(target, key);
      targetProperty = descriptor && descriptor.value;
    } else targetProperty = target[key];
    FORCED = isForced(GLOBAL ? key : TARGET + (STATIC ? '.' : '#') + key, options.forced);
    // contained in target
    if (!FORCED && targetProperty !== undefined) {
      if (typeof sourceProperty === typeof targetProperty) continue;
      copyConstructorProperties(sourceProperty, targetProperty);
    }
    // add a flag to not completely full polyfills
    if (options.sham || (targetProperty && targetProperty.sham)) {
      createNonEnumerableProperty(sourceProperty, 'sham', true);
    }
    // extend global
    redefine(target, key, sourceProperty, options);
  }
};


/***/ }),

...

/***/ "4160":
/***/ (function(module, exports, __webpack_require__) {

"use strict";

var $ = __webpack_require__("23e7");
var forEach = __webpack_require__("17c2");

// `Array.prototype.forEach` method
// https://tc39.github.io/ecma262/#sec-array.prototype.foreach
$({ target: 'Array', proto: true, forced: [].forEach != forEach }, {
  forEach: forEach
});


/***/ }),

...

// EXTERNAL MODULE: ./node_modules/core-js/modules/es.array.for-each.js
var es_array_for_each = __webpack_require__("4160");

以上为webpack打包按需引入core-js库的es.array.for-each.js特性的部分代码。

我们可以看到webpack的__webpack_require__函数方法,通过接收由moduleId键对应的模块最终return了一个module.exports。并且里面串联的大量的moduleId,代码量巨大。

再来看下同一项目在配置了externals后,bundle文件的代码:


/***/ "139f":
/***/ (function(module, exports) {

module.exports = require("core-js/modules/es.array.for-each");

/***/ }),

// EXTERNAL MODULE: external "core-js/modules/es.array.for-each"
var es_array_for_each_ = __webpack_require__("139f");

同样是按需引入core-js库的es.array.for-each.js。也是__webpack_require__,但这里只用了一个moduleId,并且最终返回的是一个require运行时:require("core-js/modules/es.array.for-each")。也就是说,它会通过外部项目(引入了该库)的node_module来获取需要的依赖,这样可以避免重复的依赖。

当然还是需要在package.json里配置必要的peerDependencies,当外部项目没有install库所需的依赖时进行安装提示。

优化二

很多时候,组件库的组件可能会需要引入该组件库的其他基础组件。

写法如下:

// table-head-filter.vue

import FilterInput from '../../filter-input';

export default {
  name: 'TableHeadFilter',

  components: {
    FilterInput
  },
  ...
}

先来分析下此种写法下,独立模块打包出来的TableHeadFilter.js:

/* harmony default export */ var filtervue_type_script_lang_js_ = ({
  name: 'FilterInput',
  components: {
    filterInput: input,
    filterSelect: src_select,
    filterDate: date
  },
  model: {
    prop: 'value',
    event: 'changeValue'
  },
  props: {}
  
  ...
  
  isObjEmpty: function isObjEmpty(obj) {
      var empty = true;
      var targetList = this.filterListAll.length ? this.filterListAll : this.filterList;

      var _loop3 = function _loop3(key) {
        var current = targetList.find(function (i) {
          return i.keyword === key;
        });
        if (current && (obj[key] && obj[key] !== '' || obj[key] === 0)) empty = false;
      };

      for (var key in obj) {
        _loop3(key);
      }

      return JSON.stringify(obj) === '{}' || empty;
    }
  }
});
// CONCATENATED MODULE: ./packages/filter-input/src/filter.vue?vue&type=script&lang=js&
 /* harmony default export */ var src_filtervue_type_script_lang_js_ = (filtervue_type_script_lang_js_); 
// CONCATENATED MODULE: ./packages/filter-input/src/filter.vue
  

可以明显的看出,webpack将该组件内引入的其他基础组件的所有源码都打包在一起了。如果引入多个基础组件或引入的基础组件较大,那么该组件==单独打包==的文件体积将大大增加。当然,因为webpack的打包优化策略,整体打包的bundle文件并没有太大影响,依旧会合并提取。

因此,如果库有按需加载需求,需要组件独立打包的,需要注意进行此项优化。

优化方式依旧是通过externals。以下为具体实现片段节选:

// config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const Components = require('../components.map.json');

let externals = {};

// 关键
Object.keys(Components).forEach(function(key) {
  externals[`fb-ui-component/packages/${key}`] = `fb-ui-component/lib/${key}`;
});

externals = [Object.assign({}, externals), nodeExternals()];

exports.externals = externals;

exports.alias = {
  '@': path.resolve(__dirname, '../src'),
  // 关键
  'fb-ui-component': path.resolve(__dirname, '../')
};

// webpack.components.js
const config = require('./config');
...
externals: config.externals,
resolve: {
    alias: config.alias
},
...

// table-head-filter.vue
// 关键
import FilterInput from 'fb-ui-component/packages/filter-input';

export default {
  name: 'TableHeadFilter',

  components: {
    FilterInput
  },
  ...
}

可以看到在import组件时写法有所不同。前缀取库项目名称,本地运行时,由于配置了alias,'fb-ui-component'即根目录,依旧会去匹配对应的组件。同时,config里通过components.map遍历了所有组件并加到externals配置。构建时,webpack会将fb-ui-component当做第三方的依赖(依赖自己),并依照“优化一”的方法进行打包。

来看下打包源码TableHeadFilter.js:

// EXTERNAL MODULE: external "fb-ui-component/lib/filter-input"
var filter_input_ = __webpack_require__(7);
var filter_input_default = /*#__PURE__*/__webpack_require__.n(filter_input_);

...

/***/ 7:
/***/ (function(module, exports) {

module.exports = require("fb-ui-component/lib/filter-input");

/***/ })

不再将整个基础组件打包入文件,而是使用了require。

效果

通过以上两点优化,构建后的库体积极大减小了,看以下对比: image

ui-component.umd.min.js 48kb -> 26kb

table-head-filter.js 106kb -> 7kb

效果显著。