Webpack 究竟解决了什么问题?

2023/6/19 module

参考文章 (opens new window)

# 要解决什么

Webpack 最初的目标就是实现前端项目的模块化,换句话来说,就是如何在前端项目中更高效地管理和维护项目中的每一个资源

想要理解 Webpack,那就必须先对它想要解决的问题或者目标有一个充分的认识,带着问题再去理解它的很多特性,学习思路会更清晰,理解也会更深刻

下面先简单介绍一下前端模块化的发展史,以及这个过程中所出现的一些标准规范

# 模块化的演进过程

# 1️⃣ 文件划分方式

最早的一种实现模块化的方式 —— 基于文件划分的方式实现模块化,也就是 Web 最原始的模块系统。具体做法是将每个功能及其相关状态数据各自单独放到不同的 JS 文件中,约定每个文件是一个独立的模块。使用某个模块将这个模块引入到页面中,一个 script 标签对应一个模块,然后直接调用模块中的成员(变量 / 函数)

└─ stage-1
    ├── module-a.js
    ├── module-b.js
    └── index.html

module-a.js

// module-a.js
function foo() {
  let data = "我是module-a的data";
  console.log("我是foo");
}

module-a.js

// module-a.js
var data = "我是module-b的data";

test.html引用

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Stage 1</title>
  </head>
  <body>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      // 直接使用全局成员
      foo(); // 我是foo
      console.log("data:", data); // 可能存在命名冲突, 会输出最后引入该变量的结果 data: 我是module-b的data
      data = "other"; // 数据可能会被修改 —— data: 修改了data
    </script>
  </body>
</html>

缺点:

  • 模块直接在全局工作,大量模块成员污染全局作用域
  • 没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改
  • 一旦模块增多,容易产生命名冲突
  • 无法管理模块与模块之间的依赖关系
  • 在维护的过程中很难分辨每个成员所属的模块

总之,这种原始“模块化”的实现方式完全依靠约定实现,一旦项目规模变大,这种约定就会暴露出种种问题,非常不可靠,所以我们需要尽可能解决这些暴露出来的问题

# 2️⃣ 命名空间方式

发展到了第二个阶段,程序员们开始约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中,具体做法是在第一阶段的基础上,通过将每个模块“包裹”为一个全局对象的形式实现,这种方式就好像是为模块内的成员添加了“命名空间”,所以我们又称之为命名空间方式。

module-a.js

// module-a.js
window.moduleA = {
  method1: function () {
    console.log("我是moduleA的内容");
  },
};

module-b.js

// module-b.js
window.moduleB = {
  data: "moduleB",
  method1: function () {
    console.log("我是moduleB的内容" + this.data);
  },
};

test.html中引入

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Stage 2</title>
  </head>
  <body>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      moduleA.method1(); // 我是moduleA的内容
      moduleB.method1(); // 我是moduleB的内容moduleB
      // 模块成员依然可以被修改
      moduleB.data = "update";
      moduleB.method1(); // 我是moduleB的内容update
    </script>
  </body>
</html>

这种命名空间的方式只是解决了命名冲突(因为需要使用moduleA.moduleB.这种方式来调用,所以可以起一样的名字,但是调用起来不会冲突了)的问题,但是其它问题依旧存在 —— 模块中的数据仍可以被外部修改

# 3️⃣ IIFE

第三阶段发展到了 IIFE 阶段 —— 使用立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)为模块提供私有空间。具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现。

module-a.js

// module-a.js
(function () {
  var data = "module-a";

  function method1() {
    console.log("我是moudule-a的内容" + data);
  }

  window.moduleA = {
    method1,
  };
})();

module-b.js

// module-b.js
(function () {
  var data = "module-b";

  function method1() {
    console.log("我是module-b的内容" + data);
  }

  window.moduleB = {
    method1,
  };
})();

test.html中引入

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Stage 2</title>
  </head>
  <body>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      moduleA.method1();
      moduleB.method1();
      // 无法读取module中的data,因为data是私有变量
      console.log("moduleB.data:", moduleB.data);
      moduleB.data = "update";
      // 无法直接修改module中的data
      moduleB.method1();
    </script>
  </body>
</html>

IIFE这种方式带来了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问,这就解决了前面所提到的全局作用域污染和命名冲突的问题

# 3️⃣ IIFE 依赖参数

在 IIFE 的基础之上,还可以利用 IIFE 参数作为依赖声明使用,这使得每一个模块之间的依赖关系变得更加明显

// module-a.js
(function ($) {
  // 通过参数明显表明这个模块的依赖
  var name = "module-a";

  function method1() {
    console.log(name + "#method1");
    $("body").animate({ margin: "200px" });
  }

  window.moduleA = {
    method1: method1,
  };
})(jQuery);

# 仍存在的问题

以上 4 个阶段是早期的开发者在没有工具和规范的情况下对模块化的落地方式,但基本上只是解决了模块代码的组织问题,但模块加载的问题却被忽略了

我们都是通过 script 标签的方式直接在页面中引入的这些模块,这意味着模块的加载并不受代码的控制,时间久了维护起来会十分麻烦

更为理想的方式应该是在页面中引入一个 JS 入口文件,其余用到的模块可以通过代码控制,按需加载进来。

关于模块化进一步的发展,请参考模块化规范的出现这篇文章

How to love
Lil Wayne