DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.designNg组件库:ng-devuiVue组件库:vue-devuiNg Admin:ng-devui-admin
前言
在西方国家有一句谚语,原话是:Don't Reinvent the Wheel,这句话也被国内 IT从业人员经常引用,翻译成中文就是:"不要重复发明轮子 "。意思是在项目开发中,有一些别人已经做过的功能或者模块,在我们需要用的时候,直接拿来用即可,而不要重新开发。
正是因为这种可复用轮子(npm 包管理器)的机制,成就了 nodejs。截止 2023 年,npm 已经拥有全球1700多万开发人员,超过 200 万个软件包,是世界上最大的软件注册表。
npm 软件包,也是模块化的一种体现。我们都知道 nodejs 是服务器端 JavaScript 运行时环境,而 JavaScript 在 ES6 标准之前,是没有自己的模块化机制的,JavaScript 文件之间无法相互引用,只能依赖脚本的加载顺序以及全局变量来确定变量的传递顺序和传递方式。所以 Nodejs 采用了当时比较先进的一种模块化规范来实现服务端 JavaScript 的模块化机制,它就是 CommonJS。
为什么要模块化,模块化的好处?
下面有一段对话(纯属虚构)
萌新:老大,这功能好复杂啊,能指点一下吗?
大佬:ok,这跟我之前在这个项目中做的一个功能有点像,我把代码发给你,你参考一下。
ini
var GET_URL = 'http://map.baidu.com/detail';
var MAX_USER_NUM = 200;
var APPLICATION_NAME = 'Launcher';
萌新:好勒。
萌新根据自己的需求改了
ini
var GET_URL = 'http://zhidao.baidu.com/detail';
var MAX_USER_NUM = 400;
var APPLICATION_NAME = 'Author';
过阵子,大佬发现自己做的功能出现了问题,看了代码。
大佬:xx,这里面的常量,别的地方也有依赖,你这样会覆盖我定义的常量。嗯,我来建一个专门存放常量的文件吧。
大佬创建了一个constants.js,并约定以后将常量都写在该文件里,要用的时候,引用该文件。
上面的案例可以看到,如果在 html 中挨个引用js,可能会导致全局变量污染和变量重名的问题。项目成员各做各的,还会导致文件之间依赖关系管理麻烦、相似功能重复开发,开发成本高等问题。
案例中大佬将常量汇总到一个js文件中,这就是模块化得一种体现,提供了一个常量对象模块。下面是模块化的一些好处:
1、代码重用方面:通过将功能划分为模块,可以将已经开发和测试过的模块在不同的项目中重复使用,提高代码的复用性和开发效率。
2、可维护性方面:模块化设计使得软件的各个部分相对独立,当需要修改或修复某个功能时,只需关注特定的模块,而无需对整个系统进行大规模的改动,简化了维护工作。
3、并行开发方面:模块化可以让不同的开发人员并行工作,在保证模块接口一致性的前提下,开发团队可以独立地开发、测试和调试各自负责的模块,提高开发效率。
4、可测试性方面:模块化设计使得单个模块的功能较小且相对独立,这使得单元测试和和集成测试更容易进行,能够更准确地定位和解决问题。
5、可扩展性方面:通过模块化设计,可以更方便地添加新功能或替换现有功能,只需修改或增加相关模块,而不必影响整个系统的其余部分。
nodejs模块化
前言中说到 nodejs 的模块化机制,它遵循的是 CommonJS 规范。
1)每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
2)CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
3)require方法用于加载模块。
commonjs对模块的定义十分简单,主要分为模块引用、模块定义、模块标识三个部分。
1)模块引用
模块引用的示例代码:
ini
var http = require('http');
var express = require('express')
var example = require('./example.js');
2)模块定义
module 提供了exports对象,用于导出当前模块的方法或者变量,并且它是唯一导出的出口
module.exports.example = function () { ...}
module.exports = function(){}
3)模块标识
模块标识其实就是传递给require()函数的参数,它必须是符合小驼峰命名的字符串,或者是 以 . 和 .. 开头的相对路径或者绝对路径,它可以没有文件名后缀.js
nodejs模块化
1)Node 内部提供一个Module构建函数。所有模块都是Module的实例,每个模块内部,都有一个module对象,代表当前模块。包含以下属性:
module.id 模块的识别符,通常是带有绝对路径的模块文件名。
module.filename 模块的文件名,带有绝对路径。
module.loaded 返回一个布尔值,表示模块是否已经完成加载。
module.parent 返回一个对象,表示调用该模块的模块。
module.children 返回一个数组,表示该模块要用到的其他模块。
module.exports 表示模块对外输出的值。
2)Node使用CommonJS模块规范,内置的require命令用于加载模块文件。
3)第一次加载某个模块时,Node会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的module.exports属性。所有缓存的模块保存在require.cache之中。
javascript
// a.js
var name = 'Lucy'
exports.name = name
// b.js
var a = require('a.js')
console.log(a.name) // "Lucy"
a.name = "hello";
var b = require('./a.js')
console.log(b.name) // "hello"
上面第一次加载以后修改了name值,第二次加载的时候打印的name是上次修改的,证明是从缓存中读取的。
想删除模块的缓存可以这样:
delete require.cache[moduleName];
4)CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。请看下面这个例子。
javascript
// a.js
var counter = 3
exports.counter = counter
exports.addCounter = function(a){ counter++}
// b.js
var a = require('a.js')
console.log(a.counter) // 3
a.addCounter()
console.log(a.age) // 3
这个例子说明a.js模块加载以后,模块内部的变化就影响不到a.counter了。这是因为a.counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
主流模块化解决方案
Js模块化的主流解决方案,除了上面讲述的CommonJS,还有AMD、UMD、CMD和ESM规范
1.1 AMD规范
专门用于浏览器端的模块化规范,模块的加载是异步的。AMD规范只定义了一个全局函数define,通过它可以定义和引用模块,它有3个参数:
javascript
// 定义 define(id?, [depends]?, callback);
// 注意:第一个参数为可选,如果未指定id,缺省值为模块加载器请求该脚本时使用的模块id
// 第二个参数为可选,如果未指定,缺省值为["require", "exports",
"module"]
define('myModule', ['moduleA', 'moduleB'],
(moduleA, moduleB) => {
// 使用moduleA.xxx
// 使用moduleB.xxx
})
// 使用 require([module], callback);
require(['myModule'], (myModule) => {
// 使用myModule
})
1.2 UMD 规范
统一模块标准,它不是模块管理规范,而是带有前后端同构思想的模块封装工具。通过UMD可以在不同环境选择对应的模块规范。比如nodejs使用commonjs,在浏览器下支持AMD的,采用AMD模块,否则导出为全局函数。
它的实现原理:
1、判断是否支持AMD(即define是否存在),存在则使用AMD方式加载模块
2、判断是否支持nodejs模块格式(即exports是否存在),存在则使用commonjs加载模块
3、如果前两个都不存在,则将模块公开到全局,比如window或global
javascript
// 1、module是否为对象
// 2、module是否有exports
// 3、define是否不是函数
// 以上都满足则为commonjs
// 否则为AMD
(define('module', [], (require, exports, module)
=> {}))(
typeof module === 'object' &&
module.exports &&
typeof define !== 'function' ?
factory => module.exports = factory(require, exports, module) : // commonjs
define // AMD
)
1.3 CMD规范
按需加载,它通过一个全局函数define来实现的,但只有一个参数,该参数可以是函数,也可以是对象。如果是对象,那么模块导出的就是这个对象;如果是函数,这个函数会被传入3个参数:require,exports和 module
javascript
// 如果参数是函数
// 第1个参数为require,用来引用其它模块,也可以调用require.async函数来异步调用模块
// 第2个参数为exports,是个对象,当定义模块时,需要通过向参数exports添加属性来导出模块API
// 第3个参数module是一个对象,它包含3个属性:uri模块完整的路径;dependencies,模块的依赖;exports,模块需要被导出的API,作用同第二个参数
define('module',(require, exports, module)=>{
// 导入的模块
let a = require(a);
// 定义导出的对象
exports.b = function() { }
// 定义当前模块的信息,比如模块id名称
module.id = "b"
})
1.4 ESM规范
ES6 是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了,这一代的标准定义了模块的规范(ESM规范)。
esm是静态声明的:
1、必须在模块首部声明
2、不可以使用表达式或变量
3、不允许被嵌套到其它语句中使用
模块功能主要由两个命令构成: exports 和 import。
export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
arduino
export const nickname = "zhangsan";
export const obj = {
nickname:
"zhangsan",
age: 18,
}
import { foo, obj } form '模块标识符'
CommonJS和ESM区别
两者的模块导入导出语法不同,CommonJs是通过module.exports,exports导出,require导入;ESM则是export导出,import导入。
CommonJs是运行时加载模块,ESM是在静态编译期间就确定模块的依赖。
ESM在编译期间会将所有import提升到顶部,CommonJs不会提升require。
CommonJs导出的是一个值拷贝,会对加载结果进行缓存,一旦内部再修改这个值,则不会同步到外部。ESM是导出的一个引用,内部修改可以同步到外部。
CommonJs中顶层的this指向这个模块本身,而ESM中顶层this指向undefined。
CommonJS加载的是整个模块,将所有的接口全部加载进来,ESM可以单独加载其中的某个接口。
后续你不知道的JS模块化系列
- 典型模块化方案实现原理
- 模块化包管理方案与简单开发一个npm包,并发布
- 模块化包管理方案实现原理
- 典型常用npm包介绍与使用
- 典型npm包实现原理系列
参考文档
- npm.nodejs.cn/about-npm (关于npm)
- cloud.tencent.com/developer/a... (JS模块化)
- blog.51cto.com/u_16145034/... (JS模块化)
- zhuanlan.zhihu.com/p/527601988 (JS模块化)
参与 DevUI
未来DevUI社区也会将更多内部优秀工程实践开源与内容发布,欢迎朋友们加入我们的社区,一起打造有竞争力的开源产品,营造有温度的开源社区,期待你的加入!(微信公众号:DevUI)