前端开发中的模块化编程详解


前言

前端技术发展日新月异,随着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语法都改回来
    node r.js –o baseUrl=./demo_js/modules name=../main out=demo_js/main-built.js
    -o后,跟参数列表:
    • 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.config({ })//用于配置基础路径 
    seajs.use(["主模块名",...],function(main,...){
      //用于引入主模块并执行
      //主模块路径相对于sea.js所在目录
    })
    seajs.use() vs require()
  • seajs.use() 主要用于载入入口js文件
  • require() 用于模块中引入其它子模块
  • 最后,在自定义脚本中引入主模块

    复制demo_js目录,重名名为demo_js1
    修改demo_js1/modules/users.js

    define(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.调用命令合并,压缩模块代码

东西挺多的,先写到这吧,希望看了之后可以对模块化编程有自己的理解。有问题欢迎交流,个人博客


文章作者: Love--金哥哥
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Love--金哥哥 !
评论
 上一篇
Js中深克隆与浅克隆的实现原理 Js中深克隆与浅克隆的实现原理
前言我们在项目开发中经常会遇到复制一个对象进行相关业务开发的情况,要求新对象和原来的对象一模一样,而开发时对对象进行操作又不能影响原对象。那么怎么实现呢?本文就带你研究一下克隆对象的实现与原理。 浅克隆概念是很枯燥的,我们用案例解释吧。 v
2020-03-26
下一篇 
Es6 Promise,Es7 async await实战教程 Es6 Promise,Es7 async await实战教程
一个案例让你吃透Es6的promise和Es7的async await
2020-03-20
  目录