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

模块化原理 #18

Open
Genluo opened this issue Aug 31, 2019 · 0 comments
Open

模块化原理 #18

Genluo opened this issue Aug 31, 2019 · 0 comments

Comments

@Genluo
Copy link
Owner

Genluo commented Aug 31, 2019

这里主要学习两种模块化规范,一种是ES6中的模块化规范,另一种是CommonJS中的模块规范,针对模块化的发展

思维导图

为什么要使用模块化

在以前的前端开发中,前端的项目都比较小,并且大部分都是单人开发,所有对于当时出现的global污染、命名冲突等不是很在意,但是对随着前端得发展,前端的项目开始变得比较大,对于一个项目,更多的人参与进来,这时候以前越来越多的全局污染,命名冲突等情况逐渐进入前端开发者的视野,于是前端er开始封装代码,使用IIFE的方法,这样就减少命名冲突和全局污染,但是现在依赖管理出现了问题,需要一个熟悉整个项目的人将代码按照顺序合并起来,这种事情不仅累,并且很容易出错,所以前端er开始思考如何加载代码,根据其他语言的特性,前端这个领域开始兴起各种各样的模块化方案,随着时间的检验,目前比较常用的模块规范有两种,一种是CommonJs规范,主要是因为Node中带起来,主要用于服务端开发,另一种就是ES规范中提出的模块化,是一种语言规范。在这个历史进程中出现模块化直接定义依赖namespace模式匿名闭包IIFE模板依赖定义注释定义依赖依赖注入CommonJS模式AMD模式ES2015 Modules

总结起来,模块化主要解决了这几个问题:

  • 避免命名冲突
  • 更好的分离,按需加载
  • 更高的复用性
  • 高可维护性

CommonJS

commonJs原名是ServiceJS,随着node的火爆,才慢慢改名称之为CommonJS,想要统一服务端和客户端,但是require是同步的,这样就导致AMD和CMD等等异步加载规范的提出,随着ES6中模块化的提出,AMD和CMD渐渐消失在视野中了,但是在Node中,CommonJS还是比较常用的,所以这里我们直接学习Node中的实现CommonJS这套规范:
在Node的实现中,最重要的就是require函数,在Node中引入模块需要经过三个过程,分别是:

  • 路径分析
  • 文件定位
  • 编译执行

ES6模块化

ES6的模块设计思想是尽量模块化,使得在编译时候就可以确定模块之间的依赖关系,以及输入和输出的变量,这个种方式的效率比CommonJS的效率高很多,所以这样的话ES6中的模块化不能按照CommonJS中理解的解构赋值来解释相关模块引入和相关的实例

为什么ES6要提供模块化?

  • 将服务器和客户端的模块化统一
  • 将来客户端新的API可以通过模块格式来提供,不需要再做成全局变量
  • 不需要在使用对象作为命名空间了

模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口import命令用于输入其他模块提供的功能。export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

为什么向外暴露的是接口,并且什么可以称之为接口?

不知道,待续。。。

存在几种应用如下:

  • export default是一种语法糖
// modules.js
function add(x, y) {
  return x * y;
}
export {add as default};
// 等同于
// export default add;

// app.js
import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';
  • 一条import语句,同时输入默认方法和其他接口,可以写成下面这种
import _, { each, forEach } from 'lodash';
  • 将具名接口改为默认接口的写法如下。
export { es6 as default } from './someModule';

// 等同于
import { es6 } from './someModule';
export default es6;

为什么都存在import还需要import()方法?

import(),以为ES6的模块化要统一客户端和服务器端,因为CommonJS中含有动态加载的功能,所以为了也实现动态加载功能,所以推出这个API,调用之后返回一个Promise对象,调用成功后悔返回一个对象当做then的参数。

两种模块规范区别

  • CommonJS输出的是值的拷贝,ES6输出的是值的引用
  • CommonJS模块是运行时加载,ES6模块是编译时输出的接口

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的**“符号连接”**,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

浏览器中使用ES6模块化方式

如果在浏览器中要使用ES6的模块化,需要在<script>中添加type="module",这样浏览器就会按照模块化的方式来执行,如果将代码放入网上,脚本相当于默认开启defer,同理我们也可以显示的为async那个这样的话脚本的加载和执行就会按照async 的方式进行执行。

Node中使用ES6模块化方式

这里面node在8.5+版本上支持使用ES6的模块化,但是Node要求,使用ES6模块,必须使用后缀为.mjs文件,并且在执行的时候需要添加相关参数,类似于node --experimental-modules index.mjs

如何处理循环加载

主要分为两种循环加载,一种是Commonjs中的循环加载,另一种就是ES6规范中的循环加载

  • CommonJS中的循环加载

一旦某个模块出现循环加载,就只输出已经执行的部分,还未执行的部分不会输出,举个例子(摘抄ES6入门):

// a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
// index.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

// node a.js 输出结果如下:
 b.js 之中,a.done = false
b.js 执行完毕
 a.js 之中,b.done = true
a.js 执行完毕

执行index.js输出如下结果,我们分析可知,当a.js中去引用b.js的时候,此时模块a的exports = { done: false}所以在模块b.js执行的时候,引用a模块,实质上获取的当前a模块的exports,所以才会输出a.done为false,当执行返回到a模块中,b模块的exports = { done: true }, 所以输出b.done 为true,然后更新a模块的exports = { done: true },所以当执行完成之后,在index.js中会输出a.done = true, b.done = true

  • ES6规范中模块的循环加载

目前实现方式不同,一种是通过babel进行转义来实现ES6的模块化,另一种就是原生来实现,目前主要有两个阶段,一个是浏览器中实现,另一个就是在node中的实验特性,通过添加--experimental-modules进行启动,这里我们主要使用node中V8的实现的模块化为准,babel转义之后可能有所不同。

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

// 执行结果如下:
$ node --experimental-modules a.mjs

// 输出如下:
b.mjs
ReferenceError: foo is not defined

为什么foo会报告未定义?

这个要从ES6模块化原理来说明,因为ES6的模块是动态加载,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。首先代码的执行分为两部分,一部分是静态编译阶段,另一部分是代码执行的部分,在静态编译的时候,每个模块的对外接口只是一种静态定义,这种静态定义在代码静态解析阶段就会生成。这样会导致import命令优先执行,优先于模块的其他内容来执行。

就根据上面那个实例来看,当我们执行代码的时候,首先对整个项目做静态分析,判断知道a模块中 bar指向来自b模块bar接口,同理也知道b模块中foo变量指向a模块中的foo,生成每个模块的对外接口的静态定义,生成一个只读引用,这样在静态编译过程就完成了,然后开始执行a模块代码,按照常规的方式来执行,首先进行变量提升,同时将import提升,这这段代码中也就是提升import {bar} from './b';,因为b模块还没有执行过,所以开始执行b模块中的内容,执行b模块中的内容同理,首先提升import语句,但是a模块已经执行过,所以认为接口foo已经初始化完成,所以开始执行b模块后面的代码,不会返回执行a模块的代码,然后执行下一步console.log(foo);,这时候就取foo中引用的值,但是我们发现此时的foo还没有在a模块中定义,所以这时候就会返回foo没有定义的错误。那么怎样才能不报错,可以将a模块中的foo进行提升,比如将foo使用var声明或者将foo变为一个函数,这样的程序就可以取到对应的值了。这就是整个执行过程,但是这里也谈一下babel中的实现,babel转义之后,会将export语句提升,所以如果这是babel中的执行代码,这时候应该会返回undefined,而不是直接报错。

// a.mjs
import { foo } from './b';
console.log('a.js');
export const bar = 1;
export const bar2 = () => {
  console.log('bar2');
}
export function bar3() {
  console.log('bar3');
}

// b.mjs
export let foo = 1;
import * as a from './a';
console.log(a);

// node --experimental-modules a.mjs
[Module] {
  bar: <uninitialized>,
  bar2: <uninitialized>,
  bar3: [Function: bar3] }
a.js
// 如果这时候取a中bar的值将会报错,报错原因如下: bar is not defined,原因同上

这里还有一个神奇的地方就是:现在可以取到未定义的值,比如在这个里面输出的,应该是ES6中新定义的Module类型。

[Module] {
  bar: <uninitialized>,
  bar2: <uninitialized>,
  bar3: [Function: bar3] }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant