- Published on
JavaScript 模块化及 AMD 最小实现
- Authors
- Name
- 文森
- Github
- @leungwensen
1. JavaScript 模块化历史
1.1 ES5 时代
现在 JS 社区早已对模块化开发这件事习以为常了,大概新的前端程序员是无法想象一个站点一个脚本的开发体验。早年间在“模块化”这个词被提出之前,绝大部分网站的 JS 结构是用全局变量支撑起来的。需要用几个库,就在 header 内加载几个文件,站点执行的主逻辑就在一个 runtime 文件里。库文件的调用方式就是全局变量,譬如至今仍运行在整个互联网站点半壁江山以上的 jQuery,老前端们都很熟悉那个美元符号 `$`。
这个时代慢慢地诞生了一些大型前端框架,借由大公司的复杂站点的迭代,从 IBM、Google 这些公司诞生了一些前端 UI 框架,而复杂的 UI 框架慢慢地也迭代出自己的“模块按需加载”的方案。典型的例子,有 Dojo、Angular、Kissy 等。这些框架的迭代,最初都是“DOM helpers”、到 Selectors、到 UI 集、到完整的 Frameworks,模块加载器慢慢开始被迭代被标准化出来。
1.2 Pre-ES6 + Module Loaders 时代
随着语言特性的丰富,和项目复杂度的逐步提升,各家框架自行其是的弊端越来越凸显。加之 Node.js 和 ES6 的发展,对齐成熟语言的模块化方案已经呼之欲出。这个时代是各类 Module Loaders 百花齐放的时期,并且最终诞生了几大模块定义标准:
1.2.1 CJS CommonJS
wiki:https://en.wikipedia.org/wiki/CommonJS
语法
//importing
const doSomething = require('./doSomething.js')
//exporting
module.exports = function doSomething(n) {
// do something
}
这大概是大家最熟悉、生命力最长久的模块定义标准了,因为 Node.js 从一开始就使用了这种模块定义标准,直至今天。不过最近几年的 Node.js 版本都在努力迁移到 ES Modules 标准,CommonJS 大概快要完成它的历史使命了。
最早这个标准源自 Mozilla 工程师 2009 年的 SeverJS 项目,重命名后应用到诸多场景里,最终由 Node.js 项目发扬光大。它最初叫 ServerJS,但其实它的设计也有针对 Web 浏览器的部分。不过因为浏览器没有原生支持这种语法,并且 require 和 module.exports 的设计对当时太过自由,运行在浏览器中必须要把 transpiler 预编译进去,所以在浏览器中一直没有得到很好的应用。
1.2.2 AMD Asynchronous module definition
wiki:https://en.wikipedia.org/wiki/Asynchronous_module_definition
语法 1
define(['dep1', 'dep2'], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {}
})
语法 2
// "simplified CommonJS wrapping" https://requirejs.org/docs/whyamd.html
define(function (require) {
var dep1 = require('dep1')
var dep2 = require('dep2')
return function () {}
})
AMD 标准最早就是 Dojo 项目实践并推广的一套适用于浏览器模块定义和加载的标准,在 Dojo 1.0 正式版推出。随后社区基于同样的规范迭代出了 RequireJS。后者是 AMD 标准被应用最广泛的一个工具库。AMD 第一套语法是精髓,第二套语法是向庞大的 CommonJS 社区妥协的结果。不过随着 UMD、Module Bundler 的发展,AMD 最先被淘汰了。
1.2.3 System.Register
定义:https://github.com/ModuleLoader/es-module-loader
语法
export let x
export function p() {
x = 10
}
SystemJS 的目标就是在 ES5 的运行时支持 ES6 的写法。换句话说,大家开发的时候按照 ES6 的语法写,运行时构建成 SystemJS 标准的代码,然后就可以在浏览器执行了。上述代码构建后的语法如下:
System.register([], function ($__export, $__moduleContext) {
var x
function p() {
x = 10
}
$__export({
x: undefined,
p: p,
})
return {
// setters: [], // (setters can be optional when empty)
execute: function () {},
}
})
不过语言标准的发展趋势不可阻挡,SystemJS 最终的归宿就是 Babel 的一个插件而已。比起其他几个更加“野路子”的标准,SystemJS 反而不够流行。
1.2.3 UMD Universal Module Definition
定义:https://github.com/umdjs/umd
语法
;(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(['jquery', 'underscore'], factory)
} else if (typeof exports === 'object') {
module.exports = factory(require('jquery'), require('underscore'))
} else {
root.Requester = factory(root.$, root._)
}
})(this, function ($, _) {
// this is where I defined my module implementation
var Requester = {
/*...*/
}
return Requester
})
在这个时期,有了五花八门的各种模块加载器实现之后,有一个问题越来越严重:我要怎样才能让不同模块定义下的 JS 模块,能协同工作起来呢?我能不能依赖不同格式的模块,然后做更多的事情呢?UMD 就是为了解决这一个问题诞生的,并且在 Module bundles 流行起来的时候,它基本成了事实标准。细看这个语法可以发现,UMD 本质就是在模块的外层套了一个上下文的壳,在这个壳的开头处理不同模块标准带来的差异。是相当丑陋,但也相当务实的一种设计。在 Webpack、Browerify 这些方案兴起之后,构建到 ES5 输出的格式,基本都是 UMD 的变种。
1.2.4 番外:CMD Common Module Definition
定义:https://github.com/seajs/seajs/issues/242
语法
// x.js
define(function (require, exports, module) {
// 错误用法
setTimeout(function () {
module.exports = { a: 'hello' }
}, 0)
})
// y.js
define(function (require, exports, module) {
var x = require('./x')
// 无法立刻得到模块 x 的属性 a
console.log(x.a) // undefined
})
这是那个时代国内模块加载器的巅峰之作 SeaJS 定义并遵从的模块定义标准。SeaJS 当时火到什么程度呢,它不只是阿里系的前端全套工程化体系的基座,甚至也是竞对腾讯内部大型前端项目的依赖。当年知道 QQ 空间也是用 SeaJS 做的模块化,那种震撼还是印象挺深的。不过在 2015 年附近,Node.js 社区已经如日中天,Module bundles 也已经确立其工程化的统治地位,React 开始方兴未艾的时候,玉伯挺低调地给 Sea.js 竖了一块墓碑:
1.3 Module bundles 时代
以 Babel、 Webpack、Browserify 为代表的方案,基本宣告了 Module loaders 项目的死亡。在大型、超大型前端项目的压力下,整个社区都意识到,基于编译器做不同标准、不同语言规范之间的兼容和构建才是出路。而网络带宽、电脑性能的提升,也让“按需加载”这件事情越来越成为一种伪需求。因此目前整个社区和工程界已经迈入了全面应用 Module bundles 的时代,甚至现在越来越多 Native 语言开发的、性能更好的 bundles 成为了主流。
不过这样的技术方案,也终归是中间态、过渡方案。前端之所以存在这样的方案,完全是因为 JavaScript 这门语言多灾多难的发展历史。因为在太不成熟的时期,被互联网、移动互联网的浪潮推动,被应用得太广、牵涉到太多利益相关方,以至于身上的包袱重得前无古人,甚至也大概率后无来者了。
JavaScript 以及它的模块化方案,最理想的状态,应该是一开始所有的 runtime 在投入生产应用前,都原生支持唯一的一种方案。这种方案就是我们即将迎来的 ES Modules。无须构建,无需兼容,写的代码和解释器解析的代码是同一份,这理应是一门成熟的脚本语言该有的待遇。
1.4 ES Modules:ES6+ES2015 built-in Modules 时代
ES Modules 磕磕绊绊,到今天慢慢成为主流了。大家平时写代码都很熟悉这套 import from / export 的写法。这套语法和 Python 里面的语法最接近。但也有不少特殊的地方。尤其因为 Babel、Wepack 这样的工具存在,事实上大家平时写的语法是不太规范的。举几个例子:
import dep1 from '../dep1'
// 最规范的写法应该是:
import dep1 from '../dep1.js'
import data from '../data.json'
// 最佳做法应该是把 data.json 预先构建成 data.json.js,然后
import data from '../data.json.js'
// 或者按照标准
import data from '../data.json' assert { type: 'json' }
import dep2 from '@/dep2'
// 应该改为相对路径
import dep2 from '../../dep2.js'
为什么呢?因为 ES Module 里是这样定义的。如果将来某一天,所有浏览器、嵌入式 JS runtime 和 Node.js 一样,都原生支持了 ES Modules 标准,那么这样的写法无需调整,就可以执行在所有地方了。
2. AMD 标准及最小化实现
好了,模块化的背景介绍完,和大家深入讲一下 AMD 这个标准,以及最小化实现涉及到的技术。之所以当时会涉及到这套技术,是因为最早有个项目我们用了 Dojo 的一个版本,但当时 Dojo 1.8 加载器有个 bug 无论如何绕不过去,最后只有通读了加载器代码、hack 了一个加载器替换了其中一些实现,最后才修掉那个 bug 的。#远峰 应该有印象,当时因为我早期贪快,技术选型失误用了 Dojo,多加了好多没意义的班[捂脸哭]。后来的几个项目,出于可控性的考虑,换过自研的 AMD loader、RequireJS、SeaJS 等,又掉进好几个新坑里。后来整体切换到 AntD + React + Webpack,大家的工程开发体验才算慢慢好起来了。
详细的 AMD 标准:https://github.com/amdjs/amdjs-api/wiki/AMD
Dojo 项目关于 AMD 的介绍:https://dojotoolkit.org/documentation/tutorials/1.10/modules/
项目链接:https://github.com/leungwensen/amd-module
Demo:
Module
const global = require('zero-lang/global')
const arrayUtils = require('zero-lang/array')
const objectUtils = require('zero-lang/object')
const typeCheck = require('zero-lang/type')
const event = require('zero-events/event')
function Module(meta) {
/*
* @description: Module constructor
*/
let mod = this
return mod.initialise(meta)
}
let data = (Module._data = {})
let moduleByUri = (data.moduleByUri = {})
let exportsByUri = (data.exportsByUri = {})
let executedByUri = (data.executedByUri = {})
let queueByUri = (data.queueByUri = {}) // queue to be executed
let undef
event(Module) // add evented functions: on(), off(), emit(), trigger()
Module.prototype = {
initialise(meta) {
let mod = this
let id
let uri
let relativeUri
objectUtils.extend(mod, meta)
Module.emit('module-initialised', mod)
if ((uri = mod.uri)) {
if (!moduleByUri[uri]) {
moduleByUri[uri] = mod
}
}
if ((id = mod.id)) {
if (!moduleByUri[id]) {
moduleByUri[id] = mod
}
}
if ((relativeUri = mod.relativeUri)) {
if (!moduleByUri[relativeUri]) {
moduleByUri[relativeUri] = mod
}
if (!queueByUri[relativeUri]) {
queueByUri[relativeUri] = mod
}
}
return mod
},
processDeps() {
let mod = this
Module.emit('module-deps-processed', mod)
return mod
},
execute() {
let mod = this
let depModExports = []
if ('exports' in mod) {
delete queueByUri[mod.relativeUri]
return mod
}
if (
arrayUtils.every(mod.deps, function (uri) {
return !!executedByUri[uri]
})
) {
let modFactory = mod.factory
let modUri = mod.uri
let modId = mod.id
let modRelativeUri = mod.relativeUri
arrayUtils.each(mod.deps, function (uri) {
depModExports.push(exportsByUri[uri])
})
mod.exports =
exportsByUri[modUri] =
exportsByUri[modId] =
exportsByUri[modRelativeUri] =
typeCheck.isFunction(modFactory) ? modFactory.apply(undef, depModExports) : modFactory
executedByUri[modUri] = executedByUri[modId] = executedByUri[modRelativeUri] = true
Module.emit('module-executed', mod)
}
return mod
},
}
Module.on('module-executed', function () {
/*
* @description: try to execute all modules in queue
* @note: hacking so hard
* @TODO: to be optimized
*/
objectUtils.forIn(queueByUri, function (mod2BeExecuted /*, uri */) {
if (mod2BeExecuted instanceof Module) {
mod2BeExecuted.execute()
}
})
})
module.exports = Module
Module 是最核心的模块,它定义了一个 JS 模块。模块的 meta 信息包括它的 id、uri、相对路径、依赖表和模块的函数体。因为 AMD 模块涉及到异步的依赖构建、状态变更和执行等,需要用类似 EventEmitter 作为基类,用事件的方式处理加载和执行的状态和逻辑。
Module 的生命周期主要有三个阶段:初始化、处理元数据和依赖、执行返回模块导出结果。每一个实例都是 define 函数创建和触发处理的。
define
if (global.define) {
// avoiding conflict
throw '"define" function exists'
}
const arrayUtils = require('zero-lang/array')
const typeCheck = require('zero-lang/type')
const Module = require('./Module')
let undef
function define(/* id, deps, factory */) {
let args = arrayUtils.toArray(arguments)
let id = typeCheck.isString(args[0]) ? args.shift() : undef
let deps = args.length > 1 ? args.shift() : []
let factory = args[0]
let meta = {
id: id,
uri: id,
deps: deps,
factory: factory,
}
Module.emit('module-meta-got', meta)
let mod = new Module(meta).processDeps().execute()
Module.emit('module-defined', mod)
}
define.amd = {} // minimum AMD implement
define('amd-module/Module', function () {
// module Module
return Module
})
define('amd-module/define', function () {
// module define
return define
})
module.exports = global.define = Module.define = define
define
函数就是 AMD 标准里的 define 函数。它接受三个参数:
- id(可选),作为全局唯一定位当前模块的 id
- deps(可选),当前模块的依赖项列表,数组成员可以是 id、可以是相对路径、也可以是绝对路径
- factory(必须),定义模块的函数体,其参数是依赖项,与 deps 一一对应。函数执行必须有返回值,这个返回值就是当前模块导出的结果
这个函数主要的逻辑就是根据定义初始化模块,并且触发模块执行。
path
const typeCheck = require('zero-lang/type')
const Module = require('./Module')
const define = require('./define')
const re_absolute = /^\/\/.|:\//
const re_dirname = /[^?#]*\//
const re_dot = /\/\.\//g
const re_doubleDot = /\/[^/]+\/\.\.\//
const re_ignoreLocation = /^(about|blob):/
const re_multiSlash = /([^:/])\/+\//g
const re_path = /^([^/:]+)(\/.+)$/
const re_rootDir = /^.*?\/\/.*?\//
const doc = document
const lc = location
const href = lc.href
const scripts = doc.scripts
const loaderScript = doc.getElementById('moduleLoader') || scripts[scripts.length - 1]
const loaderPath = loaderScript.hasAttribute
? /* non-IE6/7 */ loaderScript.src
: loaderScript.getAttribute('src', 4)
let data = Module._data
function dirname(path) {
// dirname('a/b/c.js?t=123#xx/zz') ==> 'a/b/'
return path.match(re_dirname)[0]
}
function realpath(path) {
path = path.replace(re_dot, '/') // /a/b/./c/./d ==> /a/b/c/d
// a//b/c ==> a/b/c
// a///b/////c ==> a/b/c
// DOUBLE_DOT_RE matches a/b/c//../d path correctly only if replace // with / first
path = path.replace(re_multiSlash, '$1/')
while (path.match(re_doubleDot)) {
// a/b/c/../../d ==> a/b/../d ==> a/d
path = path.replace(re_doubleDot, '/')
}
return path
}
function normalize(path) {
// normalize('path/to/a') ==> 'path/to/a.js'
let last = path.length - 1,
lastC = path.charCodeAt(last)
if (lastC === 35 /* '#' */) {
// If the uri ends with `#`, just return it without '#'
return path.substring(0, last)
}
return path.substring(last - 2) === '.js' || path.indexOf('?') > 0 || lastC === 47 /* '/' */
? path
: path + '.js'
}
function parseAlias(id) {
let alias = data.alias
return alias && zero.isString(alias[id]) ? alias[id] : id
}
function parsePaths(id) {
let m
let paths = data.paths
if (paths && (m = id.match(re_path)) && zero.isString(paths[m[1]])) {
id = paths[m[1]] + m[2]
}
return id
}
function addBase(id, refUri) {
let ret
let first = id.charCodeAt(0)
if (re_absolute.test(id)) {
// Absolute
ret = id
} else if (first === 46 /* '.' */) {
// Relative
ret = (refUri ? dirname(refUri) : data.cwd) + id
} else if (first === 47 /* '/' */) {
// Root
let m = data.cwd.match(re_rootDir)
ret = m ? m[0] + id.substring(1) : id
} else {
// Top-level
ret = data.base + id
}
if (ret.indexOf('//') === 0) {
// Add default protocol when uri begins with '//'
ret = lc.protocol + ret
}
return realpath(ret)
}
function id2Uri(id, refUri) {
if (!id) {
return ''
}
id = parseAlias(id)
id = parsePaths(id)
id = parseAlias(id)
id = normalize(id)
id = parseAlias(id)
let uri = addBase(id, refUri)
uri = parseAlias(uri)
return uri
}
data.cwd = !href || re_ignoreLocation.test(href) ? '' : dirname(href)
data.path = loaderPath
data.dir = data.base = dirname(loaderPath || data.cwd)
const pathUtils = {
id2Uri: id2Uri,
}
define('amd-module/path', pathUtils)
module.exports = pathUtils
每个模块都需要有唯一标识,并且要能感知到相对路径指向的依赖项,因此路径解释和处理的能力是必需的。这个模块有点类似 Node.js 里的 path 模块,不过是基于 loaderScript 和 location 来解析路径的。最核心的实现就是 id、relativePath 转换到 URI,URI 就是全局唯一定位模块的标记。
request
const typeCheck = require('zero-lang/type')
const Module = require('./Module')
const define = require('./define')
const doc = document
const head = doc.head || doc.getElementsByTagName('head')[0] || doc.documentElement
const baseElement = head.getElementsByTagName('base')[0]
function addOnload(node, callback, url) {
const supportOnload = 'onload' in node
function onload(error) {
// Ensure only run once and handle memory leak in IE {
node.onload = node.onerror = node.onreadystatechange = null
// }
// Dereference the node {
node = null
// }
if (typeCheck.isFunction(callback)) {
callback(error)
}
}
if (supportOnload) {
node.onload = onload
node.onerror = function () {
Module.emit('error', { uri: url, node: node })
onload(true)
}
} else {
node.onreadystatechange = function () {
if (/loaded|complete/.test(node.readyState)) {
onload()
}
}
}
}
function request(url, callback, charset, crossorigin) {
var node = doc.createElement('script')
if (charset) {
const cs = typeCheck.isFunction(charset) ? charset(url) : charset
if (cs) {
node.charset = cs
}
}
// crossorigin default value is `false`. {
const cors = typeCheck.isFunction(crossorigin) ? crossorigin(url) : crossorigin
if (cors !== false) {
node.crossorigin = cors
}
// }
addOnload(node, callback, url)
node.async = true
node.src = url
/*
* For some cache cases in IE 6-8, the script executes IMMEDIATELY after
* the end of the insert execution, so use `currentlyAddingScript` to
* hold current node, for deriving url in `define` call
*/
Module.currentlyAddingScript = node
if (baseElement) {
head.insertBefore(node, baseElement) // ref: #185 & http://dev.jquery.com/ticket/2709
} else {
head.appendChild(node)
}
Module.currentlyAddingScript = null
}
define('amd-module/request', function () {
return request
})
module.exports = request
AMD 的用法是只在 html 里添加 loader.js 和入口模块文件,其余依赖文件都是运行时自动下载执行的。所以请求模块的实现也是最小实现的必需项之一。实现也简单粗暴,就是添加一个 script 标签,然后在 onload 的时候处理模块相关逻辑。
loader
const typeCheck = require('zero-lang/type')
const arrayUtils = require('zero-lang/array')
const Module = require('./Module')
const define = require('./define')
const path = require('./path')
const request = require('./request')
const doc = document
const id2Uri = path.id2Uri
let interactiveScript
let data = Module._data
let moduleByUri = data.moduleByUri
let executedByUri = data.executedByUri
let loadingByUri = (data.loadingByUri = {})
Module.resolve = id2Uri
Module.request = request
function getCurrentScript() {
if (Module.currentlyAddingScript) {
return Module.currentlyAddingScript.src
}
if (doc.currentScript) {
// firefox 4+
return doc.currentScript.src
}
// reference: https://github.com/samyk/jiagra/blob/master/jiagra.js
let stack
try {
throw new Error()
} catch (e) {
// safari: only `line`, `sourceId` and `sourceURL`
stack = e.stack
if (!stack && window.opera) {
// opera 9 does not have `e.stack`, but `e.Backtrace`
stack = (String(e).match(/of linked script \S+/g) || []).join(' ')
}
}
if (stack) {
/*
* e.stack:
* chrome23: at http://113.93.50.63/data.js:4:1
* firefox17: @http://113.93.50.63/query.js:4
* opera12: @http://113.93.50.63/data.js:4
* IE10: at Global code (http://113.93.50.63/data.js:4:1)
*/
stack = stack.split(/[@ ]/g).pop() // at last line, after the last space or @
stack = stack[0] === '(' ? stack.slice(1, -1) : stack
return stack.replace(/(:\d+)?:\d+$/i, '')
}
if (interactiveScript && interactiveScript.readyState === 'interactive') {
return interactiveScript.src
}
let nodes = doc.getElementsByTagName('script')
for (let i = 0, node; (node = nodes[i++]); ) {
if (node.readyState === 'interactive') {
interactiveScript = node
return node.src
}
}
}
let relativeIdCounter = 0
let uuid = 0
Module.on('module-meta-got', function (meta) {
const src = getCurrentScript()
if (src) {
meta.uri = src
} else {
meta.uri = data.cwd
}
if (src === '' || (typeCheck.isString(src) && src === data.cwd)) {
if (meta.id) {
// named module in script tag
// meta.id = './' + meta.id; // @FIXME
} else {
// script tag
meta.uri = data.cwd + ('#' + uuid++)
}
}
})
.on('module-initialised', function (mod) {
if (!(typeCheck.isString(mod.uri) && mod.uri.indexOf('/') > -1)) {
mod.uri = id2Uri(mod.id)
}
const uri = mod.uri
const id = mod.id || relativeIdCounter++
mod.relativeUri = uri.indexOf(id + '.') > -1 ? uri : id2Uri('./' + id, uri)
})
.on('module-deps-processed', function (mod) {
arrayUtils.each(mod.deps, function (id, index) {
let uri
if (moduleByUri[id]) {
uri = id
} else {
uri = id2Uri(id, mod.relativeUri || mod.uri)
}
mod.deps[index] = uri
if (!moduleByUri[uri] && !loadingByUri[uri] && !executedByUri[uri]) {
request(uri)
loadingByUri[uri] = true
}
})
})
最后,loader.js 作为入口,调动整个依赖链路的执行。因为浏览器里事实上无法实现“解析执行完依赖之后,执行下一个模块”,这里采取的策略有点粗暴,就是解析到某个模块的时候,把模块加入队列,解析到模块依赖的时候,再把依赖也加入队列。每次从队列里取出一个模块,如果当前模块依赖项都已执行完成,则执行当前模块,否则退回队列。这样随着依赖树中所有叶子节点处理完毕,就可以逐步深入,最终执行到入口模块了。
不过这个设计事实上忽略了一种情况,就是循环依赖。循环依赖是所有模块加载方案都无法解决的问题。但可以在这个架构的基础上进一步扩展,补充相关错误堆栈提示的能力。
3. 相关技术对我们的意义?
其实没啥意义 [
捂脸哭]
不过,除了满足好奇,补充前端领域发展的一些历史知识,大概看清楚这个方向发展的路径以外,对未来有一些场景是有所帮助的。譬如当我们要处理移动端报表动态加载的时候,譬如当我们要像 PowerBI 那样,实现一个可视化组件的三方开放市场的时候,我们就得深入到模块定义的底层,一点一点去压榨 JavaScript 的性能和可能性了。
相关链接
- the little grave of my AMD loader leungwensen/amd-module