随着前端项目的愈来愈庞大,组件化的前端框架,前端路由等技术的发展,模块化已经成为现代前端工程师的一项必备技能。不管是什么语言一旦发展到必定地步,其工程化能力和可维护性势必获得相应的发展。javascript
模块化这件事,不管在哪一个编程领域都是至关常见的事情,模块化存在的意义就是为了增长可复用性,以尽量少的代码是实现个性化的需求。同为前端三剑客之一的 CSS 早在 2.1 的版本就提出了 @import
来实现模块化,可是 JavaScript 直到 ES6 才出现官方的模块化方案: ES Module (import
、export
)。尽管早期 JavaScript 语言规范上不支持模块化,但这并无阻止 JavaScript 的发展,官方没有模块化标准开发者们就开始本身建立规范,本身实现规范。html
十年前的前端没有像如今这么火热,模块化也只是使用闭包简单的实现一个命名空间。2009 年对 JavaScript 无疑是重要的一年,新的 JavaScript 引擎 (v8) ,而且有成熟的库 (jQuery、YUI、Dojo),ES5 也在提案中,然而 JavaScript 依然只能出如今浏览器当中。早在2007年,AppJet 就提供了一项服务,建立和托管服务端的 JavaScript 应用。后来 Aptana 也提供了一个可以在服务端运行 Javascript 的环境,叫作 Jaxer。网上还能搜到关于 AppJet、Jaxer 的博客,甚至 Jaxer 项目还在github上。前端
可是这些东西都没有发展起来,Javascript 并不能替代传统的服务端脚本语言 (PHP、Python、Ruby) 。尽管它有不少的缺点,可是不妨碍有不少人使用它。后来就有人开始思考 JavaScript 要在服务端运行还须要些什么?因而在 2009 年 1 月,Mozilla 的工程师 Kevin Dangoor 发起了 CommonJS 的提案,呼吁 JavaScript 爱好者联合起来,编写 JavaScript 运行在服务端的相关规范,一周以后,就有了 224 个参与者。java
"[This] is not a technical problem,It's a matter of people getting together and making a decision to step forward and start building up something bigger and cooler together."node
CommonJS 标准囊括了 JavaScript 须要在服务端运行所必备的基础能力,好比:模块化、IO 操做、二进制字符串、进程管理、Web网关接口 (JSGI) 。可是影响最深远的仍是 CommonJS 的模块化方案,CommonJS 的模块化方案是JavaScript社区第一次在模块系统上取得的成果,不只支持依赖管理,并且还支持做用域隔离和模块标识。再后来 node.js 出世,他直接采用了 CommonJS
的模块化规范,同时还带来了npm (Node Package Manager,如今已是全球最大模块仓库了) 。jquery
CommonJS 在服务端表现良好,不少人就想将 CommonJS 移植到客户端 (也就是咱们说的浏览器) 进行实现。因为CommonJS 的模块加载是同步的,而服务端直接从磁盘或内存中读取,耗时基本可忽略,可是在浏览器端若是仍是同步加载,对用户体验极其不友好,模块加载过程当中势必会向服务器请求其余模块代码,网络请求过程当中会形成长时间白屏。因此从 CommonJS 中逐渐分裂出来了一些派别,在这些派别的发展过程当中,出现了一些业界较为熟悉方案 AMD、CMD、打包工具(Component/Browserify/Webpack)。git
RequireJS 是 AMD 规范的表明之做,它之因此能表明 AMD 规范,是由于 RequireJS 的做者 (James Burke) 就是 AMD 规范的提出者。同时做者还开发了 amdefine
,一个让你在 node 中也可使用 AMD 规范的库。github
AMD 规范由 CommonJS 的 Modules/Transport/C 提案发展而来,毫无疑问,Modules/Transport/C 提案的发起者就是 James Burke。npm
James Burke 指出了 CommonJS 规范在浏览器上的一些不足:编程
export
的对象来暴露模块,将须要导出变量附加到 export
上,可是不能直接给该对象进行赋值。若是须要导出一个构造函数,则须要使用 module.export
,这会让人感到很疑惑。AMD 规范定义了一个 define
全局方法用来定义和加载模块,固然 RequireJS 后期也扩展了 require
全局方法用来加载模块 。经过该方法解决了在浏览器使用 CommonJS 规范的不足。
define(id?, dependencies?, factory);
复制代码
使用匿名函数来封装模块,并经过函数返回值来定义模块,这更加符合 JavaScript 的语法,这样作既避免了对 exports
变量的依赖,又避免了一个文件只能暴露一个模块的问题。
提早列出依赖项并进行异步加载,这在浏览器中,这能让模块开箱即用。
define("foo", ["logger"], function (logger) { logger.debug("starting foo's definition") return { name: "foo" } }) 复制代码
为模块指定一个模块 ID (名称) 用来惟一标识定义中模块。此外,AMD的模块名规范是 CommonJS 模块名规范的超集。
define("foo", function () { return { name: 'foo' } }) 复制代码
在讨论原理以前,咱们能够先看下 RequireJS 的基本使用方式。
模块信息配置:
require.config({ paths: { jquery: 'https://code.jquery.com/jquery-3.4.1.js' } }) 复制代码
依赖模块加载与调用:
require(['jquery'], function ($){ $('#app').html('loaded') }) 复制代码
模块定义:
if ( typeof define === "function" && define.amd ) { define( "jquery", [], function() { return jQuery; } ); } 复制代码
咱们首先使用 config
方法进行了 jquery 模块的路径配置,而后调用 require
方法加载 jquery 模块,以后在回调中调用已加载完成的 $
对象。在这个过程当中,jquery 会使用 define
方法暴露出咱们所须要的 $
对象。
在了解了基本的使用过程后,咱们就继续深刻 RequireJS 的原理。
模块信息的配置,其实很简单,只用几行代码就能实现。定义一个全局对象,而后使用 Object.assign
进行对象扩展。
// 配置信息 const cfg = { paths: {} } // 全局 require 方法 req = require = () => {} // 扩展配置 req.config = config => { Object.assign(cfg, config) } 复制代码
require
方法的逻辑很简单,进行简单的参数校验后,调用 getModule
方法对 Module
进行了实例化,getModule 会对已经实例化的模块进行缓存。由于 require 方法进行模块实例的时候,并无模块名,因此这里产生的是一个匿名模块。Module 类,咱们能够理解为一个模块加载器,主要做用是进行依赖的加载,并在依赖加载完毕后,调用回调函数,同时将依赖的模块逐一做为参数回传到回调函数中。
// 全局 require 方法 req = require = (deps, callback) => { if (!deps && !callback) { return } if (!deps) { deps = [] } if (typeof deps === 'function') { callback = deps deps = [] } const mod = getModule() mod.init(deps, callback) } let reqCounter = 0 const registry = {} // 已注册的模块 // 模块加载器的工厂方法 const getModule = name => { if (!name) { // 若是模块名不存在,表示为匿名模块,自动构造模块名 name = `@mod_${++reqCounter}` } let mod = registry[name] if (!mod) { mod = registry[name] = new Module(name) } return mod } 复制代码
模块加载器是是整个模块加载的核心,主要包括 enable
方法和 check
方法。
模块加载器在完成实例化以后,会首先调用 init
方法进行初始化,初始化的时候传入模块的依赖以及回调。
// 模块加载器 class Module { constructor(name) { this.name = name this.depCount = 0 this.depMaps = [] this.depExports = [] this.definedFn = () => {} } init(deps, callback) { this.deps = deps this.callback = callback // 判断是否存在依赖 if (deps.length === 0) { this.check() } else { this.enable() } } } 复制代码
enable
方法主要用于模块的依赖加载,该方法的主要逻辑以下:
遍历全部的依赖模块;
记录已加载模块数 (this.depCount++
),该变量用于判断依赖模块是否所有加载完毕;
实例化依赖模块的模块加载器,并绑定 definedFn
方法;
definedFn
方法会在依赖模块加载完毕后调用,主要做用是获取依赖模块的内容,并将depCount
减 1,最后调用check
方法 (该方法会判断depCount
是否已经小于 1,以此来界定依赖所有加载完毕);
最后经过依赖模块名,在配置中获取依赖模块的路径,进行模块加载。
class Module { ... // 启用模块,进行依赖加载 enable() { // 遍历依赖 this.deps.forEach((name, i) => { // 记录已加载的模块数 this.depCount++ // 实例化依赖模块的模块加载器,绑定模块加载完毕的回调 const mod = getModule(name) mod.definedFn = exports => { this.depCount-- this.depExports[i] = exports this.check() } // 在配置中获取依赖模块的路径,进行模块加载 const url = cfg.paths[name] loadModule(name, url) }); } ... } 复制代码
loadModule
的主要做用就是经过 url 去加载一个 js 文件,并绑定一个 onload 事件。onload 会从新获取依赖模块已经实例化的模块加载器,并调用 init
方法。
// 缓存加载的模块 const defMap = {} // 依赖的加载 const loadModule = (name, url) => { const head = document.getElementsByTagName('head')[0] const node = document.createElement('script') node.type = 'text/javascript' node.async = true // 设置一个 data 属性,便于依赖加载完毕后拿到模块名 node.setAttribute('data-module', name) node.addEventListener('load', onScriptLoad, false) node.src = url head.appendChild(node) return node } // 节点绑定的 onload 事件函数 const onScriptLoad = evt => { const node = evt.currentTarget node.removeEventListener('load', onScriptLoad, false) // 获取模块名 const name = node.getAttribute('data-module') const mod = getModule(name) const def = defMap[name] mod.init(def.deps, def.callback) } 复制代码
看到以前的案例,由于只有一个依赖 (jQuery),而且 jQuery 模块并无其余依赖,因此 init
方法会直接调用 check
方法。这里也能够思考一下,若是是一个有依赖项的模块后续的流程是怎么样的呢?
define( "jquery", [] /* 无其余依赖 */, function() { return jQuery; } ); 复制代码
check
方法主要用于依赖检测,以及调用依赖加载完毕后的回调。
// 模块加载器 class Module { ... // 检查依赖是否加载完毕 check() { let exports = this.exports //若是依赖数小于1,表示依赖已经所有加载完毕 if (this.depCount < 1) { // 调用回调,并获取该模块的内容 exports = this.callback.apply(null, this.depExports) this.exports = exports //激活 defined 回调 this.definedFn(exports) } } ... } 复制代码
最终经过 definedFn
从新回到被依赖模块,也就是最初调用 require
方法实例化的匿名模块加载器中,将依赖模块暴露的内容存入 depExports
中,而后调用匿名模块加载器的 check
方法,调用回调。
mod.definedFn = exports => { this.depCount-- this.depExports[i] = exports this.check() } 复制代码
还有一个疑问就是,在依赖模块加载完毕的回调中,怎么拿到的依赖模块的依赖和回调呢?
const def = defMap[name] mod.init(def.deps, def.callback) 复制代码
答案就是经过全局定义的 define
方法,该方法会将模块的依赖项还有回调存储到一个全局变量,后面只要按需获取便可。
const defMap = {} // 缓存加载的模块 define = (name, deps, callback) => { defMap[name] = { name, deps, callback } } 复制代码
最后能够发现,RequireJS 的核心就在于模块加载器的实现,不论是经过 require
进行依赖加载,仍是使用 define
定义模块,都离不开模块加载器。
感兴趣的能够在个人github上查看关于简化版 RequrieJS 的完整代码 。
CMD 规范由国内的开发者玉伯提出,尽管在国际上的知名度远不如 AMD ,可是在国内也算和 AMD 齐头并进。相比于 AMD 的异步加载,CMD 更加倾向于懒加载,并且 CMD 的规范与 CommonJS 更贴近,只须要在 CommonJS 外增长一个函数调用的包装便可。
define(function(require, exports, module) { require("./a").doSomething() require("./b").doSomething() }) 复制代码
做为 CMD 规范的实现 sea.js 也实现了相似于 RequireJS 的 api:
seajs.use('main', function (main) { main.doSomething() }) 复制代码
sea.js 在模块加载的方式上与 RequireJS 一致,都是经过在 head 标签插入 script 标签进行加载的,可是在加载顺序上有必定的区别。要讲清楚这二者之间的差异,咱们仍是直接来看一段代码:
RequireJS :
// RequireJS define('a', function () { console.log('a load') return { run: function () { console.log('a run') } } }) define('b', function () { console.log('b load') return { run: function () { console.log('b run') } } }) require(['a', 'b'], function (a, b) { console.log('main run') a.run() b.run() }) 复制代码
sea.js :
// sea.js define('a', function (require, exports, module) { console.log('a load') exports.run = function () { console.log('a run') } }) define('b', function (require, exports, module) { console.log('b load') exports.run = function () { console.log('b run') } }) define('main', function (require, exports, module) { console.log('main run') var a = require('a') a.run() var b = require('b') b.run() }) seajs.use('main') 复制代码
能够看到 sea.js 的模块属于懒加载,只有在 require 的地方,才会真正运行模块。而 RequireJS,会先运行全部的依赖,获得全部依赖暴露的结果后再执行回调。
正是由于懒加载的机制,因此 sea.js 提供了 seajs.use
的方法,来运行已经定义的模块。全部 define 的回调函数都不会当即执行,而是将全部的回调函数进行缓存,只有 use 以后,以及被 require 的模块回调才会进行执行。
下面简单讲解一下 sea.js 的懒加载逻辑。在调用 define 方法的时候,只是将 模块放入到一个全局对象进行缓存。
const seajs = {} const cache = seajs.cache = {} define = (id, factory) => { const uri = id2uri(id) const deps = parseDependencies(factory.toString()) const mod = cache[uri] || (cache[uri] = new Module(uri)) mod.deps = deps mod.factory = factory } class Module { constructor(uri, deps) { this.status = 0 this.uri = uri this.deps = deps } } 复制代码
这里的 Module,是一个与 RequireJS 相似的模块加载器。后面运行的 seajs.use 就会从缓存取出对应的模块进行加载。
注意:这一部分代码只是简单介绍 use 方法的逻辑,并不能直接运行。
let cid = 0 seajs.use = (ids, callback) => { const deps = isArray(ids) ? ids : [ids] deps.forEach(async (dep, i) => { const mod = cache[dep] mod.load() }) } 复制代码
另外 sea.js 的依赖都是在 factory 中声明的,在模块被调用的时候,sea.js 会将 factory 转成字符串,而后匹配出全部的 require('xxx')
中的 xxx
,来进行依赖的存储。前面代码中的 parseDependencies
方法就是作这件事情的。
早期 sea.js 是直接经过正则的方式进行匹配的:
const parseDependencies = (code) => { const REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g const SLASH_RE = /\\\\/g const ret = [] code .replace(SLASH_RE, '') .replace(REQUIRE_RE, function(_, __, id) { if (id) { ret.push(id) } }) return ret } 复制代码
可是后来发现正则有各类各样的 bug,而且过长的正则也不利于维护,因此 sea.js 后期舍弃了这种方式,转而使用状态机进行词法分析的方式获取 require 依赖。
详细代码能够查看 sea.js 相关的子项目:crequire。
其实 sea.js 的代码逻辑大致上与 RequireJS 相似,都是经过建立 script 标签进行模块加载,而且都有实现一个模块记载器,用于管理依赖。
主要差别在于,sea.js 的懒加载机制,而且在使用方式上,sea.js 的全部依赖都不是提早声明的,而是 sea.js 内部经过正则或词法分析的方式将依赖手动进行提取的。
感兴趣的能够在个人github上查看关于简化版 sea.js 的完整代码 。
ES6 的模块化规范已经日趋完善,其静态化思想也为后来的打包工具提供了便利,而且能友好的支持 tree shaking。了解这些已通过时的模块化方案看起来彷佛有些无趣,可是历史不能被遗忘,咱们应该多了解这些东西出现的背景,以及前人们的解决思路,而不是一直抱怨新东西更迭的速度太快。
不说鸡汤了,挖个坑,敬请期待下一期的《前端模块化的此生》。