模块

模块简介

何为模块

一个模块(module)就是一个文件。一个脚本就是一个模块。
模块之间可以相互加载,使用 exportimport 来交换功能:

  • export 关键字标记了可以从当前模块外部访问的变量和函数。
  • import 关键字允许从其他模块导入功能。

在 A 模块中定义方法,进行导出:

// sayHi.js
export function sayHi(user) {
  console.info(`Hello, ${user}!`);
}

在 B 模块导入进行使用:

// main.js
import { sayHi } from './sayHi.js';

console.info(sayHi); // function...
sayHi('John'); // Hello, John!

在浏览器中有专门的关键词 type="module" 来标记此部分属于模块,并根据导入的路径去查找对应的模块。

注意:

模块只能通过 http(s) 工作,无法通过本地 file 路径进行导入。

使用 import/export 会导入导出失败。

<!doctype html>
<script type="模块">
  import {sayHi} from './say.js';

  document.body.innerHTML = sayHi('John');
</script>

模块核心功能

  1. 始终使用 use strict
    所有模块代码自动处于严格模式下。

  2. 模块之间作用域独立
    每个模块内的内容互不可见,除了 export 的部分。
    即使在同一页面内,不同的模块之间同样互不可见。

    <script type="module">
    // 变量仅在这个 module script 内可见
    let user = "John";
    </script>
    
    <script type="module">
    console.info(user); // Error: user is not defined
    </script>
    
  3. 模块仅第一次被导入时解析

    • 同一个模块被导入到多个其他位置,那么它的代码只会执行一次(即在第一次被导入时),然后将其导出(export)的内容提供给进一步的导入者(importer)。

    模块定义规则

    • 顶层模块代码应该用于初始化,创建模块特定的内部数据结构。
    • 需要多次调用某些东西 —— 应该将其以函数的形式导出。
  4. import.meta:包含当前模块的信息

    // main.mjs
    import { sayHi } from './sayHi.mjs';
    // import.meta 对象包含关于当前模块的信息
    console.info(import.meta);
    /*
    [Object: null prototype] {
        resolve: [Function: resolve],
        url: 'file:///d:/DevApps/WorkSpace/Fronted/JS_Module/main.mjs'
        }
    */
    

    在 Node.js 环境使用模块运行,需要将后缀从 .js 改为 .mjs

  5. 模块中顶层的 thisundefined
    浏览器中非模块顶层为 window

    <script>
        console.info(this); // window
    </script>
    
    <script type="module">
        console.info(this); // undefined
    </script>
    
  6. 浏览器特定功能:模块脚本延迟加载
    defer 特性对外部脚本和内联脚本(inline script)的影响相同:

    • 模块脚本不阻塞 HTML 处理,与其他资源并行加载。
    • 模块脚本会等到 HTML 文档准备就绪才会运行。
    • 模块脚本按序执行。
    <script type="module">
        // button 对象可见,因为延迟加载
        console.info(typeof button); // object
    </script>
    

    相较于下面这个常规脚本:

    <script>
        // 先于上方执行显示,因为不进行延迟加载,此时 button 对象还未定义
        console.info(typeof button); // button 为 undefined
    </script>
    
  7. async 适用于内联模块脚本
    异步脚本会在准备好后立即运行,独立于其他脚本或 HTML 文档,即使 HTML 还未加载完成,或者其他脚本还在等待处理。

    • 非模块脚本:async 仅适用于外部脚本。
    • 模块脚本:async 也适用于内联脚本。
    <!-- 所有依赖都获取完成(analytics.js)然后脚本开始运行 -->
    <!-- 不会等待 HTML 文档或者其他 <script> 标签 -->
    <script async type="module">
        import {counter} from './analytics.js';
    
        counter.count();
    </script>
    
  8. 外部脚本

    • 相同 src 的外部脚本仅运行一次

      <!-- 脚本 my.js 被加载完成(fetched)并只被运行一次 -->
      <script type="module" src="my.js"></script>
      <script type="module" src="my.js"></script>
      
    • 跨源请求需要 CORS header
      远程服务器必须提供表示允许获取的 header Access-Control-Allow-Origin

      CORS 跨源请求

  9. 禁止裸模块
    必须提供具体的路径。

    浏览器原生环境不支持裸模块。

    但打包工具中允许是因为有自己的查找和钩子(hook)方法进行调整。

    import {sayHi} from 'sayHi'; // Error,“裸”模块
    // 模块必须有一个路径,例如 './sayHi.js' 或者其他任何路径
    
  10. 兼容性处理
    对于不支持模块的浏览器可以增加提醒:

    <script type="module">
        console.info("Runs in modern browsers");
    </script>
    <!-- 对于不支持的环境,使用 nomodule -->
    <script nomodule>
        console.info("Modern browsers know both type=module and nomodule, so skip this")
        console.info("Old browsers ignore script with unknown type=module, but execute this.");
    </script>
    

导入与导出

针对的是静态导入

声明前导出

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

导出 class/function 后没有分号。

// 导出数组
export let months = ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

// 导出 const 声明的变量
export const MODULES_BECAME_STANDARD_YEAR = 2015;

// 导出类
export class User {
  constructor(name) {
    this.name = name;
  }
}

声明与导出分离

先完成声明,在结尾处统一导出。

技术上允许在模块顶端进行导出。

function sayHi(user) {
  console.info(`Hello, ${user}!`);
}

function sayBye(user) {
  console.info(`Bye, ${user}!`);
}

export {sayHi, sayBye}; // 导出变量列表

全部导入 *

通常导入方式:

import {sayHi, sayBye} from './say.js';

sayHi('John'); // Hello, John!
sayBye('John'); // Bye, John!

允许对导入的内容起一个别名对象:

import * as say from './say.js';

say.sayHi('John');
say.sayBye('John');

但通常不建议全部导入

  1. 不利于打包构建工具的优化:构建工具的优化器会对打包的代码进行检测未使用的函数,并进行删除(称为“摇树”/tree-shaking),加快加载速度。
  2. 名称冗长:明确列出要导入的内容会使得名称较短:sayHi() 而不是 say.sayHi()
  3. 可读性差:导入的显式列表可以更好地概述代码结构,易于重构和阅读。

别名导入与导出

别名导入 import "as"

import {sayHi as hi, sayBye as bye} from './say.js';

hi('John'); // Hello, John!
bye('John'); // Bye, John!

别名导出 export "as"

export {sayHi as hi, sayBye as bye};

导入时:

import * as say from './say.js';

say.hi('John'); // Hello, John!
say.bye('John'); // Bye, John!

默认导出 export default

适用于声明单个实体的模块,即每个模块只做一件事,避免混乱。

export default 放在要导出的实体前:

export default class User { // 只需要添加 "default" 即可
  constructor(name) {
    this.name = name;
  }
}

对于默认导出的内容无需使用花括号 {}

import User from './user.js'; 
// 不需要花括号 {User},只需要写成 User 即可

new User('John');

同样支持先定义,之后再默认导出:

function sayHi(user) {
  console.info(`Hello, ${user}!`);
}

// 就像我们在函数之前添加了 "export default" 一样
export {sayHi as default};

默认导出允许没有名称:

export default class { // 没有类名
  constructor() { ... }
}

export default function(user) { // 没有函数名
  console.info(`Hello, ${user}!`);
}

// 导出单个值,而不使用变量
export default ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

默认导出与命名导出对比

命名的导出默认的导出
export class User {...}export default class User {...}
import {User} from ...import User from ...

建议使用命名导出,使得更加清晰,以及方便重新导出。

重新导出

允许对一个模块导入的内容进行重新导出。

一般用于大量模块的项目中,提供一个统一的导入路由文件。

重新导出语法

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

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

例如,存在大量模块的项目:

auth/
    index.js
    user.js
    helpers.js
    tests/
        login.js
    providers/
        github.js
        facebook.js

我们可以提供 index.js 作为统一的入口,用来暴露自定义的包,这样使用者就不应该干涉包的内部结构以及对包内部进行搜索等。

重新导出默认导出时

  1. 不能直接使用

    // 导出无效
    export User from './user.js';
    
  2. export * 只导出了命名导出,忽略了默认导出

    export * from './user.js'
    
  3. 需要两句才能将命名和默认同时导出

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

动态导入

静态导入非常严格:

  • 不能动态生成 import 的任何参数。
  • 模块路径必须是原始类型字符串,不能是函数调用。

因此无法根据条件进行选择性的 import

import() 表达式

看似为函数,其实并非函数,无法使用 apply/call,或者将其赋值到变量中。

  1. 表达式加载模块并返回一个 Promise
  2. Promise resolve 为一个包含其所有导出的模块对象。
  3. 可以在代码中的任意位置调用这个表达式。

模块代码

// say.js
export function hi() {
  console.info(`Hello`);
}

export function bye() {
  console.info(`Bye`);
}

动态导入

let {hi, bye} = await import('./say.js');

hi();
bye();

默认导出情况

export default function() {
  console.info("Module loaded (export default)!");
}

// 导入默认对象,使用 default
let obj = await import('./say.js');
let say = obj.default;
// or, in one line: let {default: say} = await import('./say.js');

say();

将动态导入封装成函数时,参考 Promise 或者 async/await 即可:

async function load() {
    let say = await import('./say.js');
    say.hi(); // Hello!
    say.bye(); // Bye!
    say.default(); // Module loaded (export default)!
}

动态导入在常规脚本中工作时,它们不需要 script type="module"