其实整个前端就是 下面图中五个 层面的东西,本期文章我们来梳理下工程化方面的概念

前端工程化就是前端开发的管理工具,目的是降本增效,也就是降低管理成本,提升效能。对于团队来讲,团队成员越多,项目越复杂,工程化价值就越高
模块化和包管理
模块化的本质就是分解和聚合,这与编写函数的思路相似,将复杂的逻辑拆分成独立的模块。在文件层面,一个文件就应该专注于一个明确的方向
js 在文件模块化有一个发展历程,主要都是去解决下面两个核心问题
- 全局污染(分解)
- 依赖混乱(聚合)
所谓全局污染就是一个 html 引入了两个 js 文件,这两个 js 文件都有同一个名称的变量,导入到 html 中后,由于加载顺序,后者就会覆盖前者,造成全局命名空间污染
所谓依赖混乱就是以前没有模块系统时,多个脚本存在隐式依赖,加载顺序要求严格,难以追踪依赖关系的问题
模块化标准
-
common js(cjs) :node 环境的模块标准
-
amd :异步模块定义,主要用于浏览器
-
cmd:通用模块定义
-
umd:通用模块定义,兼容多种环境
-
Ecmascript module(esm):官方标准
除了 esm 是官方标准外,其余标准都是社区标准,amd,cmd,umd 现在也都基本淘汰了,保留下来的就是 cjs 和 esm
由于 node 生态非常完善,cjs 依旧还是被广泛使用,不过现在 node 也在积极兼容 esm
cjs 和 esm 的区别
- 来源(社区,官方)
- 时机(运行时,编译时)
特性 | CommonJS | ES Modules |
---|---|---|
标准来源 | 社区标准 | 官方标准 |
加载时机 | 运行时 | 编译时 |
语法 | require() / module.exports |
import / export |
条件加载 | 支持 | 不支持(顶层) |
cjs 只有运行到 require() 时才会确认依赖关系,也就是支持条件加载
js
if (condition) {
const moduleA = require('./moduleA.js');
} else {
const moduleB = require('./moduleB.js');
}
而 import 只能写在文件最顶部,只有这样才能明确依赖关系,esm 不用运行就能确认依赖关系
当然 esm 有个 动态导入 的语法,import()
js
if (condition) {
const module = await import('./moduleA.js');
}
明白了这个你就会清楚,一般打包工具都是在编译阶段去解析代码的,因此大部分的标准都是要求 esm
commonjs 标准就是在 node 社区官网中有详细介绍:nodejs.org/api/modules...
前面几篇文章中有一篇 node 模块查找策略,就是讲的 commonjs 标准
esm 就需要来到 ecma 官网中:tc39.es/ecma262/mul...
ecma 其实就是 js 的标准,而真正落实这些标准得依赖 js 的运行环境,常见的环境一般就是 浏览器,node,构建工具
浏览器由于是官方的东西,所以浏览器不会在意 node 标准,也就是不支持 cjs
node 目前 cjs 和 esm 都支持,但是一般前端开发都是在构建工具中使用 模块化
像是 webpack cjs 和 esm 都支持,而 rollup,esbuilder 只支持 esm,否则需要额外的插件了
环境 | CommonJS | ES Modules |
---|---|---|
浏览器 | ❌ | ✅ |
Node.js | ✅ | ✅ |
Webpack | ✅ | ✅ |
Rollup | ❌ | ✅ |
esbuild | ✅ | ✅ |
包管理
包是模块化在更大尺度上的体现,它是一系列相关模块的集合,从尺度上看:
-
函数 - 最小的模块化单元
-
文件 - 文件级别的模块化
-
包 - 多个文件/模块的集合
比如 vue 的源码就是多个 package 组成的 monorepo 结构
包其实就是更大层面的分解和聚合
所谓包管理就是需要弄清楚下面几个问题
- 包的获取 - 从哪里下载包
- 版本管理 - 如何控制包的版本
- 依赖解析 - 如何处理包之间的依赖关系
- 生命周期管理 - 如何安装、升级、卸载包
- 包的发布 - 如何发布自己的包
像是 npm 就包含了三个重要的部分
-
Registry - 包的存储仓库
-
存储和分发包
-
支持镜像源切换(如淘宝镜像)
-
提供包的搜索和版本管理
-
-
CLI - command-line interface,就是命令行操作界面,就像是 GUI
-
package.json - 包的元数据描述文件
目前 包管理工具 的源头就是 npm,里面有 包的 属性,以及 registry,registry 就是包存放的位置,比如更改 npm 源为淘宝源,就是包的仓库放到了淘宝那里,
npm 有一些缺陷,导致衍生了更多的包管理工具,比如 yarn, pnpm, cnpm, bower
bower 是用来适配浏览器环境的,npm 并不支持浏览器
cnpm 和 bower 现在基本上已经丢弃了
先看 yarn 的优势
-
并行安装,提升速度
-
yarn.lock 确保依赖版本一致性
-
更好的错误信息和用户体验
pnpm 目前应该是最优解,因为 pnpm 除了提供包管理之外的功能,还提供了工程管理,比如 monorepo,通用库和框架的编写基本上都是用的 monorepo
-
硬链接机制 - 节省磁盘空间
-
解决幽灵依赖 - 严格的依赖隔离
-
Monorepo 支持 - 内置工作空间管理
-
更快的安装速度 - 增量安装策略
幽灵依赖其实就是没声明的依赖依旧能用
md
node_modules/
├── package-a/
│ └── node_modules/
│ └── lodash/ # package-a 的依赖
└── your-project/
└── index.js # 可以直接 require('lodash'),但 package.json 中没有声明
pnpm 通过符号链接和严格的依赖隔离解决了这个问题。
其实 npm 的问题在 yarn,pnpm 解决后,npm 又会跟着解决自己的问题,就相当于抄得明白
工具 | 特点 | 状态 |
---|---|---|
npm | 官方工具,功能完善 | ✅ 活跃 |
yarn | 更快的安装速度,lock 文件 | ✅ 活跃 |
pnpm | 节省磁盘空间,解决幽灵依赖 | ✅ 推荐 |
cnpm | 淘宝镜像的客户端 | ⚠️ 较少使用 |
bower | 专为浏览器设计 | ❌ 已废弃 |
无论是模块化还是包管理,其核心思想都是分解和聚合
函数层面的分解和聚合我们都会,文件层面就需要模块化实现,而包层面就需要包管理来实现分解和聚合,不同尺度下的分解聚合要求不同而已
js 工具链
先讲讲语言问题
前端开发主要涉及三种语言:html, css 和 js。然而,单纯依靠这三种原生语言很难满足现代复杂项目的开发需求,主要面临两大挑战:一个兼容性,一个语言自身的缺陷
比如 html 会有增强版 haml,一般 codepen 网站里面的特效都是 haml 实现的,而非 html
html 的问题其实还好,因为我们开发基本上都是单页应用,单页应用的 html 基本上都是 js 生成出来的,比如 document.createElement
比较严重的问题的问题都是出现在 css,这才出现了 sass/less/stylus postcss tailwind, css-in-js
比如 react 中的 styled-component 就是一个 典型的 css-in-js 库
除了语言问题还有工程问题和流程问题
工程问题可以用下面的方法解决
-
文件指纹 - 缓存控制
-
代码压缩 - 减小文件体积
-
代码混淆 - 保护源码
-
Tree Shaking - 移除未使用代码
流程问题就是运维问题,比如 git,预发布,自动化测试。但只要不涉及到项目架构就不会涉及这个问题
JavaScript 工具链的存在就是针对 js 的语言问题,为了解决开发体验
和运行时
要求之间的矛盾:
- 开发时: 希望使用最新的语法特性,提高开发效率
- 运行时: 需要兼容目标环境,保证代码正常执行
例如:
- TypeScript 提供了类型安全,但浏览器不认识
- JSX 让组件编写更直观,但需要转换为 JavaScript
- ES6+ 语法更现代,但需要转换为 ES5 兼容旧浏览器
兼容性
兼容性又可以分成 api 兼容 和 语法兼容
api 兼容
不同环境(浏览器版本、Node.js 版本)对 js API 的支持程度不同。
要是遇到不兼容问题,我们就需要自己手动实现
比如 数组的某些方法不兼容,那就直接给 Array.prototype 身上加一个 api 就行
这种做法就是 polyfill,就是填充,就是 缺失了 api 就给你填充上去
这个工作你可以自己做,但是更多的都是用 工具
目前最主流的 polyfill 就是 core-js
polyfill 其实也有局限性
但是有时候我们无法在 浏览器 中实现 polyfill,比如原生的 promise,observer api 都没有,那么微队列就实现不了
语法兼容
语法兼容就需要实现语法转换,但是语法转换比 polyfill 更复杂
比如 async await 语法,有时候浏览器可能不支持es 6,语法不同于 api,api 没了可以写,语法就会很麻烦,语法的兼容我们称之为 syntax transformer(runtime) 转换
语法兼容由于问题比较复杂,目前没有大而全的 transformer
比如 async await 就会有单独的 transformer 去转换
常见的语法转换:
- async/await → Generator + Promise
- 解构赋值 → 传统赋值语句
- 箭头函数 → 普通函数
- 模板字符串 → 字符串拼接
- 类语法 → 原型链实现
这个就是语法兼容
语言增强
有了语法兼容,我们可以进一步实现语言增强,创造出比原生 js 更强大的语言。
比如 ts 对应的转换工具就是 tsc
无论是做 api 兼容,还是语法兼容,增强,其核心都是做代码转换
但是这些代码转换工具都是单独成立的,因此需要一个工具链统一放到一起
这就是代码集成转换工具,也就是 babel
让具体的转换工具集成起来
babel 的工作流其实就是将 source 源码转成 ast,然后再 ast 转成 source
md
源代码 → AST → 目标代码
- 解析(Parse) - 将源代码转换为抽象语法树(AST)
- 转换(Transform) - 通过插件修改 AST
- 生成(Generate) - 将修改后的 AST 转换为目标代码
在 ast 转回 source 的过程就是 babel 插件的作用时机
一般我们需要单独给某个语法做 babel 转换时,就需要单独安装一个插件,比如 可选链 就会有一个 可选链 的 babel 插件
但是这样一来就每个特定的语法都要单独安装,这就会还是最开始的问题,还是很麻烦,因此 babel 就有了一个 预设 @babel/preset-env
预设里面会有一堆插件,比如 jsx 语法预设
babel 的目的就是整合 polyfill 和 syntax,还有个些新兴的转换工具
SWC (Speedy Web Compiler):
- 使用 Rust 编写,性能更优
- 兼容 Babel 的大部分功能
- 转换速度比 Babel 快 10-20 倍
esbuild:
- Go 语言编写
- 极快的构建速度
- 内置 TypeScript 和 JSX 支持
css 工具链
css 作为样式语言,存在比 js 更严重的语言问题
-
语法缺失:逻辑性,循环,判断,字符串拼接
-
功能缺失:颜色函数,数学函数,自定义函数
这两个问题其实都比 js 严重太多,不是某个api或者语法可以去修补的,而是需要一个新的语言。
css 预处理器
为了解决 CSS 的语言局限性,社区开发了多种 CSS 预处理器,有 sass,less,stylus ,然后会有对应的 css 预编译器,转换成 css 语言
特性 | Sass/SCSS | Less | Stylus |
---|---|---|---|
语法风格 | 缩进式/大括号式 | 类 CSS | 灵活语法 |
变量 | $variable |
@variable |
variable |
嵌套 | ✅ | ✅ | ✅ |
混入 | @mixin |
.mixin() |
mixin() |
函数 | ✅ | ✅ | ✅ |
条件语句 | @if/@else |
when() |
if/else |
循环 | @for/@each |
递归混入 | for/while |
一般这些 新的 css 语言都是包含 css 的,也就是css的超集
比如 sass 的特性就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> 前缀变量,用了 s a s s 编译后 前缀变量,用了 sass 编译后 </math>前缀变量,用了sass编译后 就会消失
sass 还支持 css value 里面写 函数,因此我们可以在函数里面实现各种逻辑,比如随机,循环等等,甚至可以 debug 去打印
sass 支持嵌套处理,虽然原生 css 已经支持了 嵌套,但是浏览器是否兼容还是个问题
css 后处理器
其实有了 新的 css 语言还不能完全解决原生 css 的问题,比如 -webkit 这种厂商前缀,以及对代码压缩,代码剪枝
像是这几个问题也会有对应的工具帮你处理,比如下面这些
- 厂商前缀 autoprefix - 自动添加浏览器兼容前缀
- 代码优化 cssnano - 压缩、合并、去重
- 代码剪枝 purgecss - 移除未使用的样式
- 类名处理 css module - 解决样式冲突问题
代码剪枝就是有些开发中可能没用上的 css ,帮你自动剔除掉
这几个问题的处理器都是属于 css 之后的问题,也就是后处理器
无论 预处理器 还是后处理器都是在做 转换
postcss 其实就是 js 的 babel,他就是去处理 css 的 原生问题,也就是厂商前缀,代码压缩,代码剪枝这种,但是 在 parse 的过程中我们可以传入 比如 scss 的编译器,这样 postcss 就支持 scss 了。但是一般在工程中,scss 和 postcss 都是分开的,也就是 postcss 只做后处理器,不过两种观点都行
js
PostCSS 配置示例
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
require('@tailwindcss/postcss7-compat'),
]
};
再来看看现代 css 解决方案
Tailwind CSS
- 原子化 CSS 框架
- 实际上是 postcss 的一个插件
- 通过工具类组合实现样式
CSS-in-JS
- styled-components (React)
- emotion
- 运行时生成样式,解决作用域问题
构建工具和脚手架
构建工具
代码层面需要进行转换,工程层面也需要转换,一个最简单的工程就是一个 index.html, main.js, index.css,这样的三个文件
而如今的工程全都是体系庞大的各种 packages,也需要转换到最终的 html,css,js
这个转换就需要用上 构建工具和脚手架
一般转换都是 build
生成的目录就是 dist 目录,里面一般都是 assets,css,js
我们对代码进行转换是为了兼容性,或者语言增强,不过这两个原因说的不够本质,本质是因为开发和维护的代码与运行时需要的代码不一致
开发环境特点:
- 模块化的源码结构
- 多种文件类型(.ts, .jsx, .scss 等)
- 丰富的 node_modules 依赖
- 开发工具和配置文件
生产环境需求:
- 浏览器可直接执行的文件(.html, .css, .js)
- 优化后的资源(压缩、合并、缓存)
- 移除开发依赖和工具代码
- 跨浏览器兼容性
构建工具就是用来做工程的转换,想要转换需要弄清下面三个问题
- 哪种工程更适合开发和维护
- 哪种工程更适合运行时
- 如何转换(打包)
由于这三个条件没有标准,因此构建工具有很多,都会有不同的差异
webpack, rollup, esbuild, trubopack, Rspack, snowpack, grunt, gulp
工具 | 特点 | 适用场景 | 性能 |
---|---|---|---|
Webpack | 功能全面,生态丰富 | 复杂应用,多页面 | 中等 |
Rollup | Tree Shaking 优秀 | 库开发,ES Modules | 快 |
esbuild | 极快的构建速度 | 开发环境,简单项目 | 极快 |
Vite | 开发体验优秀 | 现代前端项目 | 快 |
Parcel | 零配置 | 快速原型,小项目 | 快 |
Turbopack | Rust 实现,性能优异 | 大型项目(实验性) | 极快 |
这些构建工具只需要学习一个 webpack 就行
其实 webpack , rollup, esbuild 全会是最好的
webpack 认为上面三个条件的问题是
- 一切皆为模块
- 传统工程更适合运行
- 以一个入口文件出发,深度遍历,寻找依赖关系,最后转换成 js,html,css
md
入口文件 → 依赖分析 → 模块解析 → 代码转换 → 资源优化 → 输出文件
- 入口分析 - 从 entry 配置的文件开始
- 依赖图构建 - 递归分析所有依赖关系
- 模块转换 - 通过 loader 处理不同类型文件
- 代码优化 - 通过 plugin 进行各种优化
- 资源输出 - 生成最终的打包文件
入口
入口文件我们需要在 webpack.configjs 中进行配置
js
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
// 入口文件
entry: './src/index.js',
// 输出配置
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
clean: true
},
// 模块处理规则
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
},
{
test: /\.(png|jpg|gif)$/,
type: 'asset/resource'
}
]
},
// 插件配置
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css'
})
]
};
里面有个entry就是入口文件的路径
webpack 分析依赖时,它会把文件当成 字符串,分解成 ast,通过 ast 找到 导入语句,webpack 同时支持 esm 和 cjs。
我们开发能写 require 是因为工程虽是跑在 esm 环境,但是我们开发最后是给 构建工具 看的,也就是意味着你的 构建工具 支持了 cjs
分析完依赖关系后,打包结果不会存在 任何 模块代码
尽管构建工具支持了 require ,但是还是更推荐你去用 import,因为现在的构建工具会针对 esm 做优化
在 ast 分析完后需要进行模块查找
开发服务器
dist 可以直接运行出项目,但是我们肯定是希望边开发边看到运行效果
webpack 提供了 serve 命令,将项目跑到了 本地环境 中,这就是开发服务器
像是 vscode 的 live server 插件,也是开发服务器
当我们 webpack serve 的时候,会启动一个名为 dev server 的服务器,严格来讲 dev server 不是 webpack 的功能,dev server 是 webpack 的一个库实现的,名为 webpack-dev-server,而这个库又依赖 express 框架
dev server 相当于帮我们在 内存中 完成 npm run build,在内存中形成了打包结果
在控制台中提示我们在哪个 localhost 端口,浏览器去访问我们的开发服务器,开发服务器向打包结果拿到页面,响应到浏览器,浏览器渲染页面的过程又会去拿 js,css ,又会继续请求,响应
还实现了热更新,也就是 源码改变,自动刷新
webpack 可以监听文件的变化,文件发生变化,会触发重新打包,这还不够,需要浏览器刷新,才能重新请求,拿到新的打包结果,想要触发浏览器自动刷新又需要 WebSocket 的支持
刷新会有两种,热更新HMR 是一种,还有一种是浏览器真正的刷新一遍
更新方式 | 特点 | 适用场景 |
---|---|---|
热更新 | 保持应用状态,只更新变更模块 | css 修改,组件更新 |
完整刷新 | 重新加载整个页面 | 配置文件修改,入口文件变更 |
文件指纹
哈希类型 | 特点 | 使用场景 |
---|---|---|
hash | 整个构建过程的哈希 | 开发环境 |
chunkhash | 每个 chunk 的哈希 | 代码分割 |
contenthash | 文件内容的哈希 | 生产环境(推荐) |
文件指纹是根据文件内容生成的哈希值,用于实现长期缓存策略。
打包结果dist目录中的 js,css 都会有一些 hash 值,hash 会随着源码内容的变化而变化
文件内容不变,那么 build 时,hash 就不会变
文件指纹是为了缓存,一般服务器都会给 js,css 缓存期限,其实缓存期限设置就是不合适,因为我们需要清楚文件是否发生变化,文件指纹变了,这就意味着 html 中引入的文件发生了变化,缓存就不会命中,会去请求新的文件
这种保证了既能缓存又能更新
css modules
css modules 是为了解决 css 全局作用域导致的样式冲突问题。
打包结果为了避免 css 类名冲突,会使用 css modules 给 类名改成一个 hash
因此我们写的源码没有 hash,但是打包结果有 hash,代码的运行都是在打包结果中,因此我们需要一个对应关系,源码的类名对应打包结果的类名
css
/* 源码:styles.module.css */
.button {
background: blue;
color: white;
}
/* 编译后 */
.styles_button__2x3k1 {
background: blue;
color: white;
}
这个对应关系在 webpack 中就实现了
sourcemap
sourcemap 的目的是 建立打包后代码与源代码之间的映射关系,便于调试
sourcemap 对应的文件就是 .map 文件,我们调试的功能就需要 sourcemap
我们若把 sourcemap 的配置关掉了,在浏览器的 source 选项卡就会一脸懵,因为里面的代码是 打包后的代码
sourcemap 的开启有很多种,不同方式不同结果,sourcemap 帮我们把 代码位置信息对应到了 源码位置,方便我们调试
js
module.exports = {
devtool: 'source-map', // 生产环境:完整映射
// devtool: 'eval-source-map', // 开发环境:快速重建
// devtool: 'cheap-source-map', // 只映射行信息
// devtool: false, // 不生成 source map
};
模式 | 构建速度 | 重建速度 | 生产环境 | 调试质量 |
---|---|---|---|---|
eval | 快 | 最快 | ❌ | 生成代码 |
cheap-source-map | 中等 | 快 | ✅ | 转换代码(仅行) |
source-map | 慢 | 慢 | ✅ | 原始代码 |
webpack 中很多东西都是用的别的插件,里面集成了很多技术,通过了两个拓展点,一个 loader,一个 plugin,他可以把 js / css 工具链融合起来了
扩展类型 | 作用时机 | 功能 | 示例 |
---|---|---|---|
Loader | 模块转换 | 处理特定类型文件 | babel-loader, css-loader |
Plugin | 构建过程 | 执行更广泛的任务 | HtmlWebpackPlugin, CleanWebpackPlugin |
常用 loader
js
module: {
rules: [
{ test: /\.js$/, use: 'babel-loader' },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
{ test: /\.(png|jpg)$/, type: 'asset/resource' }
]
}
常用 plugin
js
plugins: [
new HtmlWebpackPlugin(), // 生成 HTML
new MiniCssExtractPlugin(), // 提取 CSS
new CleanWebpackPlugin(), // 清理输出目录
new DefinePlugin({ // 定义全局变量
'process.env.NODE_ENV': JSON.stringify('production')
})
]
脚手架
构建工具有了,其实还剩下一些问题,比如项目目录,依赖安装,插件配置
因此需要脚手架帮我们搭建工程
- 提供界面和交互(大部分时cli命令行形式)
- 工程模板
比如 vite 这个脚手架,里面 package 中就会有各种 工程模板
下面是一些主流脚手架工具
脚手架 | 适用框架 | 特点 |
---|---|---|
Create React App | React | 零配置,开箱即用 |
Vue CLI | Vue 2/3 | 插件化架构,可扩展 |
Vite | 多框架 | 快速开发服务器,现代构建 |
Angular CLI | Angular | 完整的开发工具链 |
Next.js | React | 全栈框架,内置优化 |
Nuxt.js | Vue | 全栈框架,SSR 支持 |
这种东西都是针对 业务,做架构的人一定要会去写 脚手架,这就需要深入理解脚手架的原理
总结
工程化其实就是前端开发提效的工具,之所以产生工程化,就是因为前端应用不断壮硕,产生了一系列管理工具,比如 js,css,html 原生开发缺陷,需要一系列工具或者开发友好的语法进行弥补,但是如何管理这些工具以及模块,都不是简单的事情