Published on

JavaScript 模块化及 AMD 最小实现

Authors

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 竖了一块墓碑:

the-death-of-seajs-and-kissy

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

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 的性能和可能性了。

相关链接