模块化标准规范

模块化的发展过程及其在这个发展过程中形成的标准,这些标准虽然或多或少的实现了模块化,但也都存在一些问题。

随着技术的发展,javascript的标准也在逐渐的完善,而我们在模块化这一块的实现方式相对于以往也就有了一个很大的变化了。

现如今的前端模块化已经算是非常成熟了,而且目前大家针对于前端模块化的最佳实现方式已经基本统一。

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

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

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

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

相比AMD这种社区提出的开发规范,ES Module可以说在语言层面实现了模块化,所以说更加完善一点,另外,现如今绝对多数浏览器已经开始支持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)运行javascript代码。并且每个ES Module都运行在单独的作用域中,也就意味着变脸间不会互相干扰。

在ES Module中,外部js文件是通过CORS的方式请求的,所以要求我们外部的js文件地址要支持跨域请求,也就是文件服务器要支持CORS。

CORS只能在http-server环境生效,本地静态开发不支持CORS, 我们生产环境全都是http-server, 所以不会存在这个问题,开发环境就要求我们搭建一个服务器了。

ES Module的script标签会延迟脚本加载,等待网页请求完资源之后才执行,这和使用deffer的资源方式请求相同。

import 和 export

在一个模块中,可以使用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';

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的值。

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 配合 import 使用

将import修改为export,当前模块导入的成员就会变成导出成员,当前的模块也不能再去访问这些成员了,这似乎也没什么用…

export { foo, bar } from './module.js';

默认模块直接导出

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

ES Module在浏览器环境的兼容

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

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

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

使用polyfill会导致支持的浏览器执行两次模块代码,所以需要在script标签中加入nomodule标识只有不支持module的浏览器才执行该polyfill

module不建议加入到生产环境使用,因为异步加载太慢了,会影响整个系统的性能。最好的方式还是利用webpack等打包工具进行编译阶段打包,在使用阶段直接执行。

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 Module 和 CommonJS)。12.10.0之后,可以在package.json中添加一个type=module字段,表示所有js文件都是ES Module,不需要修改后缀名为.mjs,但如果想要使用CommonJS规范,仍需要修改后缀名为.cjs;

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

Node中ES Module 和 CommonJS的差异

require, module, exports, __filename, __dirname这些CommonJS的特性在ES Module中是不存在的,所以不能使用。

require和export可以使用import 和 export代替。

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

__dirname可以在__filename中获取

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

ES Module中如何载入CommonJS模块

ES Module可以载入CommonJS 模块提供的成员,CommonJS在ES Module中引入只能载入默认成员,不支持import {} from 这样提取CommonJS,原因也很简单,因为前文说过,import {} 不是对象的解构。

CommonJS导入ES Module模块会失败,因为ES Module只能通过import载入。不能通过require载入。

Node在8.5之后,已经支持了ES Module,所以在Node中已经可以使用ES Module了,只是我们一直习惯了Node的CommonJS标准,并且这个标准也没什么问题。同样这是一个实验特性,不要在生产环境使用。

.mjs -> ES Module
.cjs -> CommonJS

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

模块化历史演进

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

模块化是一种理论思想,并不包含具体的实现,接下来我们来聊聊如何在前端实践模块化思想。

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

第一阶段

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

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

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

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

第二阶段

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

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

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>

第三阶段

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

;(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)

模块化规范的出现

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

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

在这样情况下,我们更希望存在基础的公共代码,自动去加载模块。

模块化加载器的出现

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

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

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

  3. 通过module.exports 导出成员

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

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

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

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的生态还是比较好的,只是使用起来比较复杂。

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

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

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

define(function(require, exports, module) {
    var $ = require('jquery');
    module.exports = function() {
        console.log('module2');
    }
})

这些历史对于在[和平时期]才进入前端行业的朋友还是比较重要的。


欢迎关注,更多内容持续更新