JavaScript是如何实现模块化的?
一. 为什么要有模块化?
1. 内联的JavaScript语句
JavaScript诞生之初的应用比较简单,例如用户输入校验。代码量少,一般直接写在html中。
<script>
var name="kobe"
console.log(name)
</script>
2. 独立的JavaScript文件
随着用户体验要求变高,前端承载的功能变多了,代码量也随着膨胀。
例如,ajax的出现带来了前后端分离,前端通过后端接口获取数据,动态渲染页面 ;SPA让页面切换更丝滑,但需要实现前端路由,状态管理等功能。
为了提高代码的可复用性,人们开始将JavaScript从html解耦,封装成独立的模块。
// index.html
<script src="moduleA.js"></script>
// moduleA.js
var name="kobe";
引入的外部JavaScript文件中声明的变量是全局作用域的,当引用的第三方模块多了,势必会造成全局变量冲突。
// index.html
<script src="moduleA.js"></script>
<script src="moduleB.js"></script>
// moduleA.js
var name="kobe";
// moduleB.js
var name="iverson";
3. 命名空间
解决全局变量冲突的方法之一是给每个模块分配命名空间,进行隔离。
// index.html
<script src="moduleA.js"></script>
<script src="moduleB.js"></script>
// moduleA.js
var moduleA = {};
moduleA.name="kobe";
// moduleB.js
var moduleB = {};
moduleB.name="iverson";
但是,JavaScript对象属性默认是公有的,这意味着模块内的变量既能被外部访问let scopeVar = moduleA.name,也可能被外部修改moduleA.name = 'xxx'。那么,如何保护数据不被外部修改?
4. 闭包
为了保护数据不被外部修改,人们将模块封装在函数作用域内。
// index.html
<script src="moduleA.js"></script>
<script src="moduleB.js"></script>
// moduleA.js
let moduleA = (function (){
let name = 'kobe';
return {
getName:function(){
return name;
}
}
})();
// moduleB.js
let moduleB = (function (){
let name = 'iverson';
return {
getName:function(){
return name;
}
}
})();
使用立即调用的函数表达式(IIFE) 创建闭包,将模块封装在函数作用域内,并对外提供可访问模块的公共API。这样,在moduleB中可以通过moduleA.getName()访问数据,但是必须保证moduleA在moduleB之前完成加载。当模块数量多了,如何管理好模块依赖又是一个问题。
小结
综上所述,为了提高JavaScript代码的可复用性,开发者尝试利用JavaScript语言特性来模拟实现模块化。分别用命名空间和闭包来解决全局变量冲突和实现数据保护。然而,管理模块依赖是比较复杂的问题,因此诞生了CommonJS,AMD,UMD等模块化方案。
二. 模块模式
模块化思想就是把代码拆分成独立的模块,逻辑独立,各自实现,然后再把它们连接起来实现完整功能。对应的实现模式就叫模块模式,它是所有模块化系统的基础。
1. 模块标识符
每个模块都有一个可用于引用它的标识符。
import axios form "axios"
import msgBox from "/src/ui.js"
- 原生浏览器的模块标识符是绝对文件路径。
- Node.js环境默认会搜索node_modules目录,可省去路径。
2.模块依赖
模块化系统的核心是管理依赖,即保证模块正常运行时所需要的外部依赖能够完成加载和初始化。
// moduleA.js
import moduleB form "moduleB.js"
console.log(moduleB.getName())
3.模块加载
加载模块的一般步骤是: 加载模块及其依赖的代码,在所有依赖加载和初始化之后,才会执行入口模块。
a. 同步加载
浏览器加载JavaScript文件默认是同步的。
例如moduleA.js依赖了moduleB.js,moduleB.js依赖了moduleC.js,则必需按以下顺序加载依赖。
<script src="moduleC.js"></script>
<script src="moduleB.js"></script>
<script src="moduleA.js"></script>
同步加载的缺点很明显:
- 性能: 阻塞页面渲染,直到所有依赖按顺序加载完。
- 复杂性: 需要手动管理依赖的加载顺序。
b. 异步加载
异步加载模块,不会阻塞页面,只需要在加载完成后执行回调。
例如上面同步加载的例子改用异步加载,则不会阻塞页面渲染,只需要在moduleC.js和moduleB.js完成加载和初始化之后,回调执行moduleA.js。
可使用<script>
的defer或async属性实现异步加载。
小结
模块模式是模块化思想的一种实现模式,也是所有模块化系统的基础。具体包括模块标识符,模块依赖,模块加载等。
三. CommonJS
CommonJS是一种面向服务器端的模块化规范,它规定一个文件就是一个模块,使用require()导入模块,使用module.exports导出模块。
var moduleB = require('./moduleB');
module.exports = {
stuff: moduleB.doStuff();
};
require()的返回值就是module.exports导出的对象。
1. 缓存加载
首次require一个模块时,会加载并执行该模块文件,并返回模块的module.exports值,该值会被缓存。下次require该模块时,取的是缓存数据,不需要再加载执行模块了。
2. 拷贝导出
require()返回的是模块导出对象的拷贝,也就是说,模块内部的变化不会影响已导出的对象。
3. 同步加载
CommonJS使用同步加载,这并不适用浏览器端,因为网络延迟会阻塞页面渲染。
4. 浏览器加载CommonJS
npm模块是遵循CommonJS的,不能直接在浏览器运行,需要做格式转换。
实现思路:
a. 模块封装。
使用IIFE创建闭包来封装模块,并传入module,module.exports,require。
(function(module,exports,require){
require("a.js");
// 模块内容
module.exports.b=2;
})(module,module.exports,require)
b. 依赖管理。
将所有模块都封装在各自的IIFE中,从入口模块开始,递归处理依赖,最终打包成一个能在浏览器中运行的JavaScript文件。
(function(modules){
require("index.js");
})({
"a.js":function(module,exports,require){
模块a的内容
},
"index.js":function(module,exports,require){
模块index的内容
}
})
c.模块加载。
定义一个require函数来加载模块,实现缓存加载和拷贝导出。
(function(modules){
let installedModules={};
function require(moduleName){
if(installedModules[moduleName]){
return installedModules[moduleName].exports;
}
let module = {
moduleName,
exports:{}
}
modules[moduleName].call(module,module,module.exports,require);
modules[moduleName]=module;
return module.exports;
}
return require("index.js");
})({
"a.js":function(module,exports,require){
模块a的内容
},
"index.js":function(module,exports,require){
模块index的内容
}
})
小结
CommonJS规定每个文件是一个模块,使用require()导入模块,使用module.exports导出模块。其中,还使用了缓存加载和拷贝导出。
CommonJS使用同步加载,这不适用浏览器,因为网络延迟会阻塞页面渲染。
npm模块是遵循CommonJS的,不能直接在浏览器运行,需要做格式转换。
四. AMD
AMD是一种面向浏览器端的模块化规范,使用异步加载,并在依赖加载后再回调执行入口模块。
定义模块:
define('moduleA', ['moduleB', 'moduleC'], function factory(moduleB, moduleC){
let nameB = moduleB.getName();
let nameC = moduleC.getName();
let name = nameB + nameC;
return {
getName: function(){
return name;
}
}
})
define()的第一个参数是模块标识符。第二个参数是依赖。第三个参数是模块工厂函数,用来封装模块。
导入模块:
require( ['moduleB', 'moduleC'], function main(moduleB, moduleC){
let nameB = moduleB.getName();
let nameC = moduleC.getName();
let name = nameB + nameC;
console.log(name);
})
五. ES6 Module
ES6 Module是JavaScript语言标准的模块化方案,浏览器和node环境都是原生支持的,不需要格式转换。
在ES6 Module中使用import导入模块,使用export导出模块。
import moduleA from 'moduleA'
export default {
name:'moduleB'
}
浏览器加载ES6 Module
<script type="module">
// 模块代码
</script>
<script type="module" src="module path"></script>
在<script>中设置type="module"属性,浏览器就会当作模块处理,而不是普通的脚本文件。
<script type="module">跟<script defer>一样使用异步加载,然后延迟到文档解析完成后再按顺序执行脚本。
总结
随着用户体验要求变高,前端承载的功能变多了,代码量也随着膨胀。
为了提高代码的可复用性,开发者采用了模块化思想并利用JavaScript语言特性进行模块化实践,包括命名空间,函数作用域,闭包等。
同时,诞生了CommonJS,AMD和ES6 Module等模块化规范。其中,前两者是社区版本的实现,分别是面向服务器端和浏览器端的规范。而ES6 Module是JavaScript语言标准,将会逐步成为统一的模块化规范。
参考资料
《JavaScript高级程序设计》
转载自:https://juejin.cn/post/7166243220546912286