概述
模块式是目前前端开发最重要的范式之一。
随着前端项目的日渐复杂,不得不花费大量时间去管理。
模块化就是最主流的代码组织方式。
将复杂的代码按照功能不同划分为不同的模块,通过单独维护的方式,提高开发效率,降低维护成本。
「模块化」只是思想,不包含具体实现。
演变过程
早期的技术标准并没有预料到如今前端项目的规模,所以很多设计上的遗留问题导致我们现在去实现模块化有很多问题。
虽然这些问题都被现在的模块化标准和工具所解决了,但它的演变过程值得去思考。
总体来看,JavaScript 模块化大致有 4 个阶段。
1. 文件划分的形式
这种形式就是以每个 js 文件为一个模块,在 html 文件中导入它们进行使用。
这么做有 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,就可以实现脚本的延迟执行。