JavaScript是如何实现模块化的?

一. 为什么要有模块化?

1. 内联的JavaScript语句

JavaScript诞生之初的应用比较简单,例如用户输入校验。代码量少,一般直接写在html中。

<script>
var name="kobe"
console.log(name)
</script>

2. 独立的JavaScript文件

随着用户体验要求变高,前端承载的功能变多了,代码量也随着膨胀

例如,axios的出现带来了前后端分离,前端通过后端接口获取数据,动态渲染页面;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环境都是原生支持的,不需要格式转换。

1. ES6 Module加载模块

在ES6 Module中使用import导入模块,使用export导出模块。

import moduleA from 'moduleA'

export default {
    name:'moduleB'
}

在编译阶段,根据import/export构建模块依赖关系,即模块地图sourceMap,每个节点保存了模块导出变量的内存地址和模块加载状态。
在运行阶段,执行模块时先更新节点的模块加载状态,遇到import时,则从模块地图中获取到对应模块导出变量的内存地址。

2. 浏览器加载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语言标准,将会逐步成为统一的模块化规范。

六. 实践问题

1. module.exports和exports的区别?

在CommonJS中,这两者指向同一块内存,都是用于导出模块值。exports只是方便使用。

但实际导出模块的是module.exports的值,也就是说,直接给module.exports或exports赋值时,将可能导致数据丢失。

module.exports.a = '1';
exports.b = '2'; //正常导出

exports = {c:'3'}; //无效,不会导出c
module.exports = {d:'3'}; // 导出d,但会导致之前的被覆盖。

2. 循环引用

当模块之间的依赖关系构成闭环时,就会出现循环引用的情况,这种如何避免死循环呢?

CommonJS使用模块缓存。每次require()时先检查缓存,如果没有缓存则创建module对象并加入缓存,再执行模块脚本。

ES6 Module使用模块地图。每个节点会记录模块的加载状态,如果已经加载了则不再执行模块脚本。

3. 导出拷贝值和导出引用值的区别?

CommonJS使用导出拷贝值,即将模块导出值存放到一块新的内存,这样修改模块内部数据时不会影响到已导出的数据。

// counter.js
let count = 0;
module.exports.count = count;
module.exports.add = function(){
    return ++count;
}

// main.js
require c from 'counter.js'

console.log(c.count);  //输出: 0
console.log(c.add());  //输出: 1
console.log(c.count);  //输出: 0

ES6 Module使用导出引用值,即导出模块值的内存地址,可以实现同步修改模块内部数据。

// counter.js
export let count = 0;
export function add(){
    return ++count;
}

// main.js
import {count, add} from 'counter.js'

console.log(c.count);  //输出: 0
console.log(c.add());  //输出: 1
console.log(c.count);  //输出: 1

4. 静态加载 VS 动态加载

ES6 Module使用静态加载,在编译阶段就构建好模块地图,有利于做静态分析优化。
CommonJS使用动态加载,在运行阶段决定模块依赖关系。

参考资料

《JavaScript高级程序设计》

前端科普系列-CommonJS:不是前端却革命了前端

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值