前端工程化总结

84 阅读9分钟

前言

随着前端项目的规模和复杂度不断提升,如何保证开发效率与质量,已经成为行业的核心课题。对于每一位从事前端工作的人来说,掌握并践行前端工程化并不是锦上添花,而是必不可少的能力。它不仅关系到团队能否高效协作、项目能否顺利交付,更直接影响着个人在前端职业道路上的成长与竞争力。

何为前端工程化

前端工程化是指将软件工程的原理和方法应用到前端开发中,以提高开发效率、代码质量和可维护性。随着 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) 可以在不刷新页面的情况下实时应用代码改动,从而保留页面状态;此外,跨域问题的处理 也是开发阶段需要关注的重点。
而在 生产环境 中,项目需要真正上线运行,因此关注点则转向如何 减小文件体积、提升加载速度以及增强代码的可复用性,以保证应用的性能和用户体验。