再谈JavaScript模块化

35次阅读

共计 12896 个字符,预计需要花费 33 分钟才能阅读完成。

简述

缘起

模块通常是指编程语言所提供的代码组织机制,利用此机制可将程序拆解为独立且通用的代码单元。

模块化主要是解决代码分割、作用域隔离、模块之间的依赖管理以及发布到生产环境时的自动化打包与处理等多个方面。

javascript 应用日益复杂,模块化已经成为一个迫切需求。但是作为一个模块化方案,它至少要解决如下问题:

  • 可维护性
  • 避免作用域污染
  • 代码重用
  • 依赖管理

script

最原始的方式就是,每个文件就是一个模块,然后使用 script 的方式进行引入。

但是此方式有以下问题:

  • 全局作用域污染
  • 无依赖管理,执行顺序依赖 script 标签的顺序,如果采用异步加载,那会乱套,先下载的先执行

IIFE 和 模块对象

为了解决作用域污染的问题,就产生了立即执行函数 + 模块对象模式:

// app1.js
var app = {};
// app2.js
(function(){app.a = function(a, b) {// code}  
})();
// app3.js
(function(app){var temp = [ 1, 2];
  var a = app.a(temp)
})(app);

具体的可以查阅阮一峰老师的博客 Javascript 模块化编程(一):模块的写法

在 ES6 之前,js 没有块级作用域,所以采用此方式建立一个函数作用域。但是在 ES6 之后,可以使用块级作用域。

由于使用了 IIFE,所以减少了全局作用域污染,但并不是彻底消除,因为还定义了一个 appa 模块对象呢。

所以这也仅仅只是减少了作用域污染,还是会有其他缺点。

CommonJS

后来,有人试图将 javascript 引入服务端,由于服务端编程相对比较复杂,就急需一种模块化的方案,所以就诞生了 commonjs,有 require + module.exports 实现模块的加载和导出。

CommonJS 采用同步的方式加载模块,主要使用场景为服务端编程。因为服务器一般都是本地加载,速度较快。

AMD 和 CMD

后来,随着前端业务的日渐复杂,浏览器端也需要模块化,但是 commonjs 是同步加载的,这意味着加载模块时,浏览器会冻结,什么都干不了,这在浏览器肯定是不行的,这就诞生了 AMD 和 CMD 规范,分别以 requirejs 和 seajs 为代表。

这两货都采用异步方式加载模块。

AMD

AMD(Asynchronous Module Defination)异步模块加载机制。

define([module_id,] // 模块名字,如果缺省则为匿名模块
    [dependenciesArray,] // 模块依赖 
    definition function | object // 模块内容,可以为函数或者对象
);

CMD

CMD(Common Module Defination)通用模块加载机制

// 方式一
define(function(require, exports, module) { 
    // 模块代码
    var a = require('a')
});
// 方式二
define('module', ['module1', 'module2'], function(require, exports, module){// 模块代码} );

区别

  • 对于依赖的模块,AMD 是提前执行(RequireJS 从 2.0 开始,也改成可以延迟执行),CMD 是延迟执行
  • CMD 推崇依赖就近,AMD 推崇依赖前置
  • AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一

不完美

尽管以上方案解决了上面说的问题,但是也带来了一些新问题:

  • 语法冗余,所有东西都要封装在 define 中
  • AMD 中的依赖列表必须与函数的参数列表匹配,易错且修改苦难

Browserify

由于上述这些原因,有些人想在浏览器使用 CommonJS 规范,但 CommonJS 语法主要是针对服务端且是同步的,所以就产生了 Browserify,它是一个 模块打包器(module bundler),可以打包 commonjs 规范的模块到浏览器中使用。

UMD

UMD(Universal Module Definition) 统一模块定义。

AMD 与 CommonJS 虽然师出同源,但还是分道扬镳,关注于代码异步加载与最小化入口模块的开发者将目光投注于 AMD;而随着 Node.js 以及 Browserify 的流行,越来越多的开发者也接受了 CommonJS 规范。令人扼腕叹息的是,符合 AMD 规范的模块并不能直接运行于 CommonJS 模块规范的环境中,符合 CommonJS 规范的模块也不能由 AMD 进行异步加载。

而且有这么多种规范,如果我们要发布一个模块供其他人用,我们不可能为每种规范发布一个版本,就算你蛋疼这样做了,别人使用的时候还得下载对应版本,所以现在需要一种方案来兼容这些规范。

实现的方式就是在代码前面做下判断,根据不同的规范使用对应的加载方式。

// 以 vue 为例
(function (global, factory) {typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = global || self, global.Vue = factory());
}(this, function () {// vue code ...})

由于目前 ES6 浏览器支持还不够好,所以很多第三方库都采用了这种方式。

ESModule

ES6 引入了 ESModule 规范,主要通过 export + import 来实现,最终一统江湖。可是现实很骨感,一些浏览器并不支持(IE,说的就是你),所以还不能直接在浏览器中直接使用。

常用的两种方案

  • 在线编译:requirejs/seajs/sytemjs

在页面上加载一个 AMD/CMD 模块格式解释器。这样浏览器就认识了 define, exports,module 这些东西,也就实现了模块化。

SystemJS 是一个通用的模块加载器,它能在浏览器或者 NodeJS 上动态加载模块,并且支持 CommonJS、AMD、全局模块对象和 ES6 模块。通过使用插件,它不仅可以加载 JavaScript,还可以加载 CoffeeScript 和 TypeScript。配合 jspm 也是不错的搭配。

  • 预编译:browserify/webpack

相比于第一种方案,这个方案更加智能。由于是预编译的,不需要在浏览器中加载解释器。你在本地直接写 JS,不管是 AMD/CMD/ES6 风格的模块化,它都能认识,并且编译成浏览器认识的 JS。

注意:browerify 只支持 Commonjs 模块,如需兼容 AMD 模块,则需要 plugin 转换

CommonJS

前身为 ServerJS。
我们可以理解为代码会被如下内建辅助函数包裹:

(function (exports, require, module, __filename, __dirname) {
    // ...
    // Your code
    // ...
});

加载

通过 require 加载模块。

const a = require('a')

导出

通过 exportsmodule.exports进行模块导出。

  • exportsexportsmodule.exports 的一个引用,一个模块可以使用多次,但是不能直接对 exports 重新赋值,只能通过如下方式使用
exports.a = function(){// code...}
  • module.exports:一个模块只能使用一次
module.exports = function(){// code...}

特点

  • 同步加载,定位于服务端,不适合浏览器

requirejs

特点

  • 支持同步和异步两种方式
  • 大多数第三方库都有兼容 AMD,即时不兼容也可以通过配置使其可用
  • 异步加载,不阻塞浏览器
  • 依赖前置,可以很清楚看到当前模块的依赖

引入

在引入 requirejs 的 script 标签上添加 data-main 属性定义入口文件, 该文件会在 requirejs 加载完后立即执行。

如果 baseUrl 未单独配置,则默认为引入 require 的文件的路径。

<script src="./assets/lib/requirejs/require.js" data-main="./assets/lib/requirejs/config"></script>

config

requirejs.config({
    // 为模块加上 query 参数,解决浏览器缓存,只在开发环境使用
    urlArgs: 'yn-course=' + (new Date()).getTime(),
    // 配置所有模块加载的初始路径,所有模块都是基于此路径加载
    baseUrl: './', 
    // 映射一些快捷路径,相当于别名
    paths: {
        '~': 'assets',
        '@': 'components',
        'vue': 'assets/lib/vue/vue',
        'vueRouter': 'assets/lib/vue-router/vue-router',
        "jquery" : ["http://libs.baidu.com/jquery/2.0.3/jquery"]
    },
    //  对于匹配的模块前缀,使用一个不同的模块 ID 来加载该模块
    map: {
        'layer': {'jquery': 'http://libs.baidu.com/jquery/2.0.3/jquery'}
    },
    // 从 CommonJS 包 (package) 中加载模块
    packages:{},
    // 加载上下文
    context:{},
    // 超时,默认 7S
    waitSeconds: 7,
    // 定义应用依赖的模块,在启动后会加载此数组中的模块
    deps: [],
    // 在 deps 加载完毕后执行的函数
    callback:function(){},
    // 用来加载非 AMD 规范的模块,以浏览器全局变量注入,此处仅作映射,需要在需要时手动载入
    shim: { 
        // 'backbone': {//     deps: ['underscore', 'jquery'], // 模块依赖
        //     exports: 'Backbone' // 导出的名称
        // }
    },
    // 全局配置信息,可在模块中通过 module.config()访问
    config:{color:'red'},
    // 如果设置为 true,则当一个脚本不是通过 define()定义且不具备可供检查的 shim 导出字串值时,就会抛出错误
    enforceDefine:false,
    // 如果设置为 true,则使用 document.createElementNS()去创建 script 元素
    xhtm: false,
    // 指定 RequireJS 将 script 标签插入 document 时所用的 type="" 值
    scriptType:'text/javascript'
});

默认 requirejs 会根据 baseUrl+paths 配置去查找模块,但是如下情况例外:

  • 路径以.js 结尾,比如 lib/hello.js、hello.js
  • 以“/”开始
  • 包含 url 协议:如 ”http:”、”https”

设置 baseURl 的方式有如下三种:

  • requirejs.config 指定;
  • 如指 data-main, 则 baseUrl 为 data-main 所对应的 js 的目录
  • 如果上述均未指定,则 baseUrl 为运行 RequireJS 的 HTML 文件所在目录

map 配置对于大型项目很重要:如有两类模块需要使用不同版本的 ”foo”,但它们之间仍需要一定的协同。
在那些基于上下文的多版本实现中很难做到这一点。而且,paths 配置仅用于为模块 ID 设置 root paths,而不是为了将一个模块 ID 映射到另一个。

requirejs.config({
    map: {
        'some/newmodule': {'foo': 'foo1.2'},
        'some/oldmodule': {'foo': 'foo1.0'}
    }
});

define

通过 define 来定义模块,推荐依赖前置原则,当然也可以使用 require 动态按需加载。

define([module_id,] // 模块名字,如果缺省则为匿名模块
    [dependencies,] // 模块依赖 
    definition function | object // 模块内容,可以为函数或者对象
);
// 如果仅仅返回一个键值对,可以采用如下格式,类似 JSONP
define({
    color: "black",
    size: "unisize"
})
// 如果没有依赖
define(function () {
    return {
        color: "black",
        size: "unisize"
    }
})
// 有依赖
define(["./a", "./b"], function(a, b) {})
// 具名模块
define("name",
    ["c", "d"],
    function(cart, inventory) {// 此处定义 foo/title object}   
)

如要在 define()内部使用诸如 require("./a/b") 相对路径,记得将 ”require” 本身作为一个依赖注入到模块中:

define(["require", "./a/b"], function(require) {var mod = require("./a/b");
});

或者使用如下方式:

define(function(require) {var mod = require("./a/b");
})

标识

require 加载的所有模块都是单例的,每个模块都有一个唯一的标识,这个标识是模块的名字或者模块的相对路径(如匿名模块)。

模块的唯一性与它们的访问路径无关,即使是地址完全相同的一份 JS 文件,如果引用的方式与模块的配置方式不一致,依旧会产生多个模块。

// User.js
define([], function() {
    return {
        username : 'yiifaa',
        age       : 20
    };
});
require(['user/User'], function(user) {
    //  修改了 User 模块的内容
    user.username = 'yiifee';
    //  em/User 以 baseUrl 定义的模块进行访问
    //  'user/User' 以 path 定义的模块进行访问
    require(['em/User', 'user/User'], function(u1, u2) {
        //  输出的结果完全不相同,u1 为 yiifaa,u2 为修改后的内容 yiifee
        console.log(u1, u2);
    })
})

依赖

requirejs 推荐依赖前置,在 define 或者 require 模块的时候,可以将需要依赖的模块作为第一个参数,以数组的方式声明,然后在回调函数中,依赖会以参数的形式注入到该函数上,参数列表需要和依赖数组中位置一一对应。

define(["./a", "./b"], function(a, b) {})

导出

在 requirejs 中,有 3 中方式进行模块导出:

  • 通过 return 方式导出,优先级最高(推荐);
define(function(require, exports, module) {
    return {a : 'a'}
});
  • 通关 module.exports 对象赋值导出,优先级次之;
define(function(require, exports, module) {
    module.exports = {a : 'a'}
});
  • 通过 exports 对象赋值导出,优先级最低;
define(function(require, exports, module) {exports.a = 'a'});

require

requirejs 提供了两个全局变量 requirerequirejs 供我们加载模块,这二者是完全等价的。

// 此处 require 和 define 函数仅仅是一个参数(模块标识)的差异,// 一般 require 用于没有返回的模块,如应用顶层模块
require([dependencies,] // 模块依赖 
    definition function // 模块内容
);

require 是内置模块,不用在配置中定义,直接进行引用即可。

define(['require'], function(require) {var $ = require('jquery');
})

requirejs 支持异步(require([module]))和同步(require(module))两种方式加载,即 require 参数为数组即为异步加载,反之为同步。

同步加载

在 requirejs 中,执行同步加载必须满足两点要求:

  • 必须在定义模块时使用,亦即 define 函数中;
  • 引用的资源必须是之前异步加载过的(不必在同一个模块),换句话说,同步载入的模块是不会发网络请求的,只会调取之前缓存的模块;
  • define(function(require, exports, module) {})中可以同步加载模块

应用场景

  • 明确知道模块的先后顺序,确认此模块已经被加载过,例如系统通用模块,在载入完成后,之后的模块就可以进行同步的引用,或者在 Vue 等前端技术框架中,在应用模块同步加载 vue 模块。
  • 引用的资源列表太长,懒得回调函数中写一一对应的相关参数
//  假定这里引用的资源有数十个,回调函数的参数必定非常多
define(['jquery'], function() {return function(el) {
        //  这就是传说中的同步调用
        var $ = require('jquery');
        $(el).html('Hello, World!');
    }
})
  • 可以减少命名或者命名空间冲突,例如 prototype 与 jquery 的冲突问题
define(['jquery', 'prototype'], function() {var export = {};
    export.jquery = function(el) {
        //  这就是传说中的同步调用
        var $ = require('jquery');
        $(el).html('Hello, World!');
    }

    export.proto = function(el) {
        //  这就是传说中的同步调用
        var $ = require('prototype');
        $(el).html('Hello, World!');
    }
    return export;
})
  • 处女座专用,代码显得更整洁漂亮了,硬是把回调函数写出了同步的感觉

异步加载

  • define([],function()):依赖数组中的模块会异步加载,所有模块加载完成后混执行回调函数
  • require([]):传入数组格式即表示需要异步加载
require === requirejs //=> true

require.toUrl("./a.css"): 获取模块 url

模块卸载

只要页面不刷新,被 requirejs 加载的模块只会执行一次,后面会一直缓存在内存中,即时重新引入模块也不会再进行初始化。
我们可以通过 undef 卸载已加载的模块。

require.undef("moduleName") // moduleName 是模块标识

其他

插件

  • css:加载 css
  • text:加载 HTML 及其他文本
  • domReady

模块加载错误

  • Module name has not been loaded yet for context: _

此错误表示执行时模块还未加载成功,一般为异步加载所致,改成同步加载即可。

借助类解决模块间的相互干扰

// C 模块
define([],function(){
 
 // 定义一个类
 function DemoClass()
 {
    var count = 0;
    this.say = function(){
        count++;
        return count;
    };
 }
 
 return function(){
    // 每次都返回一个新对象
    return new DemoClass();};
 
});
 
// A 模块
require(['C'],
  function(module) {cosole.log(module().say());//1
});
 
// B 模块
require(['C'],
  function(module) {cosole.log(module().say());//1
});

文档:官方文档,

[中文版](https://blog.csdn.net/wangzhanzheng/article/details/79050033)

seajs

Sea.js 追求简单、自然的代码书写和组织方式,具有以下核心特性:

  • 简单友好的模块定义规范:Sea.js 遵循 CMD 规范,可以像 Node.js 一般书写模块代码。
  • 自然直观的代码组织方式:依赖的自动加载、配置的简洁清晰,可以让我们更多地享受编码的乐趣。

通过 exports + require 实现模块的加载与导出。

引入

<script src="assets/lib/seajs/sea.js"></script>
<script src="assets/lib/seajs/seajs.config.js"></script>
<script type="text/javascript">
    seajs.use('app');
</script>

config

//seajs 配置
seajs.config({

    //1. 顶级标识始终相对 base 基础路径解析。//2. 绝对路径和根路径始终相对当前页面解析。//3.require 和 require.async 中的相对路径相对当前模块路径来解析。//4.seajs.use 中的相对路径始终相对当前页面来解析。// Sea.js 的基础路径  在解析顶级标识时,会相对 base 路径来解析   base 的默认值为 sea.js 的访问路径的父级
    base: './',

    // 路径配置  当目录比较深,或需要跨目录调用模块时,可以使用 paths 来简化书写
    paths: {
        gallery: "https://a.alipayobjects.com/gallery"
        /*
            var underscore = require('gallery/underscore');
            //=> 加载的是 https://a.alipayobjects.com/gallery/underscore.js
         */
    },

    // 别名配置  当模块标识很长时,可以使用 alias 来简化(相当于 base 设置的目录为基础)//Sea.js 在解析模块标识时,除非在路径中有问号(?)或最后一个字符是井号(#),否则都会自动添加 JS 扩展名(.js)。如果不想自动添加扩展名,可以在路径末尾加上井号(#)。alias: {
        'seajs-css': '~/lib/seajs/plugins/seajs-css',
        'seajs-text': '~/lib/seajs/plugins/seajs-text',
        '$': '~/lib/zepto/zepto'
    },

    // 变量配置  有些场景下,模块路径在运行时才能确定,这时可以使用 vars 变量来配置
    vars: {
        //locale: "zh-cn"
        /*
            var lang = require('./i18n/{locale}.js');
            //=> 加载的是 path/to/i18n/zh-cn.js
         */
    },

    // 映射配置  该配置可对模块路径进行映射修改,可用于路径转换、在线调试等
    map: [//[".js", "-debug.js"]
        /*
            var a = require('./a');
            //=> 加载的是 ./js/a-debug.js
        */
    ],

    // 预加载项  在普通模块加载前,提前加载并初始化好指定模块  preload 中的配置,需要等到 use 时才加载
    preload: ['seajs-css','seajs-text'],

    // 调试模式  值为 true 时,加载器不会删除动态插入的 script 标签。插件也可以根据 debug 配置,来决策 log 等信息的输出
    debug: true,

    // 文件编码  获取模块文件时,<script> 或 <link> 标签的 charset 属性。默认是 utf-8   还可以是一个函数
    charset: 'utf-8'
});

模块标识

  • 相对标识: 相对标识以 . 开头,只出现在模块环境中(define 的 factory 方法里面)。相对标识永远相对当前模块的 URI 来解析。
  • 顶级标识:顶级标识不以点(.)或斜线(/)开始,会相对模块系统的基础路径(即 Sea.js 的 base 路径)来解析。
  • 普通路径:除了相对和顶级标识之外的标识都是普通路径。普通路径的解析规则,和 HTML 代码中的 <script src=”…”></script> 一样,会相对当前页面解析

use

用来在页面中加载一个或多个模块。seajs.use 理论上只用于加载启动,不应该出现在 define 中的模块代码里。在模块代码里需要异步加载其他模块时,推荐使用 require.async 方法。

// 加载一个模块
seajs.use('./a');

// 加载一个模块,在加载完成时,执行回调
seajs.use('./a', function(a) {a.doSomething();
});

// 加载多个模块,在加载完成时,执行回调
seajs.use(['./a', './b'], function(a, b) {a.doSomething();
  b.doSomething();});

define

// 方式一
define(function(require, exports, module) { 
    // 模块代码
    var a = require('a')
});
// 方式二,此方法严格来说不属于 CMD 规范
define('module', ['module1', 'module2'], function(require, exports, module){// 模块代码});
// 如果模块内容仅是对象或者字符串
define({"foo": "bar"});
define('I am a template. My name is {{name}}.');

require

require 是一个方法,接受 模块标识作为唯一参数,用来获取其他模块提供的接口。

同步执行

此方式,require 的参数值 必须 是字符串直接量。

var a = require('./a');

异步回调执行

require.async 方法用来在模块内部异步加载模块,并在加载完成后执行指定回调。callback 参数可选。
此时,参数值可以是动态的,以实现动态加载。

define(function(require, exports, module) {

  // 异步加载一个模块,在加载完成时,执行回调
  require.async('./b', function(b) {b.doSomething();
  });

  // 异步加载多个模块,在加载完成时,执行回调
  require.async(['./c', './d'], function(c, d) {c.doSomething();
    d.doSomething();});

});

require.resolve 使用模块系统内部的路径解析机制来解析并返回模块绝对路径。

define(function(require, exports) {console.log(require.resolve('./b'));
  // ==> http://example.com/path/to/b.js

});

exports

exports 是一个对象,用来向外提供模块接口,也可以使用 return 或者 module.exports 来进行导出

define(function(require, exports) {

  // 对外提供 foo 属性
  exports.foo = 'bar';
  // return 
  return {
    foo: 'bar',
    doSomething: function() {}
  };
  // module.exports
  module.exports = {
    foo: 'bar',
    doSomething: function() {}
  };
});

module

module 是一个对象,上面存储了与当前模块相关联的一些属性和方法。

  • module.id 模块的唯一标识
  • module.uri 根据模块系统的路径解析规则得到的模块绝对路径
  • module.dependencies 表示当前模块的依赖
  • module.exports 当前模块对外提供的接口

其他

插件

  • seajs-css
  • seajs-preload
  • seajs-text
  • seajs-style
  • seajs-combo
  • seajs-flush
  • seajs-debug
  • seajs-log
  • seajs-health

文档:官方文档

ESModule

简介

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

严格模式

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";

严格模式主要有以下限制:

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用 with 语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀 0 表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量 delete prop,会报错,只能删除属性 delete global[prop]
  • eval 不会在它的外层作用域引入变量
  • eval 和 arguments 不能被重新赋值
  • arguments 不会自动反映函数参数的变化
  • 不能使用 arguments.callee
  • 不能使用 arguments.caller
  • 禁止 this 指向全局对象
  • 不能使用 fn.caller 和 fn.arguments 获取函数调用的堆栈
  • 增加了保留字(比如 protected、static 和 interface)

export 命令

定义模块的对外接口。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。
以下是几种用法:

//------ 输出变量 ------
export var firstName = 'Michael';
export var lastName = 'Jackson';
// 等价于
var firstName = 'Michael';
export {firstName}; // 推荐,能清除知道输出了哪些变量
//------ 输出函数或类 ------
export function multiply(x, y) {return x * y;};
//------ 输出并 as 重命名 ------
var v1 = 'Michael';
function v2() { ...}
export {
  v1 as streamV1,
  v2 as streamV2
};
//------ 输出 default------
export default function () { ...}

注意:export default在一个模块中只能有一个。

import 命令

使用 export 命令定义了模块的对外接口以后,其他 JS 文件就可以通过 import 命令加载这个模块。
以下是几种用法,必须和上面的 export 对应:

//------ 加载变量、函数或类 ------
import {firstName, lastName} from './profile.js';
//------ 加载并 as 重命名 ------
import {lastName as surname} from './profile.js';
//------ 加载有 default 输出的模块 ------
import v1 from './profile.js';
//------ 执行所加载的模块 ------
import 'lodash';
//------ 加载模块所有输出 ------
import  * as surname from './profile.js';

复合写法

如果在一个模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。

export {foo, bar} from 'my_module';

// 等同于
import {foo, bar} from 'my_module';
export {foo, bar};

不完美

  • 只能出现在模块顶层,不能在其他语句中
  • 无法动态加载,其实这点主要是为了保证静态分析,所有的模块都要在解析阶段确定它的依赖

正文完
 0