16
头图

为什么需要Javascipt模块化?

前端的发展日新月异,前端工程的复杂度也不可同日而语。原始的开发方式,随着项目复杂度提高,代码量越来越多,所需加载的文件也越来越多,这个时候就需要考虑如下几个问题:

  1. 命名问题:所有文件的方法都挂载到window/global上,会污染全局环境,并且需要考虑命名冲突问题
  2. 依赖问题:script是顺序加载的,如果各个文件文件有依赖,就得考虑js文件的加载顺序
  3. 网络问题:如果js文件过多,所需请求次数就会增多,增加加载时间

Javascript模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。

本文主要介绍Javascript模块化的4种规范: CommonJSAMDUMDESM

CommonJS

CommonJS是一个更偏向于服务器端的规范。NodeJS采用了这个规范。CommonJS的一个模块就是一个脚本文件。require命令第一次加载该脚本时就会执行整个脚本,然后在内存中生成一个对象

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

id是模块名,exports是该模块导出的接口,loaded表示模块是否加载完毕。

以后需要用到这个模块时,就会到exports属性上取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存中取值

// utile.js
const util = {
  name:'Clearlove'
  sayHello:function () {
      return 'Hello I am Clearlove';
  }
}
// exports 是指向module.exports的一个快捷方式
module.exports = util
// 或者
exports.name = util.name;
exports.sayHello = util.sayHello;

const selfUtil = require('./util');
selfUtil.name;            
selfUtil.sayHello(); 
  • CommonJS是同步导入模块
  • CommonJS导入时,它会给你一个导入对象的副本
  • CommonJS模块不能直接在浏览器中运行,需要进行转换、打包

由于CommonJS是同步加载模块,这对于服务器端不是一个问题,因为所有的模块都放在本地硬盘。等待模块时间就是硬盘读取文件时间,很小。但是,对于浏览器而言,它需要从服务器加载模块,涉及到网速,代理等原因,一旦等待时间过长,浏览器处于”假死”状态。

所以在浏览器端,不适合于CommonJS规范。所以在浏览器端又出现了一个规范—-AMD

AMD

AMD(Asynchronous Module Definition - 异步加载模块定义)规范,一个单独的文件就是一个模块。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。

这里异步指的是不堵塞浏览器其他任务(dom构建,css渲染等),而加载内部是同步的(加载完模块后立即执行回调)

AMD也采用require命令加载模块,但是不同于CommonJS,它要求两个参数:

require([module], callback);

第一个参数[module],是一个数组,里面的成员是要加载的模块,callback是加载完成后的回调函数,回调函数中参数对应数组中的成员(模块)。

AMD的标准中,引入模块需要用到方法require,由于window对象上没定义require方法, 这里就不得不提到一个库,那就是RequireJS

官网介绍RequireJS是一个js文件和模块的加载器,提供了加载和定义模块的api,当在页面中引入了RequireJS之后,我们便能够在全局调用definerequire

define(id?, dependencies?, factory);
  • id:模块的名字,如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字
  • dependencies:模块的依赖,已被模块定义的模块标识的数组字面量。依赖参数是可选的,如果忽略此参数,它应该默认为 ["require", "exports", "module"]。然而,如果工厂方法的长度属性小于3,加载器会选择以函数的长度属性指定的参数个数调用工厂方法。
  • factory:模块的工厂函数,模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值。
// 定义一个moduleA.js
define(function(){
  const name = "module A";
  return {
    getName(){
      return name
    }
  }
});

// 定义一个moduleB.js
define(["moduleA"], function(moduleA){
  return {
    showFirstModuleName(){
      console.log(moduleA.getName());
    }
  }
});

// 实现main.js
require(["moduleB"], function(moduleB){
  moduleB.showFirstModuleName();
});
<html>
<!-- 此处省略head -->
<body>
    <!--引入requirejs并且在这里指定入口文件的地址-->
    <script data-main="js/main.js" src="js/require.js"></script>
</body>
</html>

要通过script引入requirejs,然后需要为标签加一个属性data-main来指定入口文件。

前面介绍用define来定义一个模块的时候,直接传“模块名”似乎就能找到对应的文件,这一块是在哪实现的呢?其实在使用RequireJS之前还需要为它做一个配置:

// main.js
require.config({
  paths: {
    // key为模块名称, value为模块的路径
    "moduleA": "./moduleA",
    "moduleB": "./moduleB"
  }
});

require(["moduleB"], function(moduleB){
    moduleB.showFirstModuleName();
});

这个配置中的属性paths只写模块名就能找到对应路径,不过这里有一项要注意的是,路径后面不能跟.js文件后缀名,更多的配置项请参考RequireJS官网。

UMD

UMD 代表通用模块定义(Universal Module Definition)。所谓的通用,就是兼容了CmmonJSAMD规范,这意味着无论是在CmmonJS规范的项目中,还是AMD规范的项目中,都可以直接引用UMD规范的模块使用。

原理其实就是在模块中去判断全局是否存在exportsdefine,如果存在exports,那么以CommonJS的方式暴露模块,如果存在define那么以AMD的方式暴露模块:

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    define(["jquery", "underscore"], factory);
  } else if (typeof exports === "object") {
    module.exports = factory(require("jquery"), require("underscore"));
  } else {
    root.Requester = factory(root.$, root._);
  }
}(this, function ($, _) {
  // this is where I defined my module implementation
  const Requester = { // ... };
  return Requester;
}));

这种模式,通常会在webpack打包的时候用到。output.libraryTarget将模块以哪种规范的文件输出。

ESM

在ECMAScript 2015版本出来之后,确定了一种新的模块加载方式,我们称之为ES6 Module。它和前几种方式有区别和相同点:

  • 它因为是标准,所以未来很多浏览器会支持,可以很方便的在浏览器中使用
  • 它同时兼容在node环境下运行
  • 模块的导入导出,通过importexport来确定
  • 可以和CommonJS模块混合使用
  • CommonJS输出的是一个值的拷贝。ES6模块输出的是值的引用,加载的时候会做静态优化
  • CommonJS模块是运行时加载确定输出接口,ES6模块是编译时确定输出接口

ES6模块功能主要由两个命令构成:importexportimport命令用于输入其他模块提供的功能。export命令用于规范模块的对外接口。

export的几种用法:

// 输出变量
export const name = 'Clearlove';
export const year = '2021';

// 输出一个对象(推荐)
const name = 'Clearlove';
const year = '2021';
export { name, year}


// 输出函数或类
export function add(a, b) {
  return a + b;
}

// export default 命令
export default function() {
  console.log('foo')
}

import导入模块:

// 正常命令
import { name, year } from './module.js';

// 如果遇到export default命令导出的模块
import ed from './export-default.js';

模块编辑好之后,它有两种形式加载:

浏览器加载

浏览器加载ES6模块,使用<script>标签,但是要加入type="module"属性。

  • 外链js文件:
<script type="module" src="index.js"></script>
  • 内嵌在网页中
<script type="module">
  import utils from './utils.js';
  // other code
</script>

对于加载外部模块,需要注意:

  • 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见
  • 模块脚本自动采用严格模式,不管有没有声明use strict
  • 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口
  • 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的
  • 同一个模块如果加载多次,将只执行一次

Node加载

Node要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,就必须采用.mjs后缀名。Node.js 遇到.mjs文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定use strict

如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为

{
  "type": "module"
}

一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 模块。

# 解释成 ES6 模块 
$ node my-app.js

如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

总结为一句话:.mjs文件总是以 ES6 模块加载,.cjs文件总是以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置。

注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import

Nodeimport命令只支持异步加载本地模块(file:协议),不支持加载远程模块。

总结

  • 由于 ESM 具有简单的语法,异步特性和可摇树性,因此它是最好的模块化方案
  • UMD 随处可见,通常在 ESM 不起作用的情况下用作备用
  • CommonJS 是同步的,适合后端
  • AMD 是异步的,适合前端

Clearlove
1.2k 声望53 粉丝

专注做好一件事