7. 前端工程化
模块化解决了什么问题,有哪些标准
主要为了文件级的问题
- 全局污染
- 依赖混乱
为了解决这些问题,提出了一些标准
- Commonjs CJS
- AMD
- CMD
- UMD
- ESM
前面四个是民间标准,社区出的;ESM 是官方的
AST 分为几个阶段
抽象语法树
- 词法分析 input = tokenizer => token
- 语法分析 token = parser => ast
- 代码转换 ast = traverse => new ast
- 合成产物 new ast = generate => output
AST 规范
ESTree: 初衷通过社区的力量,保证和 es 规范的一致性,通过自定义的语法结构来表述 JavaScript 的 AST,后来随着知名度越来越高,多位知名工程师的参与,使得变成了事实意义上的规范,目前这个库是 Mozilla 和社区一起维护的。ESTree spec 和 Parser API 都是定义一种语法表达的标准,这种标准生成的结构就是 AST。大部分流行的 JS 源码操作工具都是基于 AST 实现的
常见 AST 节点类型
- 声明类型:
VariableDeclaration,FunctionDeclaration,ClassDeclaration - 表达式类型:
Identifier,Literal,BinaryExpression,CallExpression - 语句类型:
BlockStatement,IfStatement,ForStatement,ReturnStatement
babel - 编译流程
- 解析(Parsing):将代码解析为抽象语法树(AST),使用 @babel/parser 把源码转为 AST
- 转换(Transformation):通过插件对 AST 进行处理和修改,使用 @babel/traverse 修改 AST 节点
- 生成(Code Generation):将 AST 转换为新的代码字符串,使用 @babel/generator
Babel 常见的 preset 有哪些?
- @babel/preset-env:按目标环境转换 ES6+ 代码(最常用)
- @babel/preset-react:转换 JSX
- @babel/preset-typescript:转换 TypeScript
- @babel/preset-flow:Flow 类型支持
babel vs polyfill
- babel 是转译功能(语法转换),比如 ES6+ 转化为 ES5,是编译时处理的,比如
@babel/preset-env - Polyfill 是做 api 补丁的,提供缺失的 API,是运行时依赖于环境是否支持的,一般使用
core-js
webpack 理念
webpack 中的这种万物皆模块的理念实际上的蛮值得我们思考的,因为他确实打破了我们传统中在页面去引入各种各样资源的这种固化思维,让我们可以在业务代码中去载入一切所需资源,这样真正意义上让 js 去驱动一切;
webpack 配置文件常用的属性有哪些
- 基础配置:entry、output
- 模块配置:module.rules
- 插件配置:plugins
- 开发服务器:devServer
- 优化配置:optimization
- 解析配置:resolve
- 性能配置:performance
- 模式配置:mode
- 目标配置:target
- 其他配置:externals、stats
webpack 构建流程
一个串行的过程:
- 初始化:启动构建,从 webpack.config 和 shell 读取配置参数并合并
- 根据参数实例化一个 compiler,并实例化插件,在 webpack 的事件流上去挂一些钩子,使得插件在整个构建过程中具备改动和产出结果的能力,run 开始编译
- 确定入口,读取 entry 入口,并进行依赖收集
- 依赖 loader 进行编译,递归的找到所有依赖文件
- 完成模块编译,得到每个模块被翻译后的内容以及他们之间的依赖关系,输出依赖关系图
- 生成资源,根据入口和依赖关系图,组装成一个一个 chunk,再把 chunk 合并成一个单独的文件并加入输出列表,这一步是可以修改输出文件的最后机会,比如 code split
- 输出文件到 output
webpack 官方的钩子有多种不同的执行顺序的,讲一下这个执行顺序是怎么样去定义的
Webpack 的钩子机制基于 Tapable 库,提供了多种类型的钩子(如同步钩子、异步钩子等),并且这些钩子在 Webpack 的构建流程中按照特定的顺序执行。
- 生命周期顺序:钩子按照 Webpack 的构建流程依次执行,从初始化到输出。
- 插件注册顺序:先注册的插件的钩子会先执行。
- 优先级控制:通过 enforce 属性(pre、默认、post)调整钩子的执行顺序。
- 异步钩子类型:串行钩子按顺序执行,并行钩子同时执行。
webpack 中 complier 和 compliation 都是什么意思吗
- compiler 对象记录着构建过程中 webpack 环境与配置信息,整个 webpack 从开始到结束的生命周期。针对的是 webpack。
- compilation 对象记录编译模块的信息,只要项目文件有改动,compilation 就会被重新创建。针对的是随时可变的项目文件。
介绍下 webpack - loader,为什么是 自下而上,自右向左的?
其本质为函数,函数中的 this 作为上下文会被 webpack 填充,因此我们不能将 loader 设为一个箭头函数
函数接受一个参数,为 webpack 传递给 loader 的文件源内容
函数中 this 是由 webpack 提供的对象,能够获取当前 loader 所需要的各种信息
函数中有异步操作或同步操作,异步操作通过 this.callback 返回,返回值要求为 string 或者 Buffer
为什么是 自下而上,自右向左的? 这是因为每个 loader 是一个函数,遵循管道式设计,后一个 loader 的输出是前一个 loader 的输入。Webpack 会先从 use 数组的最后一个 loader 开始处理最原始的资源,然后将结果逐层传递给前面的 loader,最终返回给 Webpack 打包流程。所以 loader 执行顺序严格遵循从右向左、从下到上的原则。
webpack - plugin
由于 webpack 基于发布订阅模式,在运行的生命周期中会广播出许多事件,插件通过监听这些事件,就可以在特定的阶段执行自己的插件任务
如果自己要实现 plugin,也需要遵循一定的规范:
- 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问 compiler 实例
- 传给每个插件的 compiler 和 compilation 对象都是同一个引用,因此不建议修改
- 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住
webpack complier 和 compilation
webpack 编译会创建两个核心对象:
- compiler:包含了 webpack 环境的所有的配置信息,包括 options,loader 和 plugin,和 webpack 整个生命周期相关的钩子
- compilation:作为 plugin 内置事件回调函数的参数,包含了当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当检测到一个文件变化,一次新的 Compilation 将被创建
webpack 常用的 plugin 有哪些
- HtmlWebpackPlugin 自动生成 html 文件,并将打包后的 js 插入,可指定 template
- MiniCssExtraPlugin 将 css 提取为单独的文件
- HotModuleReplacementPlugin 提升开发效率
webpack 热更新的原理
Webpack HMR 的核心是:在运行时检测代码变更,把修改的模块用新代码替换掉,而不需要整个页面重新加载;
原理:
dev-server 开启 WebSocket 服务器
监听源码文件变动
编译生成 hot-update.json 和 hot-update.js
用 WebSocket 通知浏览器
浏览器拉取更新文件
浏览器 HMR runtime 替换模块
如果模块用 module.hot.accept 声明了接受更新 → 局部替换
否则冒泡到父模块 → 最后可能触发页面刷新
webpack 如果热更新失败会怎么样?
如果模块没有 module.hot.accept() 或更新过程报错,HMR 会往父模块冒泡,看父模块能不能接收更新。冒泡到入口模块还不行,就会 fallback 到页面刷新。
webpack CSS 和 JS 的 HMR 有啥区别
CSS 比较简单,CSS-loader 内部已经实现了 HMR,不需要我们额外写代码。 JS 模块(尤其是 React/Vue 组件)需要用 module.hot.accept() 声明接受更新,否则就会触发全局刷新。在 React 项目里一般会用社区方案,比如 React Refresh(react-refresh-webpack-plugin),它在 Babel 和 Webpack 层面帮我们做了更细粒度的 HMR,包括状态保留、错误边界等。
webpack scope hoisting
scope hoisting 是 webpack 的内置优化,它是针对模块的优化,在生产环境打包时会自动开启。
在未开启 scope hoisting 时,webpack 会将每个模块的代码放置在一个独立的函数环境中,这样是为了保证模块的作用域互不干扰。
而 scope hoisting 的作用恰恰相反,是把多个模块的代码合并到一个函数环境中执行。在这一过程中,webpack 会按照顺序正确的合并模块代码,同时对涉及的标识符做适当处理以避免重名。
这样做的好处是减少了函数调用,对运行效率有一定提升,同时也降低了打包体积。
但 scope hoisting 的启用是有前提的,如果遇到某些模块多次被其他模块引用,或者使用了动态导入的模块,或者是非 ESM 的模块,都不会有 scope hoisting。
webpack - 联邦模块
在大型项目中,往往会把项目中的某个区域或功能模块作为单独的项目开发,最终形成「微前端」架构;
这涉及到很多非常棘手的问题:
- 如何避免公共模块重复打包
- 如何将某个项目中一部分模块分享出去,同时还要避免重复打包
- 如何管理依赖的不同版本
- 如何更新模块
webpack5 尝试着通过模块联邦来解决此类问题
暴露出去:
// webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
// 模块联邦的名称
// 该名称将成为一个全部变量,通过该变量将可获取当前联邦的所有暴露模块
name: 'home',
// 模块联邦生成的文件名,全部变量将置入到该文件中
filename: 'home-entry.js',
// 模块联邦暴露的所有模块
exposes: {
// key:相对于模块联邦的路径
// 这里的 ./now 将决定该模块的访问路径为 home/now
// value: 模块的具体路径
'./now': './src/now.js',
},
}),
],
};引用:
// webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
// 远程使用其他项目暴露的模块
remotes: {
// key: 自定义远程暴露的联邦名
// 比如为 abc, 则之后引用该联邦的模块则使用 import "abc/模块名"
// value: 模块联邦名@模块联邦访问地址
// 远程访问时,将从下面的地址加载
home: 'home@http://localhost:8080/home-entry.js',
},
}),
],
};
// src/bootstrap.js
// 远程引入时间模块
import now from 'home/now'webpack - splitchunksplugin 的使用场景及使用方法
公共模块:比如某些多页应用会有多个入口,从而形成多个 chunk,而这些 chunk 中用到了一些公共模块,为了减少整体的包体积,可以使用 splitchunksplugin 将公共模块分离出来。可以配置 minChunks 来指定被多少个 chunk 引用时进行分包
并行下载:由于 HTML5 支持 defer 和 async,因此可以同时下载多个 JS 文件以充分利用带宽。如果打包结果是一个很大的文件,就无法利用到这一点。可以利用 splitchunks 插件将文件进行拆分,通过配置 maxSize 属性指定包体积达到多大时进行拆分
介绍下 webpack - tree Shaking
tree-shaking 仅支持 ESM 的静态导入语法,对于 CMJ 或者 ESM 中的动态导入不支持 tree shaking。
具体流程主要分为两步:标记和删除
标记:webpack 在分析依赖时,会使用注释的方式对导入和导出进行标记,对于模块中没有被其他模块用到的导出标记为 unused harmony export
删除:之后在 Uglifyjs (或者其他类似的工具) 步骤进行代码精简,把标记为无用的代码删除。
webpack5 主要升级点
Webpack 5 的核心升级体现在三个方面:
- 构建性能
- 模块共享(微前端)
- 体积优化。 比如:模块联邦、文件系统缓存、增强的 Tree Shaking、弃用 Node polyfill、以及对 ESM 的全面支持。
webpack 的 module,bundle,chunk
Module(模块):你写的每一个文件(JS、CSS、图片等)
Chunk(代码块):Webpack 打包过程中生成的中间产物(一个或多个模块的集合)
Bundle(最终产物):Webpack 输出到磁盘的文件(就是 chunk 的打包结果)
- Module 是你项目中的每一个源文件,是构建的最小单元;
- Chunk 是 Webpack 根据模块依赖图组合出来的代码块(可以是入口块或异步块);
- Bundle 是将 Chunk 经过 loader/plugin 等处理后输出的最终静态资源文件。
简单来说:Module → Chunk → Bundle 是整个构建产物的生成过程。
npm vs yarn vs pnpm
npm 是老大哥,pnpm,yarn,cnpm,bower 这些东西的出现都是去为了弥补 npm 的不足或者是修复 npm 的缺陷 npm 这个团队反射弧有点长,修复的不及时,又或者是受到历史遗留的因素,比如在 yarn 推出 yarn.lock 后,npm 也推出了 package-lock.json, 再比如 npm3 也学习 yarn 扁平化 node_modules;
- npm 是 Node 自带的包管理器,但早期安装速度慢、容易产生版本冲突。
- yarn 是为了解决这些问题开发的,引入了并发安装和 lock 文件一致性。
目前我推荐并在项目中使用的是 pnpm,因为它的核心优势是使用了 硬链接的方式管理依赖,不仅安装速度更快,还大大节省磁盘空间,特别适合 monorepo 项目结构。
pnpm 的依赖隔离机制避免了“依赖地狱”问题,和微服务/多包管理天然契合
硬链接:是多个文件名指向同一个文件内容(inode) 软连接:快捷方式
npm:
- 安装结构扁平
- 多版本依赖可能冲突
- 下载 - 解压 - 放入 node_modules
- 重复依赖占空间最大
- 安装速度较慢
yarn:
- 安装结构扁平
- 多版本依赖可控性较好
- 下载 - 解压 - 放入 node_modules
- 重复依赖占空间较大
- 安装速度较快(并发)
pnpm:
- 安装结构为属性结构 + 硬链接
- 多版本依赖可控性最佳,隔离了依赖,防止版本污染
- 下载到缓存区 - 硬链接引用 - 更快更省空间
- 重复依赖占空间较小,多个项目共用一个依赖缓存
- 安装速度最快(缓存+并发+硬链接)
介绍下 npm link
本地开发多个包、调试 NPM 模块、做组件库/SDK 本地调试时经常用到的工具
用于在多个本地项目之间创建软链接(symlink)的命令
lib 项目里执行 npm link,app 项目中执行 npm link lib
npm link 后,为什么改了组件库代码,主项目没有自动热更新?
因为 link 创建的是符号链接,主项目仍然使用自己的构建缓存。
需要主项目配置 watch 包含 node_modules/my-lib(如 Vite/Vue CLI 中设置 optimizeDeps.exclude 或 webpack symlink 支持)
package.json 中的 script 执行后会发生什么
npm run dev 等同于 ./node_modules/.bin/vite
npm run 和直接运行命令有啥区别? npm 会添加 PATH、做日志包装、处理钩子等
npm i 的时候,npm 就帮我们把这种软连接配置好了,其实这种软连接相当于一种映射,执行 npm run xxx 的时候,就会到 node_modules/.bin 中找对应的映射文件,然后再找到相应的 js 文件来执行
pnpm 为什么快?
根本原因是它的依赖管理机制和文件存储方式完全不同。 pnpm 安装依赖时,不会每个项目都下载和解压一份依赖副本。 而是将所有依赖缓存到全局的内容可寻址存储仓库中,然后,在项目目录下用 硬链接(hard link) 的方式连接依赖文件。实际物理磁盘只存了一份内容,多项目共享。 这个存储位置可以用 pnpm store path 来获取到,内容是一堆 00,01 的文件夹,这是哈希前缀分片目录,用于分散文件数量,加快查找和文件系统访问效率。 pnpm 在缓存依赖包时,为每个依赖生成一个基于内容的哈希值(比如 SHA-512) 然后按这个哈希的前两位字符,决定放到哪个子文件夹下(比如 00, 01,一直到 ff,共 256 个)
pnpm 的关键是它使用了“硬链接 + 全局缓存”的方式安装依赖,避免重复解压和下载,相同依赖在多个项目中只存储一份,大大减少磁盘空间。 安装速度也明显快,依赖安装是并发执行的。同时 pnpm 对依赖版本控制更严格,防止因为平铺 node_modules 导致的问题,是现代项目和大型 monorepo 的首选。
介绍下 ESLint
预先配置好各种规则,通过这些规则来自动化的验证代码,甚至自动修复;
ESLint 的规则非常庞大,全部自定义过于麻烦,一般我们继承其他企业开源的方案来简化配置
这方面做的比较好的是一家叫 Airbnb 的公司,他们在开发前端项目的时候自定义了一套开源规则,受到全世界的认可
介绍下 husky 的原理
Git 提供了一种钩子机制,可以在特定的 Git 操作(如 commit、push)前后执行自定义脚本。
- pre-commit:在提交代码前执行。
- pre-push:在推送代码前执行。
- commit-msg:在提交信息被保存前执行。
husky 正是利用 Git 原生的钩子机制,在项目的 .git/hooks/ 目录中注入自定义脚本,通过软链接或 shell 脚本实现“提交前/提交后”的自动化任务。
当开发者执行 Git 操作时,Husky 会触发对应的钩子脚本。
Husky 会在这些钩子文件中调用用户定义的任务(如运行 Lint、测试等)。
如何在大型项目中优化 Lint 检查的性能?
Lint 检查耗时过长时如何优化?
- 使用 Lint-staged:只检查 Git 暂存区的文件,而不是整个项目。
- 按需检查:在 CI/CD 中,针对改动的文件运行 Lint,而不是全量检查。
- 缓存结果:使用 ESLint 的 —cache 参数缓存检查结果:
git rebase vs git merge
- git merge 是最常用的合并分支方式,它保留两个分支的历史并生成一个新的合并提交
- git rebase 是将当前分支的提交“迁移”到目标分支的最新提交之后,重写提交历史,保持提交线性、整洁。
两者的核心区别在于是否重写历史和提交记录是否线性。 在实际项目中,我一般在本地同步主分支代码时使用 rebase,避免产生无意义的 merge commit,而在多人协作时,合并功能分支到主干,通常使用 merge 保留完整的历史轨迹。
npx 指令
使用 npx 命令时,它会首先从本地工程的 node_modules/.bin 目录中寻找是否有对应的命令;如果将命令配置到 package.json 的 scripts 中,可以省略 npx;
当执行某个命令时,如果无法从本地工程中找到对应命令,则会把命令对应的包下载到一个临时目录,下载完成后执行,临时目录中的命令会在适当的时候删除
minify 主要做了什么工作
其实就是 AST => 小 AST
- 去除注释,
// 注释会被删掉 - 去除空格和换行,减少文件大小
- 变量名缩短,
function abcdef() → function a() - 常量折叠
2 + 2 → 4 - 死代码移除,移除未使用的函数或分支
前端工程化发展思路
什么是工程化?前端开发的管理工具集合就叫工程化,用以降低开发成本提升开发效率的
比如:
- 抽离公共组件,开发一个契合团队的前端脚手架
- 成员之间代码风格不一样,影响代码的维护和阅读?如何划分模块?如何进行单元测试?命名规范?版本控制?性能优化?
项目简单,团队规模小,一些现成的工具,官方的脚手架就可以解决问题,无法体现工程化的含义,截至目前前端有两百万个第三方库,绝大部分都和工程化相关
模块化:分解和聚合的思想,模块化解决的问题就是文件的:
- 全局污染问题
- 依赖混乱问题
提出了一些解决方案
- CJS
- CMD
- AMD
- UMD
- ESM
有了模块化和包管理,前端就有用了应对复杂项目的可能性
前端三剑客发展到现在很难适应复杂工程;三大语言在发明之初就没想到后续需要应对这么多复杂问题
三大语言的问题
js 语言问题:
- 兼容性(
- API 兼容 => polyfill => core-js
- 语法兼容 => syntax transform => babel,一般用到一些 preset,比如 @bable/preset-env
- 语言缺陷 => 语言增强 => 比如使用 ts
css 语言问题
- 语法缺失 比如循环 判断 拼接
- 功能缺失 比如颜色函数 数学函数 自定义函数
新语言 (scss less stylus) ===编译器===> css ===后处理器===> css
css —postcss 靠插件转换 -> css 不只是 后处理器,parser 可以自定义
开发维护的工程 ==构建工具==> 运行时需要的工程
介绍下 turbopack
Webpack 是 JS 社区最经典的构建器,但它存在性能瓶颈,尤其是在大型项目中构建缓慢。Turbopack 是由同一作者基于 Rust 重新设计的新一代构建工具,专为性能和模块热更新优化,目前是 Next.js 默认构建工具之一。虽然插件生态还不成熟,但未来在 React/Nex.js 方向有很大潜力。
Webpack 是“静态分析 + AST 构建依赖图”的架构,而 Turbopack 更偏向“lazy module graph + 编译时并发处理”。
Turbopack 的插件机制将支持 WASM,让构建生态更开放(