ES6中的模块机制(原生JS的Module语法)

2,091 阅读10分钟

什么是模块体系(module)

开发大型的、复杂的项目时,把所有代码都写在一个文件里,会显得很傻X,后期维护某个功能时会忍不住砸键盘;

如果把大程序里每个小模块给单独拆分出来,然后集中拼接到总程序里,这样写起来能省很多麻烦,后期维护特定功能也方便,这样搞其实就是模块体系;

而且有了模块体系后,可以提高代码复用性,可以用别人的模块,来节省自己造轮子的时间;

很多语言都有这个功能,RubyrequirePythonimport,连CSS都有@import,但是很长一段时间内,JS都是没有这方面的支持的;

JS模块体系的发展

JS火了以后,用的人越来越多,项目越来越复杂,慢慢就发现不对劲:JS也得来一个模块功能才行,不然写大项目太折磨人了;

于是ES6之前,社区就制定一些模块加载方案,主要有CommonJS和AMD两种方案;

CommonJS是针对服务器的方案,AMD是针对浏览器的方案;

ES6出来后,实现了模块功能,而且实现的很好,所以可以替代CommonJS和AMD两种方案,成为浏览器和服务器通用的模块解决方案;

ES6中关于模块的设计思想

ES6希望模块实现尽量的静态化,让各个模块在编译的时候就能确定依赖关系、输出和输入的变量;

而CommonJS和AMD方案,都只有在运行模块的时候,才能确定这些东西:

// CommonJS模块
let { writeFile,readFile } = require('fs'); //析构赋值

// 等同于:
let fsObj = require('fs');
let writeFile = fsObj.writeFile;
let readFile = fsObj.readFile;

CommonJS模块看作对象,引入时要去对象上一个个查找属性;上面代码中加载fs模块,就是加载fs的所有方法,然后生成一个对象fsObj,然后从这个对象上读取两个方法。

这种加载称为运行时加载,只有在运行的时候才能得到对象,没办法在编译的时候做静态优化

ES6的模块就不是对象,而是通过export命令直接把要导出的东西给输出出去,别的文件通过import命令直接引入就可以得到相应内容了,不用挂载到对象上,再从对象上去找;

// ES6模块
import {writeFile,readFile} from 'fs';

上面的代码是从fs模块直接加载两个方法,其他的一律不加载,也不管;这种加载方式叫做编译时加载或者静态加载,在编译的时候就完成了模块加载,效率更高;

一个模块就是一个文件,文件里的变量其他文件是获取不到的,除非是用export导出才行;

具体语法

注意:这里不用webpack等任何工具,只用浏览器原生的解析JS文件;

// 准备三个文件:index.html、main.js、module.js

// index.html
<body>
    <script src="./main.js" type="module"></script>
    <script src="./module.js" type="module"></script>
</body>

// module.js
export let firstName = 'Vincent';
export let lastName = 'Liu';

// main.js
import { firstName,lastName } from './module.js';
console.log('Name is ' + firstName  + lastName);
// Name is VincentLiu;

需要注意的地方

因为这里没用webpack等工具,所以在html文件里引入时,要加type="module",不然会报错:

// 引入module.js时不加:
<script src="./module.js"></script>
<script src="./main.js" type="module"></script>

// 结果:
// Uncaught SyntaxError: Unexpected token 'export' VM2319 module.js:2
Name is VincentLiu //但是还是可以运行的;

// 引入main.js时不加:
<script src="./module.js" type="module"></script>
<script src="./main.js"></script>

// 结果:
// Uncaught SyntaxError: Cannot use import statement outside a module main.js:1 
// 运行不了了

单单从模块的角度看,引入顺序是不影响结果的;先引入module.js也行,先引入main.js也行;

错误情况

// 不能直接导出一个值:
export 1;
// 报错;

// 导出时忘了加 {} 括号:
let m = 1;
export m;
// 报错

// 应该加括号,导出函数、类、对象等内容时同理
let m = 1;
export {m};

引入的时候注意后缀名

// module.js
export let firstName = 'Vincent';
export let lastName = 'Liu';

// main.js
import { firstName,lastName } from './module'; /*注意这里没加后缀*/
console.log('Name is ' + firstName  + lastName);
// GET http://127.0.0.1:5500/module net::ERR_ABORTED 404 (Not Found)
// 因为没有使用工具,只用原生的浏览器解析,所以不加后缀名就出错了

定义时就导出变量

// module.js
export let firstName = 'Vincent';
export let lastName = 'Liu';

// main.js
import { firstName,lastName } from './module.js';
console.log('Name is ' + firstName  + lastName);
// Name is VincentLiu;

先定义变量,然后统一导出

// module.js
let firstName = 'Vincent';
let lastName = 'Liu';
export {
    firstName,
    lastName
}

// main.js
import { firstName,lastName } from './module.js';
console.log('Name is ' + firstName  + lastName);
// Name is VincentLiu;

在定义时就导出函数

// module.js
export function sayHi(){
    console.log('hi');
}

// main.js
import {sayHi} from './module.js';
sayHi();
// hi

先定义函数,然后导出

// module.js
function sayHi(){console.log('hi');}
export {sayHi}

// main.js
import {sayHi} from './module.js';
sayHi();
// hi

导出时给函数或变量重命名

使用as关键字可以对导出或引入的东西进行重命名

导出时重命名

// module.js
function func1(){console.log('你执行了 func1');}

function func2(){console.log('你执行了 func2');}

let m = 1export {
    func1 as f1,
    func2 as f2,
    m as n
}

// main.js
import { f1,f2,n } from './app.js';
f1(); //你执行了 func1
f2(); //你执行了 func2
console.log(n); //1

引入时重命名

// module.js
function func1(){console.log('你执行了 func1');}

function func2(){console.log('你执行了 func2');}

let m = 1;
export {
    func1,
    func2,
    m
}

// main.js
import { func1 as f1,func2 as f2,m as n} from './app.js';
f1(); //你执行了 func1
f2(); //你执行了 func2
console.log(n); //1

导出类

// module.js
export class Person {
    constructor(name,age){
        this.name = name;
        this.age = age;
    }

    sayHi(){
        console.log(this.name + ' said: hi!');
    }
}

// 或这样导出:
class Person{...}
export { Person }

// main.js
import {Person} from './module.js';
let person1 = new Person('Jack',18);
person1.sayHi();

导出对象

// module.js
export let a = {
	name:'Vincent',
    age:18
}


// main.js
import {a} from './module.js';
console.log(a.name);
// Vincent

动态导出内容

export导出的其实是一个输出的接口,与其对应的是动态绑定的关系,所以通过这个接口,我们可以取到模块内部实时的值;

// module.js
export let a = 1;
setTimeout(() => {
  a = 2
},1000);


// main.js
import {a} from './app.js';

console.log(a); //1

setTimeout(() => {
    console.log(a);
},1000)
// 一秒后输出为2

export位置的问题(作用域)

export可以放在模块文件里的任何位置,放在开头也行,放在末尾也行,放在中间也行;

但是要注意,不能放在块级作用域内!

// module.js 第一种情况
function fn() {
    export let a = 1;
}
fn();

// module.js 第二种情况
let x = 1;
if(x === 1){
    export let a = 1;
}

// main.js
import {a} from './app.js';
console.log(a);
// 报错: Uncaught SyntaxError: Unexpected token 'export'  module.js:

export如果处于作用域内,就会报错,因为处于条件代码块里,就没办法静态优化了,这就违背了ES6的设计理念了;

import位置的问题(作用域)

export要注意作用域的问题,import自然也要注意作用域的问题,因为都是ES6模块里的东西,自然理念是一样的;

本质上还是因为import是静态执行的,所以不能放在代码块里,因为代码块只有在运行时才能得到结果,这种结构是不符合静态编译的理念的;

// module.js
export function sayHi(){console.log('hi')}

// main.js
let x = 1;
if(x === 1){
	import {sayHi} from './module.js';
}
// 报错!

import具有提升效果

import命令自带提升效果,会把引入的东西自动提升到头部,首先执行;

// module.js
export function sayHi(){console.log('hi');}

// main.js
sayHi();
import {sayHi} from './module.js'
// hi
// 故意把import语句放在下面,先运行函数,也是同样的效果

两次或多次相同的import只会触发一次效果

// module.js
export function sayHi() {
    console.log('hi');
}
sayHi()

// main.js
import './module.js'import './module.js'import './module.js';
...

// 你引入一万次,也只会执行一次,输出一个hi

模块的整体加载

逐个加载的坏处在于:如果需要载入的东西多了,一个一个载入会累到爆炸;

// module.js
function f1() {console.log('你执行了f1');}
function f2() {console.log('你执行了f2');}
function f3() {console.log('你执行了f1');}
...
export {f1,f2,f3,...}

// main.js
import {f1,f2,f3,...} from './module.js';
f1();
f2();
f3();
...

所以这时我们可以使用整体加载:

// module.js
function f1() {console.log('你执行了f1');}
function f2() {console.log('你执行了f2');}
function f3() {console.log('你执行了f1');}
...
export {f1,f2,f3,...}

// main.js
import * as funcs from './module.js'; //注意*不用加括号{}
funcs.f1();
funcs.f2();
funcs.f3();
...

这样其实是在加载模块的时候,用星号*指定一个对象,然后把所有的导出值都挂载到这个对象上;

这样乍得一看和CommonJS又很像了,但是本质上并不相同,一个是直接引入一整个对象,然后从对象上找属性,可能会造成很多不用的也给引进来;而这里是先引入规定好的导出的东西,再把这些东西挂载到一个对象上;

另外,引入的函数、变量等内容,在运行时不允许被改变,因为是只读属性:

// module.js
function f1() {console.log('你执行了f1');}
let b = 1;
export { f1,b }
...

import * as funcs from './module.js';
funcs.f1 = () => {
  console.log('guoyin');
}
// 报错:Uncaught TypeError: Cannot assign to read only property 'f1' of object '[object Module]'
funcs.b = 2;
// 报错:Uncaught TypeError: Assignment to constant variable.

// 不能设置只读属性

但是有个特殊的,需要注意的,就是可以改导出的对象或数组里的值:

// module.js
export let a = [1,1,1];
export let b = {name:'Jack'}

// main.js
import {a,b} from './module.js';
a['0'] = 888;
console.log(a); //[888, 1, 1]

b.name = 'Tom';
console.log(b.name); //Tom

export default 命令

从之前的例子可以看出来,想import一个变量或者函数之类的东西,必须要知道名字才行。但是假如我不想去慢慢看文档,了解每个属性和方法名字叫啥咋办?或者说就想导出、引入一个匿名函数咋办?

这时就需要用到 export default 命令了:

// module.js
export default function(){
	console.log('hi');
}

// main.js
import f1 from './module.js'; /* 注意f1两边没加花括号{} */
f1(); 
//hi

你可能想问这个f1名字是哪儿来的,其实这个名字是可以随便起的,只要你高兴,叫f2也行,叫f3也行; 但是前提是,必须要 export default 才行;

当然,具名函数也可以用 export default 导出:

// module.js
export default function sayHi(){
	console.log('hi');
}

// 或者这样写:
function sayHi(){
	console.log('hi');
}
export default sayHi;

// main.js
import f1 from './module.js'; /* 注意f1两边没加花括号{} */
f1(); 
//hi

虽然sayHi是一个具名函数,但是用 export default 导出了,所以在外面引用时依然可以随便起名字;

export default 和 花括号 {}

使用了 default 导出时,引入时就不用加花括号;

没使用 default 导出时,引入时就要加花括号;

// module.js
export default function f1() { /* 注意有default */
	console.log('执行了f1');
}

export function f2(){ /* 注意没有default */
	console.log('执行了f2');
}


// main.js
import fn from './module.js';  /* 注意没有花括号 */
fn();

import {f2} from './module.js'; /* 注意有花括号 */
f2();

export、import 和 as 一起用

export 和 as 一起用:

// module.js
function func1(){
	console.log('执行了f1');
}

export {func1 as default} // 等同于 export default func1

// main.js
import fn from './module.js'; /* 因为作为default导出了,所以可以随便取名字 */
fn();

import 和 as 一起用:

// module.js
function func1(){
	console.log('执行了f1');
}

export {func1 as default} 

// main.js
import fn from './module.js'; /* 因为作为default导出了,所以可以随便取名字 */
fn();

// 等同于这样写:
import {default as fn} from './module.js';

import命令和import()函数

import命令会被JS引擎静态分析,不能放在代码块中或者函数中,这样虽然编译效率提高了,却也导致了一个问题:无法在运行时加载模块,也就失去了动态加载的功能;

Node.js里的require是有这个功能的,想要import也实现,于是ES2020提案加了一个 import()函数,来支持动态加载模块功能;

注意区别:一个是import命令,一个是import()函数

// html
<body>
    <button id="btn">click me</button>
    <script src="./main.js"></script>
</body>
// 注意:没有引入module.js文件,而且main.js引入时也不用写 type="module"!

// module.js
function sayHi() { // 这里没写export
    console.log('hi');
}
sayHi();

// main.js
let btn = document.querySelector('#btn');
btn.addEventListener('click',() => {
  import('./module.js')
})

点击按钮时动态载入module.js,控制台输出hi;

也可以按条件加载:

if(x === 1){
	import('module-1.js');
}else{
	import('module-2.js');
}

其实上面的省略性的用法,import()函数返回的是一个Promise对象:

import('module.js').then((...)=>{...}).catch((err)=>{...})

本篇仅为整理笔记,关于import()的更多用法不多细述,有兴趣的可以看阮一峰老师的ES6教程