最近面试时总被问到一些如为什么Vite比Webpack快、ESM和CJS的对比、Tree Shaking的原理等等的面试题,大多数时候我只能凭感觉简单说道几句,给面试官的印象就是,不太懂这些,基础不牢,于是我查阅诸多资料,总结了前端工程化的起始发展和目前的常用的一些技术原理概述。
我们可以使用HTML、CSS、JavaScript编写网站,为什么需要工程化?
大型项目的几个痛点
如果是在普通的项目中,对于上面这些特性需要配置复杂的环境,且难以做到统一标准,维护代码相当麻烦
于是出现了诸如Webpack、Vite一类的工程化工具。
本文不讨论实际的配置和代码,主要讲述工程化中的一些概念,帮助更好理解前端工程化。
自 2009 年 Node.js 诞生,前端先后出现了 CommonJS
、AMD
、CMD
、UMD
和ES Module
等模块规范。
早在模块化标准还没有诞生的时候,前端界已经产生了一些模块化的开发手段,如文件划分
、命名空间
和 IIFE 私有作用域
。
CommonJS 是业界最早正式提出的 JavaScript 模块规范,主要用于服务端,随着 Node.js 越来越普及,这个规范也被业界广泛应用。对于模块规范而言,一般会包含 2 方面内容:
统一的模块化代码规范
实现自动加载模块的加载器(也称loader
)
代码中使用 require
来导入一个模块,用module.exports
来导出一个模块。实际上 Node.js 内部会有相应的 loader 转译模块代码,最后模块代码会被处理成下面这样:
ts(function (exports, require, module, __filename, __dirname) {
// 执行模块代码
// 返回 exports 对象
});
AMD
全称为Asynchronous Module Definition
,即异步模块定义规范。模块根据这个规范,在浏览器环境中会被异步加载,而不会像 CommonJS 规范进行同步加载,也就不会产生同步请求导致的浏览器解析过程阻塞的问题了。我们先来看看这个模块规范是如何来使用的:
js// main.js
define(["./print"], function (printModule) {
printModule.print("main");
});
// print.js
define(function () {
return {
print: function (msg) {
console.log("print " + msg);
},
};
});
由于没有得到浏览器的原生支持,AMD 规范需要由第三方的 loader 来实现,最经典的就是 requireJS 库了,它完整实现了 AMD 规范,至今仍然有不少项目在使用。
ES6 Module
也被称作 ES Module
(或 ESM
), 是由 ECMAScript 官方提出的模块化规范,作为一个官方提出的规范,ES Module
已经得到了现代浏览器的内置支持。在现代浏览器中,如果在 HTML 中加入含有type="module"
属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包也可以顺利运行模块代码。
运行环境 | 加载方式 | 运行机制 | 特点 | |
---|---|---|---|---|
服务器 | 同步 | 运行时 | 第一次加载后会将结果缓存,再次加载会读取缓存的结构。 | CommonJS |
浏览器 | 异步 | 运行时 | 依赖前置,不管模块是否有用到,都会全量加载。 | AMD |
浏览器 | 异步 | 运行时 | 依赖就近,延迟加载 | CMD |
浏览器/服务端 | 异步 | 编译时 | 静态化,在编译时就确定模块之间的依赖关系,输入和输出。 | ESM |
不同于其他几种标准,ESM加载模块是静态的,在编译时就确定了模块于模块的关系,在使用一些打包器如Webpack时,他会分析依赖图从而删除一些未使用的模块和代码,保证打包结果的轻量。像CommonJS这类,是动态加载的,无法确定依赖图,从而无法进行Tree Shaking。
Webpack 是一种用于构建 JavaScript 应用程序的静态模块打包器,它能够以一种相对一致且开放的处理方式,加载应用中的所有资源文件(图片、CSS、视频、字体文件等),并将其合并打包成浏览器兼容的 Web 资源文件。
它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。
入口起点(entry point) 指示 webpack 应该使用哪个模块,来作为构建其内部 依赖图(dependency graph) 的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。
output 属性告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。主要输出文件的默认值是 ./dist/main.js
,其他生成文件默认放置在 ./dist
文件夹中。
每当一个文件依赖另一个文件时,webpack 都会将文件视为直接存在 依赖关系。这使得 webpack 可以获取非代码资源,如 images 或 web 字体等。并会把它们作为 依赖 提供给应用程序。
当 webpack 处理应用程序时,它会根据命令行参数中或配置文件中定义的模块列表开始处理。 从 入口 开始,webpack 会递归的构建一个 依赖关系图,这个依赖图包含着应用程序中所需的每个模块,然后将所有模块打包为少量的 bundle —— 通常只有一个 —— 可由浏览器加载。
Webpack在生成依赖图后,遍历Entry对象,创建中间产物Chunk,并根据Chunk生成最终的bundle
bundle 是 Webpack 打包后的最终输出文件。它包含了经过处理和优化的项目代码、依赖库以及各种资源。
Sourcemap 协议 最初由 Google 设计并率先在 Closure Inspector 实现,它的主要作用就是将经过压缩、混淆、合并的产物代码还原回未打包的原始形态,帮助开发者在生产环境中精确定位问题发生的行列位置
webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效 模块,以供应用程序使用,以及被添加到依赖图中。
Plugin 是用于扩展 Webpack 功能的插件,可以在 Webpack 构建过程的不同阶段执行各种任务。Plugin 可以实现更复杂的功能,如代码压缩、优化、生成 HTML 文件、清理输出目录等。
通过选择 development
, production
或 none
之中的一个,来设置 mode
参数,你可以启用 webpack 内置在相应环境下的优化。其默认值为 production
。
开发时
使用Webpack开发web应用时,启动开发服务器需要递归打包整个依赖树,然后将打包结果缓存到本地,供下次修改代码后快速更新,又因为Webpack使用JavaScript编写性能较弱,所以Webpack开发服务器的启动速度很慢。
打包时
Webpack的流程一般是,入口分析识别模块引用,遍历模块生成依赖图,根据遍历的结果使用相应的loader进行构建处理,同时也会在不同阶段运行plugin处理中间产物,最后生成产物。
webpack5引入了持久化缓存特性,将首次构建的过程与结果数据持久化保存到本地文件系统,在下次执行构建时跳过解析、链接、编译等一系列非常消耗性能的操作,直接复用上次的 Module/ModuleGraph/Chunk 对象数据,迅速构建出最终产物。
动态加载是 Webpack 内置能力之一,我们不需要做任何额外配置就可以通过动态导入语句(import
、require.ensure
)轻易实现。但请 注意,这一特性有时候反而会带来一些新的性能问题:一是过度使用会使产物变得过度细碎,产物文件过多,运行时 HTTP 通讯次数也会变多,在 HTTP 1.x 环境下这可能反而会降低网络性能,得不偿失;二是使用时 Webpack 需要在客户端注入一大段用于支持动态加载特性的 Runtime。
通过给生成产物文件名加上一段由产物内容生成的Hash值,对所有产物设置强缓存,当内容更新时,文件名的Hash值改变,引起服务器重新加载新的产物文件。
使用 Webpack 的 externals
特性将部分模块排除在 Webpack 打包系统之外,然后可以使用CDN的方式引入,Webpack 会 预设 运行环境中已经内置这些库。
Tree-Shaking 较早前由 Rich Harris 在 Rollup 中率先实现,Webpack 自 2.0 版本开始接入,是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,判断哪些模块导出值没有被其它模块使用 —— 相当于模块层面的 Dead Code,并将其删除。
Vite和Webpack类似,都是一种打包器,Vite一般可用于Web应用的开发,得益于其中内置的ESBuild和Rollup,Vite在开发和打包时的体验都相当好。值得注意的是ESBuild使用GO语言编写因此拥有非常快的速度,而Rollup使用JavaScript编写的。
no-bundle是vite重要的概念,vite的开发服务器借助浏览器支持ESM的性质,对于项目的源码不进行打包(no-bundle),只对第三方库进行打包(兼容ESM格式和解决递归依赖加载问题)。
可以说no-bundle是vite启动快的重要原因。
在启动开发服务器之前会先进行预构建,会将第三方依赖打包并转换为ESM(主要是解决ESM格式兼容性问题和Vite开发服务器按需加载导致的海量请求问题,如lodash-es库)
打包第三方依赖完成后,会对项目的源码进行单文件编译,如Vue、TS、JSX文件,都会被编译为ESM,在使用ESBuild以前,这种事情一般都需要使用Babel、TSC等工具,而使用ESBuild Transformer可以做到非常快速编译文件
ESBuild Transformer无法做到TS的类型检查,因此类型检查更多的需要依赖编辑器的提示。
vite打包使用了rollup进行打包,rollup轻量的特性比较适合vite集成用来打包。
其他性质类似Webpack。
在前端领域中,用来为旧浏览器提供其没有的最新原生支持的代码片段,我们将其称之为“polyfill” ,翻译过来就是“垫片”,也就是打补丁的意思
最方便的解决兼容性问题,首先想到的应该是手动写一个转换函数,将新语法转换为旧语法实现降级。比如 Object.assign()
其优点是,直接简单,并且天然的支持“按需”使用,不会有其他冗余代码,在性能上比较友好,但是缺点就是这不是一种工程化的解决方案,不易管理和维护,且复用性低。
@babel/preset-env
会根据目标环境来进行编译和打补丁,如果想在最近 3个 浏览器版本和 安卓4.4 版本以及 iOS 9.0 以上版本运行我们的代码,那么我们可以这样配置 babel
js...
presets: [
[
'@babel/preset-env',
{
targets: {
"browsers": [
"last 3 versions",
"Android >= 4.4",
"iOS >= 9.0"
],
}
},
],
]
...
如果该浏览器支持该特性的话,那么针对该特性的 polyfill 就不需要引入。那么如何减少这种冗余呢?在线动态打补丁就是一个方案。
https://polyfill.io/v3/ 就是实现该方案的服务,其提供 CDN 资源,会根据浏览器的 UA 不同,返回不同的内容。
不过现在这个网站已经失效了,据说是中国公司收购后放了恶意代码,这种CDN服务确实容易存在这类问题。
Babel 是一个 JavaScript 的编译器。
Babel 的主要功能有:
Babel 的架构模式是“插件架构模式” 。插件架构模式的特点就是将扩展功能从核心模块中抽出为插件
Babel
的转译过程主要可以分为三个步骤,解析(parse)、转换(transform)、生成(generate)
分别使用到@babel/parser
、@babel/traverse
、@babel/generator
当一个仓库规模逐渐升级并拆分为多个模块时,可以使用Monorepo
的方式管理仓库。些模块通常在同一仓库中依赖其他不同模块, 同时不同模块间还会互相依赖。
monorepo有pnpm和lerna两种解决方案,本文主要讲pnpm,我们先了解pnpm。
pnpm又称 performant npm,翻译过来就是高性能的npm。
pnpm通过使用硬链接和符号链接(又称软链接)的方式来避免重复安装以及提高安装效率。
下载流程
硬链接: 电脑文件系统中的多个文件平等的共享同一个文件存储单元。
假如磁盘中有一个名为 data 的数据,C盘中的一个名为 hardlink1 的文件硬链接到磁盘 data 数据,另一个名为 hardlink2 的文件也硬链接到磁盘 data 数据,此时如果通过 hardlink1 文件改变磁盘 data 的数据内容,则通过 hardlink2 访问磁盘 data 数据内容是改变过后的内容。
硬链接可以有多条,它们可以指向同一块磁盘空间。
软链接(符号连接): 包含一条以绝对路径或相对路径的形式指向其他文件或者目录的引用。
最常见的就是桌面的快捷方式,其本质就是一个软链接,软链接所产生的文件是无法更改的,它只是存储了目标文件的路径,并根据该路径去访问对应的文件。
pnpm 使用一个统一的内容可寻址存储目录来存放所有下载的包。这个存储目录通常位于用户主目录下的.pnpm-store
文件夹。
工作原理
node_modules
目录中。幽灵依赖指的是那些没有在项目的package.json
文件中明确声明,但却可以在项目代码中被引入和使用的依赖。
项目安装了包 A,而包 A 又依赖包 B。如果包 B 没有在项目的package.json
中声明,那么包 B 就是幽灵依赖,B可以被使用。
monorepo模式是让一个项目中的不同组件和依赖,以独立模块的方式存储在单个代码仓库中,模块与模块之前可能有依赖关系。
monilith、multirepo、monorepo的区别
在pnpm 中使用 workspace(工作空间)以支持monorepo
需要在代码仓库根目录创建pnpm-workspace.yaml
,指定哪些目录作为独立的工作空间,这个工作空间可以理解为一个子模块或者 npm
包。
例如,就表示以a文件夹为一个包,b文件夹为一个包,c文件夹下所有文件夹为都为包
ymlpackages:
- a
- b
- c/*
📦my-project ┣ 📂a ┃ ┗ 📜package.json ┣ 📂b ┃ ┗ 📜package.json ┣ 📂c ┃ ┣ 📂c-1 ┃ ┃ ┗ 📜package.json ┃ ┣ 📂c-2 ┃ ┃ ┗ 📜package.json ┃ ┗ 📂c-3 ┃ ┗ 📜package.json ┣ 📜package.json ┣ 📜pnpm-workspace.yaml
pnpm
并不是通过目录名称,而是通过目录下 package.json
文件的 name
字段来识别仓库内的包与模块的。
如果a包需要使用b包中的一些函数,可以在package.json
中配置依赖
如
json{
"name": "a",
// ...
"dependencies": {
"b": "workspace:*"
}
}
参考文章
pnpm官方文档
Webpack中文文档
Vite中文文档
掘金小册《Webpack5 核心原理与应用实践》
掘金小册《深入浅出 Vite》
本文作者:peepdd864
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!