聊聊esm和commonjs

1,857 阅读12分钟

由于实习公司使用的是vue3.0,所以最近要开始学习vue3.0。 vue3.0有很多的前置知识,TS esm等。并且vue3.0的源码也非常值得一读。希望在完成日常的开发工作之后,也能逐步开始学习vue3的源码。

参考链接:commonjs模块和ES6模块

commonjs和esm的区别

阮一峰 ES6教程

阮一峰 Node.js 如何处理 ES6 模块

前端模块化详解

在学习nodejs刚刚入门的时候,想到之前碰到过得面试题:require和import的区别。所以借鉴上面这篇博客,总结一点自己的所学。

面试题

require方式的引入

// base.js
let count = 0;
setTimeout(() => {
    console.log("base.count", ++count); // 1
}, 500)

module.exports.count = count;

// commonjs.js
const { count } = require('./base');
setTimeout(() => {
     console.log("count is" + count + 'in commonjs'); // 0
}, 1000)



// base.js
const name = 'wjb'
const age = 12

function sayHello(name){
    console.log('hello '+name)
}

exports.name = name
exports.age = age
exports.sayHello = sayHello

setTimeout(() => {
    exports.name = 'fy'
}, 1000);

setTimeout(() => {
    console.log(exports.name)
}, 3300);

// main.js
const obj = require('./base');
const name = obj.name
const age = obj.age
const sayHello = obj.sayHello

console.log(name)
console.log(age)

sayHello('kobe')

setTimeout(() => {
    obj.name = 'gaile'
}, 2000);

import方式的引入

// base1.mjs
let count = 0;
setTimeout(() => {
    console.log("base.count", ++count); // 1
}, 500)
export {count};

// es6.mjs
import { count } from './base1';
setTimeout(() => {
     console.log("count is" + count + 'in es6'); // 1
}, 1000)

commonJS 模块

Module 模块编译

在Node中,每个文件模块都是一个对象,在源码中Module的定义如下:

function Module(id = '', parent) {
    this.id = id;
    this.path = path.dirname(id);
    setOwnProperty(this, 'exports', {});
    moduleParentCache.set(this, parent);
    updateChildren(parent, this, false);
    this.filename = null;
    this.loaded = false;
    this.children = [];
}

我们可以看出,nodejs中的源码对于Module采用函数的方法定义类,并且在new Module的时候,会初始化上述几个属性。

  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
  • module.parent 返回一个对象,表示调用该模块的模块。
  • module.children 返回一个数组,表示该模块要用到的其他模块。
  • module.exports 表示模块对外输出的值。

module.exports属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports变量。 再来看一看通过 console.log(module)输出的内容

image.png 我们重点关注一下loadedchildren,其中loaded是表示模块是否加载过了,在其他require当前这个模块的时候,如果loaded是true的话,会直接读取缓存。

children是表示当前模块引入的其他模块,我在这个文件中引入了node_test的模块,所以当前模块的子模块有node_test,并且模块的id是模块所在的路径。因为引入模块的时候会先执行一次模块的内容,所以loaded是true。

再在这里关注一下path。讲到path是先要讲讲require的引入顺序的。这里给出一个nodejs的官方文档,看看官方文档是怎么描述require的执行顺序的require的引入优先级

require(X) from module at path Y
1. If X is a core module,
   a. return the core module
   b. STOP
2. If X begins with '/'
   a. set Y to be the filesystem root
3. If X begins with './' or '/' or '../'
   a. LOAD_AS_FILE(Y + X)
   b. LOAD_AS_DIRECTORY(Y + X)
   c. THROW "not found"
4. If X begins with '#'
   a. LOAD_PACKAGE_IMPORTS(X, dirname(Y))
5. LOAD_PACKAGE_SELF(X, dirname(Y))
6. LOAD_NODE_MODULES(X, dirname(Y))
7. THROW "not found"
.......(截取一部分)

简单描述一下require查找模块的过程

  1. x是一个核心模块(比如 http、path、fs),直接返回core module
  2. X是以 ./ 或 ../ 或 /(根目录)开头的。如果文件有后缀名,那么就按后缀名返回文件 如果没有后缀名,就按照如下顺序进行读取: 1.直接查找文件名 2. 依次查找 .js .json X.node
  3. 如果安装文件名没有查找到,那么就按照路径名进行查找,并且依次查找路径名下的 index.js index.json index.node
  4. 直接是一个X(没有路径),并且X不是一个核心模块。那么就按照module.path进行查找。可以见上面的贴图。

CommonJS 模块的加载原理

CommonJs 规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的 exports属性(即module.exports)是对外的接口,加载某个模块,其实是加载该模块的module.exports属性。

commonjs模块的特点

  1. 所有代码运行在模块作用域,不会污染全局作用域。
  2. 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  3. 模块加载的顺序,按照其在代码中出现的顺序。模块加载的顺序是 DFS

模块的缓存

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

require('./example.js');
require('./example.js').message = "hello";
require('./example.js').message
// "hello"
console.log(require(./example.js).message)  // 能正常输出hello,说明模块仅仅加载一次,之后require都是直接访问之前加载的缓存

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

// 删除指定模块的缓存
delete require.cache[moduleName];

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

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

commonjs的循环依赖

// a.js
exports.done = false;
let b = require('./b');
console.log('a.js: b.done = %j', b.done);  // true
exports.done = true;
console.log('a.js执行完毕');


// b.js
exports.done = false;
let a = require('./a'); // a.js停止
console.log('b.js: a.done = %j', a.done);  // false
exports.done = true;
console.log('b.js执行完毕');
// main.js

/* 执行顺序
    先要明确一个原则:CommonJS代码一个很重要的原则是,
    在代码加载时(require时)执行
    1. 引入 a.js
    2. 执行 a.js到第二行
    3. a.js 引入 b.js,所以执行b.js 代码,
        此时 a.js代码等待b.js执行完毕再执行,所以a.js先暂停执行
    4. 上面代码之中,b.js执行到第二行,就会去加载a.js,这时,就发生了“循环加载”。
       系统会去a.js模块对应对象的exports属性取值,可是因为a.js还没有执行完,
       从exports属性只能取回已经执行的部分,而不是最后的值。所以b.js 中输出false
    5.继续执行a.js,执行完毕。所以在main.js中 输出 true
*/

// 上面的代码证明了两件事。
// 一是,在b.js之中,a.js没有执行完毕,只执行了第一行。
// 二是,main.js执行到第二行require('b.js')时,不会再次执行b.js,
//   而是输出缓存的b.js的执行结果,即它的第四行。



let a = require('./a');
let b = require('./b');
console.log('main.js: a.done = %j, b.done = %j', a.done, b.done);  // true true

/*
输出结果
b.js: a.done = false
b.js执行完毕
a.js: b.done = true
a.js执行完毕
main.js: a.done = true, b.done = true
*/

ESM ES6模块

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

CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

commojs的运行时加载

// CommonJS模块
let { stat, exists, readFile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

require本质就是一个函数,可以放在模块内的任何地方进行执行。

上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象_fs,然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

ES6模块的加载

ES6模块不是对象,而是通过export关键字显式指定输出的代码,再通过import关键字输入。

export的三种方法

1. export let name = 'www'
2. export {name}
3. export {name as nm}

import的三种方法

1. import {name} from '模块名'
2. import {name as nm} from '模块名'
3. import * as obj from '模块名'

defalut的用法 前面介绍的三种import和export方法都需要知道导出导入的具体包名称。default的默认导出就不需要知道我们导出的变量的名称,并且在imoport的时候不需要使用 {},并且可以自己指定名称

export default ...
import ... from ''
// ES6模块
import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“**编译时加载**”或者静态加载,即 ES6 可以在编译时(v8引擎 parse阶段 )就完成模块加载,效率要比 CommonJS 模块的加载方式高。

ES6模块的循环引用

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

上面代码中,a.mjs加载b.mjsb.mjs又加载a.mjs,构成循环加载。执行a.mjs,结果如下。

$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined

至于 foo not found,且请看官细细往下看,将在 import/export的提升这个小节中讲到

nodejs环境下如何处理ES6模块

esm的模块在.js文件下是不被支持的。在明白了之前两种模块的差异之后。思考一下为什么在 .js文件中 es6的模块不被支持。原因是:require引入的模块是引入的时候就被立即执行的(在之前没有被引入过得情况下,也就是内存中没有缓存的情况下。)是一种同步加载。但是esm是异步加载的,也就是说esm的模块有一个独立的静态解析的阶段,依赖关系就是在这个静态解析的阶段完成的。(之后写一篇文章专门学习一下esm的独立静态解析的阶段)

node环境下执行es6模块的两个方法

  1. .js 文件的后缀名改为 .mjs,那么node就会支持esm了。
  2. 在该项目的package.json中,将 type 指定为 module
{
   "type": "module"
}

node 下定义commonjs的模块的两个方法

  1. .cjs,只能当成 commonjs模块执行
  2. .js 并且不知道 type

commonjs 模块加载 esm的模块

commonjs为什么不能加载esm的模块,就是因为esm的模块在import的时候是异步加载的。所以我们只需要在commonjs中用 await等待,就可以实现commonjs的同步加载

// 在main.js 中引入 a.mjs
(async () => {
    await import('./a.mjs');
})();

在 esm 中引入 commonjs

esm中是可以引入 commonjs的模块的,但是需要注意引入的模块只能整体加载,不能局部加载(引入时解构)

// 正确
import packageMain from 'commonjs-package';

// 报错
import { method } from 'commonjs-package';

这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是module.exports,是一个对象,无法被静态分析,所以只能整体加载。

import packageMain from 'commonjs-package';
const { method } = packageMain;

注意:注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import。

ES6模块和CommonJs模块主要有以下区别

1.CommonJs模块输出的是一个值的拷贝,ES6模块输出的是值的引用。

  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。请看下面这个模块文件lib.js的例子。

    // lib.js
    const counter = 3;
    function incCounter() {
      counter++;
    }
    module.exports = {
      counter: counter,
      incCounter: incCounter,
    };
    
    // main.js
    const mod = require('./lib');
    
    console.log(mod.counter);  // 3
    mod.incCounter();
    console.log(mod.counter); // 3
    

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

    // lib.js
    const counter = 3;
    function incCounter() {
      counter++;
    }
    module.exports = {
      get counter() {
        return counter
      },
      incCounter: incCounter,
    };
    //////因为 counter是一个取值器函数 get,访问counter的时候就运行get counter函数,所以会返回4(下面的main函数运行的结果)
    
    // main.js
    const mod = require('./lib');
    
    console.log(mod.counter);  // 3
    mod.incCounter();
    console.log(mod.counter); // 4
    
  • ES6模块输出的是值的引用

    ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

    // mod.js
    function C() {
      this.sum = 0;
      this.add = function () {
        this.sum += 1;
      };
      this.show = function () {
        console.log(this.sum);
      };
    }
    
    export let c = new C();
    
    // x.js
    import {c} from './mod';
    c.add();
    
    // y.js
    import {c} from './mod';
    c.show();
    
    // main.js
    import './x';
    import './y';
    // 输出 1,说明x.js 和 y.js import的是同一个实例c
    

CommonJs模块是运行时加载(只要没有缓存,那么require一次就执行一次),ES6模块是编译时输出接口,所以可以用来确定依赖关系。

esm存在export 和 import的提升

esm对import/export的提升,类似于var 和 function foo(){}方式定义的函数一样。会存在变量提升。多嘴一句,通过

const foo = ()=>{};

这种方式定义的函数,不会存在变量提升。 言归正传,如何去验证export 和 import是存在 提升的呢?

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

以上述这个例子为例。 先执行 a.mjs。关于 import的引入时机不是很清楚。我去查阅一些资料再来更新。因为我看很多博客也没有讲清楚这个import的引入时机,编译时引入。 一些前置知识:

题目

// index.js
console.log('running index.js');
import { sum } from './sum.js';
console.log(sum(1, 2));

// sum.js
console.log('running sum.js');
export const sum = (a, b) => a + b;

答案: running sum.js, running index.js, 3

import命令是编译阶段执行的,在代码运行之前。因此这意味着被导入的模块会先运行,而导入模块的文件会后执行。这是CommonJS中require()和import之间的区别。使用require(),您可以在运行代码时根据需要加载依赖项。 如果我们使用require而不是import,running index.js,running sum.js,3会被依次打印。

// module.js 
export default () => "Hello world"
export const name = "Lydia"

// index.js 
import * as data from "./module"

console.log(data)

答案:{ default: function default(), name: "Lydia" }

使用import * as name语法,我们将module.js文件中所有export导入到index.js文件中,并且创建了一个名为data的新对象。 在module.js文件中,有两个导出:默认导出和命名导出。 默认导出是一个返回字符串“Hello World”的函数,命名导出是一个名为name的变量,其值为字符串“Lydia”。data对象具有默认导出的default属性,其他属性具有指定exports的名称及其对应的值。