ECMAScript6-模块化

177 阅读8分钟

为什么要模块化?

原始写法

// 文件加载顺序
<script src='a.js'></script>
<script src='b.js'></script>
<script src='m.js'></script>
// a.js
var name = '小明'
var flag = true

if(flag) {
  console.log('我是小明,哈哈')
}
function sum(num1,num2) {
  return num1 + num2
}
sum(10,20) // 30
name // 小明

// b.js
var name = '小红'
var flag = false
name // 小红

// m.js
if(flag) {
  console.log('我会输出吗?')
}

此时m.js中的flag被b.js文件修改为false,所以m.js中不会正常输出。常规模式的 JavaScript开发,全局变量命名会发生冲突,而且变量依赖文件顺序。

历史上的模块化方案

对象写法

变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善的将内部私有的函数或变量隐藏起来,就很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的命名空间。所有需要暴露给外界的功能都会成为这个对象的属性,而不是将自己的标识符暴露在顶级的词法作用域中。

var MyReallyCoolLibrary = {
  awesome:'stuff',
  doSomething:function() {
    ...
  },
  doAnotherThing:function() {
    ...
  }
}

问题:这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接修改内部计数器的值。

MyReallyCoolLibrary.count = 2;

闭包写法

使用立即执行函数表达式,可以达到不暴露私有成员的目的。(function foo() {...})() 是一个函数表达式,意味着 foo 只能在 ... 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中,意味着不会非必要的污染外部作用域。

// a.js
(function foo() { // foo也可以省略直接作为匿名函数表达式
  var name = 'lxx'
  var flag = true
  if(flag) {
    console.log('I am lxx')
  }
  function sum(num1, num2) {
    return num1 + num2
  }
})()

// b.js
(function(){
  var name = '小红'
  var flag = false
})()

// m.js
if(flag) {
  console.log('我会输出吗?')
}

问题:虽然解决了全局变量命名冲突的问题,但是 m.js 中无法再使用变量 flag,因为被封闭在函数作用域中。

原始模块化解决方案

让立即执行函数返回一个对象,函数内部的属性和方法作为这个对象的属性和方法。

// a.js
var ModuleA = (function (){
  var obj = {}
  obj.name = 'lxx'
  obj.flag = true
  if(obj.flag) {
    console.log('I am lxx')
  }
  obj.sum = function sum(num1,num2) {
    return num1 + num2
  }
  return obj
})()

// b.js
var ModuleB = (function(){
  var obj = {}
  obj.name = '小红'
  obj.flag = false
  return obj
})()

// m.js
if(ModuleA.flag) {
  console.log('我会输出吗?')
}

解决了全局变量命名冲突问题,只需要保证输出的 ModuleA 和 ModuleB 不会命名冲突即可。这就是模块最基础的封装。

请结合阅读《你不知道的JavaScript 上卷 第五章 模块》

CommonJS

node中每一个js文件就是一个模块。node 中实现 commonjs 模块化的本质是对象的引用赋值。

引用赋值.png

exports

相当于模块中的全局对象,在什么也不导出时,exports 是一个空对象。 想把谁导出就把谁作为 exports 的属性,exports.name = namename 导出,将 name 赋值给 exportsname 属性。

// bar.js
const name = 'lxx'
const age = 25
function sayHello(name) {
  console.log('hello,' + name)
}
// 在导出之前,输出 exports 结果是 {}
console.log(exports) 

exports.name = name
exports.age = age
exports.sayHello = sayHello


// 在导出之后,输出 exports 结果是 { name:'lxx', age:25, sayHello:[Function:sayHello]}
console.log(exports) 

require()

是模块中的一个函数,这个函数会拿到导出的 exports 对象,并将其作为 require 函数的返回值。

// main.js
const bar = require('./bar') // require 函数有一个返回值,即bar=exports
console.log(bar) // { name:'lxx', age:25 }
bar.sayHello('lxx') // hello,lxx

因为 bar 是一个对象,所以可以使用对象解构赋值。

const { name,age,sayHello } = require('./bar')
name // lxx
age // 25
sayHello('lxx') // hello,lxx

require(x) 查找规则

  • x 是一个 Node 核心模块,直接返回核心模块并停止查找。
  • x 是一个路径,以 ./..// 开头。
    • 第一步,将 x 当做一个文件在对应目录下查找
      • 有后缀,按照后缀名查找对应文件
      • 没有后缀,按以下顺序查找:x.js x.json x.node
    • 第二步,没有找到文件,将 x 作为目录,按如下顺序查找:x/index.js x/index.json x/index.node
  • x 不是路径,也不是核心模块,从逐层目录的 node_modules 中查找。
  • 都没有找到报错 not found

module.exports 和 exports 的关系

CommonJS 规范中是没有 module.exports 概念的,但是为了实现模块的导出,Node 中使用的是 Module 类。每一个模块都是 Module 的一个实例,即 module。在 Node 中真正用于导出的不是 exports 而是 module.exportsmodule 才是导出的真正实现者。

  1. 为什么 exports 也可以导出呢? exportsmodule.exports 的引用,也就是说 bar = exports = module.exports = {} 内存地址是一样的,目的都是引用同一个对象,Node 源码中就是这样的赋值顺序。
  2. 既然有 module.exports 那么 exports 存在的意义是什么? CommonJS 的规范中要求有一个 exports 作为导出。其实是 NodeJS 为了满足 CommonJS 规范做了一个妥协,增加了一个 exports,其实是不需要的。

CommonJS 规范的缺点

CommonJS 加载模块是同步的。意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行。这个在服务器不会有什么问题,因为服务器加载的文件都是本地文件,加载速度非常快。

require('./bar')
console.log('后面的代码')
// 结果:先执行 require 进来的内容,然后再执行后面 main 中的代码

浏览器加载 js 文件需要先从服务器将文件下载下来,之后再加载运行。采用同步的方式意味着后续的 js 代码都无法正常运行,即使是一些简单的 DOM 操作。所以在浏览器中通常不使用 CommonJS 规范。
当然在 webpack 中使用 CommonJS 是另一回事,因为它会将代码转成浏览器可以直接执行的代码。

ES Module

export

export 有两种模块导出方式:命名式导出和默认导出。命名式导出每个模块可以有多个,默认导出每个模块仅一个。

命名式导出

因为导出用名称进行区分,称之为命名式导出。

export { myFunction } // 导出一个已定义的函数
export const foo = Math.sqrt(2) // 导出一个常量

模块导出时,可以使用 as 关键字对导出成重命名

var name = 'itbilu'
var domain = 'http://itbilu.com'
export { name as siteName, domain }

💣 注意下面的语法有严重错误的情况:

// 错误演示
export 1 // 绝对不可以
var a = 100
export a

export 在导出接口的时候,必须与模块内部的变量具有一一对应的关系。
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量 m,还是直接输出 11 只是一个值,不是接口。

默认导出

默认导出也被称为定义式导出。命名式导出可以导出多个值,但在 import 引用时,也要使用相同的名称来引用相应的值。而默认导出每个导出只有一个单一值,这个输出可以是一个函数、类或其他类型的值,这样在模块 import 导入时也会很容易引用。

const D = 123
export default D

export 的写法

  1. 直接导出

    export let name = 'lxx'
    export let age = 18
    
  2. 大括号语法

    let name = 'lxx'
    let age = 18
    export { name, age }
    

    💣 export {...} 中的 {} 在这里不是对象,因为尝试 export { name:name } 会报错!{} 放置的是要导出的变量的绑定列表。

  3. 导出函数或类

       export function Mul(num1, num2) {
         return num1 + num2
       }
       export class Person {
         run() {}
       }
    ``` 
    
  4. export default 在某些情况下,一个模块中包含某个功能,我们并不希望给这个功能命名,而是让导入者自己命名,此时就可以使用 export default

    export default function (arg) {
      console.log(arg)
    }
    
    import argument from './a/js'
    argument(10)
    

import

import 导入方式

  1. 命名式导入

    import { myMember } from 'my-module'
    impost { foo, bar } from 'my-module'
    
  2. 默认导入 在模块导出时,可能会存在默认导出。同样的,在导入时可以使用 import 指令导入这些默认值。

    import myDefault from 'my-module'
    
  3. 命名空间导入 相当于把所有导出的变量或方法放入一个对象中,通过点语法获取。

    import { flag, sum, name, age, Mul, Person } from './a.js'
    import * as a from './a.js'
    
    a.flag
    a.sum(1,2)
    

    💣 import{} {} 在这里也不是对象

  4. import 和 export 结合使用

    // format.js
    export function price(num) {
      console.log('$'+num)
    }
    
    export function time() {
      console.log('2022-01-14')
    }
    

    在 index.js 中导入导出所有内容,作为统一出口。

        // index.js
        export { price, time } from './format.js'
    

    使用时,直接加载 index.js 就好了。

    // main.js
    import { price, time } from './index'
    

import 错误用法

import 不能放在任何代码块中,因为 import 需要在 js 代码于解析阶段就确定依赖关系。把解析阶段的代码放在运行阶段执行肯定会报错。

let flag = true
// 此处代码处于运行阶段
if(flag) {
  import { price, time } from './index'
}

import()

为解决上面的需求,可以使用 import(),它是一个异步执行的函数,返回一个 Promise。vue router 的懒加载就运用了 import()

let flag = true
if(flag) { 
  import('./modules.foo.js').then((res) => {
    // 拿到导出的内容	
    console.log(res.name)
    console.log(res.age)
  }).catch(err => {
     ...
  })
}

ES Module 加载过程

ES Module 通过 export 导出的是变量本身的引用。
export 在导出变量时,js引擎会解析这个语法,并且创建模块环境记录(module environment record)
模块环境记录和变量进行绑定,并且绑定是实时的。
如果导出的模块中修改了变量,那么导入的地方可以实时获取最新的变量。

💣 在导入的地方不可以修改变量,因为它只是被绑定到了这个变量上,其实是一个常量。

浏览器中使用

在浏览器中使用模块,,这时文件才会被当做一个模块。且 ES Module 是异步的,不会堵塞后续代码执行。

<script src="index.js" type="module"></script> 
<script src="normal.js"></script> // 不会堵塞normal.js的执行