剥茧抽丝,细数模块化的前世今生

764 阅读12分钟

写在前面

本篇是前端工程化打怪升级的第 1 篇,关注专栏 | 小册传送门 | 案例代码

近几年,时常会感叹,前端,发展的太迅猛了。日新月异的新概念,异彩纷呈的新思想泉水般涌出;前端项目的复杂度、开发成本、维护难度也在不断提升,前端三件套开发模式(HTML-CSS-JS)滞后于前端的进步,这要求我们需要以更高层次的眼光来重新审视前端——工程化。工程化是一个工学概念,其核心是结合实际建立科学的、规范的设计和生产流程,目的是降本提效

前端工程化目前还没有比较完善的定义,各种论调众说纷纭,很多朋友很容易把工程化与 webpack 画等号,这其实一种不完善的理解,以我看来其是指借助科学的软件工程方法,更科学、严谨、系统的管理和维护整个前端开发的全流程。又或者理解为比较主流的工程四化:模块化、组件化、规范化、自动化

模块化是前端工程化的核心要素,重中之重,理解模块化是掌握工程化的重要一步。因此本文以早期模块化演变进程为着眼点,带你理解

  • 什么是模块化
  • 为什么需要模块化
  • 早期模块化的演变进程
  • 应用于模块化的依赖注入

模块化

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

还是有些难以理解,举一个现实中的栗子,火箭系统就是比较典型的模块化设计,一般会有三大模块:控制、动力、结构。三个模块各司其职;同时也存在部分的依赖关系,例如控制是核心模块,需要操纵结构模块与动力模块。

rocket-module.png

那么模块化有什么好处那?从火箭案例可以发现,各自模块都拥有自身的核心业务,擅长结构的从事结构模块研究,精研控制的设计控制程序,各司其职,模块化设计是一种降本提效的好方案。在前端中亦是如此,具体的好处小包暂且不多言,下面一起来经历模块化的演变过程,在此之后,你会有更深刻的理解。

文件划分

在早期刀耕火种的前端三件套时代,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 分别使用了 moduleAmoduleB 的变量与函数,实现了简易的代码分离与组织,但随着代码量的增大及项目复杂度的增加,文件划分方式存在很多问题。

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

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

命名空间

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

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

// 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

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
console.log(moduleB.getModuleName()); // moduleB

IIFE 方式大家其实都不陌生,jQuery 时期有很多类似的实现,例如 jQuery 源码如何将 $ 挂载到 window 实例上(换个角度可以理解为依赖了 jquery 模块)。

(function (global, factory) {
  "use strict";
  factory(global);
})(typeof window !== "undefined" ? window : this, function (window, noGlobal) {
  function jQuery() {}
  window.jQuery = window.$ = jQuery;
  return jQuery;
});

console.log(window.$); // function jQuery(){}

依赖注入

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

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

先来看看相关的几个重要概念:

依赖倒置原则

依赖倒置原则(Dependence Inversion Principle,DIP): 高层模块不应该依赖底层模块,两者都应该依赖其抽象,抽象不能够依赖于具体 ,具体必须依赖于抽象。

micro-module-inject.png

举个栗子,假设我们是一家微波炉生产商,我们有着成熟的上下游生产链,例如我们与微波炉核心模块供应商建立了合作关系。

import MicroCoreS from "vendor";
class Microwave {
  constructor(brand) {
    this.core = new MicroCoreS();
    this.brand = "zcxiaobao";
  }
}

在我们创业初期,上述的实现完全可以满足我们的需求,但随着时代的发展,codeS 类型核心开始难以满足最新市场,因此核心模块供应商推出了 codeL 类型核心。

这时我们就可以发现,我们的基类 MicrowavecoreS 锁住了,如果想要升级为 coreL,需要全部替换或者重新创建 MicrowaveL 基类,牵一发而动全身,低效高成本。

DIP 就是为了解决这个问题而提出的,我们重新回顾一下 DIP 的定义:

高层模块不应该依赖底层模块,两者都应该依赖其抽象,抽象不能够依赖于具体,具体必须依赖于抽象

因此在这里我们不再直接紧耦合于 core 的具体设计,而是引入基于 core 的抽象设计(接口),通俗来讲即微波炉核心的设计标准

micro-module-inject-dip.png

接下来不论供应商如何迭代升级,我们只需按照 MicroCore 的标准实现新核心即可。

从上面两张图可以清楚的看到,前者 MicrowavecoreScoreL 是紧耦合关系;后者 Microwave、coreL、coreS 共同依赖于抽象接口 MicroCore,实现了松耦合。

总结来说: 假设有两个类 classA 和 classB,如果 classA 依赖 classB,这便是紧耦合;如果 classA 依赖 interfaceB,而 classB 实现了 interfaceB,这便是依赖倒置。

控制反转

控制反转(Inversion of Control,IOC)是面向对象编程中的一种设计原则,它通过反转调用方和被调用方之间的关系,实现解耦合。

依赖注入

依赖注入(Dependency Injection,DI)是 IOC 最常见的方式,通过将被依赖对象的创建和管理由依赖方转移到外部容器(或者框架),然后将依赖的对象注入到需要使用的地方,实现解耦合。

微波炉的生产过程中,会需要多类组件,例如核心、外壳,各类组件也会依赖于其他组件,例如核心模块还会依赖于电导丝,电导丝还会有多种类型材料。诸如此类的生产关系,就会共同构建成一个复杂的依赖网络(Dependency Network)。

micro-module.png

const microwave = new Microwave(
  new coreL(new ElectricConductor(new coreMaterial()), ...),
  ...
);

看完例子后,我们重新品味一下依赖注入的定义,可以得出两大核心点: 存储被依赖对象的外部容器,将依赖注入所需地方

微波炉的生产过程类似于依赖注入过程,其全部模块就构成存储被依赖对象的外部容器。然后不同类型的微波炉使用不同的模块进行组装,例如电导丝 A 型与电导丝 B 型微波炉注入不同的电导丝模块。

实现

依赖注入是面向对象中常用的编程思想,因此上文主要以类和接口为切入点,更方便理解,那模块化中的依赖注入又是怎样的那?

module_inject_case.png

从图中案例我们可以清晰的理清模块化中的依赖注入。

  • 开发静态网站:注入前端传统三件套即可
  • 开发复杂网站: 依赖池全部注入,开发难度尚高,因此可以注册新的 vue 依赖
// 具象化三大模块
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 容器来存储所有的依赖)
  • 依赖注入器可以接受一个函数,注入成功后返回一个可以获取所有依赖资源的函数
  • 注入应保持被传递函数的作用域
  • 被传递的函数应该能够接受自定义参数,而不仅仅是依赖描述

下面来简单实现一个依赖注册器 injectorinjector 由依赖容器、注册依赖函数、依赖注入函数三部分组组成。对于 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");

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

总结

模块化的演变是一个特别有意思的过程,前人们探索出数十种模块化方式,各种方式有其独特特点,其中也有相互借鉴,学习这个过程可以收获良多,也可以更好地体悟当下的模块化规范。

zaoqi_module.png

早期的模块化方案并没有完善的解决模块化问题,随着前端工程化的推进,开源社区中陆续出现较为优秀且完善的模块化解决方案,随着 ES6 的出现,JavaScript 官方退出 ESM,标志着模块化正式进入成熟阶段,模块化已然不可或缺。

经过早期一系列的探索,后续又诞生了那些模块化规范那,敬请期待下章《群雄逐鹿,前端模块化路在何方?》

 本文正在参加「金石计划」