JS 模块化

98 阅读5分钟

为什么要引入模块化

最早,我们就是直接在 script 标签里写 JS 代码的

<script>
function add(a, b) {
    console.log(a + b)
}
add(1, 2)
</script>

这样做会产生一些问题:

  1. 代码复用性、可维护性问题
  2. 全局作用域污染

随着代码的增加,这些问题越来越严重。所以出现了用立即执行函数对代码进行封装,并通过提供外部方法来对它们进行访问。

var utils = (function() {
    var module = {}
    module.multiply = function(a, b) {
        console.log(a * b)
    }
    return module
}())
utils.multiply(1,2)

jQuery 就使用了这种模式,所有的函数都在一个全局对象 $ 中。然而这样做仍然有问题:

  1. 还是需要至少一个全局变量
  2. 必须按照一定的依赖顺序引用,比如需要控制在 jQuery 加载完成后才能运行带有 $ 的代码。
<script src='./jQuery.js'></script>
<script src='./main.js'></script>

模块化

将一个复杂的程序依据规范封装成几个模块,再进行组合。块的内部数据是私有的,只是向外部暴露一些接口,来完成和其他模块的通信。 好处:

  1. 避免命名冲突
  2. 按需分离
  3. 复用性、可维护性高

CommonJS 同步加载模块

2009年,在 NodeJS 中实现了 CommonJS 规范。主要方法是 用exports导出和require引用。

// utils.js 文件
function add(a, b) {
    console.log(a + b)
}
module.exports.add = add

// main.js 文件
var add = require('./utils').add
add(1, 2)

CommonJS 是同步的,只有加载完成,才能执行后面的操作。 由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。

AMD 异步加载模块

采用异步方式加载模块,即模块的加载不影响它后面语句的运行。 所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

//utils.js
define([], function() {
    return {
        add: function(a, b) {
            console.log(a + b)
        }
    }
})
// main.js 文件
require(['./utils'], function(utils) {
    utils.add(1, 2)
})

引用模块的时候,将模块名放在数组中作为 require() 的第一参数。如果定义的模块本身也依赖其他模块,同样将依赖模块放在 define() 的第一参数的数组中。

CMD 按需加载

  • 依赖前置:AMD 会一次性在 require 的第一个参数里引入所有依赖的模块
  • 就近依赖:CMD 会在需要这个模块时再 require,按需加载
// AMD
require(['./utils', 'a', 'b'], function(utils) {
    console.log(1)
    // 还没有用到 utils a b 等模块,但是 AMD 已经初始化了所有模块
    console.log(2)
    utils.add(1, 2)
})

//CMD
define(function(require, exports, module){
    console.log(1)
    if(false) {
        var utils = require('./utils') // 需要时再 require,不执行就不会加载
        utils.add(1, 2)
    }
})

但是在 AMD 也是支持依赖就近,也就是 CMD 这样的写法的,所以,RequireJS 中,以上两种方式都能执行。不过,RequireJS 官方文档中,默认都是采用依赖前置的写法。

UMD 通用模块定义

帮助判断选择用 AMD 还是 CommonJS 来加载模块,都不是的话就挂到全局对象上。 比如某段代码在服务器端和浏览器端都会用到,又不想维护 CJS 和 AMD 两套代码。

// utils.js 文件同上
(function(root, factory) {
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(['utils'], factory)
    } else if (typeof exports === 'object') {
        //CommonJS
        var utils = require('utils')
        module.exports = factory(utils)
    } else {
        root.result = factory(root.utils)
    }
}(this, function(utils) {
    utils.add(1, 2)
}))

ES6 Module

ES6 自带的模块化,使用importexport关键字来导入和导出模块。

export const utils = {
    add: function(a, b) {
        console.log(a + b)
    }
}

// main.js 文件
import { utils } from "./utils"
utils.add(1, 2)

CommonJS 和 ES6 的区别

CommonJS 是对模块的浅拷贝,ES6 Module是对模块的引用。

CommonJS 模块输出的是一个值的浅拷贝,一旦输出一个值,模块内部的变化就影响不到这个值。比如

// utils.js 文件
var count = 0
function add(a, b) {
    console.log(a + b)
    count++
}
module.exports = {add, count}

// main.js 文件
var utils = require('./utils')
utils.add(1, 2)
console.log(utils.count) // 0

虽然执行 add 函数使模块中的 count++ 但是引入 utils.count 时就是 0,模块内部改变并不会导致引入的值的变化。

ES6 模块输出的是值的引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。 到上面代码最后一行时候才会去模块中读取 utils.count 的值,这时候的输出就是 1 。

第二个区别是

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

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

模块的缓存

1. 优先从缓存中加载

模块在第一次加载后会被 Node 缓存,多次调用require()不会导致模块的代码被执行多次。

注意:不论是内置模块、用户自定义模块、还是第三方模块,它们都会优先从缓存中加载,从而提高模块的加载效率。

2. 内置模块的加载机制

内置模块是由Node.js 官方提供的模块,内置模块的加载优先级最高。

例如,require('fs')始终返回内置的fs模块,即使在node_modules目录下有名字相同的包也叫做 fs。

3. 自定义模块的加载机制

使用require()加载自定义模块时,必须指定以./../开头的路径标识符。在加载自定义模块时,如果没有指定路径标识符,则node会把它当作内置模块或第三方模块进行加载。

4. 第三方模块的加载机制

如果传递给require()的模块标识符不是一个内置模块,也没有以./../开头,则Node.js会从当前模块的父目录的/node_modules文件夹中查找该第三方模块,如果没有找到,则再移动到上一层父目录中,直到文件系统的根目录。

例如在C:Users\theima\project\foo.js文件里调用了require('tools'),则Node.js会按以下顺序查找:

  1. C:\Users\theima\project\node_modules\tools
  2. C:\Users\theima\node_modules\tools
  3. C:\Users\node_modules\tools
  4. C:\node_modules\tools
  5. 以上都查不到,报错

参考阅读

CommonJS规范 阮一峰