对前端工程化的理解
本文章仅个人对前端工程化的理解,仅供参考。
一、工程化衍生的背景
早期的前端开发更多是页面级开发:写 HTML 组织结构,写 CSS 处理样式,写 JavaScript 完成交互。页面数量少、业务逻辑简单时,这种开发方式足够直接,成本也不高。
但随着前端承担的职责越来越多,前端已经不只是"切页面"和"写交互"。现代前端通常需要处理路由、状态管理、接口请求、权限控制、组件复用、构建打包、代码分割、性能优化、环境配置、质量检测、自动化部署等一系列问题。前端项目逐渐从页面集合演变成一个完整的软件系统。
当项目复杂度提升后,单纯依赖个人习惯和手工维护会出现很多问题:
- 代码组织混乱,不同开发者的目录结构、命名方式和实现风格不一致。
- 重复代码增多,相似页面、相似组件、相似请求逻辑被反复编写。
- 项目构建依赖人工操作,开发、测试、生产环境之间容易出现配置差异。
- 代码质量缺少统一约束,问题往往要到运行时或者上线后才暴露。
- 多人协作成本上升,改动影响范围不清晰,维护成本越来越高。
- 发布流程不可控,依赖手工打包、手工上传、手工验证,容易出现遗漏。
因此,前端工程化的出现,本质上是为了解决前端项目在规模化、复杂化、多人协作场景下的可维护性和可控性问题。它不是单纯引入某个构建工具,也不是只配置一套脚手架,而是把前端开发从"手工作坊式开发"推进到"标准化、自动化、可持续维护的软件工程开发"。
二、如何进行工程化
1. 整体项目目录结构约定
一个项目,需要约定好以下规则:
- 公共组件存放目录
- 公共工具函数存放目录
- 页面存放目录
- 单个页面内,主页面以及子组件,类型文件,页面公共方法文件,hook文件,api文件存放在哪里,怎么命名
- 路由全部采用懒加载配置
- ... 以上都需要形成统一约定,从整体的目录规划上做好可维护性,实现开发者快速熟悉上手项目以及查找代码
2. 代码质量以及提交规范约束
在多人协作中,如果没有统一规范,每个人都会按照自己的习惯写代码。短期看问题不大,长期看会导致代码风格混乱、维护成本上升。
可以通过 ESLint、Prettier、Stylelint、TypeScript、Husky、Commitlint、lint-staged 等方式,把代码规范和质量检查前置到开发阶段。用工具把规范固化下来。
3. 沉淀通用能力
- 把常见的业务沉淀成组件存放在全局
- 把常用的工具函数沉淀成工具函数存放在全局
- 统一封装好Http请求的工具方法
以上行为可以减少重复劳动性,这样后续开发同类需求时,不再从零开始写,而是基于已有能力组合和扩展。
4. 多环境配置管理
前端项目通常会区分本地、测试、预发、生产等环境。不同环境下接口地址、资源路径、埋点配置都可能不同。如果这些配置散落在代码中,极容易出现打错包、连错接口、发布错误环境的问题。
工程化会把环境变量和配置管理纳入统一体系,让不同环境使用不同配置,并且通过构建流程明确区分。例如:
text
development -> 本地开发配置
test -> 测试环境配置
production -> 生产环境配置
这样可以让环境差异变得明确,而不是隐藏在业务代码里。
5. 构建打包配置(核心)
构建打包思路: 通过任意一个工具,从指定入口开始分析依赖,利用特定工具编译特定代码文件,将输出的产物落地磁盘,最终得到一个可发布的HTML/CSS/JS等文件资源。本次使用webpack作为构建工具举例
5.1 基础配置
作用:根据约定大于配置,动态加载多页面打包入口,根据不同类型的文件配置不同的编译loader转译,将编译后的文件根据各种分包配置进行拆分,最终输出到指定目录。
js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const webpack = require("webpack");
const glob = require("glob");
const Components = require("unplugin-vue-components/webpack");
const { AntDesignVueResolver } = require("unplugin-vue-components/resolvers");
const filePath = path.resolve(process.cwd(), "./app/pages/**/entry.*.js");
const templatePath = path.resolve(process.cwd(), "./app/view/entry.tpl");
const pageEntries = {};
const htmlWebpackPluginList = [];
/**
* 定义页面输出的位置
* @param {string} entryName
* @returns
*/
const outputPage = (entryName) => {
return path.resolve(process.cwd(), "./app/public/dist", `${entryName}.tpl`);
};
// 在约定大于配置的前提下,从指定目录中获取多页面的入口js
const entryList = glob.sync(filePath);
// 从fileList中动态生成entry入口和页面文件的生成插件配置
entryList.forEach((file) => {
const entryName = path.basename(file, ".js");
pageEntries[entryName] = file;
// 对每一个入口都构造一个页面文件的生成,推进htmlWebpackPluginList数组中
const item = new HtmlWebpackPlugin({
template: templatePath,
filename: outputPage(entryName),
chunks: [entryName],
});
htmlWebpackPluginList.push(item);
});
module.exports = {
// 入口配置
entry: pageEntries,
// 配置模块解析的方式,决定了要用什么方式去解析模块
module: {
rules: [
// 对js文件配置解析
{
test: /\.js$/i,
include: [path.resolve(process.cwd(), "./app/pages")],
use: "babel-loader",
},
// 对css文件配置解析
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
// 对less文件解析
{
test: /\.less$/i,
use: ["style-loader", "css-loader", "less-loader"],
},
// 对vue文件进行解析
{
test: /\.vue$/i,
use: "vue-loader",
},
// 对图片解析
{
test: /\.(png|jpe?g|gif|webp)$/i,
type: "asset",
parser: {
dataUrlCondition: {
maxSize: 8 * 1024,
},
},
generator: {
filename: "static/images/[name].[contenthash:8][ext]",
},
},
// 对字体图标进行解析
{
test: /\.(woff|woff2|eot|ttf|otf|svg)$/i,
type: "asset/resource",
generator: {
filename: "static/fonts/[name].[contenthash:8][ext]",
},
},
],
},
// 输出配置
output: {},
// 配置模块解析的具体行为
resolve: {
// 当遇到没后缀的文件引入时,会自动添加后缀
extensions: [".js", ".vue", ".less", ".css"],
// 路径别名
alias: {
$page: path.resolve(process.cwd(), "./app/pages"),
$common: path.resolve(process.cwd(), "./app/pages/common"),
$service: path.resolve(process.cwd(), "./app/pages/service"),
$widgets: path.resolve(process.cwd(), "./app/pages/widgets"),
$store: path.resolve(process.cwd(), "./app/pages/store"),
},
},
// 优化配置
optimization: {
// 将webpack运行时代码抽离成单独的chunk
runtimeChunk: "single",
splitChunks: {
chunks: "all", // 同步或者异步模块
maxAsyncRequests: 10, // 最大异步加载并行请求数
maxInitialRequests: 10, // 入口点最大加载并行请求数
cacheGroups: {
// 缓存组,将第三方库单独打包
vendor: {
test: /[\\/]node_modules[\\/]/, // 匹配node_modules中的模块
name: "vendor", // chunk名称
priority: 20, // 优先级
},
// 缓存组,将超过2个地方引用的单独打包
common: {
name: "common",
priority: 10,
minChunks: 2, // 最小引用次数
minSize: 1, // 最小大小 为了试验效果改成1kb
reuseExistingChunk: true,
},
},
},
},
plugins: [
/**
* 处理.vue文件,这个插件是必须的
* 将定义过的解析规则应用到vue文件中
*/
new VueLoaderPlugin(),
// 把第三方库暴露在window 上下文中
new webpack.ProvidePlugin({
Vue: "vue",
}),
// 定义全局常量
new webpack.DefinePlugin({
// 默认兼容vue3语法,纯vue2语法可以选择不开这个全局控制变量,减小包体积
__VUE_OPTIONS_API__: "true",
// 是否开启 Devtools 调试
__VUE_PROD_DEVTOOLS__: "false",
// SSR 场景下,是否输出水合不匹配详细日志
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
"process.env.APP_ENV": JSON.stringify(process.env.NODE_ENV),
}),
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false, // css由mini-css单独抽离,不内联
resolveIcons: true, // 图标也按需引入
}),
],
}),
// 创建页面文件,自动注入chunk
...htmlWebpackPluginList,
],
};
5.2 开发环境
在开发环境里,可以基于《基础配置 + 热更新》技术实现修改代码后,快速本地预览效果,提高开发效率
5.2.1 热更新原理
热更新原理:
- 1.在本地客户端中起一个node的服务器,当服务器启动的时候,将webpack配置文件传入complie中编译(编译产物时,会在产物中注入一段sse或者webstock的代码以及热更新的api实现),编译的产物会落地到内存里,减少IO操作。
- 2.webpack编译完成后会调用done的广播,订阅了done钩子的函数会向浏览器发送一个消息,告诉浏览器,有新的文件更新了,请刷新页面。
- 3.浏览器收到消息后,会向服务器请求新的更新清单,根据清单内容请求资源文件重新执行。
- 4.当文件系统发生变化后会重复做上面的动作
js
const express = require("express");
const app = express();
const { webapckDevConfig, DEV_SERVER_CONFIG } = require("./config/webpack.dev");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpack = require("webpack");
const webpackHotMiddleware = require("webpack-hot-middleware");
const compiler = webpack(webapckDevConfig);
const cors = require("cors");
// 跨域中间件
app.use(cors());
// 负责监听文件系统的改动,根据webpack配置重新编译,将tpl文件落地到磁盘,其他文件落地到内存中
// 配置publicPath,可以拦截对应的文件请求,从内存中返回出去
app.use(
webpackDevMiddleware(compiler, {
writeToDisk: (filePath) => /.tpl$/.test(filePath),
publicPath: webapckDevConfig.output.publicPath,
}),
);
// webpack编译完成,webpack-hot-middleware中间件会监听到done钩子,通过sse或者webstock通知客户端,重新请求资源清单进行热更新
app.use(
webpackHotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
}),
);
// 服务启动
app.listen(DEV_SERVER_CONFIG.PORT, DEV_SERVER_CONFIG.HOST, () => {
console.log(`listening on http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}`);
});
5.3 生产环境
对于落地生产的包,需要尽可能的减少单个资源的体积,减少初始资源的加载数量,尽可能利用浏览器的并发请求,缓存等优化手段,提高用户体验与访问速度
- 静态资源优化:如果资源很小的话,可以直接打进chunk里,如果体积过大,可以直接打成单独的资源文件
- 所有资源文件都要使用哈希,利用浏览器的缓存机制,可以有效避免重复资源请求
- 对于css资源或者js资源需要做以下优化
- 将公共部分提取成单独的chunk
- 进行多线程压缩(提高构建速度以及降低体积)
- 去掉无用的注释
- 执行树摇
- 给资源文件添加crossorigin="anonymous",避免跨源资源请求携带用户凭证
- 关闭 sourcemap,避免生产环境暴露源码、源码路径和调试信息
- 抽离构建工具注入的运行时代码
- 根据框架提供的全局常量,控制开关,可以影响到框架编译时的代码判断,提高代码运行速度
- 抽离第三方库
js
const webpackConfig = require("./webpack.base");
const { mergeWithRules } = require("webpack-merge");
const path = require("path");
const os = require("os");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizer = require("css-minimizer-webpack-plugin");
const HtmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
const TerserPlugin = require("terser-webpack-plugin");
module.exports = mergeWithRules({
module: {
rules: {
test: "match",
use: "replace",
},
},
})(webpackConfig, {
mode: "production",
devtool: false,
output: {
// 产物输出的目标目录
path: path.resolve(process.cwd(), "./app/public/dist/prod"),
// 打包的js文件的命名
filename: "js/[name].[contenthash:8].bundle.js",
publicPath: "/dist/prod/",
// 异步模块的命名
chunkFilename: "js/async.[name].[contenthash:8].chunk.js",
clean: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
{
loader: "thread-loader",
options: {
workers: os.cpus().length,
},
},
"css-loader",
],
},
{
test: /\.less$/i,
use: [
MiniCssExtractPlugin.loader,
{
loader: "thread-loader",
options: {
workers: os.cpus().length,
},
},
"css-loader",
"less-loader",
],
},
{
test: /\.js$/i,
use: [
{
loader: "thread-loader",
options: {
workers: os.cpus().length,
},
},
"babel-loader",
],
},
],
},
plugins: [
// 抽离css成独立文件
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash:8].css",
chunkFilename: "css/async.[name].[contenthash:8].css",
}),
new HtmlWebpackInjectAttributesPlugin({
crossorigin: "anonymous",
}),
],
optimization: {
minimize: true,
minimizer: [
// 压缩js
new TerserPlugin({
// 多进程压缩
parallel: true,
// 提取注释到单独文件(生产关闭)
extractComments: false,
}),
new CssMinimizer({
parallel: true,
}),
],
},
});
6. CI/CD部署
前端项目从开发到上线会经历很多重复流程,例如安装依赖、启动本地服务、编译代码、压缩资源、生成产物、上传部署等。如果这些流程都依赖人工操作,就容易产生环境差异和操作失误。
工程化通过脚本、构建工具和 CI/CD 流程,把这些重复动作自动化。例如:
text
本地开发 -> 代码检查 -> 单元测试 -> 构建打包 -> 部署发布
这样可以减少人为操作的不确定性,让每一次构建和发布都尽量可复现。