模块化

82 阅读27分钟

### 一、js 模块化的发展史和构建工具的变化

javascript 语言设计之初,只是作为一个简单的脚本语言用来丰富网站的功能,并不像 java、c++ 那样有 module 的概念,发展到现在的模样,也经历了相当长的时间。 这段时间,可以简单归纳为:

  • 青铜时代 - no module;
  • 白银时代 - cjs、amd、cmd、umd、esm 相继出现;
  • 黄金时代 - 组件模块化;

不同的时代,构建工具也不同。

1. 青铜时代

由于没有 module 的概念, javascript 无法在语言层面实现模块之间的相互隔离、相互依赖,只能由开发人员手动处理。 相应的,早期的 web 开发也比较简单甚至简陋:

通过对象、iife(或者闭包)的方式实现模块隔离;

通过手动确定 script 的加载顺序确定模块之间的依赖关系。 jsp 开发模式,没有专门的前端,html、js、css 代码通常也由后端开发人员编写。

为了节省带宽和保密,通常需要对前端代码做压缩混淆处理。这个时候,构建工具为 YUI Tool + Ant。

2. 白银时代

chrome v8 引擎 和 node 的横空出世,给前端带来了无限的可能。 同时,javascript 的模块化标准也有了新的发展:

  • commonjs 规范,适用于 node 环境开发。
  • amd、cmd 规范,适用于浏览器环境。
  • umd,兼容 amd、commonjs,代码可以同时运行在浏览器和 node 环境。
  • ESM,即 ES6 module(这个时候还不是很成熟);

同时还出现了 less、sass、 es6、 jslint、 eslint、typescript 等新的东西, 前端角色也开始承担越来越重要的作用,慢慢的独立出来。 有了 node 提供的平台,大量的工具开始涌现:

  • requirejs 提供的 r.js 插件,可以分析 amd 模块依赖关系、合并压缩 js、优化 css;
  • less / sass 插件,可以将 less / sass 代码转化为 css 代码;
  • babel,可以将 es6 转化为 es5;
  • typescript,将 ts 编译为 js;
  • jslint / eslint,代码检查; ...

这个时候,我们可以将上面的的这些操作配置成一个个任务,然后通过 Grunt / Gulp 自动执行任务。

3. 黄金时代

基于 Angular、Vue、React 三大框架和 Webpack 的使用,组件模块化成为前端开发的主流模式。同时 ESM 规范也原来越成熟,被更多的浏览器支持。

以 React 和 Webpack 为例,通常我们会将一个应用涉及到的所有的功能拆分为一个个组件,如路由组件、页面组件、表单组件、表格组件等,一个组件对应一个源文件,然后通过 Webpack 将这些源文件打包。在开发过程中,还会通过 Webpack 开启一个 local server,实时查看代码的运行效果。

Webpack 是一个静态模块打包器,它会以 entry 指定的入口文件为起点,分析整个项目内各个源文件之间的依赖关系,构建一个模块依赖图 - module graph,然后将 module graph 分离为多个 bundle。在构建 module graph 的过程中,会使用 loader 处理源文件,将它们转化为浏览器可以是识别的 js、css、image、音视频等。 随着时间的发展, Webpack 的功能越来越来强大,也迎来诸多对手。

Webpack1
   |
   |
Rollup 出现(推崇 ESM 规范,可以实现 tree shaking, 打包出来的代码更干净)
   |
   |
Webpack2(也实现了 tree shaking, 但是配置还是太繁琐了)
   |
   |
Parcel (号称 0 配置)
   |
   |
Webpack4(通过 mode 确定 development 和 production 模式,各个模式有自己的默认配置)
   |
   |
Webpack5(持久化缓存、module federation)

Esbuild(采用 go 语言开发,比 Webpack 更快)

Vite(推崇 ESM 规范,开发模式采用 nobundle,更好的开发体验)

丰富的构建工具,形成了百花绽放的局面,可用于不同的情形,给开发人员带来了越来越多的选择。

### 二、模块化的发展的探索

模块化其实就是指解决一个复杂问题时自顶向下逐层把系统划分成若干模块的过程,每个模块完成一个特定的子功能(单一职责),模块内部私有,对外暴露接口与其他模块通信,所有的模块按某种方法组装起来,成为一个整体,从而完成整个系统所要求的功能。

1. 文件划分

在早期刀耕火种的前端三件套时代,HTML 中通过引入到多个不同逻辑的 js 文件,构成了最原始的模块化实现方式——文件划分模式。

/ moduleA.js
let moduleName = "moduleA";
// moduleB.js
let getModuleName = () => {
  console.log("This is moduleB!");
};
// entry.js
console.log(moduleName); // moduleA
getModuleName(); // This is moduleB!
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./moduleA.js"></script>
    <script src="./moduleB.js"></script>
    <script src="./entry.js"></script>
  </body>
</html>

上述案例代码中,entry.js 分别使用了 moduleA、moduleB 的变量与函数,实现了简易的代码分离与组织,但随着代码量的增大及项目复杂度的增加,文件划分方式存在很多问题。

  • 命名冲突: moduleA 和 moduleB 定义在全局,并没有构造私有空间,如果 moduleB 同时定义了 moduleName,两个模块间就会发生变量名的覆盖,引起变量冲突
  • 依赖模糊: 无法清晰的确定模块之间的依赖关系和加载顺序。文件划分方式中被依赖项需要引用在前,也就是说 script 引入顺序仅仅提供了某些 js 文件的前后依赖,并不能确切的反应各模块间的依赖关系。
  • 全局作用域污染
  • 维护性差: 代码间组织方式混乱,后期维护难度较高
  • 复用性差

文件划分模式的缺点简直可以举一大箩筐,后续模块化的演变正是对缺点的不断优化。

2. 命名空间

命名空间是另一种模块化的实现方案,其目的在于解决命名冲突问题。

命名空间的核心实现在于将变量与函数声明为对象的属性,只要外层对象命名不发生冲突,内部的成员就不会发生覆盖。

// moduleA.js
let moduleA = {
  moduleName: "moduleA",
  getModuleName() {
    return this.moduleName;
  },
};
// moduleB.js
let moduleB = {
  moduleName: "moduleB",
  getModuleName() {
    return this.moduleName;
  },
};
// entry.js
console.log(moduleA.getModuleName()); // moduleA
console.log(moduleB.getModuleName()); // moduleB

命名空间的写法一定程度上减少了命名冲突问题,但其本质写法为对象,并没有构建私有作用域,所有模块的成员可以被外部访问,这违背了模块化的设计理念,同时也无法管理模块间的依赖问题。

// entry.js
moduleA.moduleName = "alterModule";
moduleA.getModuleName(); // alterModule

3. IIFE

JavaScript 中函数可以生成函数作用域,外部无法访问内部定义的成员,使用闭包思想,可以将内部成员暴露给外部使用。因此可以借助函数+闭包特性实现私有数据和共享方法,由于该函数只是为了辅助模块化的实现,因此采用 IIFE 立即执行函数的模式。

当外部使用 IIFE 构建的模块时,只能通过模块提供的接口进行操作,无法访问私有成员。这种方式成功的解决了命名冲突以及私有空间的问题,同时也是现代模块化规范的思想来源。

// moduleA.js
let moduleA = (function () {
  const _moduleName = "moduleA";
  const getModuleName = function () {
    return _moduleName;
  };
  return { getModuleName };
})();
// moduleB.js
let moduleB = (function () {
  const _moduleName = "moduleB";
  const getModuleName = function () {
    return _moduleName;
  };
  return { getModuleName };
})();
// entry.js
console.log(moduleA.getModuleName()); // moduleA
console.log(moduleB.getModuleName()); // moduleB

moduleA._moduleName = "alterModule";
console.log(moduleA.getModuleName()); // moduleA

新的问题来了,IIFE 方式可以解决依赖问题吗?

这时可以使用引入依赖,即通过 IIFE 的函数参数将依赖传入。

// moduleC.js
let moduleC = (function () {
  const _moduleName = "moduleC";
  const _moduleData = { x: 1 };
  const getModuleData = function () {
    console.log("Module: ", _moduleName, " Data: ", _moduleData.x);
  };
  return { getModuleData };
})();
// moduleA.js
let moduleA = (function (module) {
  const _moduleName = "moduleA";
  const getModuleName = function () {
    console.log(_moduleName);
    console.log(module.getModuleData());
  };
  return { getModuleName };
})(moduleC);
// entry.js

// Module:  moduleC  Data: 1
console.log(moduleA.getModuleName()); // moduleA

4. 依赖注入

在 IIFE 方式之后,后续又出现了多种模块化方案,例如模板定义依赖、注册定义依赖、Sandbox 模式、依赖注入。

依赖注入的作为重要的开发思想,解耦大杀器,目前已经遍布前端世界,vue3、react、angular、nest 等都有使用,因此本文来特地讲解一下。

在解释依赖注入之前,我们要先知道一个定义,控制反转 (Inverse of Control),也就是我们经常听到的 IOC

  • 控制反转(Inversion of Control,缩写为IoC)

是一种编程思想,它的主要目的是将程序中组件之间的控制关系颠倒过来。

在传统的编程模式中,每个对象都负责创建或获取它所需要的其他对象,而在IoC中,这些对象的创建和管理被转移到一个外部容器中,由容器来管理它们的生命周期并将它们注入到需要使用它们的组件中。 这样可以增强程序的灵活性、可扩展性、可维护性和可测试性。

依赖注入是实现控制反转思想的其中一种实现

  • 依赖注入

依赖注入(Dependency Injection) 是一种编程模式,它的主要目的是解耦组件之间的依赖关系。

在依赖注入中,一个对象不再负责创建或获取它所需要的其他对象,而是由外部的容器来负责将这些依赖注入进来。这样可以使得代码更加灵活、可测试和可维护。

  • 实现
// 具象化三大模块
function vue() {
  return {
    module: "vue",
    ability: "code module",
  };
}

function vite() {
  return {
    module: "vite",
    ability: "bunde module",
  };
}

function server() {
  return {
    module: "server",
    ability: "data module",
  };
}

假设现在有 vue、vite、server 三个模块,我们试图借助这三个模块开发一些有意思的网站,可以将依赖模块借鉴 IIFE 引入依赖的方式以函数参数传入。

var developWeb = function (vue, vite, server) {
  let v = vue();
  let vi = vite();
  let s = server();
  console.log(v.ability, vi.ability, s.ability);
};

但问题来了,依赖的模块该如何进行管理那,例如后续开发需要依赖新的模块,只能修改函数参数或者构造新的函数,这可是大忌。 这时候,依赖注入闪亮登场,来分析一下实现要点。

  • 可以实现依赖关系的注册(即 IOC 容器来存储所有的依赖)
  • 依赖注入器可以接受一个函数,注入成功后返回一个可以获取所有依赖资源的函数
  • 注入应保持被传递函数的作用域
  • 被传递的函数应该能够接受自定义参数,而不仅仅是依赖描述

下面来简单实现一个依赖注册器 injector,injector 由依赖容器、注册依赖函数、依赖注入函数三部分组组成。对于 resolve 函数,deps 代表被依赖 key 数组,func 代表需要注入依赖的函数,scope 代表 func 函数作用域。

const injector = {
  dependencies: {}, // 依赖管理中心
  register: function (key, value) {
    // 注册依赖关系
    this.dependencies[key] = value;
  },
  resolve: function (deps, func, scope) {}, // 依赖注入
};

resolve 函数目的在于将 deps 涉及的依赖注入到 func 函数中,实现并不复杂。首先根据 deps 数组将所需的依赖从 dependencies 取出添加到 dependModule 数组中,然后在返回的函数中使用 apply 方法传递 scope 作用域及其他参数。

var injector = {
  dependencies: {},
  register: function (key, value) {
    this.dependencies[key] = value;
  },
  resolve(deps, func, scope) {
    const dependModule = [];
    for (let i = 0; i < deps.length; i++) {
      const d = deps[i];
      // 分析依赖是否存在,收集所需依赖
      if (this.dependencies[d]) {
        dependModule.push(this.dependencies[d]);
      } else {
        throw new Error(d + "依赖不存在");
      }
    }
    return function () {
      // 传递函数作用域
      // 接受其他参数
      func.apply(
        scope,
        dependModule.concat(Array.prototype.slice.call(arguments, 0))
      );
    };
  },
};

来看看使用:

injector.register("vue", vue);
injector.register("vite", vite);

injector.resolve(["vue", "vite"], function (vue, vite) {
  let v = vue();
  let vi = vite();
  console.log(v.ability, vi.ability);
})();

// 传入其他参数
injector.resolve(["vue", "vite"], function (vue, vite, other) {
  let v = vue();
  let vi = vite();
  console.log(v.ability, vi.ability, other);
})("other");

到这里,实现了一个简单的依赖注入,但上述实现并不完美。例如使用时需要重复所需依赖两次,此外由于附加参数的存在,还不能混淆顺序。

5. 总结

首先我们来总结一下早期模块化的探索历程:

最初文件划分方式实现简单的代码逻辑划分 --> 命名空间方式减少命名冲突 --> IIFE 构建私有作用域 --> IIFE 引入依赖实现简单的依赖管理 --> 依赖注入降低模块依赖间的耦合度

从早期模块化的探索历程中,大抵可以总结出模块化的核心诉求: 命名冲突、依赖管理、全局污染。模块化并非一个孤立概念,不能将模块化脱离工程化,模块化的内涵也要追溯到降本提效上,因此在我看来,一个完善的模块化要具备下列几部分:

  • 隔离作用域
  • 解决命名冲突
  • 增加代码的可维护性
  • 增加代码的复用性
  • 便捷的依赖管理

三、模块化规范

1.Commonjs 规范

Commonjs 是业界最早提出的模块化规范,主要应用于服务器端,Nodejs 的模块系统便是 Commonjs 规范的最佳践行者。

Commonjs 模块化规范实现围绕四个核心环境变量:

  • module: 每个模块内部都存有 module 对象代表当前模块
  • exports: 通过 exports(或 module.exports) 暴露模块内部属性
  • require: 使用 require 来实现模块加载
  • global: 全局上下文环境

学会核心四大环境变量后,Commonjs 规范就简单多了:

  • 每一个文件就是一个模块,拥有自己独立的作用域。
  • 模块内部定义变量以及方法等都是私有的,对外界不可见。
  • module 对象的 exports(或 module.exports) 属性是对外的接口,加载某个模块,实际上就是加载该模块的 module.exports 属性(不推荐直接使用 exports)
  • 使用 require 加载模块

Commonjs 使用起来比较简单,下面咱们来尝试一下:

// 导出模块
// moduleA.js
const moduleName = "moduleA";
const add = function (a, b) {
  return a + b;
};
module.exports = {
  moduleName,
  add,
};
// 加载模块
// entry.js
const moduleA = require("./moduleA");
console.log(moduleA.moduleName); // moduleA
console.log(moduleA.add(1, 2)); // 3
  • require 加载模块,本质上就是读取 module.exports 属性,exports 也可以实现导出功能,但通常不推荐使用

    module.exports 和 exports 在模块中默认情况下指向同一地址空间,等价;但若后续发生地址层面的修改,两者就会产生差异,造成导出内容存在问题。

    // Mary.js
    const Tom = require("./Tom");
    if (Tom.name === "Tom" && Tom.height === 180) {
    console.log("成功找到 Tom");
    } else {
    console.log("未能找到 Tom");
    }
    

    第一种情形: module.exports 和 exports 分别提供了一条线索

    // Tom.js
    module.exports.name = "Tom";
    exports.height = 180;
    

    成功找到 Tom

    第二种情形: module.exports 指向了另一个重名 Tom,而 exports 仍指向原来的 Tom,require 默认读取 module.exports, Mary 最终只获取了同名 Tom 的 name 信息。

    module.exports = {
    name: "Tom",
    };
    exports.height = 180;
    

    未能找到 Tom

    第三种情形: exports 指向了另一个 Tom,而 require 获取 module.exports 上接口,因此 exports 提供的线索一律不予采纳。

    module.exports.name = "Tom";
    exports = {
    height: 180,
    };
    

    未能找到 Tom

  • Commonjs 模块输出的是值的拷贝,对于原始类型,复制其值,模块内部的变化不会影响导出值;对于引用类型为浅复制,属性的变动会影响导出值。

    // moduleA.js
    let count = 0;
    let obj = {
    count: 0,
    };
    let add = () => {
    count++;
    obj.count++;
    };
    let alterObj = () => {
    obj = {
        newCount: 0,
    };
    };
    
    module.exports = { count, add, obj, alterObj };
    
    // entry.js
    const { count, add, obj, alterObj } = require("./moduleA");
    console.log("count: ", count, " obj.count:", obj.count); // count:  0  obj.count: 0
    add();
    console.log("count: ", count, " obj.count:", obj.count); // count:  0  obj.count: 1
    
    console.log("obj", obj); // obj { count: 1 }
    alterObj();
    console.log("obj", obj); // obj { count: 1 }
    

    通过上述案例,Commonjs 引用的机制非常类似于 ES6 const 语法,基本类型不会变化,引用类型只会发生属性级别的变化

  • 模块存在缓存机制,第一次加载后模块会被缓存,因此多次重复引用或加载会读取缓存

  • 模块加载采用同步方式

    Commonjs 应用于服务端,模块存放在本地磁盘上,不需要进行网络 I/O,读取速度特别快;此外,服务端启动后通常会一直运行,模块读取只发生在服务启动阶段,这种模式并不会影响服务的性能。而在浏览器端则存在大量的异步操作,使用 Commonjs 规范会造成浏览器 JS 解析过程的阻塞,严重影响页面加载速度。 可见 Commonjs 并不适用于浏览器端,因此业界后续又设计出了全新的异步加载规范应用于浏览器端,下面来依次介绍一下。

  • CommonJs模块化实现原理

    name.js:
    
    module.exports = "不要秃头啊";
    
    main.js:
    
    let author = require("./name.js");
    console.log(author, "author");
    

    在看具体打包代码之前,我们先来分析一下。

    在name.js中有一个 module 对象,module 对象上有一个 exports 属性,我们给 exports 属性进行了赋值:"不要秃头啊"。

    在main.js中,我们调用了 require 函数,入参为模块路径(./name.js),最后返回值为 module.exports 的内容。

    如果让我们来设计一下这个运行过程,是不是这样就可以了:将name.js中的内容转换到一个modules对象中,该对象中key值为该模块路径,value值为该模块代码。在require函数执行时获取导出对象。

    var modules = {
    "./name.js": () => {
        var module = {};
        module.exports = "不要秃头啊";
        return module.exports;
    },
    };
    const require = (modulePath) => {
    return modules[modulePath]();
    };
    
    let author = require("./name.js");
    console.log(author, "author");
    

    其实源码中的大致思路也是类似的,以上就是CommonJs能在浏览器中运行的核心思想。

    接下来我们看看具体源码中的实现(对打包后的内容进行了调整优化,不影响阅读)。

    主要分为以下几个部分:

    • 初始化:定义 modules 对象
    • 定义缓存对象cache
    • 定义加载模块函数require
    • 执行入口函数
    //模块定义
    var modules = {
    "./src/name.js": (module) => {
        module.exports = "不要秃头啊";
    },
    };
    // 定义缓存对象cache
    var cache = {};
    
    //接受模块的路径为参数,返回具体的模块的内容
    function require(modulePath) {
        var cachedModule = cache[modulePath]; //获取模块缓存
        if (cachedModule !== undefined) {
            //如果有缓存则不允许模块内容,直接retuen导出的值
            return cachedModule.exports;
        }
        //如果没有缓存,则定义module对象,定义exports属性
        //这里注意!!!module = cache[modulePath] 代表引用的是同一个内存地址
        var module = (cache[modulePath] = {
            exports: {},
        });
        //运行模块内的代码,在模块代码中会给module.exports对象赋值
        modules[modulePath](module, module.exports, require);
    
        //导入module.exports对象
        return module.exports;
    }
    
    //执行入口函数
    (() => {
    let author = require("./src/name.js");
    console.log(author, "author");
    })();
    

2.AMD 规范

AMD 全称为 Asynchronous Module Definition,即异步模块定义规范。借助该规范,浏览器端可以实现模块异步加载,避免同步加载的页面阻塞。

AMD 规范是一种标准,没有得到浏览器端的原生支持,使用它需要借助第三方实现,requireJS 是最经典的库,其完整实现了 AMD 规范,后续的使用基于 requireJS。 requireJS 提供了三个核心方法

  • define 定义模块
  • require 加载模块
  • require.config 指定引用路径

下面来建立一个项目,项目结构如下:

├── index.html
├── scripts
│   ├── utils.js
│   |   └── print.js
│   ├── require.js
│   └── entry.js

然后看一下具体使用

// 网页中引入 requirejs 以及模块入口
<script src="./scripts/require.js" data-main="./scripts/entry"></script>;

// entry.js
require.config({
  baseUrl: "scripts/utils",
});

require(["print"], function (printModule) {
  printModule.print("entry");
});

// print.js
define(function () {
  return {
    print: function (msg) {
      console.log("print " + msg);
    },
  };
});

回看 requireJS 的模块加载方式,是不是有几分眼熟,没错,这里的思想类似于上一篇文章中的依赖注入思想。

require(["print"], function (printModule) {
  printModule.print("entry");
});

但在 requireJS 中,有一个更标准的称呼——依赖前置。依赖前置是 AMD 的核心设计思想,AMD 通过动态创建 script 标签的方式来异步加载模块,加载完成后立即执行该模块,所有的依赖加载并执行完毕后,本模块才会执行。

基于依赖前置的 requireJS 成功实现了异步模块加载,同时也暴露出很多问题

  • AMD 加载依赖模块后会立即执行,并不考虑该该依赖模块后续是否会被使用
  • AMD 异步加载模块通过动态创建 script 标签实现,这会提高页面的 js 文件请求量
  • AMD 依赖前置的模式要求必须提前写好所需依赖,无法实现按需加载
  • AMD 规范使用起来稍显复杂,代码阅读和书写都比较困难

综上所述,AMD 规范只能说是前端模块化探索过程中的中间方案,距离现代模块化方案还相差甚远。

3.CMD 规范

CMD(Common Module Definition)规范是另一种异步模块化解决方案,它出现相对较晚,是在 SeaJS 推广过程中产生的,其吸收了 AMD 和 Commonjs 规范的一些优点。

CMD 规范规定:

  • 一个文件就是一个模块
  • define 定义模块
  • require 方法加载模块

CMD 规范使用起来非常简单,使用区别就在于 factory 的不同。

如果 factory 为对象或者字符串,直接就代表该模块的接口

如果 factory 为函数,则表示模块的构造方法,执行该方法获取可以获取模块导出的接口。

define(factory);

当 factory 为函数时,其有三个参数: require、exports、module

define(function (require, exports, module) {
  // module content
});

通过基础使用部分,可以发现 CMD 与 AMD 非常类似,下面的案例对比了两者的使用。但两者的设计思想有很大的差异,AMD 推崇依赖前置,而 CMD 则主张依赖就近,延迟执行。也就是说在 CMD 中,加载完依赖模块后不会立即执行,而是基于一种懒加载的思想,只有该模块后续被使用才会执行。

CMD 规范使用依赖就近的规则定义一个模块,会导致模块的加载逻辑偏重,此外对于当前模块的依赖关系也非常不直观。

// AMD
define(["print"], function (printModule) {
  printModule.print("entry");
});
// CMD
define(function (require, exports, module) {
  cosnt printModule = require('./print')
  printModule.print("entry");
});

4.ESModule 规范

模块化方案关乎到整个前端生态链,官方在 ECMAScript6 标准中增加了 JavaScript 语言层面的模块体系定义,作为浏览器和服务器通用的模块解决方案,也就是 ES6 Module(或称为 ESModule、ESM)。

ESModule 规范并不复杂,使用起来相对也比较简单

  • 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取
  • import 命令用于输入其他模块提供的功能
  • export 命令用于规定模块的对外接口

(1)export

export 命令可以单个导出,也可以批量导出。

// zcxiaobao.js
// 单个导出
export const firstName = "zc";
export const lastName = "xiaobao";
export const year = 18;

// 批量导出
const firstName = "zc";
const lastName = "xiaobao";
const year = 18;
export { firstName, lastName, year };

// 导入
import { firstName, lastName, year } from "./zcxiaobao.js";

与 Commonjs 不同,ESM 导出的为值的引用,因此 export 命令在导出时需要为接口名和模块内部变量构建一一对应关系。

ESModule 还支持默认导出的功能,即 export default。

// export-default.js
export default function () {
  console.log("foo");
}

上述使用 export default 默认输出了一个函数。当然也可以默认导出非匿名函数,但在模块外部并没有任何作用,统统视为匿名函数。

这时你可能会有疑惑?export 与 export default 的导出机制好像有些天差地别,如下面代码,export default 使用 export 格式导出会抛出错误。

// export-default.js
// throw error
export default const foo = function () {
    console.log("foo");
}

其实是基于这样的考虑:export default 被设计成模块的默认导出方式,这个默认值只会有一个,但是const可以支持这种形式:const x = 8, y = 10, z = 5; 所以开发人员可能会这样去写export default const x = 8, y = 5, z=99; 这显然是自相矛盾的,不是一个好的语法设计。所以这种形式的语法干脆就被禁止掉了,可以用以下形式替代:

const x = 9;
export default x;

(2)import

// zcxiaobao.js
const firstName = "zc";
const lastName = "xiaobao";
const year = 18;
export { firstName, lastName, year };

const male = true;
export default male;

我们以上面的代码介绍一下 import 的基本使用。

对于 export 命令,import 命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块对外接口的名称相同。

// import-export.js
import { firstName, lastName, year } from "./zcxiaobao.js";
console.log(firstName, lastName, year);

由于 ESModule 在编译时运行,因此大括号内不支持运行时才能得到结果表达式和变量。

// import-error.js
// throw error
import { 'first' + 'Name'} from "./zcxiaobao.js";

对于 export default 命令,import 导入时可以任意为其重新命名,但切记此时不需要大括号。

// import-default.js
import zcMale from "./zcxiaobao.js";
console.log(Male); // true

// 也可以混合导入
import zcMale, { firstName, lastName } from "./zcxiaobao.js";
console.log(zcMale, firstName, lastName);

除了指定加载某些值,import 还支持整体加载。

// import-whole.js
import * as zcxiaobao from "./zcxiaobao.js";
console.log(zcxiaobao);

打印结果:

[Module]:{
  default:true,
  firstName:"zc",
  lastName:"xiaobao",
  year:18
}

我们惊喜的发现,default 也出现了,同时与 firstName 等属性是平级的。

上文提过 export 命令的本质是在导出时需要为接口名和模块内部变量构建一一对应关系,那是不是意味着 default 是 ESModule 内置构建的默认接口,缺少的只不过是与内部变量的对应关系。

也就是说 export default 可以理解为export 一个特殊的语法糖,本质就是输出 default 的变量或方法,只不过系统允许随便为它命名。

(3)import()

ESModule 在编译时运行,编译时会对 import 命令进行静态分析,这也就意味着 import 和 export 命令只能在模块的顶层,不能在代码块之中。

例如下面的代码就会报错

if (x !== undefined) {
  import { firstName } from "./zcxiaobao.js";
}

得益于编译时运行机制,可以实现模块的静态分析,可以实现类似 TreeShaking 等功能减少不必要的代码,但这同样也丧失了运行时模块加载的功能。

Commonjs 为运行时加载,require 函数可以出现在任何地方,模块的动态加载自由。

ES2020 中,引入了 import() 函数,来实现动态加载模块,该方法返回一个 Promise 对象,可以支持按需加载,大大提高了模块引用的灵活性。

// dynamic-import.js
function getZc() {
  setTimeout(() => {
    import("./zcxiaobao.js").then(({ firstName, lastName }) => {
      console.log(firstName + lastName);
    });
  }, 1000);
}

getZc(); // zcxiaobao

import() 函数的可以兼容市面 95% 以上的浏览器份额,可以比较放心的应用于日常开发中。

(4)import.meta

开发者开发模块时,有时需要获取模块自身的信息,类似于 Commonjs 为模块注入的 __filename,__dirname 变量等。

ES2020 提案中,为 import 命令添加了一个元属性 import.meta,返回当前模块的元信息。import.meta.url 返回当前模块的 URL 路径。

// import-meta.js
// Nodejs 环境下执行,返回本地路径。
console.log(import.meta);
console.log(import.meta.url); // file:URL

从 caniuse 可以查到,import.meta元属性也达到 95%以上的兼容性。

(5)ES Module模块化原理

name.js:
const author = "不要秃头啊";

export const age = "18";
export default author;
main.js:
import author, { age } from "./name";

console.log(author, "author");
console.log(age, "age");

我们还是先来理一理思路。

这下可没有exports对象给我们赋值了,这可怎么办?

换一种思路:我们可不可以将 name.js 中导出的内容还是挂载在 exports 对象上,如果是通过export default 方式导出的,那就在 exports 对象加一个 default 属性,将 name.js 中导出的内容变成这样:

const exports = {
  age: "18",
  default: "不要秃头啊",
}

然后在模块引用时(在 Webpack 编译时会将 import author from "./name" 代码块转换成 const exports = require(./name) 代码块),这样在 main.js 中拿到的是还是这个 exports 对象,就能够正常取值啦。

大致原理就是这么简单,只不过这里给exports赋值的方式是通过代理做到的。

//模块定义
var modules = {
  "./src/name.js": (module, exports, require) => {
    //给该模块设置tag:标识这是一个ES Module
    require.setModuleTag(exports);
    //通过代理给exports设置属性值
    require.defineProperty(exports, {
      age: () => age,
      default: () => DEFAULT_EXPORT,
    });
    const author = "不要秃头啊";
    const age = "18";
    const DEFAULT_EXPORT = author;
  },
};

var cache = {};
function require(modulePath) {
  var cachedModule = cache[modulePath];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[modulePath] = {
    exports: {},
  });
  modules[modulePath](module, module.exports, require);
  return module.exports;
}

//对exports对象做代理
require.defineProperty = (exports, definition) => {
  for (var key in definition) {
     // 在exports上添加相应的getter
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

为了实现ESM的规范,它定义了一个getter来通过闭包的方式引用了模块里面需要导出的值,这也说明了导出的不是值的拷贝,而是共享的内存空间。
由于没有定义setter,所以也不能修改导出变量的值。

像这样实现动态绑定的原因,实际上就是为了更好地去支持循环依赖。以导出一个相同的变量a为例,如果像commonJS一样是值拷贝的方式,且发生了循环依赖,后续程序运行的时候得到的值就只会一直是undefined; 而如果是ESM,用的是值引用的方式,后续运行时取值的时候,实际上触发的是getter,等到所有模块都初始化求值完成后,就不会一直是undefined

//标识模块的类型为ES Module
require.setModuleTag = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: "Module",
  });

  Object.defineProperty(exports, "__esModule", {
    value: true,
  });
};

//以下是main.js编译后的代码
//拿到模块导出对象exports
var _name__WEBPACK_IMPORTED_MODULE_0__ = require("./src/name.js");

console.log(_name__WEBPACK_IMPORTED_MODULE_0__["default"], "author");
console.log(_name__WEBPACK_IMPORTED_MODULE_0__.age, "age");

这里与 CommonJS 模块化原理不同的在于:

通过 require.setModuleTag 函数来标识这是一个ES Module(在现在这个例子中其实没什么作用)

给传入的 exports 对象通过 Object.defineProperty 做了一层代理(这样当访问default属性时,其实访问的是DEFAULT_EXPORT变量,访问age属性时,访问的是age变量)。

5.Commonjs 与 ESM 对比

<1>拷贝 vs 引用

上文讲到Commonjs 模块输出的是值的拷贝,对于原始类型,复制其值,模块内部的变化不会影响导出值;对于引用类型为浅复制,属性的变动会影响导出值。

ESModule 运行机制与 Commonjs 不同。ESM 导入模块是在编译阶段进行静态分析确定模块的依赖关系,并将 import 导入语句提升到模块首部,生成只读引用,链接到引入模块的 export 接口,等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。也就是说如果模块内代码运行过程中原始值发生变化,import 加载值也会发生改变。

<2>运行 vs 编译

Commonjs 模块的本质是一个对象,模块加载的过程即 module.exports 对象的生成过程,然后 require 方法再从对象上读取方法,这种加载被称为运行时加载。最大特性是全部加载,只有运行时才能得到该对象,无法在编译时做静态优化

ESModule 则是通过静态分析 import 命令来构建起 import 与 export 导出的只读引用(该引用不可修改),后续脚本执行后再沿只读引用获取值。最大特性是按需加载,在编译时就完成模块加载。

这也就客观解释了为什么 require 可以出现在任何地方,而 import 必须在模块顶层。

为了能更好的取代 require 函数,ES2020 引入了 import() 函数,支持动态加载模块,import() 函数同样也可以出现在任何地方。

<3>同步 vs 异步

Commonjs 模块读取使用 Node.js 的 fs.readSync 方法,为同步加载模式,通常应用于服务端;ESModule 则通过 CORS 的方式请求外部 js 模块,为异步加载模式,目前可用于服务端及浏览器端。

严格模式: Commonjs 默认是非严格模式,而 ESModule 默认是严格模式。

6.CommonJS 加载 ES Module的原理

name.js:
export const age = 18;
export default "不要秃头啊";
main.js:
let obj = require("./name");
console.log(obj, "obj");

对打包后的代码进行分析(经过优化):

var modules = {
  "./src/name.js": (module, exports, require) => {
    require.setModuleTag(exports);
    require.defineProperty(exports, {
      age: () => age,
      default: () => DEFAULT_EXPORT,
    });
    const age = 18;
    const DEFAULT_EXPORT = "不要秃头啊";
  },
};
var cache = {};
function require(moduleId) {
  var cachedModule = cache[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[moduleId] = {
    exports: {},
  });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

require.setModuleTag = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: "Module",
  });

  Object.defineProperty(exports, "__esModule", {
    value: true,
  });
};

(() => {
  let obj = require("./src/name.js");
  console.log(obj, "obj");
})();

运行结果:

{ age: [Getter], default: [Getter] } obj

7.ES Module加载CommonJS的原理

name.js:
module.exports = "不要秃头啊";
main.jsimport author from "./name";

console.log(author, "author");

这一步的思路其实跟前面基本上相同,唯一的区别在于多了个require.n函数,它用来返回模块的默认导出内容,核心思想依旧是将最终模块的内容导出为一个 exports 对象。

对打包后的代码进行分析(经过优化):

var modules = {
  "./src/name.js": (module) => {
    module.exports = "不要秃头啊";
  },
};
var cache = {};
function require(modulePath) {
  var cachedModule = cache[modulePath];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[modulePath] = {
    exports: {},
  });
  modules[modulePath](module, module.exports, require);
  return module.exports;
}

require.n = (module) => {
  var getter =
    module && module.__esModule ? () => module["default"] : () => module;
  require.defineProperty(getter, {
    a: getter,
  });
  return getter;
};

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

require.setModuleTag = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, {
    value: "Module",
  });

  Object.defineProperty(exports, "__esModule", {
    value: true,
  });
};

var __webpack_exports__ = {};
(() => {
  "use strict";
  require.setModuleTag(__webpack_exports__);
  var _name__WEBPACK_IMPORTED_MODULE_0__ = require("./src/name.js");
  var _name__WEBPACK_IMPORTED_MODULE_0___default = require.n(
    _name__WEBPACK_IMPORTED_MODULE_0__
  );
  console.log(_name__WEBPACK_IMPORTED_MODULE_0___default(), "author");
})();

8.总结

最后,通过一道代码执行题来看看大家到底掌握没有哦!考点是这些模块化规范是如何解决循环依赖的问题的。

a.js文件

javascript复制代码const getMes = require('./b')
console.log('我是 a 文件')
exports.say = function(){
    const message = getMes()
    console.log(message)
}
b.js文件

typescript复制代码const say = require('./a')
const  object = {
   name:'从构建产物洞悉模块化原理',
   author:'不要秃头啊'
}
console.log('我是 b 文件')
module.exports = function(){
    return object
}
文件main.js
javascript复制代码const a = require('./a')
const b = require('./b')
console.log('node 入口文件')

接下来执行 main.js 文件,控制台会输出什么呢?

解析

执行main.js

  1. require a 的时候,去加载 a 的代码,
  2. 在实际执行前,会提前声明一个空对象最为a export 的导出值,并赋值给cache,cache['./a'] = {}
  3. 实际开始加载a,a 在加载的过程中,碰到 require('./b'),然后开始加载 b,并且也会提前声明一个空对象赋值给 cache,cache['./b'] = {};
  4. 实际开始加载b,此时发现要加载a,先从缓存中取,取到了(可以在b 文件加一行日志,console.loog(say) 你会发现它就是空对象)。就继续往下执行。输出【我是b 文件】
  5. require('./b’) 执行完成,执行a 后续代码,输出【我是a'文件】
  6. main 的第一行执行完成,执行第二行 require('./b') ,此时直接从缓存中取到了值
  7. 最后输出【node 入口文件】