一、前端模块化
1.1 为什么需要模块化
前端的产品的交付环境通常是浏览器,但是我们不可能一个html文件放入所有的js代码,因此:
(1)我们需要拆分复用;
(2)拆分后变量不能污染。
1.2 模块化方案
(1) script标签: 解决「拆分/复用」问题
拆分思想:按照文件方式拆分(而不是按照函数方式拆分),以文件的形式存储我们拆分的代码;
复用思想:以script标签引入的方式复用;
我们通过script标签就可以将代码按照指定的业务相关性进行拆分,后续也可以实现逻辑复用,例如指定的纯函数放入script标签中,就可以在script标签后面的代码使用这个函数。(可以对比后面以文件方式拆分和以import/export方式导入导出的拆分复用思想)
(1) 依然存在的问题:变量污染的问题
(2) script带来的问题: 虽然解决了代码的拆分问题和复用问题,但是script标签依然解决不了变量污染问题,并且同时也带来一些问题。
问题1: 引入顺序问题,如果一个script标签需要复用另一个 script标签中的函数,那么这两个标签的先后顺序就需要有讲究。
问题2: 加载同步阻塞问题,前一个script标签加载完成之前,会阻塞后面所有的标签加载,且后面代码运行也要等待。这个问题后面通过script标签加入 async(异步加载) 和 defer(延迟加载) 属性,具体三者的区别如下所示:
按照何时网络加载 / 网络记载是否影响后面script运行 / 加载完成何时运行三个方面区分,其中defer是最舒适,网络记载是异步加载,且加载完成也是最后执行。async虽然是异步加载,但是加载完成立即执行。
(2) CommonJS (Node端): 解决「变量污染」& 「导入导出」问题
commonjs最大贡献在于提供了一套规范解决了变量污染 && 导入导出的问题。
(1) 通过闭包解决了模块之间的变量污染问题
(2) 通过require加载的时候,给每个文件放入到require函数中,并给每个文件设置module对象,module.exports对象来存在导出的缓存(若执行过,则从缓存中提取)
CommonJs的缺点
但是commonjs同样也有一些缺点和局限性,比较显著的就是同步加载。而同步加载最大的问题就是阻塞后续代码的运行。但是cjs的开发者心想,我为什么不能同步加载啊?我设计这个cjs本来就是为了在node服务端运行的,require获取文件内容很快。你们吐槽我不就是因为浏览器端加载文件是远程加载,存在延迟带宽问题嘛,但是服务端又不存在啊。
可见,CommonJS是主要为了JS在后端的表现制定的,他是不适合前端的。这里可以看一下浏览器端的js和服务器端js都主要做了哪些事,有什么不同?
知识点1: js放在浏览器中运行和放在node端运行有什么区别
区别 浏览器 Node服务端 提供js运行的环境 浏览器中的JS引擎 node提供的JS引擎 js可操作的功能 JS自身功能、DOM接口、BOM接口 JS自身功能、fs(文件系统)、data-base(操作数据库)、os(操作系统)、网络系统 主要的性能瓶颈 网络带宽 内存资源 && CPU 现在我们来回答那个问题,为什么commonjs是适用于后端的的模块化方案,而不适用于前端?。这个问题转化过来就是“为什么commonjs不适用于在浏览器中运行”。首先浏览器不提供import关键字,其次commonjs为什么不再出一套浏览器端的呢?因为浏览器和node的瓶颈不一样,浏览器端出一套就要解决导入文件的加载问题。而node端不需要考虑网络记载问题,都是从服务器中取文件,所以commonjs是同步加载,很快。即,问题总结如下:
问题1.为什么commonJS只适用于node环境,或者说为什么浏览器不支持commonJS?
浏览器不兼容CommonJS的根本原因,在于缺少四个Node.js环境的变量(只要能够提供这四个变量,浏览器就能加载 CommonJS 模块):
- module
- exports
- require
- global
问题2.为什么comonjs要设计为同步形式?
在服务端,模块文件都存放在本地磁盘,读取非常快,所以这样做不会有问题,因此node端同步加载很快,故只需要解决变量污染问题,不用解决异步加载问题。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。问题3: 我们平时在开发中为什么也可以使用commonjs规范导出模块
用于浏览器端的前端应用里使用 CommonJS 规范来编写模块代码,因为打包器会实现一套模拟 CommonJS 的机制来处理我们编写的 CommonJS 规范的代码,关于webpack等打包器是如何实现对 CommonJS 的支持和转换的 问题4: commonjs规范为什么重要?因为他解决了前两种没有能力解决的变量污染问题。让我们在定义变量的时候不用保持唯一性了。
CommonJS是主要为了JS在后端的表现制定的,他是不适合前端的,为什么这么说呢?
(3) AMD/CMD (浏览器端): 解决「变量污染」&&「导入导出」 && 「同步阻塞」问题
由于node端工程师,自己定制了一套适用于node端的模块化方案,没有带浏览器端玩,因此,web端工程师在commonjs的变量污染,导入导出解决方案的基础上,进一步解决了同步加载的阻塞的问题,设计了一套适用于web端的方案。这个方案最明显的改进就是:基于commonjs解决了同步阻塞的问题。
注意点: 由于之前script标签并没有出现async和defer属性异步加载的时候,存在两种个异步加载模块的解决方案:AMD && CMD,这两种方案都是在「模块化方案一」- script标签的基础上,针对script标签的同步阻塞问题来提出的解决方案。
两者区别
AMD:前置声明。使用者需要事先知道我这个script加载过来之后,html文件中那部分的js代码需要加载过来的script内容,然后把这部分代码放在 script标签加载完成之后的回调函数中。那么不在回调函数内的代码就是不受加载影响的代码,可以与script标签网络加载异步执行。
AMD主打一个开发者需要密切关注我哪部分代码对应那部分的script,因此对后续维护要求很高。
CMD:就近依赖。AMD中如果有一个函数中,前半部分需要引入script-a,后半部分需要复用script-b,那么在AMD中,就需要同时加载 script-a && script-b,两个都加载完成之后,再去执行后面联合起来的回调。但是CMD中不是,而是前半部分我可以先加载A,然后到了函数后半部分,再使用require记载B,接着执行。这样我就不用事先清楚考虑清楚我要加载什么了,而是我用到了我就加载,不用像AMD那样事先规划的很清楚。如下图所示:
/** AMD写法 **/
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) {
// 等于在最前面声明并初始化了要用到的所有模块
a.doSomething();
if (false) {
// 即便没用到某个模块 b,但 b 还是提前执行了
b.doSomething()
}
});
/** CMD写法 **/
define(function(require, exports, module) {
var a = require("./a"); //在需要时申明
a.doSomething();
if (false) {
var b = require("./b");
b.doSomething();
}
});
/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
var $ = require("jquery.js");
var add = function(a,b){
return a+b;
}
exports.add = add;
});
(4) UMD(浏览器/node): 解决「拆分复用」、「异步加载」、「变量污染」问题:
UMD(Universal Module Definition)是一种用于编写通用模块的前端规范,它兼容了多种模块化方案,包括 CommonJS、AMD 和全局变量(Global Variable)等。 UMD打包规范的目标是希望打包后的「模块」可以在任意环境中被引用,并且可以根据环境选择不同的「模块加载方式」。它通常包含以下几个步骤:
step1: 检测环境:UMD 首先会检测当前的运行环境,判断是否存在已知的模块加载器(如 CommonJS 的 module 对象、AMD 的 define 方法),或者是否处于浏览器环境中。
step2: 注册模块:根据检测结果,UMD 会注册模块的定义。如果在 CommonJS 环境中,使用 module.exports 导出模块;如果在 AMD 环境中,使用 define 方法定义模块;如果在全局变量环境中,将模块赋值给全局变量。
step3: 解析依赖:如果模块有依赖其他模块,UMD 会在相应的模块加载器中解析这些依赖,以确保模块的正确加载和执行。
UMD 的灵活性使得开发者可以将模块以多种方式引入到不同的环境中,从而实现代码的可复用性和跨平台的兼容性。UMD 是 「AMD」 && 「CommonJS」 的一个糅合。
(1)AMD是浏览器优先,异步加载;
(2)CommonJS是服务器优先,同步加载;
既然要实现通用加载模式,所以本质上就是先判断是否支持node.js的模块,存在就使用node.js;再判断是否支持AMD(define是否存在),存在则使用AMD的方式加载。这就是所谓的UMD,能让你能在所有可以运行js代码的环境中实现模块导入导出。因此要手动实现UMD,就要满足Commonjs、AMD、CMD三种规范。// UMD (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD define(['jquery'], factory); } else if (typeof exports === 'object') { // Node, CommonJS-like module.exports = factory(require('jquery')); } else { // Browser globals (root is window) root.returnExports = factory(root.jQuery); } }(this, function ($) { // methods function myFunc(){}; // exposed public method return myFunc; }));问题1:为什么有时浏览器端也要打包成cjs?
commonjs是运行在node端的,而我构建前端的UI组件库,显然是运行在浏览器端的,为什么也需要构建commonjs打包呢?
问题2:commonjs如何解决循环引用?
问题3:commonjs如何解决变量污染的模块化?
这是因为我们的模块代码最终是被放在一个匿名函数内部执行的,每个模块都处于单独的函数作用域,所以不会造成污染全局变量的问题。并且通过module.exports实现了模块导出,可以将模块内部的某些代码共享给其他模块复用,通过require可以方便地加载其他模块的导出内容供当前模块使用。
(5) ES-ModuleJS (标准化):在语言层面的模块化方案
显然随着时间的发展,node端-commonjs模块化方案 和 浏览器端-AMD/CMD方案 都是为了解决js语言的模块化的缺失。因此2015年,官方下场宣称js语言开始支持模块化-ES-Module原生模块机制。这样就可以在node端和浏览器端使用统一的模块化方案。
但是由于官方下场太迟,导致历史老版本的node(nodeV14之前)和浏览器依然没办法使用esmodule。因此有时还需要babel转译。此外,node高版本心想,不能你出来一个新标准我就废弃我一直使用的模块化规范,因此node提出了要想在node端运行ejs,需要一些特殊的配置。
node端支持ES-Module方式
方式一:使用es-module文件后缀名为.mjs && package.json中配置type:module
方式二:使用webpack
5.1 ES-Module语法
导入: import
导出:
export const a = 1
export default 4
5.2 如何理解import静态编译执行
(1) js代码执行周期
js代码是解释型语言,他不像纯编译型语言那样,可以统一先编译得到exe编译文件,然后在交给cpu执行程序。js没有exe程序文件,他是对一行/代码块代码编译完成生成字节码之后,立即交给解释器运行。
所以,JS代码的运行周期有两个阶段:
我们都知道js是存在编译阶段和执行阶段,但是一行代码是编译完成之后,拿到这行代码的AST树立即执行的。那么我怎么确定这行代码的如果存在this,或者变量,对应的对象是什么,或者向上拿哪个变量呢?这里其实就涉及到我们常说的:执行上下文对象、作用域链。
那么执行上下文、作用域链在哪个阶段产生的呢?还有变量提升,变量的生命周期又分别对应js执行周期的那一部分呢
step1 - JS 编译阶段
词法分析 -> 语法分析 -> AST树 -> 生成字节码
编译阶段表现:
产物1:创建执行上下文context,执行上下文中有四个组成部分:
- 变量环境:指我们在代码中定义的变量、函数等,此时会留一块内存将所有被声明的变量存入其中,然后赋值为undefined。
这么看来,编译阶段就和我们之前常提的 变量提升概念 & 变量生命周期 概念想通了:
变量提升:指编译阶段单独开辟内存存放变量的过程。
变量生命周期:声明阶段(开辟一个变量存放地址)、初始化阶段(变量赋值为undefined)、赋值阶段(赋值为a=3)- 词法环境:变量环境类似,但是还包含了当前执行上下文所处的词法作用域(这里指生成作用域链,在变量声明的时候就确定,而不像this在执行时候确定)。
- outer
- this:向当前执行上下文所属的对象
产物2: 构建AST树并生成字节码(这里为什么是字节码而不是01机器码,因为字节码比机器码更小,且解释器可以解析字节码)
注意点:我们所说的变量提升就是发生在编译阶段step2 - JS 运行阶段
执行字节码
执行阶段的表现:一行代码执行完毕之后,执行上下文就会更新(所以这里的this所指的对象也更新)
问题1: 如何理解import在编译阶段解析
首先:我们常说import是静态执行,即在编译阶段就能确定运行阶段;动态执行是指在运行阶段才能确定结果。确定结果的时间不同。
而import就是静态执行的代码,因此在代码中不能使用变量或表达式作为模块路径,而只能使用字符串字面量。即使你使用变量也是把变量当成常量来导入的,肯定就找不到路径了。
可以将import 是做一份[指针引用]对应的属性和方法,import在编译阶段解析,是指js引擎在编译阶段就会解析import关键字,遇到import就开始解析import那一行代码,这样在编译阶段就知道了import导入了哪些文件模块,然后在编译阶段就确定了import的指针指向。然后在运行阶段开始执行指针指向的文件。而require则是在运行阶段才知道我具体导入哪些文件。
以下待探究:「这里加载是“异步加载”,即文件的远程加载不影响后续import文件的加载,可以多个import文件同时加载的」。
问题2: 为什么要在编译阶段解析import
因为 import {obj} from './export.js' ,这样编译阶段确立的东西,就可以方便后面的静态解析。比较典型的,rollup和webpack中的tree-shaking,由于import在写代码阶段就可以确定文件依赖关系,那么就是可以通过扫描静态代码来提前构建依赖树,然后执行tree-shaking。当然后期import()方法动态导入例外,不能通过静态分析。
因为import是在编译阶段解析,所以在执行阶段依赖关系就确定了。在此基础上,我们就可以做静态分析(所谓的静态分析, 就是在不运行代码的情况下, 对代码进行检测扫描分析.)如下:
// demo.js
export const a = 'a'
export const b = 'b'
// test.js
import { a } from './demo.js'
// 以上代码不运行, 仅仅经过扫描分析, 抛弃了const b, 代码缩减了size
// 这就是tree shaking的静态分析的基本原理: 就是有引用就保留, 没有引用就抛弃
import 连环问:
你知道这个编译阶段和运行阶段是什么吗?
代码的生命周期是什么? 变量的生命周期是什么?对应js执行周期的那个阶段 为什么import要在编译阶段就开始解析?
webpack中的通过import执行tree-shaking和这个有什么关系?
值引用和拷贝的具体表现是什么?
js中的编译做了什么
js中的执行做了什么
import解析发生在什么时候?为什么要在编译阶段解析import?
tree-shaking如何通过import确定依赖关系
编译过程
编译过程不必多说,我们只要清楚这个过程会将字符串代码编译为可执行码。
5.3 import() 如何动态引入
我们知道import是静态执行的语句,即在编译阶段就可以确定运行结果(没有变量,全部当成常量执行)。而为了解决import不能在运行阶段执行含有变量的问题,es6推出了import(动态加载),通过正则import(正则)来区分import是静态执行还是动态执行。
import() 函数返回一个 Promise,可以在 Promise 的 then 方法中使用导入的模块。与 import 不同,import() 可以动态地加载模块,即可以在运行时根据需要动态加载模块,而不需要在代码加载阶段就加载所有模块。
import动态引入的使用示例:
import(moduleName)
.then((module) => {
// 使用模块中的内容
})
.catch((error) => {
// 处理错误
});
5.4 面试题:commonJS和ES-Module的对比
我们常听说两者的区别是:import是在编译阶段解析,require是在运行时加载;然后import是接口引用,require是值拷贝。这两个最典型的区别。那么,你知道这个编译阶段和运行阶段是什么吗?代码的生命周期是什么?为什么import要在编译阶段就开始解析?webpack中的通过import执行tree-shaking和这个有什么关系?值引用和拷贝的具体表现是什么?
(0) 语法上的区别
(1) impor编译阶段确定依赖关系,commonjs在js执行阶段确定依赖关系。
答:esmodule是在编译阶段确定,具体如下:
编译阶段 -> js编译阶段工作 -> import在编译阶段解析,并确定引用地址 -> 为什么要在编译阶段解析import(tree-shaking)
(2) 对模块导入对象的拷贝/引入
表现在import引入的是模块的地址引用,所以如果改变模块中的任何一个值,其他文件引用这个模块也会相应改变 而import是值拷贝,导出的简单类型到对应的引入文件中,即使更改了模块中的简单类型,也不会影响另一个引入模块
mp.weixin.qq.com/s/S7cvqCCea…
(3) import异步加载,commonjs同步加载
(4) commonjs和es6如何解决模块循环引入
我们知道常规的项目打包工具是使用webpack,但是我们使用antd-ui组件库的时候会发现,常规的UI组件库使用的打包工具却是rollup,并且打包的格式有umd格式/es格式/cmd格式。那么我们就来探究一下前端打包代码规范 && 前端UI组件库如何打包
二、如何封装好UI组件
2.1 组件封装需要考虑哪些因素?
- 单一责任原则: 每个组件应该专注于一个特定的功能或用途。有助于组件的可维护性,使其更容易理解和调试。
- 组件的可复用性: 将通用的功能封装成可复用的组件,这样在不同的地方都可以使用相同的逻辑和界面。尽量使组件的耦合度低,使其能够在不同的上下文中使用。
- 抽象和接口设计: 定义清晰的组件接口,包括props、事件和插槽(slot)等,以便组件的用户能够以一种直观的方式使用它。
- 可定制性: 为组件提供足够的选项和配置,使用户可以根据自己的需求对组件进行定制。这可以通过props、样式类、插槽等方式实现。
- 状态管理: 决定是否需要将状态内置到组件中,或者将状态管理留给组件的使用者。
- 样式和样式隔离: 考虑组件的样式,尽量使用局部作用域的样式,以避免样式污染和冲突。可以使用CSS Modules、Scoped CSS或CSS-in-JS等方式实现样式隔离。
- 文档和示例: 提供清晰的文档和示例,帮助其他开发者了解如何正确使用你的组件。文档应该包括组件的属性、方法、事件以及示例代码。
三、组件库打包
问题1: 我们知道UMD是统一的模块化方案,那么为什么还需要额外打包出一份cjs呢?是因为umd因为要兼容所有构建的文件内存大嘛?
问题2: 我们组件库是运行在node端的,而cjs是适用于node端的,为什么需要cjs呢?
问题3: 我们打包的时候怎么样保证体积最小?
问题4: cjs和es之间相互转化,相互引用的实现。
3.1 组件库为什么要打包
场景一:UI组件库作为npm包使用(CJS\EMS)
假设,我们使用ts、less开发组件库发布不打包直接发布到npm,然后我们项目通过install后是下载到项目的node_module目录下的,然后通过run build打包:打包工具会将:项目业务代码 && node_modules中必要的文件打包一起打包到dist中。
然后,浏览器下载js文件开始解析。但是在打包之前,webpack会先执行babel编译,其作用就是为了浏览器兼容(ts转为js,less转为css等一系列操作)。而我们一般项目为了节省打包时间,是默认约定 node_modules 通常是不走 babel 编译的。所以如果你的组件- UI库不打包,那么浏览器是识别不了你组件库中的高级语法的,甚至你在webpack最后进行压缩代码阶段就会提前出现报错。
场景二:通过<\script>标签引入组件
- type 为
module(esm with dependency) - type 为空或
text/javascript(umd)
场景三:浏览器通过 requirejs 或 seajs 等加载器引入(umd)
总结
当我们的组件库作为npm包使用的时候,如果不打包,那么浏览器会出现高级语法错误,且webapack代码压缩阶段也会出现报错。
注:什么情况下可以不打包:浏览器足够识别你的代码,或者你的代码就是es5+css写的。
3.2 打包输出的几种格式
cjs是运行在node环境中的,而前端组件库是运行在浏览器甚至在webview中的,想再浏览器中直接使用cjs是行不通的,为什么还需要打包成cjs格式呢?因为webpack 是支持 cjs 的,通过 webpack 就可以将其运行在浏览器中。所以,通过 webpack 打包你就可以在浏览器环境中使用 ms。
(1) 构建es模块
(2) 构建commonjs模块
(3) 样式文件构建
组件库打包相关问题:
- 组件为什么要打包?可不可以不打包?
- package.json 里的 main、module 和 unpkg 等是什么意思?
- cjs, esm, umd, amd 是什么意思?他们是怎么来的?
- es 2015 和 es6 是什么关系?
- tree-shaking 怎么实现的?
- 有哪些组件打包方式?最佳实践是什么?
- webpack、rollup、babel、browserify、microbundle、pikapkg 等等,该怎么选?
- 流行的社区库是怎么处理的?
参考文献:sorrycc.com/library-bun…
四、打包工具选择
3.1 打包工具选择
打包UI组件库和项目打包的区别 rollup基础:juejin.cn/post/699064…
背景:有了webpack,为什么后面又出现了rollup?
rollup比webpack晚出2年,对比webpack肯定是有差异化的,rollup是在es6出来之后推出来的打包工具。
webpack支持es2015,commonjs,AMD等规范;webpack中由于存在自身的一些依赖解析polyfill,所以打包会存在很多webpack自身的代码一起打包进去,但是rollup可以认为是es6模块的打包工具,将我们的代码转化为目标js。
3.1.1 webpack
3.1.2 rollup
使用者:React、Vue、Ember、Preact、D3、Three.js、Moment
rollup,esModule打包器,采用 es6 原生的模块机制进行模块的打包构建,rollup 更着眼于未来,对 commonjs 模块机制不提供内置的支持,是一款更轻量的打包工具。rollup 比较适合打包 js 的 sdk 或者封装的框架等,例如,vue 源码就是 rollup 打包的。而 webpack 比较适合打包一些应用,例如 SPA 或者同构项目等等。
优点:
- 编译运行出来的代码内容格式可读性好。
- 几乎没什么多余代码,除了必要的cjs, umd头外,bundle代码基本和源码没什么差异,没有奇怪的
__webpack_require__,Object.defineProperty - 相比Webpack,Rollup拥有无可比拟的性能优势,这是由依赖处理方式决定的,编译时依赖处理(Rollup)自然比运行时依赖处理(Webpack)性能更好,而且没什么多余代码,如上文提到的,webpack bundle不仅体积大,非业务代码(
__webpack_require__,Object.defineProperty)执行耗时也不容小视。需要花费更长的时间下载。Rollup没有生成这些额外的东西,执行耗时主要在于Compile Script和Evaluate Script上,其余部分可以忽略不计 - 支持导出
es模块文件(webpack不支持导出es模块)和IIFE格式。 - 对于ES6模块依赖库,Rollup会静态分析代码中的 import,并将排除任何未实际使用的代码。(Tree-shaking)
(1)如何兼容commonjs
众所周知,rollup是基于es6模块加载机制的打包工具,所以如果在rollup中使用require加载模块显然是无效的。但是如果我们希望兼容之前的commonjs模块规范,那么我们可以使用[rollup-plugin-commonjs]插件
区别1:打包体积大小
可以看出,webpack打包后的体积要比rollup大一些?为什么会造成这种现象?
我们先来看个例子:
我们现在有如下代码,分别交给webpack和rollup打包
// 入口main。js import { b } from './test/a' console.log(b + 1) console.log(1111) // './test/a' export const b = 'xx' export const bbbbbbb = 'xx'rollup打包(非常干净,无注入代码)
const b = 'xx'; console.log(b + 1); console.log(1111);webpack打包
/******/ (function(modules) { // webpackBootstrap /******/ // The module cache /******/ var installedModules = {}; /******/ /******/ // 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; /******/ } /******/ /******/ /******/ // expose the modules object (__webpack_modules__) /******/ __webpack_require__.m = modules; /******/ /******/ // expose the module cache /******/ __webpack_require__.c = installedModules; /******/ /******/ // define getter function for harmony exports /******/ __webpack_require__.d = function(exports, name, getter) { /******/ if(!__webpack_require__.o(exports, name)) { /******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); /******/ } /******/ }; /******/ /******/ // define __esModule on exports /******/ __webpack_require__.r = function(exports) { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ /******/ // create a fake namespace object /******/ // mode & 1: value is a module id, require it /******/ // mode & 2: merge all properties of value into the ns /******/ // mode & 4: return value when already ns object /******/ // mode & 8|1: behave like require /******/ __webpack_require__.t = function(value, mode) { /******/ if(mode & 1) value = __webpack_require__(value); /******/ if(mode & 8) return value; /******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; /******/ var ns = Object.create(null); /******/ __webpack_require__.r(ns); /******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); /******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); /******/ return ns; /******/ }; /******/ /******/ // getDefaultExport function for compatibility with non-harmony modules /******/ __webpack_require__.n = function(module) { /******/ var getter = module && module.__esModule ? /******/ function getDefault() { return module['default']; } : /******/ function getModuleExports() { return module; }; /******/ __webpack_require__.d(getter, 'a', getter); /******/ return getter; /******/ }; /******/ /******/ // Object.prototype.hasOwnProperty.call /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; /******/ /******/ // __webpack_public_path__ /******/ __webpack_require__.p = "./"; /******/ /******/ /******/ // Load entry module and return exports /******/ return __webpack_require__(__webpack_require__.s = 0); /******/ }) /************************************************************************/ /******/ ([ /* 0 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; // ESM COMPAT FLAG __webpack_require__.r(__webpack_exports__); // CONCATENATED MODULE: ./src/test/a.js const b = 'xx'; const bbbbbbb = 'xx'; // CONCATENATED MODULE: ./src/main.js console.log(b + 1); console.log(1111); /***/ }) /******/ ]);
显然,webpack多出这么多代码,这些代码就是打包后体积很大的原因。那么webpack为什么会多这么多代码?因为webpack自己实现了一套polyfill来实现文件依赖关系的解析(import/require),
面试题1:
webpack中为什么既可以用import,又可以用require,webpack在打包的时候如何处理import和require。
1.webpack对于ES模块/CommonJS模块的实现,是基于自己实现的webpack_require,通过自己实现了一套文件依赖解析的polyfill(即webpack_reuqire, es5 代码)
2.从 webpack2 开始,已经内置了对 ES6、CommonJS、AMD 模块化语句的支持。但不包括新的ES6语法转为ES5代码,这部分工作还是留给了babel及其插件。
3.在webpack中可以同时使用ES6模块和CommonJS模块。因为 module.exports很像export default,所以ES6模块可以很方便兼容 CommonJS:import XXX from 'commonjs-module'。反过来CommonJS兼容ES6模块,需要额外加上default:require('es-module').default。www.freecodecamp.org/chinese/new…
面试题2:
rollup如何处理import,是否支持require
3.1.3 vite
五、单包架构的按需加载
5.1 为什么单包架构需要按需加载
使用单包架构的来管理组件库的话,那么你就必须提供按需加载的能力,否则使用者在项目构架的时候就会把整个组件库npm都打包进入项目bundle,显然会对性能造成影响。因此,以降低使用者的成本,你可以考虑支持 ES Modules 的 Tree shaking 的功能来实现按需加载的能力
5.2 按需引入
问题1:我们实现组件按需引入是要达到什么样的效果?
问题2:这里的按需加载和webpack中的按需加载有什么区别?
我们需要实现的是js文件的按需加载和样式文件的按需加载 如果使用 rollup 将 js、css 一同打包的话,所有的样式内容都将被注入到一个文件中去,就无法实现样式文件的按需加载了。所以 css 使用 gulp 进行打包,并保持与原有的 css 文件相同的目录结构。
(1) 为什么要按需引入?
我们在使用vant、element-ui、ant-design 等 UI组件库时候会用到按需加载。那么为什么我们需要按需加载呢?显然最终的目的就是一个:相对减少构建打包的体积。这里的相对是比较级,所以是和两个参照物及进行对比:
参照物1: 全量引入大小对比。 如下例子所示,我们如果没有按需引入button,那么整个antd组件库都会在项目构建阶段被打包进去,这样会严重增加打包产物体积,这样就会增加文件传输时间,影响首屏渲染时间。而如果使用了按需加载,那么在通过构建工具(webpack)构建拆包的时候就会删去没有使用的组件,不必打包。
参照物2: tree-shaking不能处理css。 有人会问即使不支持按需加载,但是我使用import Button from 'antd/lib/button',然后配合tree-shaking,不是也可以去除其他组件UI代码嘛?那是不是可以不用按需加载了呢?
首先,tree-shaking的确可以删除掉无关代码,这样可以一定程度上减轻打包体积,但是还是不够的,因为tree-shaking的原理是基于import编译阶段的静态代码扫描确定的依赖关系,从而实现的无效依赖删除。如果说我们css代码写在vue组件中,那么这部分css代码就不能做到极致的压缩(base64),此外,tree-shaking还存在着“副作用”判定的问题,因此依然存在优化的空间 (juejin.cn/post/715903…
此外,这种按需引入的方式也不太友好,使用者需要知道具体的路径。
这里就要讲一下按需加载 && tree-shaking的区别了。
按需加载和tre-shaking的区别
在项目引用UI-组件库的时候可以减少UI-组件库部分构建打包的体积。说白了,:我们需要手动引入特定的组件,例如:找到my-compo-test组件库的代码,可以看到Button组件的目录为my-compo-test/lib/components/Button,
我们先来看一下按需加载和没有按需加载
// 1.未配置按需加载:需使用此方法引入组件
import Button from 'antd/lib/button'
// 此时会引入css文件会存在冗余
// 2.配置按需加载:引入只需要如下即可(Element-UI、iView等组件库实现组件按需引入的方式)
import { Button } from 'antd'
// 本质上等价于下面js和css的按需引入,通过插件可以自动转化为如下:
import "vant/es/button/style";
import _Button from "vant/es/button";
可以想到,如果每次需要用到新的组件都像这样时都同时手动引入 js、css 或 less 文件岂不是很麻烦,所以为了免去引入写法的繁杂,产生了两种方案:引入全部组件和使用插件自动引入。使用插件自动引入就是通过babel-plugin-import插件,帮我们把import { Button } from 'antd'写法进行了转换,最后转换成了下面两行js和css的按需引入写法。
按需加载的好处:
1.减少无用代码打包,增加打包文件体积
2.方便开发人员不用了解组件所在路径,即可引入
(有了tree-shaking,是否还需要按需加载:juejin.cn/post/696850… 注意: 如果你是单仓库,那么你需要按需引入,如果你是monorepo那么你就不需要按需引入了
(2) JS && CSS 的按需加载
JS的按需加载
CSS文件的按需加载
CSS 按需引入实现的复杂性
webpack处理js的按需打包是有手段实现的(vue组件的按需加载、tree-shaking等),但是让组件库支持 CSS 按需引入的功能会比较复杂。既需要组件库的开发者在打包流程和产物上进行处理,又需要使用者按照一定规则引入样式文件。首先组件库开发者需要定一套样式文件的目录组织规范,使其能在打包流程中支持以组件为单位打包样式文件,之后使用者就可以按需手动引入对应组件的样式文件。对于具有特定目录组织规范的组件库,目前已经有插件可以在编译阶段辅助生成引入样式的 import 语句,例如 [babel-plugin-import]、[unplugin-vue-components] 等。
这里还要知道组件库中css样式管理的艺术:(重中之重文章)segmentfault.com/a/119000004…
css文件的按需加载在有的组件库中是不存在的,如果采用css in js的话,那么就不存在单独的css文件,但是antd和element-plus等UI组件库,都是实现了css文件的按需加载,这样可以将文件的大小压缩到极致。而不是将所有的css放在一个文件中。
UI组件库中如何处理css样式?
这里我们要想进一步了解css文件的按需加载,我们先要看一下,UI组件库中如何处理css的?我们平时写业务组件的时候,很少会重视css,但是在组件库中相关的问题就涌现出来了,比如: 如何不去影响使用者的自定义样式? 如何让使用者可以方便的覆盖组件库样式?
css的解决方案 我们都知道不管采用less还是sass来作为css的预处理方案,最后都需要额外引入 css,例如:
import Button from 'antd/lib/button'; import 'antd/lib/button/style';为了解决这种尴尬的情况,Antd 用 [Babel 插件]将这种情况 Hack 掉了,而
material-ui并不存在这种情况,他不需要显示引入 css,这个最流行的 React 前端组件库里面只有 js 和 ts 两种代码,并不存在 css 相关的代码,为什么呢? 他们用jss作为css-in-js 的解决方案,jsx 的引入已经将 js 和 html 耦合,css-in-js将 css 也耦合进去,此时组件便不需要显示引入 css,而是直接引用 js 即可.
我们按需加载的终极优化目标是实现:组件的 js文件按需加载 和 css文件按需加载。这就要求我们写组件库的vue文件和平时写业务组件的代码编写上有所区别。如下为我们常见的业务组件写法:
如上图所示,这是最基本的 vue 开发模式了:template + script + css。这样写法的组件,通过 vite 的 lib 模式直接打包,可以得到产物:**.js 和一个 style.css。这样有什么样的问题?这样会导致所有组件的样式都被打包进了同一个 css 文件里,按需引用对于样式文件来说就不存在了。这样就增加了css文件的体积。
(2) 如何实现按需引入?
(3) 组件库按需引入原理
扩展:聊一聊前端涉及到的按需加载:cloud.tencent.com/developer/a…
4.2 实现 Ejs 和 Cjs 的相互引用
参考1: juejin.cn/post/715903…
参考2: juejin.cn/post/694236…
参考3:juejin.cn/post/725551…
六、多包架构的按需加载
6.0 Monorepo OR Multirepo?
结论:monorepo方案天然支持按需加载,因此不用像multirepo那样特地拆分出按需加载。
Monorepo(Monolithic Repositories)是目前比较流行的一种将多个项目的代码放在同一个库统一管理的代码管理组织方式,这种方式能够比较方便地进行版本管理和依赖管理。
那么,UI 组件库需不需要使用 Monorepo 这种模式呢?
纵观目前各大开源项目,像 React、babel 等生态较为丰富的项目,都是以”一个主包,多个从包“构建的生态系统,比较适合采用 Monorepo 的方式管理复杂的依赖关系,例如 React 的 packages:
但对于 UI 组件库来说,每个组件作为一个个独立的单元存在,相互之间的依赖一般比较少,所以对于组件库自身没有必要采用Monorepo的方式拆分多个 package。那以组件库为主包、各种自研的工具库作为从包的方式可以使用 Monorepo 进行管理吗?答案是可以的,目前有赞的 zent 就是采用这种方式:
所以,这里的重点是,你要考虑把所有的组件打包成一个大的NPM包,还是分割是一个个独立的小NPM包 。可以发现像antd还是使用的是单仓库管理,所以他需要实现按需加载
(1) 包管理器中的workspaces功能
npm\yarn\pnpm 包管理工具通过以下方式实现 workspace 的支持:
代码结构组织:在 Monorepo 中,不同的项目或模块通常位于同一个代码库的不同目录中。包管理工具通过识别并管理这些目录结构,可以将它们作为独立的项目或模块进行操作。项目共享三方依赖(重点):Monorepo 中的不同项目或模块可以共享相同的依赖项。包管理工具可以通过在根目录中维护一个共享的依赖项列表,以确保这些依赖项在所有项目或模块中都可用。项目之间交叉引用(重点):npm-link语法糖。在 Monorepo 中,不同项目或模块之间可能存在相互引用的情况。包管理工具需要处理这些交叉引用,以确保正确解析和构建项目之间的依赖关系。版本管理:Monorepo 中的不同项目或模块可能具有不同的版本。包管理工具需要能够管理和跟踪这些版本,并确保正确地安装和使用适当的版本。构建和测试:包管理工具需要支持在 Monorepo 中进行增量构建和测试。这意味着只有发生更改的项目或模块会重新构建和测试,而不需要重新构建和测试整个代码库。
前端目前最主流的三款包管理工具 npm7+、yarn、pnpm 都已经原生支持 workspace 模式,也就是说不管使用哪个包管理工具,我们都可以实现其与 monorepo 的配合,但最终依然选择 pmpm 作为包管理工具主要是由于 pnpm 很好的解决了 npm 与 yarn 遗留的历史问题。
npm 与 yarn 的历史遗留问题
- 扁平化依赖算法复杂,需要消耗较多的性能,依赖串行安装还有提速空间。
- 大量文件需要重复下载,对磁盘空间的利用率不足。(虽然在同一个项目中我不会重复的安装依赖 d 了,但是如果我有100个项目,100个项目都需要用到某个包,那么这个包依然会被下载100次,也就是在磁盘的不同地方写入100次)
- 扁平化依赖虽然解决了不少问题,但是随即带来了依赖非法访问的问题,项目代码在某些情况下可以在代码中使用没有被定义在 package.json 中的包,这种情况就是我们常说的幽灵依赖。
而pnpm是安装时候实现扁平化,但是查找的时候是按照依赖树规则查找的。即,pnpm能够实现依赖共享,但是在npm包查找的时候,并不是直接向上查找,而是先判断你找的这个依赖是不是在dependence树中,如果在向上查找,如果不在则直接报错。即使共享的node_module中存在这个依赖也不行
在单项目仓库中,一个项目就是一个仓库,而monorepo中仓库还是一个仓库,但是项目已经是workspaces中的其中一个workspace(项目)。虽然npm/yarn/pnpm都支持workspace,且都有几乎统一的workspace协议(定义在package.json)中,彼此的架构也都借鉴了老祖宗lerna,但语法上还是略有不同,大同小异。
参考:
6.1 Monorepo项目管理实现方案
| 类型 | 方案 | 描述 |
|---|---|---|
| 构建型 | Turborepo、Rush、Nx | 主要解决构建效率低的问题,项目代码仓库越来越庞大,工作流(int、构建、单元测试、集成测试)也会越来越慢,专门针对这样的场景进行极致的性能优化。 |
| 轻量型 | Lerna、yarn、pnpm | 渐进式方案,初期建议选择,主要为了解决依赖管理、版本管理等问题。 |
提问:pnpm 实现 monorepo方案 的问题?
我们知道通过使用pnpm的workspace功能在管理子模块间的依赖方面表现出色,实现在依赖包管理的共享(同仓库项目之间可以相互引用)。但它并未提供统一修改各个子模块关联版本号的功能,开发者需要单独进入每个子模块进行版本号的修改。因此,为了实现更全面的 Monorepo 项目管理,包括批量修改版本号、记录各个模块的修改日志以及自动化发布等功能,我们需要为其选择一个适用的版本管理和发布工具。
yarn-workspace实现
Monorepo方案同理:
-yarn处理依赖安装工作(只想做好包管理工具);
-lerna处理发布流程。
综上所述,Monorepo多项目管理方案目前主要最合适的解决方案有两个:
- pnpm + lerna + conventional-changelog
- pnpm + changesets
之所以pnpm都要搭配,是因为pnpm在包的依赖管理的确是出色,但是在包的版本管理更新上还是有所不足,以及日志记录上的功能依然需要借助外部工具,因此这里需要搭配使用,才能发挥最大功能。
方案一、pnpm + lerna + conventional-changelog
1) 依赖管理:pnpm-workspaces。由于yarn的依赖管理会存在幽灵依赖,因此将pnpm来隐式隔离来解决这个问题。
2) 版本管理:lerna。统一/独立修改 Monorepo 项目中各个子模块的关联版本号
3) 日志管理:changelog。记录每次发布修改的日志
注:由于lerna已经不再维护,因此前期可以使用lerna,后期变复杂之后可以转为nx,即:Nx + Lerna + pnpm,他们可以各司其职。
方案二、pnpm + changesets
1)依赖管理:pnpm- workspaces。
2)版本管理 + 日志管理:是一个用于管理项目版本和发布流程的工具,其功能相比于方案一种的版本管理和日志管理更加强大,且指令更加简单,操作更加灵活。
七、对外文档服务能力
文档调试能力和组件的按需加载一样,都是一个成熟的组件库必备的能力。
7.1 业务组件库要素
- 构建能力:按需引入的能力 / 整体加载的能力(按需打包使用rollup,整体打包使用webpack)
- 数据统计能力
需要统计组件被多少项目使用,具体在哪个地方使用。这个能力的主要目的是提供统计数据以及了解改动的参考影响范围。
2.1 ) - 组件内增加埋点 来进行统计。埋点方案会有一个时效性的限制,在你统计的时间周期内,如果说该组件的功能没有用户用到的这种情况是统计不到的
2.2 ) - 定时扫描分析所有代码仓库依赖来进行统计。可以搜索关键词 dependency tree - 组件文档能力
- demo调试能力
当基础的能力都准备好之后,我们最后再关注一下对外的一个输出。也就是我们的文档网站。这里我们需要把它当成一个线上服务来搭建,这里需要考虑一个具体的架构是什么?
- 可能是纯静态资源
- 配到的 CI 怎么搭建
UI组件库打包输出了什么?
关于peerdependence,如果是基于vue的组件库是不需要将vue打包进去
参考文献
1:www.bilibili.com/read/cv2858…
2.www.bookstack.cn/read/webpac… 3.juejin.cn/post/719547…