目录

🪤JavaScript 的模块管理

# 关于 JavaScript 模块

随着 JavaScript 中的脚本应用代码越来越多,可以将它 拆分成多个文件(模块 Module)。一个模块可以包含用来 特定目的的类 或者 函数库

# 不同的模块系统

  • AMD : 基于 require.js 库实现的模块系统。

  • CommonJS: 为 Node.js 服务器创建的模块系统。

  • UMD: 建议作为通用的模块系统,兼容 AMD 和 CommonJS。

# 模块

一个模块 (Module)就是一个文件。(一个脚本)模块之间可以互相加载,使用特殊的指令 exportimport 交换功能,从一个模块调用另一个模块的 函数。

  • export (导出):用来标记 从当前模块 外部可以访问的变量和函数。
  • import (引入):允许 从其他模块 导入变量和函数。

🌰 例子:

  • sayHi.js 文件中 导出 一个函数:
export function sayHi(user) {
  alert(`Hello, ${user}!`);
}
1
2
3
  • 在另一个文件(例如 main.js )导入并且使用这个函数:
import { sayHi } from './sayHi.js'

console.log(sayHi) // function sayHi ... 
sayHi('John')
1
2
3
4

🌰 例子 / 在浏览器中导入模块

由于模块支持特殊的关键字和功能,因此必须要通过使用 <script type="module"> 特性来告诉浏览器,此脚本应该被当作模块来对待。

sayHi.js 同上)

  • 在 HTML 页面文件中:
<!DOCTYPE html>
<script type="module">
  import {sayHi} from './say.js';

  document.body.innerHTML = sayHi('John');
</script>
1
2
3
4
5
6

浏览器会自动获取并解析导入的模块(如果需要,还可以分析该模块的导入),然后运行该脚本。

注意在浏览器打开 file 协议的 HTML 文件读取模块不起作用。

# 模块中的核心语法 / 功能

在模块中:

  • 给未声明的变量赋值不能使用,将产生错误。

  • 每个模块都有自己的 顶级作用域。即 一个模块中的顶级作用域变量和函数在其他脚本中是不可见的。

    但是,可以通过将变量显式地分配给 window 的一个属性,使其成为窗口级别的全局变量。例如 window.user = "John" 。这样所有的脚本都会看到它,无论脚本是否带有 type="module"避免全局变量的使用!

  • 模块代码仅在第一次导入时被解析。如果同一个模块被导入到多个其他位置,那么它的代码只会执行一次,即在第一次被导入时。将其导出的内容提供给进一步的导入。

    🌰 例子:

    • 在一个模块 alert.js 中,导出一个 JavaScript 语句:
    console.log("Module is evaluated!")
    
    1
    • 在不同的文件中导入相同的模块:
    // 📁 1.js
    import `./alert.js`; // Module is evaluated!
    
    // 📁 2.js
    import `./alert.js`; // (什么都不显示)
    
    1
    2
    3
    4
    5

    所以,一般 顶层模块代码应该用于初始化,创建模块特定的内部数据结构。如果需要多次调用某些东西,应该将其以函数的形式导出。

    🌰 例子 / 模块导出对象:

    // 📁 admin.js
    export let admin = {
      name: "John"
    };
    
    1
    2
    3
    4

    如果这个模块被导入到多个文件中,模块仅在 第一次被导入时被解析,并创建 admin 对象,然后将其传入到所有的导入。

    即,所有的导入获得的都是同一个唯一的 admin 对象

    // 📁 1.js
    import { admin } from './admin.js';
    admin.name = "Pete";
    
    // 📁 2.js
    import { admin } from './admin.js';
    alert(admin.name); // Pete
    
    1
    2
    3
    4
    5
    6
    7

    当在一个脚本中导入一个模块对象,并修改它;在另一个脚本中可以看到该修改,因为共享的是同一个 模块对象

    🌰 例子:

    模块可以提供需要配置的通用功能,可以导出一个配置对象,期望外部代码可以对其进行赋值。

    这是经典的模块使用方式

    • 模块导出一些配置方法,例如一个配置对象。
    • 在第一次导入时,对其进行初始化,写入其属性。可以在应用顶级脚本中进行此操作。
    • 进一步地导入使用模块。
    • admin.js 模块可能提供了某些功能(例如身份验证),但希望凭证可以从模块之外赋值到 config 对象:
    // 📁 admin.js
    export let config = { };
    
    export function sayHi() {
      alert(`Ready to serve, ${config.user}!`);
    }
    
    1
    2
    3
    4
    5
    6
    • init.js 中,导入了 config 并设置了 设置了 config.user
    import { config } from './admin.js';
    config.user = "Pete";
    
    1
    2
    • 在另一个脚本中导入 config
    // 📁 another.js
    import { sayHi } from './admin.js';
    
    sayHi(); // Ready to serve, Pete!
    
    1
    2
    3
    4

    这时可以正确显示被配置的内容。

  • import.meta :该对象包含关于当前模块的信息。

    它的内容取决于其所在的环境。在浏览器环境中,它包含当前脚本的 URL,或者如果它是在 HTML 中的话,则包含当前页面的 URL。

    🌰 例子 / 获取当前 HTML 的 URL:

    <script type="module">
      alert(import.meta.url);
    </script>
    
    1
    2
    3
  • 在一个模块中 顶级 this 的值为 undefined (对比来看,非模块的 this 是 全局对象)

    🌰 例子:

    <script>
      alert(this); // window
    </script>
    
    <script type="module">
      alert(this); // undefined
    </script>
    
    1
    2
    3
    4
    5
    6
    7

# 模块的 导出 / 导入

# 在声明之前导出

可以通过在声明之前放置 export 来标记 任意声明 为导出,无论声明的是变量,函数还是类都可以。

🌰 例子:

// 导出数组
export let months = ['Jan', ... ]

// 导出常量 
export const STANDARD_YEAR = 2048;
            
// 导出类
export class User {
	constructor(name) {
    this.name = name
  }                      
}
1
2
3
4
5
6
7
8
9
10
11
12

提示

** 导出 class / function 后没有分号。** 在类或者函数前的 export 不会让它们变成 函数表达式 (opens new window)。尽管被导出了,但它仍然是一个函数声明。大部分 JavaScript 样式指南都不建议在函数和类声明后使用分号。

# 声明之后导出

🌰 例子 / 先声明函数再导出:

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

function sayBye(user) {
  alert(`Bye, ${user}!`);
}

export {sayHi, sayBye};
1
2
3
4
5
6
7
8
9

一般最后的导出放在代码末尾,防止遗漏。

# 导入

  • 通常导入:把要导入的东西列在花括号 import {...} 中。

    🌰 例子:

    // 📁 main.js
    import {sayHi, sayBye} from './say.js';
    
    sayHi('John');
    sayBye('John');
    
    1
    2
    3
    4
    5
  • 将导入的内容为一个 对象

    🌰 例子:

    import * as say from './say.js';
    
    say.sayHi('John');
    say.sayBye('John');
    
    1
    2
    3
    4

提示

最好明确列出 要导入的内容

  • 现代的构建工具(webpack (opens new window) 和其他工具)将模块打包到一起并对其进行优化,以加快加载速度并删除未使用的代码。

    优化器(optimizer)就会检测到导入,并从打包好的代码中删除那些未被使用的函数,从而使构建更小。这就是所谓的「摇树(tree-shaking)」。

  • 明确列出要导入的内容会使得名称较短: sayHi() 而不是 say.sayHi()

  • 导入的显式列表可以更好地概述代码结构:使用的内容和位置。它使得代码支持重构,并且重构起来更容易。

# 别名

使用 as 让导入和导出具有不同的名字。

  • import as *

    🌰 例子:

    import {sayHi as hi, sayBye as bye} from './say.js';
    
    hi('John'); // Hello, John!
    bye('John');
    
    1
    2
    3
    4
  • export as *

    🌰 例子:

    export {sayHi as hi, sayBye as bye};
    
    1

    在导入时使用别名。

# export default

实际情况中有两种模块:

  • 包含库或函数包的模块。
  • 声明 单个实体 的模块。

大部分情况下,开发者倾向于使用第二种方式,以便每个「东西」都存在于它自己的模块中。

文件具有良好的命名,并且文件夹结构得当,那么代码导航会变得更容易。

模块的 默认导出语法export default 放在要导出的实体前。

🌰 例子:

  • 默认导出一个类 User
// 📁 user.js
export default class User {
  constructor(name) {
    this.name = name;
  }
}
1
2
3
4
5
6
  • 每个文件可能只有一个 export default ,所以导入不用 {}
// 📁 main.js
import User from './user.js'; // 不需要花括号 {User},只需要写成 User 即可

new User('John');
1
2
3
4

由于每个文件最多只能有一个默认的导出,因此导出的实体可能没有名称。

🌰 例子:

// 导出类
export default class { 
  constructor() { ... }
}
  
// 导出函数
export default function(user) {
  alert(`Hello, ${user}!`);
}

// 数组
export default ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

1
2
3
4
5
6
7
8
9
10
11
12
13

对于不加 default ,又不给命名,这样的导出是错误的。

# default

在某些情况下, default 关键词被用于 引用 默认的导出。

🌰 例子:

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

export {sayHi as default};
1
2
3
4
5

export default 的效果相同。

🌰 例子 / 当模块中又有 默认导出 又有别的一些导出时:

// 📁 user.js
export default class User {
  constructor(name) {
    this.name = name;
  }
}

export function sayHi(user) {
  alert(`Hello, ${user}!`);
}
1
2
3
4
5
6
7
8
9
10

导入时:

// 📁 main.js
import {default as User, sayHi} from './user.js';

new User('John');
1
2
3
4

另一种方法, 将所有东西 * 作为对象导入:

// 📁 main.js
import * as user from './user.js';

let User = user.default; // 默认的导出
new User('John');
1
2
3
4
5

提示

  • 对于命名的导出,在导入时一般使用正确相同的名称。

  • 对于默认的导出,一般使用文件名相同的名称形式导入(别的命名也可以,但一般为了变量命名规范,要与文件名对应)。

    🌰 例子:

    import User from './user.js';
    import LoginForm from './loginForm.js';
    import func from '/path/to/func.js';
    ...
    
    1
    2
    3
    4

所以即便是模块中只有一个实体导出,也一般使用命名导出,这也方便了 重新导出。

# 重新导出

重新导出 export ... from 允许导入内容,并立即将其导出(可能是用的是其他的名字)。

🌰 例子:

export {sayHi} from './say.js'; // 重新导出 sayHi

export {default as User} from './user.js'; // 重新导出 default
1
2
3

🌰 例子 / 实际应用例子:

当开发一个 package (一个包含大量模块的文件夹),其中一些功能是导出到外部的,并且其中一些模块仅仅是供其他 package 中的模块内部使用的 「辅助模块」。

文件结构:

auth/
    index.js
    user.js
    helpers.js
    tests/
        login.js
    providers/
        github.js
        facebook.js
        ...
1
2
3
4
5
6
7
8
9
10

当想要使用其中的功能,应该只从 index.js 导入:

import {login, logout} from 'auth/index.js'
1

所以 index.js 出口文件 应该要导出希望包中提供的所有功能:

这样做的目的,其他使用包的开发者不应该干预其内部结构,不应该搜索包的文件夹中的文件。所以只在 auth/index.js 中导出必要的部分,并保持其他内容「不可见」。

// 📁 auth/index.js

// 导入 login/logout 然后立即导出它们
import {login, logout} from './helpers.js';
export {login, logout};

// 将默认导出导入为 User,然后导出它
import User from './user.js';
export {User};
...
1
2
3
4
5
6
7
8
9
10

使用 export ... from ... 可以简写这个过程:

// 📁 auth/index.js
export {login, logout} from './helpers.js'

export {default as User} from './user.js'
1
2
3
4

export ... fromimport/export 相比的显着区别是 重新导出的模块在当前文件中不可用。所以在上面的 auth/index.js 中,不能使用重新导出的 login/logout 函数。

🌰 例子 / 重新导出 默认导出:

  • 如果 有一个 user.js 脚本,其中写了 export default class User ,并且想重新导出类 User
// 📁 user.js
export default class User {
  // ...
}
1
2
3
4

这时可能会遇到两个问题:

  • export User from './user.js' 无效。

    要重新导出默认导出,必须明确写出 export {default as User} ,就像上面的例子中那样。

  • export * from './user.js' 重新导出只导出了 命名的导出,但是忽略了默认的导出。

如果想要将 命名的导出和默认的导出都重新导出,那么需要两条语句:

export * from './user.js' // 重新导出命名的导出
export {default} from './user.js' // 重新导出默认的导出
1
2

所以 默认导出 给重新导出带来了不便。要重新导出最好命名。

# 动态导入

import(module) 表达式加载模块并返回一个 promise,该 promise resolve 为一个包含其所有导出的模块对象。可以在代码中的任意位置调用这个表达式。

🌰 例子 / 使用例子:

let modulePath = prompt("Which module to load?");

import(modulePath)
  .then(obj => <module object>)
  .catch(err => <loading error, e.g. if no such module>)
1
2
3
4
5

或者在 异步函数中使用 await

let module = await import(modulePath)
1

🌰 例子 / 完整例子:

  • 若有模块 say.js
// 📁 say.js
export function hi() {
  alert(`Hello`);
}

export function bye() {
  alert(`Bye`);
}
1
2
3
4
5
6
7
8
  • 那么可以这样动态导入:
let {hi, bye} = await import('./say.js');

hi();
bye();
1
2
3
4
  • 如果模块 say.js 有默认导出:
// 📁 say.js
export default function() {
  alert("Module loaded (export default)!");
}
1
2
3
4
  • 动态导入需要在导入后使用 default 属性:
let obj = await import('./say.js');
let say = obj.default;
say();
1
2
3

或者:

let {default: say} = await import('./say.js');
1

提示

import() 只是一种特殊语法,不能将 import 复制到一个变量中,或者对其使用 call/apply 。因为它不是一个函数。

# 总结

  • 一个模块就是一个文件。
  • 模块具有自己的本地顶级作用域,并可以通过 import/export 交换功能。
  • 模块代码只执行一次。导出仅创建一次,然后会在导入之间共享。

模块的导出:

  • 在声明一个 class/function/… 之前:
    • export [default] class/function/variable ...
  • 独立的导出:
    • export {x [as y], ...} .
  • 重新导出:
    • export {x [as y], ...} from "module"
    • export * from "module" (不会重新导出默认的导出)。
    • export {default [as y]} from "module" (重新导出默认的导出)。

模块的导入:

  • 导入命名的导出:
    • import {x [as y], ...} from "module"
  • 导入默认的导出:
    • import x from "module"
    • import {default as x} from "module"
  • 导入所有:
    • import * as obj from "module"
  • 导入模块(其代码,并运行),但不要将其任何导出赋值给变量:
    • import "module"
📢 上次更新: 2022/09/02, 10:18:16