对前端工程化的理解

对前端工程化的理解

本文章仅个人对前端工程化的理解,仅供参考。

一、工程化衍生的背景

早期的前端开发更多是页面级开发:写 HTML 组织结构,写 CSS 处理样式,写 JavaScript 完成交互。页面数量少、业务逻辑简单时,这种开发方式足够直接,成本也不高。

但随着前端承担的职责越来越多,前端已经不只是"切页面"和"写交互"。现代前端通常需要处理路由、状态管理、接口请求、权限控制、组件复用、构建打包、代码分割、性能优化、环境配置、质量检测、自动化部署等一系列问题。前端项目逐渐从页面集合演变成一个完整的软件系统。

当项目复杂度提升后,单纯依赖个人习惯和手工维护会出现很多问题:

  1. 代码组织混乱,不同开发者的目录结构、命名方式和实现风格不一致。
  2. 重复代码增多,相似页面、相似组件、相似请求逻辑被反复编写。
  3. 项目构建依赖人工操作,开发、测试、生产环境之间容易出现配置差异。
  4. 代码质量缺少统一约束,问题往往要到运行时或者上线后才暴露。
  5. 多人协作成本上升,改动影响范围不清晰,维护成本越来越高。
  6. 发布流程不可控,依赖手工打包、手工上传、手工验证,容易出现遗漏。

因此,前端工程化的出现,本质上是为了解决前端项目在规模化、复杂化、多人协作场景下的可维护性和可控性问题。它不是单纯引入某个构建工具,也不是只配置一套脚手架,而是把前端开发从"手工作坊式开发"推进到"标准化、自动化、可持续维护的软件工程开发"。

二、如何进行工程化

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 生产环境

对于落地生产的包,需要尽可能的减少单个资源的体积,减少初始资源的加载数量,尽可能利用浏览器的并发请求,缓存等优化手段,提高用户体验与访问速度

  1. 静态资源优化:如果资源很小的话,可以直接打进chunk里,如果体积过大,可以直接打成单独的资源文件
  2. 所有资源文件都要使用哈希,利用浏览器的缓存机制,可以有效避免重复资源请求
  3. 对于css资源或者js资源需要做以下优化
  • 将公共部分提取成单独的chunk
  • 进行多线程压缩(提高构建速度以及降低体积)
  • 去掉无用的注释
  • 执行树摇
  1. 给资源文件添加crossorigin="anonymous",避免跨源资源请求携带用户凭证
  2. 关闭 sourcemap,避免生产环境暴露源码、源码路径和调试信息
  3. 抽离构建工具注入的运行时代码
  4. 根据框架提供的全局常量,控制开关,可以影响到框架编译时的代码判断,提高代码运行速度
  5. 抽离第三方库
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 复制代码
本地开发 -> 代码检查 -> 单元测试 -> 构建打包 -> 部署发布

这样可以减少人为操作的不确定性,让每一次构建和发布都尽量可复现。

相关推荐
Slice_cy1 小时前
状态机设计理念与实现
前端
星栈1 小时前
LiveView 的生命周期:mount、handle_event 和 Socket 到底怎么运转
前端·前端框架·elixir
yingyima1 小时前
JWT Token 解析与安全实践速查:5 问 5 答直击要害
前端
kyriewen2 小时前
我用 Codex 重写了同事维护三年的代码,他没说谢谢——而是找了领导
前端·javascript·ai编程
OpenTiny社区3 小时前
从零开发 AI 聊天页要两周?试试这款 Vue3 垂直对话组件库 TinyRobot,直接开箱即用
前端·vue.js·github
铁皮饭盒3 小时前
S3已成为文件存储标准,阿里/腾讯/华为云都支持,Bun率先原生支持
前端·javascript·后端
Cobyte3 小时前
22.Vue Vapor 组件 props 的实现
前端·javascript·vue.js
lichenyang4533 小时前
从 has.showToast 看 ASCF 的 API 调用链路
前端
张就是我1065924 小时前
DOMPurify 的一个漏洞:你以为 {} 是空的?
前端