如何让自己的组件库支持按需引入

按需引用Tree-Shaing也就是为了优化打包体积,消除对无用模块的引用,作为性能优化中的重要手段之一,也应该是每个合格组件库都应该做到的。接下来就以package-demo组件库为例,一步步构建出一个内部组件库。
引用方式 首先可以确定的是用户希望的使用方式是(方式1):

import { Button } from 'package-demo';

而不是需要知道组件的具体位置之后,手动引入(方式2):
import Button from 'package-demo/lib/button.vue'; import 'package-demo/lib/style/button.css'

由方式二到方式一的实现可以有两种方法:
  • 借助babel插件。比如antd之前提供的babel-plugin-import,插件通过引入固定路径的组件及组件样式,替代手动shaking的过程。由此也可以确定打包后的文件路径(组件要求lib/xx,样式文件要求lib/xx/style/xx)和文件模块CommonJs
    import { Button } from 'antd'; ReactDOM.render(); ↓ ↓ ↓ ↓ ↓ ↓var _button = require('antd/lib/button'); require('antd/lib/button/style/css'); ReactDOM.render(<_button>xxxx);

  • 通过开启webpackES modulesTree-shaking(mode=production 时默认开启此功能)。
webpack把所有 import 标记为 /harmony import/,使用过的export标记为 /harmony export/,未使用过的export标记为/unused harmony export/,再配合Uglifyjs等工具把没用的代码删除
输出格式
示例中也是采用了基于 ES modulestree-shaking来实现按需引用,那为什么ESM可以被tree-shaking,什么是tree-shaking呢?先来了解一下概念:
ES modules ES6 module 中通过import关键字引入模块,通过export关键字导出模块,在编译时输出静态定义的接口。import命令会被js引擎静态分析,优于模块内的其他内容执行,加载某个输出值而不是整个模块,这种加载称为“编译时加载”。
import { member1 , member2 } from "module-name"; // 导入指定名称的多个成员 export function add() {};

与之不同的CommonJs,通过require关键字加载的是整个对象(module.exports属性),从对象上读取方法,这种加载称为“运行时加载”。该对象是在脚本加载完成后生成的,所以不能进行静态分析。
exports.fs = fs; module.exports = fs;

module.exports:module是一个变量,指向一块内存,exports是module中的一个属性,存储在内存中,然后exports属性指向{}模块。既可以使用.语法,也可以使用=直接赋值。
所谓静态分析就是不执行代码,从字面量上对代码进行分析,我们使用require()语法的 CommonJS 模块规范,它允许代码根据条件去动态加载不同的模块,只有代码运行时才知道引用的什么模块。ESM的模块引入只能在条件外,作为模块顶层的语句出现,下面的写法会造成语法错误。
// CommonJs var dynamicModule; if (condition) { dynamicModule= require("moduleA"); } else { dynamicModule= require("moduleB"); } // ESM 错误语法 if (condition) { dynamicModule= import("moduleA"); } else { dynamicModule= import("moduleB"); }

所以总结就是CommonJs引入的是整个对象,并且可以进行条件加载;ESM模块在加载时引入的是某个输出值,只能进行静态加载。ESM模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,这也是tree-shaking的基础。
tree-shaking Tree-Shaking 依赖于 ES6 import 和 export 静态分析能力,当模块是通过 static 方式引用,它会尽可能的删除只写不读的变量和函数。
未被使用的函数和变量 定义两个模块文件moduleA和moduleB
// moduleA.js export function a1() { console.log('a1') }; export function a2() { console.log('a2') }// moduleB.js import { a1 } from 'moduleA'; export function b1() { a1(); }; export function b2() { console.log('b2') }

在index.js中进行调用moduleB.b2
import { b2 } from 'moudleB'; b2();

经过编译就可以看到代码中只包含了b2函数,未使用的b1模块A都在tree-shaking之后被清除,即使模块A被引入了。
'use strict'; function b2() { console.log('b2'); }b2();

存在副作用 当一个函数修改了自己函数范围外的资源,该函数就存在副作用。比如在moduleA中添加对数组原型链的修改
// moduleA Array.prototype.find = null;

【如何让自己的组件库支持按需引入】经过编译可以看到代码中整个引入了模块A,这就是因为模块A中存在副作用,打包工具将其保留了下来,如果在模块B中只引入moduleA.a1而不调用,会看到模块A虽然被消除了,但是副作用代码Array.prototype.find = null; 也会被保留下来。
'use strict'; require('moduleA'); function b2() { console.log('b2'); }b2();

未使用的类 定义函数Util类,在类中定义isPhone方法和isEmail方法
// Utils.js export default class Util { isPhone () { return 'phone' }isEmail () { return 'email' } }

在模块B中引入Util
import Util from './util.js' export function b2() { let util = new Util() let result1 = util.hello() console.log(result1) };

同样的在index.js中调用b2(),却发现打包后的代码存在isEmail函数,将Util类完整的打包了进去,并没有按照所想的那样删除没有使用过的isEmail函数
'use strict'; class Util { isPhone () { return 'phone' } isEmail () { return 'email' } } function b2() { let util = new Util(); let result1 = util.hello(); console.log(result1); } b2();

由于 JS 的动态语言特性所致,如果删除了isEmail方法,当通过变量(Util['isEmail'])去访问Util类上的方法时,就会运行出错,所以为了保守起见,webpack和rollup在打包时都会保留类和对象的内部。
webpacke打包后会多出很多模块定义的代码,为了方便观看例子是rollup经过tree-shaking的打包结果
虽然tree-shaking并不能完全的消除未使用的代码,但配合webpack的相关配置(工程化管理下项目一般会搭配webpack),也可以达到我们想要的按需加载的效果。
打包工具
综上我们需要一个可以输出es模块的打包工具,rollup便是很好的选择,webpack打包的文件会添加一些自定义的模块加载代码__webpack_require__等工具函数,这些是一个libary库所不需要的。
rollup不支持动态加载和代码分割,所以一般的application应用还是webpack比较合适。
package配置
module 当用户安装组件库时,npm install命令会根据项目根目录下的package.json配置文件,自动下载所需的模块。package.json还定义了项目的版本、名称和script脚本,具体可以查看javascript标准参考教程,其中main字段指定加载的入口文件,早期基于CommonJs规范npm包都以此为入口,所以在ESM出现后,rollup便采用了另一个字段module,也逐渐被webpack所支持,当配置文件中存在 module 字段,会优先使用,如果没找到对应的文件,则会使用 main 字段。当然两者也可以指向同一个入口。
"main": "lib/index.js", "module": "es/index.js"

sideEffects 当引入不是像lodash那样的纯工具函数的package-demo组件库时,会发现由于副作用的存在,开启了tree-shaking的bundle还是引入了大部分模块,这时就需要sideEffects。当引入的 package-demo 被标记为 sideEffects: false 时,webpack就知道这个npm包里的文件是没有副作用的,只要组件没有被引用到,整个 模块/包 都会被完整的移除。( webpack version >= 4才能启用)
只要你的包不是用来做 polyfill 或 shim 之类的事情,比如修改了 window 上的属性,复写了原生对象方法等,就可以设置 sideEffects: false,至于是不是真的有副作用则并不重要,这对于 webpack 而言都是可以接受的。
调试本地组件库 在组件库还没有上线之前,可以在package-demo中运行npm link,组件库会根据package.json中填写的信息(包名package-demo),被链接到全局。在其他项目(项目A)中执行 npm link package-demo,就可以在其node_modules中发现组件库的快捷方式,此快捷方式映射着组件库。当组件库发生修改时,项目A中也可以同步看到修改。
总结
如果想按需加载一个组件库package-demo,首先组件库需要输出es modules模块规范,并在package.json中配置sideEffetsmodule,其次使用者需要使用webpack
欢迎关注公众号,分享前端小知识~
如何让自己的组件库支持按需引入
文章图片

    推荐阅读