工程化在前端开发中的重要性不言而喻,在如今前端应用复杂度指数级增长的大背景下,其是必然产物。原先的传统开发模式已经满足不了单页面应用(SPA)、组件化架构、跨端开发等场景的需要
前端工程化的意义
- 工业化生产体系的构建
- 规范化工程管理
- 全流程质量管控
那么本文书接上回,将使用webpack作为elpis的项目构建工具(注意:要学会工程化底层原理而非学会使用工程化工具本身)
核心概念理解
入口与出口(Entry & Output)
-
入口
入口配置顾名思义,将指导 webpack 应该从哪里开始解析文件,这是webpack构建依赖图谱的起点。
module.exports = { entry: './path/to/my/entry/file.js', }; -
出口
告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。可以在这里配置输出文件的名称格式
output: { filename: '[name].[contenthash:8].js', // 哈希缓存策略 path: path.resolve(__dirname, 'dist'), publicPath: '/assets/', // CDN路径配置 chunkFilename: '[name].chunk.js' // 异步chunk命名 }
模块处理(Module Rules)
典型Loader链配置:
rules: [
{
test: /\\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
},
{
test: /\\.scss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: { modules: true } // CSS模块化
},
'sass-loader'
]
}
]
在打包时,除了js文件之外,还会碰到其他格式的文件和资源,此时就需要对应的loader来处理这些文件,使其变为webpack可以理解的格式。以上述例子来说,其牵扯到:
- Loader执行顺序:从右到左、从下到上处理(sass → css → style)
- AST转换:Babel通过@babel/core生成AST(抽象语法树)进行操作
- CSS模块化:通过唯一哈希类名实现样式隔离
插件配置(plugins)
常用插件示例:
plugins: [
//处理vue文件,将定义的规则应用到.vue文件
new VueLoaderPlugin(),
//把第三方库暴露到window.context上,使得全局可用
new webpack.ProvidePlugin({
Vue: 'vue',
axios:'axios',
_: 'lodash',
}),
new HtmlWebpackPlugin({
filename: path.resolve(process.cwd(),'./app/public/dist/', `${entryName}.tpl`),//最终模板的输出路径
template: './public/index.html',//使用指定的模板文件
minify: { collapseWhitespace: true } // HTML压缩
chunks:'entry.page1' //要注入的代码块(需要对应入口文件)
}),
new CleanWebpackPlugin(), // 构建前清理目录
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css' // CSS文件分离
}),
//定义全局常量
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) // 环境变量注入
__VUE_OPTIONS_API__: 'true',//启用vue3.0的options api
__VUE_PROD_DEVTOOLS__: 'false',//禁用vue3.0的devtools
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',//禁用生产环境显示水合信息
})
]
插件机制原理:
- Tapable事件流:Webpack基于发布订阅模式的核心库
- Hook拦截机制:通过
compiler.hooks.emit.tap()等钩子介入构建过程 - 生命周期阶段:compile → make → seal → emit 等关键阶段
分包
在工程化实践中,分包优化是提升应用性能的核心手段之一。
分包的黄金分割法则
-
体积平衡:单个chunk建议控制在100-300KB(HTTP/2下可适当放宽)
-
变更频率分层:
- 高频变更:业务代码
- 低频变更:公共代码或者组件(common)
- 永不变更:Runtime/Webpack注入代码;第三方库等(vendor)
以下是有关变更频率、公共模块提取、运行分离的优化配置
optimization: {
splitChunks: {
chunks: "all", //对同步和异步模块都进行分割
maxAsyncRequests: 10, //最多同时加载的异步请求数
maxInitialRequests: 10,//入口点的最大并行请求数
//针对不同类型的模块进行规配置,如果打包内容同时符合多个规则,则按照优先级进行打包
cacheGroups: {
vendor:{
test: /[\\\\/]node_modules[\\\\/]/, //打包第三方库
name: 'vendor', //模块名称
priority: 20, //优先级,越大越优先打包,
enforce: true, //强制打包成单独的chunk
reuseExistingChunk: true, //是否复用已经打包的模块
},
common:{
name: 'common', //模块名称
minChunks: 2,//被两次或以上次数引用的模块,才打包成公共模块
minSize: 1,//最小分割文件大小,单位为byte
priority: 10, //优先级,越大越优先打包,
reuseExistingChunk: true, //是否复用已经打包的模块
},
}
},
//将webpack运行时的代码打包到runtime.js
runtimeChunk: true
}
分包维度选择
-
按模块类型分割:
- node_modules
- src业务代码
- 公共工具库
-
按功能模块分割:
- 基础框架(React/Vue)
- UI组件库
- 路由模块
- 数据状态管理
-
按加载时序分割:
- 首屏核心包
- 异步加载包
- 预加载包
原理级优化
哈希策略优化
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js'
}
- contenthash:根据文件内容生成哈希,最大化缓存利用率
- 哈希长度:8位哈希在碰撞概率与体积间取得平衡
Tree Shaking深度优化
Webpack生产模式自动启用Terser压缩
optimization: {
usedExports: true,
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
pure_funcs: ['console.log'] // 移除指定函数
}
}
})
]
}
预编译优化
// DLL预编译配置
new webpack.DllPlugin({
context: __dirname,
path: path.join(__dirname, 'dll', '[name]-manifest.json'),
name: '[name]_[hash]'
})
// 配合DllReferencePlugin使用
new webpack.DllReferencePlugin({
manifest: require('./dll/vendor-manifest.json')
})
开发服务器(DevServer)
目标是实现在开发模式下的代码热更新
const DEV_SERVER_CONFIG = {
HOST:'127.0.0.1',
PORT:9002,
HMR_PATH:'__webpack_hmr',//官方规定的热更新路径
TIMEOUT:20000,
}
//开发阶段的entry需要加入HRM
Object.keys(baseConfig.entry).forEach( v => { //拿到基础配置中的所有入口文件并添加额外配置
//第三方包不作为hmr的入口文件
if( v !== 'vendor') {
baseConfig.entry[v] =[
//主入口文件
baseConfig.entry[v],
//hmr更新入口,官方指定的hmr路径
//目的是为了打包的文件中能注入HMR的代码
`webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`
]
}
})
const webpackConfig = merge.smart(baseConfig, { //这里使用merge方法合并基础配置,类似于接口中的继承
// 指定开发环境
mode: 'development',
//source-map,呈现代码映射关系,方便开发过程中调试代码
devtool: 'eval-cheap-module-source-map',
output:{
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: path.resolve(process.cwd(), './app/public/dist/dev/'), //输出文件的存储路径
publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, //外部资源的访问路径
globalObject:'this', //支持多环境打包
},
//开发阶段的插件
plugins: [
//热更新(HMR)插件,使得程序在运行时就可以更新最新代码
new webpack.HotModuleReplacementPlugin({
multiStep: false,
}),
],
})
开发环境打包的启动文件配置:
//本地开发启动 devServer
const express = require('express');
const path = require("path");
const consoler = require('consoler');
const webpack = require("webpack");
const devMiddleware = require("webpack-dev-middleware");
const hotMiddleware = require("webpack-hot-middleware");
//从webpack.dev.js中获取webpackConfig和devServer配置
const {
webpackConfig,
DEV_SERVER_CONFIG
} = require('./config/webpack.dev')
const app = express();
const compiler = webpack(webpackConfig);
//指定静态文件目录
app.use(express.static(path.join(__dirname, '../public/dist')))
//引入devMiddleware,监听文件改动
app.use(devMiddleware(compiler, {
//指定落地文件,即写入磁盘的文件,出此之外的文件都是放到内存中
writeToDisk:(filePath) => {
return filePath.endsWith('.tpl')
},
//资源路径
publicPath: webpackConfig.output.publicPath, //对应webpack.dev.js中的第37行
//header配置
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,
}
}));
//引入hotMiddleware,实现热更新
app.use(hotMiddleware(compiler,{
path:`/${DEV_SERVER_CONFIG.HMR_PATH}`,
log:() => {}
}));
//指定静态文件目录
app.use(express.static(path.resolve(__dirname, 'dist')));
//监听文件改动
consoler.info('等待webpack初次编译完成...')
const port = DEV_SERVER_CONFIG.PORT;
app.listen(port, () => {
consoler.info(`app listening on port ${port}`)
})
热更新流程:
用户开发完业务文件后,被devServe(选择express)的监控模块(webpack-dev-middleware)所监听到,通知解析引擎进行解析编译->模块分包->压缩优化。生成的模板文件会保存,而其他的js,css文件会放到devServe的内存中。同时当js,css文件发生变化时(不存在到存在也是变化)会通过devServe的通知模块(webpack-hot-middleware)会与HMR客户端建立联系,让浏览器请求这些资源并注入相应的js、css文件到模板文件中,随后刷新页面。从而实现热更新。
这个过程和vue中的数据响应式原理类似。监控模块相当于Observe 部件,使得内容改变都能被其所感知到;通知模块相当于Dep部件的派发更新功能dep.notify() ,而HMR客户端拉取更新代码的操作相当于watcher部件在接收到Dep的派发更新后重行运行其对应的render函数一样,最终实现代码的热更新。
需要掌握的基本底层原理
模块解析机制
- resolve.alias:路径别名转换
- resolve.extensions:自动扩展名匹配顺序
- resolve.modules:模块搜索目录优先级
代码生成策略
- Runtime代码:包含模块加载、缓存等基础逻辑
- SourceMap生成:通过devtool配置不同映射模式
- Tree Shaking:基于ES Module静态分析实现无用代码消除
性能优化原理
-
缓存策略:
cache: { type: 'filesystem' }持久化缓存babel-loader?cacheDirectory=true编译缓存
-
并行处理:
thread-loader多进程编译TerserWebpackPlugin.parallel并行压缩
常见问题解决矩阵
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 打包后文件过大 | 未做代码分割 | 配置 SplitChunks |
| 修改代码不生效 | 缓存未清除 | 禁用 cache/clean 输出目录 |
| 样式不生效 | loader 顺序错误 | 检查 loader 执行顺序 |
| 动态导入失效 | 未配置 babel 插件 | 添加 @babel/plugin-syntax-dynamic-import |
总结
前端工程化需要根据项目的需求进行多元化配置,在我看来这就像css的庞大数量的各种属性一样,要根据具体布局选取最佳的配置方案。以webpack为例,种类多样的loader和plugin也是如此。选择的越契合那么我们项目的开发效率就会越高。而要实现这样的效果,理解工程化背后的本质及底层原理是至关重要的,至于具体的实现工具则依项目实际情况而定。我认为在前端工程化配置方面没有固定的配置模板,不见得搜到的高赞的方案就一定适合你。好的方案永远是能做到效果最大化、平衡项目业务与开发情况的。希望大家都能在项目中综合选择最适合自己的打包方案~