彻底掌握前端模块化

347次阅读  |  发布于2年以前

一. 什么是模块化开发

1.1. JavaScript设计缺陷

那么,到底什么是模块化开发呢?

上面说提到的结构,就是模块;

按照这种结构划分开发程序的过程,就是模块化开发的过程;

无论你多么喜欢JavaScript,以及它现在发展的有多好,我们都需要承认在Brendan Eich用了10天写出JavaScript的时候,它都有很多的缺陷:

Brendan Eich本人也多次承认过JavaScript设计之初的缺陷,但是随着JavaScript的发展以及标准化,存在的缺陷问题基本都得到了完善。

在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:

<button id="btn">按钮</button>

<script>
  document.getElementById("btn").onclick = function() {
    console.log("按钮被点击了");
  }
</script>

但是随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了:

所以,模块化已经是JavaScript一个非常迫切的需求:

在这个章节,我们将详细学习JavaScript的模块化,尤其是CommonJS和ES6的模块化。

1.2. 没有模块化的问题

我们先来简单体会一下没有模块化代码的问题。

我们知道,对于一个大型的前端项目,通常是多人开发的(即使一个人开发,也会将代码划分到多个文件夹中):

小明开发了aaa.js文件,代码如下(当然真实代码会复杂的多):

var flag = true;

if (flag) {
  console.log("aaa的flag为true")
}

小丽开发了bbb.js文件,代码如下:

var flag = false;

if (!flag) {
  console.log("bbb使用了flag为false");
}

很明显出现了一个问题:

但是,小明又开发了ccc.js文件:

if (flag) {
  console.log("使用了aaa的flag");
}

问题来了:小明发现ccc中的flag值不对

备注:引用路径如下:

<script src="./aaa.js"></script>
<script src="./bbb.js"></script>
<script src="./ccc.js"></script>

所以,没有模块化对于一个大型项目来说是灾难性的。

当然,我们有办法可以解决上面的问题:立即函数调用表达式(IIFE)

aaa.js

const moduleA = (function () {
  var flag = true;

  if (flag) {
    console.log("aaa的flag为true")
  }

  return {
    flag: flag
  }
})();

bbb.js

const moduleB = (function () {
  var flag = false;

  if (!flag) {
    console.log("bbb使用了flag为false");
  }
})();

ccc.js

const moduleC = (function() {
  const flag = moduleA.flag;
  if (flag) {
    console.log("使用了aaa的flag");
  }
})();

命名冲突的问题,有没有解决呢?解决了。

但是,我们其实带来了新的问题:

所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。

JavaScript社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们就学习具有代表性的一些规范。

二. CommonJS规范

2.1. CommonJS和Node

我们需要知道CommonJS是一个规范,最初提出来是在浏览器意外的地方使用,并且当时被命名为ServerJS,后来为了体现它的广泛性,修改为CommonJS,平时我们也会简称为CJS。

所以,Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:

前面我们提到过模块化的核心是导出和导入,Node中对其进行了实现:

2.2. Node模块化开发

我们来看一下两个文件:

bar.js

const name = 'coderwhy';
const age = 18;

function sayHello(name) {
  console.log("Hello " + name);
}

main.js

console.log(name);
console.log(age);

sayHello('kobe');

上面的代码会报错:

导出和导入

2.2.1. exports导出

强调:exports是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出

bar.js中导出内容:

exports.name = name;
exports.age = age;
exports.sayHello = sayHello;

main.js中导入内容:

const bar = require('./bar');

上面这行代码意味着什么呢?

main中的bar = bar中的exports

所以,我可以编写下面的代码:

const bar = require('./bar');

const name = bar.name;
const age = bar.age;
const sayHello = bar.sayHello;

console.log(name);
console.log(age);

sayHello('kobe');

模块之间的引用关系

为了进一步论证,bar和exports是同一个对象:

定时器修改对象

2.2.2. module.exports

但是Node中我们经常导出东西的时候,又是通过module.exports导出的:

我们追根溯源,通过维基百科中对CommonJS规范的解析:

但是,为什么exports也可以导出呢?

image-20201011163653515

注意:真正导出的模块内容的核心其实是module.exports,只是为了实现CommonJS的规范,刚好module.exports对exports对象有一个引用而已;

那么,如果我的代码这样修改了:

image-20201011164006266

你能猜到内存中会有怎么样的表现吗?

image-20201011164223607

2.2.3. require细节

我们现在已经知道,require是一个函数,可以帮助我们引入一个文件(模块)中导入的对象。

那么,require的查找规则是怎么样的呢?

这里我总结比较常见的查找规则:

导入格式如下:require(X)

2.2.4. 模块加载顺序

这里我们研究一下模块的加载顺序问题。

结论一:模块在被第一次引入时,模块中的js代码会被运行一次

aaa.js

const name = 'coderwhy';

console.log("Hello aaa");

setTimeout(() => {
  console.log("setTimeout");
}, 1000);

main.js

const aaa = require('./aaa');

aaa.js中的代码在引入时会被运行一次

结论二:模块被多次引入时,会缓存,最终只加载(运行)一次

main.js

const aaa = require('./aaa');
const bbb = require('./bbb');

aaa.js

const ccc = require("./ccc");

bbb.js

const ccc = require("./ccc");

ccc.js

console.log('ccc被加载');

ccc中的代码只会运行一次。

为什么只会加载运行一次呢?

结论三:如果有循环引入,那么加载顺序是什么?

如果出现下面模块的引用关系,那么加载顺序是什么呢?

多个模块的引入关系

2.3. Node的源码解析

Module类

Module类

Module.prototype.require函数

require函数

Module._load函数

_load函数的实现

三. AMD和CMD规范

3.1. CommonJS规范缺点

CommonJS加载模块是同步的:

如果将它应用于浏览器呢?

所以在浏览器中,我们通常不使用CommonJS规范:

在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD:

3.2. AMD规范

AMD主要是应用于浏览器的一种模块化规范:

我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:

这里我们以require.js为例讲解:

第一步:下载require.js

第二步:定义HTML的script标签引入require.js和定义入口文件:

<script src="./lib/require.js" data-main="./index.js"></script>

第三步:编写如下目录和代码

├── index.html
├── index.js
├── lib
│   └── require.js
└── modules
    ├── bar.js
    └── foo.js

index.js

(function() {
  require.config({
    baseUrl: '',
    paths: {
      foo: './modules/foo',
      bar: './modules/bar'
    }
  })

  // 开始加载执行foo模块的代码
  require(['foo'], function(foo) {

  })
})();

modules/bar.js

define(function() {
  const name = "coderwhy";
  const age = 18;
  const sayHello = function(name) {
    console.log("Hello " + name);
  }

  return {
    name,
    age, 
    sayHello
  }
})

modules/foo.js

define(['bar'], function(bar) {
  console.log(bar.name);
  console.log(bar.age);
  bar.sayHello('kobe');
})

3.3. CMD规范

CMD规范也是应用于浏览器的一种模块化规范:

CMD也有自己比较优秀的实现方案:

我们一起看一下SeaJS如何使用:

第一步:下载SeaJS

第二步:引入sea.js和使用主入口文件

<script src="./lib/sea.js"></script>
<script>
  seajs.use('./index.js');
</script>

第三步:编写如下目录和代码

├── index.html
├── index.js
├── lib
│   └── sea.js
└── modules
    ├── bar.js
    └── foo.js

index.js

define(function(require, exports, module) {
  const foo = require('./modules/foo');
})

bar.js

define(function(require, exports, module) {
  const name = 'lilei';
  const age = 20;
  const sayHello = function(name) {
    console.log("你好 " + name);
  }

  module.exports = {
    name,
    age,
    sayHello
  }
})

foo.js

define(function(require, exports, module) {
  const bar = require('./bar');

  console.log(bar.name);
  console.log(bar.age);
  bar.sayHello("韩梅梅");
})

四. ES Module

4.1. 认识ES Module

JavaScript没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD等,所以在ES推出自己的模块化系统时,大家也是兴奋异常。

ES Module和CommonJS的模块化有一些不同之处:

ES Module模块采用export和import关键字来实现模块化:

了解:采用ES Module将自动采用严格模式:use strict

4.2. ES Module的使用

4.2.1. 代码结构组件

这里我在浏览器中演示ES6的模块化开发:

代码结构如下:

├── index.html
├── main.js
└── modules
    └── foo.js

index.html中引入两个js文件作为模块:

<script src="./modules/foo.js" type="module"></script>
<script src="main.js" type="module"></script>

如果直接在浏览器中运行代码,会报如下错误:

模块化运行

这个在MDN上面有给出解释:

我这里使用的VSCode,VSCode中有一个插件:Live Server

image-20201012153439900

4.2.2. export关键字

export关键字将一个模块中的变量、函数、类等导出;

foo.js文件中默认代码如下:

const name = 'coderwhy';
const age = 18;
let message = "my name is why";

function sayHello(name) {
  console.log("Hello " + name);
}

我们希望将其他中内容全部导出,它可以有如下的方式:

方式一:在语句声明的前面直接加上export关键字

export const name = 'coderwhy';
export const age = 18;
export let message = "my name is why";

export function sayHello(name) {
  console.log("Hello " + name);
}

方式二:将所有需要导出的标识符,放到export后面的 {}

const name = 'coderwhy';
const age = 18;
let message = "my name is why";

function sayHello(name) {
  console.log("Hello " + name);
}

export {
  name,
  age,
  message,
  sayHello
}

方式三:导出时给标识符起一个别名

export {
  name as fName,
  age as fAge,
  message as fMessage,
  sayHello as fSayHello
}

4.2.3. import关键字

import关键字负责从另外一个模块中导入内容

导入内容的方式也有多种:

方式一:import {标识符列表} from '模块'

import { name, age, message, sayHello } from './modules/foo.js';

console.log(name)
console.log(message);
console.log(age);
sayHello("Kobe");

方式二:导入时给标识符起别名

import { name as wName, age as wAge, message as wMessage, sayHello as wSayHello } from './modules/foo.js';

方式三:将模块功能放到一个模块功能对象(a module object)上

import * as foo from './modules/foo.js';

console.log(foo.name);
console.log(foo.message);
console.log(foo.age);
foo.sayHello("Kobe");

4.2.4. export和import结合

如果从一个模块中导入的内容,我们希望再直接导出出去,这个时候可以直接使用export来导出。

bar.js中导出一个sum函数:

export const sum = function(num1, num2) {
  return num1 + num2;
}

foo.js中导入,但是只是做一个中转:

export { sum } from './bar.js';

main.js直接从foo中导入:

import { sum } from './modules/foo.js';
console.log(sum(20, 30));

甚至在foo.js中导出时,我们可以变化它的名字

export { sum as barSum } from './bar.js';

为什么要这样做呢?

4.2.4. default用法

前面我们学习的导出功能都是有名字的导出(named exports):

还有一种导出叫做默认导出(default export)

导出格式如下:

export default function sub(num1, num2) {
  return num1 - num2;
}

导入格式如下:

import sub from './modules/foo.js';

console.log(sub(20, 30));

注意:在一个模块中,只能有一个默认导出(default export);

4.2.5. import()

通过import加载一个模块,是不可以在其放到逻辑代码中的,比如:

if (true) {
  import sub from './modules/foo.js';
}

为什么会出现这个情况呢?

const path = './modules/foo.js';

import sub from path;

但是某些情况下,我们确确实实希望动态的来加载某一个模块:

aaa.js模块:

export function aaa() {
  console.log("aaa被打印");
}

bbb.js模块:

export function bbb() {
  console.log("bbb被执行");
}

main.js模块:

let flag = true;
if (flag) {
  import('./modules/aaa.js').then(aaa => {
    aaa.aaa();
  })
} else {
  import('./modules/bbb.js').then(bbb => {
    bbb.bbb();
  })
}

4.3. ES Module的原理

4.3.1. ES Module和CommonJS的区别

CommonJS模块加载js文件的过程是运行时加载的,并且是同步的:

console.log("main代码执行");

const flag = true;
if (flag) {
  // 同步加载foo文件,并且执行一次内部的代码
  const foo = require('./foo');
  console.log("if语句继续执行");
}

CommonJS通过module.exports导出的是一个对象:

ES Module加载js文件的过程是编译(解析)时加载的,并且是异步的:

<script src="main.js" type="module"></script>
<!-- 这个js文件的代码不会被阻塞执行 -->
<script src="index.js"></script>

ES Module通过export导出的是变量本身的引用:

export和import绑定的过程

所以我们下面的代码是成立的:

bar.js文件中修改

let name = 'coderwhy';

setTimeout(() => {
  name = "湖人总冠军";
}, 1000);

setTimeout(() => {
  console.log(name);
}, 2000);

export {
  name
}

main.js文件中获取

import { name } from './modules/bar.js';

console.log(name);

// bar中修改, main中验证
setTimeout(() => {
  console.log(name);
}, 2000);

但是,下面的代码是不成立的:main.js中修改

import { name } from './modules/bar.js';

console.log(name);

// main中修改, bar中验证
setTimeout(() => {
  name = 'kobe';
}, 1000);

导入的变量不可以被修改

思考:如果bar.js中导出的是一个对象,那么main.js中是否可以修改对象中的属性呢?

4.3.2. Node中支持 ES Module

在Current版本中

在最新的Current版本(v14.13.1)中,支持es module我们需要进行如下操作:

这里我们暂时选择以 .mjs 结尾的方式来演练:

bar.mjs

const name = 'coderwhy';

export {
  name
}

main.mjs

import { name } from './modules/bar.mjs';

console.log(name);

在LTS版本中

在最新的LST版本(v12.19.0)中,我们也是可以正常运行的,但是会报一个警告:

lts版本的警告

4.3.3. ES Module和CommonJS的交互

CommonJS加载ES Module

结论:通常情况下,CommonJS不能加载ES Module

ES Module加载CommonJS

结论:多数情况下,ES Module可以加载CommonJS

foo.js

const address = 'foo的address';

module.exports = {
  address
}

main.js

import foo from './modules/foo.js';
console.log(foo.address);

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8