JavaScript模块化方案

1、概念

模块化方案是一种用于组织、封装和复用代码的方法论或规范。它定义了如何将代码划分为独立的模块,并规定了模块之间的依赖关系、导入和导出机制等。模块化方案的目的是解决代码的可维护性、可读性和复用性等问题。通过模块化,开发者可以将代码分割成独立的功能模块,每个模块只关注自己的功能,并通过明确的接口与其他模块进行交互。这样可以降低代码的复杂度,提高代码的可维护性和可读性,并促进代码的复用和模块化开发。

JavaScript代码通常是运行在各个浏览器以及node环境中。由于语言本身在早期并没有原生的模块化支持,开发者使用了各种模块化方案来实现模块化。其中一些常见的模块化方案包括 CommonJS、AMD、CMD、UMD 等。

2、模块化方案

常用模块化方案有 CommonJS、ESModules、AMD、CMD、UMD 等。

1、简要介绍

  • CommonJS 是一种在服务器端 JavaScript 环境中实现模块化的规范,它使用requiremodule.exports来导入和导出模块,require同步加载 ,导入模块会阻塞后续代码的执行。CommonJS 最初是由 Ryan Dahl 在开发 Node.js 时提出的,旨在解决服务器端 JavaScript 的模块化问题。

  • ESModules,是ES6推出的原生模块化方案,现在新版浏览器多数都支持 ESModules 了。

  • AMD(Asynchronous Module Definition )是一种在浏览器端实现 异步模块加载 的规范,它使用definerequire来定义和引入模块。AMD 最初是由 RequireJS 提出的,旨在解决浏览器端 JavaScript 的模块化问题。

  • CMD (Common Module Definition ) 也是一种在浏览器端实现 异步模块加载 的规范,与CMD类似,也是使用definerequire来定义和引入模块。与AMD最大的区别是,CMD推崇在使用时加载模块 ,而AMD是推崇依赖前置

  • UMD(Universal Module Definition)是一种通用的模块化方案,它可以在不同的环境中运行,包括浏览器和 Node.js。UMD 兼容 CommonJS 和 AMD 的模块化规范,使得模块可以在不同的环境中使用。

2、CommonJS模块

在 ES6 之前,JavaScript 并没有原生的模块化支持,ES6 之前的JavaScript代码一般可遵循 CommonJS 规范,然后需要使用工具(如 webpack 等)将 CommonJS 模块化代码转为可识别的代码。ES6 提出了新的模块化方案 ESModules (所以是 JavaScript 原生模块化方案),并随着时间的推移,较新的浏览器开始慢慢支持 ESModules。要是早期的JavaScript就提供了一个模块化方案,就不用像现在这样,即便使用了 ES6 的 ESModules,还要考虑把它转为 CommonJS 代码,来兼容一些低版本浏览器。。。

下面简单说一下commonJS的几个规范:

(1)导出

  1. modules.export = {};
  2. exports.xxx = '../..';

注意,没有 exports = { name: 'xxx' } 这种写法,可能 export.name 中像是 exports对象name属性,所以感觉 exports = { name: 'xxx'} 这种写法是正确的。这只是一种规范。。。。

(2)导入

CommonJS 使用 require() 导入模块。注意,require()nodeJS 的全局方法,不止 CommonJS 使用 require() 作为模块导入,AMD(RequireJS)、SystemJS也使用require()。因为 CommonJS 最初就是为了在 NodeJS 环境中使用而设计的,所以 CommonJS 规范代码是可以直接运行在nodeJS环境下的,但是在 windows 下是不能用的。浏览器环境并不支持 CommonJS 规范,所以需要使用构建工具(如 webpack 等)将 CommonJS 规范代码转为浏览器可识别代码。

(3)模块标识符

模块标识符就是 require 模块的方式,一共有3种:

  • 相对路径: require('./')require('../')
  • 绝对路径: require('/');
  • 模块名: 不使用路径,使用内置模块、第三方模块或者自定义模块,如 require('path')require('lodash') 等。其中,自定义模块会从当前目录的node_modules中查找,如果没有就会向上一级继续查找 node_modules,直到根目录下没有就抛出异常。

由于模块标识符可能没有后缀,CommonJS 会按照下面规则定位模块:

  1. 首先,会尝试按照指定的模块名直接查找对应的文件或目录。
  2. 如果找不到与模块名完全匹配的文件或目录,会尝试按照以下顺序查找文件:
    • 按照模块名查找同名的 .js 文件。
    • 按照模块名查找同名的 .json 文件。
    • 按照模块名查找同名的目录,并在该目录中查找 package.json 文件。
      • 如果找到 package.json 文件,并且该文件中有 main 字段指定了入口文件,则使用指定的入口文件。
      • 如果找到 package.json 文件,但没有 main 字段,则默认使用该目录下的 index.js 文件作为入口文件。
    • 如果以上步骤都没有找到对应的文件或目录,则抛出一个错误。

(4)CommonJS规范特点

  • 所有代码都运行在模块作用域,不会污染全局作用域;
  • 模块是同步加载的,即只有加载完成,才能执行后面的操作;
  • 模块在首次执行后就会缓存,再次加载只返回缓存结果,如果想要再次执行,可清除缓存;
  • CommonJS 输出是值的拷贝(即,require返回的值是被输出的值的浅拷贝,模块内部的变化也不会影响这个值,不过引用类型的修改会影响输出值)。
  1. 同步加载,指的是代码中使用 require('xx') 导入模块的话,会先把xx模块的所以内容全部执行完毕(包括非导出部分),然后将导出的内容返回给导入模块。但是,我们不能简单理解为把被导入模块的代码直接放进当前模块。因为模块是运行在模块作用域内的,导入模块和被导入模块是相互独立的模块,代码、变量等都是相互隔离的。

m1.js

js 复制代码
console.log('m1模块')
module.exports = {
    name: 'Tonny',
    sex: 'boy'
}

m2.js

js 复制代码
require('./m1');
console.log('m2模块')

输出结果:

js 复制代码
m1模块
m2模块
  1. 模块加载后会缓存,导致同一个模块多次调用时的结果一样。比如:

m1.js

js 复制代码
var name = 'Tonny';
var sex = 'boy';

exports.name = name;
exports.sex = sex;

m2.js

js 复制代码
var m1 = require('./m1');
m1.sex = 'girl';
console.log(m1);

var m2 = require('./m1');
console.log(m2);

输出结果:

js 复制代码
{ name: 'Tonny', sex: 'girl' }
{ name: 'Tonny', sex: 'girl' }

首先,commonJS 输出的是值的拷贝,所以并不是 m1.sex 修改了 m1模块的值;其次,m1模块m2模块是相互独立的,不存在可以相互修改的问题。这就是因为缓存导致的。正式因为 CommonJS 的同步加载、缓存机制,导致有些人往往喜欢将其理解为被导入模块就是把代码直接放在了当前模块内了。

3、AMD规范和CMD规范

CommonJS 是为服务端 NodeJS 推出的,是同步加载。浏览器使用同步加载的话会很慢。所以会有 AMD 和 CMD ,它们采用异步的方式加载模块。它们是使用define的方式导出模块,定义如下:

js 复制代码
define(id?, dependencies?, factory)

AMD 和 CMD 最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同,二者皆为异步加载模块。 所谓处理时机指的是:

  • AMD 推崇依赖前置,如果在definedependencies参数中就定义好了依赖,js 很方便的就知道要加载的是哪个模块了,会立即加载它。
  • CMD 推崇就近依赖,通常在用到的时候再去require,而不是在dependencies中定义好依赖。

示例:

同样在match.js中加载m1模块

AMD 写法如下:

math.js

js 复制代码
define(['m1'], function (m1) {
  console.log('我是math')
  var add = function (a, b) {
    return a + b;
  }
  var print = function () {
    console.log(m1.name)
  }
  return {
    add: add,
    print: print
  }
})

CMD 写法如下:

math.js

js 复制代码
define(function (require, exports, module) {
  console.log('我是math')
  var m1 = require('m1');
  var add = function (a, b) {
    return a + b;
  }
  var print = function () {
    console.log(m1.name)
  }
  module.exports = {
    add: add,
    print: print
  }
})

假如此时m1.js中有一个语句是在m1模块被加载的时候打印出我是m1。那么AMD会先加载我是m1,而CMD会先执行我是math

4、ESModules

1、导出

(1)默认导出

使用export default关键词导出一个默认的值或者对象。一个模块只能有一个默认导出,导出后可以用任意名称来接收默认导出的值。

javascript 复制代码
export default expression;
export default function() { ... }
export default class { ... }
export default value;

(2)具名导出

使用export导出一个或多个具名对象。导出后需要使用相应的名称来接收导出的值。

javascript 复制代码
export { name1, name2, ..., nameN };
export { variable1 as name1, variable2 as name2, ..., nameN };
export const name1 = value1;
export function name1() { ... }
export class name1 { ... }

(3)中转

一个常见的应用场景是,当你有一个中间模块,需要将另一个模块中的特定项重新导出,以便其他模块可以更方便地访问这些项,同时又不需要直接引用原始模块。简单来说,B模块使用export...from...导入了A模块中的特定导出;C模块可以直接从B模块中导入A模块中的特定导出,不需要从A模块中导入了。

javascript 复制代码
export { name1, name2, ..., nameN } from './module.js';
export { default, name1, ..., nameN } from './module.js';
export * as name from './module.js';

当使用 ESModules 导出时,以下是正确的写法:

js 复制代码
1. 导出一个具名的变量:
export const message = 'Hello, World!';

2. 导出一个具名的函数:
export function sayHello() {
    console.log('Hello!');
}

3. 导出一个具名的类:
export class Person {
    constructor(name) {
    this.name = name;
    }
}
4. 导出一个具名的对象:
export const person = {
    name: 'John',
    age: 30
};

错误的导出如下:

js 复制代码
1. 导出一个匿名的变量、函数、类或对象:
// 错误示例
export const = 'Hello, World!';
export function() {
    console.log('Hello!');
}
export class {
    constructor(name) {
    this.name = name;
    }
}
export {
    name: 'John',
    age: 30
};

2. 导出一个默认的变量、函数、类或对象时,不能使用具名导出的语法:
// 错误示例
export default const message = 'Hello, World!';
export default function sayHello() {
    console.log('Hello!');
}
export default class Person {
    constructor(name) {
    this.name = name;
    }
}
export default {
    name: 'John',
    age: 30
};

3.不能导出赋值表达式、不能直接导出一个变量名:
// 错误示例
export message = 'Hello, World!';
const test = 'Hello, World!';
export test;

2、导入

在 ES Modules 中,有以下几种导入语法:

  1. 默认导入(Default Imports):

import myDefault from './myModule.js';

  1. 命名导入(Named Imports):

import { myValue } from './myModule.js';

  1. 导入所有导出项(默认和非默认都包含):

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

  1. 导入并重命名导出项:

import { myValue as renamedValue } from './myModule.js';

  1. 导入并重命名默认导出项:

import { default as myDefault } from './myModule.js';

3、错误示例

js 复制代码
# lib.js
export default { 
 a: 1,
 b: 2
}
# main.js
import { a,b } from './lib';

原因:默认导出,这种写法就是把{a: 1, b: 2}作为一个对象,因为默认导出只能是一个对象,而import { a,b } from './lib';这是对具名导出对象的导入方式,这导入导出没有任何关联性。正确做法如下:   

import module from './lib';
console.log(module.a);
console.log(module.b);

# main.js
import * as { a, b } from './lib';
import { default as { a, b } } from './myModule.js';

原因:这两个都是想把默认导出对象,导出后直接解构(其中import * 是导入全部)。但是`as`只能赋值给一个变量,别想太多。。。

还有,可以看看这位老师的描述深入解析ES Module(二):彻底禁用default export,不同的编译总会出各种不同的问题。。。。

3、打包

1、webpack打包工具

webpack和metro 打包工具在打包时会进行静态分析,没有被引用到的模块时不会被打包进最终的输出文件的。比如,如果代码适配多端,已知某端一定不存在某些模块,其实可以在打包的时候剔除。所以要做到打包的时候该模块一定没有被引用,比如import或者require

js 复制代码
// 分包
module.exports = is_Web ? require('./module.webModule') : require('./module.appModule');

// web独有模块
const webModules = {
    get TapView() {
        const module = require('./tapView');
        return module && module.__esModule ? module.default : module;
    },
    get BookView() {
        const module = require('./bookView');
        return module && module.__esModule ? module.default : module;
    }
}
// app独有模块
const appModules = {
    get TipsView() {
        const module = require('./tipsView');
        return module && module.__esModule ? module.default : module;
    }
}

如果在app端打包,可以写一个脚本,让上述代码变成如下:

js 复制代码
module.exports = is_Web ? require('./module.appModule') : require('./module.appModule');

这样,打包的时候,webModule就不会被打包进去了。

2、lib / es / dist

(1)dist 是webpack/metro的产物;eslibreact或者react native项目中两个常见的文件夹,用于存放 JavaScript 代码;

(2)es文件夹中存放的是经过Babel转译后的ESModules代码;

(3)lib文件夹中存放的是经过Babel转译后的CommonJS代码;

通常,在React Native项目中,开发者可以选择使用 es 文件夹中的代码进行开发和调试,而将 lib 文件夹中的代码用于构建和发布应用程序。这样可以在开发过程中享受到更好的开发体验,同时在生产环境中获得更好的兼容性。

3、引入es还是lib?

如果我们使用一个依赖,且该依赖目录包含eslib两个文件。那使用import 引入就是使用 es文件夹下内容,require 引入就是使用 lib文件夹下内容。

可以试验一下,我们引入一个npm依赖,如果使用 vscode 点击会发现跳转到了lib文件下,是因为 vscode 默认是 node 环境,链接时按照 require 来链接;但最终打包 webpack 是依据你当前导入语句来决定的。目前,开发基本都是使用 ES6,即使用 import 的方式导入模块,并使用 babel 进行向下兼容。在 Node.js 中,模块是通过 CommonJS 规范来定义和导出的。虽然从 Node.js 12 版本开始,Node.js 已经开始支持 ES Modules 了。但是,Node.js 仍然默认使用 CommonJS 规范来加载模块。如果你想在 Node.js 中使用 ES Modules,需要在文件中添加 .mjs 扩展名,并使用 --experimental-modules 标志来启用实验性的 ES Modules 功能。

4、Babel

提到了打包,就简单说一下 babel。注意,Babel 默认只转换新的 JavaScript 句法,而不转换新的 API,比如IteratorGeneratorSetMapProxyReflectSymbolPromise等全局对象,以及一些定义在全局对象上的方法(比如Object.assign)都不会转码。

举例来说,ES6 在Array对象上新增了Array.from方法。Babel 就不会转码这个方法。如果想让这个方法运行,必须使用babel-polyfill,为当前环境提供一个垫片。否则比如代码:

colors = Array.from(str.matchAll(regex), match => match[1]);

在commonJS环境下会报错。(polyfill 配置方式见bable配置polyfill)。但是,polyfill会增加额外的空间和性能开销!

资料:

参考了一下优秀博主的文章

1、webpack打包commonJS和esmodule区别

2、Webpack打包commonjs和esmodule混用模块的产物对比

3、commonJS和ES导入导出的写法

4、ES之export default内部结构分析

5、ES Module禁用export default

6、一篇不是标题党的CommonJS和ES模块

7、babel入门

相关推荐
迷雾漫步者19 分钟前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-1 小时前
验证码机制
前端·后端
燃先生._.2 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖3 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235243 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240254 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar4 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人4 小时前
前端知识补充—CSS
前端·css
GISer_Jing5 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试