前言
近期有不少我们社区小伙伴给我们投稿,感谢大家信任与支持,也期待更多小伙伴参与到我们的社区建设。DevUI是面向企业中后台产品的开源前端解决方案,其设计价值观基于高效、开放、可信、乐趣四种自然与人文相结合的理念,旨在为设计师、前端开发者提供标准的设计体系,并满足各类落地场景,是一款企业级开箱即用的产品。
感谢我们的DevUI社区贡献者bugbuliu提供的好文章!
背景
为什么需要模块化? 模块化有什么用?
关于模块化,我们可以从一棵树开始
首先模块最大限度地提高了可重用性,因为模块可以导入导出,然后在任何需要它的其他模块中使用。
还有就是组合 ,模块明确定义了它们的导入和导出,所以我们可以很容易地组合多个模块一起使用。比如这棵树有这么多叶子模块,和树枝组合在一起,就成了这棵树。 同时也具有隔离 的作用,每个叶子模块,独立工作,独立维护。 又由于这种隔离性,我们允许多开发者单独工作。比如一个需求我们安排你开发这个模块,我开发这个模块,我们并行开发互不干扰,最后组合在一起就行了。 那么这种方式,就可以大大的提高我们的生产效率。 除此之外,我们也可以很容易的往系统上扩展模块和移除模块。
再看这颗树上还有这么多游离的节点,也就是我们这个系统根本就没用到它,那我们是不是就可以把它剔除掉,优化性能,这个就叫做树摇优化tree-shaking。这个路由是一个模块,这个路由是一个模块,那么我们是不是可以做按需加载。
总的来说:
模块化就是为我们提供了一种更好的方式来组织和管理我们的代码
模块化发展历程
首先是无模块化时代,1995年javascript诞生,这时候并没有一个真正意义上的模块化规范。包括文件划分方式、命名空间方式、还有IIFE,都是社区提出的基于语言特性来帮我们组织和管理代码的方式。其中使用IIFE方式的典型代表就是jquery。
直到2009年,commonjs横空出世,他是一个真正意思上的模块化规范,nodejs就是commonjs的主要实践者。 2011年左右,AMD、CMD模块化规范被提出,他们分别是requireJS和seajs库推广的产物。其中CMD是我们国内的大牛玉伯提出的。2013年UMD通用模块化规范诞生,它可以根据环境选择合适的模块加载方式。
2015年ESmodule诞生了,它是javascript官方的标准化模块系统,随ES6规范提出。直到现在,javascript,终于有了属于自己的,官方的,模块化系统。 目前比较火的vite,就是利用浏览器了原生的ES module来实现本地开发服务,极大的提升了启动速度和热更新速度。 现在任然有一些实验性的新特性在ESM的草案当中,比如webassembly这些。
文件划分、命名空间、IIFE
文件划分
在早期前端业务比较简单,JS 承担的业务较少,工程师可能随便几行代码就搞定了,直接写在一个文件里即可,稍微复杂些的会分文件引入,然后手动维护加载顺序, 这就是文件划分模式。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial- scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./moduleA.js"></script>
<script src="./moduleB.js"></script>
<script src="./entry.js"></script>
</body>
</html>
这种方式是明显有缺陷的:
- 没有私有的独立空间,在模块外部可以被随意的访问和修改,污染全局作用域
- 模块多了之后会产生命名冲突问题
- 无法管理模块之间的依赖关系
命名空间方式
命名空间是另一种模块化的实现方案,其目的在于解决命名冲突问题。 名空间的写法一定程度上减少了命名冲突问题,但其本质写法为对象,并没有构建私有作用域,所有模块的成员可以被外部访问。模块之间的依赖关系也没有被解决
javascript
// module-a.js
window.moduleA = {
data: "moduleA",
getName: function() {
return "moduleA"
}
}
// module-b.js
window.moduleB = {
data: "moduleB",
getName: function() {
return "moduleB"
}
}
IIFE(立即执行函数)
IIEF全称叫做立即执行函数,是最早的被认可的模块化方案:通过函数构建一个私有作用域,将需要对外暴露的数据和接口通过函数返回输出。这种模式是现代模块化方案的基础。利用构建工具打包后的模块化就是这种模式。 原理就是将函数声明包裹在一个括号内,然后立即执行这个函数。函数可以提供函数作用域,括号包裹立即执行,可以形成匿名函数,解决命名冲突。
典型代表是Jquery,jq使用IIFE将其代码封装在一个函数中,只暴露出一个全局变量$符号,其他内部变量和方法都被保护起来。
javascript
(function (global, factory) {
"use strict";
factory(global);
})(typeof window !== "undefined" ? window : this, function (window, noGlobal) {
function jQuery() {}
// @CODE
// build.js inserts compiled jQuery here
window.jQuery = window.$ = jQuery;
return jQuery;
});
console.log(window.$); // function jQuery(){}
虽然IIFE可以用于模块化编程,但他仍然不是一个标准的模块化规范。写来很麻烦,依赖关系很难管理。
模块化的三要素
我们从使用者的角度来看,一个好用的模块化规范,它应该拥有三个特点:
Commonjs规范
CommonJS 是由 Mozilla 的工程师 Kevin Dangoor 于 2009 年 8 月改名的,原项目叫做 ServerJS,是在 2009 年 1 月创建的。
它有四个重要的环境变量为模块化的实现提供支持:module
、exports
、require
、global
。 其中这个module对象,就代表了整个模块。 实际使用时,用module.exports
定义模块导出,用require
加载模块。
exports其实就是指向module.exports的变量。直接赋值是没有用的。
1)文件即模块,文件内的所有代码都运行在独立的作用域中,因此不会污染全局空间
2)模块可以被多次引用、加载。在第一次被加载时,会被缓存,之后都从缓存中直接读取结果
3)加载某个模块,就是引入该模块的module.exports属性
4)module.exports属性输出的是值的拷贝,一旦这个值被输出,模块内在发生变化也不会影响到输出的值
5)模块按照代码引入的顺序进行加载
6)同步加载模块文件,因此不适合浏览器端
javascript
var moduleA = require('./moduleA.js');
var users = ["Tyler", "Sarah", "Dan"];
function getUsers() {
return users;
}
module.exports = {
getUsers: getUsers
}
我们从require的加载机制来看commonjs的原理。
require
命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的module.exports对象。
require内部调用的是Module.load, Module.load内部会检测缓存,如果缓存没有,就创建一个新的实例,去加载模块文件,然后调用module.compile执行代码, 这个module.compile内部实际上封装了一个函数,在函数的参数上注入exports、require、module这些,commonjs模块的代码实际上就是在函数的内部去执行的。这个函数就叫做模块封装器,module wrapper 也就是commonjs的核心原理。
javascript
(function(exports, require, module, _filenam, _dirname){
// 执行模块代码
// 返回exports对象
})
模块加载规则
还有一个重要的原理就是,这里,加载模块文件的时候,它的加载规则是怎么样的。
模块加载分为两个部分,首先是路径分析。如果他是nodejs的核心模块,比如fs、http、path这些,它是已经编译成二进制文件了的,就会直接返回。第二种是带路径的文件模块,可以直接通过路径定位到的。第三种是不是已路径开头的,比如直接导入一个npm包。这种就会去找当前目录下的node_modules, 如果当面目录找不到,就会一层层往上在node_modules里面找。
第二步是文件定位 ,如果它是带扩展名的话,同样可以在直接定位到具体文件了。如果不带扩展名的话,就会依次以js、json、node为扩展名去找。如果都没用,那么它就可能是一个目录、或者包模块了。首先会根据package.json的main属性或者exports属性去找。如果package.json
文件没有main
字段,或者根本就没有package.json
文件,那么只有最后一条路了,会依次找index.js、index.json、index.node
es module的加载规则也基本一致。
循环依赖
我们来看一个循环依赖的例子,来帮助我们理解commonjs的运行流程。 我先暂停一分钟,大家可以简单看一下。
javascript
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
javascript
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
javascript
// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main', a.done, b.done);
/*
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
它的模块加载是同步的,即在模块加载完成之前,代码会一直等待,这种方式在浏览器端会导致页面卡死,因为浏览器是单线程的。因此AMD、CMD诞生了。
AMD、CMD、UMD
AMD(Asynchronous Module Definition)
AMD规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。 require.js在申明依赖的模块时,会在第一时间加载并执行模块内的代码。即便没用到某个模块 b,但 b 还是提前执行了
AMD规范实际上是requirejs推行的产物,使用define定义模块,require加载模块。
javascript
//模块定义
define(function() {
// 模块的代码
var message = "Hello, AMD!";
function sayHello() {
console.log(message);
}
return {
sayHello: sayHello
};
});
//模块加载
require(['myModule'], function(myModule) {
myModule.sayHello();
});
这种方案就允许开发者在需要的时候,异步加载模块,而不是在页面加载时就加载所有的模块。这样可以提高页面加载速度,减少不必要的网络请求。
CMD(Common Module Definition)
在2011年CMD(Common Module Definition)。CMD模块化方案与AMD类似,也采用异步加载模块的方式,但是它更加注重模块的依赖关系,可以更好地管理模块之间的依赖关系。 而CMD是懒加载,虽然会一开始就并行加载js文件,但是不会执行,而是在需要的时候才执行。
CMD推崇依赖就近,所以一般不在define的参数中写依赖,在factory中写,依赖延迟执行
javascript
// cmd1.js
define(function(require,exports,module){
// ...
module.exports={
// ..
}
})
// cmd2.js
define(function(require,exports,module){
var cmd2 = require('./cmd1')
// cmd2.xxx 依赖就近书写
module.exports={
// ...
}
})
seajs.use(['cmd2.js','cmd1.js'],function(cmd2,cmd1){
// ....
})
UMD (Universal Module Definition)
UMD又叫通用模块规范。 前面说了这么多模块化规格,每个规范的适用环境不一样。AMD主要用于浏览器环境,而CommonJS主要用于Node.js环境。为了兼容多个环境的规范,UMD诞生了。 UMD允许开发人员编写可以在不同环境中运行的模块,它可以检测当前环境并选择合适的模块加载方式。如果当前环境支持AMD,则使用AMD加载模块;如果当前环境支持CommonJS,则使用CommonJS加载模块;否则,将模块暴露为全局变量。
javascript
((root, factory) => {
if (typeof define === 'function' && define.amd) {
//AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
//CommonJS
var $ = requie('jquery');
module.exports = factory($);
} else {
root.testModule = factory(root.jQuery);
}
})(this, ($) => {
//todo
});
ES6 Module
ES Module 简称 ESM,是 ES6 中新增规范。ES6 在 2015 年 6 月正式发布,最早于chrome 61浏览器原生支持。 ES module的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,和输入和输出的变量。所以ESmodule是很容易做树摇优化的。
ESmodule使用 import 导入,export导出 。 import/export
必须位于模块顶级,不能在作用域内;即使你写在模块中间,或者后面import/export
也会提升到模块顶部。它不像commonjs,写在哪就是在哪。
javascript
webpack中动态import有个注意点就是:不能使用完全动态路径.必须至少包含一些关于模块的路径信息。打包可以限定于一个特定的目录或文件集,例如, ``import(`./locale/${language}.json`)`` 会把 `.locale` 目录中的每个 `.json` 文件打包到新的 chunk 中。在运行时,计算完变量 `language` 后,就可以使用像 `english.json` 或 `german.json` 的任何文件。
import和export的写法也有很多,export const、export function 、export default ,默认导入、命名导入、还有这种类似解构的导入,但他不是解构,他有一个专属名词叫做符号绑定。他们链接的是同一块内存空间,而且是只读的。
javascript
// moduleA.js
const methodA = () => {
console.log('moduleA');
import('./path/dynamic.js').then((m) => {
console.log('main', foo, bar);
})
}
export { methodA };
export const name = 'hello';
export function sayHello() { console.log('Hello World!'); }
export default {
methodA: methodA
}
// moduleB.js
import { methodA } from './moduleA.js';
import sayHello, { name } from './module.js';
import * as module from './module.js';
import { name, sayHello as say } from './moduleA.js';
ESM的工作原理
接下来我们再看一下ESmodule的工作原理。ESmodule的运行包含3个过程,构建、实例化、和运行。
首先第一步是构建,加载器会根据我们的静态导入导入语句,查找模块,下载下载模块,以深度优先遍历的方式快速得把所有文件解析成模块记录module record 第二步是实例化,为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。 最后执行代码,将导入导出的变量填充真实的值。
Es module 规范规定了如何解析文件为模块记录Module Records
,以及如何实例化和执行模块。但是,最开始的如何拿到文件并没有涉及。 以浏览器为例,就是script标签type=module加载入口的es模块。他的加载方式类似于script defer, 是异步的,不会阻塞html解析。 拿到这个文件之后就开始解析文件内容,查找并下载导入语句的模块,并解析成module record, 记录在module map中缓存。module map
来管理模块缓存
然后,JS引擎会根据module record创建一个模块环境记录module environment record。来管理模块记录中的变量,然后它会找到所有导出在内存中的变量,模块环境记录就会跟踪内存中那个盒子对应哪个导出。
内存中盒子现在暂时还没有值。它们只有在执行之后才会被填充。在这里还有一点注意的是:任何被导出的函数声明初始化是在这个阶段。这样会让后续的执行更简单。
引擎采用深度优先后序遍历来实例化模块依赖图。这也意味着他会先找到图的底部------没有依赖其他模块------设置他们的导出。
因此在这个步骤结束,我们得到了所有的模块实例和连接到一起的导入/导出变量。
此时整个依赖关系已经建立起来了,但内存中是没有值的,第三步就是回到最开始的时候去执行代码,然后往内存中填充值。同样是深度遍历的方式去运行。遇到export语句,就会往内存中导出值。当你import导出一个变量的时候,他们就是共享这同一个内存空间,这也是为什么他即使导出的是一个基本类型,也可以动态变化的原因,这个导出语法也并不是叫做解构,他有一个名词叫做符号绑定。
循环依赖
我们再来看一个ES module循环依赖的例子,来理解ESmodule的加载流程。 这里有三个文件,evenjs、oddjs、main.js。 evenjs导出了一个函数和三个变量,oddjs导出了一个函数,main.js主要是打印和执行。
javascript
// main.mjs
import { counter, counter2, even, asyncValue } from "./even.mjs";
console.log("counter", counter);
console.log("counter2", counter2);
even(10);
console.log("main end", counter);
console.log("asyncValue", asyncValue);
setTimeout(() => {
console.log("asyncValue 1000ms after", asyncValue);
}, 1000);
javascript
// even.mjs
import { odd } from './odd.mjs'
export var counter = 0;
export let counter2 = 0;
export function even(n) {
counter++;
return n === 0 || odd(n - 1);
}
export let asyncValue = 1;
setTimeout(()=> {
asyncValue = 2;
}, 1000);
javascript
// odd.mjs
import { even, counter } from "./even.mjs";
console.log("counter in odd", counter);
// console.log('counter2 in odd', counter2);
export let odd = (n)=> {
let a = 1;
let b = 2;
return n !== 0 && even(n - 1);
}
javascript
// 打印输出
counter in odd undefined
counter 0
counter2 0
main end 6
asyncValue 1
asyncValue 1000ms 2
我们直接来看执行main.js会发送什么。 按照前面讲的三个过程,首先是不是要进行构建和实例化,这个import,导入了evenjs, 这个import又导入了oddjs, odd又import了evenjs。但是此时已经有了module map 模块缓存,直接拿就行了,所以它并不会死循环。此时我们已经可以拿到整个模块依赖图了。 开始执行,同样是深度优先,main进入even, even进入odd,odd console.log( counter in odd ), counter从even中拿,它的值是什么?undefined, 因为这个赋值语句还没有执行。 这里有行注释, 如果我不注释它的话,它会怎么样?会直接报错,因为var和funtion它会变量提升,let不会。同样这个function也是,如果它改成一个变量,赋值一个函数,那么这里的循环调用一样会出问题。
此时里面已经执行完了,我们再来看一下counter,是0, counter2呢,也是0, 然后执行函数。event调用odd,odd调用even, 打印counter发现它执行了6次,没用问题。 然后打印asyncValue, = 1, 1s后再打印,发现它等于2。意味着什么?即使我导出的是一个基本数据类型,模块内部的变化依旧可以同步传递给外部。这就是我们所说的,commonjs是值的拷贝, ESmodule是值的引用。
这个例子,如果改成commonjs的写法,会怎么样呢?大家后面可以思考一些。
可以看出,无论是commonjs还是es module,循环依赖都不一定会报错,只要你能够控制好导入导出的关系。
CJS和ESM的区别和互操作性
我们再来简单对比一下commonjs和es module的区别。
那么问题来了,commonjs和es module在存在这些差异的情况下,能不能一起用呢?
比如说我用import去导入commonjs模块,用require去导入es module模块,会发生什么? 这就要讲到CJS和ESM的互操作性了。
import 语句可以引用 ES 模块或 CommonJS 模块。 import 语句只允许在 ES 模块中使用,但 CommonJS 支持动态 import() 表达式来加载 ES 模块。
当导入 CommonJS 模块 时,提供 module.exports 对象作为默认导出。 CommonJS 模块 require 总是将它引用的文件视为 CommonJS。 不支持使用 require 加载 ES 模块,因为 ES 模块具有异步执行。 而是,使用 import() 从 CommonJS 模块加载 ES 模块。
这使得 Node.js 能够运行 CommonJS 入口点,而捆绑器等构建工具则使用 ES 模块入口点,因为 Node.js 忽略(并且仍然忽略)顶级字段"module"
。
模块包同时支持CJS和ESM,可以通过main字段指定cjs入口,module字段指定mjs入口。现在一般都用exports来指定入口。
json
{
"name": "moduleA",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"exports": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
}
}
exports
的优先级比main
和module
高,也就是说,匹配上exports
的路径就不会使用main
和module
的路径。 入口尽量显式的去写扩展名.mjs
和.cjs
,如果您的文件使用.js
扩展名,"type": "module"
将导致此类文件被视为 ES 模块 如果包的 CommonJS 和 ES 模块版本是等效的,那么这样做没有问题。不然的话可能会带来一定的风险,虽然应用或包不太可能有意直接加载两个版本,但应用加载一个版本而应用的依赖加载另一个版本是很常见的。这可能会导致一些难以解决的错误。
参考文献
•nodejs.cn/api-v18/esm... ,NodeJS
•ui.dev/javascript-...,by Tyler McGinnis
•hacks.mozilla.org/2018/03/es-... , Hacks blog post by Lin Clark
•tc39.es/ecma262/#se... ECMA Language Specification
•v8.dev/features/mo... , by Addy Osmani and Mathias Bynens
•hacks.mozilla.org/2015/08/es6..., Hacks blog post by Jason Orendorff
•javascript.ruanyifeng.com/nodejs/modu..., ruanyifeng blog
加入我们
DevUI是面向企业中后台产品的开源前端解决方案,其设计价值观基于高效、开放、可信、乐趣四种自然与人文相结合的理念,旨在为设计师、前端开发者提供标准的设计体系,并满足各类落地场景,是一款企业级开箱即用的产品。
- DevUI Design 官网: devui.design/home
- GitHub仓库: github.com/DevCloudFE/...
如果你今天刚刚加入我们,可以先看看官网上的示例组件,你可以在左侧导航栏中切换想要查看的组件,然后通过右侧的快速前往在不同Demo之间切换。
如果你准备添加 Vue DevUI,请前往快速开始文档,只需要几行代码。
如果你对我们的开源项目感兴趣,并希望参与共建,欢迎加入我们的开源社区,关注DevUI微信公众号:DevUI 。
文 / DevUI社区贡献者 刘国林