前端模块化与打包
文章写于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
不会在它的外层作用域引入变量eval
和arguments
不能被重新赋值arguments
不会自动反映函数参数的变化- 不能使用
arguments.callee
- 不能使用
arguments.caller
- 禁止
this
指向全局对象 - 不能使用
fn.caller
和fn.arguments
获取函数调用的堆栈 - 增加了保留字(比如
protected
、static
和interface
)
ES6的模块具有默认defer(后解析)的特性。当一个script标签被声明为type="module"
的时候,它成为一个模块,它的解析时间会被自动的推迟到html全部加载完毕才开始运行。所以,我们把模块script标签放在html的哪个位置都是一样的。
独立作用域特性。html中默认的js代码是存在于全局作用域中的。但是一旦一段代码被声明为模块,那么它将具有自己独立的作用域。包含export或者import关键字的文件将自动的被识别为一个模块。
预解析特性。同一个模块被引用多次的时候,不会被重复解析:他们公用第一次引入的那个模块,也就是已加载的模块列表中的某个模块。当列表中的模块被改变时候,它将影响到所有引入它的模块,是一对多的关系,引入的模块有且只有一个。
基本使用
最基本的使用就是export+import的方式了。 我们使用export导出,在另一个文件使用大括号import {}
来引入:
//1.js
export func() {}
//2.js
import { func } from './1.js';
另外,还用一种使用import函数的方式,它可以在代码内部按需引入模块,解决了按需引入模块的问题。它的返回值是一个promise对象,我们可以利用它在导入后调用.then
做一些事情。
导出名的问题
导出的模块肯定要被用,所以导出一定要有导出名。根据名字的不同,我们分为默认导出和具名导出。一个文件中,只允许存在一个默认导出。引入默认导出不需要加大括号,而且引入名字可以随意起:
//1.js
export default function func() {}
//2.js
import a from './1.js';
实际上,默认导出是一个名字为default
的具名导出,我们在使用的时候不需要加名字,所以看起来他就好像没有名字一样:
import { ... as default } from ...
等价于
import ... from ...
默认导出和具名导出可以混用,因为可以很明显的区分他们,加大括号的就是非default导出,不加的就一定是default导出:
//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
用于在导入的时候给模块起一个别名。
批量导出
可以用*
来实现批量的导入:
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则是仅仅去更新被修改过的模块文件,这就是它速度快的原理。