前端工程化总览

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

前端工程化就是前端开发的管理工具,目的是降本增效,也就是降低管理成本,提升效能。对于团队来讲,团队成员越多,项目越复杂,工程化价值就越高

模块化和包管理

模块化的本质就是分解和聚合,这与编写函数的思路相似,将复杂的逻辑拆分成独立的模块。在文件层面,一个文件就应该专注于一个明确的方向

js 在文件模块化有一个发展历程,主要都是去解决下面两个核心问题

  1. 全局污染(分解)
  2. 依赖混乱(聚合)

所谓全局污染就是一个 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 的区别

  1. 来源(社区,官方)
  2. 时机(运行时,编译时)
特性 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 结构

包其实就是更大层面的分解和聚合

所谓包管理就是需要弄清楚下面几个问题

  1. 包的获取 - 从哪里下载包
  2. 版本管理 - 如何控制包的版本
  3. 依赖解析 - 如何处理包之间的依赖关系
  4. 生命周期管理 - 如何安装、升级、卸载包
  5. 包的发布 - 如何发布自己的包

像是 npm 就包含了三个重要的部分

  1. Registry - 包的存储仓库

    • 存储和分发包

    • 支持镜像源切换(如淘宝镜像)

    • 提供包的搜索和版本管理

  2. CLI - command-line interface,就是命令行操作界面,就像是 GUI

  3. 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 → 目标代码
  1. 解析(Parse) - 将源代码转换为抽象语法树(AST)
  2. 转换(Transform) - 通过插件修改 AST
  3. 生成(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 这种厂商前缀,以及对代码压缩,代码剪枝

像是这几个问题也会有对应的工具帮你处理,比如下面这些

  1. 厂商前缀 autoprefix - 自动添加浏览器兼容前缀
  2. 代码优化 cssnano - 压缩、合并、去重
  3. 代码剪枝 purgecss - 移除未使用的样式
  4. 类名处理 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)
  • 优化后的资源(压缩、合并、缓存)
  • 移除开发依赖和工具代码
  • 跨浏览器兼容性

构建工具就是用来做工程的转换,想要转换需要弄清下面三个问题

  1. 哪种工程更适合开发和维护
  2. 哪种工程更适合运行时
  3. 如何转换(打包)

由于这三个条件没有标准,因此构建工具有很多,都会有不同的差异

webpack, rollup, esbuild, trubopack, Rspack, snowpack, grunt, gulp

工具 特点 适用场景 性能
Webpack 功能全面,生态丰富 复杂应用,多页面 中等
Rollup Tree Shaking 优秀 库开发,ES Modules
esbuild 极快的构建速度 开发环境,简单项目 极快
Vite 开发体验优秀 现代前端项目
Parcel 零配置 快速原型,小项目
Turbopack Rust 实现,性能优异 大型项目(实验性) 极快

这些构建工具只需要学习一个 webpack 就行

其实 webpack , rollup, esbuild 全会是最好的

webpack 认为上面三个条件的问题是

  1. 一切皆为模块
  2. 传统工程更适合运行
  3. 以一个入口文件出发,深度遍历,寻找依赖关系,最后转换成 js,html,css
md 复制代码
入口文件 → 依赖分析 → 模块解析 → 代码转换 → 资源优化 → 输出文件
  1. 入口分析 - 从 entry 配置的文件开始
  2. 依赖图构建 - 递归分析所有依赖关系
  3. 模块转换 - 通过 loader 处理不同类型文件
  4. 代码优化 - 通过 plugin 进行各种优化
  5. 资源输出 - 生成最终的打包文件

入口

入口文件我们需要在 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')
  })
]

脚手架

构建工具有了,其实还剩下一些问题,比如项目目录,依赖安装,插件配置

因此需要脚手架帮我们搭建工程

  1. 提供界面和交互(大部分时cli命令行形式)
  2. 工程模板

比如 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 原生开发缺陷,需要一系列工具或者开发友好的语法进行弥补,但是如何管理这些工具以及模块,都不是简单的事情

相关推荐
若梦plus21 分钟前
Nuxt.js基础与进阶
前端·vue.js
樱花开了几轉27 分钟前
React中为甚么强调props的不可变性
前端·javascript·react.js
风清云淡_A27 分钟前
【REACT18.x】CRA+TS+ANTD5.X实现useImperativeHandle让父组件修改子组件的数据
前端·react.js
小飞大王66628 分钟前
React与Rudex的合奏
前端·react.js·前端框架
若梦plus1 小时前
React之react-dom中的dom-server与dom-client
前端·react.js
若梦plus1 小时前
react-router-dom中的几种路由详解
前端·react.js
若梦plus1 小时前
Vue服务端渲染
前端·vue.js
Mr...Gan1 小时前
VUE3(四)、组件通信
前端·javascript·vue.js
OEC小胖胖1 小时前
渲染篇(二):解密Diff算法:如何用“最少的操作”更新UI
前端·算法·ui·状态模式·web
万少1 小时前
AI编程神器!Trae+Claude4.0 简单配置 让HarmonyOS开发效率飙升 - 坚果派
前端·aigc·harmonyos