ES6+ 模块化扩展

1. 前言

要深入前端学习时绕不开的是 Node 的学习,而 Node 中自带了模块化系统,Node 中的模块化是基于 CommonJS 规范实现的。而 ES6 中的模块化与之还有很多的不同的地方。现阶段 Node 还依然使用的是 CommonJS 规范,而前端正在逐渐使用 ES6 module 规范。两个规定统一是一个漫长的过程,两者都存在历史遗留问题和兼容问题需要浏览器和 Node 核心的支持。有必要搞清楚两个规范的区别和注意事项,有助于我们深入地学习前端。

上一节我们学习 ES6 Module 的环境搭建和基本用法,本节将继续学习模块化的相关知识,本节主要是学习 CommonJS 规范,还有对比 ES6 module 规范的一些区别和注意事项。

2. CommonJS 规范

在维基百科中是这样定义 CommonJS 的:

CommonJS 是一个项目,其目标是为 JavaScript 在网页浏览器之外创建模块约定。创建这个项目的主要原因是当时缺乏普遍可接受形式的 JavaScript 脚本模块单元,模块在与运行 JavaScript 脚本的常规网页浏览器所提供的不同的环境下可以重复使用

JavaScript 语言在很长一段时间是没有模块化的概念的,直到 Node.js 的诞生后,让 JavaScript 有能力编写服务端语言,对操作系统、网络、文件系统等等的复杂业务场景,使用模块化就是不可或缺。这样也把模块化的概念带到了前端,而这时的客户端的功能也很复杂,急需一种可以拆分代码模块方便管理代码的一种模式。最终在社区的推动下 ES6 给出了 JavaScript 模块化的规范。

在 Node 模块中,CommonJS 规定每个文件就是一个模块,有它自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

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

2.1 导出模块

使用 module.exports 把需要暴露的内容导出,没有导出的在外面是访问不了的。

// a.js
module.exports.name = 'imooc';
module.exports.fn = function(){}
const age = 18;

上面的代码中在 a.js 文件中相当于一个私有的作用域, module.exports 把 name 和 fn 两个变量导出,但是 age 没有导出,所以在外部是访问不了的。

为了方便 module.exports 也可以省略 module 直接使用 exports 进行导出操作:

exports.a = 'hello'

使用 module.exports 时还可以整体导出,整体导出时不能简写 exports

module.exports = { name: 'imooc', fn:function(){} }

2.2 导入模块

使用 require 用于导入其他模块暴露出来的内容。导出的内容是一个对象。

const a = require('./a');
console.log(a);	// { name: 'imooc', fn: [Function (anonymous)] }

2.3 CommonJS 模块的特点

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

3. 不同规范之间的加载

3.1 import 加载 CommonJS 模块

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

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

// 在import引入时等同于
export default {
  foo: 'hello',
  bar: 'world'
}

CommonJs 模块是运行时确定输出接口,所以采用 import 命令加载 CommonJS 模块时,只能使用整体输入(*)。

import {readfile} from 'fs' //当'fs'为CommonJS模块时错误
// 整体输入
import * as express from 'express'
const app = express.default();

3.2 require 加载 ES6 模块

require 命令加载 ES6 模块时,所有的输出接口都会成为输入对象的属性。

// es.js
let foo = {bar : 'my-default'};
exxport default foo;
foo = null;

// cjs.js
const es_namespace = require('./es')
console.log(es_namespace.default);// {bar:'my-default'}

4. 面试题

模块化在面试中经常会被问到,掌握其深层原理是回答这类问题的关键。下面是面试中参考的两道题,这里和大家分享一下,提供的答案仅供参考。

  1. commonjs 规范与 es module 规范的区别?

两个规范的区别可以从以下几个方面来回答:

  • 模块的导出和导入:commonjs 使用的是 module.exports 和 require;es module 使用的是 export 和 import;
  • 模块的引入方式:commonjs 是动态引用;esmodule 是静态分析,export 和 import 只能出现在代码的顶层,在编译时就可以确定引用;
  • 模块的引用类型:commonjs 对基本类型传递值,esmodule 对基本类型是传递引用;
  • CommonJs 的 this 是当前模块,ES6 Module 的 this 是 undefined;
  • 对 webpack 来说,想要支持 tree shaking,包必须采用 es module 规范。

JS 在加载时分为两个阶段:编译和执行,而 ES6 模块是在 编译时进行加载(也可以叫:静态加载),这使得静态分析成为可能。es module 自动采用严格模式,不管你有没有在模块头部加上 "use strict";

  1. 题目:commonjs 规范的循环引用

这是一道经典的 commonjs 的面试题,分析下列这段代码,并解释原理。

//main.js
var a = require('./a')
console.log(a)

// a.js
module.exports.a = 1
var b = require('./b')
console.log(b)
module.exports.a = 2

// b.js
module.exports.b = 11
var a = require('./a')
console.log(a)
module.exports.b = 22

回答本题的核心就是要知道 require 后的模块是会被缓存的,还需要注意的是先加入缓存,然后再执行。这样在按照代码同步的执行顺序去分析代码就会很清晰。具体分析如下:

  1. 使用 node main.js 执行 main.js 文件内容;
  2. 执行 require('./a') 会将 a 模块加入缓存,然后执行 a 模块中的内容,执行权交到了 a 模块中,执行 a;
  3. 执行第一行将缓存的 a 值赋值为 1,然后执行第二行 require('./b') 把 b 模块加入缓存,并把执行权交到 b 模块中;
  4. b 模块中把 b 的值赋值为 11,在 require('./a') 时,是从缓存中取的值,这里就会在控制台打印 {a: 1},最后把缓存中的 b 值修改为 22,执行权交给上一级;
  5. 代码执行权回到 a 模块中,这时 b 从缓存中取的值是 22,控制台中打印 { b: 22 } ,最后把缓存中的 a 值修改为 2,执行权交给上一级;
  6. 代码执行回到 main 模块中,这时缓存中的 a 是 2,控制台中打印 { a: 2 } ,然后代码执行完毕。

5. 小结

本节主要学习了 CommonJS 的使用、在 CommonJS 和 ES Module 混用的一些问题,最后通过两道面试题学习了两个规范的区别和 CommonJS 在使用时会存在循环引用的问题,并分析了其执行的顺序和缓存的特点。