WSの小屋

无模块化

由于早期前端业务比较简单,JS 承担的业务较少,工程师可能随便几行代码就搞定了,直接写在一个文件里即可,稍微复杂些的会分文件引入,然后手动维护加载顺序。

moduleA.js:

js 复制代码
function add(a, b) {
  return a + b;
}

moduleB.js:

js 复制代码
function average(a, b) {
  return add(a, b) / 2;
}

main.js:

js 复制代码
var result = average(5, 10);
console.log(result);

然后在 HTML 中的引入如下:

html 复制代码
<!-- ...一些业务代码 -->
<script src="./moduleA.js"></script>
<script src="./moduleB.js"></script>
<script src="./main.js"></script>

命名空间

为了解决命名冲突问题及团队成员间方便合作,这个时候提出了命名空间的思路,此时 moduleA.js, moduleB.js 的写法类似这样:

moduleA.js

js 复制代码
var moduleA = {
  add: function (a, b) {
    return a + b;
  },
  foo: function () {},
  bar: 1,
};

moduleA.baz = function () {};
js 复制代码
moduleB.js

var moduleB = {
  average: function (a, b) {
    return moduleA.add(a, b) / 2;
  },
};

引入命名空间后,命名冲突问题得到了一定程度的解决,模块间的依赖也比较容易的看出来,但是 moduleA 和 moduleB 里面的成员变量及方法都暴露了出来,外界可以任意的对其进行更改,无法创建私有变量及私有方法,模块不够安全。

IIFE(自执行函数)

为了创建私有变量或私有方法,引入了自执行函数及闭包的思路,此时写法类似:

js 复制代码
// moduleA.js
var moduleA = (function () {
  var bar = 1; // 私有属性
  var moduleA = window.moduleA || {};

  // 私有方法
  function getBar() {
    return bar;
  }

  moduleA.add = function (a, b) {
    return a + b;
  };

  moduleA.foo = function () {
    return getBar();
  };

    // 或者挂到 window 下:  window.moduleA = moduleA
  return moduleA;
})();

这样保证了部分变量及方法的安全性,另外还可以通过传参的方式传递模块的引用:

js 复制代码
// moduleB.js
(function (modA) {
  var moduleB = window.moduleB || {};

  moduleB.average = function (a, b) {
    return modA.add(a, b) / 2;
  };

  window.moduleB = moduleB;
})(moduleA);

但是,此时问题依然很多:

  • 各个模块依然要创建全局变量污染全局
  • 编写模块无法保证不影响其它模块
  • 模块间的引用及依赖关系还是不够清晰
  • 各个 JS 的加载顺序需要手动管理
  • 直到 Node.js 的到来、CommonJS 规范的落地,这成为了一切的转折点…

CommonJS

2009 年 1 月,Mozilla 的工程师 Kevin Dangoor 创建了一个项目,当时的名字是 ServerJS。在 2009 年 8 月,这个项目被改名为 CommonJS,以显示其 API 的更广泛实用性,其中,Node.js 采用的就是这个规范。

CommonJS 约定:

  • 每个文件就是一个模块, 有自己的作用域
  • 每个文件中定义的变量、函数、类都是私有的,对其它文件不可见
  • 每个模块内部可以通过 exports 或者 module.exports 对外暴露接口
    = 每个模块通过 require 加载另外的模块

CommonJS 代码示例:

js 复制代码
// moduleA.js
var foo = 1;

function add(a, b) {
  return a + b;
}

function foo() {
  return foo;
}

module.exports = {
  add: add,
  foo: foo,
};

// moduleB.js
const moduleA = require('./moduleA');

const result = moduleA.add(5, 10);
console.log(result);

但是,CommonJS 是一套同步的方案,由于 Node.js 主要运行在服务端,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步的加载方式,所以 CommonJS 规范比较适用。但对于浏览器就无法适用了,必须要有异步的加载机制。

AMD

即 Asynchronous Module Definition,几乎与 CommonJS 同一时期,AMD 规范出现了,它采用异步的方式加载 JavaScript 模块,模块的加载并不会影响它后面语句的运行。

AMD 规范规定用 define 定义模块用 require 加载模块,语法如下:

js 复制代码
// 模块定义
define(id?, dependencies?, factory);

// 模块加载
require([module], callback);

说起 AMD 规范,不得不提其代表产物 RequireJS,它不仅实现了异步加载,还可以按需加载,一时间成为了众多项目的选择,RequireJS + jQuery 几乎成为了前端项目标配。

下面就来看下基于 RequireJS 的代码是什么样子的。

目录结构如下:

复制代码
.
├── index.html
└── js
    ├── entry.js
    ├── handleClick.js
    ├── lib
    │   ├── jquery.js
    │   └── require.js
    ├── main.js
    ├── moduleA.js
    ├── moduleB.js
    └── utils
        └── index.js

然后是在 HTML 文件中引入 require.js 并声明 data-main 属性:

html 复制代码
<!-- ... -->
<body>
  <button id="btn-click">click me</button>
  <script data-main="./js/main.js" src="./js/lib/require.js"></script>
</body>

data-main 属性的作用是指定项目的 JS 主模块,这个模块会被第一个加载,并且可以对模块进行一些路径、别名等配置。

js 复制代码
// main.js
require.config({
  baseUrl: 'js/',
  paths: {
    jquery: './lib/jquery',
    utils: './utils/index',
  },
});

require(['./entry'], function (entry) {
  entry.init();
});

这里对 baseUrl 进行指定,require 模块时就不用每次都写 ./js/ 路径,同理 utils: './utils/index' 对 utils 模块进行了别名声明也能起到同样的效果。

模块定义示例:

js 复制代码
// moduleA.js
define(['utils', './moduleA', './moduleB'], function (utils, moduleA, moduleB) {
  console.log('---- entry.js utils', utils);
  console.log('---- entry.js moduleA', moduleA);
  console.log('---- entry.js moduleB', moduleB);
  return {
    init: function () {
      moduleB.init();
      console.log('entry.js', 'init');
    },
  };
});

JS 文件的加载顺序会按照模块的依赖声明顺序进行加载,例如上面代码中依赖模块的加载顺序就是 utils/index.js -> moduleA.js -> moduleB.js。当然 RequireJS 会对加载过的模块进行缓存,如果有多次依赖,就只加载一次。

另外,RequireJS 也可以实现模块的懒加载,只需要在需要时再 require 模块即可,代码示例如下:

js 复制代码
define(['jquery'], function ($) {
  return {
    init: function () {
      var $btn = $('#btn-click');
      $btn.click(function () {
          // 事件触发时再加载
        require(['./handleClick'], function (handleClick) {
          handleClick.init($btn);
        });
      });
    },
  };
});

上面代码在事件触发时才加载需要的 handleClick.js 文件,这样就实现了 JS 文件的懒加载,不用页面刚进入时就加载过多的 JS 文件。但是代码却不够优雅,这种回调看起来太难受了。

CMD

即 Common Module Definition,以玉伯大大的 Sea.js 为代表,SeaJS 要解决的问题和 RequireJS 一样,都是浏览器端的模块加载方案,只不过在模块定义方式和执行时机上有所不同,AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。

下面来看下 Sea.js 的项目代码大概是什么样子的。

目录结构如下:

复制代码
├── index.html
└── js
    ├── entry.js
    ├── handleClick.js
    ├── lib
    │   ├── jquery.js
    │   └── sea.js
    ├── main.js
    ├── moduleA.js
    ├── moduleB.js
    └── utils
        └── index.js

首先在 HTML 文件中引入 sea.js 及 main.js:

html 复制代码
<!-- ... -->
<body>
  <button id="btn-click">click me</button>
  <script src="./js/lib/sea.js"></script>
  <script src="./js/main.js"></script>
</body>

这里的 main.js 作为页面入口文件,对 SeaJS 进行了配置 baseUrl 配置及别名配置,并且通过 seajs.use() 加载入口 JS 文件:

js 复制代码
seajs.config({
  base: './js/',
  alias: {
    jquery: 'lib/jquery',
    utils: 'utils/index',
  },
});

seajs.use(['jquery', 'entry'], function ($, entry) {
  entry.init();
});

之后就是模块的定义及引用了,以 moduleA.js 为例:

js 复制代码
define(function (require, exports, module) {
  return {
    a: function () {
      var utils = require('utils');

      utils.formatDate();
      console.log('moduleA.js');
    },
  };
});

这里就近引用 utils 模块,然后代码执行到此位置时 utils 模块相关的代码才会执行。

UMD

即 Universal Module Definition,UMD 是为了兼容浏览器、Node.js 等多种环境所产生的一种模块规范,经典的代码片段如下:

js 复制代码
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined'
    ? (module.exports = factory())
    : typeof define === 'function' && define.amd
    ? define(factory)
    : (global.mylib = factory());
})(this, function () {
  'use strict';
  var mylib = {};
  mylib.version = '0.0.1';
  mylib.say = function (message) {
    console.log(message);
  };
  return mylib;
});

符合 UMD 规范的 JS 模块既可以在 Node.js 中运行,也可以作为 AMD 模块运行,否则就挂载到当前的上下文环境中,如浏览器中的 window。

ES Module

2015 年,ES6 发布,JavaScript 终于在语言标准层面上有了自己的模块系统,ES6 使用 import 引入模块,使用 export 导出模块。

引用模块:

js 复制代码
import moduleA from './moduleA';
import { foo } from './moduleB';

moduleA();
foo();

document.getElementById('btn-click').onclick = () => {
  import('./handleClick').then(({ handleClick }) => {
    handleClick();
  });
};

导出模块:

js 复制代码
// moduleA.js 默认导出
export default function () {
  console.log('moduleA');
}

// moduleB.js 具名导出
export const NAME = 'moduleB';

export function foo() {
  console.log('foo');
}

export function bar() {
  console.log('bar');
}

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

另外,CommonJS 模块输出的是值的拷贝,而 ES6 模块输出的是值的引用,并且 CommonJS 模块是运行时加载,而 ES6 模块是编译时输出接口,基于这个特性,ES6 模块就很容做静态分析,比如在 Webpack 打包构建时通过tree shaking 去除无用代码减少代码体积。

参考链接

JavaScript 模块化发展历程

Comments | 0条评论