前言
前端技术发展日新月异,随着Ajax技术的广泛引用,jQuery库的层出不穷,前端代码日益膨胀,javascript的发展领域越来越广泛,就会应用使用模块化编程去规范管理。本文从模块化概念,为什么要模块化,各种模块化方式的优缺点。以及并且都一一通过实例模拟演练,介绍模块化的发展进程,模块化规范,。能让读者更好的理解模块化编程的理念。
模块化基本概念
- 模块是什么?
模块就是独立存储实现特定功能的一组方法的程序结构。其实就是一个实现特定功能的js文件。 - 什么是模块化编程?
模块化编程就是,程序中都是用模块先分类组织方法,再按需引入并使用模块中的方法。其实就是按一定规范将一个大文件拆分成相互依赖的小文件,再进行统一的拼装和加载。 - 为什么要使用模块化编程?
随着前端网站和应用的功能逐渐发展,引入的Javascript代码越来越庞大,就迫切需要按业务逻辑划分和维护程序结构,然后由团队分工协作才能完成。模块化编程就是为了便于按业务逻辑划分程序结构,并且便于团队分工协作。比如一个十万行代码的项目,如果都放在一个文件里,然后一群人围着一个文件办公,你可以想像一下那是个什么情况 - 模块化编程优点
1.避免变量全局污染,命名冲突
2.提高代码复用率
3.提高代码的可维护性
4.多人协作互不干扰
js文件实现模块化编程(原始方式)
其实就是将多个相关的函数,集中定义在一个js文件中。
- 全局function模式 : 将不同的功能封装成不同的全局函数。比如,我们可以创建一个users.js文件,包含登录和注册的方法,然后使用时引入users.js文件,就可以调用js文件中的方法了。
//user.js文件 function signin(){ console.log("登录..."); } function signup(){ console.log("注册..."); }
问题:模块中所有内容都是全局变量和函数,造成全局污染,也极易发生冲突。<!-- 引入user.js文件 --> <script src="users.js"></script> <script> signin(); signup(); </script>
比如我们修改users.js文件,添加getById函数,表示按id查找一个用户。再新建products.js文件,也添加getById函数,表示按id查找一个商品。
//user.js文件 function signin(){ console.log("登录..."); } function signup(){ console.log("注册..."); } function getById(){ console.log("按id查询一个用户...");
// product.js文件 function getById(){ console.log("按id查询一个用户..."); }
<!-- 引入user.js和product.js文件 --> <script src="users.js"></script> <script src="product.js"></script> <script> getById() //结果只会输出一个,后引入的getById()会把之前引入的覆盖掉 </script>
- namespace模式 : 简单对象封装。定义功能时,将一个js文件中一组相关方法,存储在一个对象结构中,,一定程度上减少了全局污染。
使用时,同样引入js文件。
但是,不能直接调用函数,而是通过对象.方法()的方式,调用对象的方法。但这样做也有问题,会将整个对象,暴露在其他程序中,易被篡改。//user.js文件 var users={ count: 10, signin(){ console.log("登录..."); }, signup(){ console.log("注册..."); }, getById(){ console.log("按id查询一个用户..."); }, getCount(){ console.log("在线人数: "+this.count); } }
// product.js文件 var products={ getById(){ console.log("按id查询一个商品..."); } }
<!-- 引入user.js和product.js文件 --> <script src="users.js"></script> <script src="product.js"></script> <script> users.signin(); users.signup(); users.getById(); products.getById();//两个id查找函数都可以调用 users.getCount(); users.count=0;//轻松篡改对象中的属性 users.getCount(); </script>
- IIFE模式:匿名函数自调用(闭包),可以防止对象中的属性被篡改。
使用匿名函数自调,包裹内层函数定义。匿名函数返回生成的包含内层函数的模块对象。这样做的优点是:外部程序,只能获得返回的对象中的函数,而未包含在返回对象中的变量或函数,则被封装在形成的闭包中,不会被篡改。//user.js文件 var users=(function(){ var count=10; function signin(){…}//里面代码和上面一样,省略了 function signup(){…} function getById(){…} function getCount(){ // 不是对象中的方法了,而是普通的函数了,所以不用加this console.log("在线人数: "+count); } return {signin, signup, getCount} })();
<!-- 引入user.js和product.js文件 -->
<script src="users.js"></script>
<script src="product.js"></script>
<script>
users.signin();
users.signup();
users.getCount();
// 尝试修改未抛出的count属性
users.count=0; //访问不到闭包中的count,只是强行给users又添加了一个count属性
users.getCount();//输入为10,内层函数只能访问闭包中保护的变量count,而不会访问对象上强行添加的属性
users.getById();//试图访问未返回的方法,也访问不了!
//只能调用return抛出的,未抛出,就无法访问
</script>
模块化规范
- 虽然前边的三种方法都能一定程度实现模块化,但毕竟都很随意,没有形成标准,这就对不同功能模块之间的兼容性,造成了障碍。 为了消除这种模块定义方式上的不一致带来的障碍,行业内制定了一系列规范,来统一模块的定义方式。
- 这种为了让程序互相之间能顺畅的无缝的加载各种模块,而制定的模块定义和使用的标准,就称为规范。定义模块和使用模块化编程,都必须遵守相应的规范。
- 目前主流的模块化规范有四种:分别是CommonJS规范,AMD规范,CMD规范和ES6规范。接下来,我们就逐个聊聊每种规范的要求,以及使用场景。
1. Commonjs规范
- CommonJS规范是为了便于划分nodejs中各种服务器端功能,比如文件读写,网络通信,HTTP支持等,而专门制定的。
- CommonJS规定:
1.一个单独的js文件就是一个模块。js文件中,module对象,代表当前模块本身。
2.加载模块使用require方法。require方法读取一个js文件并执行,最后返回文件内部的module对象的exports对象属性的内容
3.require方法使用单例模式创建该模块。首次加载后,缓存起来,再次require时,反复使用同一个模块对象,不再重复创建。创建users文件,定义users模块,其中,专门封装用户相关的一组方法。所有方法都封装在一个对象中,最后,将对象整体赋值给module.exports属性。
//user.js文件 module.exports={ signin(){ console.log("登录..."); }, signout(){ console.log("注销..."); }, signup(){ console.log("注册..."); } }
添加use_users.js, 引入users模块,并解构其中的部分方法,为我所用
强调://use_user.js文件 var users=require("./users"); var {signin, signup}=users; signin(); signup();
- 使用require方法引入自定义模块,必须加路径前缀,比如./
- 使用require方法引入系统内置模块,可直接用模块名。
我们可以尝试使用exports别名来定义模块。
强调: 虽然有简写的exports,但只是别名而已,不能完全代替module.exports,如果用’.’添加成员,则等效于module.exports。
如果返回整个替换exports对象,就必须用module.exports属性。
修改: users.js中module.exports为别名exports//user.js文件 exports={ //module.exports={ signin(){ console.log("登录..."); }, signout(){ console.log("注销..."); }, signup(){ console.log("注册..."); } } //运行,结果,报错
//但修改为以下方式运行正确 exports.signin=function(){ console.log("登录..."); }, exports.signout=function(){ console.log("注销..."); }, exports.signup=function(){ console.log("注册..."); } // 结果:正确
问题: Commonjs规范中模块是同步加载:
1.前一个模块加载完,才能加载后续模块,才能执行后续代码。
2.如果运行在服务端,加载本地js文件模块,则不用担心加载速度问题
3.如果运行在客户端,要加载远程服务器上的模块,这样就会造成延迟。因为: script默认是同步加载的。前一个script加载不完,后一个script不能开始。
4.那么如何解决呢? 就是在客户端采用异步加载模块的方式。下面看异步模块加载AMD规范。
2.AMD规范
- AMD,全称:Asynchronous Module Definition,意为,采用异步方式加载模块。也就是说:前面模块的加载不影响它后面模块加载或语句的运行。
- 如果模块之间确实有先后的依赖关系,那么,所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
这样的优点有两个:
1.能异步加载多个js文件,减少网页加载的等待。
2.能设置某两个js文件前后顺序加载,管理模块间的依赖性。 - 如何使用AMD规范
AMD规范不是JavaScript原生支持,需要用到对应的库函数:RequireJS库
第一步:定义子模块,模块必须用特定的define()函数来定义,define()中回调函数的返回值return,决定了模块对外抛出的内容。
第二步:在主js文件中引入子模块:
强调: 无论是define()还是require()中[ ]里的模块js文件路径,都是相对于主js文件的。
最后一步:HTML中,先引入require.js,并引入主js文件。
引入主js文件,有两种方式: 普通引入和异步加载
1.普通引入,是以同步方式加载require.js文件本身
<script src="require.js" data-main="demo_js/main"></script>
2.异步加载,使require.js文件也以异步方式加载
<script src="require.js" defer async="true" data-main="demo_js/main"></script>
新建文件夹demo_js,其中,新建modules文件夹,其中添加demo_js/modules/users.js,封装用户相关业务功能的模块
// 用户模块,包含三个方法
// AMD中define定义模块
define(function(){
alert("加载users.js模块");
function signin(){
console.log("登录...");
}
function signup(){
console.log("注册...");
}
function signout(){
console.log("注销...");
}
//AMD中要求用return抛出
return {signin,signup,signout}
});
添加demo_js/modules/utils.js,封装通用工具功能的库
alert("加载工具库utils.js...");
添加demo_js/modules/products.js,封装商品相关业务功能的模块,依赖于utils模块
define(['modules/utils'], function() {
alert("加载products.js模块...");
function getById(){
console.log("按id查询一个商品...");
}
return { getById }
});
在demo_js文件夹下,modules文件夹平级,添加demo_js/main.js,主js文件,引入其它子模块
// 主模块
alert("加载main.js");
// 定义统一路径,要在require引入之前
require.config({
baseUrl: "demo_js/modules/"
// paths:{
// "user":"module/user",
// "product":"module/product",
// "utils":"module/utils"
// }
})
// 主模块中引入用户模块和商品模块,用require
// function中user获得数组中第一个user模块返回的对象
// product获得数组中第二个product模块返回的对象
require(["users","products"],function(users,products){
// 解构,不想用解构也可以用user.signin()
const {signin,signup,signout} = users;
signin();
signup();
signout();
products.getById();
});
添加amd.html,用script引入require.js文件和主js文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- data-main指定主模块 defer async="true"使require.js文件也以异步方式加载-->
<script src="require.js" defer async="true" data-main="demo_js/main"></script>
</body>
</html>
运行,并观察network中和elements中head元素的变化
发现是按照依赖顺序动态添加的script元素加载的其它模块的js文件
存在的问题
- 1: 引入模块时,都要重复指定路径名
修改主模块main.js,统一定义路径,2种方法解决方法一: 提前配置所有子模块以及更子一级模块的路径,并起别名。
// 主模块demo_js/main.js中添加配置,要在require引入之前 require.config({ paths: { "users":"modules/users", "products":"modules/products", "utils":"modules/utils" } }); // 然后修改所有require()和define()中引入的子模块路径,删除文件夹名,只保留文件名即可 // 运行,发现结果正常。
方法二: 配置基础目录:
// 主模块demo_js/main.js中,要在require引入之前
// paths是相对于主模块的路径,baseUrl是相对于根目录
require.config({
baseUrl:"demo_js/modules/"
});
// 运行,结果正常
- 2: 打开network发现实际上,还是多个script请求,降低了页面加载的效率
解决方法:使用requireJS工具,将多个js,按照依赖关系,打包为一个js文件
从requireJS官网,下载r.js工具文件
在r.js所在文件夹运行node命令, 注意: 不支持Es6语法,要把之前的Es6语法都改回来
-o后,跟参数列表:node r.js –o baseUrl=./demo_js/modules name=../main out=demo_js/main-built.js
- baseUrl=./demo_js/modules 定义包含所有子模块文件夹的
- name=../main 相对于baseUrl,指定main.js主文件的位置
- out=demo_js/main-built.js 定义最终生成的一个js文件所在的路径
结果在demo_js目录下生成了main-built.js
修改amd.html页面中的data-main属性值为demo_js/main-built.js
运行HTML,观察network和elements中head元素的变化
结果network中发现除require.js外,只有一个main-built.js请求
总结:
- AMD的主要思想是,提前执行依赖的模块,再执行后续模块
优点: 如果前一个依赖模块发生错误,后一个模块不再请求,节约带宽 - 缺点: 无法按需加载模块
比如: 如果模块的功能,只有在if判断满足条件时,才被使用。那么,提前执行,会导致即使条件不满足,也必须加载依赖的模块js - 复现AMD的缺点
// 修改demo_js/main.js,使用随机数决定调用或不调用products.getById()方法
if(Math.random()<0.5){
products.getById();
}else{
alert("没用到products模块...");
}
运行: 结果: 即使没用到products模块的功能,也必须提前加载了products模块,在程序中按需加载js,这就要用到下一个标准CMD
3.CMD规范
- CMD 全称: Common Module Definition,像CommenJS和AMD的结合
- 优点: 按需加载模块对象,因为也不是原生,必须用SeaJS库来实现CMD规范。
- 大体步骤和AMD类似: 首先,定义子模块
define(function(require, exports, module) {
require()//用法同CommenJS中的require()
exports别名//用法同CommenJS中的exports别名用法
module对象//用法同CommenJS中的module对象用法
});
//require()用于在需要时引入子模块
- 如果SeaJS配置了base基础路径,则不必每次都加./
点 - 定义主模块,在主模块中,按需加载子模块使用
其中:
seajs.use() vs require()seajs.config({ })//用于配置基础路径 seajs.use(["主模块名",...],function(main,...){ //用于引入主模块并执行 //主模块路径相对于sea.js所在目录 })
- seajs.use() 主要用于载入入口js文件
- require() 用于模块中引入其它子模块
- 最后,在自定义脚本中引入主模块
复制demo_js目录,重名名为demo_js1
修改demo_js1/modules/users.jsdefine(function(require, exports, module) { alert("加载users.js模块..."); function signin(){ console.log("登录..."); } function signout(){ console.log("注销..."); } function signup(){ console.log("注册..."); } module.exports={ signin:signin, signout:signout, signup:signup } });
修改demo_js1/modules/utils.js
define(function(){ alert("加载工具库utils.js..."); });
修改demo_js1/modules/products.js
define(function(require, exports, module) { alert("加载products.js模块..."); require("utils"); function getById(){ console.log("按id查询一个商品..."); } exports.getById=getById; });
修改demo_js1/main.js
define(function(require, exports, module){ alert("加载main.js..."); // 主模块中引入用户模块和商品模块,都有一半的几率,主要是看如果没有引入会不会提前加载 if(Math.random()<0.5){ var users=require("users"); users.signin(); users.signout(); users.signup(); }else{ alert("没用到users模块..."); } if(Math.random()<0.5){ var products=require("products"); products.getById(); }else{ alert("没用到products模块..."); } }); seajs.config({ base:"./demo_js1/modules" }); seajs.use(["./demo_js1/main"],function(main){})
项目根目录:定义cmd.html,引入sea.js和主模块。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script src="sea.js"></script>
<script src="demo_js1/main.js"></script>
</body>
</html>
运行: 结果: 按需执行
但是,观察element和network,依然有问题,只是按需执行,其实,还是在开始时,就加载了全部js文件,没有起到优化的目的
- 解决: 用require.async()代替require()
require.async("子模块",function(模块对象){
... 后续要执行的代码 ...
})
那么,require() vs require.async(),有什么不同?
- 加载方式不同:
1.require() 提前加载完,但暂不执行,等待按需执行
2.require.async() 异步加载,后续代码放在回调函数中按需执行 - 加载阶段不同
1.require() 在代码分析阶段就加载所有模块js,没起到优化带宽的作用
2.require.async() 在执行阶段,真正按需加载,按需执行下面我们在练习中体会一下:
修改demo_js1/main.js,用require.async()代替require()define(function(require, exports, module){ alert("加载main.js..."); if(Math.random()<0.5){ require.async("users",function(users){ users.signin(); users.signout(); users.signup(); }) }else{ alert("没用到users模块..."); } if(Math.random()<0.5){ require.async("products",function(products){ products.getById(); }); }else{ alert("没用到products模块..."); } });
运行,结果一样。但是观察network和element,不再一开始加载所有,而是按需加载。
- 那么,CMD规范的模块如何合并打包呢?
使用的是Grunt工具:专门压缩,合并,打包的工具。但是,使用Grunt有两个前提:
1.如果合并,就不能用require.async,动态引入模块,必须用require
因为合并是将所有js文件合并为一个js文件,要下载一定是整个文件都下载。原来单独的js文件已经找不到了,自然无法有选择的下载。
2.require中的路径不能偷懒: 同目录必须加./, 下级目录中的js必须: 子目录名/模块名 - 如何使用呢?
主要有四步:
1.全局安装grunt-cli
2.添加package.json
3.定义Gruntfile.js
4.调用命令合并,压缩模块代码
东西挺多的,先写到这吧,希望看了之后可以对模块化编程有自己的理解。有问题欢迎交流,个人博客