前端模块化

更新日期: 2019-11-04阅读: 2.9k标签: 模块化

背景

众所周知,早期 JavaScript 原生并不支持模块化,直到 2015 年,TC39 发布 ES6,其中有一个规范就是 ES modules(为了方便表述,后面统一简称 ESM)。但是在 ES6 规范提出前,就已经存在了一些模块化方案,比如 CommonJS(in Node.js)、AMD。ESM 与这些规范的共同点就是都支持导入(import)和导出(export)语法,只是其行为的关键词也一些差异。

CommonJS

// add.js
const add = (a, b) => a + b
module.exports = add
// index.js
const add = require('./add')
add(1, 5)

AMD

// add.js
define(function() {
  const add = (a, b) => a + b
  return add
})
// index.js
require(['./add'], function (add) {
  add(1, 5)
})

ESM

// add.js
const add = (a, b) => a + b
export default add
//index.js
import add from './add'
add(1, 5)

ESM 的出现不同于其他的规范,因为这是 JavaScript 官方推出的模块化方案,相比于 CommonJS 和 AMD 方案,ESM采用了完全静态化的方式进行模块的加载。


ESM规范

模块导出

模块导出只有一个关键词:export,最简单的方法就是在声明的变量前面直接加上 export 关键词。

export const name = 'Shenfq'

可以在 const、let、var 前直接加上 export,也可以在 function 或者 class 前面直接加上 export。

export function getName() {
  return name
}
export class Logger {
    log(...args) {
    console.log(...args)
  }
}

上面的导出方法也可以使用大括号的方式进行简写。

const name = 'Shenfq'
function getName() {
  return name
}
class Logger {
    log(...args) {
    console.log(...args)
  }
}

export { name, getName, Logger }

最后一种语法,也是我们经常使用的,导出默认模块。

const name = 'Shenfq'
export default name

模块导入

模块的导入使用import,并配合 from 关键词。

// main.js
import name from './module.js'

// module.js
const name = 'Shenfq'
export default name

这样直接导入的方式,module.js 中必须使用 export default,也就是说 import 语法,默认导入的是default模块。如果想要导入其他模块,就必须使用对象展开的语法。

// main.js
import { name, getName } from './module.js'

// module.js
export const name = 'Shenfq'
export const getName = () => name

如果模块文件同时导出了默认模块,和其他模块,在导入时,也可以同时将两者导入。

// main.js
import name, { getName } from './module.js'

//module.js
const name = 'Shenfq'
export const getName = () => name
export default name

当然,ESM 也提供了重命名的语法,将导入的模块进行重新命名。

// main.js
import * as mod from './module.js'
let name = ''
name = mod.name
name = mod.getName()

// module.js
export const name = 'Shenfq'
export const getName = () => name

上述写法就相当于于将模块导出的对象进行重新赋值:

// main.js
import { name, getName } from './module.js'
const mod = { name, getName }

同时也可以对单独的变量进行重命名:

// main.js
import { name, getName as getModName }

导入同时进行导出

如果有两个模块 a 和 b ,同时引入了模块 c,但是这两个模块还需要导入模块 d,如果模块 a、b 在导入 c 之后,再导入 d 也是可以的,但是有些繁琐,我们可以直接在模块 c 里面导入模块 d,再把模块 d 暴露出去。


// module_c.js
import { name, getName } from './module_d.js'
export { name, getName }

这么写看起来还是有些麻烦,这里 ESM 提供了一种将 import 和 export 进行结合的语法。

export { name, getName } from './module_d.js'

上面是 ESM 规范的一些基本语法


ESM 与 CommonJS 的差异

首先肯定是语法上的差异,前面也已经简单介绍过了,一个使用 import/export 语法,一个使用 require/module 语法。

另一个 ESM 与 CommonJS 显著的差异在于,ESM 导入模块的变量都是强绑定,导出模块的变量一旦发生变化,对应导入模块的变量也会跟随变化,而 CommonJS 中导入的模块都是值传递与引用传递,类似于函数传参(基本类型进行值传递,相当于拷贝变量,非基础类型【对象、数组】,进行引用传递)。

下面我们看下详细的案例:

CommonJS

// a.js
const mod = require('./b')

setTimeout(() => {
  console.log(mod)
}, 1000)

// b.js
let mod = 'first value'

setTimeout(() => {
  mod = 'second value'
}, 500)

module.exports = mod
$ node a.js
first value

ESM

// a.mjs
import { mod } from './b.mjs'

setTimeout(() => {
  console.log(mod)
}, 1000)

// b.mjs
export let mod = 'first value'

setTimeout(() => {
  mod = 'second value'
}, 500)
$ node --experimental-modules a.mjs
# (node:99615) ExperimentalWarning: The ESM module loader is experimental.
second value

另外,CommonJS 的模块实现,实际是给每个模块文件做了一层函数包裹,从而使得每个模块获取 require/module、__filename/__dirname 变量。那上面的 a.js 来举例,实际执行过程中 a.js 运行代码如下:

// a.js
(function(exports, require, module, __filename, __dirname) {
    const mod = require('./b')
  setTimeout(() => {
    console.log(mod)
  }, 1000)
});

而 ESM 的模块是通过 import/export 关键词来实现,没有对应的函数包裹,所以在 ESM 模块中,需要使用 import.meta 变量来获取 __filename/__dirname。import.meta 是 ECMAScript 实现的一个包含模块元数据的特定对象,主要用于存放模块的 url,而 node 中只支持加载本地模块,所以 url 都是使用 file: 协议。

import url from 'url'
import path from 'path'
// import.meta: { url: file:///Users/dev/mjs/a.mjs }
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)


加载的原理

步骤:

  1. Construction(构造):下载所有的文件并且解析为module records。
  2. Instantiation(实例):把所有导出的变量入内存指定位置(但是暂时还不求值)。然后,让导出和导入都指向内存指定位置。这叫做『linking(链接)』。
  3. Evaluation(求值):执行代码,得到变量的值然后放到内存对应位置。

模块记录

所有的模块化开发,都是从一个入口文件开始,无论是 Node.js 还是浏览器,都会根据这个入口文件进行检索,一步一步找到其他所有的依赖文件。

// Node.js: main.mjs
import Log from './log.mjs'
<!-- chrome、firefox -->
<script type="module" src="./log.js"></script>

值得注意的是,刚开始拿到入口文件,我们并不知道它依赖了哪些模块,所以必须先通过 js 引擎静态分析,得到一个模块记录,该记录包含了该文件的依赖项。所以,一开始拿到的 js 文件并不会执行,只是会将文件转换得到一个模块记录(module records)。所有的 import 模块都在模块记录的 importEntries 字段中记录,更多模块记录相关的字段可以查阅tc39.es



模块构造

得到模块记录后,会下载所有依赖,并再次将依赖文件转换为模块记录,一直持续到没有依赖文件为止,这个过程被称为『构造』(construction)。

模块构造包括如下三个步骤:

  1. 模块识别(解析依赖模块 url,找到真实的下载路径);
  2. 文件下载(从指定的 url 进行下载,或从文件系统进行加载);
  3. 转化为模块记录(module records)。

对于如何将模块文件转化为模块记录,ESM 规范有详细的说明,但是在构造这个步骤中,要怎么下载得到这些依赖的模块文件,在 ESM 规范中并没有对应的说明。因为如何下载文件,在服务端和客户端都有不同的实现规范。比如,在浏览器中,如何下载文件是属于 html 规范(浏览器的模块加载都是使用的 <script> 标签)。

虽然下载完全不属于 ESM 的现有规范,但在 import 语句中还有一个引用模块的 url 地址,关于这个地址需要如何转化,在 Node 和浏览器之间有会出现一些差异。简单来说,在 Node 中可以直接 import 在 node_modules 中的模块,而在浏览器中并不能直接这么做,因为浏览器无法正确的找到服务器上的 node_modules 目录在哪里。好在有一个叫做 import-maps 的提案,该提案主要就是用来解决浏览器无法直接导入模块标识符的问题。但是,在该提案未被完全实现之前,浏览器中依然只能使用 url 进行模块导入。

<script type="importmap">
{
  "imports": {
      "jquery": "/node_modules/jquery/dist/jquery.js"
  }
}
</script>
<script type="module">
    import $ from 'jQuery'
  $(function () {
    $('#app').html('init')
  })
</script>

下载好的模块,都会被转化为模块记录然后缓存到 module map 中,遇到不同文件获取的相同依赖,都会直接在 module map 缓存中获取。

// log.js
const log = console.log
export default log

// file.js
export { 
  readFileSync as read,
  writeFileSync as write
} from 'fs'



模块实例

获取到所有依赖文件并建立好 module map 后,就会找到所有模块记录,并取出其中的所有导出的变量,然后,将所有变量一一对应到内存中,将对应关系存储到『模块环境记录』(module environment record)中。当然当前内存中的变量并没有值,只是初始化了对应关系。初始化导出变量和内存的对应关系后,紧接着会设置模块导入和内存的对应关系,确保相同变量的导入和导出都指向了同一个内存区域,并保证所有的导入都能找到对应的导出。


由于导入和导出指向同一内存区域,所以导出值一旦发生变化,导入值也会变化,不同于 CommonJS,CommonJS 的所有值都是基于拷贝的。连接到导入导出变量后,我们就需要将对应的值放入到内存中,下面就要进入到求值的步骤了。

模块求值

求值步骤相对简单,只要运行代码把计算出来的值填入之前记录的内存地址就可以了。到这里就已经能够愉快的使用 ESM 模块化了。


ESM的进展

因为 ESM 出现较晚,服务端已有 CommonJS 方案,客户端又有 webpack 打包工具,所以 ESM 的推广不得不说还是十分艰难的。

客户端

我们先看看客户端的支持情况,这里推荐大家到 Can I Use 直接查看,下图是 2019/11的截图。


目前为止,主流浏览器都已经支持 ESM 了,只需在 script 标签传入指定的 type="module" 即可。

<script type="module" src="./main.js"></script>

另外,我们知道在 Node.js 中,要使用 ESM 有时候需要用到 .mjs 后缀,但是浏览器并不关心文件后缀,只需要 http 响应头的MIME类型正确即可(Content-Type: text/javascript)。同时,当 type="module"时,默认启用 defer 来加载脚本。这里补充一张 defer、async 差异图。


我们知道浏览器不支持 script 的时候,提供了 noscript 标签用于降级处理,模块化也提供了类似的标签。

<script type="module" src="./main.js"></script>
<script nomodule>
  alert('当前浏览器不支持 ESM !!!')
</script>

这样我们就能针对支持 ESM 的浏览器直接使用模块化方案加载文件,不支持的浏览器还是使用 webpack 打包的版本。

<script type="module" src="./src/main.js"></script>
<script nomodule src="./dist/app.[hash].js"></script>

预加载

我们知道浏览器的 link 标签可以用作资源的预加载,比如我需要预先加载 main.js 文件:

<link rel="preload" href="./main.js"></link>

如果这个 main.js 文件是一个模块化文件,浏览器仅仅预先加载单独这一个文件是没有意义的,前面我们也说过,一个模块化文件下载后还需要转化得到模块记录,进行模块实例、模块求值这些操作,所以我们得想办法告诉浏览器,这个文件是一个模块化的文件,所以浏览器提供了一种新的 rel 类型,专门用于模块化文件的预加载。

<link rel="modulepreload" href="./main.js"></link>

现状

虽然主流浏览器都已经支持了 ESM,但是根据 chrome 的统计,有用到 <script type="module"> 的页面只有 1%。截图时间为 2019/11。



服务端

浏览器能够通过 script 标签指定当前脚本是否作为模块处理,但是在 Node.js 中没有很明确的方式来表示是否需要使用 ESM,而且 Node.js 中本身就已经有了 CommonJS 的标准模块化方案。就算开启了 ESM,又通过何种方式来判断当前入口文件导入的模块到底是使用的 ESM 还是 CommonJS 呢?为了解决上述问题,node 社区开始出现了 ESM 的相关草案,具体可以在 github 上查阅。

2017年发布的 Node.js 8.5.0 开启了 ESM 的实验性支持,在启动程序时,加上 --experimental-modules来开启对 ESM 的支持,并将 .mjs 后缀的文件当做 ESM 来解析。早期的期望是在 Node.js 12 达到 LTS 状态正式发布,然后期望并没有实现,直到最近的 13.2.0 版本才正式支持 ESM,也就是取消了 --experimental-modules 启动参数。具体细节可以查看 Node.js 13.2.0 的官方文档

关于 .mjs 后缀社区有两种完全不同的态度。支持的一方认为通过文件后缀区分类型是最简单也是最明确的方式,且社区早已有类似案例,例如,.jsx 用于 react 组件、.ts 用于 ts 文件;而支持的一方认为,.js 作为 js 后缀已经存在这么多年,视觉上很难接受一个 .mjs 也是 js 文件,而且现有的很多工具都是以 .js 后缀来识别 js 文件,如果引入了 .mjs 方案,就有大批量的工具需要修改来有效的适配 ESM。

所以除了 .mjs 后缀指定 ESM 外,还可以使用 pkg.json 文件的 type 属性。如果 type 属性为 module,则表示当前模块应使用 ESM 来解析模块,否则使用 CommonJS 解析模块。

{
  "type": "module" // module | commonjs(default)
}

当然有些本地文件是没有 pkg.json 的,但是你又不想使用 .mjs 后缀,这时候只需要在命令行加上一个启动参数 --input-type=module。同时 input-type 也支持 commonjs 参数来指定使用 CommonJS(-—input-type=commonjs)。

总结一下,Node.js 中,以下三种情况会启用 ESM 的模块加载方式:

  1. 文件后缀为.mjs;
  2. pkg.json 中 type 字段指定为 module;
  3. 启动参数添加 --input-type=module。

同样,也有三种情况会启用 CommonJS 的模块加载方式:

  1. 文件后缀为.cjs;
  2. pkg.json 中 type 字段指定为 commonjs;
  3. 启动参数添加 --input-type=commonjs。

虽然 13.2 版本去除了 --experimental-modules 的启动参数,但是按照文档的说法,在 Node.js 中使用 ESM 依旧是实验特性。

Stability: 1 - Experimental

不过,相信等到 Node.js 14 LTS 版本发布时,ESM 的支持应该就能进入稳定阶段了,这里还有一个 Node.js 关于 ESM 的整个计划列表可以查阅。

链接: https://www.fly63.com/article/detial/6702

一览js模块化:从CommonJS到ES6

模块化是指把一个复杂的系统分解到一个一个的模块。模块化开发的优点:代码复用,让我们更方便地进行代码管理、同时也便于后面代码的修改和维护。一个单独的文件就是一个模块,是一个单独的作用域,只向外暴露特定的变量和函数。

JS模块化

CommonJS 是服务器端的模块化方案,nodeJs 就采用了这种方案。在 CommonJS 规范中,一个文件即一个模块,用module.exports和exports定义模块输出的接口,用require加载模块。在 requireJS 中用define定义模块,require载入模块,require.config用来配置路径。ES6 Module 主要使用export输出,import加载。

js模块化总结

在很长的一段前端历史里,是不存在打包这个说法的。那个时候页面基本是纯静态的或者服务端输出的, 没有 AJAX,也没有 jQuery。Google 推出 Gmail 的时候(2004 年),XMLHttpRequest, 也就是我们俗称的 AJAX被拾起的时候

AMD 模块化最佳实践

AMD 是 RequireJS 给出的模块加载方案。 支持递归依赖解析、模块异步加载,夜兼容 CommonJS 可以在 Node.js 里用。 虽然目前已经不再流行,很多站点更倾向于编写 ES Modules 并直接 Webpack 打包, 但 AMD 是完整的

ES6 模块化和 .vue组件的应用举例

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。export通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

Js模块化方案总结

本文包含两部分,第一部分通过简明的描述介绍什么是 CommonJS、AMD、CMD、UMD、ES Module 以及它们的常见用法,第二部分则根据实际问题指出在正常的 webpack 构建过程中该如何指定打包配置中的模块化参数。

css模块化方案

这篇文章主要介绍了css模块化方案,css的模块化方案可能和js的一样多,下面简单介绍几种主要的模块方案,非常具有实用价值,需要的小伙伴可以参考下。css的模块化方案可能和js的一样多,下面简单介绍几种主要的模块方案

理解JS 模块化

在模块化规范形成之前,JS开发者使用Module设计模式来解决JS全局作用域的污染问题。Module模式最初被定义为一种在传统软件工程中为类提供私有和公有封装的方法。在JavaScript中,Module模式使用匿名函数自调用 (闭包)来封装

ES6与 CommonJS 模块化的区别

CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。ES6 Modules 的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。

前端模块化:CommonJS,AMD,CMD,ES6

模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。目前流行的js模块化规范有CommonJS、AMD、CMD以及ES6的模块系

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!