一、什么是前端工程化:
前端工程化是一套贯穿开发、测试、部署、维护全生命周期的思维模式和实践体系。它的目标是将项目里的零零散散的东西用标准化的“流水线”来保证效率、质量和可维护性。
二、开发阶段的工程化过程
(一) 我认为这是前端工程化在项目中必备的一个工程化设计:
通常情况下在项目里会写一堆代码文件,我们需要把这些代码文件,通过 “编译解析” --> “模块分包” --> "压缩优化" 这几个过程产出浏览器可以访问识别的产物文件,比如:html、css、js 等文件。
1. 编译解析:
这里用 webpack 举例,在解析编译需要进过这几个过程:
一、将源代码转换成抽象语法树(AST)
webpack 打包工具它需要先通过解析器(Parser) (例如 acorn)将每个模块的源代码解析成一种树状结构的数据结构,即 AST。AST 能够精确地描述代码的语法结构(比如哪些是变量声明,哪些是函数调用,哪些是 import 或 require 语句)。
类比:就像你收到一段英文文本,你需要先把它拆成单词、短语、句子(AST),才能理解它的意思。
不同的打包工具都会将其转化各种各样的语法树。
二、分析模块依赖关系(收集依赖)
有了 AST 之后,Webpack 会遍历这棵树,找出所有的 import、require、define 等语句。每找到一个依赖,它就会记录下:
- 依赖的模块路径(如
'./a.js'、'lodash') - 依赖的类型(ES Module、CommonJS、AMD 等)
这样,Webpack 就能从入口文件开始,一层一层地递归解析,最终构建出整个项目的依赖关系图(Dependency Graph) 。这张图是后续打包的基础。
三、执行 Loader 转换(非 JS 资源的编译)
这是工程化的重点。你写的代码很可能不是纯 JavaScript(比如 .vue、.tsx、.scss 文件)。Webpack 本身的解析器只认识 JS 和 JSON。因此,在解析之前或解析过程中,Webpack 会根据配置的 module.rules 调用相应的 Loader,把这些资源编译成标准的 JavaScript 模块。
例如:
sass-loader+css-loader:将.scss文件编译成 CSS,再转换成 JS 模块(导出 CSS 字符串或 style 对象)。babel-loader:将 ES6+ 或 JSX 代码编译成 ES5 标准的 JS。ts-loader:将 TypeScript 编译成 JavaScript。
这一步可以理解为“翻译”,把各种非 JS 资源“翻译”成 Webpack 能够解析的 JS 模块。
四、处理模块解析规则(Resolve)
在解析依赖路径时,Webpack 需要知道如何找到真正的文件。比如你写 import 'lodash',Webpack 会根据 resolve 配置去 node_modules 里找;如果你写 import '@/utils',它需要识别 @ 这个别名。编译解析阶段会应用这些路径解析规则,定位到真实的模块文件。
五、生成模块对象(Module)
对于每个经过解析和转换的文件,Webpack 最终会生成一个模块对象,里面包含:
- 模块 ID(通常是路径或数字)
- 模块的最终代码(经过 Loader 处理后的 JS 字符串)
- 该模块的依赖列表
- 其他元信息(如是否被
sideEffects标记等)
这些模块对象将被传递给打包(Seal) 阶段,用于生成最终的 bundle。
2. 模块分包:
模块分包(通常指代码分割,Code Splitting)是现代前端工程化中的一项关键优化手段。它的核心目的是把一个庞大的 JavaScript 文件拆分成多个更小、更合理的“块”(chunk),从而提升页面性能和用户体验。
你可能见过这样的场景:一个单页应用(SPA)的 main.js 有 5MB 大小。用户第一次访问时,浏览器需要下载、解析并执行这 5MB 代码,期间页面白屏时间很长。而通过模块分包,可以将其拆分为:
vendor.js:第三方库(React、Vue、lodash 等)home.js:首页业务代码about.js:关于页面的代码(仅当用户访问关于页时按需加载)common.js:多个页面共用的组件
这样做带来了以下几个实实在在的好处:
- 减少首屏幕加载的时间
- 利用浏览器缓存,减少重复下载
- 按需加载,减少不必要的资源浪费
- 降低内存占用
至于具体按什么划分去模块分包,需要根据不同的项目场景考虑:
- 分包不是分的越多越好
因为太多细小的 chunk 会导致额外的网络请求开销(尤其是 HTTP/1.1 下)。需要权衡:首屏关键请求数不宜超过 10~15 个(HTTP/2 可适当增多)。
- 分包会增加复杂度
但现代打包工具(Webpack、Vite)已经将智能分包策略内置或提供简单配置,大部分场景下只需配置 splitChunks: { chunks: 'all' } 就能获得很好的默认优化。
3. 压缩优化:
一、减少文件体积,加快网络传输
这是最直接的作用。通过压缩,JavaScript、CSS、HTML 和图片等资源的体积可以显著减小(通常能减少 30%~70% 甚至更多)。
二、降低带宽和服务器成本
对于网站运营者,每次用户请求都会消耗服务器流量。压缩后的文件体积更小,意味着:
- 同样的流量配额可以服务更多用户。
- CDN 传输费用更低。
- 用户流量消耗少(尤其对手机流量用户更友好)。
三、3. 提升页面关键性能指标
- 减少白屏时间(FCP / LCP) :较小的主资源(如入口 JS、CSS)能被更快下载和解析,浏览器更早渲染出首屏内容。
- 加快可交互时间(TTI) :JS 文件越小,下载、解析、编译、执行的总体开销越小,页面能响应用户操作的时间点提前。
(二) webpack 详细配置
一、文件目录:
二、webpack.base.js 配置:
const path = require("path");
const glob = require("glob");
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
/**
* @file webpack.base.js
* @author afeifly
* @description webpack基础配置
* @date 2024-06-17
*/
// 获取 app 目录下的 pages 下的所有入口文件
const pageEntrys = {};
const htmlWebpackPluginsList = [];
// 动态获取入口文件和构造最终渲染的页面模版
glob.sync(path.resolve(process.cwd(), './app/pages/**/entry.*.js')).forEach(file => {
const entryName = path.basename(file, '.js');
// 构造 entry
pageEntrys[entryName] = file;
// 构造最终渲染的页面文件
htmlWebpackPluginsList.push({
// 产物(最终的模版)输出路径
filename: path.resolve(process.cwd(), './app/public/dist', `${entryName}.tpl`),
// 指定要使用的模版文件
template: path.resolve(process.cwd(), './app/view/entry.tpl'),
// 要注入的代码块
chunks: [entryName],
});
});
module.exports = {
// 入口配置
entry: pageEntrys,
// 模块解析配置(决定了要加载解析哪些模块以及用什么样的方式解析)
module: {
rules: [{
test: /.vue$/,
use: {
loader: 'vue-loader',
}
}, {
test: /.js$/,
// 只对业务代码进行 babel ,加快 webpack 构建速度
include: [ path.resolve(process.cwd(), './app/pages') ],
use: {
loader: 'babel-loader',
}
}, {
test: /.(png|jpe?g|gif)(?.+)?$/,
use: [{
loader: 'url-loader',
options: {
limit: 300,
esModule: false,
}
}]
}, {
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
}, {
test: /.less$/,
use: [
'style-loader',
'css-loader',
'less-loader'
]
}, {
test: /.(eot|svg|ttf|woff|woff2)(?\S*)?$/,
use: [{
loader: 'file-loader',
}]
}]
},
// 产物输出路径 因为开发和生产环境输出不一致,所以在各自环境中自行配置
output: {},
// 配置模块解析的具体行为(定义 webpack 如何寻找模块所对应的文件)
resolve: {
extensions: ['.js', '.vue', '.less', '.css'],
alias: {
$pages: path.resolve(process.cwd(), './app/pages'),
$common: path.resolve(process.cwd(), './app/pages/common'),
$widgets: path.resolve(process.cwd(), './app/pages/widgets'),
$store: path.resolve(process.cwd(), './app/pages/store'),
}
},
// 配置 webpack 插件
plugins: [
// 处理 .vue 文件 它的职能是将 .vue 文件转换成 JavaScript 模块
new VueLoaderPlugin(),
// 把第三方库暴露到全局,供业务代码使用
new webpack.ProvidePlugin({
Vue: 'vue',
axios: 'axios',
_: 'lodash'
}),
// 定义全局变量
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true', // 支持 Vue 支持 Options API
__VUE_PROD_DEVTOOLS__: 'false', // 生产环境禁用 devtools 调试工具
__VUE_PROD_HYDRATION_MISMATCH_DETAILS_: 'false', // 生产环境禁止使用 “水合” 信息
}),
// 构造最终渲染的页面模版
...htmlWebpackPluginsList.map(options => new HtmlWebpackPlugin(options)),
]
};
其中 “entry” 入口配置项,这里是引入了多页面入口,比如:项目中存在多个入口,需要将其流转成打包的页面产物,这里就会用对象的形式传入配置:
// 构造 entry
pageEntrys[entryName] = file;
// 执行结果:
const pageEntrys = {
home: './src/pages/home.js',
about: './src/pages/about.js'
};
得到多个页面之后,传入配置就实现了多入口配置:
渲染页面模版时也需要配置多个:
module.exports = {
plugins: {
...
new HtmlWebpackPlugin({
filename: './app/public/dist/home.tpl',
template: './app/view/entry.tpl',
// 要注入的代码块
chunks: ['home'],
}),
...
new HtmlWebpackPlugin({
filename: './app/public/dist/about.tpl',
template: './app/view/entry.tpl',
// 要注入的代码块
chunks: ['about'],
})
...
};
};
三、optimization 分包配置:
module.exports = {
// 配置打包输出优化(代码分割、模块合并、缓存,tree-shaking、压缩等)
optimization: {
/**
* 分割代码块
* 把 js 文件打包成3种类型:
* 1. vendor:第三方 lib 库,基本不会改动,除非依赖版本升级
* 2. comon:业务组件代码的公共部分抽取出来,改动比较少
* 3. entry.{page}:不用页面 entry 里的业务组件代码的差异部分,会进场改动
* 目的:把改动和引用频率不一样的 js 区分出来,以达到更好利用浏览器缓存的效果
*/
splitChunks: {
chunks: 'all', // 对同步和异步模块进行分割
maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
maxInitialRequests: 10, // 入口点的最大并行请求数
// 代码块分割的最小尺寸,单位为字节
cacheGroups: {
// 第三方依赖库
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendor', // 模块名称
priority: 20, // 优先级,数值越大优先级越高
enforce: true, // 强制执行
reuseExistingChunk: true, // 复用已有的公共 chunk
},
common: {
name: 'common',
minChunks: 2, // 被两处引用即被归为公共模块
minSize: 1, // 最小分割文件大小为 1 字节
priority: 10, // 优先级,数值越大优先级越高
reuseExistingChunk: true, // 复用已有的公共 chunk
}
}
},
// 将 webpack 运行时生成的代码打包到 runtime.js
runtimeChunk: true,
}
}
需要根据项目需求,进行配置分包,chunks 不一定非要配置 “all” 所有模块,也可以是 “async”、“initial”。
它们的主要区别在于,对三种模块加载/定义方式进行优化的范围不同:
- 动态导入:运行时按需加载,比如使用
import()语法。 - 同步导入:编译时确定依赖,在文件开头使用
import ... from ...或require()。 - 入口起点:在
webpack.config.js的entry配置中定义的模块。
| 配置值 | 作用的加载方式 | 行为描述 | 典型场景 |
|---|---|---|---|
'async'(默认值) | 动态导入 | 只对动态导入的模块进行打包优化。 | 按需加载的路由页面,不增加首屏加载压力。 |
'initial' | 同步导入 + 入口起点 | 对同步导入的模块和入口起点进行优化,可抽取公共代码。 | 多入口应用,消除重复代码。 |
'all'(推荐) | 所有模块 | 最强大的模式,会优化所有类型的模块。能抽取动态和同步模块之间的共享代码。 | 大部分现代应用,最大化代码复用效果。 |
分包策略也可以按照业务划分:比如 公共组件库、第三方 node_modules 库....之类的进行分包划分。
四、webpack.dev.js 配置:
webpack.dev.js 开发环境的配置,是基于 webpack.base.js 基类配置扩展出来的配置,利用 webpack-merge 的 smart 方法扩展 dev 环境的需求配置。
const { smart } = require('webpack-merge');
const webpack = require('webpack');
const path = require('path');
// 基类配置
const baseConfig = require('./webpack.base');
// devServer 的配置
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 9002,
HMR_PATH: '__webpack_hmr',
TIMEOUT: 2000
};
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;
// 开发阶段的 entry 配置需要加入 hmr
Object.keys(baseConfig.entry).forEach(v => {
// 第三方包 不用热更新, 不作为 hmr 入口
if (v === 'vendor') {
baseConfig.entry[v] = [
// 主入口文件
baseConfig.entry[v],
// hmr 更新入口
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`,
]
}
})
/**
* @file webpack.dev.js
* @author afeifly
* @description webpack 开发环境配置
* @date 2024-06-17
*/
const webpackDevConfig = smart(baseConfig, {
// 指定开发环境
mode: 'development',
devtool: 'eval-cheap-module-source-map', // 呈现代码的映射关系,方便调试代码
// 开发环境的 output 配置
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: path.resolve(process.cwd(), './app/public/dist/dev/'), // 输出文件存储的位置
publicPath: `http://${HOST}:${PORT}/public/dist/dev/`, // 外部资源引用路径
globalObject: 'this',
},
// 开发阶段插件
plugins: [
// 用于热模块替换(HMR)的插件
// 极大的提升开发阶段的构建性能和开发体验
new webpack.HotModuleReplacementPlugin({
multiStep: true, // 多步骤构建,提升性能
}),
]
});
module.exports = {
// webpack 的配置
webpackDevConfig,
// devServer的配置暴露给 dev.js 使用
DEV_SERVER_CONFIG
}
开发环境会用到 hmr 热更新,因为在开发过程中,需要实时编译实时生效。这样在就能避免开发是反复打包然后运行的问题。
如何做到热更新?
- 监控文件改动:
通过配置某个插件,然后监控文件的改动,将这个改动的文件,注入到内存里。
webpack.dev.js 开发环境配置
const webpack = require('webpack');
// devServer 的配置
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 9002,
HMR_PATH: '__webpack_hmr',
TIMEOUT: 2000
};
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;
// 开发阶段的 entry 配置需要加入 hmr
Object.keys(baseConfig.entry).forEach(v => {
// 第三方包 不用热更新, 不作为 hmr 入口
if (v === 'vendor') {
baseConfig.entry[v] = [
// 主入口文件
baseConfig.entry[v],
// hmr 更新入口
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`,
]
}
})
const webpackDevConfig = smart(baseConfig, {
// 开发阶段插件
plugins: [
// 用于热模块替换(HMR)的插件
// 极大的提升开发阶段的构建性能和开发体验
new webpack.HotModuleReplacementPlugin({
multiStep: true, // 多步骤构建,提升性能
}),
]
});
dev.js 文件配置,利用 webpack-dev-middleware 中间件,实现将代码文件打包加载到内存中,然后配合 publicPath 的访问地址,访问加载到内存文件。
// 本地开发启动 decServer
const express = require('express');
const path = require('path');
const webpack = require('webpack');
const devMiddleware = require('webpack-dev-middleware');
// 从 webpack.dev.js 中获取 webpack 配置和 devServer 配置
const { webpackDevConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev');
const app = express();
const compiler = webpack(webpackDevConfig);
// 引入静态目录
app.use(express.static(path.join(__dirname, '../public/dist')));
// 引入 devMiddleware 中间件,监控文件改动
app.use(devMiddleware(compiler, {
// 落地文件
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
// 资源路径
publicPath: webpackDevConfig.output.publicPath,
// headers 配置
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
},
stats: {
colors: true
}
}));
// 启动 devServer 服务
const port = DEV_SERVER_CONFIG.PORT || 9002;
app.listen(port, () => {
console.log(`开发服务器已启动,访问地址:http://${DEV_SERVER_CONFIG.HOST}:${port}`);
});
- 通知浏览器更新:
通知产物文件更新配置在内存里内容,被改动的区域更新,然后再自动刷新浏览器展示最新的修改内容。webpack-hot-middleware 中间件 实现热模块替换
// devServer 的配置
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 9002,
HMR_PATH: '__webpack_hmr',
TIMEOUT: 2000
};
// webpack-hot-middleware 中间件
const hotMiddleware = require('webpack-hot-middleware');
// 引入 hotMiddleware 中间件,支持热更新(HMR)
app.use(hotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
log: (err) => {}
}));