yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
262 stars 12 forks source link

深入理解 JavaScript 模块系统 #61

Open yinguangyao opened 3 years ago

yinguangyao commented 3 years ago

1. 什么是模块化?

现代软件开发往往利用模块作合成的单位。 当一个系统足够复杂的时候,需要团队分工协作,把系统划分成若干模块管理,这一过程叫做模块化。

模块化是一种处理复杂系统分解成为更好的可管理模块的方式。它可以通过在不同组件设定不同的功能,把一个问题分解成多个小的独立、互相作用的组件,来处理复杂、大型的软件。

在很多编程语言中都有模块的概念,比如 Python 中使用 pip 进行模块的管理,在 JavaScript 中也使用 bower 和 npm 进行模块管理。

模块化本质上也属于“分治法”的一种。

分治法的精髓: 分--将问题分解为规模更小的子问题; 治--将这些规模更小的子问题逐个击破; 合--将已解决的子问题合并,最终得出“母”问题的解;

模块化在现实中也有体现,图为谷歌 Project Ara 模块化手机:

谷歌.jpeg-183.3kB

2. 模块?组件?插件?

在开始之前,先聊一个老生常谈的问题,模块、组件和插件之间的区别是什么呢? 写过 jQuery 的同学应该知道 jQuery 拥有众多插件,比较知名的有 Swiper、fullPage 等等。 而写过 React / Vue (下文统称为 RV)的同学就会说,RV 中也有 Swiper、fullPage 啊,可这些都被称之为组件。 插件一般是一种遵循一定规范的应用程序接口编写出来的程序,它集成在某个平台,比如 Jenkins 插件、Chrome 插件等等,jQuery 插件也是类似。 平台只提供一些基本的能力,它提供了应用接口来吸引开发者开发更多定制化的功能,这就是插件。 我们常谈起的 jQuery 插件往往是 UI 层面的,所以不论是 jQuery 的插件还是 RV 的组件,在我看来都是一种偏向 UI 层面的模块。组件可以是由 模板( template )、样式( style )、JavaScript 代码( script )三部分组成的。

image_1dnaichu311v01fas1sam1cbq1o6g9.png-23.1kB

组件就像是乐高积木一样,很多小组件组成了一个完整的页面。而模块则是 JavaScript 模块,主要是根据业务内容来划分的,比如一个格式化时间的模块、一个实现加盐算法的模块等等。 因此,一个组件的 script 可以包括多个小模块,比如一个日历组件可能会包含格式化时间、计算日期偏差等模块,而一个大的模块也同样可以包括多个小组件,比如很多 App 都有消息通知的功能,而一个消息通知的模块也可以包括 Badge、Icon 等组件。

借用知乎张云龙大佬的一张图来表示就是:

image_1dn2qmgunjpl1gr8v3q3he1jg212.png-98.2kB

3. script

在我们刚接触前端开发的时候,通常最先接触到的就是在 html 文件的 script 标签里面直接写 JavaScript 代码。 如果依赖了 jQuery 或者 Underscore 等库,还需要在 script 标签中引入对应的文件或者 CDN 链接。 由于浏览器解析 DOM 是从上到下的,所以 script 标签也是按照先后顺序来解析的,这样就必须要保证自己写的代码需要放到依赖库后面才能使用。

// jQuery 一定要在使用之前引入
<script src="./lib/jQuery.js"></script>
<script>
    $(function() {
        $('ul').on('click', li, function() {
        })
    })
</script>

当一个项目复杂到一定程度的时候,也许会依赖很多第三方库,JavaScript 代码也会被拆分成多个文件,依赖性最大的模块需要放到最后。这样就带来了一个问题,如果依赖关系过于复杂,如何保证 script 引入顺序呢?

image_1dn7popmbvnnbt7hlg135s1t1p9.png-60.5kB

4. 模块化的发展

在回答上面这个问题之前,先来简单地了解一下 JavaScript 模块化的发展历程。 最初的模块化非常简单,可以直接使用一个对象当做模块。

const Calculator = {
    count: 0,
    add(num) {
        return this.count + num;
    },
    minus(num) {
        return this.count - num;
    }
}

但是这样导致了一个问题,就是没有私有变量,会直接将内部的属性暴露出去,能够被外界访问到,甚至被修改。倘若我不想让 count 暴露出去怎么办? 聪明的同学肯定马上就想到了,可以在函数内部创建 count 变量,利用闭包的特性,只暴露出想要暴露到外界的。

function Calculator() {
    let count = 0;
    return {
        add() {},
        minus() {},
        getCount() {}
    }
}
const module1 = Calculator();

但是这样也有个明显的缺点,每次执行 Calculator 返回的都是一个新的对象,可我只想有一个模块,保持单例。 于是,立即执行函数的作用就体现出来了。由于立即执行函数返回了一个对象,并赋值给了 Calculator,所以保持了对对象的引用。

let Calculator = (function() {
    let count = 0;
    return {
        add() {},
        minus() {},
        getCount() {}
    }
}())

借助于立即执行函数,可以返回同一个对象,这样每次访问 Calculator 拿到的都是同一个对象。 如果一个模块比较大,通常会拆分到不同的文件中维护,这个时候就需要模块支持扩展。利用 JavaScript 向上查找作用域链的特性,把模块传给立即执行函数,在立即执行函数内部读取模块后,进行一些功能扩展,最后返回这个加强后的模块。

Calculator = (function(mod) {
    // 增加一个 multi 方法
    mod.multi = function() {}
    return mod;
}(Calculator))

模块内部最好不与其他部分直接交互,比如在模块内调用全局变量,那么首选将全局变量显示的传入。

const module1 = (function($, _) {
}(jQuery, lodash))

甚至还可以将全局变量 window 传进去,用来达到暴露模块的作用。

(function(exports) {
    exports.module1 = function() {}
    exports.module2 = function() {}
}(window))

这种方式既保证了模块的独立性,也让模块之间的依赖关系更加清晰。

5. AMD

上面的模块加载方式,已经接近 requirejs 的雏形了,而 requirejs 是遵守 AMD 规范的一种实现。AMD 彻底解决了上述的 script 标签引入模块的顺序问题。

AMD 规范全称是 Asynchronous Module Definition,即异步模块加载机制。

5.1 AMD 语法

AMD 规定了模块必须用特定的 define 函数来定义,一个模块不依赖其他模块则可以直接定义在 define 函数中。

// math.js
define(function() {
    const multi = function() {}
    return {
        multi: multi
    }
})

而加载方式则是这样的:

// index.js
require(['math'], function(math) {
    console.log(math.multi());
})

但是当一个模块还依赖了其他模块,那么在定义的时候就需要指定依赖,这时 define 函数的第一个参数是个数组。

// test.js
define(['math', 'date'], function(math, date) {  
    // ...
})

require 函数加载这个模块之前,会先加载 math.js 和 date.js 两个模块。

为什么说 AMD 是异步模块加载呢? 因为 AMD 在加载模块的时候,不会影响到后面代码的执行,以上面的 test.js 中的代码为例,所有代码都在一个回调函数中。当 math 和 date 两个模块加载完成之后,才会去执行回调函数中的代码,这样就做到了异步加载,不会对浏览器进行阻塞。

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标准库。

和 AMD 不一样的地方是,CommonJS 是同步加载模块的,由于服务端文件都在硬盘中,因此加载速度较快,不用像浏览器一样加载速度完全取决于网速。

6.1 NPM

NPM是随同NodeJS一起安装的包管理工具,能解决NodeJS代码部署上的很多问题。

npm 的包安装分为本地安装(local)、全局安装(global)两种:

npm install express -g // 全局安装
npm install express // 本地安装

6.1.1 本地安装

  1. 把安装包放在 ./node_modules 下(运行 npm 命令时所在的目录),如果没有 node_modules 目录,会在当前执行 npm 命令的目录下生成 node_modules 目录。
  2. 可以通过 require() 来引入本地安装的包。

    6.1.2 全局安装

  3. 将安装包放在 /usr/local 下或者你 node 的安装目录。
  4. 可以直接在命令行里使用。

    6.2 CommonJS 语法

    在 CommonJS 中有一个 require 方法用来加载模块。

    // index.js
    const math = require('math');
    math.multi();

    而导出模块可以使用 module.exports 或者 exports

    // math.js
    const multi = function() {};
    // module.exports
    module.exports = {
    multi: multi
    }
    // 或者 exports
    exports.multi = multi;

    exportsmodule.exports 的区别是什么呢? 其实 exports 只是 module.exports 的一份引用,相当于下面这句:

    const exports = module.exports;

    因此,无法像 module.exports 那样直接对 exports 进行赋值,这样会切断两者之间的关联。

关于 CommonJS 这里有一篇深度好文对加载方式进行了详细地讲解:Commonjs规范及Node模块实现

7. UMD

UMD 叫做通用模块定义规范(Universal Module Definition)。 它可以通过运行时或者编译时让同一个代码模块在使用 CommonJs、CMD 甚至是 AMD 的项目中运行。

UMD 没有自己的规范,只是集结于各种规范于一身,可以看一下它的具体实现。

((root, factory) => {
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        //CommonJS
        var $ = requie('jquery');
        module.exports = factory($);
    } else {
        root.testModule = factory(root.jQuery);
    }
})(this, ($) => {
    // ...
});

这个方法做了三件事:

  1. 判断当前环境中是否有 define 函数来决定是否为 AMD 模块。
  2. 判断当前环境中是否有 exports 对象来决定是否为 CommonJS 模块。
  3. 如果前两者都不是,那么就挂载到传进来的上下文 this 中。

8. ES6 module

2015年发布的 ES2015 规范中,终于为我们带来了原生的模块系统,importexport 两个关键字实现了模块的导入和导出。

8.1 模块导入

使用 import 关键字可以导入模块,使用语法为import [模块名] from '模块地址'

import math from './math.js'

8.2 模块导出

使用 export 关键字可以导出模块,import 的导入方式也会取决于 export 的导出方式。 可以导出多个变量,导入的时候注意导入模块名和导出名保持一致。

// math.js
export const multi = () => {};
export const add = () => {};
// index.js
import { multi, add } from './math.js'

变量不一定非要在定义的时候导出,也可以将导出统一放到文件底部。

// math.js
const multi = () => {};
const add = () => {};
export {
    multi,
    add
}
// index.js
import { multi, add } from './math.js'

甚至还可以直接导入整个模块:

// index.js
import * as math from './math.js';
const add = math.add,
    multi = math.multi;

默认导出

一个模块只允许有一个默认导出,且导入和导出的模块名不需要保持一致。

// math.js
const math = {
    add,
    multi
}
export default math;
// index.js
import Math from './math.js'

如果你想混合使用 exportexport default,这也是可以的,只是在导入的时候也要混合使用两种方式。

// math.js
export const add = () => {}
const multi = () => {}
export default {
    add,
    multi
}
// index.js
import Math, { add } from './math.js'

重命名导入与导出

有时候我们想要导入的模块与现有变量冲突了,所以 ES6 提供了 as 关键字来支持导入和导出的时候对模块进行重命名。 导入时将 add 重命名为 plus

// index.js
import { add as plus } from './math.js'

导出时将 add 重命名为 plus

// math.js
export {
    add as plus,
    multi
}

8.5 中转模块导出

如果在一个文件夹下有很多模块文件,类似下面这种结构:

// 文件目录
+ components
    + Slider.js
    + Card.js
    + Switch.js
    + Button.js
+ index.js

你想要在当前的 index.js 文件中引入这些模块,看起来还不是很麻烦。如果还有其他文件也要引入这些模块呢?是不是就看起来非常麻烦?

// 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 文件作为中转模块,这样 components 文件夹就可以看做是一个。其他文件想要导入 components 下面的模块时可以直接从 components 包中导入。

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

为什么增加一个 index.js 后 components 文件夹就能作为一个包呢?这是因为在读取模块的时候,如果路径是一个文件夹,那么就会优先读取这个文件夹下面的 index.js 文件。

8.6 ES Module 导出引用

如果你有了解过最新的 Top-level await 规范,你会发现 ES Module 有一个问题。 我们在模块 A 里面定义一个变量 count,将其导出,同时在这个模块中设置 1000ms 之后修改 count 值。

// moduleA.js
export let count = 0;
setTimeout(() => {
    count = 10;
}, 1000)
// moduleB.js
import { count } from 'moduleA'
console.log(count);
setTimeout(() => {
    console.log(count);
}, 2000)

你会觉得这两次输出会有什么不一样吗?这个 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 的时候会变成这样。

// 转换前
export default function test() {}
// 转换后
module.exports.default = function test() {}

看到问题在哪里了吗?我们可能都以为会被转成 module.exports 直接导出,但这里却是在导出的 default 变量里面。 这也就意味着,如果我们想导入这个模块,就比如再访问一次 default 属性。

const test = require('test.js').default;

也就是因为这个问题,很多库都需要做一些特殊处理。比如 React 就提供了这些导出方式。

module.exports = react;
module.exports.default = react;

9. 推荐阅读

  1. 前端工程——基础篇
  2. 前端模块化开发的价值
  3. 前端模块化的意义是什么?小项目需要模块化吗?