🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] # 模块化 使用一个技术肯定是有原因的,那么使用模块化可以给我们带来以下好处 * 解决命名冲突 * 提供复用性 * 提高代码可维护性 ## 立即执行函数 在早期,使用立即执行函数实现模块化是常见的手段,通过函数作用域解决了命名冲突、污染全局作用域的问题 ~~~ (function(globalVariable){ globalVariable.test = function() {} // ... 声明各种变量、函数都不会污染全局作用域 })(globalVariable) ~~~ <br> <br> ## AMD RequireJS 是 AMD 规范的代表之作,它之所以能代表 AMD 规范,是因为 RequireJS 的作者 (James Burke) 就是 AMD 规范的提出者。同时作者还开发了[`amdefine`](https://github.com/jrburke/amdefine),一个让你在 node 中也可以使用 AMD 规范的库。 <br> AMD 规范由 CommonJS 的 Modules/Transport/C 提案发展而来,毫无疑问,Modules/Transport/C 提案的发起者就是 James Burke。 <br> James Burke 指出了 CommonJS 规范在浏览器上的一些不足: 1. 缺少模块封装的能力:CommonJS 规范中的每个模块都是一个文件。这意味着每个文件只有一个模块。这在服务器上是可行的,但是在浏览器中就不是很友好,浏览器中需要做到尽可能少的发起请求。 2. 使用同步的方式加载依赖:虽然同步的方法进行加载可以让代码更容易理解,但是在浏览器中使用同步加载会导致长时间白屏,影响用户体验。 3. CommonJS 规范使用一个名为`export`的对象来暴露模块,将需要导出变量附加到`export`上,但是不能直接给该对象进行赋值。如果需要导出一个构造函数,则需要使用`module.export`,这会让人感到很疑惑。 <br> AMD 规范定义了一个`define`全局方法用来定义和加载模块,当然 RequireJS 后期也扩展了`require`全局方法用来加载模块 。通过该方法解决了在浏览器使用 CommonJS 规范的不足。 ~~~ define(id?, dependencies?, factory); ~~~ <br> 1. 使用匿名函数来封装模块,并通过函数返回值来定义模块,这更加符合 JavaScript 的语法,这样做既避免了对`exports`变量的依赖,又避免了一个文件只能暴露一个模块的问题。 2. 提前列出依赖项并进行异步加载,这在浏览器中,这能让模块开箱即用。 ~~~ define("foo", ["logger"], function (logger) { logger.debug("starting foo's definition") return { name: "foo" } }) ~~~ 3. 为模块指定一个模块 ID (名称) 用来唯一标识定义中模块。此外,AMD的模块名规范是 CommonJS 模块名规范的超集。 ~~~ define("foo", function () { return { name: 'foo' } }) ~~~ ### RequireJS 原理 在讨论原理之前,我们可以先看下 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`方法主要用于模块的依赖加载,该方法的主要逻辑如下: 1. 遍历所有的依赖模块; 2. 记录已加载模块数 (`this.depCount++`),该变量用于判断依赖模块是否全部加载完毕; 3. 实例化依赖模块的模块加载器,并绑定`definedFn`方法; > `definedFn`方法会在依赖模块加载完毕后调用,主要作用是获取依赖模块的内容,并将`depCount`减 1,最后调用`check`方法 (该方法会判断`depCount`是否已经小于 1,以此来界定依赖全部加载完毕); 4. 最后通过依赖模块名,在配置中获取依赖模块的路径,进行模块加载。 ~~~ 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 原理总结 最后可以发现,RequireJS 的核心就在于模块加载器的实现,不管是通过`require`进行依赖加载,还是使用`define`定义模块,都离不开模块加载器。 <br> <br> ## CMD 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() }) ~~~ ![requirejs result](https://segmentfault.com/img/remote/1460000020621570 "requirejs result") **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 result](https://segmentfault.com/img/remote/1460000020621571 "sea.js result") 可以看到 sea.js 的模块属于懒加载,只有在 require 的地方,才会真正运行模块。而 RequireJS,会先运行所有的依赖,得到所有依赖暴露的结果后再执行回调。 正是因为懒加载的机制,所以 sea.js 提供了`seajs.use`的方法,来运行已经定义的模块。所有 define 的回调函数都不会立即执行,而是将所有的回调函数进行缓存,只有 use 之后,以及被 require 的模块回调才会进行执行。 ### sea.js 原理 下面简单讲解一下 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](https://github.com/seajs/crequire)。 #### sea.js 原理总结 其实 sea.js 的代码逻辑大体上与 RequireJS 类似,都是通过创建 script 标签进行模块加载,并且都有实现一个模块记载器,用于管理依赖。 主要差异在于,sea.js 的懒加载机制,并且在使用方式上,sea.js 的所有依赖都不是提前声明的,而是 sea.js 内部通过正则或词法分析的方式将依赖手动进行提取的。 <br> ## CommonJS 十年前的前端没有像现在这么火热,模块化也只是使用闭包简单的实现一个命名空间。2009 年对 JavaScript 无疑是重要的一年,新的 JavaScript 引擎 (v8) ,并且有成熟的库 (jQuery、YUI、Dojo),ES5 也在提案中,然而 JavaScript 依然只能出现在浏览器当中。早在2007年,AppJet 就提供了一项服务,创建和托管服务端的 JavaScript 应用。后来 Aptana 也提供了一个能够在服务端运行 Javascript 的环境,叫做 Jaxer。网上还能搜到关于 AppJet、Jaxer 的博客,甚至 Jaxer 项目还在[github](https://github.com/aptana/Jaxer)上。 <br> ![Jaxer](https://image-static.segmentfault.com/312/044/3120446892-5d9d3d8640748_articlex) <br> 但是这些东西都没有发展起来,Javascript 并不能替代传统的服务端脚本语言 (PHP、Python、Ruby) 。尽管它有很多的缺点,但是不妨碍有很多人使用它。后来就有人开始思考 JavaScript 要在服务端运行还需要些什么?于是在 2009 年 1 月,Mozilla 的工程师[Kevin Dangoor](http://www.kevindangoor.com/)发起了 CommonJS 的提案,呼吁 JavaScript 爱好者联合起来,编写 JavaScript 运行在服务端的相关规范,一周之后,就有了 224 个参与者。 <br> > "\[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." <br> CommonJS 标准囊括了 JavaScript 需要在服务端运行所必备的基础能力,比如:模块化、IO 操作、二进制字符串、进程管理、Web网关接口 (JSGI) 。但是影响最深远的还是 CommonJS 的模块化方案,CommonJS 的模块化方案是JavaScript社区第一次在模块系统上取得的成果,不仅支持依赖管理,而且还支持作用域隔离和模块标识。再后来 node.js 出世,他直接采用了`CommonJS`的模块化规范,同时还带来了npm (Node Package Manager,现在已经是全球最大模块仓库了) 。 <br> CommonJS 在服务端表现良好,很多人就想将 CommonJS 移植到客户端 (也就是我们说的浏览器) 进行实现。由于CommonJS 的模块加载是同步的,而服务端直接从磁盘或内存中读取,耗时基本可忽略,但是在浏览器端如果还是同步加载,对用户体验极其不友好,模块加载过程中势必会向服务器请求其他模块代码,网络请求过程中会造成长时间白屏。所以从 CommonJS 中逐渐分裂出来了一些派别,在这些派别的发展过程中,出现了一些业界较为熟悉方案 AMD、CMD、打包工具(Component/Browserify/Webpack)。 ~~~ // a.js module.exports = { a: 1 } // or exports.a = 1 // b.js var module = require('./a.js') module.a // -> log 1 ~~~ 因为 CommonJS 还是会使用到的,所以这里会对一些疑难点进行解析 <br> 先说`require`吧 ~~~ var module = require('./a.js') module.a // 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了, // 重要的是 module 这里,module 是 Node 独有的一个变量 module.exports = { a: 1 } // module 基本实现 var module = { id: 'xxxx', // 我总得知道怎么去找到他吧 exports: {} // exports 就是个空对象 } // 这个是为什么 exports 和 module.exports 用法相似的原因 var exports = module.exports var load = function (module) { // 导出的东西 var a = 1 module.exports = a return module.exports }; // 然后当我 require 的时候去找到独特的 // id,然后将要使用的东西用立即执行函数包装下,over ~~~ 另外虽然`exports`和`module.exports`用法相似,但是不能对`exports`直接赋值。因为`var exports = module.exports`这句代码表明了`exports`和`module.exports`享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对`exports`赋值就会导致两者不再指向同一个内存地址,修改并不会对`module.exports`起效。 <br> <br> # 参考资料 * 前端面试之道 - 掘金小册 * [前端模块化的前世](https://segmentfault.com/a/1190000020621564)