前端模块化之路

前言


  • 随着CPU,内存,以及浏览器内核性能不断提升,前端在软件开发中扮演越来越重要的角色。
  • 前端框架层出不穷,逻辑日益复杂,代码量日益庞大。
  • 前端也需要一种有效组织和管理大型应用系统代码的方式。
  • 前端开始探索自己的模块化开发之路。

什么是模块化


  • 模块化是指解决一个复杂问题时自顶向下逐层把系统分解为更好的可管理模块的方式。
  • 每个模块完成一个特定的子功能,封装细节,提供使用接口,彼此之间可相互依赖,但互不影响
  • 所有的模块可以按某种方法组装起来,成为一个整体,完成整个系统所要求的功能。
  • 模块化使代码耦合度降低,最大化的设计重用,以最少的模块,更快速的满足更多的个性化需要。

科学的模块化肯定要具备以下几点:

  • 分解成模块,每个模块实现特定子功能。
  • 模块封装具体细节,提供使用接口。
  • 模块间互不影响。
  • 模块间依赖明确,可连接。

模块化的好处

  • 避免变量名冲突(全局变量污染)
  • 更好的分离代码,可按需加载,提升性能
  • 可维护性更高
  • 可复用性更高

前端模块化探索


  • ES6出现之前,JS先天是不具备模块化能力的。
  • 我们需要基于JS的原生土壤(ObjectFunctionCursor…)去抽象和封装。
  • JS是面向函数编程的。若无任何封装,基本遍地的全局函数。最突出的问题便是全局命名空间污染,命名冲突。

那么,加一个命名空间好了。

命名空间模式-对象封装

我们把模块封装成一个对象,这个对象(保证命名唯一性)就相当于一个命名空间。

比如,我们用 Object 来封装一个我们常用的日期处理函数库:

/**************定义模块**********/
var MyDateUtils = {
  defaultFmt:'yyyy-MM-dd',
  // 格式化日期格式
  dateFormat: function(date, _fmt) {
    var fmt = _fmt || this.defaultFmt;
    var o = {
      'M+': date.getMonth() + 1, // 月份
      'd+': date.getDate(), // 日
      'h+': date.getHours(), // 小时
      'm+': date.getMinutes(), // 分
      's+': date.getSeconds(), // 秒
      'q+': Math.floor((date.getMonth() + 3) / 3), // 季度
      S: date.getMilliseconds(), // 毫秒
    };
    if (/(y+)/.test(fmt)) { fmt = fmt.replace(RegExp.$1, (`${date.getFullYear()}`).substr(4 - RegExp.$1.length)); }
    for (var k in o) {
      if (new RegExp(`(${k})`).test(fmt)) { fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : ((`00${o[k]}`).substr((`${o[k]}`).length))); }
    }
    return fmt;
  },
  // 获取当前日期
  getDateNow(fmt) {
    return this.dateFormat(new Date(), fmt);
  },
  //其他API略...
}
  • 优点:模块增加了命名空间,大大降低了命名冲突的情况。
  • 缺点:外部可以随意修改对象内部成员。这种不可控的状态非常要命,属于很大的安全漏洞。
// A同学正常使用
MyDateUtils.getDateNow(); 
// B同学修改内部成员为自己场景常用的格式,就会影响使用默认格式的A同学
MyDateUtils.defaultFmt = 'MM-dd'; 

模块化满足情况:

  • 分解成模块,每个模块实现特定子功能。 ✅ 对象即模块
  • 模块封装具体细节,提供使用接口。 ❌ 全暴露,可读可写
  • 模块间互不影响。 ❌ 可随意篡改其他模块
  • 模块间依赖明确,可连接。 ❌ how?

函数封装

对象封装明显不靠谱,还是得靠JS的一等公民:Function。我们知道,JS函数有自己独立的作用域。

还是上面的日期函数库,我们尝试使用Function来封装,最大程度符合模块化特性可能是这样:

/**************定义模块**********/
var MyDateUtils = function (defaultFmt){
  this.defaultFmt = defaultFmt || 'yyyy-MM-dd';
};
MyDateUtils.prototype.dateFormat = function(date, fmt) {
  // 略...(同上)
};
MyDateUtils.prototype.getDateNow = function(fmt) {
  return this.dateFormat(new Date(), fmt);
}

/**************使用模块**********/
var newDateUtils = new MyDateUtils();
var today = newDateUtils.getDateNow();

也可以使用ES6-class语法糖,这么定义:

/**************定义模块**********/
class MyDateUtils = {
  constructor(defaultFmt){
    this.defaultFmt = defaultFmt || 'yyyy-MM-dd';
  };

  dateFormat(date, fmt) {
    // 略...(同上)
  };

  getDateNow (fmt) {
    return this.dateFormat(new Date(), fmt);
  }
}

/**************使用模块**********/
var newDateUtils = new MyDateUtils();
var today = newDateUtils.getDateNow();

再来看看模块化满足情况:

  • 分解成模块,每个模块实现特定子功能。 ✅ 函数对象即模块
  • 模块封装具体细节,提供使用接口。 ❌ 所有方法可访问,其实模块中往往存在很多子方法是不需要外部访问的,也毫无意义
  • 模块间互不影响。 ✅ 所有方法定义在 prototype 属性上才能防止其他模块复写。
  • 模块间依赖明确,可连接。 ❌ no way!

如何才能只暴露部分接口,还能和外界取得联系?

闭包

  • 闭包 就是能够读取其他函数内部变量的函数。
  • 由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成”定义在一个函数内部的函数”。
  • 所以在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

使用闭包-IIFE(立即执行函数)

我们使用IIFE来封装前面的模块:

/**************定义模块**********/
var MyDateUtils = (function (){
  let defaultFmt = 'yyyy-MM-dd';

  function dateFormat(date, _fmt) {
    // 略...(同上)
  }
  // 获取当前日期
  function getDateNow(fmt) {
    return this.dateFormat(new Date(), fmt);
  }

  return { 
    getDateNow: getDateNow;
  }
})();

或者(流行方式):

/**************定义模块**********/
;(function (){
  let defaultFmt = 'yyyy-MM-dd';

  function dateFormat(date, _fmt) {
    // 略...(同上)
  }
  // 获取当前日期
  function getDateNow(fmt) {
    return this.dateFormat(new Date(), fmt);
  }

  window.MyDateUtils = { 
    getDateNow: getDateNow;
  }
})();
/**************使用模块**********/
MyDateUtils.getDateNow(); // 可以访问
MyDateUtils.dateFormat(); // 不可以访问

再来看看模块化满足情况:

  • 分解成模块,每个模块实现特定子功能。 ✅
  • 模块封装具体细节,提供使用接口。 ✅
  • 模块间互不影响。 ✅
  • 模块间依赖明确,可连接。 ❌

问题:那么一个模块依赖另一个模块怎么办?

办法:把依赖的模块作为立即执行函数的参数即可。

/**************定义模块**********/
;(function (_A,_B){

  // 略...(同上) 这里便可以使用 ModuleA 和 ModuleB 暴露的方法

  window.MyDateUtils = { 
    getDateNow: getDateNow;
  }
})(ModuleA,ModuleB);

上面模块化方案也是后来一系列模块化框架和规范的基石。

模块化规范


前面都是原生的模块化探索。在此基础上,衍生了一系列模块化的库和规范:

  • commonJS:服务端nodeJS引入,同步加载。
  • requireJS:遵循 AMD 规范
  • seaJS: 遵循 CMD 规范
  • ES6 模块化

CommonJs

1)介绍

  • 由服务端 nodeJS 引入并发扬光大。
  • 同步加载,适用于服务端。
  • 客户端(浏览器端)JS一般是异步加载,不适用。
  • 模块加载的顺序,按照其在代码中出现的顺序。
  • 服务器端Node,模块的加载是运行时同步加载的;浏览器端Node,模块需要提前编译打包处理。

2)语法

  • 定义模块:一个文件就是一个模块,拥有自己独立的作用域。
  • 暴露模块:module.exports = valueexports.xxx = value
  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径。require 返回该模块的 exports对象。
const webpack = require('webpack')
const path = require('path')

module.exports = env => {
  let config = { };
  //...
  return config;
}

RequireJS(AMD)

1) 介绍

  • AMDAsynchronous Module Definition,异步模块定义的意思。它是一个在浏览器端模块化开发的规范。
  • 浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。
  • JS原生不支持AMD规范,需要对应的工具库来做这件事。requireJS诞生,用于客户端的模块管理。

requireJS 主要解决两个问题:

  • 多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
  • js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长

2)语法

  • 定义模块:define(id?, [dependencies]?, factory);
    • id: 可选,用来定义模块的标识,一般使用默认的脚本文件名(去掉拓展名);
    • dependencies: 可选,如果有依赖,列出依赖的模块数组;
    • factory:工厂方法 如果是对象 则表示模块的返回。
  • 暴露模块:通过在上面的 factory 工厂方法中 return value
  • 引入模块:require([dependencies], function(){});
//定义没有依赖的模块
define(function(){

   return { ...模块对象/方法 }
})

define({ ...模块对象/方法 }) //模块是对象,直接暴露整个模块对象写法。
//定义有依赖的模块
define(['module1', 'module2'], function(m1, m2){
   return { ...模块对象/方法 }
})
//引入模块
require(['module1', 'module2'], function(m1, m2){
   // 使用m1/m2
})

require()函数在加载依赖的函数的时候是异步加载的,这样浏览器不会失去响应,它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。

SeaJs(CMD)

1) 介绍

  • CMDCommon Module Definition,通用模块定义的意思。它是一个在国内发展起来的模块化开发规范。
  • seaJSCMD 规范在浏览器端的实现。
  • seaJS 要解决的问题和requireJS一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同。

2)语法

  • 定义模块:define(function(require, exports, module) {...})。只有一个工厂方法。
  • 暴露模块:使用上面的工厂方法的exports 对象 exports.doSomething = ...
  • 引入模块:使用上面的工厂方法的require 对象。还提供了异步引入的方法require.async.

定义模块时其实也可以指定id和依赖,不常用,不推荐:define(id?, dependencies?, function(require, exports, module) {...})

// 定义模块moduleA
define(function(require, exports, module) {
  function fn1 () {
    //...
  }
  function fn2 () {
    //...
  }
  // 暴露模块内容
  export.fn1 = fn1;
});
// 引入模块(同步)
define(function(require, exports, module) {
   //引入依赖模块(同步)
  var moduleA = require('./moduleA');
  moduleA.fn1();
});

// 引入模块(异步)
define(function(require, exports, module) {
  require.async('./moduleA', function (mA) {
    mA.fn1()
  })
});

AMD与CMD区别

注意,AMDCMD加载模块都是异步的。他们最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机。

  • AMD依赖前置,js可以方便知道依赖模块是谁,立即加载,加载模块完成后就会执行该模块,所有模块都加载执行完后会进入require的回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。

  • CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块(这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略)。CMD加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。

两者各有优劣,很多人说 AMD用户体验好,因为没有延迟,依赖模块提前执行了,CMD性能好,因为只有用户需要的时候才执行的原因。

UMD

UMD 叫做通用模块定义规范(Universal Module Definition)。随着大前端的趋势所诞生,它可以通过运行时或者编译时让同一个代码模块在使用 CommonJsCMD 甚至是 AMD 的项目中运行。未来同一个 JavaScript 包运行在浏览器端、服务区端甚至是 APP 端都只需要遵守同一个写法就行了。

它没有自己专有的规范,是集结了 CommonJsCMDAMD 的规范于一身,大体实现如下:

((root, factory) => {
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        //CommonJS
        var $ = requie('jquery');
        module.exports = factory($);
    } else {
        root.testModule = factory(root.jQuery);
    }
})(this, ($) => {
    //todo
});

ESM (ECMA Script Modules)

1) 介绍

前面讲到,JS 原生是不支持模块化的,必须借助其他抽象的工具库。ES6 的出现彻底改变了这种局面,对 JS 模块化方面进行了补充:

  • export: 用来暴露模块的 API,可以有多个输出。export default命令,为模块指定默认输出,其他模块加载该模块时,可以为该匿名函数指定任意名字。
  • import: 用于引入其他模块提供的功能。

导出示例:

//导出多个
const obj1 = { ... }
const obj2 = { ... }
export { obj1, obj2 }
//默认导出
export default { str: "abc" }

导入示例:

// 默认导出可以随意命名
import str1 , { obj1 , obj2 } from "A.js";
// 导入多个api,命名需和导出一致
import { obj1 , obj2 } from "A.js";
// 可设置别名
import { obj1 as obj3 , obj2 } from "A.js";

ESM 运行机制与 CommonJS 不一样。CommonJS 模块输出的是一个值的拷贝;ESM 模块输出的是静态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。ESM在编译时就能确定模块的依赖关系,以及输入和输出的变量,Tree Shaking 就是基于 ESM 来实现的。

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

2)使用

现在,基本上所有的主流浏览器版本都已经支持 ESM。但是浏览器对 ES6 语法的兼容还不够全面,我们需要使用 Babel 编译成浏览器都识别的 ES5 代码来使用。在实际项目中,我们通过打包构建工具( 如 webpack,rollup) 来统一处理。

参考文献


前端模块化详解
前端模块化
SeaJs快速API
简单实现一个RequireJS
Node.js require()源码解读
SystemJs探秘
差点被SystemJs惊掉了下巴,解密模块加载黑魔法