原文链接:软件架构之前后端分离与前端模块化发展史
传统的模块化
随着 Ajax 的流行,前端工程师能做的事情就不只是“切图” 这么简单,现在前端工程师能做的越来越多,开始出现了明确的分工,并且能够与服务端工程师进行数据联调。这里说的传统模块化还不是后现代的模块化,早期的模块化是不借助任何工具的,纯属由 JavaScript 完成代码的结构化。在传统的模块化中我们主要是将一些能够复用的代码抽成公共方法,以便统一维护和管理,比如下面代码。
function show(id) {
document.getElementById(id).setAttribute('style', "display: block")
}
function hide(id) {
document.getElementById(id).setAttribute('style', "display: none")
}
然后,我们将这些工具函数封装到一个 JS 脚本文件里,在需要使用它们的地方进行引入。
但是,这种做法会衍生出两个很大的问题,一个是全局变量的污染,另一个是人工维护模块之间的依赖关系会造成代码的混乱。
例如,当我们的项目有十几个甚至几十个人维护的时候,难免会有人在公用组件中添加新的方法,比如 show 这个方法一旦被覆盖了,使用它的人会得到和预期不同的结果,这样就造成的全局变量的污染。另一个问题,因为真实项目中的公用脚本之间的依赖关系是比较复杂的,比如 c 脚本依赖 b 脚本,a 脚本依赖 b 脚本,那么我们在引入的时候就要注意必须要这样引入。
<script scr="c.js"></script>
<script scr="b.js"></script>
<script scr="a.js"></script>
要这样引入才能保证 a 脚本的正常运行,否则就会报错。对于这类问题,我们该如何解决这样的问题呢?
全局变量的污染
解决这个问题有两种,先说说治标不治本的方法,我们通过团队规范开发文档,比如说我有个方法,是在购物车模块中使用的,可以如下书写。
var shop.cart.utils = {
show: function(id) {
document.getElementById(id).setAttribute('style', "display: block")
},
hide: function(id) {
document.getElementById(id).setAttribute('style', "display: none")
}
}
这样就能比较有效的避开全局变量的污染,把方法写到对象里,再通过对象去调用。专业术语上这叫命名空间的规范,但是这样模块多了变量名会比较累赘,一写就是一长串,所以我叫它治标不治本。
还有一种比较专业的方法技术通过立即执行函数完成闭包封装,为了解决封装内变量的问题,立即执行函数是个很好的办法,这也是早期很多开发正在使用的方式,如下所示。
(function() {
var Cart = Cart || {};
function show (id) {
document.getElementById(id).setAttribute('style', "display: block")
}
function hide (id) {
document.getElementById(id).setAttribute('style', "display: none")
}
Cart.Util = {
show: show,
hide: hide
}
})();
上述代码,通过一个立即执行函数,给予了模块的独立作用域,同时通过全局变量配置了我们的模块,达到了模块化的目的。
当前的模块化方案
先来说说 CommonJS 规范,在 Node.JS 发布之后,CommonJS 模块化规范就被用在了项目开发中,它有几个概念给大家解释一下。
- 每个文件都是一个模块,它都有属于自己的作用域,内部定义的变量、函数都是私有的,对外是不可见的;
- 每个模块内部的 module 变量代表当前模块,这个变量是一个对象;
module 的 exports 属性是对外的接口,加载某个模块其实就是在加载模块的 module.exports 属性; - 使用 require 关键字加载对应的模块,require 的基本功能就是读入并执行一个 JavaScript 文件,然后返回改模块的 exports 对象,如果没有的话会报错的;
下面来看一下示例,我们就将上面提到过的代码通过 CommonJS 模块化。
module.exports = {
show: function (id) {
document.getElementById(id).setAttribute('style', "display: block")
},
hide: function (id) {
document.getElementById(id).setAttribute('style', "display: none")
}
}
// 也可以输出单个方法
module.exports.show = function (id) {
document.getElementById(id).setAttribute('style', "display: block")
}
// 引入的方式
var utils = require('./utils')
// 使用它
utils.show("body")
除了 CommonJS 规范外,还有几个现在只能在老项目里才能看到的模块化模式,比如以 require.js 为代表的 AMD(Asynchronous Module Definition) 规范 和 玉伯团队写的 sea.js 为代表的 CMD(Common Module Definition) 规范。
AMD 的特点:是一步加载模块,但是前提是一开始就要将所有的依赖项加载完全。CMD 的特点是:依赖延迟,在需要的时候才去加载。
AMD
首先,我们来看一下如何通过 AMD 规范的 require.js 书写上述模块化代码。
define(['home'], function(){
function show(id) {
document.getElementById(id).setAttribute('style', "display: block")
}
function hide(id) {
document.getElementById(id).setAttribute('style', "display: none")
}
return {
show: show,
hide: hide
};
});
// 加载模块
require(['utils'], function (cart){
cart.show('body');
});
require.js 定义了一个函数 define,它是全局变量,用来定义模块,它的语法规范如下:
define(id, dependencies, factory)
- id:它是可选参数,用于标识模块;
- dependencies:当前模块所依赖的模块名称数组,如上述模块依赖 home 模块,这就解决了之前说的模块之间依赖关系换乱的问题,通过这个参数可以将前置依赖模块加载进来;
- factory:模块初始化要执行的函数或对象。
require([dependencies], function(){})
然后,在其他文件中使用 require 进行引入,第一个参数为需要依赖的模块数组,第二个参数为一个回调函数,当前面的依赖模块被加载成功之后,回调函数会被执行,加载进来的模块将会以参数的形式传入函数内,以便进行其他操作。
CMD
sea.js 和 require.js 解决的问题其实是一样的,只是运行的机制不同,遵循的是就近依赖,来看下使用CMD方式实现的模块化代码。
define(function(require, exports, module) {
function show(id) {
document.getElementById(id).setAttribute('style', "display: block")
}
exports.show = show
});
<script type="text/javascript" src="sea.js"></script>
<script type="text/javascript">
// 引入模块通过seajs.use,然后可以在回调函数内使用上面模块导出的方法
seajs.use('./utils.js',function (show) {
show('#box');
});
</script>
首先是引入 sea.js 库,定义和导出模块分别是 define() 和 exports,可以在定义模块的时候通过 require 参数手动引入需要依赖的模块,使用模块通过 seajs.use。
ES6
ES6 提出了最新的模块化方案,并且引入了类的机制,让 JavaScript 从早期的表单验证脚本语言摇身一变成了一个面向对象的语言了。ES6 的模块化使用的是 import/export 关键字来实现导入和导出,并且自动采用的是严格模式(use strict),考虑到都是运行在模块之中,所以 ES6 实际上把整个语言都升到了严格模式。
在 ES6 中每一个模块即是一个文件,在文件中定义变量、函数、对象在外部是无法获取的。如果想要获取模块内的内容,就必须使用 export 关键字来对其进行暴露。我们把之前的公用脚本用 ES6 的形式再重构一遍。
// utils.js
const show = () => {
document.getElementById(id).setAttribute('style', 'display: block');
}
const hide = () => {
document.getElementById(id).setAttribute('style', 'display: none');
}
export {
show,
hide
}
// 或者直接抛出方法
export const show = (id) => {
document.getElementById(id).setAttribute('style', 'display: block');
}
export const hide = (id) => {
document.getElementById(id).setAttribute('style', 'display: none');
}
// 外部引入模块
import { show, hide } from './utils'
可以发现,ES6 的写法更加清晰。具备了面向对象和面向函数的特征,可读性更强。