彻底搞清楚 ECMAScript 的模块化(一)

简介: 彻底搞清楚 ECMAScript 的模块化

概述


模块式是目前前端开发最重要的范式之一。

随着前端项目的日渐复杂,不得不花费大量时间去管理。

模块化就是最主流的代码组织方式。

将复杂的代码按照功能不同划分为不同的模块,通过单独维护的方式,提高开发效率,降低维护成本。

「模块化」只是思想,不包含具体实现。


演变过程


早期的技术标准并没有预料到如今前端项目的规模,所以很多设计上的遗留问题导致我们现在去实现模块化有很多问题。

虽然这些问题都被现在的模块化标准和工具所解决了,但它的演变过程值得去思考。

总体来看,JavaScript 模块化大致有 4 个阶段。


1. 文件划分的形式


这种形式就是以每个 js 文件为一个模块,在 html 文件中导入它们进行使用。

这么做有 3 个问题:

  1. 污染全局作用域,这样一个模块内的任何成员都可以被访问和修改。
  2. 命名冲突,模块多了过后,很容易产生命名冲突。
  3. 无法管理模块依赖关系

这种方式完全依靠约定,项目一旦体量过大,就很难保证所有模块完全按照约定来使用。


var name = "module-a";
function method1() {
  console.log(name + "#method1");
}
function method2() {
  console.log(name + "#method2");
}


2. 命名空间方式


基于文件划分进行优化,每个模块只暴露一个对象。模块的成员都只暴露在这个对象下面。

这种方式可以减少命名冲突的可能。

但这种方式仍然没有私有空间,内部成员仍然可以被访问和修改。模块间的依赖关系也没有得到解决。


var moduleA = {
  name: "module-a",
  method1: function () {
    console.log(name + "#method1");
  },
  method2: function () {
    console.log(name + "#method2");
  },
};


3. IIFE


IIFE 就是立即执行函数,将需要暴漏出来的成员挂载到 window 对象上,通过这种方式可以保证内部成员无法被访问和修改。


(function () {
  var name = "module-a";
  function method1() {
    console.log(name + "#method1");
  }
  function method2() {
    console.log(name + "#method2");
  }
  window.moduleA = {
    method1,
    method2,
  };
})();


4. IIFE 通过参数声明依赖


在 IIFE 基础上接收一个参数,这样模块的依赖关系也更加明确。


(function ($) {
  var name = "module-b";
  function method1() {
    console.log(name + "#method1");
    $("body").animate({ margin: "200px" });
  }
  function method2() {
    console.log(name + "#method2");
  }
  window.moduleB = {
    method1,
    method2,
  };
})(jQuery);

这些就是早期在没有工具和规范的情况下对模块化的落地方式。


规范的出现


上面介绍的几种方式,在不同项目和不同开发者的实际使用中,会存在细微的差异。为了统一差异,就需要一个标准来规范模块加载模块。

手动在 html 中引入 js 文件会有很多问题。当新增加模块或者修改模块名字时,需要手动修改。模块的依赖关系发生改变时,需要手动修改。模块多余,不需要时,需要手动移除。总之就是需要人工维护模块的加载。

所以我们需要模块化标准和模块加载器,通过代码的方式来帮我们自动加载模块。


CommonJS 规范


CommonJS 是 Nodejs 提出的一套模块化规范,具有以下几条约定。

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过 module.exports 导出成员
  • 通过 require 函数载入模块

但是这套规范在浏览器中使用会有问题。

CommonJS 是以同步的方式加载模块。nodejs 的机制是在启动时加载所有模块,运行时不会再去加载,只会去使用模块。

这种模式运行在浏览器中,会导致应用效率低下,每打开一个页面都会导致大量的同步请求出现。

所以早起浏览器中并没有使用 CommonJS 规范,而是结合浏览器的特点,重新设计了一套浏览器规范,AMD。


AMD(Asynchronous Module Definition)


AMD 是异步模块定义规范。

Require.js 是一个实现了 AMD 规范的库。

具体用法如下:


define("moduleName", ["jQuery", "./module2"], function ($, module2) {
  return {
    start: function () {
      $("body").animate({ margin: "200px" });
      module2();
    },
  };
});

define 是定义模块的函数。它接收两个或三个参数。

第一个参数是模块名,第二个参数是依赖数组,第三个是模块函数。

模块函数提供了独立的作用域,并可以按照依赖参数数组的顺序接收参数列表。返回的对象就是暴露的成员。

第二个参数是可选的,如果没有依赖的模块,可以省略。

除了 define,AMD 还有一个 require 函数,用法如下:


require(["./module1"], function (module1) {
  module1.start();
});

require 用法和 define 类似,不同的是 require 只是会导入模块和执行代码,而不会去定义模块。导入模块的方式是创建一个 script 标签,然后去请求模块代码。

目前绝大多数第三方库都支持 AMD 规范。

所以 AMD 规范生态是很好的,但是使用相对复杂,而且如果模块划分过细的话,js 文件请求会很频繁,导致页面效率低下。


Sea.js + CMD


同时期的淘宝推出了 sea.js,和 require.js 类似。CMD 规范类似于 AMD,目的是想简化 AMD 的写法,尽量和 CMD 保持一致,从而减少开发者学习成本,但是后来被 AMD 兼容了。算是一个重复的轮子。


define(function (require, exports, module) {
  var $ = require("jquery");
  module.exports = function () {
    console.log("module 2~");
    $("body").append("<p>module2</p>");
  };
});


标准规范


上面的几种规范虽然都解决了模块化,但或多或少存在一些问题。

现在的前端模块化已经非常成熟了,而且大家对目前的前端模块化方式已经基本统一。

在浏览器中,使用 ES Modules 规范;在 nodejs 中,使用 CommonJS 规范。

由于 CommonJS 是 nodejs 内置支持的模块化规范,所以不存在兼容性问题。

ES Modules 是 ECMAScript2015 才被定义的标准,会存在各种环境下的兼容性问题。不过随着 Webpack 等打包工具的流行,这个问题也逐渐被解决。

目前来说,ES Modules 是最流行的模块化规范。相较于社区提出的 AMD 规范,ES Modules 在语言层面实现了模块化,更加完善。

因为 ES Modules 是官方提出的规范,所以迟早所有浏览器都会原生实现这个特性。未来有着非常好的发展。

而且,短期内应该也不会再有新的模块化标准轮子出现。


ES Modules


基本特性


在浏览器中直接使用模块的方式是给 script 标签设置 type=module。

首先来创建一个 index.html 文件体验一下。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script type="module">
      var hi = "hello, world!";
      console.log(hi);
    </script>
  </body>
</html>

通过 serve 工具启动它,会发现这个模块的内容和普通脚本一样正常执行,没有什么区别。

接下来看一下模块和脚本的几个具体区别。


区别 1 模块自动采用严格模式


在普通的脚本文件中,默认采用宽松模式,如果需要启用严格模式,需要使用 "use strict"声明。

拿 this 举例。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      console.log(`脚本:${this}`);
    </script>
    <script type="module">
      console.log(`模块:${this}`);
    </script>
  </body>
</html>

这里会有两句日志输出。第一句会输出 window,第二句会输出 undefined。


区别 2 每个模块都会拥有私有作用域


看下面的例子。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      var a = 1;
    </script>
    <script type="module">
      var b = 2;
      console.log("模块1: ", a, b);
    </script>
    <script type="module">
      console.log("模块2: ", a, b);
    </script>
  </body>
</html>

它也会打印 2 句日志。


模块1:  1 2
ReferenceError: b is not defined

可以看到,脚本中用 var 创建的变量被挂载到 window 对象上,所以所有脚本和模块都可以访问到变量 a。

但是模块中使用 var 创建的变量并不会被挂载到 window 对象上,所以接下来的模块或者脚本访问 b 时都会得到 b is not defined 的错误。

这样就可以放心的在模块中创建变量,而不需要担心全局作用域命名空间污染问题。


区别 3 模块是通过 CORS 的方式请求外部模块的


如果通过 src 请求模块文件,同源的情况下没有问题。非同源的话比如服务端开启 CORS 响应头信息才可以。

查看下面百度 jquery 的例子,这个 js 文件是不支持 CORS 的。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
    <script>
      console.log("get success");
    </script>
    <script
      type="module"
      src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"
    ></script>
  </body>
</html>

可以看到控制台先打印 get success,由于 script 默认是同步执行的,所以意味着通过脚本的模式加载文件成功了。接下来会得到一个跨域的错误,意味着以模块的方式加载文件失败了。

而且模块只支持通过 http 协议加载,不支持本地 file 协议加载。


区别 4 模块会延迟执行脚本


脚本默认会立即执行,脚本的执行过程中会中断页面渲染。

而模块会延迟执行,相当于给 script 标签添加了 defer 属性。

看下面的例子。


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./alert.js"></script>
    <div>world</div>
  </body>
</html>

要在 index.html 同级目录下创建一个 alert.js 文件。


alert("hello");

alert 的执行会阻塞页面渲染,所以在 alert 存在的时候,是看不到 world 的。

如果加上 type=module,就可以实现脚本的延迟执行。


相关文章
|
1月前
|
C语言 开发者
模块化程序设计
模块化程序设计
15 0
|
5月前
|
安全 程序员 C++
代码规范:函数设计
除非告诉人们“危险”是什么,否则这个警告牌难以起到积极有效的作用。难以理解的断言常常被程序员忽略,甚至被删除。 ↩︎
43 0
|
5月前
|
JavaScript 前端开发 UED
JavaScript模块化:提高代码可维护性和可重用性的利器
JavaScript模块化:提高代码可维护性和可重用性的利器
62 0
|
9月前
|
JavaScript 前端开发 内存技术
彻底搞清楚 ECMAScript 的模块化(三)
彻底搞清楚 ECMAScript 的模块化
298 0
|
9月前
|
JavaScript 前端开发
彻底搞清楚 ECMAScript 的模块化(二)
彻底搞清楚 ECMAScript 的模块化
115 0
|
JavaScript
用大白话让你理解TypeScript的要点.(二)
用大白话让你理解TypeScript的要点.(二)
用大白话让你理解TypeScript的要点.(二)
|
JavaScript 索引
用大白话让你理解TypeScript的要点.(三)
用大白话让你理解TypeScript的要点.(三)
用大白话让你理解TypeScript的要点.(三)
|
JavaScript Java C语言
用大白话让你理解TypeScript的要点.(一)
用大白话让你理解TypeScript的要点.(一)
用大白话让你理解TypeScript的要点.(一)
|
JavaScript 前端开发
【Typescript综合】模块化开发
【Typescript综合】模块化开发
112 0
【Typescript综合】模块化开发