你需要知道的前端模块化

1,711 阅读14分钟

前言

很久之前,js只是负责比较简单的交互,代码量很少,所以所有功能代码都混在一起,但是随着前端技术的发展,js能做的事情也越来越多,像node已经是跑在服务器上的js了。如果js不能模块化,所有的代码都写到一个文件里,必然导致单个文件代码量大,业务功能模块逻辑不够清晰,难以维护。基于以上历史原因和发展需要,js模块化概念顺其自然的产生了。

概念

将文件(复杂的程序)按照一定的语法,遵循一定的规范将它拆分为几个独立的文件,这些文件应该具有相互独立和功能逻辑单一的特性,对外暴露数据或接口,在需要的时候在进行导入或引用

模块化的发展历程

1、全局函数

将不同的单一功能封装成不同的全局函数,然后在对这些功能函数进行组合,实现相应的业务逻辑。但是这样的方式会存在以下一些问题。

  • 污染全局命名变量
  • 容易引起命名冲突导致数据不够安全(数据被不知的情况下修改了)
  • 模块之间看不出有什么直接的关系
function module1(){}
function module2(){}
function add(){
  module1()
  module2()
}

这里有个值得借鉴的地方,就是对函数的抽离和封装成功能单一。这在平常开发的时候也是很实用的,可以让函数更加的纯粹,只负责单一的事情,这样就比较好维护和扩展,是符合单一原则的。

2、命名空间简单的对象封装

将每一个模块封装成一个简单的对象,方法函数和变量都封装为这个空间对象的属性,以对象为一个整体,减少了全局变量的污染和命名冲突,但是数据安全并没有解决,外部可以直接修改对象内容,因为对象的属性可以重写,导致一些数据被外部意外的修改了。

let waterModule = {
	data:'water',
	log(){
		console.log(`log() ${this.data}`)
	}
	go(){
		console.log(`go() ${this.data}`)
	}
}
waterModule.data = 'newWater'
waterModule.log()

这在一定程度上改变全局函数方式的一些问题,但是这样会暴露所有模块成员,内部状态可以被外部改写。

3、 IIFE匿名函数自调用(闭包)

  • 数据是私用的,外部只能通过暴露的方法操作
  • 将数据和行为封装到一个函数内部,通过window添加属性来向外暴露接口
  • 如果当前这个模块依赖另一个模块怎么办
// index.html文件
<script type="text/javascript" src="waterModule.js"></script>
<script type="text/javascript">
    waterModule.logData1()
    waterModule.logData2()
    console.log(waterModule.data) //undefined 不能访问模块内部数据
    waterModule.data = 'xxxx' //不是修改的模块内部的data
    waterModule.logData1() //没有改变
</script>
// waterModule.js文件
(function(window) {
  let data = 'water'
  //操作数据的函数
  function logData1() {
    //用于暴露有函数
    console.log(`logData1() ${data}`)
  }
  function logData2() {
    //用于暴露有函数
    console.log(`logData2() ${data}`)
    otherFun() //内部调用
  }
  function otherFunc() {
    //内部私有的函数外部无法调用
    console.log('otherFunc()')
  }
  //暴露行为
  window.waterModule = { logData1, logData2 } 
})(window)

这个封装方式,是内部数据的做到私有和独立。修改内部的数据只能通过调用暴露的方法才能修改,不然直接修改或者添加的数据并不是同一个数据比如:

(function (window) {
  var data = "water";

  function showData() {
    console.log(`data is ${data}`);
  }
  function updateData() {
    data = "newWater";
    console.log(`data is ${data} `);
  }
  window.waterModule = { showData, updateData };
})(window);

只能通过updateData方法来修改内部的变量值,这就保证了数据的安全,不会被外部调用方法的时候无意间修改的变量的值

3、IIFE增强引入依赖

// module.js文件
(function(window, $) {
  let data = 'water'
  //操作数据的函数
  function logData1() {
    //用于暴露有函数
    console.log(`logData1 ${data}`)
    $('body').css('height', '200px')
  }
  function logData2() {
    //用于暴露有函数
    console.log(`logData2() ${data}`)
    otherFunc() //内部调用
  }
  function otherFunc() {
    //内部私有的函数
    console.log('otherFun()')
  }
  //暴露行为
  window.waterModule = { logData1, logData2 }
})(window, jQuery)
// index.html文件
  <!-- 引入的js必须有一定顺序 -->
  <script type="text/javascript" src="jquery-1.10.1.js"></script>
  <script type="text/javascript" src="waterModule.js"></script>
  <script type="text/javascript">
    waterModule.logData1()
  </script>

通过jQuery的方法将页面dom的属性,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显,这个模式是现代模块化实现的基石。

这样的模块化有如下优点:

  • 避免命名冲突和减少命名空间污染
  • 更好的分离和按需加载
  • 高复用
  • 高可维护

但是同时也暴露出一些问题

  • http请求变得很多,一个功能需要依赖多少个模块就会发送多少个http请求来后去模块,导致http请求过多。基于性能考虑方面,过多的http请求肯定是不行的,因为http的请求数是有限制的

  • 依赖模糊,不知道他们的具体依赖关系是什么,很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。进而导致代码执行报错,比如需要使用的模块加载滞后,会导致暴露的方法找不见,代码报错。

  • 以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。

而这些问题可以通过接下来介绍的模块化规范来解决。

其实jQuery库也是如此方式实现的,jQuery的做法就是使用了一个匿名函数形成一个闭包,然后自执行,所有逻辑都在这个闭包中完成,这样不会污染全局变量,也无法在其他地方访问闭包内的变量。最后将 jQuery对象进行暴露,这样在外部就可以通过 jQuery或者 $访问闭包内的其他变量了。

代码片段如下:

(function (window, undefined) {
  //...
  if (typeof window === "object" && typeof window.document === "object") {
    window.jQuery = window.$ = jQuery;
  }
})(window);

很多人最开始不能理解为什么自执行函数要传入 window,主要有两个原因:

  1. 使window全局变量变成局部变量,当内部代码访问window对象时,不用顺着作用域链逐级查找,可以更快的访问 window
  2. 为了压缩代码时更好的优化;

另外传入 undefined 一部分原因是因为压缩优化,另一部分是由于一些低版本浏览器的兼容需要。

此时,模块化已经初具规模,已经可以实现一些基础功能。事实上,这就是现代模块化方案的基石。

下面介绍开发中最流行的CJS、 AMD、ES6,、CMD、UMD规范。

CJS(CommonJS)

概念

Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。

特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块加载的顺序,按照其在代码中引入的顺序。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • module.exports属性输出的是值的拷贝,一旦输出操作完成,模块内发生的任何变化不会影响到已输出的值。
  • module.exportsexports的用法以及区别。

基本语法

  • 暴露模块:module.exports = valueexports.key = value
  • 引入模块:require(mod),如果是第三方模块,mod为模块名;如果是自定义模块,mod为模块文件路径

cjs规定每个模块内部。module变量代表当前模块。这个变量是一个对象,它的exports属性(module.exports)是对外的接口,加载某个模块,其实就是加载该模块的module.exports属性

var x =5
var sum = function(value){
	return value + x
}
module.exports.x = x
module.exports.sum = sum

上面代码通过module.exports输出变量x和函数sum

var example = require('./example.js')
console.log(example.x)
console.log(example.sum(1))

require命令用于加载模块文件。require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。

加载模式

CommonJS规范加载模块是同步的,在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。也就是说,只有加载完成,才能执行后面的操作。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,读取非常快,所以这样做不会有问题。

模块的加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。这点与ES6模块化有重大差异(下文会介绍),请看下面这个例子:

// lib.js
var a = 1;
function funcAdd() {
  a++;
}
module.exports = {
  a: a, // a
  funcAdd: funcAdd, // funcAdd
};

上面代码输出内部变量a和改写这个变量的内部方法funcAdd。

// main.js
var a = require('./lib').a;
var funcAdd = require('./lib').funcAdd;

console.log(a);  // 1
funcAdd();
console.log(a); // 1

上面代码说明,a输出以后,lib.js模块内部的变化就影响不到a了。这是因为a是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值

module对象

Node内部提供一个Module构建函数。所有模块都是Module的实例。每个模块内部,都有一个module对象,代表当前模块。它有以下属性。

  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。-
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
  • module.parent 返回一个对象,表示调用该模块的模块。
  • module.children 返回一个数组,表示该模块要用到的其他模块。
  • module.exports 表示模块对外输出的值。

如果在命令行下调用某个模块,比如node cli.js,那么module.parent就是null。如果是在脚本之中调用,比如require('./cli.js'),那么module.parent就是调用它的模块。利用这一点,可以判断当前模块是否为入口脚本。

module.exports和exports 详解

  1. module.exports: module.exports属性表示当前模块对外输出的接口,当其他文件加载该模块,实际上就是读取 module.exports这个属性;
  2. exports node 为每一个模块提供了一个 exports对象 ,这个 exports对象的引用指向 module.exports。这相当于隐式的声明 var exports = module.exports;。 这样,在对外输出时,可以在这个变量上添加属性方法。 例如:exports.test = function () { // ... }; 注意:不能把 exports直接指向一个值(exports = xxx方式赋值),这样会改变exports的引用地址,相当于切断了exportsmodule.exports的关系。

总结下 module.exports 和 exports 的区别就是:

  1. exports = module.exports = {}exportsmodule.exports的一个引用
  2. require引用模块后,返回给调用者的是 module.exports而不是 exports; 3.exports.xxx的方式更新属性,相当于修改了module.exports,那么该属性对调用模块可见;
  3. exprots = xxx的方式相当于给 exports重新赋值,改变引用,失去了之前的 module.exports引用,该属性对调用模块不可见;

如果你分不清,那么就使用 module.exports

AMD

概念

CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD规范则是非同步加载模块,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS规范在浏览器端实现要来着早。

基本语法

定义模块

// 语法
define([], function () {
  // 模块可以直接返回函数,也可返回对象
  return {
    fn() {
      // ...
    },
  };
});
// 例子
define(['mod'], function(mod){
  function func(){
    mod.log('water');
  }

  return {
    func
  };
});

define函数是定义模块的,但是可以看到,如果这个模块依赖其他模块,那么就需要把依赖的模块放在第一个参数数组内,因为是数组所以可以依赖多个模块,在回调函数中进行使用。

引用使用模块

// 语法
require([module], callback);
// 例子
require(['jquery', 'math'],function($, math){
  var sum = math.add(1,2);
  $("#sum").html(sum);
});

AMD规范也采用require方法加载模块,但是不同于CJS规范,它要求两个参数:第一个参数就是要加载的模块的数组集合,第二个参数就是加载成功后的回调函数。

简单看下require.js在全局定义了define和require,并且在最外层包裹的是一个IIFE函数,将global和settimeout传入其中

var requirejs,require,define;
(function(){
  // ...
}(this,(typeof setTimeout === 'undefined'?undefined:setTimeout)))

AMD模块定义的方法非常清晰,不会污染全局环境,能够清楚地显示依赖关系。AMD模式可以用于浏览器环境,并且允许非同步加载模块,也可以根据需要动态加载模块。

CMD

概念

CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。而且CMD是综合了CJS和AMD的特点,可以同步也可以异步。AMD的实现者require.js在申明依赖的模块时,会在第一时间加载并执行模块内的代码。

基本语法

定义模块

//定义没有依赖的模块
define(function(require, exports, module){
  exports.key = value
  module.exports = value
})


//定义有依赖的模块
define(function(require, exports, module){
  //引入依赖模块(同步)
  var module2 = require('./module2')
  //引入依赖模块(异步)
    require.async('./module3', function (m3) {
    })
  //暴露模块
  exports.key = value
})

引入使用模块

define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.func()
  m4.func()
})

CMD的代表就是sea.js

ES6

概念

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。有几个需要注意的点

  • CJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CJS 模块是运行时加载,ES6 模块是编译时输出接口,静态化,编译时就确定模块之间的关系,每个模块的输入和输出变量也是确定的
  • ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块

基本语法

定义模块

//module1.js文件
// 分别暴露
export function func1() {
  console.log('func1')
}
export function func2() {
  console.log('func2')
}


//module2.js文件
// 统一暴露
function func3() {
  console.log('func3()')
}
function func4() {
  console.log('func4()')
}
export { func3, func4 }


//module3.js文件
// 默认导出 可以暴露任意数据类项,暴露什么数据,接收到就是什么数据
export default () => {
  console.log('默认导出')
}

引入使用模块

import { func1, func2 } from './module1'
import { func3, func4 } from './module2'
import module3 from './module3'
func1()
func2()
func3()
func4()
module3()

这里就简单写一下基本用法,还有一些其他的用法可以去看下es6的文档,写的比较详细

UMD

概念

就是一种通用模块定义规范,可以通过运行编译时让同一个代码模块在使用CJS、CMD、AMD的项目中运行。这样就使得js包运行在浏览器端、服务端等,兼容性更强,写法上更加统一一致。

基本语法

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD 规范
    define(["b"], factory);
  } else if (typeof module === "object" && module.exports) {
    // 类 Node 环境,并不支持完全严格的 CommonJS 规范
    // 但是属于 CommonJS-like 环境,支持 module.exports 用法
    module.exports = factory(require("b"));
  } else {
    // 浏览器环境
    root.returnExports = factory(root.b);
  }
})(this, function (b) {
  // 返回值作为 export 内容
  return {};
});

小结

  1. CJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。

  2. AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。

  3. CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。

  4. ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。