江辰

博客

前端模块化

发布于 # 模块化

模块化的背景

Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 Javascript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。好消息是,最新的浏览器开始原生支持模块功能了,这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。

什么是模块化?

将一个复杂的程序依据一定的规则封装成代码块(文件), 并进行组合在一起,代码块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与其它模块通信

原生如何实现模块化

function 模式

优点

  • 将不同的功能封装成不同的全局函数

  • 天生的模块化

缺点

  • 污染全局命名空间, 易引起命名冲突

  • 模块成员之间看不出直接关系

例子

function function() {
    //...
}

function function2() {
    //...
}

namespace 模式

优点

减少了全局变量,解决命名冲突

缺点

数据不安全(外部可以直接修改模块内部的数据)

例子

let module = {
    name: 'Faker',
    getName() {
        console.log(`${this.name}`)
    }
}

module.name = 'other Name' // 能直接修改模块内部的数据
module.getName() // other Name

IIFE(立即调用函数表达式) 模式

优点

数据是私有的, 外部只能通过暴露的方法操作

缺点

如果当前这个模块依赖另一个模块怎么办?

引入 script

优点

相比于使用一个 js 文件,这种多个 js 文件实现最简单的模块化的思想是进步的。

缺点

请求过多,如果我们要依赖多个模块,那样就会发送多个请求,导致请求过多

依赖关系不明显,我们不知道他们的具体依赖关系是什么,因为不了解他们之间的依赖关系导致加载先后顺序出错。

难以维护,以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。

例子

<script src="jquery.js"></script>
<script src="jquery_scroller.js"></script>
<script src="main.js"></script>
<script src="other1.js"></script>
<script src="other2.js"></script>
<script src="other3.js"></script>

模块化规范

CommonJS

Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。

特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。
  • 对外抛出的变量,是一个值的地址,不是引用地址,内部修改的变量对外导出的变量不影响。

基本语法

  • 暴露模块:module.exports = valueexports.xxx = value
  • 引入模块:require(xxx), 如果是第三方模块,xxx 为模块名;如果是自定义模块,xxx 为模块文件路径

此处我们有个疑问:CommonJS 暴露的模块到底是什么? CommonJS 规范规定,每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即 module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。

// example.js
const count = 5;
const add = function () {
    return count;
};
module.exports.count = count;
module.exports.add = add;

上面代码通过 module.exports 输出变量 count 和函数 add

const example = require('./example.js');
console.log(example.count); // 5
console.log(example.add()); // 5

CommonJS 模块的加载机制

require 命令是 CommonJS 规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的 module.require 命令,而后者又调用 Node 的内部命令 Module.\_load

Module._load = function(request, parent, isMain) {
    // 1. 检查 Module._cache,是否缓存之中有指定模块
    // 2. 如果缓存之中没有,就创建一个新的Module实例
    // 3. 将它保存到缓存
    // 4. 使用 module.load() 加载指定的模块文件,
    //    读取文件内容之后,使用 module.compile() 执行文件代码
    // 5. 如果加载/解析过程报错,就从缓存删除该模块
    // 6. 返回该模块的 module.exports
};

上面的第 4 步,采用 module.compile()执行指定模块的脚本,逻辑如下。

Module.prototype._compile = function(content, filename) {
    // 1. 生成一个require函数,指向module.require
    // 2. 加载其他辅助方法到require
    // 3. 将文件内容放到一个函数之中,该函数可调用 require
    // 4. 执行该函数
};

上面的第 1 步和第 2 步,require 函数及其辅助方法主要如下。

  • require(): 加载外部模块
  • require.resolve():将模块名解析到一个绝对路径
  • require.main:指向主模块
  • require.cache:指向所有缓存的模块
  • require.extensions:根据文件的后缀名,调用不同的执行函数

一旦 require 函数准备完毕,整个所要加载的脚本内容,就被放到一个新的函数之中,这样可以避免污染全局环境。该函数的参数包括 requiremoduleexports,以及其他一些参数。

Module.\_compile 方法是同步执行的,所以 Module.\_load 要等它执行完成,才会向用户返回 module.exports 的值。

// main.js
let counter = require('./lib').counter;
let incCounter = require('./lib').incCounter;

console.log(counter); // 3
incCounter();
console.log(counter); // 3

上面代码说明,counter 输出以后,lib.js 模块内部的变化就影响不到 counter 了。这是因为 counter 是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

CommonJS 模块的缓存

第一次加载某个模块时,Node 会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports 属性。

require('./example.js');
require('./example.js').message = "hello";
require('./example.js').message
// "hello"

上面代码中,连续三次使用 require 命令,加载同一个模块。第二次加载的时候,为输出的对象添加了一个 message 属性。但是第三次加载的时候,这个 message 属性依然存在,这就证明 require 命令并没有重新加载模块文件,而是输出了缓存。

如果想要多次执行某个模块,可以让该模块输出一个函数,然后每次 require 这个模块的时候,重新执行一下输出的函数。

所有缓存的模块保存在 require.cache 之中,如果想删除模块的缓存,可以像下面这样写。

// 删除指定模块的缓存
delete delete require.cache[require.resolve("./a.js")];

// 删除所有模块的缓存
Object.keys(require.cache).forEach(function(key) {
    delete delete require.cache[require.resolve(key)];
})

注意,缓存是根据绝对路径识别模块的,如果同样的模块名,但是保存在不同的路径,require 命令还是会重新加载该模块。

CommonJS 模块的循环加载

如果发生模块的循环加载,即 A 加载 B,B 又加载 A,则 B 将加载 A 的不完整版本。

// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';

// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

上面代码是三个 JavaScript 文件。其中,a.js 加载了 b.js,而 b.js 又加载 a.js。这时,Node 返回 a.js 的不完整版本,所以执行结果如下。

$ node main.js
b.js a1
a.js b2
main.js a2
main.js b2

修改 main.js,再次加载 a.jsb.js

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

执行上面代码,结果如下。

$ node main.js
b.js  a1
a.js  b2
main.js  a2
main.js  b2
main.js  a2
main.js  b2

上面代码中,第二次加载 a.jsb.js 时,会直接从缓存读取 exports 属性,所以 a.jsb.js 内部的 console.log 语句都不会执行了。

ES6 模块化

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

特点

  • ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

基本语法

export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。

export

let basicNum = 0;
let add = function(a, b) {
    return a + b;
};
export { basicNum, add };

import

import { basicNum, add } from './math';
function test(ele) {
    ele.textContent = add(99 + basicNum);
}

如上例所示,使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到 export default 命令,为模块指定默认输出。

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

ES6 模块与 CommonJS 模块的差异

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

总结

CommonJS 规范主要用于服务端编程,加载模块是同步的,并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的。 ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJSAMD 规范,成为浏览器和服务器通用的模块解决方案。