模块化历史演进

2,286 阅读13分钟

1. 概述

模块化开发是当下最重要的前端开发规范之一,随着项目需求日益复杂,前端代码已经膨胀到需要花费大量时间去管理的程度,模块化是最主流的解决方式,通过将代码按照功能进行拆分,从而降低开发复杂度。

模块化是一种理论思想,并不包含具体的实现,早期的前端技术标准并没有预料到前端会有今天这样的一个规模,所以在设计上的遗留问题导致实现前端模块化存在着很多的问题,虽然这些问题已经被新推出的标准和方法掩埋了,但是这个掩埋的过程还是值得学习和思考的。这有利于更加的了解前端领域。

2. 第一阶段

第一阶段是以文件拆分的方式实现模块化,这也是web中最原始的模块系统,开发时拆分不同的js文件,在html中引入所有的js依次执行。

<body>
    <script src="module_A.js"></script>
    <script src="module_B.js"></script>
    <script src="module_C.js"></script>
</body>

这样方式存在的最大问题就是代码会污染全局作用域,所有的代码都在全局工作,没有一个私有空间,导致模块中的内容都可以在模块外被访问和修改。

由于代码运行在全局作用域也就存在很多的命名冲突问题。模块无法管理各自的依赖关系,早期模块化完全依靠约定,项目到一定体量的时候就无法维持了,所以出现各种各样奇怪的命名或者代码。

3. 第二阶段

第二阶段约定每个模块只暴露一个全局对象,所有的模块成员都挂载在这个对象下面。

具体的实现是在第一个阶段的基础上,将每个模块包裹成一个全局对象的方式,类似于在模块内为模块中的成员添加了命名空间的感觉,通过命名空间的方式可以减小命名冲突的问题,但是仍旧没有私有空间,模块成员可以在外部被访问和修改,模块间的依赖关系也没有被解决。

var module_A = { // 所有的成员都挂在module_A这个对象上。
    name: 'yd',
    age: 18,
}
<script src="module_A.js"></script>
<script>
    module_A.name; // yd 可以被访问
    module_A.name = 'zd'; // 可以被修改
    module_A.name; // zd
</script>

4. 第三阶段

第三阶段解决了私有空间的问题,具体实现是将每一个模块中所有的内容放到一个函数的私有作用域中,将需要对外暴露的成员,通过挂载全局对象的方式实现。

;(function() {
    var name = 'yd';
    var age = 18;
    window.module_A = {
        name: name,
        age: age
    }
})()

这种方式实现了私有成员的概念,私有成员只能在内部通过闭包的方式去访问,而在外部是没办法使用的,这就确保了私有变量的安全。自执行函数的参数可以作为依赖声明去使用,这使得每个模块之间的依赖关系实现十分明显。比如使用jQuery就接收jQuery参数,这样在后期维护的时候,就可以知道,想要使用该模块就要引入jQuery

;(function($) {
    var name = 'module_A';
    function setBackColor() {
        $('body').css({ backgroundColor: 'yellow'});
    }
    window.module_A = {
        name: name,
        setBackColor, setBackColor
    }
})(jQuery)

这种方法是早起在没有工具和规范的情况下,对模块化思想的落地方式,解决了前端领域在模块化遇到的各种各样的问题。

5. 模块化规范的出现

以上的方式都是以原始的模块内容为基础,通过约定的方式实现模块化,这些方式在不同的开发者去实施的时候会有一些细微的差别,为了统一不同的开发者和不同项目之间的差异。需要一个标准去规范模块化的实现方式。

在模块化中针对模块化的加载问题以上这几种方式,都是通过script标签手动引入的,意味着加载并不受代码控制,一旦时间久了,维护起来就非常麻烦,可能存在使用的模块忘记引入,移除模块忘记移除,这些都会产生很大的问题。

6. CommonJS

CommonJSNodeJS提出的一套模块化标准,在NodeJS中所有模块必须要遵守CommonJS规范,这个规范约定了:

1.一个文件就是一个模块

2.每个模块都有独立的作用域

3.通过module.exports 导出成员

4.通过require函数载入模块。

想在浏览器中使用该规范是有问题的,CommonJS以同步的模式加载模块的,因为Node执行机制是在启动时加载模块,执行过程中不需要加载模块只会使用模块。

这种方式在Node中没有问题。但是在浏览器端使用CommonJS规范必然导致效率低下,因为每次页面加载都会导致大量同步请求出现,所以说早期前端模块化中,并没有选择CommonJS这种规范。而是专门为浏览器端,针对浏览器特点重新设计了一个规范。

// 定义模块math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在这里写上需要向外暴露的函数、变量
  add: add,
  basicNum: basicNum
}

/** 必须加./路径,不加的话只会去node_modules文件找 **/
// 引用自定义的模块时,参数包含路径,可省略.js
var math = require('./math');
math.add(2, 5);

// 引用核心模块时,不需要带路径
var http = require('http');
http.createService(...).listen(3000);

exports对于本身来讲是一个变量(对象),它不是module的引用,它是{}的引用,它指向module.exports{}模块。只能使用.语法 向外暴露变量。

exports.name = "yd";

module是一个变量,指向一块内存,exportsmodule中的一个属性,存储在内存中,然后exports属性指向{}模块。既可以使用.语法,也可以使用=直接赋值。


module.exports.name = "yd";

module.exports = {
    name: 'yd'
}

7. AMD

Asynchronous Module Definition异步的模块定义规范,同期也推出了requireJS库实现了AMD规范,本身也是一个强大的模块加载器。

AMD规范中,每个模块要通过define方式定义,可以传递三个参数,第一个参数是当前模块的名字,供其他模块引入使用,第二个参数是一个数组,声明依赖哪些模块,第三个参数是回调函数,依赖加载完成之后,执行该函数,函数中的参数为对应的模块对象。函数的返回值为当前模块被其他模块引用时的导出内容。

define('module1', ['jQuery', './module2'], function($, module2) {
    return {
        start: function() {
            $('body').css({ backgroundColor: 'yellow'});
            module2();
        }
    }
})

模块加载使用require,用法和define类似,只是接收两个参数,第一个参数是依赖的模块数组,第二个参数为回调函数。

require(['jQuery', 'module1'], function($, module1) {
    // 业务代码
    module1.start();
})

require去加载模块的时候,内部会发送一个script请求,加载对应的模块脚本,并且执行相应模块的代码,目前绝大多数第三方库都支持AMD规范。AMD的生态还是比较好的,只是使用起来比较复杂。

因为在编写业务的时候,除了业务代码还要编写很多的definerequire等操作模块的代码,会导致代码复杂度提高,项目中如果模块划分过于细致的话,会导致模块JS请求频繁,请求次数越多页面效率也就越低。

AMD是前端模块化演进道路上的一步,是一种妥协的实现模块化的方式,他并不是最终的解决方案。这在当时的环境背景下还是非常有意义的,给前端模块化提供了一个标准。

8. CMD

Common Module Definition这个标准有点类似CommonJS, 在使用上和requireJS差不多,算是一个重复的轮子,当时的想法是希望CMD写出来的代码尽可能的与CommonJS类似,从而减轻开发者的学习成本,这种方式在后来也被requireJS兼容了。

CMD使用上还与AMD很类似,不同点在于AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

/** CMD写法 **/
define(function(require, exports, module) {
    var a = require('./a'); //在需要时申明
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});

// 加载模块
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

9. ES Module

随着技术的发展,javascript的标准逐渐完善,在模块化这一块的实现方式相对以往有了一个很大的变化了。现如今前端模块化已经算是非常成熟了,而且目前大家针对于前端模块化的最佳实现方式已经基本统一。

NodeJS中会遵循CommonJS规范,在浏览器环境中会采用ES Module规范,当然也会有极少部分例外情况出现,但是前端模块化目前算是统一成了CommonJSES Module这两个规范了。对于现代的前端开发者,主要掌握这两种规范就可以了。

Node中使用CommonJS是没什么好说的,他是Node内置的模块系统,没有环境问题,但是对于ES Module就会相对复杂一些。

ES ModuleECMAScript2015当中定义的一套模块系统,他是最近几年才被定义的一套标准,所以肯定存在各种各样的环境兼容性问题。

最早在这个标准刚推出的时候,所有主流浏览器在都是不支持这个特性的,但是随着webpack等打包工具的出现,这一标准才逐渐开始普及,截止到目前ES Module可以说是最主流的前端模块化方案。

相比AMD这种社区提出的开发规范,ES Module可以说在语言层面实现了模块化所以更加完善,另外,现如今绝对多数浏览器已经开始支持ES Module这个规范了,以后可以直接使用这种规范,而且在短期,针对模块化来说也不会有新的标准和轮子出现。

语法特性绝大多数浏览器已经支持ES Modiule了,通过给script标签添加type=module的属性就可以使用ES Module的标准去执行javascript代码。

<script type="module">
console.log('this is es module');
</script>

ES Module规范下,代码会默认采用严格模式(use strict)运行,并且每个ES Module都运行在单独的作用域中,也就意味着变量间不会互相干扰。外部js文件是通过CORS的方式请求的,所以要求外部的js文件地址要支持跨域请求。ES Modulescript标签会延迟脚本加载,等待网页请求完资源之后才执行,这和使用deffer的资源方式请求相同。

1. 基本使用

在一个模块中,可以使用export导出模块中的内容, 无论是变量,函数,还是class对象

var foo = 'es module';

function hello() {

}

class Person {

}

export { foo, hello, Person, foo }

导出时可以使用as改变导出成员的名称。


export { foo, hello, Person, foo as fooName }

使用import引入模块内容,

import { foo, hello, Person, fooName } from './module.js';

一旦将导出成员的名称设置为default,那么在引用的时候就一定要把这个成员重命名掉,因为default是一个关键字,不能把他当做一个变量使用。

// 导出
var foo = 'es module';

export {
    default: foo,
}
// 导入
import { default as fooName } from './module.js';

default作为默认导出使用,引入的时候可以使用一个合法的名字接收默认导出内容

// 默认导出
export default 'yd';

// 导入
import name from './module.js';

使用ES Module导出成员的时候,比如下面这种情况。

var name = 'yd';
var age = 18;
export { name, age };

很多人会认为导出的是一个字面量,认为是ES6的对象简写模式,因为写法看起来和解构一样,但实际并不是这样,export {}`` 是一个固定的语法,和对象的没有任何关系,export```单独使用的时候必须要用两个花括号包裹导出的成员。

如果想要导出一个对象的话,就不能使用export导出,而是要改成export default {}; 这才是字面量的含义,这和ES6的解构完全不同。

var name = 'yd';

export { name }; // 固定语法,导出name

export default { name }; // 导出一个对象,对象中包含name属性。

export default name; // 导出 name 变量

一旦使用了export default导出,就不能使用下面的方式导入,即使导出的是一个对象。

import { name } from './module.js'; // 不能使用该方式导入默认导出的对象

因为这也是固定写法,用于提取export导出的成员,export {}import {}就是一个固定的约定用法,想要导出具体变量或者对象,要用export defaultimport modulename from 'moduleName';

2. export

export导出的是成员的引用,并不是真正导出了成员的值,也就是导出之后,访问的仍旧是原本模块的内存空间。

var name = 'yd';

setTimeout(function() {
    name = 'zd';
}, 1000);

export { name };

定义name属性1s后改变name的值,新模块中1.5s后打印该内容。

import { name } from './module.js';

setTimeout(function() {
    console.log( name ); // zd
}, 1500)

打印的是zd,并不是yd。所以说模块暴露出来的只是一个引用关系并且它是只读的,并不能在模块外部修改这个变量也就是不能在import的模块中修改name的值。

3. import

import引入文件的时候,必须书写完整文件名称,不能省略扩展名。如果使用相对路径加载资源./也是不能省略的,省略会认为加载的是第三方资源,这一点与CommonJS规范相同。它也支持使用/开头的绝对路径,也就是从网站的跟目录开始的路径,也可以使用完整的url加载模块。

import & from 'https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js';

如果只是需要加载某个模块,并不需要提取这个模块的成员的话,花括号中可以不写成员,也可以省略花括号

import {} from './module1.js';
import from './module2.js';

如果一个模块导出的成员很多,导入的时候都需要使用这些成员,可以使用*号,也可以通过as生成一个新的对象,将所有成员挂载在上面。

import * as mod from './module.js';

import关键词只能写在模块最顶层,不能写在if和函数中,导入的时候必须要写明路径,不能使用变量替代。

if (true) {
    import { name } from './module.js';
}

var module = './module.js';

import { name } from module;

可以使用ECMAScript2020中新增的异步导入功能返回的是一个Promise对象。

var module './module.js';

import(module).then(file => { console.log(file);});

在一个模块中,如果同时导出了命名成员又导出了默认成员,在导入的时候可以同时导入。

// 导出
export { name };
export default title;

// 导入
import title, { name } from './module.js';

// 导入的时候导出
export { foo, bar } from './module.js';

默认模块直接导出

export { default as button } from './module.js';

4. ES Module在浏览器中使用

ES Module2014年提出,IE完全不支持这个属性。可以使用polyfill解决该问题。

Browser ES Module Loader这个模块就是个js文件,将polyfill代码引入,注意一共两个文件,都要引入。

<script nomodule src="https://unpkg.com/promise-polyfill"></script>

使用polyfill会导致支持的浏览器执行两次模块代码,所以需要在script标签中加入nomodule标识只有不支持module的浏览器才执行该polyfillmodule不建议加入到生产环境使用,因为异步加载太慢了,会影响整个系统的性能。最好的方式还是利用webpack等打包工具进行编译阶段打包,在使用阶段直接执行。

5. Node 对 ES Module的支持

可以通过@babel/node@bable/core@babel/preset-env这三个模块在低版本Node中使用ES Module

preset-env是一组插件集,可以安装他支持的所有特性,也可以单独安装具体特性单独使用,比如@babel/plugin-transform-modules-commonjs

Node的最新版本中重新支持了ES Module,之前可以通过后缀名.mjs.cjs来标识当前模块采用哪种规范来执行(ES ModuleCommonJS)。12.10.0之后,可以在package.json中添加type=module字段,表示所有js文件都是ES Module,不需要修改后缀名为.mjs,但如果想要使用CommonJS规范仍需要修改后缀名为.cjs.mjs->ES Module.cjs->CommonJS

import { foo, bar } from './module.mjs';
console.log(foo, bar);

6. ES Module和CommonJS的差异

requiremodule, exports__filename__dirname这些CommonJS的特性在ES Module中是不存在的,所以不能使用。

requireexport可以使用importexport代替。

__filename可以使用import.meta.url代替。

__dirname可以在__filename中获取

import {fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

7. ES Module载入CommonJS模块

ES Module可以载入CommonJS模块提供的成员,并且只能载入默认成员,不支持import {} from这样提取CommonJS

CommonJS无法导入ES Module模块,因为ES Module只能通过import载入。不能通过require载入。

Node8.5之后,已经支持了ES Module,所以在Node中已经可以使用ES Module了,只是开发者习惯了NodeCommonJS标准,并且这个标准也没什么问题。

import fs from 'fs';
fs.writeFileSync('./foo.txt', 'es module working');