// index.js
import Slider from './components/Slider.js'
import Card from './components/Card.js'
import Switch from './components/Switch.js'
import Button from './components/Button.js'
// components/index.js
export { default as Slider } from './Slider.js'
export { default as Card } from './Card.js'
export { default as Switch } from './Switch.js'
export { default as Button } from './Button.js'
// index.js
import {
Slider,
Card,
Switch,
Button
} from './components'
1. 什么是模块化?
现代软件开发往往利用模块作合成的单位。 当一个系统足够复杂的时候,需要团队分工协作,把系统划分成若干模块管理,这一过程叫做模块化。
模块化是一种处理复杂系统分解成为更好的可管理模块的方式。它可以通过在不同组件设定不同的功能,把一个问题分解成多个小的独立、互相作用的组件,来处理复杂、大型的软件。
在很多编程语言中都有模块的概念,比如 Python 中使用 pip 进行模块的管理,在 JavaScript 中也使用 bower 和 npm 进行模块管理。
模块化本质上也属于“分治法”的一种。
模块化在现实中也有体现,图为谷歌 Project Ara 模块化手机:
2. 模块?组件?插件?
在开始之前,先聊一个老生常谈的问题,模块、组件和插件之间的区别是什么呢? 写过 jQuery 的同学应该知道 jQuery 拥有众多插件,比较知名的有 Swiper、fullPage 等等。 而写过 React / Vue (下文统称为 RV)的同学就会说,RV 中也有 Swiper、fullPage 啊,可这些都被称之为组件。 插件一般是一种遵循一定规范的应用程序接口编写出来的程序,它集成在某个平台,比如 Jenkins 插件、Chrome 插件等等,jQuery 插件也是类似。 平台只提供一些基本的能力,它提供了应用接口来吸引开发者开发更多定制化的功能,这就是插件。 我们常谈起的 jQuery 插件往往是 UI 层面的,所以不论是 jQuery 的插件还是 RV 的组件,在我看来都是一种偏向 UI 层面的模块。组件可以是由 模板( template )、样式( style )、JavaScript 代码( script )三部分组成的。
组件就像是乐高积木一样,很多小组件组成了一个完整的页面。而模块则是 JavaScript 模块,主要是根据业务内容来划分的,比如一个格式化时间的模块、一个实现加盐算法的模块等等。 因此,一个组件的 script 可以包括多个小模块,比如一个日历组件可能会包含格式化时间、计算日期偏差等模块,而一个大的模块也同样可以包括多个小组件,比如很多 App 都有消息通知的功能,而一个消息通知的模块也可以包括 Badge、Icon 等组件。
借用知乎张云龙大佬的一张图来表示就是:
3. script
在我们刚接触前端开发的时候,通常最先接触到的就是在 html 文件的 script 标签里面直接写 JavaScript 代码。 如果依赖了 jQuery 或者 Underscore 等库,还需要在 script 标签中引入对应的文件或者 CDN 链接。 由于浏览器解析 DOM 是从上到下的,所以 script 标签也是按照先后顺序来解析的,这样就必须要保证自己写的代码需要放到依赖库后面才能使用。
当一个项目复杂到一定程度的时候,也许会依赖很多第三方库,JavaScript 代码也会被拆分成多个文件,依赖性最大的模块需要放到最后。这样就带来了一个问题,如果依赖关系过于复杂,如何保证 script 引入顺序呢?
4. 模块化的发展
在回答上面这个问题之前,先来简单地了解一下 JavaScript 模块化的发展历程。 最初的模块化非常简单,可以直接使用一个对象当做模块。
但是这样导致了一个问题,就是没有私有变量,会直接将内部的属性暴露出去,能够被外界访问到,甚至被修改。倘若我不想让
count
暴露出去怎么办? 聪明的同学肯定马上就想到了,可以在函数内部创建count
变量,利用闭包的特性,只暴露出想要暴露到外界的。但是这样也有个明显的缺点,每次执行
Calculator
返回的都是一个新的对象,可我只想有一个模块,保持单例。 于是,立即执行函数的作用就体现出来了。由于立即执行函数返回了一个对象,并赋值给了Calculator
,所以保持了对对象的引用。借助于立即执行函数,可以返回同一个对象,这样每次访问
Calculator
拿到的都是同一个对象。 如果一个模块比较大,通常会拆分到不同的文件中维护,这个时候就需要模块支持扩展。利用 JavaScript 向上查找作用域链的特性,把模块传给立即执行函数,在立即执行函数内部读取模块后,进行一些功能扩展,最后返回这个加强后的模块。模块内部最好不与其他部分直接交互,比如在模块内调用全局变量,那么首选将全局变量显示的传入。
甚至还可以将全局变量
window
传进去,用来达到暴露模块的作用。这种方式既保证了模块的独立性,也让模块之间的依赖关系更加清晰。
5. AMD
上面的模块加载方式,已经接近 requirejs 的雏形了,而 requirejs 是遵守 AMD 规范的一种实现。AMD 彻底解决了上述的 script 标签引入模块的顺序问题。
5.1 AMD 语法
AMD 规定了模块必须用特定的
define
函数来定义,一个模块不依赖其他模块则可以直接定义在define
函数中。而加载方式则是这样的:
但是当一个模块还依赖了其他模块,那么在定义的时候就需要指定依赖,这时
define
函数的第一个参数是个数组。在
require
函数加载这个模块之前,会先加载 math.js 和 date.js 两个模块。5.2 AMD 的问题
AMD 虽然解决了模块加载的依赖问题,但也有不足之处。必须在使用前先将所有模块加载,无法做到按需加载,会多浪费不少时间。 后来阿里的玉伯提出了 CMD 的规范,主要实现为 sea.js,使用了按需加载的方式。但在 ES6 module 出现之后,sea.js 就已经宣布不再维护。因此,这里不对 CMD 做详解,具体可以看玉伯在知乎上的回答:AMD 和 CMD 的区别有哪些?
6. CommonJS
2009年,一位叫 Ryan Dahl 的人创造了 node.js 的项目,从此将 JavaScript 编程带到了服务端领域。 而 Commonjs 则是为 JavaScript 服务端编程提供的,它的终极目标是提供一个类似Python,Ruby和Java标准库。
6.1 NPM
npm 的包安装分为本地安装(local)、全局安装(global)两种:
6.1.1 本地安装
6.1.2 全局安装
6.2 CommonJS 语法
在 CommonJS 中有一个
require
方法用来加载模块。而导出模块可以使用
module.exports
或者exports
。exports
和module.exports
的区别是什么呢? 其实exports
只是module.exports
的一份引用,相当于下面这句:因此,无法像
module.exports
那样直接对exports
进行赋值,这样会切断两者之间的关联。关于 CommonJS 这里有一篇深度好文对加载方式进行了详细地讲解:Commonjs规范及Node模块实现
7. UMD
UMD 没有自己的规范,只是集结于各种规范于一身,可以看一下它的具体实现。
这个方法做了三件事:
define
函数来决定是否为 AMD 模块。exports
对象来决定是否为 CommonJS 模块。this
中。8. ES6 module
2015年发布的 ES2015 规范中,终于为我们带来了原生的模块系统,
import
和export
两个关键字实现了模块的导入和导出。8.1 模块导入
使用
import
关键字可以导入模块,使用语法为import [模块名] from '模块地址'
。8.2 模块导出
使用
export
关键字可以导出模块,import
的导入方式也会取决于export
的导出方式。 可以导出多个变量,导入的时候注意导入模块名和导出名保持一致。变量不一定非要在定义的时候导出,也可以将导出统一放到文件底部。
甚至还可以直接导入整个模块:
默认导出
一个模块只允许有一个默认导出,且导入和导出的模块名不需要保持一致。
如果你想混合使用
export
和export default
,这也是可以的,只是在导入的时候也要混合使用两种方式。重命名导入与导出
有时候我们想要导入的模块与现有变量冲突了,所以 ES6 提供了
as
关键字来支持导入和导出的时候对模块进行重命名。 导入时将add
重命名为plus
:导出时将
add
重命名为plus
:8.5 中转模块导出
如果在一个文件夹下有很多模块文件,类似下面这种结构:
你想要在当前的 index.js 文件中引入这些模块,看起来还不是很麻烦。如果还有其他文件也要引入这些模块呢?是不是就看起来非常麻烦?
所以我们可以在 components 文件夹下面增加一个 index.js 文件作为中转模块,这样 components 文件夹就可以看做是一个包。其他文件想要导入 components 下面的模块时可以直接从 components 包中导入。
8.6 ES Module 导出引用
如果你有了解过最新的 Top-level await 规范,你会发现 ES Module 有一个问题。 我们在模块 A 里面定义一个变量 count,将其导出,同时在这个模块中设置 1000ms 之后修改 count 值。
你会觉得这两次输出会有什么不一样吗?这个 count 怎么看都是一个基本类型,难道 2000ms 之后输出还会变化不成? 没错,在 2000ms 后再去打印 count 的确是会变化,你会发现 count 变成了 10,这也意味着 ES Module 导出的时候并不会用快照,而是从引用中来获取值。 而在 CommonJS 中则完全相反,CommonJS 中两次都输出了 0,这意味着 CommonJS 导出的是快照。
8.7 export default 的问题
这里也有一个坑,这也是为什么很多人都不推荐使用
export default
的原因。 我们知道 ES Module 虽然很早就提出了,但浏览器一直不支持,我们常常要配置 webpack 和 babel 来进行一系列转换,最常见的也是转换成 CommonJS 模块。 正常的export
导出倒没多大问题,关键在于export default
,它被转为 CommonJS 的时候会变成这样。看到问题在哪里了吗?我们可能都以为会被转成
module.exports
直接导出,但这里却是在导出的default
变量里面。 这也就意味着,如果我们想导入这个模块,就比如再访问一次default
属性。也就是因为这个问题,很多库都需要做一些特殊处理。比如 React 就提供了这些导出方式。
9. 推荐阅读