【JS】模块(二)

上一篇文章中,我们认识了模块,本文则继续学习导入和导出指令,以及动态导出指令。

导出和导入

原文链接 导出和导入指令有几种语法变体:

  1. 在声明前导出
  2. 导出与声明分开
  3. Import *
  4. Import "as"
  5. Export "as"
  6. Export default
  7. 重新导出

下面我们挨个学!!

在声明前导出

  • 可以在任意"声明"前加 export 导出:变量、函数、类都行。
  • 这类导出属于"命名导出"(named export)。

示例:

js 复制代码
// 导出变量(数组)
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;
  }
}

// 导出函数(声明式)
export function sayHi(user) {
  alert(`Hello, ${user}!`);
} // 这里无需分号
  • 导入命名导出时用花括号并按名称匹配:
js 复制代码
import { months, MODULES_BECAME_STANDARD_YEAR, User, sayHi } from './module.js';

为什么## export function/class 后面没有分号?

export 修饰的是"声明",不会把它们变成"表达式"。 函数声明、类声明本身就不需要分号;只有表达式语句通常以分号结束。

对比:

js 复制代码
// 声明(不需要分号)
export function f() {}
export class C {}

// 表达式(通常以分号结束,或靠 ASI)
export const g = function () {};
export const D = class {};

导出与声明分开

可以先声明,后在尾部集中 export,便于统一管理导出接口。

js 复制代码
// say.js
function sayHi(user) { alert(`Hello, ${user}!`); }
function sayBye(user) { alert(`Bye, ${user}!`); }

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

技术上也可以把 export 写在函数上方;二者等价。团队通常选择一种风格保持一致。

Import * as obj form "..."

这个意思是把一个模块的所有导出收集到命名空间对象上:

js 复制代码
import * as say from './say.js';

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

通常不推荐这种"通通导入",为什么呢?

  • 构建优化(tree-shaking)对"显式命名导入"更友好,能更好地移除未使用代码。
  • 调用名更简洁:sayHi() 而非 say.sayHi()
  • 显式列出依赖能更清晰地表达模块边界,便于重构

导入导出重命名:as

导入重命名:

js 复制代码
import { sayHi as hi, sayBye as bye } from './say.js';
hi('John');
bye('John');

导出重命名:

js 复制代码
// say.js
export { sayHi as hi, sayBye as bye };

// 使用
import * as say from './say.js';
say.hi('John');
say.bye('John');

Export default

用于"一个模块只暴露一个主要实体"的场景:

js 复制代码
// user.js
export default class User {
  constructor(name) { this.name = name; }
}

// 导入(无花括号,自选名称)
import User from './user.js';
new User('John');

一个文件最多一个默认导出,可无名称:

js 复制代码
export default class { /* ... */ }
export default function (user) { alert(`Hello, ${user}!`); }
export default ['Jan', 'Feb', 'Mar', /* ... */];

错误示例(非默认导出必须有名):

js 复制代码
export class { /* Error */ }

也可用"分离写法"导出默认:

js 复制代码
function sayHi(user) { alert(`Hello, ${user}!`); }
export { sayHi as default }; // 等价于 export default sayHi

同时存在默认与命名导出(不推荐频繁混用,但语法允许):

js 复制代码
// user.js
export default class User { /* ... */ }
export function sayHi(user) { /* ... */ }

// 导入默认与命名
import { default as User, sayHi } from './user.js';

// 或者
import * as user from './user.js';
const User = user.default;

那么什么时候用默认导出,什么时候用命名导出呢?

  • 命名导出更"显式",强制一致的名称,便于工具与重构。

  • 默认导出名称可随意,团队容易出现不同命名,可能影响一致性。

  • 常见团队约定:

    • 若使用默认导出,导入名与文件名保持一致(提高一致性)
    • 或者干脆团队规范"只用命名导出",即便模块只导出一个东西

重新导出

"重新导出"让你在一个模块中把其他模块的导出"转手"导出,常用于创建单一入口(facade)或整理 API 表面,而无需在中间模块里显式使用这些标识符。

1.核心目的:模块封装(Encapsulation)与接口抽象(Interface Abstraction)

在软件工程中,封装是将实现细节隐藏起来,只暴露一个清晰、稳定的公共接口的过程。这对于构建可维护、可扩展和解耦的系统至关重要。

export ... from ... 语法是实现这一目标的关键工具,特别是在创建 "包"(Packages)或 "库"(Libraries)时。一个包通常由多个内部模块组成,但消费者(使用该包的开发者)不应该、也不需要了解其内部文件结构。

index.js 或类似的主入口文件在这种设计模式中扮演了 "门面"(Facade)的角色。 门面模式是一种结构型设计模式,它为一组复杂的子系统提供一个统一的、简化的接口。

export ... from ... 语法正是实现门面模式的专用语言特性。它允许入口文件从包的内部模块中聚合(aggregate)并导出(export)指定的公共 API,而无需在入口文件自身的作用域中创建任何本地绑定。

示例:

javascript 复制代码
// 📁 my-package/index.js (门面文件)

// 从内部模块重新导出公共 API。 // 这些被重新导出的绑定在 `index.js` 文件内部是不可用的。 
export { a, b } from './internal/moduleA.js'; 
export { default as C } from './internal/moduleB.js'; 
export * from './internal/moduleC.js'; // 只重新导出命名导出,会忽略默认导出

使用者(Consumer):

javascript 复制代码
// 消费者只与稳定、公开的门面进行交互。
import { a, C } from 'my-package';

这样做的好处:

  • API 稳定性 :我们可以自由地重构包的内部文件结构(例如,将 amoduleA.js 移动到 moduleA_v2.js),只需要更新 index.js 中的 from 路径即可,而不会破坏消费者的代码。
  • 关注点分离:入口文件只负责定义公共接口,不包含任何实现逻辑。这使得 API 的结构一目了然。
  • 防止内部依赖泄露 :消费者被引导只依赖于官方的入口,避免了他们直接导入内部模块(如 import { a } from 'my-package/internal/moduleA.js'),这种不稳定的深度导入会使重构变得异常困难。

2. 与 import/export 的技术差异

export ... from ... 不仅仅是 importexport 的语法糖,它在语义上有本质区别。

场景一:import 后再 export

javascript 复制代码
// 📁 index.js
import { utility } from './utils.js';
// ... 这里的其他代码可以使用 'utility' ...
export { utility };

执行过程:

  1. ./utils.js 模块被解析和评估。
  2. utility 绑定被导入到 index.js模块作用域(Module Scope)中。
  3. 此时,utilityindex.js 的一个本地绑定,可以在该文件中被调用或引用。
  4. export 语句创建一个指向这个本地绑定的导出项。

场景二:export ... from ... (Re-export)

javascript 复制代码
// 📁 index.js
export { utility } from './utils.js';
//'utility' 在这里是不可用的。如果尝试使用,会抛出 ReferenceError。

执行过程:

  1. ./utils.js 模块被解析。
  2. export ... from ... 语句在 index.js导出列表 (Export List)中创建一个条目,该条目直接链接到 ./utils.js 模块的 utility 绑定。
  3. 关键区别utility 绑定不会 被添加到 index.js 的模块作用域中。它只是一个传递(pass-through)或代理(proxy)的导出。这使得 index.js 本身更加轻量,因为它不持有对这些绑定的直接引用。

3. 对默认导出的处理 (export default)

ECMAScript 模块规范对命名导出(named exports)和默认导出(default export)进行了区分。default 实际上是一个特殊的导出名。

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

    • 分析 :这条语句非常明确。它指示模块加载器:"从 ./user.js 中获取名为 default 的导出,并以 User 这个名字将其添加到当前模块的导出列表中。"
    • 这是重新导出 default 导出的标准且唯一明确的方式,因为它遵循了与重命名命名导出 (export { oldName as newName }) 一致的逻辑。
  2. export * from './user.js' (Star Export)

    • 规范定义 :根据 ECMA-262 规范,export * from 'module'(星号导出)会遍历指定模块的所有命名导出 ,并将它们添加到当前模块的导出列表中。这个过程明确排除了 default 导出
    • 设计原因 :这种设计的背后有几个原因。其一,如果星号导出也包括 default,那么应该如何命名它呢?自动命名可能会导致冲突或不可预测的行为。其二,default 导出通常被认为是一个模块的"主要"或"唯一"导出,规范鼓励对其进行显式处理,而不是通过通配符隐式处理。
  3. 组合导出 (Combined Re-export)

    因此,要完整地重新导出一个模块的所有内容(命名和默认),需要两条语句的组合,这并非语言的"怪癖",而是其设计规则的直接体现:

    javascript 复制代码
    // Re-exports all named exports.
    export * from './user.js';
    // Explicitly re-exports the default export.
    export { default } from './user.js';

注意:

import/export 有位置无关性:importexport 语句是"静态的" 。 "静态"在这里意味着,JavaScript 引擎在执行 任何代码之前,会先进行一个"链接"阶段。在这个阶段,它会扫描所有模块,找出所有的 importexport 语句,并建立起模块之间的依赖关系图。它会确定哪个变量是从哪个模块导入的,哪个变量要被导出。正因为 importexport 在代码执行前就已经被处理完毕,所以它们写在文件的顶部还是底部,对于程序最终的运行结果来说,没有任何区别

import/export 必须在顶层作用域:importexport 必须位于模块的顶层。 这意味着你不能把它们放在代码块({...})中,比如 if 语句、for 循环、或者函数内部。

动态导入

原文链接

动态导入(Dynamic Import)通过 import() 表达式实现,与传统的静态导入不同,它可以在运行时按需加载模块。

静态导入 vs 动态导入

上文中其实已经提到了静态导入的限制

静态导入的限制:

javascript 复制代码
// ❌ 这些都不被允许
import ... from getModuleName(); // 路径必须是字符串字面量
if (condition) {
  import ...; // 不能在条件语句中使用
}
{
  import ...; // 不能在代码块中使用
}

动态导入的优势:

javascript 复制代码
// ✅ 这些都是可以的
let modulePath = './myModule.js';
import(modulePath).then(module => {
  // 使用模块
});

// 条件加载
if (condition) {
  const module = await import('./conditionalModule.js');
}

// 运行时决定加载哪个模块
const moduleName = userChoice ? './moduleA.js' : './moduleB.js';
const module = await import(moduleName);

动态导入的基本用法

1. Promise 形式

javascript 复制代码
import('./myModule.js')
  .then(module => {
    module.someFunction();
  })
  .catch(err => {
    console.error('模块加载失败:', err);
  });

2. async/await 形式

javascript 复制代码
async function loadModule() {
  try {
    const module = await import('./myModule.js');
    module.someFunction();
  } catch (err) {
    console.error('模块加载失败:', err);
  }
}

处理不同的导出类型

具名导出

javascript 复制代码
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// 使用
const { add, subtract } = await import('./math.js');
console.log(add(5, 3)); // 8

默认导出

javascript 复制代码
// utils.js
export default function greet(name) {
  return `Hello, ${name}!`;
}

// 使用方式1
const module = await import('./utils.js');
const greet = module.default;
console.log(greet('张三'));

// 使用方式2
const { default: greet } = await import('./utils.js');
console.log(greet('张三'));

混合导出

javascript 复制代码
// api.js
export default class ApiClient { }
export const API_URL = 'https://api.example.com';
export function formatData(data) { }

// 使用
const { default: ApiClient, API_URL, formatData } = await import('./api.js');

实际应用场景

1. 代码分割和懒加载

javascript 复制代码
// 只有当用户点击按钮时才加载重的图表库
async function showChart() {
  const { Chart } = await import('./chartLibrary.js');
  new Chart(document.getElementById('chart'), config);
}

2. 条件性功能加载

javascript 复制代码
// 根据设备类型加载不同的功能
async function loadDeviceSpecificFeatures() {
  if (window.innerWidth < 768) {
    const mobileFeatures = await import('./mobileFeatures.js');
    mobileFeatures.initMobileUI();
  } else {
    const desktopFeatures = await import('./desktopFeatures.js');
    desktopFeatures.initDesktopUI();
  }
}

3. 国际化

javascript 复制代码
async function loadLanguage(lang) {
  try {
    const translations = await import(`./locales/${lang}.js`);
    return translations.default;
  } catch (err) {
    // 回退到默认语言
    const defaultTranslations = await import('./locales/en.js');
    return defaultTranslations.default;
  }
}

4. 插件系统

javascript 复制代码
class PluginManager {
  async loadPlugin(pluginName) {
    try {
      const plugin = await import(`./plugins/${pluginName}.js`);
      return new plugin.default();
    } catch (err) {
      console.error(`无法加载插件 ${pluginName}:`, err);
      return null;
    }
  }
}

重要注意事项

  1. 不是函数调用import() 是特殊语法,不能被赋值或使用 call/apply
javascript 复制代码
// ❌ 错误用法
const dynamicImport = import;
dynamicImport('./module.js'); // 不会工作
  1. 返回 Promise:始终返回 Promise,即使在同步环境中也是如此
  2. 模块缓存:同一个模块只会被加载一次,后续导入会返回缓存的实例
  3. 错误处理:始终要处理可能的加载错误

性能考虑

优点:

  • 减少初始包大小:不立即需要的代码不会包含在初始bundle中
  • 更快的首屏加载:只加载必要的代码
  • 更好的用户体验:按需加载避免了不必要的网络请求

缺点:

  • 网络延迟:动态加载需要额外的网络请求
  • 加载状态处理:需要处理加载中的状态

动态导入是现代JavaScript中非常强大的特性,特别适合大型应用的性能优化和代码组织。它让我们能够构建更加高效、灵活的应用程序。

相关推荐
云枫晖3 小时前
JS核心知识-执行上下文
前端·javascript
麦当_3 小时前
TanStack Router File-Based Router Mask 完全指南
前端·javascript·设计模式
珍珠奶茶爱好者3 小时前
vue二次封装ant-design-vue的table,识别columns中的自定义插槽
前端·javascript·vue.js
烛阴3 小时前
【TS 设计模式完全指南】用适配器模式优雅地“兼容”一切
javascript·设计模式·typescript
三脚猫的喵3 小时前
微信小程序中实现AI对话、生成3D图像并使用xr-frame演示
前端·javascript·ai作画·微信小程序
炒毛豆4 小时前
移动端响应式px转换插件PostCSS的使用~
前端·javascript·postcss
Swift社区4 小时前
为什么 socket.io 客户端在浏览器能连上,但在 Node.js 中报错 transport close?
javascript·node.js
wordbaby4 小时前
用 window.matchMedia 实现高级响应式开发:API 全面解析与实战技巧
前端·javascript
薄雾晚晴4 小时前
Rspack 实战,构建流程升级:自动版本管理 + 命令行美化 + dist 压缩,一键输出生产包
前端·javascript