ES6是如何解决js中功能模块导入导出问题的

2,308 阅读1分钟
原文链接: www.zcfy.cc

ES6 modules, Node.js and the Michael Jackson Solution

JavaScript没有一个标准的方法,来从一个文件向其他文件导入或者导出某个或者某些功能。还好,它有全局变量这个属性。例如:

`<script src="https://code.jquery.com/jquery-1.12.0.min.js">`</script>
<script>
// `$` variable available here
</script>

这种方式是非常不完美了,因为它可能导致一些问题

  • 如果你使用的其他库里面使用了相同的变量,你的代码可能会有冲突。这就是为什么许多库都有一个noConflict()方法。
  • 你无法实现循环引用。如果一个模块A依赖一个模块B互相依赖,我们要以怎样的顺序来放置<script>标签呢?
  • 即使代码中不存在循环引用,你放置<script>标签的顺序也非常重要并且这种书写方式会导致以后维护起来特别困难

CommonJs的救援

后来Node.js和其他服务器端的JavaScript解决方案开始出现,他们商定一个办法来解决这个问题。他们制定了一个叫做CommonJS的规范。对于引入和导出的问题,该规范定义了一个在runtime时期被注入的require()函数,同时定义了一个exports变量来导出功能。

Note: CommonJs并不是唯一的规范。除此之外还有一种不仅可以在前端还可以在后端使用的规范 UMD

曾几何时,前端工具大井喷,其中不乏针对单页应用(SPA)的构建。随着前端代码量与日俱增,加之前后端代码共享的趋势,前端代码缺乏模块化管理的劣势愈发明显,特别是在浏览器端。随之而来诞生了以browserifywebpack 为代表的工具。他们利用cjs的规范,巧妙的弥补了以上缺陷。

这种方式是一种完完全全的hack。因为浏览器并没有实现require()或者exports,那些工具所做的只是通过某种方式把代码打包到一起。如果想了解更多,请移步 how JavaScript bundlers work

ES6模块是如何工作的,为什么Node.js还没有实现它

随着JavaScript的发展,这个问题终于在ES6中得到了解决。这就是ES模块的由来,它在语法是和CJS类似。

现在让我们来比较这些方式。看一下如何在不同的的系统中来引入一些东西。

const { helloWorld } = require('./b.js') // CommonJS
import { helloWorld } from './b.js' // ES modules

导出功能的方式如下:

// CommonJS
exports.helloWorld = () => {
  console.log('hello world')
}
// ES modules
export function helloWorld () {
  console.log('hello world')
}

很类似,不是吗?

Node已经实现了ECMAScript 2015(即ES6)99%的特性,但是对modules的支持,则预计要到2017年底才会完成,而且需要手动开启。为什么在ES6模块与CJS如此类似的情况下,Node.js花了如此长的时间才支持了ES6模块呢?

因为,关键问题出现在在细节方面。两个系统的语法非常类似,但是语义是完全不同的。在一些细节方面需要特殊的处理来实现100%的规范方面的兼容。

即使在Node.js没有支持ES模块的时候,一些浏览器已经实现了对于ES模块的支持。比如:你可以在Safari 10.1上进行测试。下面让我们来看一些例子。通过这些例子我们将会了解为什么语义十分重要。首先,创建以下三个文件。

// index.html
``<script type="module" src="./a.js">``</script>
// a.js
console.log('executing a.js')
import { helloWorld } from './b.js'
helloWorld()
// b.js
console.log('executing b.js')
export function helloWorld () {
  console.log('hello world')
}

当文件执行的时候,我们会在浏览器的控制台看到以下结果:

executing b.js
executing a.js
hello world

然而,在Node.js中使用CJS语法执行相同的代码:

// a.js
console.log('executing a.js')
import { helloWorld } from './b.js'
helloWorld()
// b.js
console.log('executing b.js')
export function helloWorld () {
  console.log('hello world')
}

控制台显示的结果却是:

executing a.js
executing b.js
hello world

所以...相同的代码执行的顺序却不一样!这是因为ES模块首先解析代码(并不会直接执行),其次runtime查找imports并且加载他们,最后再执行代码。这种方式被称为异步加载。

另一方面,Node.js在执行代码的时候才会加载所需的依赖项(requires)。这两种执行方式不同。虽然在某些情况下没什么区别,但是在其他情况下表现是完全不同的。

Node.js和web浏览器需要以第一种方式来实现代码加载。但是它们如何确定使用哪种系统对应的方式呢?浏览器知道,因为你可以在<script>标签上指定,正如我们下面例子看到的type属性。

`<script type="module" src="./a.js">`</script>

然而,Node.js是如何得知的呢?关于这个有许多讨论和建议(首先检查语法,然后决定是否将它视为一个模块?在package.json中直接定义它?...)。最终,决定的方案是:Michael Jackson Solution。基本上,如果你想一个文件作为ES6模块来加载,就使用一个不同的文件扩展名:.mjs来替代.js

这个扩展名(.mjs)就是为什么这个方案被称为Michael Jackson Solutionde原因。

在一开始,这种方式看起来貌似是一个很差的决定,但是现在我认为它是一个很棒的解决方案。因为它非常简单并且其它工具(text editor,IDE,preprocessor)都可以很方便的知道是否一个文件需要被视为一个ES6模块。同时在加载工程方面,这种方式增加的开销最小。

如果你想了解更多Node.js中ES6模块的实现程度,你可以阅读this update

关于Babel的一个提示

Bable实现了ES6模块,但是准确上来说,它没有实现所有的规范。如果你正在使用Babel来转义一个原生的ES6模块的时候,请当心,这可能会有某些副作用。

为什么ES6模块是好的以及如何实现两全其美的效果呢

ES6模块有以下两个最主要的优点:

  • 它们是跨平台的,无论在浏览器还是Node.js中都可以正常执行。

  • import 和 export 都是静态方法,只有这么实现我们才能知道依赖载入是如何工作的。因为 runtime 会先载入文件,解析它然后我们需要在执行之前载入依赖,只有将它们实现成静态方法才能做到。意味着你不能使用import 'engine-' + browserVersion这种语法。这种方式有一个好处:工具可以静态分析代码,找出哪一部分代码确实被使用了然后按需加载这部分代码(tree shake it)。当在使用第三方库的时候这是非常有用的:你不可能使用它们提供的所有方法,所以你可以删除许多没有执行的代码。

但是,这意味这我们没有办法来异步引入某项功能了吗?对我来说,这种方式是很有用处的。许多情况下我都像下面这样来做一些事情:

const provider = process.env.EMAIL_PROVIDER
const emailClient = require(`./email-providers/${provider}`)

通过这种方式,我可以在改变配置的情况下获得相同的接口的不同实现,而不必加载所有实现的代码。

所以如果使用ES6模块会发生什么呢?不用担心,有一个处于stage-3(意味着它很可能在不久后获得批准)的提案,这个提案添加了一个import() function。这个方法接受一个路径然后会以promise的方式来导出功能。

所以通过ES6模块和import(),我们将实现两全其美的效果

ES6模块是很棒的,但是接受它可能需要花些时间。希望这篇文章的内容能帮助你做好准备!