03-模块化开发
随着互联网的飞速发展,前端开发越来越复杂。 在早期(2000-2015) ES6 还未成为最新的 JS 语言规范,nodejs 还未真正的普及,复杂的项目中前端遇到了种种问题
1. 命名冲突
常常会将一些通用的、底层的功能抽象出来,独立成一个个函数。统一放在 _.js
里。引入该文件就行,提供便利的工具包
- 想自定义
each()
遍历对象,但页头的_.js
里已经定义了一个,只能叫eachObject()
function each(arr) {
// 实现代码
}
function unique(arr) {
// 实现代码
}
参照 Java 的方式,引入命名空间来解决。于是 _.js
里的代码变为
var org = {};
org.CoolSite = {};
org.CoolSite.Utils = {};
org.CoolSite.Utils.each = function (arr) {
// 实现代码
};
org.CoolSite.Utils.unique= function (str) {
// 实现代码
};
将命名空间的概念在前端中发扬光大,首推 Yahoo!
的 YUI2
项目。下面一段真实代码,来自 Yahoo!
的开源项目
if (org.cometd.Utils.isString(response)) {
return org.cometd.JSON.fromJSON(response);
}
if (org.cometd.Utils.isArray(response)) {
return response;
}
作为前端业界的标杆,YUI 团队下定决心解决这一问题。在 YUI3 项目中,引入了一种新的命名空间机制
YUI().use('_', function (_) {
// _.js 模块已加载好
// 下面可以通过 _ 来调用
var foo = Y.unique([1,2,3,4,5,64,4,4,3]);
});
YUI3 通过沙箱机制,很好的解决了命名空间过长的问题。然而,也带来了新问题
YUI().use('a', 'b', function (_) {
_.each();
// each() 究竟是模块 a.js 还是 b.js 提供的?
// 如果模块 a.js 和 b.js 都提供 each(),如何避免冲突?
});
2. 文件依赖
基于 _.js
,开始开发 UI 层通用组件,这样项目组同事就不用重复造轮子了
- 其中有一个最被大家喜欢的组件是
messageBox.js
,使用方式很简单 - 无论怎么写文档,以及多么郑重地发邮件宣告,时不时总会有同事来询问为什么
messageBox.js
有问题? - 在
messageBox.js
前没有引入_.js
,因此messageBox.js
无法正常工作
<script src="_.js"></script>
<script src="messageBox.js"></script>
<script>
org.CoolSite.messageBox.init({ /* 传入配置 */ });
</script>
当项目越来越复杂,大部分脚本的依赖目前依旧是通过人肉的方式保证,经常会让人抓狂。下面这些问题,我相信每天都在真实地发生着。
- 通用组更新了前端基础类库,却很难推动全站升级
- 业务组想用某个新的通用组件,但发现无法简单通过几行代码搞定
- 一个老产品要上新功能,最后评估只能基于老的类库继续开发
- 公司整合业务,某两个产品线要合并。结果发现前端代码冲突
文件的依赖,目前在绝大部分类库框架里,通过配置的方式来解决。eg:国外的 YUI3 框架、国内的 KISSY 等类库
YUI.add('my-module', function (Y) {
// ...
}, '0.0.1', {
requires: ['node', 'event']
});
通过 requires
等方式来指定当前模块的依赖。这很大程度上可以解决依赖问题,但不够优雅
3. Module设计
历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能。这对开发大型的、复杂的项目形成了巨大障碍
Ruby 的
require
、Python 的import
,甚至就连 CSS 都有@import
Node 内部提供一个 Module
构建函数。所有模块都是 Module
的实例
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
// ...
每个模块内部,都有一个 module
对象,代表当前模块。它有以下属性
module.id
:模块的识别符,通常是带有绝对路径的模块文件名module.filename
:模块的文件名,带有绝对路径module.loaded
:返回一个布尔值,表示模块是否已经完成加载module.parent
:返回一个对象,表示调用该模块的模块module.children
:返回一个数组,表示该模块要用到的其他模块module.exports
:表示模块对外输出的值
4. CommonJS规范
命名冲突和文件依赖,是前端开发过程中的两个经典问题。下来看如何通过模块化开发来解决
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS、AMD、CMD 几种。前者用于服务器,后者用于浏览器
1. 概述
Node 应用由模块组成,早期采用 CommonJS 模块规范
每个文件就是一个模块,有自己的作用域。在一个文件里面定义的 变量、函数、类 都是私有的,对其他文件不可见
// 变量 `x` 和 `add()`,是当前文件 `_.js` 私有的,其他文件不可见
var x = 5;
var add = function (value) {
return value + x;
};
如果想在多个文件分享变量,必须定义为 window 对象的属性,可以被所有文件读取。很容易导致全局命名冲突的问题
window.add = add;
CommonJS 规范规定:每个模块内部,module
变量代表当前模块,是一个对象,exports
属性是对外接口。加载某个模块,其实是加载该模块的 module.exports
属性
var x = 5;
var add = function (value) {
return value + x;
};
module.exports.x = x;
module.exports.add = add;
// `require` 用于加载模块
var _ = require('./_.js');
console.log(_.x); // 5
console.log(_.add(1)); // 6
2. 特点
- 所有代码都运行在模块作用域,不会污染全局作用域
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果被缓存,以后再加载,就直接读取缓存结果。如果让模块再次运行,必须清除缓存
- 模块加载的顺序,按照其在代码中出现的顺序
3. 同步
- CommonJS 规范加载模块是同步的。只有加载完成,才能执行后面的操作。由于 Node.js 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用
- 但是,如果是浏览器环境,要从服务器端加载模块,在浏览器端就会造成阻塞,白屏时间过长,用户体验不够友好。这时就必须采用异步,因此浏览器端一般采用 AMD / CMD 规范
5. AMD规范
(Asynchronous Module Definition)
require.js
是基于 AMD 规范的实现。在浏览器端实现的模块加载器,它解决 CommonJS
规范在浏览器端的不足
define
:从名字就可以看出这个 api 是用来定义一个模块require
:加载依赖模块,并执行加载完后的回调函数
define(function () {
function fun1() {
alert("it works");
}
fun1();
})
加载该模块。注意:require
第一个参数是一个数组,即使只有一个依赖;第二个参数是 callback,一个 function,处理加载完毕后的逻辑
require(["js/a"], function () {
alert("load finished");
})
1. 加载文件
大部分情况下网页需要加载的 JS 可能来自本地服务器、其他网站或 CDN,这样就不能通过这种方式来加载了。eg:加载 jquery 库
require.config({
paths: {
"jquery": ["http://libs.baidu.com/jquery/2.0.3/jquery"]
}
})
require(["jquery", "js/a"], function ($) {
$(function () {
alert("load finished");
})
})
require.config
来配置模块加载的位置,简单说就是给模块起一个更短更好记的名字
- 将百度的 jQuery 库地址标记为
jquery
,require 时只需要写["jquery"]
就可以加载该 js - 本地 js 也可以这样配置
require.config({
paths: {
"jquery": ["http://libs.baidu.com/jquery/2.0.3/jquery"],
"a": "js/a"
}
})
require(["jquery", "a"], function ($) {
$(function () {
alert("load finished");
})
})
2. 执行时机争议
虽然说 require.js
在前端领域应用的还算是广泛,但是 AMD 规范一直没有被 CommonJS
社区老大哥认同,核心争议点
- CommonJS:
var a = require("./a") // 执行到此处时,a.js 才同步下载并执行
- AMD:
define(["_"], function(_) {
// 在这里,模块 _.js 已经下载并执行好
})
AMD 里提前下载 a.js 是浏览器的限制,没办法做到同步下载,这个社区都认可
但执行,AMD 里是前置执行,老大哥是第一次 require 时才执行。这个差异很多人不能接受,这个差异,也导致实质上 Node 的模块与 AMD 模块是无法共享的,存在潜在冲突
3. 模块书写风格争议
AMD 风格下,通过参数传入依赖模块,破坏了 就近声明 原则
define(["a", "b", "c", "d", "e", "f"], function (a, b, c, d, e, f) {
// 等于在最前面声明并初始化了要用到的所有模块
if (false) {
// 即便压根儿没用到某个模块 b,但 b 还是提前执行了
b.foo()
}
})
CommonJS 不认可,最后 AMD 从 CommonJS 社区独立了出去,单独成为了 AMD 社区。 也成为了受到前端开发者喜爱的工具之一
6. CMD规范
(Common Module Definition)
sea.js
是CMD规范的实现,一个成熟的开源项目。核心目标:给前端开发提供简单、极致的模块化开发体验。补全了 require.js
的规范,书写方式上更贴近老大哥 CommonJS
- 使用
sea.js
,在书写文件时,需要遵守 CMD(Common Module Definition)模块定义规范。一个文件就是一个模块
define(function (require, exports) {
exports.each = function (arr) {
// 实现代码
};
exports.unique = function (str) {
// 实现代码
};
});
require
引入模块,获取其他模块提供的接口require('./_.js')
就可以拿到_.js
中通过exports
暴露的接口require
:可以认为是sea.js
给 JavaScript 语言增加的一个 语法关键字
exports
向外提供提供自身模块。这样messageBox.js
的代码变成
define(function (require, exports) {
var _ = require('./_.js');
exports.init = function () {
// 实现代码
};
});
这样,在页面中使用 messageBox.js
将变得非常简单
- 在页面中引入
sea.js
文件,这一般通过页头全局把控,也方便更新维护 - 在页面中使用某个组件时,只要通过
seajs.use()
调用
<script src="sea.js"></script>
<script>
seajs.use('messagebox', function (messagebox) {
messagebox.init(/* 传入配置 */);
});
</script>
exports
暴露接口:意味着不需要命名空间了,更不需要全局变量。彻底解决命名冲突require
引入依赖:让依赖内置,意味着懒加载用户依赖的模块。比require.js
减少无谓的性能损耗
7. ES6模块
(ES6 Modules)主流的模块规范,以上都在被融合、过渡、淘汰
- ES6 模块是 JavaScript 语言标准的一部分,它是在 ECMAScript 2015(ES6)中引入的。语法更加简洁和直观,逐渐成为了 JavaScript 模块系统的主流标准
- 目的:是提供一种统一的、简洁的模块系统,既可以用于浏览器环境,也可以用于服务器端环境
export const add = (a, b) => a + b;
import { add } from './math.js';
console.log(add(2, 3));
1. 特点:
- 静态模块结构。ES6 模块的导入 / 导出,在编译阶段确定,不能在运行时动态改变。使得模块的依赖关系更加清晰,编译器可以更好进行代码优化和错误检查
- 支持多种导入 / 导出方式。还可以使用默认导出(export default)和整体导入(import * as)等方式,方便不同场景下的模块复用