CommonJS规范详解

634 阅读8分钟

Commonjs 标准的提出弥补 Javascript 对于模块化没有统一标准的缺陷。nodejs 借鉴了 Commonjs 规范,实现了良好的模块化管理。

Commonjs 规范

  • 每个文件就是一个模块,称之为 module,所有代码运行在模块作用域中,不会污染全局作用域;
  • nodejs 中的 Commonjs 模块加载采用同步加载方式,模块的加载顺序,按照其在代码中出现的顺序进行加载;
  • 模块可以加载多次,第一次加载时会运行模块,模块输出结果会被缓存,再次加载时,会从缓存结果中读取输出模块;
  • 通过 require 加载模块,通过 exports 或 module.exports 输出模块;

commonjs 基本使用

// hello.js
let name = 'xiaoming'
module.exports = function sayName (){
    return name
}

// home.js
const sayName = require('./hello.js')
module.exports = function say() {
    return {
        name: sayName(),
        author: 'xiaoming'
    }
}

如上就是 Commonjs 最简单的实现,那么就必须解决两个问题:

  • 如何解决变量私有化的问题?
  • module.exports,exports,require 三者是如何工作的?

解决变量私有化

我们知道每个模块文件上存在 module,exports,require三个变量,它们分别表示:

  • module: 记录当前模块信息;
  • require: 引入模块的方法;
  • exports: 当前模块导出的属性;

然而这三个变量并没有被定义的,但是我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们,这是为什么呢?

这是因为 node 在编译的过程中对 js 的代码块进行首尾包装,以上述的 home.js 为例子,它被包装之后的样子如下:

(function(module, exports, require, __filename, __dirname){
  const sayName = require('./hello.js')
  module.exports = function say() {
    return {
      name: sayName(),
      author: 'xiaoming'
    }
  }
})

可以看到,require ,exports ,module 是通过形参的方式传递到包装函数中的。那么包装函数本质上是什么样子的呢?

function wrapper(script) {
  return (
    '(function (module, exports, require, __filename, __dirname) {' +
    script +
    '\n})'
  )
}

模块的代码script(脚本),是以形参的形式传递到包装函数中去的。

const modulefunction = wrapper(`
  const sayName = require('./hello.js')
    module.exports = function say(){
        return {
            name:sayName(),
            author:'我不是外星人'
        }
    }
`)

上面模拟了一个包装函数功能, script 为我们在 js 模块中写的内容,最后返回的就是包装之后的函数,当然这个函数暂且是一个字符串。

在模块加载的时候,会通过 runInThisContext (可以理解成eval) 执行 modulefunction ,传入require,exports,module 等参数,最终我们写的 nodejs 文件就这么执行了。

runInThisContext(modulefunction)(module, exports, require, __filename, __dirname)

至止,就完成了整个模块执行的原理,同时利用IIFE解决了变量私有化的问题。

require

require 加载文件类型

require 加载文件有三种类型:

const fs = require('fs') // 核心模块
const sayName = require('./hello.js') // 文件模块
const crypto = require('crypto-js') // 第三方自定义模块

当 require 方法执行的时候,接收的唯一参数作为一个标识符,Commonjs 下对不同的标识符,处理流程不同,但是目的相同,都是找到对应的模块。

核心模块处理

核心模块的优先级仅次于缓存加载,在 Node 源码编译中,已被编译成二进制代码,所以加载核心模块,加载过程中速度最快。

文件模块处理

./ , ../ 和 / 开始的标识符,会被当作文件模块处理。require() 方法会将路径转换成真实路径,并以真实路径作为索引,将编译后的结果缓存起来,第二次加载的时候会更快。至于怎么缓存的,我们稍后会讲到。

自定义模块处理

自定义模块,一般指的是非核心的模块,它可能是一个文件或者一个包,它的查找会遵循以下原则:

  • 在当前目录下的 node_modules 目录查找。
  • 如果没有,在父级目录的 node_modules 查找,如果没有在父级目录的父级目录的 node_modules 中查找。
  • 沿着路径向上递归,直到根目录下的 node_modules 目录。
  • 在查找过程中,会找 package.json 下 main 属性指向的文件,如果没有 package.json,在 node 环境下会以此查找 index.js,index.json,index.node。

3.png

require 加载原理

首先要明白两个概念,那就是 module 和 Module:

  • module: 在 Node 中每一个 js 文件都是一个 module,module 上保存了 exports 等信息。
  • Module: Module 是 module 的构造函数,同时在 Module 上挂载了很多属性,比如,会用 Module 缓存每一个模块加载的信息。

require 的源码在 Node 的 lib/module.js 文件,为了便于理解,本文引用的源码是简化过。

Module 构造函数

function Module(id, parent) {
  this.id = id
  this.exports = {}
  this.parent = parent
  this.filename = null
  this.loaded = false
  this.children = []
}

上面代码中,Node 定义了一个构造函数 Module,所有的模块都是 Module 的实例。

require 方法

  1. 在 Module 的原型有一个 require 方法
Module.prototype.require = function(path) {
  return Module._load(path, this);
};
  1. require 方法调用了 Module._load 方法
Module._load = function(request, parent, isMain) {
  //  计算绝对路径
  var filename = Module._resolveFilename(request, parent);

  //  第一步:如果有缓存,取出缓存
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;

  // 第二步:是否为内置模块
  if (NativeModule.exists(filename)) {
    return NativeModule.require(filename);
  }

  // 第三步:生成模块实例,存入缓存
  var module = new Module(filename, parent);
  Module._cache[filename] = module;

  // 第四步:加载模块
  try {
    module.load(filename);
    hadException = false;
  } finally {
    if (hadException) {
      delete Module._cache[filename];
    }
  }

  // 第五步:输出模块的exports属性
  return module.exports;
}
  1. require 流程参考下图

4.png

  • require 接收一个参数(文件标识符),根据其计算出文件的绝对路劲,首先在 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。
  • 如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件,最后返回 module.exports 对象。

exports 和 module.exports

首先看一下两个的用法:

exports 使用

// a.js
exports.author = 'xiaoming'
exports.say = function () {
  console.log(666)
}

引用

const a = require('./a')
console.log(a)

打印结果:

{
    author: 'xiaoming',
    say: function () {
      console.log(666)
    }
}

可以看到 exports 最后导出了一个对象。

module.exports 使用 module.exports 本质上就是 exports ,我们用 module.exports 来实现如上的导出。

module.exports = {
  author:'xiaoming',
  say(){
      console.log(666)
  }
}

module.exports 也可以单独导出一个函数

module.exports = function () {
  // ...
}

从上述 require 原理实现中,我们知道了 exports 和 module.exports 持有相同引用,因为最后导出的是 module.exports 。所以,我们使用的时候选择exports 和 module.exports 两者之一,如果两者同时存在,很可能会造成覆盖的情况发生。比如如下情况:

exports.author = 'alien' // 此时 exports.name 是无效的
module.exports = {
  author: 'xiaoming',
  say() {
    console.log(666)
  }
}

既然有了 exports,为何又出了 module.exports ?

如果我们不想导出对象,而是只导出一个函数,那么使用 module.exports 就更方便了。因为 exports 始终会导出一个对象,不能导出除对象之外其他类型。

Browserify 中如何实现 Commonjs

上面的章节,我们详细介绍了 nodejs 中 Commonjs 的实现,Commonjs 除了在node中实现以外,它还应用的场景有:

  • Browserify(一个编译工具) 是 Commonjs 在浏览器中的一种实现;
  • webpack 打包工具对 CommonJS 的支持和转换,也就是前端应用也可以在编译之前,尽情使用 CommonJS 进行开发。

我们知道 Commonjs 代码只能在 nodejs 环境中运行,是不能在浏览器中运行的,如果要想在浏览器环境下使用 Commonjs 模块的代码,那就需要通过 Browserify 编译后才能在浏览器中使用。下面我们详细来介绍 Commonjs 在 Browserify 中的实现。

首先安装 Browserify

npm install browserify -g

源码

// entry.js
const sum = require('./sum')
sum(1, 2)

// sum.js
function sum(a, b) {
  return a + b
}

module.exports = sum

打包

browserify cjs/entry -o dist/entry.js  

打包后的代码(对代码进行了精简)

;(function () {
  function r(e, n, t) {
    // 这个就是require函数
    function o(i, f) {
      if (!n[i]) {
        var p = (n[i] = { exports: {} })
        // e[i] 就是 entry.js 模块里面的代码,利用call函数执行,并把require, module, exports传入
        e[i][0].call(
          p.exports,
          // 传入require函数
          function (r) {
            var n = e[i][1][r]
            return o(n || r)
          },
          // 传入module
          p,
          // 传入exports
          p.exports,
          ...
        )
      }
      // 返回结果
      return n[i].exports
    }
    for ( var i = 0; i < t.length; i++) {
       // 调用require函数 
       o(t[i])
    }
    return o
  }
  return r
})()(
  {
    // entry.js 模块
    1: [
      // 在代码外面套了一层函数,然后把require, module, exports传入
      function (require, module, exports) {
        const sum = require('./sum')
        sum(1, 2)
      },
      // entry.js 模块 依赖了那些模块
      { './sum': 2 }
    ],
    // sum.js 模块
    2: [
      function (require, module, exports) {
        function sum(a, b) {
          return a + b
        }
        module.exports = sum
      },
      {}
    ]
  },
  {},
  [1]
)
  • 首先 Browserify 会把每个js文件代码外面套一层函数,通过参数的形式传入require, module, exports,类似于nodejs中的require的实现。
function (require, module, exports) {
    function sum(a, b) {
      return a + b
    }
    module.exports = sum
}
  • 实现一个 require 函数
function o(i, f) {
  if (!n[i]) {
    var p = (n[i] = { exports: {} })
    e[i][0].call(
      p.exports,
      function (r) {
        var n = e[i][1][r]
        return o(n || r)
      },
      p,
      p.exports,
      ...
    )
  }
  return n[i].exports
}

总结

本文分析了 nodejs 是如何实现 commonjs 规范要求的。首先,通过IIFE在模块外层套一层函数,解决了变量私有化的问题;接着,分析了require函数的加载过程;最后比较了 exports 和 module.exports 的区别及使用场景。

利用上面所学的知识,进一步分析了 Browserify 是如何实现 commonjs 规范的,发现其实两者原理都很相似。通过本文的学习,我们对commonjs模块化有了大致了解,为后续的学习打下坚实的基础。