前端工程化之模块化

274 阅读8分钟

前端模块化

什么是模块化

模块化是一种编程技术,它将一个复杂的应用程序拆解成一个一个模块,模块化具有以下特点:

  • 每个模块有自己的作用域,模块内部的变量和函数在模块外部是不可见的。
  • 模块自己的接口只暴露必要的接口,外部只能通过接口与其他模块进行交互。

模块化的发展

1. 全局Function模式

// 模块一
function module1() {
  // ...
}

// 模块二
function module2() {
  // ...
}
  • 特点:将不同的功能模块封装到一个个全局函数中,通过全局函数的调用来实现模块间的通信。
  • 问题:污染全局命名空间,并且随着项目越来越大会出现命名冲突的问题。

2.简单对象封装模式

// 模块一
let module = {
  data: 'foo',
  foo(){
    // ...
  }
  bar(){
    // ...
  }
};


module.data = 'bar';
module.foo();
  • 特点:将模块封装成一个个简单对象,通过对象属性的访问来实现模块间的通信。
  • 问题:内部属性可以直接修改,数据不安全。

3.IIFE模式(闭包 + 立即执行函数)

普通模块:

// 创建模块
(function (window) {
  // 内部私有数据
  let data = 'foo';
  function foo() {
    console.log(data);
  }
  function bar() {
    privateFunc();
  }

  function privateFunc() {
    console.log('bar');
  }
  // 向外暴露接口
  window.module = {
    foo,
    bar,
  };
})(window);
<!--  引入模块 -->
<script src="module.js"></script>
<script>
  module.foo(); // 'foo'
  module.bar(); // 'bar'
  console.log(module.data); // undefined
  console.log(module.privateFunc); // undefined
</script>

引入其他依赖:

// 引入JQuery模块
(function (window, $) {
  function foo() {
    $('body').css('background', 'red');
  }

  // 向外暴露接口
  window.module = {
    foo,
  };
})(window, JQuery);
<!--  必须先引入JQuery -->
<script src="jquery.js"></script>
<script src="module.js"></script>
<script>
  module.foo();
</script>
  • 特点:将模块的内部数据和方法封装到一个立即执行的函数中,通过参数传入外部环境和依赖,利用闭包的特性,通过暴露内部接口,来访问函数内部私有数据,并且内部私有数据不可更改。
  • 问题:模块引入顺序有要求,在不清楚模块之间依赖关系的时候,可能会出现模块引入顺序错误的情况。

上述模块化方式除以上问题外,还存在很多问题,比如在引入多个 script 标签时,会发送很多请求,模块之间的依赖比较模糊,模块难以维护等等,因此,针对以上问题,社区和官方提出了很多模块化规范。

模块化规范

1.CommonJs

CommonJsnode环境中的一种模块规范。每个文件就是一个模块,有自己的作用域,文件内部的变量和函数都是私有的,需要向外暴露才可见。 注意:CommonJs的模块加载机制是同步的,要想在浏览器端运行,需要进行打包编译。

特点:

  • 所有的代码都运行在模块的作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,以后再加载就直接返回第一次运行的结果,不会再次运行。
  • 模块按照代码出现顺序依次加载。

基本语法:

// module1.js
module.exports = {
  data: 'foo',
  bar() {
    console.log('bar');
  },
};

// module2.js
const module1 = require('./module1');
console.log(module1.data); // 'foo'
module1.bar(); // 'bar'

这里有两点需要注意:

  • CommonJs 可以通过 module.exportsexports两种方式进行模块导出,本质上 module.exportsexports 以及 this 都是指向同一个对象。而最终导出的对象是 module.exports 指向的对象,所以通过给 module.exports 或者 exports 添加属性,导出的对象上都会被添加上相应的属性,而给 module.exports 重新赋值,则会覆盖原有导出的对象。

  • CommonJs 通过 require函数来加载模块,它是同步加载,也就是说,只有当前模块的依赖加载完成,才能执行当前模块的脚本。此外,require 可以接受一个参数,如果参数是一个相对或者绝对路径,则会从这个路径加载模块,否则,则会从 node_modules 目录下加载,如果没有发现指定模块,则会抛出错误。

模块加载机制:

CommonJs模块导入的值是可以理解成导出的值深拷贝后的值,因此一旦一个值被导入之后,那么这个值就不会被修改了,即使在原模块对其进行了修改,也不会影响到导入的值。下面是具体的例子:

// module1.js
let a = 1;

setTimeout(() => {
  a = 2;
}, 1000);

module.exports = { a };

// module2.js
const module1 = require('./module1');
console.log(module1.a); // 1

setTimeout(() => {
  console.log(module1.a); // 1
}, 2000);

module2.js 中,我们通过 require 加载 module1.js,然后打印 module1.a 的值,此时 module1.a 的值为 1,然后我们在 module1.js 中设置了一个定时器,在 1 秒后将 a 的值改为 2,然后我们 1.5 秒后再次打印 module1.a 的值,此时 module1.a 的值仍为 1,说明 module1.a 的值是不会被修改的。

2.AMD

CommonJs不同的是,AMD 是非同步加载模块,允许指定回调函数。因此 CommonJs适用于服务端加载模块,而 AMD 适用于浏览器端。

AMD模块需要使用 require.js这个库,通过 define 函数来定义模块,define 函数接受两个参数,第一个参数是模块的依赖列表(如果没有依赖的模块可不传),第二个参数是模块的执行函数。通过 require 函数来加载模块,require 函数接受一个数组参数,数组的每个元素是一个依赖模块的名字。通过 require.js加载的模块必须符合 AMD 规范,如果不符合,需要先用require.config()方法,定义它们的一些特征 。这里不过多阐述。

定义模块:

// module1.js
// 定义没有依赖的模块
define(function () {
  let bar = 1;
  function foo() {
    console.log(bar);
  }

  return {
    foo,
  };
});

// module2.js
// 定义有依赖的模块
define(['jquery'], function ($) {
  function bar() {
    $('body').css('background', 'green');
  }
  return {
    bar,
  };
});

加载模块:

// 加载 module1.js
require(['./module1'], function (module1) {
  module1.foo(); // 1
});

引入 require.js 和 模块, 并制定 data-main 属性值作为入口文件:

<!-- 引入 require.js -->
<script
  data-main="./index.js"
  src="https://requirejs.org/docs/release/2.3.7/minified/require.js"
></script>
<script src="./module1.js"></script>

3.CMD

CMDSea.js 的模块定义规范。它整合了CommonJsAMD的特点, 并且和 AMD 类似,也是为了解决浏览器端模块化问题而提出的,CMD 的模块加载是异步的,在模块使用时才会加载执行。

基本语法:

// 定义模块
define(function (require, exports, module) {
  module.exports.bar = function () {
    console.log('bar');
  };
});

// 加载模块
define(function (require) {
  let m1 = require('./module1');
  m1.bar(); // 'bar'
});

使用 sea.js同时指定入口文件。

<script src="https://cdn.bootcdn.net/ajax/libs/seajs/3.1.1/sea.js"></script>
<script>
  seajs.use('./index.js');
</script>

4.ESModule

ESModuleECMAScript 的一个提案,旨在解决模块化问题。 CommonJsAMD 模块都是在运行时确定模块关系,而 ESModule模块化的思想是在编译时就确定莫亏啊的依赖关系。

HTML 文件中的基本使用:

<!-- 通过给 `<script>` 标签添加 `type="module"` 属性,就可以启用 `ESModule` 模块: -->
<script type="module">
  console.log('这是一个模块');
</script>

特性:

  • ESM 自动采用严格模式 相当于开启了 use strict
  • 每个 ESModule 都有自己的单独作用域
  • ESModule 通过 CORS 的方式请求外部 JS 脚本
  • ESModulescript 标签会延迟执行脚本 相当于 defer
<!-- 1. ESM自动采用严格模式,相当于开启了 `use strict` -->
<script type="module">
  console.log(this); // undefined 严格模式下,全局的this为undefined
</script>

<!-- 2. 每个ESModule 都有自己的单独作用域 -->
<script type="module">
  let x = 1;
  console.log(x); // 1
</script>
<script type="module">
  console.log(x); // undefined 不同模块的作用域互不干扰
</script>

<!-- 3. ESModule 通过 CORS 的方式请求外部 JS 脚本 -->
<script type="module" src="不支持跨域访问的js脚本"></script>
// 报跨域错误
<script type="module" crossorigin src="支持跨域访问的js脚本"></script>
// 允许跨域访问

<!-- 4. ESModule 的 script 标签会延迟执行脚本 -->
<script type="module" src="demo1.js"></script>
// 脚本的执行不会阻塞后续的渲染,相当于 defer 属性
<p>需要显示的内容</p>

导入导出语法:

ESModule 使用 importexport 关键字来导入和导出模块。

// module1.js
let foo = 1;
export { foo }; // 导出

// module2.js
import { foo } from './module1'; // 导入
console.log(foo); // 1

导入导出可以通过 as 进行重命名:

// module1.js
let foo = 1;
let bar = 2;
export { foo as myFoo, bar }; // 重命名导出

// module2.js
import { myFoo, bar as myBar } from './module1'; // 导入重命名
console.log(myFoo); // 1
console.log(myBar); // 2

默认导出:

默认导出有两种方式:

  1. 使用 export default 关键字导出默认值
  2. 将变量重命名为default: export { foo as default } 针对这种方式,在导入是需要将 default 重命名为其他名字。
// 方式1
// module1.js
let foo = 1;
export { foo as default };
// module2.js
import {default as foo} from './module1'

// -----------------

// 方式2
// module1.js
let foo = 1;
export default foo;

// module2.js
import foo from './module1';
console.log(foo); // 1

混合导出:

ESModule 可以同时默认导出和命名导出。

// module1.js
let foo = 1;
let bar = 2;
export { foo, bar };
let a = 2;
export default a;

// module2.js
// 两种导入都可以
// import { foo, bar, default as a } from './module1';
import a, { foo, bar } from './module1';
console.log(foo); // 1
console.log(bar); // 2
console.log(a); // 2

动态导入:

ESModule 提供了 import() 函数来动态导入模块。

// module1.js
let a = 1;

export { a };

// module2.js
import('./module1').then((m) => {
  console.log(m.a); // 2
});

需要注意的是, ESModuleCommonJs 不同, ESModule 导入的值并不是拷贝关系,而是值的引用。 这里用个例子演示一下:

// module1.js
let a = 1;
setTimeout(() => {
  a = 2;
}, 1000);
export { a };

// module2.js
import { a } from './module1';
console.log(a); // 1

setTimeout(() => {
  console.log(a); // 2
}, 1500);

module2.js 中,我们通过 import 加载 module1.js,然后打印 module1.a 的值,此时 module1.a 的值为 1,然后我们在 module1.js 中设置了一个定时器,在 1 秒后将 a 的值改为 2,然后我们 1.5 秒后再次打印 module1.a 的值,此时 module1.a 的值变为 2,说明 module1.a 的值是会被修改的, 因为 ESModule 导入的值是值的引用。 因此,强烈建议在导出变量时,要用 const 声明,避免出现意外的修改

5.UMD

UMDUniversal Module Definition,它是一种兼容 CommonJsAMD 的模块定义规范。

  1. 首先判断是否支持 CommonJs 模块化,如果支持则使用 CommonJs 模块化,否则判断是否支持 AMD 模块化,如果支持则使用 AMD 模块化,否则使用 window 对象。
  2. 然后判断是否支持 define 函数,如果支持则使用 define 函数定义模块,否则判断是否支持 exports 对象,如果支持则使用 exports 对象定义模块,否则使用 window 对象。
  3. 最后判断是否支持 require 函数,如果支持则使用 require 函数加载模块,否则使用 window 对象。

具体示例:

(function (window, factory) {
  if (typeof exports === 'object') {
    // CommonJS
    module.exports = factory();
  } else if (typeof define === 'function' && define.amd) {
    // AMD
    define(factory);
  } else {
    // 浏览器全局定义
    window.eventUtil = factory();
  }
})(this, function () {
  // do something
});