你有没有遇到过这种情况:同一个项目里,开发环境跑得好好的,一打包线上就出怪毛病;或者反过来,线上正常,本地开发又报错。查来查去,最后发现就是配置文件里的几个字段被无情地覆盖了。今天咱们就用大白话聊聊,怎么在多环境下的 Webpack 配置里,既能复用公共部分,又不让那些“环境特有”的逻辑互相打架。

一、先从一个小坑说起

假设你有一个 Webpack 公共配置,里面写死了output.publicPath'/',然后你的生产环境配置(prod)里,为了配合 CDN,把它改成了'https://cdn.example.com/'。如果你是像下面这样直接“铺开”合并的:

// webpack.prod.config.js
const common = require('./webpack.common.config.js');

module.exports = {
  ...common,          // 先展开公共配置
  mode: 'production',
  output: {
    publicPath: 'https://cdn.example.com/',  // 想覆盖成CDN的地址
  },
};

乍一看没问题,但如果你在common里还设置了output.pathoutput.filename之类的字段,那对不起,这里一展开合并,你的output对象就直接被后面这个output整个替换掉了。output.pathoutput.filename全部消失,Webpack 会找不到出口,直接给你脸色看。

这就是我今天要说的“坑”的典型表现:你以为是在给公共配置打补丁,实际上是拿一个新对象把公共配置里的同一级对象整个“踩”扁了。而且这种覆盖往往发生在你完全没有意识到的地方,排查起来特别费劲。

二、为什么会有这种坑

Webpack 本身的配置文件就是一个 JavaScript 模块,它导出一个对象。当你用多个配置文件时,往往需要把它们合并起来。问题就出在“合并”这个动作上——很多人会用 JavaScript 的Object.assign或者对象展开运算符...。但这玩意儿是浅拷贝,它只会合并一层。

举个例子:

const common = {
  output: {
    path: '/dist',
    filename: 'bundle.js',
  },
};

const prod = {
  output: {
    publicPath: 'https://cdn.example.com/',
  },
};

// 用Object.assign合并
const merged = Object.assign({}, common, prod);
console.log(merged);

你猜输出什么?输出结果里output只剩下{ publicPath: 'https://cdn.example.com/' }了。pathfilename都没了,因为Object.assign在处理output这个 key 时,发现两边都是对象,就直接用后面的值覆盖了前面的值,根本不会递归地去把里面的字段一个一个合在一起。

这就是坑的根源:JavaScript 原生的对象合并操作,不擅长处理“嵌套层级”的合并。你拿它来合并 Webpack 配置,就等于默认了所有冲突都以“后写的为准”,而且不是“字段级”的为准,而是“对象级”的为准。这谁受得了啊?

三、一个典型的多环境配置示例

为了把问题说清楚,咱们先造一个典型的项目结构。假设你有一个前端项目,用 Webpack 构建,需要区分开发(development)、测试(test)、生产(production)三种环境。技术栈就用 JavaScript(Node.js) + Webpack 5。当然,如果你是 Webpack 4,思路完全一样。

你的目录大概是这样的:

your-project/
├── build/
│   ├── webpack.common.config.js   # 公共配置
│   ├── webpack.dev.config.js      # 开发环境
│   ├── webpack.test.config.js     # 测试环境
│   └── webpack.prod.config.js     # 生产环境
├── src/
│   └── index.js
└── package.json

先看看公共配置里通常有什么。它起码包含入口、出口、模块规则、插件等所有环境都通用的部分。

// build/webpack.common.config.js

// 公共配置:负责所有环境都一样的构建规则
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 入口:项目从哪个文件开始打包
  entry: './src/index.js',

  // 出口:打包后文件放哪、叫什么名字
  output: {
    path: path.resolve(__dirname, '../dist'), // 打包到dist目录
    filename: 'js/[name].[contenthash:8].js', // 文件名带哈希,方便缓存
    clean: true,                             // 每次打包前清理旧文件
  },

  // 模块规则:告诉Webpack怎么处理各种文件
  module: {
    rules: [
      {
        test: /\.js$/,                        // 匹配.js文件
        exclude: /node_modules/,              // 不需要处理依赖包
        use: {
          loader: 'babel-loader',             // 用Babel转译ES6+语法
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /\.css$/,                       // 匹配.css文件
        use: ['style-loader', 'css-loader'],  // 从右往左执行
      },
    ],
  },

  // 插件:做loader做不了的事情
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
      // 生成的HTML文件名
      filename: 'index.html',
    }),
  ],
};

再看一个容易出问题的开发环境配置。注意这里我们用了错误的合并方式,故意踩坑:

// build/webpack.dev.config.js

// 开发环境配置:重点关注开发体验
const common = require('./webpack.common.config.js');

module.exports = {
  ...common,               // 展开公共配置
  mode: 'development',     // 开发模式
  devtool: 'eval-cheap-module-source-map', // 更快更准确的源码映射

  // 这里想覆盖公共配置里的output对象
  output: {
    // 开发环境不需要缓存文件名,改成不哈希
    filename: 'js/[name].js',
    // 需要devServer的热更新能访问到
    publicPath: '/',
  },

  devServer: {
    port: 8080,
    hot: true,
    open: true,
  },
};

看到问题没?你这里一写output,公共配置里的output.pathoutput.clean就全没了。因为...common展开后,接着后面又写了output对象,这个对象就会把前面的output整个替换掉。虽然开发环境可能不影响什么,因为你没设置path也能跑,但如果你在output.path里有重要逻辑,比如要动态生成目录名,那开发环境就废了。

生产环境就更严重了。你可能既要filename带哈希,又要publicPath是CDN,还希望保留公共的pathclean。可一旦你用展开运算符去覆盖,所有公共字段瞬间消失:

// build/webpack.prod.config.js

// 生产环境配置:关注优化和CDN
const common = require('./webpack.common.config.js');

module.exports = {
  ...common,                     // 展开公共配置
  mode: 'production',            // 生产模式
  output: {
    publicPath: 'https://cdn.example.com/', // 资源路径指向CDN
  },
};

这个配置运行后,你的filename变成了默认的[name].js,哈希没了;path变成了默认的dist,也就是当前工作目录下的dist,而不是项目根目录下的dist。如果你们的部署流程是约定好打包产物必须放在项目根目录的dist文件夹,那这就有可能出现“找不到文件”的诡异问题。

四、解决覆盖问题的几种思路

坑已经摆在那了,怎么填平?别急,路子有好几条,咱们一条一条捋。

4.1 用函数返回配置

最先想到的办法是:把配置对象换成函数,函数接收一个“环境变量”参数,然后返回真正的配置。在函数内部,你可以根据环境变量手动拼接不同的字段,从根源上避免“对象覆盖对象”的窘境。

比如你可以把公共配置抽成一个函数:

// build/webpack.common.config.js

// 公共配置工厂:接收环境参数字典,返回基础配置
// 这样每个环境都可以基于它来调整,而不是简单替换
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = function (env) {
  // 基础配置:所有环境共用
  const baseConfig = {
    entry: './src/index.js',
    output: {
      // 注意:path还是可以设置的,因为后面不会再整体覆盖output
      path: path.resolve(__dirname, '../dist'),
      clean: true,
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'],
            },
          },
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: './public/index.html',
        filename: 'index.html',
      }),
    ],
  };

  // 如果传入了环境变量,可以在这里微调(注意只是微调,不会覆盖整块)
  if (env && env.production) {
    // 生产环境特有的配置,这里只额外添加或修改某个具体字段
    baseConfig.output.filename = 'js/[name].[contenthash:8].js';
    baseConfig.output.publicPath = 'https://cdn.example.com/';
    baseConfig.mode = 'production';
  } else {
    baseConfig.output.filename = 'js/[name].js';
    baseConfig.output.publicPath = '/';
    baseConfig.mode = 'development';
    baseConfig.devtool = 'eval-cheap-module-source-map';
    baseConfig.devServer = {
      port: 8080,
      hot: true,
      open: true,
    };
  }

  return baseConfig;
};

这样每个环境的配置文件就变成了:

// build/webpack.dev.config.js

// 开发环境配置文件
// 直接把公共配置工厂导出的函数跑一遍,并设置development标识
const createConfig = require('./webpack.common.config.js');

module.exports = createConfig({ development: true });

这种方式的优点很明显:逻辑完全受你控制,想怎么改就怎么改,不会出现“悄悄覆盖”的情况。缺点就是所有环境判断都堆在一个文件里,如果项目大了,配置文件会越来越臃肿,看着有点乱。

4.2 使用webpack-merge

既然手动合并容易出错,那就用专业工具。webpack-merge是官方推荐的配置合并工具,它专门为了解决 Webpack 配置合并问题而生。它的特点就是能够“深合并”嵌套对象,还能对数组做智能处理。

安装一下:

npm install --save-dev webpack-merge

然后你的公共配置保持普通对象方式导出:

// build/webpack.common.config.js

// 公共配置:各个环境共享的配置,注意这里用的是普通对象导出
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: 'js/[name].[contenthash:8].js', // 默认用带哈希的文件名
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
      filename: 'index.html',
    }),
  ],
};

接下来开发环境用webpack-merge来合并,注意这里面的顺序:第一个参数是公共配置,第二个参数是覆盖或新增的配置。webpack-merge会递归合并对象,并且数组默认会“追加”,而不是替换。

// build/webpack.dev.config.js

// 开发环境配置:使用webpack-merge进行智能合并
const { merge } = require('webpack-merge');
const common = require('./webpack.common.config.js');

module.exports = merge(common, {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',

  // 想覆盖output里的filename怎么办?直接指定具体字段就行
  output: {
    filename: 'js/[name].js',             // 只改文件名
    publicPath: '/',                      // 加上开发环境需要的publicPath
    // 注意:不需要写path和clean,它们会被保留下来
  },

  devServer: {
    port: 8080,
    hot: true,
    open: true,
  },
});

生产环境也类似:

// build/webpack.prod.config.js

// 生产环境配置:使用webpack-merge安全合并
const { merge } = require('webpack-merge');
const common = require('./webpack.common.config.js');

module.exports = merge(common, {
  mode: 'production',

  output: {
    // 只覆盖publicPath,其它字段如filename、path、clean都保留
    publicPath: 'https://cdn.example.com/',
  },
});

这样你会发现,公共配置里的output.filenameoutput.path都会原封不动地保留下来,只有你明确要改的字段被覆盖或新增。这就是webpack-merge帮我们解决的问题。

4.3 拆分更细的配置片段

如果你觉得webpack-merge虽然好用,但有时候数组合并的规则还得记一下,那你可以考虑把配置拆成更细的小片段,每个片段只负责一块功能,然后按需组合。这种方式我称之为“乐高式配置”。

比如你把“出口配置”单独抽一个文件:

// build/configs/output.js

// 出口配置片段:把不同环境的output配置独立出来
const commonOutput = {
  path: require('path').resolve(__dirname, '../../dist'),
  clean: true,
};

const devOutput = {
  ...commonOutput,       // 先继承公共部分(这里同样要注意展开对象的问题)
  filename: 'js/[name].js',
  publicPath: '/',
};

const prodOutput = {
  ...commonOutput,
  filename: 'js/[name].[contenthash:8].js',
  publicPath: 'https://cdn.example.com/',
};

module.exports = {
  dev: devOutput,
  prod: prodOutput,
};

然后把“模块规则”、“插件”也类似地拆出来。最后在环境的配置里,直接引用对应片段:

// build/webpack.dev.config.js

// 开发环境配置:把几个片段拼起来
const output = require('./configs/output').dev;
const rules = require('./configs/rules').common;
const plugins = require('./configs/plugins').common;

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: output,
  module: { rules },
  plugins: plugins,
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    port: 8080,
    hot: true,
    open: true,
  },
};

这种方式的优点是每个配置文件都一目了然,你知道用了哪些片段,不会有隐藏合并逻辑。缺点是文件数量变多了,而且如果你追求极致复用,可能会在“片段”之间也遇到相互依赖的问题。

4.4 注意数组字段的合并

不管是webpack-merge还是手写合并,都有一个绕不开的特殊点:数组字段。Webpack 配置里数组字段太多了,最常见的module.rulesplugins,还有 resolve.extensions 等等。这些数组怎么合并?直接替换?还是拼接?

如果你用webpack-merge,它的默认行为是“后者的数组会追加到前者的数组后面”。但有时候你可能不希望追加,而是想覆盖。比如resolve.extensions,如果你希望默认的扩展名列表完全由自己的配置控制,那就不希望公共配置里的数组拼过来。这时候webpack-merge提供了“自定义合并策略”。

看这个例子:

// build/webpack.prod.config.js

// 生产环境配置:演示自定义数组合并策略
const { merge } = require('webpack-merge');
const common = require('./webpack.common.config.js');

module.exports = merge(
  {
    // 第一个参数:公共配置
    ...common,
  },
  {
    mode: 'production',
    output: {
      publicPath: 'https://cdn.example.com/',
    },
    // 假设公共配置里resolve.extensions是['.js', '.json']
    resolve: {
      // 我想完全自己指定,不要追加
      extensions: ['.js', '.mjs'],
    },
  },
  {
    // 第二个参数:合并策略配置
    // 意思是对resolve.extensions这个字段,采用“后替换前”的策略
    customizeArray: (a, b, key) => {
      if (key === 'resolve.extensions') {
        return b; // 直接返回后面的值,把前面的值扔掉
      }
      // 其它字段不做特殊处理,走默认的追加逻辑
      return undefined;
    },
  }
);

说实话,这种自定义策略用起来稍微有点繁琐,但能解决“我就是想全量替换”的需求。如果你不想记这些规则,还有一个土办法:在合并之前,先把公共配置里的那个数组字段置为空数组,再合并。虽然不是特别优雅,但很简单直白。

// 土办法:先把公共配置的extensions清空再合并
const modifiedCommon = {
  ...common,
  resolve: {
    ...(common.resolve || {}),
    extensions: [], // 先清空
  },
};

const merged = merge(modifiedCommon, {
  resolve: {
    extensions: ['.js', '.mjs'],
  },
});

五、实战:一套干净的配置模板

说了这么多,咱们还是来套完整的、可以直接照抄的模板。技术栈是 JavaScript(Node.js)+ Webpack 5 + webpack-merge。假设项目要支持开发和生产两种环境,以后要扩展测试环境也只要加一个文件。

首先安装 Webpack 和合并工具:

npm init -y
npm install --save-dev webpack webpack-cli webpack-merge webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-env css-loader style-loader

创建公共配置:

// build/webpack.common.config.js

// 公共配置:所有环境都必须遵守的构建规则
// 我们用webpack-merge来支持深合并,所以这里可以放心地写嵌套对象
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 入口:所有环境都从同一个入口开始
  entry: './src/index.js',

  // 默认出口配置,后续环境可以只修改需要的字段
  output: {
    // 打包输出目录:项目根目录下的dist
    path: path.resolve(__dirname, '../dist'),
    // 默认文件名规则:带8位哈希,用于长效缓存
    filename: 'js/[name].[contenthash:8].js',
    // 每次构建前清理旧文件
    clean: true,
  },

  // 模块处理规则:所有环境的JS和CSS处理方式一致
  module: {
    rules: [
      {
        // 命中.js文件
        test: /\.js$/,
        // 不处理node_modules里的代码
        exclude: /node_modules/,
        use: {
          // 用babel-loader转译ES6+语法
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        // 命中.css文件
        test: /\.css$/,
        // 注意:loader的执行顺序是从右到左,先css-loader后style-loader
        use: ['style-loader', 'css-loader'],
      },
    ],
  },

  // 插件配置:所有环境都要生成HTML文件
  plugins: [
    new HtmlWebpackPlugin({
      // 用public目录下的index.html作为模板
      template: './public/index.html',
      // 输出文件名
      filename: 'index.html',
    }),
  ],
};

开发环境配置:

// build/webpack.dev.config.js

// 开发环境配置:目标是让开发过程又爽又不容易出错
const { merge } = require('webpack-merge');
const common = require('./webpack.common.config.js');

module.exports = merge(common, {
  // 开发模式:打包速度更快,不压缩代码
  mode: 'development',

  // 源码映射:方便调试,定位报错位置
  devtool: 'eval-cheap-module-source-map',

  // 覆盖output中的部分字段,其余保留
  output: {
    // 开发环境不带哈希,因为不需要缓存,改完文件能立刻看到
    filename: 'js/[name].js',
    // 开发服务器根路径
    publicPath: '/',
  },

  // 开发服务器配置
  devServer: {
    port: 8080,                 // 端口号
    hot: true,                  // 开启热模块替换
    open: true,                 // 启动时自动打开浏览器
    historyApiFallback: true,   // 支持单页应用路由
  },
});

生产环境配置:

// build/webpack.prod.config.js

// 生产环境配置:关注最终构建产物的体积和缓存策略
const { merge } = require('webpack-merge');
const common = require('./webpack.common.config.js');

module.exports = merge(common, {
  mode: 'production',

  // 生产环境不用devtool,或者用一个慢但更精确的
  devtool: 'hidden-source-map',

  // 覆盖output中的publicPath指向CDN
  output: {
    publicPath: 'https://cdn.example.com/',
  },

  // 额外的生产环境优化插件可以在后面继续添加
});

package.json里加几个脚本,方便调用:

{
  "scripts": {
    "dev": "webpack serve --config build/webpack.dev.config.js",
    "build": "webpack --config build/webpack.prod.config.js"
  }
}

这样一套配置,不管你是开发环境还是生产环境,公共配置里的字段都会完整地保留下来,不会因为环境特定逻辑而互相覆盖。以后想加测试环境,只需新建一个webpack.test.config.jsmerge合并公共配置,再调整几个字段就完事了。

六、应用场景和优缺点分析

什么样的项目适合用这种多环境复用策略?说几个典型场景:

  • 中小型前端项目,没有太复杂的构建需求,但需要区分开发和生产环境。
  • 团队里有多个人同时开发,不希望每个人改配置文件时把别人的配置覆盖掉。
  • 想统一管理构建规则,比如公司规定了所有项目的 Babel 配置或 CSS 处理方式。
  • 项目要部署到不同的环境(测试服、预发布服、正式服),每个环境的 API 地址、CDN 地址都不一样。

这种复用策略的优点很突出:

  • 公共逻辑只写一遍,避免重复代码,也避免多个环境之间规则不一致。
  • 环境特定逻辑被清晰隔离,改一个环境不影响其它环境。
  • webpack-merge后,嵌套对象和数组的合并变得可控,很大程度避免了“神秘覆盖”。

但缺点也要心里有数:

  • webpack-merge不是万能的,数组默认追加这个行为在某些场景下会让你懵一下。
  • 过度拆分配置片段可能导致文件数量膨胀,新同事接手时需要看多个文件才能拼出完整配置。
  • 如果完全依赖函数式配置,环境判断逻辑可能都堆在一个文件里,代码可读性会变差。

七、注意事项

写多环境配置,有这几个细节需要特别留意:

  1. 公共同名配置项不要重复定义。比如公共配置里已经有output.filename,环境的配置里又想用另一个,在webpack-merge里可以直接覆盖;但你要是自己用展开运算符,就会覆盖整个output对象。
  2. pluginsrules这类数组字段,默认是追加而不是覆盖。如果你不想追加,可以给数组项加上自己的标识,比如给每个插件设置一个唯一的name,然后配置webpack-mergecustomizeArray来根据标识决定保留还是替换。
  3. 注意merge的顺序。webpack-merge的语法是merge(公共配置, 环境配置, 自定义合并策略)。后写的优先级更高,但只作用于具体字段,不是整块替换。
  4. 如果你用了 TypeScript,配置文件的扩展名是.ts,那你需要用到ts-node来运行。虽然技术栈不同,但合并思路完全一样。
  5. devServer里的配置在某些环境下没有,比如生产环境根本不会跑devServer,但公共配置里最好别留,免得混淆。
  6. 要是某个环境需要完全不一样的插件列表,千万别简单地在环境配置里写一个plugins: [],那相当于把公共插件全删了。要么用自定义合并策略,要么先清空再合并。
  7. 最后一点,也是最容易被忽略的:修改配置文件后,如果改动没有生效,先看看是不是缓存了node_modules/.cache目录。Webpack 4/5 会缓存部分编译结果,有时候配置改了但缓存没失效,会让人误以为覆盖逻辑有问题。这时候删掉缓存目录再试试。

八、总结

多环境 Webpack 配置复用,核心就一句话:别用“展开运算符”或Object.assign去合并嵌套对象,也别指望“后写的对象”能覆盖“前写的同名字段”而保留其它字段。要找对工具,webpack-merge是目前最省心的方案。同时你要理解数组字段的默认行为是追加,遇到需要全量替换的场景,准备好自定义合并策略。另外,把环境特有逻辑和公共逻辑分开管理,让每个配置文件都只关心自己真正关心的差异,这样你的构建流程就会稳定得多,再也不怕那些“莫名其妙”的覆盖问题。

你现在就可以打开项目,看看自己的配置文件是不是也踩了这个坑。如果有,按上面说的改成webpack-merge,然后跑一次构建,你会感觉世界清爽了很多。