Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CommonJS 与 ES6 Module #32

Open
Jouryjc opened this issue Nov 21, 2018 · 0 comments
Open

CommonJS 与 ES6 Module #32

Jouryjc opened this issue Nov 21, 2018 · 0 comments

Comments

@Jouryjc
Copy link
Owner

Jouryjc commented Nov 21, 2018

一直以来对 CommonJS 和 ES6 的模块机制的认识很模糊。此文来梳理一下二者的用法和区别。

静态编译 VS 运行时加载

CommonJS

CommonJS是在运行时确定模块的依赖关系。举🌰:

// a.js
module.exports = {
  a () {
      console.log('this is module a, function a');
  },
    
  aa () {
      console.log('this is module a, functon aa');
  }
};

// b.js
let {a, aa} = require('./a');

// 等同于
let A = require('./a');
let a = A.a;
let aa = A.aa;

CommonJS 实质上就是加载整个模块,生成一个对象 A。再从对象上读取方法。这种就是“运行时加载”。只有在运行时才能得到这个对象,导致没办法在编译时做“静态优化”。

ES6

ES6 的模块思想是尽量的静态化,编译时就能确定模块的依赖关系。举🌰:

import {a, aa} from './a';

上面代码就直接加载 a 和 aa 方法,其他方法不加载。这种就称为“静态加载”,在编译时就完成模块加载。

基本语法

CommonJS

Node 内部提供一个 Module 构建函数。所有模块都是 Module 的实例。

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

上面例子直接输出当前模块的 module,可以查看到:

Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/apple/Documents/test/common-and-es6-module/b.js',
  loaded: false,
  children:
   [ Module {
       id: '/Users/apple/Documents/test/common-and-es6-module/a.js',
       exports: [Object],
       parent: [Circular],
       filename: '/Users/apple/Documents/test/common-and-es6-module/a.js',
       loaded: true,
       children: [],
       paths: [Array] } ],
  paths:
   [ '/Users/apple/Documents/test/common-and-es6-module/node_modules',
     '/Users/apple/Documents/test/node_modules',
     '/Users/apple/Documents/node_modules',
     '/Users/apple/node_modules',
     '/Users/node_modules',
     '/node_modules' ] }

将注意力看到 parent: null。在命令行下返回是 null,可以用于判断模块是不是入口。

module.exports

表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports变量。

exports

为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令:

var exports = module.exports;

造成的结果是可以直接向 exports 挂一些属性:

exports.area = function (r) {
  return Math.PI * r * r;
};

exports.circumference = function (r) {
  return 2 * Math.PI * r;
};

特别小心的是,不能直接将一个变量或方法赋值给 exports:

// 注意不要这么做,会切断 exports 和 module.exports 的联系
exports = function (x) { console.log(x); }  

还有一种写法也要特别小心:

exports.hello = function () {
  return 'hello';
};

// module.exports 被重新赋值了,上面的输出无效
module.exports = 'Hello world';

从上面代码中可以得出:

  • 如果对外只想输出一个函数,那么只能使用 module.exports;
  • 如果对 exports 和 module.exports 懵XX,那么就请只使用后者,放弃前者!

require

require命令的基本功能是读入并执行一个JavaScript文件,然后返回该模块的exports对象。举个🌰:

// example.js
exports.message = 'hello CommonJS';
exports.sayMessage = function () {
    console.log(`say ${message}`);
}

var example = require('example');
console.log(example);
// {
//   message: "hello CommonJS",
//   sayMessage: [Function]
// }

加载规则

  • 参数字符串以 '/' 开头,表示绝对路径
  • 参数字符串以 './' 或 '../' 开头,表示相对路径
  • 参数字符串不以 '/' 或 './' 或 '../' 开头,如果不是一个路径则表示加载默认的核心模块;如果是一个路径则先找到第一个位置,再根据第一个位置依次寻找后面的路径
  • 除了 .js ,node 还会尝试添加 .json、 .node 格式
  • 如果想得到require命令加载的确切文件名,使用require.resolve()方法

针对规则的第6条举个🌰:

var example = require.resolve('./a');
console.log(example);  // /Users/apple/Documents/test/common-and-es6-module/a.js

模块缓存

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

// a.js
exports.message = 'hello CommonJS';
exports.sayMessage = function () {
    console.log(`say ${message}`);
}

// b.js
var example = require('./a');
var example = require('./a');

example.abc = 'abc';

var example = require('./a');
console.log(example);
// {
//   message: "hello CommonJS",
//   sayMessage: [Function],
//   abc: "abc"
// }

// c.js
var example = require('./a');
console.log(example);
// {
//   message: "hello CommonJS",
//   sayMessage: [Function]
// }

删除缓存也很简单:

// 删除指定模块的缓存
// moduleName 是模块路径名称
delete require.cache[moduleName];

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

循环加载

// 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);

上面代码中, a 加载 b , b 加载 a ,在 main 中引用两个文件,输入如下:

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

将 main 中的代码改成如下:

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.js 和 b.js ,直接从缓存读取 exports 属性,所以最前面的两条输出都没有了。

模块的加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

一个通用的🌰:

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

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

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

要解决上述问题可以将 counter 属性换成取值函数

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter () {
    return counter;
  },
  incCounter: incCounter,
};

这样再执行 main.js,会得到想象中的值:

$ node main.js
3
4

ES6 module

export

export命令用于规定模块的对外接口。

// 输出变量
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
// 输出函数或类
export function multiply(x, y) {
  return x * y;
};
// 通过 as 关键字改名
function a () { ... }
export { 
    a as name01,
    a as name02
};

需要特别注意的是:

export 命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

// 报错
export 1;
// 报错
var m = 1;
export m;
// 正确
export var m = 1;
// 正确
var m = 1;
export { m };

export语句输出的接口,与其对应的值是动态绑定关系 。

export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

上面代码输出变量 foo,值为 bar,500 毫秒之后变成 baz。

export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错。

function foo() {
  export default 'bar' // SyntaxError
}
foo()

import

import 命令用于输入其他模块提供的功能。同样的, import 也可以用关键字 as 重新命名。建议不要修改 import 进来的变量或方法。

注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。

foo();

import { foo } from 'my_module';

上述代码能够正常运行的本质是 import 命令是编译阶段执行的,在代码运行之前。

import 是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

// 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

import 'lodash';
import 'lodash';

export default

// a.js
export default function () {
  console.log('foo');
}
// main.js
import customName from './a';
customName(); // 'foo'

上面代码中,可以用任意名字指向 a.js 中的函数。需要注意的是,此时名称没有大括号。

本质上,export default 就是输出一个叫做 default 的变量或方法,然后系统允许你为它取任意名字。因为输出的是叫 default 的变量或方法,所以 export default 后面不能接变量声明语句。

import()

前面提到过,import 是静态编译的,无法做到动态加载。import() 就是解决 ES6 模块不能运行时加载问题的。同 CommonJS 的 require。不同在于 require 是同步加载的,import() 是异步加载。

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
  });

import() 的作用

  • 按需加载
    import() 可以在需要的时候,再加载某个模块。
button.addEventListener('click', event => {
  import('./dialogBox.js')
  .then(dialogBox => {
    dialogBox.open();
  })
  .catch(error => {
    /* Error handling */
  })
});
  • 条件加载
    import()可以放在if代码块,根据不同的情况,加载不同的模块。
if (condition) {
  import('moduleA').then(...);
} else {
  import('moduleB').then(...);
}
  • 动态的模块路径
import(f())
.then(...);

动态加载

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

上述代码在 CommonJS 中也出现过,不同点是第二次输出 counter ,ES6 模块输入的变量是活的,完全反应在其模块中的变化。再看一个🌰:

// m1.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

// m2.js
import {foo} from './m1.js';
console.log(foo);
setTimeout(() => console.log(foo), 500);

查看结果:

$ babel-node m2.js

bar
baz

上面代码表明,ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。

最后,export通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

循环加载

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用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加载b.mjs,b.mjs又加载a.mjs,构成循环加载。执行a.mjs,结果如下:

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

顺一下 ES6 循环加载处理机制:

  • 首先,执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.mjs。
  • 接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。
  • 执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。
    解决方法:
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};

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

结果是:

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

ES6 模块加载 CommonJS 模块

CommonJS 模块输出都在 module.exports 上。Node 的 import 命令加载 CommonJS 模块,Node 会自动将 module.exports 属性,当作模块的默认输出,即等同于 export default xxx

// a.js
module.exports = {
  foo: 'hello',
  bar: 'world'
};

// 等同于
export default {
  foo: 'hello',
  bar: 'world'
};

通过 import 获取上面的模块:

// 第一种写法:
import baz from './a';
// 第二种写法:
import {default as baz} from './a';
// 第三种写法:
import * as baz from './a';

CommonJS 模块的输出缓存机制,在 ES6 加载方式下依然有效。

// foo.js
module.exports = 123;
setTimeout(_ => module.exports = null);

上面代码中,对于加载 foo.js 的脚本,module.exports 将一直是123,而不会变成 null 。判断下面写法是否正确:

import { readFile } from 'fs';

上述代码是错误的,因为 fs 是 CommonJS 格式。只有运行时才能确定 readFile 接口,而 import 需要在编译时就确定接口。改为整体输入的方式:

import fs from 'fs';
const readFile = fs.readFile;

CommonJS 模块加载 ES6 模块

CommonJS 模块加载 ES6 模块,不能使用require命令,而要使用 import() 函数。

@Jouryjc Jouryjc changed the title Commonjs和ES6的模块加载机制 CommonJS 与 ES6 Module Dec 3, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant