前言
随着前端项目的规模和复杂度不断提升,如何保证开发效率与质量,已经成为行业的核心课题。对于每一位从事前端工作的人来说,掌握并践行前端工程化并不是锦上添花,而是必不可少的能力。它不仅关系到团队能否高效协作、项目能否顺利交付,更直接影响着个人在前端职业道路上的成长与竞争力。
何为前端工程化
前端工程化是指将软件工程的原理和方法应用到前端开发中,以提高开发效率、代码质量和可维护性。随着 Web 应用的复杂度不断增加,传统的前端开发方式已经难以满足需求,因此引入了工程化的概念来更好地管理和优化前端开发流程。
前端工程化的好处
- 提升开发效率
- 自动化构建、自动化部署、代码热更新,让开发者更专注于业务逻辑,而不是重复劳动。
- 模块化、组件化提高了代码复用率,减少了“从零开始写”的情况。
- 保障代码质量
- 通过 ESLint、Prettier、单元测试、类型检查等工具,避免低级错误,保证代码的一致性和稳定性。
- 降低协作成本
- 统一的项目结构、代码规范和分支管理,让团队成员可以无缝协作,快速接手项目。
- 提高可维护性和可扩展性
- 代码结构清晰、模块边界明确,后期维护和功能扩展更容易。
- 长期项目也能保持可持续演进,而不会因为“技术债”拖垮。
- 加快交付与迭代
- CI/CD 流程缩短了从开发到上线的周期,让产品能更快触达用户。
- 出现问题时也能快速回滚,降低风险。
刚好在最近把Elpis项目的前端工程化看完了,所以准备写一篇学习总结来加深对前端工程化的理解。前端工程化的构建工具有很多,比如webpack、vite、rolldown、rollup等,但是他们本质以及目标其实是大同小异的。Elpis使用的是webpack进行,所以接下来,我会分享webpack在开发、生产环境下具体是怎么实现构建的。
webpack的基础配置
/**
* 基础配置
*/
const { resolve, basename } = require('path');
const glob = require('glob');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader');
const webpack = require('webpack');
// 动态构造 pageEntries 和 htmlWebpackPluginList
// 利用约定大于配置的原则,在pages下面以entry.*.js文件命名的文件就是一个项目的入口。基于这个,我们构造了打包的多入口对象pageEntries,以及打包利用到的HtmlWebpackPlugin数组,配置了他的输出路径,基于哪个模版,以及他对应使用了哪个chunks
const pageEntries = {};
const htmlWebpackPluginList = [];
// 获取app/pages下所有的入口文件
const entryFiles = glob.sync(resolve(process.cwd(), './app/pages/**/entry.*.js'));
entryFiles.forEach((file) => {
const entryName = basename(file, '.js');
// 构建entry
pageEntries[entryName] = file;
// 构建htmlWebpackPluginList
htmlWebpackPluginList.push(
new HtmlWebpackPlugin({
// 最终模版输出目录
filename: resolve(process.cwd(), './app/public/dist/', `${entryName}.tpl`),
// 指定模板文件
template: resolve(process.cwd(), './app/view/entry.tpl'),
// 要注入的代码块
chunks: [entryName],
})
);
});
module.exports = {
// 入口文件
entry: pageEntries,
// 模块解析配置(决定了要加载解释哪些模块,以及用什么方式解释)
module: {
rules: [
{
test: /\.vue$/,
use: {
loader: 'vue-loader',
},
},
{
test: /\.js$/,
// 只对业务代码运行 babel 转换,加快webpack打包速度
include: [resolve(process.cwd(), './app/pages')],
use: {
loader: 'babel-loader',
},
},
{
test: /\.(png|jpe?g|gif)(\?.+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
esModule: false,
},
},
},
{
test: /\.(css)$/,
use: ['vue-style-loader', 'css-loader'],
},
{
test: /\.(less)$/,
use: ['vue-style-loader', 'css-loader', 'less-loader'],
},
{
test: /\.(woff|woff2|eot|ttf|otf|svg)(\?.+)?$/,
use: 'file-loader',
},
],
},
// 产物输出路径, 因为生产和开发环境输出不一致,所以在各自的环境中自行配置
output: {},
// 配置模块解析的具体行为(定义 webpack在打包,如何找到并解析具体模块的路径)
resolve: {
extensions: ['.js', '.vue', '.less', '.css'],
alias: {
$pages: resolve(process.cwd(), './app/pages'),
$common: resolve(process.cwd(), './app/pages/common'),
$widgets: resolve(process.cwd(), './app/pages/widgets'),
$store: resolve(process.cwd(), './app/pages/store'),
},
},
// 插件
plugins: [
// 处理.vue文件,这个插件是必须的
// 它的职能是将你定义过的其他规则复制并应用到.vue文件中
// 例如,如果有一条匹配规则,/\.js$/,那么它会应用到.vue文件中的script代码中代码上
// 如果是css|less|scss,也同样会应用到.vue文件中的所有<style>代码上
new VueLoaderPlugin(),
// 把第三方库暴露到window context下
new webpack.ProvidePlugin({
Vue: 'vue',
axios: 'axios',
_: 'lodash',
}),
// 定义全局常量
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true', // 支持vue解析option Api
__VUE_PROD_DEVTOOLS__: 'false', // 关闭vue生产环境下的devtools
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', // 禁止生产环境显示“水合”信息
}),
// 构建htmlWebpackPluginList
...htmlWebpackPluginList,
],
// 开发服务器配置
devServer: {},
// 配置打包输出代码优化(代码分割,压缩,缓存、tree-shaking等优化策略)
optimization: {
/**
* 把js文件打包成三种类型
* 1、vender第三方lib库,基本不会改动,除非依赖版本升级
* 2、common:业务组件代码的公共部分抽离出来、改动较少
* 3、entry.{page}:不用页面entry里的业务组件代码的差异部分,会经常改动
* 目的:把改动和引用频率不一样的js区分开,以便达到浏览器缓存的效果
*/
splitChunks: {
chunks: 'all', // 对同步和异步模块都进行分割
maxAsyncRequests: 10, // 异步加载的文件的最大并发数量
maxInitialRequests: 10, // 入口文件加载的文件的最大并发数量
cacheGroups: {
vender: {
// 第三方库依赖库
test: /[\\/]node_modules[\\/]/,
name: 'vender',
priority: 20, // 优先级,数字越大,优先级越高
enforce: true, // 强制执行
reuseExistingChunk: true, // 复用已有的chunk
},
common: {
// 业务组件代码的公共部分抽离出来、改动较少
test: /[\\/]app[\\/]pages[\\/]/,
name: 'common',
minChunks: 2, // 最小引用次数
minSize: 1, // 最小体积
priority: 10, // 优先级,数字越大,优先级越高
reuseExistingChunk: true, // 复用已有的chunk
},
},
},
},
};
以上是webpack的基础配置,主要需要关注optimization属性的配置,这里主要是做一些打包上的优化。比如代码压缩、分包、缓存等。上面我的分包策略是第三方包都打在一起,公共部分在抽离出来,理由是:
- 第三方包都打在vender文件下的原因是,第三方包一般不会变动,而我们是以contentHash作为文件的名称的,这样文件名称也不会变,我们一般会对资源文件进行强缓存,这样在浏览器访问系统的时候,就可以很好的利用缓存提高用户体验。
- 业务组件代码的公共部分抽离出来也有一部分是上面的原因,还有一部分是把代码独立出来,防止重复打包
dev环境的配置
const { resolve } = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
// webpack基础配置
const baseConfig = require('./webpack.base');
// devServer配置
const DEV_SERVER_CONFIG = {
PORT: 9002,
HOST: '127.0.0.1',
HMR_PATH: '__webpack_hmr', // 官方规定
TIMEOUT: 20000,
};
// 开发阶段的entry配置需要加入hmr
Object.keys(baseConfig.entry).forEach((v) => {
// 第三方包不作为hmr入口
if (v !== 'vender') {
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;
baseConfig.entry[v] = [
// 主入口文件
baseConfig.entry[v],
// hmr更新入口,官方指定的hmr路径
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`,
];
}
});
// 开发环境webpack配置
const webpackDevConfig = merge.smart(baseConfig, {
// 指定开发环境
mode: 'development',
// 开发环境sourceMap配置, 开启后,浏览器会自动生成sourceMap文件,方便调试
devtool: 'eval-cheap-module-source-map',
// 开发环境output配置
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: resolve(process.cwd(), './app/public/dist/dev/'),
publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev`, // 外部资源公共路径
globalObject: 'this', // 全局对象
},
// 开发环境plugins配置
plugins: [
// 用于实现热模块替换(Hot Module Replacement)
// 模块热替换允许在应用程序运行时替换
// 在开发过程中,当修改代码时,只重新编译修改的模块,而不是重新加载整个页面
new webpack.HotModuleReplacementPlugin({
multiStep: false, // 启用多步骤热更新
}),
],
});
// 合并基础配置
module.exports = {
DEV_SERVER_CONFIG,
webpackDevConfig,
};
dev脚本代码为:
// 本地开发环境打包 devServer
const { join } = require('path');
const consoler = require('consoler');
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
// 从webpack.dev.js中获取webpack配置
const { webpackDevConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev');
const app = express();
const compiler = webpack(webpackDevConfig);
// 指定静态文件目录
app.use(express.static(join(__dirname, '../public/dist/')));
// 引用devMiddleware中间件(监听文件变化,重新编译)
app.use(
webpackDevMiddleware(compiler, {
// 落地文件
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
// 资源路径
publicPath: webpackDevConfig.output.publicPath,
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(
webpackHotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
})
);
// app.use(webpackDevMiddleware(compiler, {}));
consoler.info('请等待webpack初次构建完成提示。。。');
const port = DEV_SERVER_CONFIG.PORT;
app.listen(port, () => {
console.warn(`开发环境启动成功,请访问 http://localhost:${port}`);
});
其实开发环境相比于生产环境主要的特点是dev需要做热更新。热更新具体指的是什么呢?
含义:
在开发环境,应用运行过程中,不刷新整个页面,而是仅替换掉发生变化的模块,让应用立即生效,同时尽可能保留原有运行状态。这其实也是为了提高开发的效率和体验感。
具体实现:
前端有很多的构建工具,它们在热更新的实现方式上,有略微的差异
webpack的热更新
webpack:
- 对于webpack来说,他会去监听业务文件的代码的变化,只要监听到代码的变化,会生成新的依赖图以及chunk
- 通知客户端,也就是利用WebSocket向浏览器发送一条信息,里面包含一些模块信息和hash
- 浏览器端的 HMR runtime 会通过 jsonp(或 fetch)加载新的模块代码。
- 浏览器触发更新
vite: - 对于vite的来说,在开发环境他是利用原生ESM模块的,所以他不需要向webpack那样重新依赖解析,生成 新chunk,他的做法是直接通过WebSocket通知浏览器哪个路径的代码变了,直接用import直接回去新的模块,之后浏览器触发更新
生产环境配置
const os = require('os');
const { resolve } = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const HappyPack = require('happypack');
const HtmlWebpackInjectAttributesPlugin = require('html-webpack-inject-attributes-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
const merge = require('webpack-merge');
// webpack基础配置
const baseConfig = require('./webpack.base');
const happypckCommonConfig = {
debug: false,
threadPool: HappyPack.ThreadPool({
size: os.cpus().length,
}),
};
// 合并基础配置
module.exports = merge.smart(baseConfig, {
// 指定生产环境
mode: 'production',
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'happypack/loader?id=css'],
},
{
test: /\.js$/,
// 只对业务代码运行 babel 转换,加快webpack打包速度
include: [resolve(process.cwd(), './app/pages')],
use: ['happypack/loader?id=js'],
},
],
},
// webpack不会有大量的hit信息
performance: {
hints: false,
},
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js', // 输出文件名
path: resolve(process.cwd(), './app/public/dist/prod'), // 输出路径
publicPath: '/dist/prod', // 公共路径
crossOriginLoading: 'anonymous', // 跨域加载
},
plugins: [
// 每次build前,先清空public/dist目录
new CleanWebpackPlugin(['public/dist'], {
root: resolve(process.cwd(), './app/'),
exclude: [],
verbose: false,
dry: false,
}),
// 提取css的公共部分,有效利用缓存,(非公共部分使用inline-style-loader)
new MiniCssExtractPlugin({
filename: 'css/[name].css', // 入口 CSS 文件路径
chunkFilename: 'css/[name]_[chunkhash:8].bundle.css',
}),
// 优化并压缩css资源
new CssMinimizerPlugin(),
// 多线程打包 JS,加快打包速度
new HappyPack({
...happypckCommonConfig,
id: 'js',
loaders: [
`babel-loader?${JSON.stringify({
presets: ['@babel/preset-env'],
plugins: ['@babel/plugin-transform-runtime'],
})}`,
],
}),
// 多线程打包 CSS,加快打包速度
new HappyPack({
...happypckCommonConfig,
id: 'css',
loaders: [
{
path: 'css-loader',
options: {
importLoaders: 1,
},
},
],
}),
// 浏览器在请求资源时,不发送用户的身份验证
new HtmlWebpackInjectAttributesPlugin(),
],
optimization: {
// 使用TerserPlugin的并发和缓存,提升压缩阶段的性能
minimize: true,
minimizer: [
new TerserWebpackPlugin({
parallel: true, // 利用多核 CPU 提升构建速度
cache: true, // 启用缓存来加速构建过程
terserOptions: {
compress: {
pure_funcs: ['console.log', 'console.warn'],
},
},
}),
],
},
});
总结
在前端工程化中,首先需要明确项目在不同运行环境下的产物需求,并针对性地进行配置,以提升整体效率。
在 开发环境 中,项目并不直接面向上线运行,因此性能并不是首要考虑因素,更重要的是如何提升开发体验,让开发者能够更高效地编写和调试代码。例如,热更新(HMR) 可以在不刷新页面的情况下实时应用代码改动,从而保留页面状态;此外,跨域问题的处理 也是开发阶段需要关注的重点。
而在 生产环境 中,项目需要真正上线运行,因此关注点则转向如何 减小文件体积、提升加载速度以及增强代码的可复用性,以保证应用的性能和用户体验。