模块化

🌽 模块化:把复杂的系统分解到多个模块以方便编码

模块化编程中,开发者将程序分解成离散功能块(discrete chunks of functionality),并称之为模块

  • 将一个复杂的程序依据一定的规则(规范)封装成几个块(文件),并进行组合在一起
  • 块的内部数据相对而言是私有的,只是向外部暴露一些接口与外部其他模块通信

每个模块具有比完整程序更小的接触面,使得校验、调试、测试轻而易举。 精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。

模块化趋势

痛点

过去代码组织方式,会出现的问题:

  • 命名空间冲突
  • 无法合理地管理项目依赖和版本
  • 无法方便控制依赖的加载顺序
  • 项目体积变大后难以维护

优势

实现模块化能实现的优势:

  • 方便代码维护
  • 提高代码复用性
  • 降低代码耦合度(解耦)
  • 分治思想

模块化进化史

全局模式

module1.js

// 数据
let data1 = 'module one data';
// 操作数据的函数
function foo() {
console.log(`foo() ${data1}`);
}
function bar() {
console.log(`bar() ${data1}`);
}

module2.js

let data2 = 'module two data';
function foo() {
//与模块1中的函数冲突了
console.log(`foo() ${data2}`);
}

test.html

<!-- 同步引入,若函数冲突,则后面覆盖前面 -->
<script type="text/javascript" src="module1.js"></script>
<script type="text/javascript" src="module2.js"></script>
<script type="text/javascript">
foo(); // foo() module two data
bar(); // bar() module one data
</script>

说明:

  • 全局模式:将不同的功能封装成不同的全局函数
  • 问题:全局变量被污染了,很容易引起命名冲突

单例模式

module1.js

let moduleOne = {
data: 'module one data',
foo() {
console.log(`foo() ${this.data}`);
},
bar() {
console.log(`bar() ${this.data}`);
},
};

module2.js

let moduleTwo = {
data: 'module two data',
foo() {
console.log(`foo() ${this.data}`);
},
bar() {
console.log(`bar() ${this.data}`);
},
};

test.html

<script type="text/javascript" src="module1.js"></script>
<script type="text/javascript" src="module2.js"></script>
<script type="text/javascript">
moduleOne.foo(); //foo() module one data
moduleOne.bar(); //bar() module one data
moduleTwo.foo(); //foo() module two data
moduleTwo.bar(); //bar() module two data
moduleOne.data = 'update data'; //能直接修改模块内部的数据
moduleOne.foo(); //foo() update data
</script>

说明:

  • 单例模式模式:简单对象封装
  • 作用:减少了全局变量(如两个模块的 data 都不是全局变量了,而是对象的某一个属性)
  • 问题:不安全,可以直接修改模块内部的数据

IIFE 模式

module1.js

(function(window) {
// 数据
let data = 'IIFE module data';
//操作数据的函数
function foo() {
// 用于暴露的函数
console.log(`foo() ${data}`);
}
function bar() {
// 用于暴露的函数
console.log(`bar() ${data}`);
otherFun(); //内部调用
}
function otherFun() {
// 内部私有的函数
console.log('privateFunction go otherFun()');
}
// 暴露 foo 函数和 bar 函数
window.module = { foo, bar };
})(window);

test.html

<script type="text/javascript" src="module1.js"></script>
<script type="text/javascript">
module.foo(); // foo() IIFE module data
module.bar(); // bar() IIFE module data privateFunction go otherFun()
// module.otherFun() //报错,module.otherFun is not a function
console.log(module.data); // undefined 因为我暴露的 module 对象中无 data
module.data = 'xxxx'; // 不是修改的模块内部的 data,而是在 module 新增 data 属性
module.foo(); // 验证内部的 data 没有改变 还是会输出 foo() IIFE module data
</script>

说明:

  • IIFE 模式:匿名函数自调用(闭包)
  • IIFE:Immediately-Invoked Function Expression(立即调用函数表达式)
  • 作用:数据是私有的,外部只能通过暴露的方法操作
  • 问题:如果当前这个模块依赖另一个模块怎么办?见下面 IIFE 增强版的(模块依赖于 jQuery)

IIFE 模式增强

引入 jQuery 到项目中

module1.js

(function(window, $) {
//数据
let data = 'IIFE Strong module data';
//操作数据的函数
function foo() {
//用于暴露的函数
console.log(`foo() ${data}`);
$('body').css('background', 'red');
}
function bar() {
//用于暴露的函数
console.log(`bar() ${data}`);
otherFun(); //内部调用
}
function otherFun() {
//内部私有的函数
console.log('privateFunction go otherFun()');
}
//暴露foo函数和bar函数
window.moduleOne = { foo, bar };
})(window, jQuery);

test.html

<!--引入的js必须有一定顺序-->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="module1.js"></script>
<script type="text/javascript">
moduleOne.foo(); //foo() IIFE Strong module data 而且页面背景会变色
</script>

说明:

  • IIFE 模式增强:引入依赖
  • 这就是现代模块实现的基石。其实很像了,有引入和暴露两个方面。
  • 存在的问题:一个页面需要引入多个 JS 文件的问题
<script type="text/javascript" src="module1.js"></script>
<script type="text/javascript" src="module2.js"></script>
<script type="text/javascript" src="module3.js"></script>
<script type="text/javascript" src="module4.js"></script>
  • 请求过多:一个 <script> 标签就是一次请求
  • 依赖模糊:看不出来谁依赖着谁?哪些模块是有依赖关系的,很难看出来
  • 难以维护:内部依赖关系混乱也就难以维护啦

模块化方案

CommonJS

CommonJS 是服务器端模块的规范,Node.js 就是采用了这个规范。但目前也可用于浏览器端,需要使用 Browserify 进行提前编译打包。

CommonJS 模块化的引入方式使用 require,暴露的方式使用 module.exportsexports

CommonJS

特点

  • 同步加载依赖的模块
  • 可复用于 Node 环境
  • 成熟的第三方模块社区

彻底说明白 module.exportsexports 的区别:

在 Node.js 中,module 是一个全局变量,类似于在浏览器端的 Window 也是一个全局变量一样的道理。

module.exports 初始的时候置为空对象,exports 也指向这个空对象。

内部代码实现:

var module = {
id: 'xxxx',
exports: {},
};
var exports = module.exports;
// exports 是对 module.exports 的引用
// 也就是 exports 现在指向的内存地址和 module.exports 指向的内存地址是一样的

上面的代码可以看出我们平常使用的 exports 是对 module.exports 的一个引用,两者都是指向同一个对象。

用一句话来说明就是,模块的 require(引入)能看到的只有 module.exports 这个对象,它是看不到 exports 对象的,而我们在编写模块时用到的 exports 对象实际上只是对 module.exports 的引用。

exports = module.exports;

我们可以使用 exports.a = ‘xxx’exports.b = function(){} 添加方法或属性,本质上它也添加在 module.exports 所指向的对象身上。

但是你不能直接 exports = { a: 'xxx'},这就将 exports 重新指向新的对象,它和 module.exports 就不是指向同一个对象,也就让两者失去了关系,而 Node.js 中 require 能看到的是 module.exports 指向的对象。

因此,我们一般都会直接使用:

module.exports;

再举例说明两者区别:

function foo() {
console.log('foo');
}
function bar() {
console.log('bar');
}

想要将这两个函数暴露出去,可以直接使用exports

exports.foo = foo;
exports.bar = bar;

也可以对 module.exports 赋值

module.exports = {
foo: foo,
bar: bar,
};

但是不能直接对 exports 赋值

// 错误
exports = {
foo: foo,
bar: bar,
};

因为这样做仅仅改变了exports 的引用,而不改变 module.exports

总结

特点:同步加载,有缓存

用法:关键在于引入和暴露

  • 引入模块
    • require(url)url 为路径参数)
    • 路径:自定义模块路径必须以 ./ 或者 ../ 开头
    • 第三方模块/内置模块/核心模块:路径直接使用模块名称
  • 暴露模块
    • exports
    • module.exports

主要是在服务器端使用的,但是也能在浏览器端运行,需要借助 Browserify 进行编译。

AMD

CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。由于 NodeJS 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,所以同步加载没有问题。但是如果是浏览器端,同步加载很容易阻塞,这时候 AMD 规范就出来了。AMD 规范则是非同步加载模块,允许指定回调函数。故浏览器端一般会使用 AMD 规范。

AMDrequire.js 在推广过程中对模块定义的规范化产出 。

AMD

特点:

  • 异步加载依赖的模块
  • 可在不转换代码的情况下直接在浏览器运行
  • 并行加载多个模块
  • 可运行在浏览器和 Node 环境

用法:

  • 暴露模块
    • 在模块内部使用 return
  • 定义模块
    • define(['模块名'], function (模块暴露内容) {})
    • require(['模块名'], function (模块暴露内容) {})
    • 在模块内部可以使用 require 定义异步模块
  • 主模块:
    • requirejs.config({}) 配置使用的模块路径
    • requirejs(['模块名'], function (模块暴露内容) {})
  • HTML 文件引入 <script> 标签
    • <script data-main='app.js' src='require.js'></script>

AMD(通用模块定义)主要是在浏览器使用的。

CMD

CMD 是根据 CommonJS 和 AMD 基础上提出的。

CMD(通用模块定义)和 AMD(异步模块定)是比较相似的。

require.js 遵循的是 AMD(异步模块定义)规范,sea.js 遵循的是 CMD (通用模块定义)规范。

CMD

特点:

  • 异步加载,有缓存

用法:

  • 定义模块
    • define(function (require, exports, module) {})
  • 引入模块
    • 同步加载 require()
    • 异步加载 require.async(['模块名'], function(模块暴露内容) {})
  • 暴露模块
    • exports
    • module.exports
  • HTML 文件引入 <script>标签
    • <script src='sea.js'></script>
    • <script>seajs.use('app.js')</script>

sea.jsrequire.js 一样主要在浏览器中使用。其实这两个一般都很少使用。用的比较多的是 commonjs 和马上要介绍的 ES6 模块化。

ES6 Module

特点:

  • 动态引入(按需加载),没有缓存

    用法:

  • 引入模块使用 import

    • 统一暴露:import {模块暴露的内容} from '模块路径'
    • 分别暴露:import * as m1 from './module1'
      • 这两者暴露的本质是对象,接收的时候只能以对象的解构赋值的方式来接收值
    • 默认暴露:直接使用 import 模块暴露的内容 from '模块路径'。默认暴露,暴露任意数据类型,暴露什么数据类型,接收什么数据类型。
  • 暴露模块使用 export

    • 分别暴露 (基本不用)
    • 统一暴露 (暴露多个内容)
    • 默认暴露 (暴露单个内容)

主要是用在浏览器,服务器端也使用。但是现在浏览器和服务器均不支持 ES6 的模块化语法,所以要借助工具来编译运行

  • Babel 将 ES6+ 转换为 ES5- (ES6 的模块化语法 编译成 commonjs
  • Browserify 将 CommonJS 语法编译成能让浏览器识别的语法

严格模式

ES6 的模块自动采用严格模式,不管你是否有在模块头部加上 'use strict'

严格模式主要有以下限制:

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用 with 语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀 0 表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量 delete prop,会报错,只能删除属性 delete global[prop]
  • eval 不会在它的外层作用域引入变量
  • evalarguments 不能被重新赋值
  • arguments 不会自动反映函数参数的变化
  • 不能使用 arguments.callee
  • 不能使用 arguments.caller
  • 禁止 this 指向全局对象
  • 不能使用 fn.callerfn.arguments 获取函数调用的堆栈
  • 增加了保留字(比如 protectedstaticinterface

其中,尤其需要注意 this 的限制。ES6 模块之中,顶层的 this 指向 undefined,即不应该在顶层代码使用 this

模块化与组合化

既然说到模块化,其实我更想说说模块化与组件化。这两个概念在前端领域已经十分普遍。

先有模块化后有组件化。组件化是建立在模块化思想上的一次演进,一个变种。所以,我们会在软件工程体系中看过一句话:模块化是组件化的基石

组件化和模块化的思想都是 分而治之 的思想。但还是有细小的区分,他们的侧重点有所不同。

组件化更加倾向于 UI 层面上,是一个可以独立展示内容的「积木」,比如一个页面的头部组件,包含结构 HTML、样式 CSS、逻辑 JS、以及静态资源图片组合一个集合体。一个页面是由众多组件组成的,就像由众多「积木」搭成的「城堡」一样; 模块化更加倾向于功能或者数据的封装,一般由几个组件或单个组件构成的带有一定功能的集合体;

引用一下 @张云龙 对组件化的理解:

组件化示意图1

就如上图的这个 title 组件,包含了结构 HTML、样式 CSS、逻辑 JavaScript、以及静态资源图片,往往组件的组成就是以上四个方面。这个 header 文件夹我们可以拿到其他项目中使用,它具有可以独立展示内容的特点。

结合前面提到的模块化开发,整个前端项目可以划分为这么几种开发概念:

名称说明举例
JS 模块独立算法和数据单元浏览器环境检测(detect)、网络请求(ajax)、应用配置(config)、DOM 操作(dom)、工具函数(utils)以及组件里的 JS 单元
CSS 模块独立的功能性样式单元栅格系统(grid)、字体图标(icon-fonts)、动画样式(animate)以及组件里的 CSS 单元
页面前端这种 GUI 软件的的界面状态,是 UI 组件的容器首页(index)、列表页(list)、用户管理(user)
应用整个项目或整个站点被称之为应用,由多个页面组成

那么它们之间的关系如下图所示,一个应用由多个下图的页面组成。一个页面由多个组件组合。组件中可依赖 JS 模块。

所以,前端开发现在不仅仅只是别人说的「画画页面实现点效果」的职位,它是实现软件的图形用户界面(Graphical User Interface,简称 GUI),是一名软件工程师。现在前端开发都是基于模块化和组件化的开发,可以说算是工程化的项目了。从单页面(SPA)的应用就可以看出 JavaScript 大大改善了 Web 应用的用户体验。从谷歌提出 PWA(Progressive Web Apps)就可以看出前端在领域的成长。

不仅仅如此,多终端也已经成为时下以及未来的一个必然趋势,移动端、PC 端、触摸屏、智能设备、物联网等等,相信前端在跨端的领域下肯定会有更好的解决方案。

但是,如果从整个软件工程来看,我们就会意识到一个惨痛的事实:前端工程师在整个系统工程中的地位太低了。前端是处于系统软件的上游(用户入口),因此没有其他系统会来调取前端系统的服务。而后端它在软件开发中处于下游,后端一方面要为前端提供接口服务,一方面要向中后台以及数据层索取服务,对接层次会更多,地位也就更高了。由此导致,感觉每次需求评估前端往往是最后一道坎,因为上游依托下游,就只能是下游先行了,整体上就会感觉前端对业务的参与度太低了。


参考资料: