前端模块系统

2019-08-15 09:50:16 浏览数 (1)

伴随着移动互联的大潮,当今越来越多的网站已经从网页模式进化到了 Webapp 模式。它们运行在现代的高级浏览器里,使用 HTML5、 CSS3、 ES6等更新的技术来开发丰富的功能,网页已经不仅仅是完成浏览的基本需求,并且webapp通常是一个单页面应用,每一个视图通过异步的方式加载,这导致页面初始化和使用过程中会加载越来越多的JavaScript 代码,这给前端开发的流程和资源组织带来了巨大的挑战。 前端开发和其他开发工作的主要区别,首先是前端是基于多语言、多层次的编码和组织工作,其次前端产品的交付是基于浏览器,这些资源是通过增量加载的方式运行到浏览器端,如何在开发环境组织好这些碎片化的代码和资源,并且保证他们在浏览器端快速、优雅的加载和更新,就需要一个模块化系统,这个理想中的模块化系统是前端工程师多年来一直探索的难题。 前端模块要在客户端中执行,所以他们需要加载到浏览器中。模块的加载和传输,我们首先能想到两种极端的方式,一种是每个模块文件都单独请求,另一种是把所有模块打包成一个文件然后只请求一次。显而易见,每个模块都发起单独的请求造成了请求次数过多,导致应用启动速度慢;一次请求加载所有模块导致流量浪费、初始化过程慢。这两种方式都不是好的解决方案,它们过于简单粗暴。分块传输,按需进行懒加载,在实际用到某些模块的时候再增量更新,才是较为合理的模块加载方案。

一、script标签

代码语言:javascript复制
<script src="module1.js"></script>
<script src="module2.js"></script>
<script src="module3.js"></script>

这是最原始的 JavaScript 文件加载方式,如果把每一个文件看做是一个模块,那么他们的接口通常是暴露在全局作用域下,也就是定义在 window 对象中,不同模块的接口调用都是一个作用域中,一些复杂的框架,会使用命名空间的概念来组织这些模块的接口,典型的例子如 YUI 库。

缺点

  • 全局作用域下容易造成变量冲突
  • 文件只能按照 <script> 的书写顺序进行加载
  • 开发人员必须主观解决模块和代码库的依赖关系

二、CommonJS

服务器端的 Node.js 遵循 CommonJS规范,该规范的核心思想是允许模块通过 require 方法来同步加载所要依赖的其他模块,然后通过 exports 或 module.exports 来导出需要暴露的接口。CommonJS规范

代码语言:javascript复制
// moduleA.js
module.exports = function( value ){
    return value * 2;
}

// moduleB.js
var multiplyBy2 = require('./moduleA');
var result = multiplyBy2(4);

优点:

  • 解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块在它自身的命名空间中执行

缺点:

  • 同步加载模块,不能非阻塞的并行加载多个模块

实现:

  • 服务器端的 Node.js
  • Browserify

补充,exports与module.exports区别: 为了方便,Node为每个模块提供一个exports变量,指向module.exports。var exports = module.exports;造成的结果是,在对外输出模块接口时,可以向exports对象添加方法。

代码语言:javascript复制
 exports.area = function (r) {
  return Math.PI * r * r;
 };

注意,不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系。

代码语言:javascript复制
exports = function(x) {console.log(x)};

下面的写法也是无效的。

代码语言:javascript复制
exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';

上面代码中,hello函数是无法对外输出的,因为module.exports被重新赋值了。这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用exports输出,只能使用module.exports输出。

代码语言:javascript复制
module.exports = function (x){ console.log(x);};

如果你觉得,exports与module.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports。

三、AMD

AMD 定义了一套 JavaScript 模块依赖异步加载标准,来解决同步加载的问题。https://github.com/amdjs/amdjs-api/wiki/AMD

代码语言:javascript复制
define(id?: String, dependencies?: String[], factory: Function|Object);
  • id 是模块的名字,它是可选的参数
  • dependencies 指定了所要依赖的模块列表,它是一个数组,也是可选的参数,每个依赖的模块的输出将作为参数一次传入 factory 中。如果没有指定dependencies,那么它的默认值是 [“require”, “exports”, “module”]。
代码语言:javascript复制
define(function(require, exports, module) {})
  • factory 是最后一个参数,它包裹了模块的具体实现,它是一个函数或者对象。如果是函数,那么它的返回值就是模块的输出接口或值。
代码语言:javascript复制
define("module", ["dep1", "dep2"], function(d1, d2) {
  return someExportedValue;
});

注意:主模块module是在dep1、dep2加载完成并执行结束后才执行,即使处理函数中没有用到这两个模块,dep1、dep2一旦被依赖引用就会被加载执行!

代码语言:javascript复制
require(["module", "../file"], function(module, file) { /* ... */ });

在模块定义内部引用依赖:

代码语言:javascript复制
define(function(require) {
    var $ = require('jquery');
    $('body').text('hello world');
});

优点

  • 适合在浏览器环境中异步加载模块,可以并行加载多个模块

缺点

  • 提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅,不符合通用的模块化思维方式

实现

  • RequireJS
  • curl

四、CMD

CMD规范和AMD很相似,尽量把持简单,并与 CommonJS 和 Node.js 的 Modules 规范保持了很大的兼容性。遵循按需执行依赖的原则,只有在用到某个模块的时候才会执行模块内部的require语句,同时加载完某个依赖文件后并不立即执行,在所有依赖模块加载完成后进入主模块逻辑,遇到模块运行语句的时候才执行对应的模块https://github.com/seajs/seajs/issues/242

代码语言:javascript复制
define(function(require, exports, module) {
  var $ = require('jquery');
  var Spinning = require('./spinning');
  exports.doSomething = ...
  module.exports = ...
})

优点:

  • 依赖就近,延迟执行
  • 可以很容易在 Node.js 中运行

缺点:

  • 依赖 SPM 打包,模块的加载逻辑偏重

实现:

  • Sea.js
  • coolie

五、ES6模块

EcmaScript6 标准增加了 JavaScript 语言层面的模块体系定义。ES6 模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。

代码语言:javascript复制
import "jquery";
export function doStuff() {}
module "localModule" {}

优点:

  • 容易进行静态分析
  • 面向未来的 EcmaScript 标准

缺点

  • 原生浏览器端还没有实现该标准
  • 全新的命令字,新版的 Node.js才支持

实现:

  • Babel

补充:

Webpack中提出了tree-shaking,其依赖ES6 modules的静态特性得以实现,ES6 modules 的 import 和 export statements相比完全动态的CommonJS、require,有着本质的区别:

  • 只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面。(ECMA-262 15.2)
  • import 的模块名只能是字符串常量。(ECMA-262 15.2.2)
  • 不管 import 的语句出现的位置在哪里,在模块初始化的时候所有的 import 都必须已经导入完成。换句话说,ES6 imports are hoisted。(ECMA-262 15.2.1.16.4 - 8.a)
  • import binding 是 immutable 的,类似 const。比如说你不能 import { a } from ‘./a’ 然后给 a 赋值个其他什么东西。(ECMA-262 15.2.1.16.4 - 12.c.3)

注意:Babel 6的规范选择是使用预设es2015

代码语言:javascript复制
{
    presets: ['es2015'],
}

但是,该预设包含插件transform-es2015-modules-commonjs,这意味着Babel会输出CommonJS模块,而webpack将无法进行tree-shaking。想要的是Babel的es2015,但没有插件transform-es2015-modules-commonjs。目前,唯一的办法就是提到配置数据中的所有预置插件,除了我们要排除的插件。预设的来源是GitHub,所以基本上是复制和粘贴的情况:

代码语言:javascript复制
{
    plugins: [
        'transform-es2015-template-literals',
        'transform-es2015-literals',
        'transform-es2015-function-name',
        'transform-es2015-arrow-functions',
        'transform-es2015-block-scoped-functions',
        'transform-es2015-classes',
        'transform-es2015-object-super',
        'transform-es2015-shorthand-properties',
        'transform-es2015-computed-properties',
        'transform-es2015-for-of',
        'transform-es2015-sticky-regex',
        'transform-es2015-unicode-regex',
        'check-es2015-constants',
        'transform-es2015-spread',
        'transform-es2015-parameters',
        'transform-es2015-destructuring',
        'transform-es2015-block-scoping',
        'transform-es2015-typeof-symbol',
        ['transform-regenerator', { async: false, asyncGenerators: false }],
    ],
}

六、Webpack

Webpack 是一个前端资源模块化管理和打包工具。它将根据模块的依赖关系进行静态分析,然后将这些模块按照指定的规则生成对应的静态资源。还可以将按需加载的模块进行代码分隔,等到实际需要的时候再异步加载。通过 loader 的转换,任何形式的资源都可以视作模块,比如 CommonJs 模块、 AMD 模块、 ES6 模块、CSS、图片、 JSON、Coffeescript、 LESS 等。

优点

  • 将依赖树拆分成按需加载的块
  • 初始化加载的耗时尽量少
  • 各种静态资源都可以视作模块
  • 将第三方库整合成模块的能力
  • 可以自定义打包逻辑的能力
  • 适合大项目,无论是单页还是多页的 Web 应用

0 人点赞