熟悉而陌生的模块化(全面剖析 CommonJs 和 ES6Module)

前言

💬 “ 来了吗 ”
💬 “ 来了,来了 ”
javascript

🏃🏃🏃🏃🏃🏃🏃🏃🏃🏃💨html

各位看官姥爷好,今天是 2019 年 12 月 31 日了,2019 年的最后一天了。前端

立刻 2020 年了,先在这祝各位看官姥爷 新年快乐!!!vue

先放鞭炮,请各位 🙉🙉🙉java

💥💥💥💥💥💥💥
🎉🎉🎉node

好:👏👏👏
react


21 世纪 20 年代了,还傻傻分不清模块化吗?
面试官问你对模块化的理解,内心明白着,殊不知道该怎么回答?
面试官问你 AMD、CMD、UMD、CommonJs 一脸蒙圈?
CommonJs 和 ES6 Module 的区别又是什么呢?
webpack

别着急,你想知道的不想知道的,你知道的不知道的,你知不知道的,本文都(bu yi ding)有。🙈🙈🙈es6

请注意: 本文篇幅有点长,若有兴趣,结合代码食用更佳,还请跟随文章代码敲一敲。web

耐得住寂寞,才能守得住繁华。

请知悉: 本文主要内容是用来分析 CommonJs 规范ES6Moudle 两个模块化方式的,对于其余的模块化方式本文未作分析。我的笔记,还请批评。

在本文,你可以收获到:


正文开始

在现代前端开发中,我想听到最多的应该是工程化、模块化、组件化这几个概念了吧。
或许你不能流畅的描述什么是工程化、模块化、组件化。
可是,你必定用到过。

你确定用到过以下指令:

npm run serve | dev
npm run build
npm run lint
...
复制代码

你也确定用到过以下语法:

const http = require("http");
import { log } from "@/utils";
...
复制代码

你也确定用过以下结构:

<a-button type="primary">Primary</a-button>
// 或者
<el-button type="primary">主要按钮</el-button>
...
复制代码

呐,这些都是你平常用到,再熟悉不过的开发方式了对吧。

工程化

目前来讲,随着浏览器的发展、网络的发展、前端生态圈的发展...
总之时代在进步,人们的需求不断增长,有需求就有业务。
如今,web 业务日益复杂化和多元化,纵观市场上的项目,都已经再也不是过去的拼个页面 + 搞几个 jQuery 插件就能完成的了。前端开发已经由 webPage 模式为主转变为以 webApp 模式为主了。运行在 web 端的 app,可见其复杂度。

综上所述,咱们开发一个前端项目再也不是从画页面,几个页面互相跳转一下的时代。
咱们要将项目看作一个工程,从大局出发,一个项目要使用哪些工具,要使用哪些技术,哪些部分是复用的,要如何高效的抽离,如何优化性能,如何加载资源,如何使开发更规范,如何使后期维护更高效等等。

转换一下,所谓前端工程化是否是就是咱们平常开发中使用的 模块化、组件化、规范化、自动化的集合体?
前端工程化是否是前端质的变化呢?

而对于平常开发中使用的 webpack、vue、angular、react、ant-design、element-ui... 你不能说它们就是前端工程化,它们只是实现前端工程化的方式而已。

咱们要作的是前端工程师,而不是前端页面师。

是否是有点跑题了呢,本文主要目的是说模块化的啊,我以为工程化仍是有必要放在模块化以前提一下的

模块化

咱们已经意识到了前端的 web 程序愈来愈复杂,也默转潜移的身处于前端工程化的潮流中,是否有种 “初闻不知曲中意,再听已经是曲中人” 的意思了呢。

"模块化" 又是什么呢?

对于工程化来讲,它是工程化的下游分支;
对于 JavaScript 来讲,它是一种代码的组织方式;
对于程序来讲,它是一种清晰的、易于维护、高效的开发方式。

在没有模块化以前

你有没有见过这样的代码

在很长的一段时间里,前端只能经过一系列的 <script> 标签来维护咱们的代码关系,可是一旦咱们的项目复杂度提升的时候,这种简陋的代码组织方式即是如噩梦般使得咱们的代码变得混乱不堪。
而且,这种方式将多个 js 文件一股脑的引入到页面,其实都是在一个全局执行环境下,很容易形成变量污染的问题

亦或者这样的代码

一个 js 文件里 3000 行代码,一段代码这粘贴一块,那粘贴一块,维护这 3000 行代码,难度可想而知....

我想从上面的代码不难看出以往开发的痛点在哪里了。

模块化后

咱们既然知道痛点在哪里,就应该从痛点出发,去解决问题。
咱们思考下,若是要解决上面的几个问题,咱们要怎们作?

  • 首先须要解决多个脚本引入的依赖关系问题
    • 有没有哪一种方式可以明确的看到某个脚本依赖哪些脚本?
    • 而且不用在一股脑的在页面中如此引用,太混乱了。
  • 其次须要解决多个脚本都在一个全局执行环境中,变量都混在一块儿。
    • 有没有什么方式可以使脚本之间独立运行,互不影响?
  • 而后,某个脚本因为业务复杂,不能都写在一个文件里面
    • 能不能一个脚本实现的业务拆成多个文件,分开管理?

有问题,也就会有解决问题的方式 -- 模块化就为此而生

概念:

  • 将一个复杂的程序依据必定的规则(规范)封装成几个块(文件),并进行组合在一块儿
  • 块的内部数据与实现是私有的,只是向外部暴露一些接口(方法)与外部其它模块通讯

其实模块化就是将一个复杂的系统分解成多个独立的模块的代码组织方式;

不少人以为模块化开发的工程意义是复用,其实应该是模块化开发的最大价值应该是分治。无论你未来是否要复用某段代码,你都有充分的理由将其分治为一个模块。

模块化好处:

  • 避免命名冲突(减小命名空间污染)
  • 更好的分离,按需加载
  • 更高复用性
  • 高可维护性

说明

好,说了这么多可算把模块化的概念说完了。

更多的还有关于模块化的进化过程:

全局 function 模式 => namespace 模式 => IIFE 模式 => IIFE 模式加强 : 引入依赖

具体的几种模块化的规范:

  • IIFE
  • AMD
  • CMD
  • CommonJS
  • UMD
  • ES6 Modules

就再也不逐一分析,重点仍是放到 CommonJsES6 Modules 中,由于这两个是目前用的最多的。

CommonJs

1. 概述

随着 Javasript 应用进军服务器端,业界急需一种标准的模块化解决方案,因而,CommonJS 应运而生。这是一种被普遍使用的 Javascript 模块化规范,你们最熟悉的 Node.js 应用中就是采用这个规范。

在 Node.js 模块系统中,每一个文件都被视为一个独立的模块。模块有本身的做用域,一个模块内部全部的变量、函数、类 都是私有的,模块之间不能直接访问模块内部。
在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块须要提早编译打包处理。

2. 基本语法

  • 暴露模块:

    • exports.xxx = value
    • module.exports = value
  • 导入模块:

    • require(xxx)
      • 若是是第三方模块,xxx 为模块名;
      • 若是是自定义模块,xxx 为模块文件路径;

3. 特色

  • 全部代码都运行在模块做用域,不会污染全局做用域。
  • 模块能够屡次加载,可是只会在第一次加载时运行一次,而后运行结果就被缓存了,之后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。

4. 分析

好,咱们大体了解了下 CommonJs,如今让咱们逐步分析

4.1 module 对象

已知在 node 中,每一个文件都是一个独立的模块,那么,这个 “模块” 究竟是什么呢?
nodejs 官网告诉咱们:在每一个模块中都有一个名为 module 的自由变量是对表示当前模块的对象的引用

如今,新建一个 app.js 文件,在里面尝试打印下 module

console.log(module);

node app.js
复制代码

顺利的话,你应该能够看到相似的输出,没错,module 是一个可访问的对象,
而这个对象,就是表明了当前文件(模块)的引用;
如今知道 commonjs 中的 "模块" 到底是什么了吧。

module 对象的属性
  • id: 模块的标识符。 一般是彻底解析后的文件名。

  • exports: module.exports 对象由 Module 系统建立

    exports 属性是重中之重,这个属性是对外的接口,在外部加载模块时,其实加载的是这个模块的 module.exports 属性。咱们在暴露属性时,也是经过将属性挂载到 module.exports 上面进行暴露操做的。

  • parent: 标识最早引用该模块的模块。

  • filename: 模块的彻底解析后的文件名。

  • loaded: 模块是否已经加载完成,或正在加载中。

  • children: 被该模块引用的模块对象。

  • paths: 模块的搜索路径。

4.2 暴露模块

在暴露模块时,咱们有两种方式来将属性暴露出去:module.exports 和 exports

exports 是一个对于 module.exports 的更简短的引用形式。
exports 变量是在模块的文件级做用域内可用的,且在模块执行以前赋值给 module.exports。

实际上:
一个模块最终暴露的是 module 整个对象,而在加载时,加载的是 module 对象的 exports 属性;
module.exports 始终做为一个模块的输出接口,以供外部访问内部的变量。

在一个模块做用域中,还有一个 exports 属性,与 module.exports 属性是同一个引用,指向同一个数据,使用 exports.xxx 的方式,能够将对应的属性挂载到 module.exports 属性上,从而达到暴露属性的目的,以下所示:

// module1.js

person = {
  name: "Jack",
  age: 18,
  sex: "man"
};

function pointPerson() {
  console.log("point person at module1.js:", person);
}

exports.person = person;
module.exports.pointPerson = pointPerson;

console.log("module.exports === exports:", module.exports === exports); // true
console.log("module1:", module);
复制代码

能够看到 exports 与 module.exports 是同一个引用;

不管使用 exports 仍是使用 module.exports 都挂载到了 module 下面,也会在未来暴露出去;

若是咱们将 module.exports 或者 exports 的引用改变了呢?

  1. 咱们先将 exports 的引用改变:
// module1.js

person = {
  name: "Jack",
  age: 18,
  sex: "man"
};

function pointPerson() {
  console.log("point person at module1.js:", person);
}

exports.person = person;
module.exports.pointPerson = pointPerson;

console.log("module.exports === exports:", module.exports === exports); // true
// console.log('module1:', module);

exports = { index: 1 };
exports.a = "aaa";
console.log("module.exports === exports:", module.exports === exports); // false

console.log("module1:", module);
复制代码

承接未更改 exports 引用,对比发现,exports 再也不和 moudle.exports 全等,给 exports 添加的属性也没有被添加到 module.exports 上面;

因为module.exports 始终做为一个模块的输出接口,当 exports 与 module.exports 发生断链后,再往 exports 上面添加属性,将再也不被暴露出去;

  1. 尝试改变 module.exports 的引用:
// module1.js

module.exports = {
  count: 123
};

console.log("module.exports === exports:", module.exports === exports); // false
console.log("module1:", module);
复制代码

一样的,exports 与 module.exports 再也不全等。在这一步的状况下,exports 还指向旧的 moudle.exports 指向的对象,并未自动的随着 module.exports 的改变而改变;

区别于改变 exports 的引用,直接改变 module.exports 的引用是真实有效的;最终暴露出去的接口,始终取决于 module.exports 的指向。

若是要改变 module.exports 的引用,大可将 exports 的引用改为同一个引用,以下:

// module1.js

module.exports = exports = {
  count: 123
};

console.log("module.exports === exports:", module.exports === exports); // true
复制代码

4.3 加载模块

在 node 中,加载模块使用的是 require 方法,这个方法被内置于 node 模块做用域中,和 module 及 exports 同样,能够直接拿来使用。

require 在 node 官方给出的解释是这样的:

用于引入模块、JSON、或本地文件。 能够从 node_modules 引入模块。 可使用相对路径(例如 ./、 ./foo、 ./bar/baz、 ../foo)引入本地模块或 JSON 文件,路径会根据 __dirname 定义的目录名或当前工做目录进行处理。

// 引入本地模块:
const myLocalModule = require("./path/myLocalModule");

// 引入 JSON 文件:
const jsonData = require("./path/filename.json");

// 引入 node_modules 模块或 Node.js 内置模块:
const crypto = require("crypto");
复制代码

require 同步的读入并执行一个 js 文件,并返回这个 js 模块的 module.exports 属性,若是 js 文件并无 exports 任何接口,那么它的 module.exports 就是一个空对象,require 返回的也将是这个空对象

require 还能够引入 json 文件,返回值就是 json 文件内的 json 数据

上代码:

// module2.js
console.log("module2.js start:", new Date().getTime());
const person = {
  name: "Jack",
  age: 18,
  sex: "man"
};

exports.person = person;
// 暴露出去了一个 quote 变量,使它引用了当前模块的 module.exports 属性
exports.quote = module.exports;
console.log("module2.js end:", new Date().getTime());

// app.js
console.log("app.js start:", new Date().getTime());
const m2 = require("./modules/module2");

console.log("m2:", m2);
console.log("m2.quote:", m2.quote);
console.log("m2.quote === m2:", m2.quote === m2); // true
console.log("app.js end:", new Date().getTime());
复制代码

result:

解析加载流程

1)在 app.js 开始部分咱们打印了一个开始的时间戳,最开始输出的也是这一个
2)代码遇到 require,读入并执行 module2.js
3)在 module2.js 中打印了 module2 开始的时间戳
4)在 module2 中,暴露了一个 person 对象,及一个 module.exports 的引用
5)module2.js 执行完,输出最后一条语句的时间戳
6)执行流返回 app.js,定义的 m2 变量接收 require() 调用的返回值
7)输出 m2 及 m2.quote 属性
8)发现 m2 和 m2.quote 是相等呢,那么是否是能够证实 require 返回的就是 模块下的 module.exports 属性呢
9)最后输出 app.js 执行完毕的时间戳,其实咱们在上面就已经能看出,在执行完 module2.js 后才返回继续执行 app.js,是否是就已经证实了 require() 是同步读入并执行的呢。

加载缓存

既然 require 会执行 js 文件,若是我屡次加载同一模块,是否会执行屡次这个 js 文件呢。
咱们来试一下:

// module2.js 依旧保持上一状态

// app.js
const m2 = require("./modules/module2");
console.log("第一次加载 module2", new Date().getTime());

const m3 = require("./modules/module2");
console.log("第二次加载 module2", new Date().getTime());

const m4 = require("./modules/module2");
console.log("第三次加载 module2", new Date().getTime());
复制代码

result:

咦?不是说 require 会读入并执行 js 文件吗?
怎么就执行了一次 module2.js 呢

实际上:

在第一次使用 require 加载模块后,这个被加载的模块的 module 属性(对应前面的 module 对象),就被缓存了起来;

在缓存后,require 就会返回这个缓存中的模块的 module.exports 属性(是否验证了module.exports 始终做为一个模块的输出接口这一说法);

若是后续还有 require 加载相同的模块(好比 module2),那么 require 将不会再从新读入且执行那个模块,而是直接将缓存中的对应的模块的 module.exports 属性返回;

对于当前模块来讲,不管加载多少次,不管使用什么变量(m2/m3/m4)去接收 require 的返回值,都只不过是引用缓存中的模块对象的 exports 属性而已;

验证:

// app.js

const m2 = require("./modules/module2");
const m3 = require("./modules/module2");
const m4 = require("./modules/module2");

console.log(m2 === m3); // true
console.log(m2 === m4); // true
复制代码
requer.cache

在 app.js 中尝试打印 require.cache

// app.js

const m2 = require("./modules/module2");
const m3 = require("./modules/module2");
const m4 = require("./modules/module2");
console.log(require.cache);
复制代码

result:

虽然咱们不可以明确的知道这个到底输出的是个什么东西,不过看这个结构也能猜个七七八八了

咱们能够将 require.cache 看作一个对象,缓存对象;
看这个对象的格式,key 应该是某些模块的完整路径及模块名字,value 应该是对应模块的module 对象; 第一个属性是当前模块的 module 对象
其余的属性应该是加载的其余模块的 module 对象
不难看出,require 按照规则,返回的就是这个对象下的某个字段(某个module 对象)下的 exports 属性;

咱们能够看下代码:

// app.js

const m2 = require("./modules/module2");
const m3 = require("./modules/module2");
const m4 = require("./modules/module2");

const name = require.resolve("./modules/module2");
const moduleCache = require.cache[name];

console.log(m2 === moduleCache.exports); // true
console.log(m3 === moduleCache.exports); // true
console.log(m4 === moduleCache.exports); // true
复制代码

当咱们直接获取 require.cache 对象中的 module2 的属性后,将它的 exports 属性,与 require 加载的模块比较,发现就是全等的;
这样一来,是否是就明白了这个缓存的机制了呢。
若是想要让模块文件再次执行,那就在加载模块前清除掉缓存就能够了(删除掉 require.cache 中表明模块的属性便可);

function clearCache(path) {
  path = require.resolve(path);
  delete require.cache[path];
}
复制代码

在 app 模块中,大概就像下图这样:

【注意:】 若是你能弄明白这个加载的缓存机制,也就可以明白为何 commonjs 中加载的模块为何不能实时响应模块内部数据的变化了。 由于模块加载的是被加载模块的一个缓存副本,并不能实时的响应模块内部的数据的变化

小结

1)在 node 中,每一个文件都是独立的模块;
2)在每一个模块中,都有一个名为 module 的自由变量,用来表示当前模块的引用;
3)module 对象下面的 exports 属性是最终引用的关键属性
4)暴露模块有两种方式,module.exports && exports

  • exports 是 module.exports 的简写
  • 它们两个在最初时指向同一个地址
  • 改变其中任意一个,都会使 exports 和 moudle.exports 断链
  • 最终暴露出去的接口,彻底取决于 module.exports 属性

5)加载模块使用 require 方法;
6)加载模块时会将被加载模块的 module 对象缓存在当前模块中的 require.cache 中;
7)正式由于加载的缓存机制,加载事后的模块不能实时获取模块内部的数据。

ES6Module

个人妈耶,光一个 CommonJs 剖析就写了这么多,有点出乎意料,有点蒙圈。我须要整理一下思绪,再整理 es6 的 module。

太多了,累死了 😨😨😨 估计也没人会认真看到这,先溜了,我要去跨年啦,明年再继续写。。。

新年快乐!新年快乐!新年快乐!

参考文章

前端模块化详解(完整版)
前端模块化的前世此生
浅谈前端工程化
NodeJs - Module
ES6 - Module

相关文章
相关标签/搜索