前端模块化
模块化开发最终的目的是将程序划分为一个个小的结构,
每个结构中编写属于直接的逻辑代码,有自己的作用域,定义变量时不会影响到其他结。
在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:
这个时候我们只需要将JavaScript代码写到<script>标签中即可,没有必要放到多个文件中来编写;
早期甚至流行将 JavaScript 程序的长度规定为一行。

上例中,utils.js和main.js都被嵌入到index.html中,此时utils.js中定义的变量和函数,在main.js是可以直接调用的。
这就是没有模块化的结果,所有js代码的作用域相同。
这跟将 JavaScript 代码写在同一个文件没有本质上的区别。
早期网站,由于功能简单,这样的设计并不会出现什么问题。
但是随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了:
- ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染;
- SPA(Single Page Application)的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现;
- Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤;
所以,模块化已经是JavaScript一个非常迫切的需求:
但是JavaScript本身,直到ES6(2015)才推出了自己的模块化方案;
在此之前,为了让JavaScript支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS等;
01 ConmonJS
CommonJS是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,(因为主要在服务端使用)
后来为了体现它的广泛性,修改为CommonJS,平时我们也会简称其为CJS。
-
Node是CommonJS在服务器端一个具有代表性的实现;
-
Browserify是CommonJS在浏览器中的一种实现;(现已逐渐淘汰)
-
webpack打包工具具备对CommonJS的支持和转换;
Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:
-
在Node中,每一个js文件都是一个单独的模块;
-
每个模块包括CommonJS规范的核心变量:exports、module.exports、require;
模块化的核心是导出和导入,Node对其进行了实现:
-
exports和module.exports可以负责对模块中的内容进行导出;
-
require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;
1.1 exports案例
utils.js:
const UTILS_NAME = 'utils_name'
function f1() {
return '我是函数f1'
}
function f2() {
return '我是函数f2'
}
exports.name=UTILS_NAME
exports.f1=f1
exports.f2=f2
main.js:
const utils = require("./utils")
console.log(utils.name)
console.log(utils.f1())
console.log(utils.f2())
// 执行main.js,输出
// utils_name
// 我是函数f1
// 我是函数f2
在main.js中对utils.js进行导入,也可以直接对对象进行结构,
const {name, f1, f2} = require("./utils")
console.log(name)
console.log(f1())
console.log(f2())
exports与require本质:
exports其实是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出;
而require实现的效果是:在main.js中,utils变量就等于exports对象。
(require通过各种查找方式,最终找到了exports这个对象,并且将这个exports对象赋值给了utils变量)
1.2 module.exports
在Node开发中,我们导出东西的时候,经常是通过module.exports导出的,
以下的代码和上例实现效果是一样的。
utils.js:
const name = 'zibuyu'
const age = 18
function sayHello() {
console.log('Hello')
}
module.exports.name = name
module.exports.age = age
module.exports.sayHello = sayHello
main.js:
const {name, age, sayHello} = require("./utils")
console.log(name)
console.log(age)
sayHello()
module.exports和exports有什么关系或者区别呢?
追根溯源,原因在于CommonJS规范。CommonJS中有exports概念,但是没有module.exports概念。
而Node一开始的设计并不符合CommonJS规范。
但为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module;
所以在Node中真正用于导出的是module.exports;因为module才是导出的真正实现者。
为了让Node符合CommonJS规范,
exports和module.exports做了一个绑定:
module对象的exports属性是exports对象的一个引用,两者的底层内存是一样的。
也就是说 module.exports = exports。
注意,以下的导出方式与上例是有区别的,实际开发中常常使用这一种导出方式:
const name = 'zibuyu'
const age = 18
function sayHello() {
console.log('Hello')
}
module.exports = {
name,
age,
sayHello,
}
区别在于:module.exports = {}将module.exports指向了一个新的对象。
在底层中,另辟了一块内存用来存放变量。
这样,module.exports与exports对象就再没有引用关系了。
1.3 require
require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。
那么,require的查找规则是怎么样的呢?
我们来总结一下常见的查找规则:
导入格式如下:require(X)
情况一:X是一个Node内置模块,比如path、http。直接返回该模块,并且停止查找。
// node跟python类似,自带了很多模块。使用require可以直接导入这些模块。
const path = require('path')
const path = require('http')
情况二:X是以 ./ 或 ../ 或 /(根目录)开头的
-
第一步:将X当做一个文件,在当前目录下进行查找;
-
如果有后缀名,按照后缀名的格式查找对应的文件
-
如果没有后缀名,会按照如下顺序:
- 直接查找文件X
- 查找X.js文件
- 查找X.json文件
- 查找X.node文件
-
-
第二步:没有找到对应的文件,将X作为一个目录,查找该目录下面的index文件
-
查找X/index.js文件
-
查找X/index.json文件
-
查找X/index.node文件
-
如果没有找到,那么报错:not found
-
情况三:X并不是内置模块,也不是路径。直接是第三方模块。
- 在当前目录下查找node_modules文件夹,(这个文件名是固定的),继而在node_modules文件夹中查找X;
- 如果在当前目录下查找不到node_modules文件夹,则去上一层目录中查找,知道根目录。
实际上,通过
npm install 模块名去安装模块时,就是将模块下载存放到node_modules文件夹中的。
1.4 模块的加载过程
-
结论一:模块在被第一次引入时,模块中的js代码会被运行一次;
-
结论二:模块被多次引入时,并不会运行多次;
-
结论三:循环引入,根据图结构进行搜索。
每个模块对象module都有一个属性:loaded
为false表示还没有加载,为true表示已经加载;
第一次引入时,loaded便会被设置为true,所以此后的引入都不会再运行。
循环引入案例:
下图模块的引用关系,其加载顺序是什么?

这个其实是一种数据结构:图结构;
图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
Node采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb
1.5 CommonJS规范的缺点
CommonJS最大的确定是,其加载模块是同步的。
同步的意味着,只有等到对应的模块加载完毕,当前模块中的内容才能被运行;
同步加载,在服务器一般不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快;
但在浏览器端,这一点缺陷就非常明显。
浏览器加载js文件都是需要先从服务器上,将文件下载下来,之后再加载运行;
采用同步加载,就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作;
所以在浏览器中,通常不使用CommonJS规范。
在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD:
但目前AMD和CMD已经逐渐被淘汰了。
一方面现代的浏览器已经支持ES Modules,
另一方面借助于webpack等工具也可以实现对CommonJS或者ES Module代码的转换。
webpack会将所有的js文件整合成一个,浏览器端只需要下载一个js文件即可;
而如果整合的js文件过大,webpack也支持分包的操作。
02 AMD规范
如前所述,AMD主要是应用于浏览器的一种模块化规范:
AMD是Asynchronous Module Definition(异步模块定义)的缩写;
它采用的是异步加载模块;
事实上,AMD规范的出现还要早于CommonJS;
AMD比较常用的库是require.js和curl.js;
- 第一步:下载require.js
下载地址:https://github.com/requirejs/requirejs
找到其中的require.js文件;
- 第二步:定义HTML的script标签引入require.js和定义入口文件:
<script src="./lib/require.js" data-main="./index.js"></script>
data-main属性的作用是在加载完src的文件后,会立即加载执行该文件
03 CMD规范
CMD 是Common Module Definition(通用模块定义)的缩写;
它也是采用异步加载模块,但是它将CommonJS的优点吸收了过来;
CMD也有自己比较优秀的实现方案:SeaJS。
其使用与AMD规范非常类似。
- 第一步:下载SeaJS
下载地址:https://github.com/seajs/seajs
找到dist文件夹下的sea.js
- 第二步:引入sea.js和使用主入口文件
seajs是指定主入口文件的

04 ES Module
JavaScript没有模块化一直是它的痛点,所以才会产生社区规范:CommonJS、AMD、CMD等,
在2015年,ECMA推出自己的模块化系统时,大家也是兴奋异常。
ES Module和CommonJS的模块化有一些不同之处:
-
使用了import和export关键字;
- export负责将模块内的内容导出;
- import负责从其他模块导入内容;
-
采用编译期的静态分析,并且也加入了动态引用的方式;
注意,ES Module使用的导出关键字是export,而Common JS使用的导出关键字是exports。
4.1 基本使用
在HTML中,只需要在引入js文件时,添加type="module"属性,即可标记该js文件为模块。
<script src="./modules/foo.js" type="module"></script>
<script src="main.js" type="module"></script>
4.1.1 使用export导出
util.js:
const name = 'zibuyu'
const age = 18
function sayHello() {
console.log('Hello')
}
export {
name,
age,
sayHello,
}
注意,此处export后面跟上的{}并不是一个对象,它是ES Module 的语法。
与Common JS的module.exports新建对象导出有本质的区别。
main.js:
import {name, age, sayHello} from "./utils.js"
console.log(name)
console.log(age)
sayHello()
在ide运行js代码是,可能会以下报错:
(node:10812) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
解决办法:
1、安装新版node.js
2、使用npm init -y初始化项目,生成一个pakeage.json文件
3、在pakeage.json文件中添加"type": "module"
4.1.2 在定义时导出
ES Module支持在定义变量时,直接将该变量导出。
export const name = 'zibuyu'
export const age = 18
export function sayHello() {
console.log('Hello')
}
4.1.3 导出时取别名:
utils.js
const name = 'zibuyu'
const age = 18
function sayHello() {
console.log('Hello')
}
export {
name as utils_name,
age as utils_age,
sayHello,
}
main.js
import {utils_name, utils_age, sayHello} from "./utils.js"
console.log(utils_name)
console.log(utils_age)
sayHello()
4.1.4 导入时起别名
ES Module支持在导入模块时,对变量取别名。
import {name as u_name, age as u_age, sayHello} from "./utils.js"
console.log(u_name)
console.log(u_age)
sayHello()
4.1.5 导入整个模块
import * as util from "./utils.js"
console.log(util.name)
console.log(util.age)
util.sayHello()
4.1.6 综合使用
在实际开发中,往往会有很多的模块,逐一导入会显得非常臃肿。
常见的做法是,在存放模块的文件夹中,新建一个index.js文件,在此文件中导入其他模块。
最后,再将index.js中的函数与变量进行统一的导出。
utils/hello.js
export function sayHello() {
console.log('Hello!')
}
utils/hi.js
export function sayHi() {
console.log('Hi!')
}
utils/index.js
import {sayHello} from "./hello.js";
import {sayHi} from "./hi.js";
export {
sayHello,
sayHi
}
最后,在需要导入模块的地方,导入index.js即可:
import {sayHi,sayHello} from "./utils/index.js"
sayHello()
sayHi()
utils/index.js文件作为一个中转站,导入模块后再统一导出,也有更简洁的语法:
export {sayHello} from "./hello.js";
export {sayHi} from "./hi.js";
// 或者
export * from "./hello.js";
export * from "./hi.js";
4.2 default用法
前面学习的导出功能都是有名字的导出(named exports);
-
在导出export时指定了名字;
-
在导入import时需要知道具体的名字;
还有一种导出叫做默认导出(default export)
-
默认导出export时可以不需要指定名字;
-
在导入时不需要使用 {},并且可以自己来指定名字;
-
它也方便我们和现有的CommonJS等规范相互操作;
注意:在一个模块中,只能有一个默认导出(default export);
default.js:
function parseLyric() {
return ['解析好的歌词']
}
export default parseLyric
// 更简洁的写法
export default function () { // 由于一个模块只能由一个默认导出,此处也可以省略函数名
return ['解析好的歌词']
}
main.js:
import aa from './default.js' // 其中,‘aa’为任意起的名字
console.log(aa())
4.3 import函数
通过import加载一个模块,是不可以在其放到逻辑代码中的,比如:
if (true){
import {sayHi,sayHello} from "./utils/index.js"
sayHello()
sayHi()
}
// 不支持这种写法
但是某些情况下,我们确确实实希望动态加载某一个模块,
这个时候我们需要使用 import() 函数。
import函数返回一个Promise,可以通过then获取结果;
let flag = true
if (flag){
const importPromise = import('./utils/index.js')
importPromise.then(res=>{
res.sayHello()
res.sayHi()
})
}
简洁写法:
let flag = true
if (flag){
import('./utils/index.js').then(res=>{
res.sayHello()
res.sayHi()
})
}
在ES11(ES2020)中新增了一个特性import.meta;
import.meta是一个给JavaScript模块暴露特定上下文的元数据属性的对象。
它包含了这个模块的信息,比如说这个模块的URL。
4.4 ES Module的解析过程
ES Module是如何被浏览器解析并且让模块之间可以相互引用的呢?
官方文档:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
ES Module的解析过程可以划分为三个阶段:
-
阶段一:构建(Construction),根据地址查找js文件,并且下载,将其解析成模块记录(Module Record);
-
阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址;
-
阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中;

阶段一:构建阶段

生成模块记录:

阶段二与阶段三:实例化阶段与求值阶段


浙公网安备 33010602011771号