ES6模块简记

396 阅读8分钟

这两天学习了阮一峰老师关于ES6模块的教程,打算在这里做下学习小结。这并不是自己第一次学习此类知识,但是因为平常使用未加谨记,故常常忘记。阮老师博客

JS模块化的由来

对于其它语言来说,模块化是天生就支持的基础特性,例如 javapackage。而对于JS来说,初始并不支持模块化,以前写过 jquery 的都记得,我们经常一个JS文件写的满满当当的,各种各样的函数都在一个JS文件中相互调用或放在顶层作用域window下面实现跨文件调用。

这样的写法对于一个大型项目来说是非常不便于管理和维护的,所有模块化呼之欲出。

ES6模块化的使用

ES6模块化是为了处理JS模块化而新引入的标准,我想现在大部分同学都在项目中已经用的溜得飞起,就是我们常常写的 importexport

// a.js
export const a = 1;

// b.js
import { a } from './a.js';

因为大家在项目中经常使用,所有简单的导入导出就不再唠叨了,我们主要看看几个需要注意的地方

  1. 什么样的导出写法才是正确的

我想经常会有人在声明导出的时候出错

const a = 2;

// error
export a;

// error
export 2;

// ok
export const a = 2;

对于 export 命令来说,我们必须在其后加上声明语句,不能直接导出一个声明后的变量名或者是变量值。

我们可以理解为 export 定义了一个接口,我们必须通过 export + 声明 的方式来声明这个接口并定义输出的变量

const a = 2;

// ok
export default 2;

// ok
export default a;

// error
export default const a = 2;

和上面的写法完全相反,这也是导致我们经常写错的原因

我们可以这样理解,对于 export default 来说,其实际等于 export const default = ,因为它实际是声明加上将其后的变量或值赋值给 default,所以不能再加上重复声明

  1. 什么样的导入才是正确的

我之前常常误解,将import { a } from './a.js'作为一种解构来理解,所以常常会有以下的错误写法

// a.js
export default {
  a: 1,
  b: 2
}

// b.js
import { a, b } from './a.js'

这样的写法显然是错误的,我应该纠正自己的理解

import moduleA from './a.js'

const {a, b} = moduleA;

对于 export const a = 1; 这样的写法就是对应 import { a } from 'xxx',这是一种标准约定的写法。

而对于 export default 来说,我们仅仅 import a from 'xxx',如果 a 是个对象,此时应该再进一步去解构对象才对

  1. 静态解析

对于es6模块来说,其输出接口是在解析阶段就已经确定的,而不是在运行时进行定义的,怎么理解呢

// a.js
export let count = 1

export const addCount = () => {
  count += 1
}
// b.js
import { count, addCount } from './a.js'

conole.log(count) // 1

addCount()

conole.log(count) // 2

可以发现,我们使用exportimport 之后,其实就是定义了它们之间的联系,不管在模块中定义的变量a如何变化,其导入处的 a 变量值也会跟着变化。

它就像我们在 A模块 定义了 变量A,在 B模块 引入 变量A,此时两个模块的 变量A 是同一个内存。

  1. 执行1次

在不同的模块中,或者在同一个模块中,导入另一个模块 B 的时候,仅在第一次导入会执行 B 模块

// a.js
import { b } from './b.js'

// c.js
import defaultB from './b.js'

以上代码仅执行一次 b.js,所以我们在项目代码中多次引入同个模块的时候可以做到模块数据共享,不会多次初始化重置数据

  1. 依赖循环

在项目复杂的时候,将有可能遇到依赖循环的问题,我在项目中也有遇到,并且被eslint检查报红了。

如果可以的话,当然是修改依赖避免循环最理想。但是如果无法避免的时候呢?

其实依赖循环本身并没有问题,只是如果我们不清楚其执行机制,有可能导致问题,所以如果我们有遇到依赖循环的情况,应该好好理解。

我们举个例子

// a.js
console.log('enter a')

import { nameB } from './b.js'

console.log('nameb in ajs', nameB)

export var nameA = 'a'

console.log('load a')
// b.js
console.log('enter b')

import { nameA }  from './a.js'

console.log('namea in bjs', nameA)

export const nameB = 'b'

console.log('load b')

setTimeout(() => {
  console.log('namea in bjs', nameA)
}, 0);

最后打印的结果

// enter b
// namea in bjs undefined
// load b
// enter a
// nameb in ajs b
// load a
// namea in bjs a

我们来分析下

  • 进入 a.js 因为 import 提升,所以最先执行 import { nameB } from './b.js'

  • 进入 a.js 同样声明提前,首先执行 import { nameA } from './a.js'

  • 此时发现依赖循环,所以不会进入 a.js,而是继续执行 b.js

  • 打印 enter b

  • 执行 console.log('namea in bjs', nameA),此时因为 a.js 未执行余下部分,所以 nameAundefined,打印 namea in bjs undefined

  • 执行 export const nameB = 'b'

  • 打印 load b

  • b.js 执行完毕,此时继续执行 a.js

  • 打印 enter a

  • 执行 console.log('nameb in ajs', nameB),因为 b.js 已经执行完毕,所以 nameB 的值为 b,打印 nameb in ajs b

  • 执行 export var nameA = 'a' 定义 a.js 的输出接口

  • 打印 load a

  • 最后执行 b.js 中的延迟函数,此时 a.js 已经加载完毕,打印 namea in bjs a

总结以下,当 ab 文件依赖循环时,假设首先进入 a.js,此时会先执行 b.js,在执行 b.js 的时候,导入的 a.js 中的变量是获取不到值的,因为 a.js 未执行完毕,当 b.js 执行结束再回到 a.js,此时再执行剩余部分,并且导入的 b.js变量都是有值的,如果后面再执行b.js中的函数或其它逻辑时,因为 a.js加载完毕,所以导入的值此时也可以获取到值了。

所以只要我们不是同步的立即执行函数,在某些时候依赖循环是没有问题的,我们只要注意初始化时候是获取不到值的就行。

与commonJS的差异

commonJSNODE 环境中定义并实现的一套模块化标准,现在比较普遍存在的就是 commonJSESM 即上文的 es6模块化,至于 AMD 等属于之前 ESM 未流行之前的替代方案,我们就不分析了。

  1. 使用方式不同

对于 commonJS 来说,我们通过 exportsrequire 来引用及导出

// a.js
exports.a = 1;

// b.js
const a = require('./a.js').a
  1. 声明提升及作用域的区别

对于 ESM 来说,不管我们在什么位置使用 import 都会提升到最顶部执行,而 require 不会如此。

还有就是 ESM 不允许定义在块级作用域中

if (1) {
  // error
  import a from './a.js'
}

require 可以

if (1) {
  // ok
  require('./a.js')
}

所以 require 是可以按需加载的,而 import 不行,当然这边所说的并不包括 import()

  1. 静态解析VS运行时加载

前面说 ESM 属于 静态解析,而 commonJS 是运行时加载,什么意思呢

也就是说 ESM 在代码解析阶段就已经完成模块定义,而 commonJS 是在执行 require 才执行模块加载及定义的。

所以 ESM 不支持动态路径

// error
import { a } from `./${path}/a.js`

这就相当于写了

// error
const `{path}a` = 1;

所以在代码解析阶段就出错了

require 支持动态路径,就和平常执行函数一样

// ok
require(`./${path}/a.js`)
  1. commonJS的加载原理

我们再来简单说说 commonJS 的加载原理,在执行 require 的时候,就会定义一个新的模块对象,并存储在全局环境中

{
  id: '...',
  exports: {},
  loaded: false
}

对于同一个模块来说,会生成唯一ID,并在执行模块的时候,往 exports 添加变量,所以 exports 其实导出了变量的浅拷贝,我们往后在原模块中所作的修改,不再会同步到引用模块中

// a.js
let count = 1

exports.count = count

const addCount = () => {
  count += 1
}

exports.addCount = addCount
// b.js
const moduleA = require('./a.js')

conole.log(moduleA.count) // 1

moduleA.addCount()

const moduleA2 = require('./a.js')

conole.log(moduleA.count) // 1
conole.log(moduleA2.count) // 1

可以看到,虽然我们调用 addCount 改变了 count 但是依然是旧值,就是因为 moduleA 来自 exports,其为原模块的浅拷贝,当我们再次调用 require 其实也是调用在全局存储的 exports 对象,不会更新其值。

注意我们上面说的是浅拷贝,引用对象还是会有所差别的

其它

按照标准来说,在同一个模块中是不支持 ESMcommonJS 混用的,也就是不支持同时使用 importrequire。我们在 NODE 项目的时候可以通过 package.jsontype: module/commonjs 来指定 JS 文件的模块类型。

但是为什么我们在平常开发项目的时候可以使用 ESMcommonJS呢?

因为 webpack 在编译的时候,其实会按照标准对模块进行编译打包,这时候会将 ESMcommonJS 的模块都打包成 webpack 自身的模块实现,也就是我们常见的 webpack_require 逻辑的实现。所以其实是 webpack 的打包机制兼容了不同的模块标准进行打包编译。

最后

ESMcommonJS 是现在比较流行的两种前端模块标准,ESM 用于浏览器,commonJS 主要用于 NODE 项目,我们在项目中常常使用,所以还是得好好学习一番。写的比较粗糙,有错误的希望不吝指正。good good staduy day day up