Skip to content

Webpack专题研究

企业级Webpack配置

核心配置结构

javascript
// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  
  return {
    entry: {
      main: './src/index.js',
      vendor: ['react', 'react-dom']
    },
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: isProduction ? '[name].[contenthash].js' : '[name].js',
      chunkFilename: isProduction ? '[id].[contenthash].js' : '[id].js',
      publicPath: isProduction ? 'https://cdn.example.com/' : '/'
    },
    module: {
      rules: [
        {
          test: /\.(js|jsx)$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env', '@babel/preset-react'],
              plugins: ['@babel/plugin-transform-runtime']
            }
          }
        },
        {
          test: /\.(css|scss)$/,
          use: [
            isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
            {
              loader: 'css-loader',
              options: {
                modules: {
                  auto: /\.module\.(css|scss)$/,
                  localIdentName: '[name]__[local]--[hash:base64:5]'
                }
              }
            },
            'postcss-loader',
            'sass-loader'
          ]
        },
        {
          test: /\.(png|jpe?g|gif|svg)$/,
          type: 'asset',
          parser: {
            dataUrlCondition: {
              maxSize: 8 * 1024 // 8kb
            }
          },
          generator: {
            filename: 'assets/images/[name].[hash][ext]'
          }
        },
        {
          test: /\.(woff|woff2|eot|ttf|otf)$/,
          type: 'asset/resource',
          generator: {
            filename: 'assets/fonts/[name].[hash][ext]'
          }
        }
      ]
    },
    plugins: [
      new CleanWebpackPlugin(),
      new HtmlWebpackPlugin({
        template: './public/index.html',
        minify: isProduction ? {
          collapseWhitespace: true,
          removeComments: true,
          removeRedundantAttributes: true,
          removeScriptTypeAttributes: true,
          removeStyleLinkTypeAttributes: true,
          useShortDoctype: true
        } : false
      }),
      new MiniCssExtractPlugin({
        filename: isProduction ? '[name].[contenthash].css' : '[name].css',
        chunkFilename: isProduction ? '[id].[contenthash].css' : '[id].css'
      }),
      new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
        'process.env.API_URL': JSON.stringify(process.env.API_URL || 'http://localhost:3000')
      })
    ],
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            name: 'vendor',
            test: /[\\/]node_modules[\\/]/,
            priority: 10,
            chunks: 'initial'
          },
          common: {
            name: 'common',
            minChunks: 2,
            priority: 5,
            chunks: 'initial'
          }
        }
      },
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            compress: {
              drop_console: isProduction,
              drop_debugger: isProduction
            },
            output: {
              comments: false
            }
          }
        }),
        new CssMinimizerPlugin()
      ],
      runtimeChunk: 'single'
    },
    resolve: {
      extensions: ['.js', '.jsx', '.json'],
      alias: {
        '@': path.resolve(__dirname, 'src')
      }
    },
    devServer: {
      contentBase: path.join(__dirname, 'dist'),
      compress: true,
      port: 3000,
      hot: true,
      historyApiFallback: true,
      proxy: {
        '/api': {
          target: 'http://localhost:8080',
          changeOrigin: true
        }
      }
    },
    devtool: isProduction ? 'source-map' : 'cheap-module-eval-source-map'
  };
};

常见场景与配置

1. 多页面应用配置

javascript
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    page1: './src/page1.js',
    page2: './src/page2.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'page1.html',
      template: './src/page1.html',
      chunks: ['page1', 'vendor', 'common']
    }),
    new HtmlWebpackPlugin({
      filename: 'page2.html',
      template: './src/page2.html',
      chunks: ['page2', 'vendor', 'common']
    })
  ]
};

2. 代码分割与懒加载

javascript
// 动态导入实现懒加载
import('./lazyComponent').then(module => {
  const LazyComponent = module.default;
  // 使用LazyComponent
});

// React中的懒加载
import React, { lazy, Suspense } from 'react';

const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

3. 环境变量配置

javascript
// webpack.config.js
module.exports = (env, argv) => {
  return {
    plugins: [
      new webpack.DefinePlugin({
        'process.env': {
          NODE_ENV: JSON.stringify(argv.mode),
          API_URL: JSON.stringify(process.env.API_URL || 'http://localhost:3000'),
          VERSION: JSON.stringify(require('./package.json').version)
        }
      })
    ]
  };
};

4. Tree Shaking配置

javascript
// webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,
    minimize: true
  }
};

易错点与坑

1. 路径配置错误

问题:静态资源路径错误,导致404 原因publicPath配置不正确,或者资源引用路径与输出路径不匹配 解决方案

javascript
output: {
  publicPath: process.env.NODE_ENV === 'production' ? 'https://cdn.example.com/' : '/'
}

2. 缓存失效问题

问题:版本更新后,用户仍访问旧资源 原因:未使用内容哈希或缓存策略不当 解决方案

javascript
output: {
  filename: '[name].[contenthash].js',
  chunkFilename: '[id].[contenthash].js'
}

3. 构建性能问题

问题:构建时间过长 原因:文件过多、配置不当、依赖过多 解决方案

  • 使用DllPlugin预编译常用依赖
  • 配置exclude/include减少文件扫描范围
  • 使用HardSourceWebpackPlugin缓存构建结果
  • 升级Webpack版本到最新

4. 配置冲突

问题:不同loader或plugin之间配置冲突 原因:配置顺序不当或参数冲突 解决方案

  • 注意loader执行顺序(从右到左,从下到上)
  • 仔细阅读插件文档,避免参数冲突
  • 使用enforce: 'pre'enforce: 'post'控制loader执行阶段

企业级Webpack插件开发

1. CDN自动上传插件

javascript
// CdnUploadPlugin.js
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');

class CdnUploadPlugin {
  constructor(options) {
    this.options = {
      cdnUrl: '',
      cdnToken: '',
      uploadPath: '/',
      include: /\.(js|css|png|jpg|gif|svg|woff|woff2|eot|ttf|otf)$/,
      ...options
    };
  }

  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync('CdnUploadPlugin', async (compilation, callback) => {
      try {
        const assets = Object.keys(compilation.assets);
        const uploadPromises = [];

        for (const asset of assets) {
          if (this.options.include.test(asset)) {
            const assetPath = path.join(compiler.outputPath, asset);
            if (fs.existsSync(assetPath)) {
              uploadPromises.push(this.uploadFile(asset, assetPath));
            }
          }
        }

        await Promise.all(uploadPromises);
        console.log('所有静态资源已成功上传到CDN');
        callback();
      } catch (error) {
        console.error('CDN上传失败:', error);
        callback(error);
      }
    });
  }

  async uploadFile(assetName, assetPath) {
    const form = new FormData();
    form.append('file', fs.createReadStream(assetPath));
    form.append('path', this.options.uploadPath);

    const response = await axios.post(this.options.cdnUrl, form, {
      headers: {
        'Authorization': `Bearer ${this.options.cdnToken}`,
        ...form.getHeaders()
      }
    });

    return response.data;
  }
}

module.exports = CdnUploadPlugin;

使用方法

javascript
const CdnUploadPlugin = require('./plugins/CdnUploadPlugin');

module.exports = {
  plugins: [
    new CdnUploadPlugin({
      cdnUrl: 'https://api.example.com/upload',
      cdnToken: process.env.CDN_TOKEN,
      uploadPath: '/static/'
    })
  ]
};

2. 版本号生成插件

javascript
// VersionGeneratorPlugin.js
const fs = require('fs');
const path = require('path');

class VersionGeneratorPlugin {
  constructor(options) {
    this.options = {
      versionFile: 'version.json',
      outputPath: '',
      ...options
    };
  }

  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync('VersionGeneratorPlugin', (compilation, callback) => {
      try {
        const versionData = {
          version: this.generateVersion(),
          buildTime: new Date().toISOString(),
          commitHash: this.getGitCommitHash(),
          branch: this.getGitBranch()
        };

        const outputPath = path.join(
          this.options.outputPath || compiler.outputPath,
          this.options.versionFile
        );

        fs.writeFileSync(outputPath, JSON.stringify(versionData, null, 2));
        console.log(`版本文件已生成: ${outputPath}`);

        // 也可以将版本信息注入到全局变量
        compilation.assets['version.txt'] = {
          source: () => `${versionData.version}\n${versionData.buildTime}\n${versionData.commitHash}`,
          size: () => Buffer.byteLength(`${versionData.version}\n${versionData.buildTime}\n${versionData.commitHash}`)
        };

        callback();
      } catch (error) {
        console.error('版本文件生成失败:', error);
        callback(error);
      }
    });
  }

  generateVersion() {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    const day = String(now.getDate()).padStart(2, '0');
    const hours = String(now.getHours()).padStart(2, '0');
    const minutes = String(now.getMinutes()).padStart(2, '0');
    return `${year}${month}${day}.${hours}${minutes}`;
  }

  getGitCommitHash() {
    try {
      return fs.readFileSync('.git/HEAD', 'utf8')
        .trim()
        .split(' ')[1]
        .split('/')
        .pop();
    } catch (error) {
      return 'unknown';
    }
  }

  getGitBranch() {
    try {
      return fs.readFileSync('.git/HEAD', 'utf8')
        .trim()
        .split('/')
        .pop();
    } catch (error) {
      return 'unknown';
    }
  }
}

module.exports = VersionGeneratorPlugin;

使用方法

javascript
const VersionGeneratorPlugin = require('./plugins/VersionGeneratorPlugin');

module.exports = {
  plugins: [
    new VersionGeneratorPlugin({
      versionFile: 'version.json',
      outputPath: './dist'
    })
  ]
};

3. 构建分析与报告插件

javascript
// BuildAnalyzerPlugin.js
const fs = require('fs');
const path = require('path');

class BuildAnalyzerPlugin {
  constructor(options) {
    this.options = {
      reportFile: 'build-report.html',
      outputPath: '',
      ...options
    };
  }

  apply(compiler) {
    compiler.hooks.done.tap('BuildAnalyzerPlugin', (stats) => {
      try {
        const statsData = stats.toJson();
        const report = this.generateReport(statsData);

        const outputPath = path.join(
          this.options.outputPath || compiler.outputPath,
          this.options.reportFile
        );

        fs.writeFileSync(outputPath, report);
        console.log(`构建分析报告已生成: ${outputPath}`);
      } catch (error) {
        console.error('构建分析报告生成失败:', error);
      }
    });
  }

  generateReport(statsData) {
    const assets = statsData.assets.sort((a, b) => b.size - a.size);
    const chunks = statsData.chunks;

    return `
      <!DOCTYPE html>
      <html>
      <head>
        <title>构建分析报告</title>
        <style>
          body { font-family: Arial, sans-serif; margin: 20px; }
          .section { margin-bottom: 30px; }
          .asset-table { width: 100%; border-collapse: collapse; }
          .asset-table th, .asset-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
          .asset-table th { background-color: #f2f2f2; }
          .size { text-align: right; }
        </style>
      </head>
      <body>
        <h1>构建分析报告</h1>
        <div class="section">
          <h2>构建信息</h2>
          <p>时间: ${new Date().toLocaleString()}</p>
          <p>版本: ${statsData.version}</p>
          <p>耗时: ${statsData.time}ms</p>
        </div>
        <div class="section">
          <h2>资源大小(前20个)</h2>
          <table class="asset-table">
            <thead>
              <tr>
                <th>文件名</th>
                <th class="size">大小</th>
              </tr>
            </thead>
            <tbody>
              ${assets.slice(0, 20).map(asset => `
                <tr>
                  <td>${asset.name}</td>
                  <td class="size">${this.formatSize(asset.size)}</td>
                </tr>
              `).join('')}
            </tbody>
          </table>
        </div>
      </body>
      </html>
    `;
  }

  formatSize(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

module.exports = BuildAnalyzerPlugin;

使用方法

javascript
const BuildAnalyzerPlugin = require('./plugins/BuildAnalyzerPlugin');

module.exports = {
  plugins: [
    new BuildAnalyzerPlugin()
  ]
};

4. 代码规范检查插件

javascript
// CodeLintPlugin.js
const eslint = require('eslint');
const fs = require('fs');
const path = require('path');

class CodeLintPlugin {
  constructor(options) {
    this.options = {
      extensions: ['.js', '.jsx', '.ts', '.tsx'],
      configFile: '.eslintrc.js',
      ignoreFile: '.eslintignore',
      failOnError: true,
      ...options
    };
  }

  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync('CodeLintPlugin', (compilation, callback) => {
      try {
        const eslintOptions = {
          extensions: this.options.extensions,
          configFile: this.options.configFile,
          ignoreFile: this.options.ignoreFile
        };

        const eslint = new eslint.ESLint(eslintOptions);
        const results = eslint.lintFiles(['src/**/*']);

        results.then(lintResults => {
          const formatter = eslint.loadFormatter('stylish');
          const resultText = formatter.format(lintResults);
          console.log(resultText);

          const errorCount = lintResults.reduce((sum, result) => sum + result.errorCount, 0);
          if (this.options.failOnError && errorCount > 0) {
            callback(new Error(`代码规范检查失败,发现${errorCount}个错误`));
          } else {
            callback();
          }
        });
      } catch (error) {
        console.error('代码规范检查失败:', error);
        callback(error);
      }
    });
  }
}

module.exports = CodeLintPlugin;

使用方法

javascript
const CodeLintPlugin = require('./plugins/CodeLintPlugin');

module.exports = {
  plugins: [
    new CodeLintPlugin({
      failOnError: process.env.NODE_ENV === 'production'
    })
  ]
};

构建工具子菜单配置

在项目的构建工具菜单中,已包含以下内容:

总结与最佳实践

  1. 配置分离:将开发、测试、生产环境的配置分离,提高可维护性
  2. 模块化:将复杂配置拆分为多个模块,便于管理和复用
  3. 性能优化:使用缓存、代码分割、Tree Shaking等技术提高构建性能和运行效率
  4. 插件定制:根据业务需求开发自定义插件,提升开发效率
  5. 监控与分析:使用构建分析工具,持续优化构建流程
  6. 版本控制:使用内容哈希和版本号,确保缓存有效性和可回滚性

Webpack作为前端工程化的核心工具,需要根据业务需求进行合理配置和定制开发。企业级配置需要考虑性能、可维护性、可扩展性等多个方面,同时结合自定义插件满足特定业务场景的需求。

Released under the MIT License.