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>
 
 



当项目越来越复杂,大部分脚本的依赖目前依旧是通过人肉的方式保证,经常会让人抓狂。下面这些问题,我相信每天都在真实地发生着。

  1. 通用组更新了前端基础类库,却很难推动全站升级
  2. 业务组想用某个新的通用组件,但发现无法简单通过几行代码搞定
  3. 一个老产品要上新功能,最后评估只能基于老的类库继续开发
  4. 公司整合业务,某两个产品线要合并。结果发现前端代码冲突

文件的依赖,目前在绝大部分类库框架里,通过配置的方式来解决。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 将变得非常简单

  1. 在页面中引入 sea.js 文件,这一般通过页头全局把控,也方便更新维护
  2. 在页面中使用某个组件时,只要通过 seajs.use() 调用
<script src="sea.js"></script>
<script>
  seajs.use('messagebox', function (messagebox) {
    messagebox.init(/* 传入配置 */);
  });
</script>
 

 



  1. exports 暴露接口:意味着不需要命名空间了,更不需要全局变量。彻底解决命名冲突
  2. 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)等方式,方便不同场景下的模块复用