Skip to content

前端模块化与打包

文章写于2023年3月21日,最后修改于2023年3月22日。

引言

最近学习前端过程中,被模块化和打包折腾的不轻。究其缘由是因为不太了解前端的发展历程,以及各种模块的不熟悉,还有没有接触过webpack的原因。所以写下此篇用于总结前端模块化发展历程,介绍各种模块,以及现代开发工具。

背景

历史上,JS作为一个脚本语言,一直没有模块体系,无法像其他语言那样轻松的构建大型程序。

一开始的前端,全部都是js文件,通过将各种js文件在一个个html文件中引入,来实现整个网页的加载和构造。随着前端功能越来越复杂,前端代码日益膨胀,产生了以下的问题:

  • 请求过多。由于我们要依赖多个模块,所以就会发送多个请求。
  • 依赖难以整理。由于缺乏模块化,都是文件,所以没有引入的显示声明依赖,各种文件放在一堆,可以跑,但不知道显式的谁依赖谁。

这就让代码变得难以维护。为了减少维护成本,提高代码的可复用性,前端模块化出现了,它把后端那一套模块化拿过来。在ES6之前,社区率先的制定了一些模块加载方案,最主要的有CommonJS 和AMD两种。前者用于服务器,后者用于浏览器。后来,ES6在语言标准中直接的新加了模块,而且实现的相当简单,完全可以取代CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

目前主要的模块化规范有:

  • CommonJS
  • CMD
  • AMD
  • ES6模块

关于它们之间的具体区别,可以参考这里.现在推荐使用ES6标准模块化规范。

ES6模块化

下面全部都是介绍前端ES6的模块化。

特性

ES6的设计是编译时加载的。这不同于CommonJS的运行时加载。编译时加载的好处在于,它在编译的时候就能够确定模块的依赖关系,而非运行时报错。

默认严格模式特性。ES6的模块自动成为严格模式。事实上这也是未来的趋势,应该尽可能的使用严格模式,它弥补了js的一些缺陷,可以降低错误的发生率。

补充:严格模式主要特点:

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用with语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀 0 表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
  • eval不会在它的外层作用域引入变量
  • evalarguments不能被重新赋值
  • arguments不会自动反映函数参数的变化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全局对象
  • 不能使用fn.callerfn.arguments获取函数调用的堆栈
  • 增加了保留字(比如protectedstaticinterface

ES6的模块具有默认defer(后解析)的特性。当一个script标签被声明为type="module"的时候,它成为一个模块,它的解析时间会被自动的推迟到html全部加载完毕才开始运行。所以,我们把模块script标签放在html的哪个位置都是一样的。

独立作用域特性。html中默认的js代码是存在于全局作用域中的。但是一旦一段代码被声明为模块,那么它将具有自己独立的作用域。包含export或者import关键字的文件将自动的被识别为一个模块。

预解析特性。同一个模块被引用多次的时候,不会被重复解析:他们公用第一次引入的那个模块,也就是已加载的模块列表中的某个模块。当列表中的模块被改变时候,它将影响到所有引入它的模块,是一对多的关系,引入的模块有且只有一个。

基本使用

最基本的使用就是export+import的方式了。 我们使用export导出,在另一个文件使用大括号import {}来引入:

js
//1.js
export func() {}

//2.js
import { func } from './1.js';

另外,还用一种使用import函数的方式,它可以在代码内部按需引入模块,解决了按需引入模块的问题。它的返回值是一个promise对象,我们可以利用它在导入后调用.then做一些事情。

导出名的问题

导出的模块肯定要被用,所以导出一定要有导出名。根据名字的不同,我们分为默认导出和具名导出。一个文件中,只允许存在一个默认导出。引入默认导出不需要加大括号,而且引入名字可以随意起:

js
//1.js
export default function func() {}

//2.js
import a from './1.js';

实际上,默认导出是一个名字为default的具名导出,我们在使用的时候不需要加名字,所以看起来他就好像没有名字一样:

js
import { ... as default } from ...
等价于
import ... from ...

默认导出和具名导出可以混用,因为可以很明显的区分他们,加大括号的就是非default导出,不加的就一定是default导出:

js
//1.js
export function func1() {}
export default function func() {}

//2.js
import a, {func} from './1.js';

导入路径的问题

ts模块文档对于js中的node.js(CJS)的模块导入路径问题总结非常非常清晰。ESM(es6 module)和它的策略基本一致(在路径查找上)。这篇文章描述了node.js中是如何处理ES6模块的。

模块导入的时候,路径可以填相对路径或者非相对路径。

对于相对路径,这是最简单的情况,如果引用相对路径,则直接寻找这个相对路径文件 export 出来的内容。

对于非相对路径,则有一些特殊了。它会依次寻找node_modules。具体来说,会从当前文件夹开始,依次往上层遍历,寻找一个名字叫做node_mo dules的文件夹。找到后,寻找里面的package.json文件,根据里面的main字段确定模块的入口文件(如果package.json不存在,则在当前文件夹里面找index.js文件,把它作为模块入口文件)。

关于引入文件要不要加后缀名的问题

有时候我们会看到一些很奇妙的现象,引入一个模块不需要加js后缀名是很正常的,但引入一个js文件却也不需要,就显得有些奇怪了。

对于nodejs 端引入一个ES模块而言,import 语句后面接的是模块标识符,一般是node module模块名。nodejs 模块查找有一套自己的逻辑,还可以通过 package.json 查找,正如上面所提到的。这种情况下一般不加后缀名。但是如果是引入单个文件,则必须加。

而现在的前端项目一般都会使用打包工具,它们会自定义import的行为:这意味着 import 后面的东西只要是构建工具能理解的就可以。所以你会看到 import 省略后缀的,直接 import 到目录的,甚至 import 一个非 js 模块比如一张图片,一个 css 文件的。当然他们最终会转化成浏览器能理解的import,也可能直接合并成一个模块import都消失了。常见的打包工具有vite和webpack。我们需要遵循他们的规则行事。

其他

别名

关键字as用于在导入的时候给模块起一个别名。

批量导出

可以用*来实现批量的导入:

js
import * as mymodule from './..js'

打包工具

为什么需要打包工具

打包工具存在有两点意义:

  • 兼容性问题:es6模块虽然很好,但是毕竟是新的标准,在旧的浏览器中无法去进行识别。所以我们需要一个打包工具,它把新标准转换为旧的可以在通用浏览器中运行的代码,提高了代码的兼容性。
  • 模块构建问题:在模块化后,我们引入的模块虽然条理更加清晰了,但是依然存在一个问题,就是html中引入的js模块可能非常多,客户端加载一个网站,需要向服务器端请求很多很多次各种js文件,得不偿失。打包工具将所有的模块全部聚合在一个文件当中,客户端请求后直接把打包后的文件丢过去,避免了多次请求。(注意这里是文件,不是模块,打包后的文件夹不再有模块的概念,而仅仅是一段可以在各种浏览器中跑起来的代码)。

webpack

webpack是传统的打包工具。

使用步骤

  • 初始化项目:npm init -y
  • 安装依赖webpack,webpack-cli(命令行工具)
  • 配置webpack.config.js
  • 执行npm webpack打包。

vite

vite也是一种打包工具,它和webpack相比有点在于软件调试响应速度块,即热更新的概念。在打包的过程中,webpack工具的做法是将所有的模块全部拿去编译,而vite则是仅仅去更新被修改过的模块文件,这就是它速度快的原理。