ES6入门十二:Module(模块化)

PANH 2019-10-28

  • webpack4打包配置babel7转码ES6
  • Module语法与API的使用
  • import()
  • Module加载实现原理
  • Commonjs规范的模块与ES6模块的差异
  • ES6模块与Nodejs模块相互加载
  • 模块循环加载

 一、webpack4打包配置babel7转码ES6

1.webpack.config.js

在webpack中配置babel转码其实就是在我之前的webpack配置解析基础上添加babel的加载器,babel的具体转码配置在之前也有一篇详细的博客做了解析:

webpack安装与核心概念

ES6入门一:ES6简介及Babel转码器

在原本的webpack配置基础上添加加载器(原配置详细见:webpack安装与核心插件):

//下载babel加载器:babel-loader
npm install babel-loader --save-dev

然后关键的webpack.config.js配置代码是下面这一段:

module.exports = {
    ...
    module:{
        rules:[
            {
                test: /\.js$/,
                loader:‘babel-loader‘,
                exclude: /node_modules/
            }
        ]
    }
    ...
}

完整的webpack.config.js代码在这里:

const path = require(‘path‘);
const root = path.resolve(__dirname);
const htmlWebpackPlugin = require(‘html-webpack-plugin‘);

module.exports = {
    mode:‘development‘,
    entry:‘./src/index.js‘,
    output:{
        path: path.join(root, ‘dist‘),
        filename: ‘index.bundle.js‘
    },
    module:{
        rules:[
            {
                test: /\.js$/,
                loader:‘babel-loader‘,
                exclude: /node_modules/
            }
        ]
    },
    plugins:[
        new htmlWebpackPlugin({
            template:‘./src/index.html‘,
            filename:‘index.html‘
        })
    ]
}

2. .babelrc转码配置以及package.json

//关于这个配置的具体解析可以到(ES6入门一:ES6简介及Babel转码器)了解
{
    "presets":["@babel/preset-env"],
    "plugins": [
        ["@babel/plugin-proposal-class-properties",{"loose":true}],
        ["@babel/plugin-proposal-private-methods",{"loose":true}]
    ]
}

.babelrc

//包
{
  "name": "demo",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.6.4",
    "@babel/plugin-proposal-class-properties": "^7.5.5",
    "@babel/plugin-proposal-private-methods": "^7.6.0",
    "@babel/polyfill": "^7.6.0",
    "@babel/preset-env": "^7.6.3",
    "babel-loader": "^8.0.6",
    "html-webpack-plugin": "^3.2.0",
    "webpack": "^4.41.2"
  }
}

package.json

3.工具区间及目录解构:

//工作区间
    src//文件夹
        a.js//index.js依赖的模块
        index.js//入口文件
        index.html//解构文件
    .babelrc//babel转码配置
    package.json//包
    webpack.config.js//webpack打包配置文件

src中的代码文件:

// 导出

export let num = 10;
export const str = "HELLO";
export function show(){

}

export class Node {
    constructor(value, node = null){
        this.value = value;
        this.node = node;
    }
}

a.js

1 import {num, str, show, Node} from ‘./a.js‘;
2 
3 console.log(num,str,show,Node);

index.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    
</body>
</html>

index.html

4.下载打包及转码必备的包:

npm install//也可以手动一个个下载npm install ... --save-dev

包全部下载完成以后,就可以执行打包指令了,这里我使用动态时时监听自动打包(-w指令)

webpack -w --config ./webpack.config.js

5.这里插入一条:在visual Studio Code编辑器中的控制台在:View-Terminal(快捷键:Ctrl + ` ),这可以免去在开启系统的控制台或者使用git的控制台。

准备工作做完以后就开始Module的语法分析了,由于前面已经解析了webpack和babel的使用,加上模块发开发必然绕不开打包和转码这个环节,所以在这里补充了webpack的babel转码打包配置,上面的配置是最简单的入门版,如果想深入了解webpack打包可以考虑读一读Vue-cli的源码,后面我也会有Vue-cli源码解析的博客,但是可能会要等一段时间。

 二、Module语法与API的使用

在解析语法之前,强烈建议先阅读这篇博客:js模块化入门与commonjs解析与应用。通常作为ES6模块化的相关内容都会先阐述为什么要实现模块,这的确很重要,但是在commonjs规范的模块化在ES6之前就已经出现了,可以说ES6模块化是基于commonjs的模块化思想实现的,但是commonjs也并非js模块化思想的始祖。在这之前我们很多时候会使用闭包来实现一些功能,这些功能对外暴露一个API,比如jQuery就是在一个闭包内实现的,然后将jQuery赋给window的jQuery和$这两个属性,这种做法就是为了保证jQuery库实现时其不会对全局产生污染。另外闭包也在一定程度上可以将程序分块实现,但是闭包还是在一个js文件中实现的分块,并不能像java那样实现独立的文件快。

这些都是为什么要实现模块化的原因,在之前的“js模块化入门与commonjs解析与应用”中有比较详细的说明,顺便也了解以下nodejs的模块化的一些语法,这是有必要的。

1.ES6模块化导出(export)导入(import)指令

在之前的webpack打包及babel转码示例基础上继续语法演示,a.js作为index.js的依赖文件

// a.js==>导出
export let num = 10;
export const str = "HELLO";
export function show(){

}
export class Node {
    constructor(value, node = null){
        this.value = value;
        this.node = node;
    }
}

再在index.js中导入a.js导出的内容:

1 import {num, str, show, Node} from ‘./a.js‘; //导出语法:import {...} from ./a.js
2 console.log(num,str,show,Node);

对比nodejs的导出( module.exports )导入的和导出( require(url) )实现这很容易理解,因为nodejs是基于平台API和工具方法实现,而ES6是基于语言的底层编译指令实现。

如果前面你使用了动态监听打包指令,这时候只需要使用浏览器打开./dist/index.html你会发现控制台有了这样的打印结果(如果关闭了就重新打包一次):

10 "HELLO" ƒ show() {} ƒ Node(value) {
  var node = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;

  _classCallCheck(this, Node);

  this.value = value;
  this.node = node;
}

2.关于ES6导出的对象写法:

相信前面你已经发现了一个问题,写这么多export?ES6一样可以直接使用一个大括号导出“{}”,比如前面的a.js导出可以这样写:

// a.js==>导出
let num = 10;
const str = "HELLO";
function show(){

}
class Node {
    constructor(value, node = null){
        this.value = value;
        this.node = node;
    }
}
export {num, str, show, Node} //导出

3.导出语法大括号中“{}”不是解构赋值语法,比如导出之后直接给导出的元素执行一个写入操作:

1 import {num, str, show, Node} from ‘./a.js‘;
2 
3 num = 2;//Uncaught ReferenceError: num is not defined

我们看到了这并非解构赋值,因为解构赋值是将一个变量的值赋给另一个,执行了一个全新的变量声明操作,但是import指令只负责导出对应模块的内容接口,并不在当前模块重新声明变量,这也就意味着我们只能直接读取导出的内容而不能写入,那如果有需要写入的操作怎么办呢?

4.在导入模块中给导出模块的变量执行写入操作:

//a.js导出模块
let num = 10;
function show(){
    num = 2;
}
export {num, show}
//index.js导入模块
import {num, show} from ‘./a.js‘;

console.log(num);//10
show();
console.log(num);//2

关于这种数据操作方式,在实际项目开发中我们通常会在导出模块中给变量定义get和set方法,然后将这两个方法发用一个对象的方式导出。

5.导入重命名:

如果在导入模块中出现了与导出模块相同名称,或者为了更加方便区别导入的内容,通常会需要将导出内容名称通过as指令修改。

import {num as num_aModule, str as as_aModule,show as show_aModule, Node as Node_aModule} from ‘./a.js‘;
console.log(num_aModule);//10
show_aModule();
console.log(num_aModule);//2

6.默认导出:

默认导出只能一次导出一个元素,且只能使用一次默认导出。默认导出可以导出模块中的变量,甚至可以直接导出一个值,这个值导出不需要使用as重命名,而是在默认导入时直接给他一个名称就可以了。

// a.js==>导出
let num = 10;
const str = "HELLO";
function show(){
    num = 2;
}
class Node {
    constructor(value, node = null){
        this.value = value;
        this.node = node;
    }
}
export default [1,2,3,4]; //默认导出
export {num, str, show, Node}

//index.js==>导入模块
import arr from "./a.js"; //导入默认元素
import {num as num_aModule, str as as_aModule,show as show_aModule, Node as Node_aModule} from ‘./a.js‘;
console.log(num_aModule);//10
show_aModule();
console.log(num_aModule);//2

console.log(arr);//[1, 2, 3, 4]

7.通配符(*)整体导出模块内容:

import arr from "./a.js"; //导入默认元素
import * as a_Module from ‘./a.js‘;
console.log(a_Module);//打印出一个导入模块的内容对象,这个对象的元素只有get方法
console.log(a_Module.str);//HELLO

8.模块继承:

假设现在有一个模块b.js,b继承a.js模块,并且index.js依赖b.js,直接在b.js模块中使用export导出a模块就可以了。

//b.js
export * from ‘./a.js‘

//index.js
import arr from "./a.js"; //导入默认元素,需要注意默认导出元素不会被继承
import * as b_Module from ‘./b.js‘;
console.log(b_Module);//打印出一个导入模块的内容对象,这个对象的元素只有get方法
console.log(b_Module.str);//HELLO
console.log(arr)

如果出现了b模块自身也存在需要导出的元素,这时候就只能先将a模块中的元素通过import导入进来,在使用export将a和b自身导出元素一起导出

// a.js==>导出
let num = 10;
const str = "HELLO";
function show(){
    num = 2;
}
class Node {
    constructor(value, node = null){
        this.value = value;
        this.node = node;
    }
}
export default [1,2,3,4]; //默认导出
export {num, str, show, Node}

//b.js模块
import {num, str, show, Node} from "./a.js";
let bstr = "biu";
export {num, str, show, Node, bstr}

//index.js
import arr from "./a.js"; //导入默认元素
import * as b_Module from ‘./b.js‘;
console.log(b_Module);//打印出一个导入模块的内容对象,这个对象的元素只有get方法
console.log(b_Module.str);//HELLO
console.log(b_Module.bstr);//biu
console.log(arr)// [1, 2, 3, 4]

 三、import()

之前的内容一直没有涉及一个问题,就是模块是怎么加载的?既然ES6出现了模块的标准API那是不是意味着浏览器可以自行加载解析模块?它们又如何加载?

就目前来讲其实我们很少关注ES6的模块加载问题,毕竟ES6的模块在浏览器的兼容还需要一些时间,现阶段我们使用ES6的模块都是通过转码打包,最后客户端请求到的js文件都是基于编译后的文件通篇加载,所以不管开发时基于多少模块生成的js文件,都是在一个js文件中执行通篇加载同步执行,但是有时候我们总有一些业务需求想做成按需加载执行。

在HTML5中提供了有一种js异步执行机制worker,详细可以了解HTML5之worker开启JS多线程模式及window.postMessage跨域,这种机制严格来说是一种多线程的,在Promise那篇博客中我引用了《你不知道的js下篇》的相关内容阐述了异步与多线程的区别,有兴趣可以了解以下。

而import()则是严格意义上的js异步机制,因为它的底层是基于Promise实现的,这里我就不详细描述Promise的相关内容了,详细可以了解:ES6入门八:Promise异步编程与模拟实现源码,如果你了解了Promise你就可以瞬间明白import()的模块异步导入原理,下面直接来看基于前面代码index.js的异步导入b.js模块的示例:

import arr from "./a.js"; //导入默认元素
import(`./b.js`).then((b_module) =>{
    console.log(b_module);//导入的b模块对象最后被打印
},(err) => {
    console.log(err);
})
console.log("import是异步机制,所以我会先出现");//我第一个被打印
console.log(arr)// [1, 2, 3, 4],我第二个被打印

而对于import()的异步机制在webpack打包时并不会打包到生成到主index.js中,而是将import()中的`./b.js`打包成一个独立的js文件,当import()被触发时再发起一个请求获取这个独立js文件,所以这个js文件必然会是在主js文件执行完以后再执行。

关于import()方法有几个值得注意的关键点:

1.通过import()方法导入的模块不会打包到主js文件中。

2.只有import()方法被触发时才会加载这个导入的模块。

3.需要单独发起一个网络请求。

在这之前,如果我们需要异步加载一个js的话一般都是采用创建一个script元素,然后通过script的Element对象的src属性发起一个网络请求,这种方法有几个闭端:第一、需要于文档对象模型进行通信来创建元素对象,相比import()的性能肯定有很大的差别;第二、需要使用回调来来接两个js任务,这显然代码的阅读比模块化导入导出要差。

除了创建script元素对象,还有就是前面的worker机制,这种机制是一种多线程机制,有独立线程,通过线程的事件机制通信,这种操作相对模块化也要复杂一些,但其兼容性是一个问题,不能像import()作为纯js虽然ES6也还没有特别好的兼容性,但import()可以通过转码工具降级来解决兼容性问题。

 四、Module加载实现原理

 https://whatwg.github.io/loader/

1.浏览器加载机制:

为什么要了解浏览器的加载机制呢?我们知道模块本身就是一个独立的js文件,虽然浏览器兼容性不是很好,但是在部分新版本浏览器中也是可以通过script标签来加载的。作为模块机制它们是相互依赖的关系,而模块必然需要与主js文件有所区分,这就得要依靠script标签的type属性来标识它们的类型,而相互依赖的关系有必然影响到执行顺序的问题,我们知道网络传输的因素会导致最后接收到的文件顺序并不一定就是我们的请求顺序,所以这一切我们都需要从浏览器的加载机制开始。

关于浏览器加载机制的详细内容请移驾到这篇博客:浏览器加载解析渲染网页原理,除了详细的解析博客以外,这里简单的回顾一些相关的关键内容,如果对浏览器的加载解析机制有一定了解,通过这些相关的内容就可以开始了解模块的加载机制了。

 1.1.同步加载模式

//内嵌脚本--同步解析执行,阻塞页面
<script type="text/javascript">
    ...
</script>
//外部脚本--同步解析加载执行,阻塞页面
<script type="text/javascript" src="path/to/index.js">
    ...
</script>

1.2.异步加载模式

//defer异步加载--等到页面正常渲染完成以后执行
<script type="text/javascript" src="path/to/index.js" defer></script>

//async异步加载--加载完成以后就立即执行
<script type="text/javascript" src="path/to/index.js" async></script>

1.3.ES6模块加载规则

//模块加载(type值为module)--异步加载:默认等同于defer
<script type="module" src="path/to/index.js"></script>

ES6模块加载采用module的媒体类型,默认情况下为defer异步加载模式,等待页面加载完成以后执行。

但模块加载也可以为async异步加载模式,需要手动添加async属性,设置async属性以后模块就会在加载完成以后立即执行。

//模块async模式加载,手动添加async属性
<script type="module" src="path/to/index.js" async></script>

2.import引入模块及引入外部模块的加载

ES6有了模块化就必然有模块引入,通过import引入的模块与引入外部模块脚本完全一致。

<script type="module">
    import utile from ‘./utils.js‘; //引入内部模块
    import outerUtile from ‘https://example.com/js/utils.js‘ //引入外部模块
</script>

引入的脚本必然是在引入指定所在的js脚本基础上实现,所以引入脚本的加载也就不再考虑阻塞页面这个问题,这是由引入脚本的加载执行方式决定的,但是ES6引入脚本必须是同步模式,引入指令是肯定会阻塞js的执行。这个很容易理解,因为引入其他模块就代表着当前模块必然要依赖其他模块,如果依赖的模块还没加载执行就执行当前模块,那当前模块的依赖其他模块的功能必然不能正常执行。

引入模块除了同步执行以外,还有一些需要注意的问题:

2.1.每个模块都有自己独立的作用,模块内部的变量外部不可见;(解决了命名冲突问题)

2.2.模块默认自动采用严格模式;

2.3.加载外部模块时不能省略‘.js’后缀;

2.4.模块顶层对象指向undefined,而不是window;

2.5.同一个模块加载多次,只执行一次。

 五、Commonjs规范的模块与ES6模块的差异

ES6模块在前面API的使用中展示了修改引入模块的变量需要在依赖模块中导出一个修改方法,这是因为ES6导出的变量是改变了的get方法,如果直接在引入模块中做写入操作会出现未声明的错误。

但是Commonjs对于引入的数据直接进行写操作并不会报错,并且会写入成功,但是这种写入成功并不是正真的成功,只是对当前模块引入的变量而言,因为Commonjs模块引入机制是对导出模块将变量合成一个对象然后引入模块复制这个对象,所以会出现以下情况,来看下面这个nodejs的示例:

//导出模块 a.js
let num = 5;
function foo(){
    num++;
}
module.exports = { //导出
    num : num,
    foo : foo
}
//引入模块
let aModule = require(‘./a.js‘);
console.log(aModule.num);//5
aModule.foo();
console.log(aModule.num);//5

上面这种情况这是因为Commonjs导出的是一个对象,而这时候执行的foo方法内num则是在引入模块中寻找这个变量,而不是在引入的对象中寻找这个变量,出给foo这种写入的是一个"this.num++"才可以实现这个累加写入的操作。

如果是在ES6中上面示例中的写法就可以实现正常的累加写入和读取,前面API解析中已经有类似的代码片段就不再重复展示。

总结上面这种情况ES6与Commonjs模块的区别就是:

1.ES6导入的是对依赖模块的数据只读引用;

2.Commonjs导入的则是依赖模块导出的一组数据键值对的复制操作。

 六、ES6模块与Nodejs模块相互加载

1.使用ES6模块语法import导入Nodejs模块:

在Nodejs模块中导出的方式是module.exports = {...},如果这个模块被ES6模块语法import导入,就相当于Nodejs默认导出一个对象(export default {...})。

//a.js
module.exports = {
    foo:‘hello‘,
    bar:‘world‘
}
//如果a.js被ES6的import导入,上面的导出就相当于下面这种形式
//export default {
//    foo:‘hello‘,
//    bar:‘world‘
//}

//index.js采用ES6语法导入a.js
import baz from ‘./a.js‘;//baz = {foo:‘hello‘,bar:‘world‘}
import {default as baz} from ‘./a.js‘;////baz = {foo:‘hello‘,bar:‘world‘}

需要注意一种情况是如果ES6模块语法import使用了通配符导入nodejs模块,这时候获得的模块数据引用对象中会包括nodejs整体变量get方法:get default()、同时还分别包括每个变量的get方法:get foo()、get bar()。例如上面的示例被通配符导出会是这样的结果:、

import * as baz from ‘./a.js‘;
//baz ={
//    get default () {return module.exports;},
//    get foo() {return thisl.default.foo}.bind(baz),
//    get bar() {return this.default.bar}.bind(baz)
//}

2.使用Nodejs的require方法加载ES6的模块:

nodejs使用require()导入ES6模块时,将ES6模块导出的数据接口转换成一个对象的属性。(但是需要注意的是node并不直接支持ES6的模块语法,所以如果需要在nodejs模块中导入ES6的模块,需要使用转码工具降级,降级操作前面的示例已经有展示,也可以参考这篇博客:ES6入门一:ES6简介及Babel转码器

//es6模块
let num = 10;
const str = "HELLO";
let arr = [1,2,3,4];
function show(){
    num = 2;
}
class Node {
    constructor(value, node = null){
        this.value = value;
        this.node = node;
    }
}
export default arr; //默认导出
export {num, str, show, Node}

ES6模块a.js

{
  "name": "Node_Module",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/cli": "^7.6.4",
    "@babel/core": "^7.6.4",
    "@babel/plugin-proposal-class-properties": "^7.5.5",
    "@babel/plugin-proposal-decorators": "^7.6.0",
    "@babel/plugin-proposal-private-methods": "^7.6.0",
    "@babel/polyfill": "^7.6.0",
    "@babel/preset-env": "^7.6.3"
  }
}

package.json

//关于这个配置的具体解析可以到(ES6入门一:ES6简介及Babel转码器)了解
{
    "presets":["@babel/preset-env"],
    "plugins": [
        ["@babel/plugin-proposal-class-properties",{"loose":true}],
        ["@babel/plugin-proposal-private-methods",{"loose":true}]
    ]
}

./babelrc

npx babel a.js -o aa.js//将es6模块语法降级生成aa.js文件

index.js:nodejs模块

1 let aModule = require(‘./aa.js‘); //导入降级后的es6模块
2 console.log(aModule);

在nodejs环境下执行index.js文件

node index.js

打印结果:

{ show: [Function: show],
  default: [ 1, 2, 3, 4 ],
  num: 10,
  str: ‘HELLO‘,
  Node: [Function: Node] }

 七、模块循环加载

循环加载是指a模块执行依赖b模块,而b模块又依赖a模块。虽然这种极端的强耦合一般不存在,但是在复杂的项目中可能出现a依赖b,b依赖c,然后c依赖a的情况。而且ES6模块与Commonjs模块的加载原理存在差异,两者是有区别的。在解析区别之前我想强调一点,无论是ES6模块还是Commonjs模块都只加载一遍。

 1.commonjs模块的加载模式

//a.js
exports.done = false;
var b = require(‘./b.js‘);
console.log("在a.js中导入b.js",b.done);
exports.done = true;
console.log(‘a.js执行完毕‘);

//b.js
exports.done = false;
var b = require(‘./a.js‘);
console.log("在b.js中导入a.js",b.done);
exports.done = true;
console.log(‘b.js执行完毕‘);

在node环境中执行a.js,打印以下结果:

在b.js中导入a.js false
b.js执行完毕
在a.js中导入b.js true
a.js执行完毕

从打印结果看到的差别就是a导入b时,b执行又导入a,这时候a导入给b的done是一个初始值,并不是a模块最终导出的结果。这是因为a没有执行完,所以导出了一个初始值,但是a会等待b执行完再执行导入指令后面的程序。这里有个很关键的注意点就是每个模块只执行一次,因为b导入a时,a已经执行就不会再次执行,而是基于a当前的执行结果导入a的数据。

2.ES6模块的加载模式

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

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

注意这里如果直接使用babel转码的话需要将转码后的代码中的引入文件名改成对应的转码后的文件名称,然后再在node环境中执行转码后生成的文件。当然你也可以使用打包工具导出到一个html文件中测试。

//打印结果
b.js
undefined
./a.js
bar

ES6模块的执行方式与Commonjs的执行方式有很大的区别,重点就是在于模块没有执行完成就不能获取该模块中的导出数据,在示例中当a执行时,就立即导入b,然后b执行。这时候b又导入了a。当b导入a时,a还没有执行完,所以打印除了undefined。

这里你可能会有点迷惑,即使代码改成下面这种情况也还是同样的打印结果:

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

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

ES6的这种情况是由于加载模块本质上是生成一个模块引用给模块导入的位置,而这个引用必须是文件全部执行完成以后才会生成,所以在模块执行完成以前引用ES6模块中导出的数据都是undefined。

相关推荐