JavaScript 模块系统记录

370 阅读5分钟

说明:本文仅用于记录个人学习 JavaScript 模块系统的记忆笔记,以详细说明当前JavaScript模块系统的优缺点/注意事项为主,其发展历史请行搜索。

一、现在进行时

(一)CommonJS 模块

CommonJS是首个内置于Node.js平台的模块系统

核心概念:

  1. 基本使用

推荐写法

// 模块导出 index.js
exports.prop1 = 'hello world'
module.exports.prop2 = 'hi world' 
// 模块导入 other.js
const obj = require('index.js')
obj.prop1 // 'hello world'
obj.prop2 // 'hi world'

不推荐写法

// 做法:直接复制给 exports
// 结果:该导出无效
// 原因:exports 仅是指向 module.exports 变量,具体看实现原理的 loadModule 函数
exports = () => {}

// 做法:利用 require() 函数缓存机制,导出实例,形成单例模式
// 结果:可能导出多个实例
// 原因:下文解释
class Logger {}
module.exports = new Logger()

// 做法:monnkey patching
// 结果:全局副作用(side effect),难以检查
// patch.js
require('./logger').prop1 = funciton() {}
// other.js
require('./patch')
  1. 伪代码

/* 原compiled: function (exports, require, module, __filename, __dirname) {} */

function loadModule(filename, moudle, require) {
    const wrappedSrc = `
        (function (module, exports, require){
            ${fs.readFileSync(filename, 'utf8')}
        })(module, module.exports, require)
    `
    eval(wrappedSrc)
}

function require(moduleName) {
    const id = require.resolve(moduleName)
    if (require.cache[id]) {
        return require.cache[id].exports
    }
    
    // 模块的原数据
    const module = {
        exports: {},
        id
    }
    
    // 更新模块
    require.cache[id] = module
    // 载入模块
    loadModule(id, module, require)
    // 返回导出的变量
    return module.exports
}

require.cache = {}
require.resolve = (moduleName) => {
    /* 根据 moduleName 解析出完整的模块id */
}

NodeJS实现的模块类

/** Module
    id: 源码文件绝对路径,如 /User/sam/Desktop/index.js
    path: 源码路径对应的文件夹,通过 path.dirname(id) 生成
    exports: 模块输出的内容,默认为 {}
    parent: 父模块信息
    filename: 源码文件路径
    loaded: 是否已经加载完毕
    children: 子模块对象集合
    paths: [ '/home/fanwenzh/tmp/node_modules', // 模块查询范围
          '/home/fanwenzh/node_modules',
          '/home/node_modules',
          '/node_modules' ]
*/ 
  1. 模块解析算法 (中文)

  • 文件模块:以 / 开头,绝对路径;以 ./ 或 ../开头,相对路径(.js , .json, .node , .mjs => 其他文件以.js文件打开)
  • 核心模块:以moduleName名称开头,NativeModule
  • 包模块:如果没有找到moduleName相匹配的核心模块,从发出加载请求的模块开始,逐层向上寻名为 node_module 的目录载入该模块,直至到达文件系统的根部为止
  1. 循环依赖

CommonJS并没有从实际意义解决循环依赖的问题(下文举例:加载未完全执行的依赖返回非旧引用值),需要开发者在定义时规范好文件依赖顺序。

// 原因:require 是同步函数
// a.js 模块
exports.loaded = false
const b = require('./b')
module.exports = {
    b, 
    loaded: true // 覆写上文的loaded
}

// b.js 模块
exports.loaded = false
const a = require('./a')
module.exports = {
    a,
    loaded: true // 同上
}

// main.js
const a = require('./a')
const b = require('./b')
console.log('a =>', JSON.stringfy(a, null, 2))
console.log('b =>', JSON.stringfy(b, null, 2))
/*
a => {
    "b": {
       // 非更新版本!!!
        "a": {
            "loaded": false
        },
        "loaded": true
    },
    "loaded": true
}
// b.js 模块请求载入 a.js模块时,反映该模块所处的状态
// 无法反映a.js模块最终加载完毕时的状态
b => {
    "a": {
        "loaded": false
    },
    "loaded": true,
}
*/
  1. 单例模式的风险/解决方案

在 CommonJS 以伪单例模式导出可能存在多例情况:

// 单例模式导出
moduel.exports = new Database('mydb')

// 两个包(路径不同)a、b,同时依赖同一个包 c 时,由于缓存机制的key为路径,导致引入了两个实例c
/**
-- node_modules
   |-- package-a
   |  '-- node_modules
   |      '-- mydb // v1.0.0
   '-- package-b
      '--module_modules
          '-- mydb // v2.0.0
*/

// 方案:把单例(实例)保存到全局(顶层)
global.dbInstance = new Database('mydb')

(二)ECMAScript模块

ECMAScript 模块(简称ES模块,或ESM),是ECMAScript 2015规范制定的模块系统。支持循环依赖,能够异步加载(Promise)。

ES模块是静态的(static),区别于CommonJS,必须写在最顶层,而且要置于流控制语句之外。

// ESM
import path from 'path'

// CommonJS
if (process.debug) {
   const path = require('path')
}
  1. 基本使用

推荐用法

// 模块导出 index.js
export function log(){} // 对外导出 log
export const DEFAULT_LEVELS = {} // 对外导出 'DEFAULT_LEVELS'

// 模块导入
// import {log as mylog, DEFAULT_LEVELS} from './index.js'
import * as newModule from './index.js'
newModule.log() 
newModule.DEFAULT_LEVELS // {}

// 默认导出 logger.js, 忽略Logger导出名,记载default名下
export default class Logger {}

import MyLogger from './logger.js' // 导出默认模块
import * as loggerModule from './logger.js' // 全部导出
console.log(loggerModule) // {default: [Function: Logger]}

错误用法

// default 关键字不能用作变量名
import { default } from './logger.js'
  1. 模块解析算法(解决循环依赖)

分3阶段解析代码:

  • 第一阶段构造 Construction(或解析Parsing):寻找所有的引入语句,并递归加载每个模块内容
  • 第二阶段实例化 Instantiation:针对每个导出的实体,在内存中保留一个带名称的引用,但不给它赋值(创建依赖关系linking,而不执行JavaScript代码),只读live在引入方只能读而不能写
  • 第三阶段执行 Evaluation:执行代码获取值
// a.js
improt * as bModule from './b.js'
export let loaded = false
export const b = bModule
loaded = true

// b.js
import * as aModule from './a.js'
export let loaded = false
export const a = aModule 
loaded = true 

// main.js
import * as a from './a.js'
import * as b from './b.js'
console.log('a =>', a)
console.log('b =>', b)

/*
a => <ref *1>[Module] {
    b:[Module]{a: [Circular *1], loaded: true},
    loaded: true
}
b => <ref *1>[Module] {
    a:[Module]{b: [Circular *1], loaded: true},
    loaded: true
}
*/

(三)ESM 与 CommonJS 之间的区别

  1. ESM 默认运行在严格模式下,即不用在每份文件开头加上 "use strict"

  2. ESM 中通过 import.meta.url 获取当前模块文件路径,CommonJS用require、exports、module.exports、__filename、__dirname关键字

import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
  1. ES 模块中可引用 CommonJS模块,反之不可;.json文件在ES中也需要 createRequire 引入
// ES 中重现 require
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
// 但无法在 CommonJS 中引用 ES模块

二、过去式

过去式的模块系统,仅简要说明其实现机制。

(一)IIFE

IIFE,Immediately-invoked Function Expression,为立即调用函数表达式

const mod = (function(){
    const pro1 = ''
    const fn1 = () => {}
    
    const exported = {
        pro1,
        fn1
    }
    return exported
})()

(二)AMD 简化实现

AMD(Asynchronous Module Definition)是浏览器端的模块加载,实现有RequireJS(规范, github)和 Sea.js

(function(global) {
    const AMD = {} 
    // 模块缓存
    const moduleStorage = {} 
    AMD.define = function(name, dependencies, factory){}
    // 模块发射(触发执行)
    AMD.emit = function(name) {}
    // 模块获取
    AMD.require = function(name) {}
    
    global.define = AMD.define(name, dependencies, factory)
    global.require = AMD.require(name)
})(window)
  1. 基本使用

通过 define 定义模块,require 加载模块

// index.js
// 依赖文件
define(['other.js'], function() {
    // 定义的模块代码
    return 
})
  1. 具体原理

// 1.异步加载
const node = document.creaetElement('script')
node.type = 'text/javascript'
node.src = '3.js'
document.body.appendChild(node)

// 2. 按依按依赖顺序加载:监听 node 的 onload 事件以执行模块代码
// 3. 执行代码 emit 触发
  1. 循环依赖

以ReaquireJS为例,模块在没有执行过(没有缓存记录)的前提下,总会有其中一个模块读取依赖值是 空对象 或者 undefined

(三)UMD 简化实现

UMD(Universal Module Definition),(通过环境判断,执行不同的入口逻辑)提供前后端支持 AMD 和 CommonJS 模块实现方案。代码简单,可直接看 github

// returnExportsGlobal.js
(function (root, factory) {
    // AMD: 
    if (typeof define === 'function' && define.amd) {
        define(['b'], function (b) {
            return (root.returnExportsGlobal = factory(b));
        });
    // Node: CommonJS 
    } else if (typeof module === 'object' && module.exports) {
        module.exports = factory(require('b'));
    } else {
        // 浏览器: root 为 window
        root.returnExportsGlobal = factory(root.b);
    }
}(typeof self !== 'undefined' ? self : this, function (b) {
    return {};
}));

三、其他知识

  1. UTF-8 中 byte order mark(BOM
  1. Unix操作系统中的 Shebang(或Hashbang),即#!

四、参考资料

  1. 《 Node.js设计模式》
  2. Node.js require
  3. require源码解读——阮一峰