28- ES6 模块化

1、模块 概述

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。

CommonJS 模块就是对象,输入时必须查找对象属性。
CommonJS 模块:

let { stat, exists, readFile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readFile = _fs.readFile;

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

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

import { stat, exists, readFile } from 'fs';

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

2、ES6 模块的优势

由于 ES6 模块是编译时加载,使得静态分析成为可能。
不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

3、严格模式

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";。

严格模式主要有以下限制:

变量必须声明后再使用
函数的参数不能有同名属性,否则报错
不能使用with语句
不能对只读属性赋值,否则报错
不能使用前缀 0 表示八进制数,否则报错
不能删除不可删除的属性,否则报错
不能删除变量delete prop,会报错,只能删除属性delete global[prop]
eval不会在它的外层作用域引入变量
eval和arguments不能被重新赋值
arguments不会自动反映函数参数的变化
不能使用arguments.callee
不能使用arguments.caller
禁止this指向全局对象
不能使用fn.caller和fn.arguments获取函数调用的堆栈
增加了保留字(比如protected、static和interface)

尤其需要注意this的限制。ES6 模块之中,顶层的this指向undefined,即不应该在顶层代码使用this。

4、export 命令

ES6 模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。

// 写法一
export var year = 2018;
export function add(x, y) { return x + y };
// 写法二
var year = 2018;
var add = function(x, y) { return x + y };
export {
    year,
    add
}
// 写法三
// 通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
// 重命名后,变量可以用不同的名字输出多次。
export {
    year as date,
    add as addFun1,
    add as addFun2,
    add as addFun3
}

export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

export var foo = 1;
setTimeout(() => foo = 2, 1000);

上面代码输出变量foo,值为 1 ,一秒之后变成 2。 这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。

最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。

5、import 命令

import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(export)对外接口的名称相同。

// 写法一
import { year } from './profile.js';
// 写法二
// 如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import { year as date } from './profile.js';
// 写法三
// import语句会执行所加载的模块,比如执行一个js文件,或者加载一张图片等
import './createTime.js';
import './data.json';
import './image/dog.png';
import './style.css';
// 写法四
// 模块的整体加载,除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
import * as circle from './circle.js';

import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面改写接口。 但是,如果导入的变量是对象类型,改写它的属性是允许的,一旦改写了,其他模块也可以读以它被改写后的值。因此,这种写法很难查错,建议凡是import导入的变量,都当作完全只读,不要轻易改变它的属性。

注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。import命令是编译阶段执行的,在代码运行时之前。 由于import是静态执行,所以不能使用表达式和变量。

// 报错
import { 'f' + 'oo' } from 'my_module';
// 报错
let module = 'my_module';
import { foo } from module;
// 报错
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}

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

import './do.js';
import './do.js';
import './do.js';   // 只会执行一次

import { foo } from 'module';
import { bar } from 'module';
// 等同于
import { foo, bar } from 'module';

上面代码中,虽然foo和bar在两个语句中加载,但是它们对应的是同一个module实例。也就是说,import语句是 Singleton 模式。

目前阶段,通过 Babel 转码,CommonJS 模块的require命令和 ES6 模块的import命令,可以写在同一个模块里面,但是最好不要这样做。因为import在静态解析阶段执行,所以它是一个模块之中最早执行的。
ES6 模块是静态加载执行的,发生在编译阶段
CommonJS 模块是动态加载的,发生在运行时阶段

6、export default 命令

本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。
一个模块中只能有一个默认输出,因此export default命令只能使用一次。

export default function() {};
export default function foo() {};
export default a;
export default 45;
export default class MyClass {};
// export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。
export default var a = 1;   // 报错
// 导入 default 接口
import xxx from './my_default.js';

注意:一个模块中,使用 export default 导出的接口,import 时无须大括号。直接用 export 导出的接口,import 时,都需要用大括号包裹起来。

export a;
export b;
export default c;

import c, { a, b} from './my_default.js';  // 注意大括号的使用

7、export 与 import 的复合写法

如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。写成一行以后,被导入再导出的接口 API,实际上并没有被导入当前模块,只是相当于对外转发了这些接口,导致当前模块不能直接使用这些接口。

export { foo, bar } from 'my_module';
// 等同于
import { foo, bar } from 'my_module';
export { foo, bar };

这种写法,可以用于对模块接口进行改名和整体输出:

// 接口改名
export { foo as myFoo } from 'my_module';
// 整体导出
export * from 'my_module';

// 导出默认接口
export { default } from 'my_module';
export { es6 as default } from 'my_module';
export { default as es6 } from 'my_module';

8、模块的继承

模块之间也可以继承。

export * from 'circle';
export var e = 2.718;
export default function(x) {
    return Math.exp(x);
}

注意,export *命令会忽略circle模块的default方法。然后,上面代码又输出了自定义的e变量和默认方法。

9、跨模块常量

const声明的常量只在当前代码块有效。如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。

// constants.js模块
export const A = 1;
export const B = 2;
export const C = 2;

// 使用这些跨模块的常量
import * as constants from './constants.js';
console.log(constants.A);
// 另一种使用方式
import { A, B } from './constants.js';
console.log(B);

如果要使用的常量非常多,可以建立一个常量目录,把这些常量分模块进行定义,再统一导出。做法如下:

// constants/db.js
export const db = {
    url: 'http://my.local:8000',
    admin: 'root',
    pwd: '123456'
};
// constants/user.js
export const users = ['root', 'admin', 'staff', 'ceo'];

// 合并常量,统一导出
// constants/index.js
export { db } from './db.js';
export { users } from './users.js';

// 使用这些常量
import { db, users } from './index.js';

10、import ()

import命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行。也就是说,import和export命令只能在模块的顶层,不能在代码块之中。

这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载就不可能实现。如果import命令要取代 Node 的require方法,这就形成了一个障碍。因为require是运行时加载模块,import命令无法取代require的动态加载功能。

提案:建议引入 import(),实现动态加载功能。像node.js的require()一样:

const path = './' + fileName;
let myModule = null;
if (bol) {
    myModule = require(path);
}

上面介绍了模块的语法,下面介绍如何在浏览器和 Node 之中加载 ES6 模块,以及实际开发中经常遇到的一些问题(比如循环加载)。

11、浏览器中的模块加载(传统做法)

// 内嵌脚本

// 加载外部脚本

由于浏览器脚本的默认语言是 JavaScript,因此type="application/javascript"可以省略。
默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到

defer与async的区别是:defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

12、在浏览器中加载 ES6 模块
浏览器加载 ES6 模块,也使用

13、浏览器下加载 ES6模块,以下点必须注意:

代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
模块脚本自动采用严格模式,不管有没有声明use strict。
模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
同一个模块如果加载多次,将只执行一次。

14、ES6 模块与 CommonJS 模块的差异

差异一:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

差异二:CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

15、在 Node.js 环境中加载 ES6 模块

Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。

Node 要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import。
Node 的import命令是异步加载,这一点与浏览器中的ES6模块加载处理方法相同。

ES6 模块之中,顶层的this指向undefined;CommonJS 模块的顶层this指向当前模块,这是两者的一个重大差异。

其次,以下这些顶层变量在 ES6 模块之中都是不存在的。

    arguments
    require
    module
    exports
    __filename
    __dirname

16、ES6 模块中 加载 CommonJS 模块

import * as express from 'express';
// 或者
import expres from 'express';

17、CommonJS 模块中 加载 ES6 模块

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

const es = await import('./es.js');

18、模块的循环加载

“循环加载”(circular dependency)指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。

通常,“循环加载”表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现。

19、ES6 模块的转码

浏览器目前还不支持 ES6 模块,为了现在就能使用,可以将转为 ES5 的写法。除了 Babel 可以用来转码之外,还有以下两个方法,也可以用来转码。

ES6 module transpiler是 square 公司开源的一个转码器,可以将 ES6 模块转为 CommonJS 模块或 AMD 模块的写法,从而在浏览器中使用。

另一种解决方法是使用 SystemJS。它是一个垫片库(polyfill),可以在浏览器内加载 ES6 模块、AMD 模块和 CommonJS 模块,将其转为 ES5 格式。它在后台调用的是 Google 的 Traceur 转码器。

参考链接: 阮一峰ES6教程


完!!!

你可能感兴趣的:(28- ES6 模块化)