为什么要用 webpack-merge ,不用 Object.assign ?

背景

最近在整理重构前辈们留下的项目,发现项目中有一处代码,我有点疑问。

这段代码应该是想把 vue 项目路由的参数 query 合并更新。不想删掉老的参数,同时增加或者替换新的参数值。

我的疑问其实是前辈们为啥要用 merge (import merge from 'webpack-merge') , 而不用 Object.assign() , 或者用对象的解构 query = { ...this.$route.query, search: this.input, current_page: 1}

在我印象中,merge 是使用在 webpack 配置中的,用来合并配置参数之类的。

此时处在了似懂非懂之间,有点尴尬,索性就好好学一遍 webpack-merge,探个究竟!感兴趣的就跟着我一起往下看。

简介

为 Webpack 设计的合并

webpack-merge 提供了一个 merge 函数,它连接数组并合并对象以创建一个新对象。如果遇到函数,它将执行它们,通过算法运行结果,然后再次将返回值包装在函数中。

这种行为在配置 webpack 时特别有用,尽管它还有其他用途。每当你需要合并配置对象时,webpack-merge 就能派上用场。(联想自己可能的应用场景)

语法

默认语法

js 复制代码
merge(...configuration | [...configuration])

merge 是API的核心,也是最重要的思想。通常这就是你所需要的,除非你想进一步定制。

js 复制代码
const { merge } = require('webpack-merge');

// 默认 API
const output = merge(object1, object2, object3, ...);

// 可以直接传递对象数组。
// 这适用于所有可用的函数
const output = merge([object1, object2, object3]);

// 与右侧匹配的 Keys 优先
const output = merge(
  { fruit: "apple", color: "red" },
  { fruit: "strawberries" }
);
console.log(output);
// { color: "red", fruit: "strawberries"}

Tips:不支持 Promise

分析:

如果 merge 的两个对象中的键值对:

  • 数据类型不一样,后面完全覆盖前面;
  • 如果两者都是基础数据类型,后面覆盖前面。
  • 如果两者都是数组,就会把两个数组进行合并
  • 如果两者都是对象,键值对规则同上
js 复制代码
// 数据类型不一样
let dog = {
    color: {},
};
let cat = {
    color: '#000',
};
console.log(merge(dog, cat)); // {color: '#000'}

// 两者都是基础数据类型
let dog = {
    color: 'yellow',
};
let cat = {
    color: '#000',
};
console.log(merge(dog, cat)); // {color: '#000'}

// 如果两者都是数组
let dog = {
    habit: ['run', 'eat'],
};
let cat = {
    habit: ['sleep', 'eat'],
};
console.log(merge(dog, cat)); // {habit: ['run', 'eat', 'sleep', 'eat']}

// 完整示例
let dog = {
    color: 'yellow',
    habit: ['run', 'eat'],
    behavior: {
        character: 'lively',
        age: 7,
    },
};
let cat = {
    color: '#000',
    habit: ['sleep', 'eat'],
    behavior: {
        character: 'cute',
        age: 8,
    },
};
console.log(merge(dog, cat));
// {
//   color: '#000',
//   habit: [ 'run', 'eat', 'sleep', 'eat' ],
//   behavior: { character: 'cute', age: 8 }
// }

自定义语法

js 复制代码
mergeWithCustomize({ customizeArray, customizeObject })(...configuration | [...configuration])

如果您需要更大的灵活性,可以按字段自定义 merge 行为,如下所示:

js 复制代码
const { mergeWithCustomize } = require('webpack-merge');

const object1 = {
    foo1: ['object1'],
    foo2: ['object1'],
    bar1: { object1: {} },
    bar2: { object1: {} },
}
const object2 = {
    foo1: ['object2'],
    foo2: ['object2'],
    bar1: { object2: {} },
    bar2: { object2: {} },
}

const output = mergeWithCustomize(
  {
    customizeArray(a, b, key) {
      if (key === 'extensions') {
        return _.uniq([...a, ...b]);
      }
      // 回退到默认合并
      return undefined;
    },
    customizeObject(a, b, key) {
      if (key === 'module') {
        // 自定义合并
        return _.merge({}, a, b);
      }
      // 回退到默认合并
      return undefined;
    }
  }
)(object1, object2, ...);

上述代码运行后,则将为 Array 类型的每个属性调用 customizeArray ,即:

js 复制代码
customizeArray(["object1"], ["object2"], "foo1");
customizeArray(["object1"], ["object2"], "foo2");

customizeObject 将被调用为 Object 类型的每个属性,即:

js 复制代码
customizeObject({ object1: {} }, { object2: {} }, bar1);
customizeObject({ object1: {} }, { object2: {} }, bar2);

上述这部分看懂了吗?如果没看懂,好好看一下,直到看懂为止。

customizeArraycustomizeObjectmergeWithCustomize 提供了小策略。它们支持字段名的 appendprependreplace 和通配符。

js 复制代码
const { mergeWithCustomize, customizeArray, customizeObject } = require('webpack-merge');

const output = mergeWithCustomize({
  customizeArray: customizeArray({
    'entry.*': 'prepend'
  }),
  customizeObject: customizeObject({
    entry: 'prepend'
  })
})(object1, object2, object3, ...);

unique

js 复制代码
`unique(<field>, <fields>, field => field)`

unique 是一种策略,用于强制配置中的唯一性。当你想确保只有一个插件时,它对插件最有用。

第一个 <field> 是用于查找重复项的配置属性。

第二个 <fields> 表示在每个副本上运行 field => field 函数时应该唯一的值。

当第一配置中的 <field> 的元素的顺序不同于第二配置中的顺序时,保留第二配置。

js 复制代码
const { mergeWithCustomize, unique } = require("webpack-merge");

const output = mergeWithCustomize({
  customizeArray: unique(
    "plugins",
    ["HotModuleReplacementPlugin"],
    (plugin) => plugin.constructor && plugin.constructor.name,
  ),
})(
  {
    plugins: [new webpack.HotModuleReplacementPlugin()],
  },
  {
    plugins: [new webpack.HotModuleReplacementPlugin()],
  },
);

// 输出现在只包含一个 HotModuleReplacementPlugin
// 并且它将是最后一个插件实例。

mergeWithRules

为了支持高级合并需求(即在加载器中合并), mergeWithRules 包含了额外的语法,允许您匹配字段并应用策略进行匹配。考虑下面的完整示例:

js 复制代码
const a = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [{ loader: "style-loader" }, { loader: "sass-loader" }],
      },
    ],
  },
};
const b = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: "style-loader",
            options: {
              modules: true,
            },
          },
        ],
      },
    ],
  },
};
const result = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: "style-loader",
            options: {
              modules: true,
            },
          },
          { loader: "sass-loader" },
        ],
      },
    ],
  },
};

assert.deepStrictEqual(
  mergeWithRules({
    module: {
      rules: {
        test: "match",
        use: {
          loader: "match",
          options: "replace",
        },
      },
    },
  })(a, b),
  result,
);

它的工作方式是,您应该使用与您的配置结构匹配的 match (或 CustomizeRule.Match ,如果您使用 TypeScript )来注释字段,然后使用特定的策略来定义应如何转换特定字段。如果匹配项不存在于规则之上,则它将自动应用该规则。

支持的注释:

  • matchCustomizeRule.Match )-可选的匹配器,根据相似性将合并行为限制在特定部分(想想DOM或jQuery选择器)
  • appendCustomizeRule.Append )-双列直插式项目
  • prependCustomizeRule.Prepend )-预先准备项目
  • replaceCustomizeRule.Replace )-替换项目
  • mergeCustomizeRule.Merge )-合并对象(浅合并)

支持使用 TypeScript

webpack-merge 支持开箱即用的 TypeScript。你应该像下面这样把 webpack 中的 Configuration type传递给它:

js 复制代码
import { Configuration } from "webpack";
import { merge } from "webpack-merge";

const config = merge<Configuration>({...}, {...});

源码分析

都到 webpack-merge 的家门口,不进去看看,心有不甘啊,走,感兴趣的一起去看看源码的大概实现。

因源码要占用的文章篇幅挺多,就不在此粘贴,自行打开,对照下边的解释辅助阅读源码即可。

index.ts

1、源码地址第一部分(300行左右):github.com/survivejs/w...

该模块提供了一套用于合并配置对象的工具函数,支持自定义合并规则,并且具有一些用于数组和对象合并的辅助函数。具体的如下:

导入的模版作用:

  • wildcard: 从 "wildcard" 模块导入,该模块用于通配符匹配。
  • mergeWith: 从相对路径 "./merge-with" 导入,这是一个自定义的合并函数。
  • joinArrays: 从相对路径 "./join-arrays" 导入,是用于合并数组的函数。
  • unique: 从相对路径 "./unique" 导入,是用于获取唯一值的函数。
  • CustomizeRule, CustomizeRuleString, ICustomizeOptions, Key: 从 "./types" 导入一些类型定义。
  • isPlainObject, isSameCondition, isUndefined: 从 "./utils" 导入一些辅助函数。

函数 merge:

  • 接受一个或多个配置对象,并将它们合并为一个新的配置对象。
  • 使用了 mergeWithCustomize 函数。

函数 mergeWithCustomize:

  • 接受一个 options 对象,并返回一个函数,该函数用于合并配置对象。
  • 使用了 mergeWith 函数,其中包含一些自定义的配置选项。(后边再说这个函数)

函数 customizeArray:

  • 接受一个规则对象,返回一个函数,该函数用于自定义数组的合并规则。

类型 Rules:

  • 用于描述合并规则的类型,规定了一些自定义规则的格式。

函数 mergeWithRules:

  • 接受一个 Rules 对象,返回一个用于合并配置对象的函数,该函数使用了 mergeWithCustomize

函数 mergeWithRule:

  • 用于处理特定的合并规则,包括 MatchAppendMergePrependReplace 等。

函数 mergeIndividualRule:

  • 用于处理单个规则的合并,根据不同的规则类型进行不同的操作。

函数 customizeObject:

  • 接受一个规则对象,返回一个函数,该函数用于自定义对象的合并规则。

merge-with.ts

2、源码第二部分(26行代码):github.com/survivejs/w...

这段源代码主要实现了一个合并对象数组的功能,允许通过自定义函数 customizer 对每个键值进行合并操作。具体如下:

mergeWith 函数:

  • 接受一个对象数组 objects 和一个自定义合并函数 customizer
  • 通过解构赋值获取数组的第一个元素 first 和剩余的元素 rest
  • 使用 forEach 遍历 rest 数组,依次调用 mergeTo 函数进行合并。
  • 返回合并后的结果。

mergeTo 函数:

  • 接受两个对象 ab,以及一个自定义合并函数 customizer
  • 创建一个空对象 ret,用于存储合并结果。
  • 使用 Object.keys 获取 ab 的所有键,并通过 concat 合并为一个数组。
  • 遍历合并后的键数组,对每个键调用 customizer 函数,获取合并后的值,并将结果存储在 ret 对象中。
  • 如果 customizer 返回 undefined,则使用 a 对象中相应键的值。
  • 返回合并后的对象 ret

join-arrays.ts

3、源码第三部分(60行代码):github.com/survivejs/w...

这个模块提供了一个可定制的数组合并函数,通过参数传递不同的定制规则,可以对数组的合并行为进行个性化配置。这样的设计使得合并逻辑更加灵活,并允许在特定场景下定制化处理。

导出 joinArrays 函数:

  • 接受一个参数对象,包含 customizeArraycustomizeObject、和 key
  • 返回一个函数 _joinArrays,这是实际执行数组合并的函数。

函数 _joinArrays:

  • 接受两个数组 ab,以及一个键 k
  • 如果提供了 key 参数,则创建新的键 newKey,否则使用原有的键 k
  • 如果 ab 都是函数,则返回一个新的函数,该函数调用 _joinArrays 对两个函数的结果进行合并。
  • 如果 ab 都是数组,且提供了 customizeArray 函数,则调用 customizeArray 并返回其结果,否则直接合并两个数组。
  • 如果 b 是正则表达式,则直接返回 b
  • 如果 ab 都是普通对象,且提供了 customizeObject 函数,则调用 customizeObject 并返回其结果,否则使用 mergeWith 合并两个对象,递归调用 joinArrays 处理嵌套数组。
  • 如果 b 是普通对象,则使用 cloneDeepb 进行深度克隆。
  • 如果 b 是数组,则创建一个新数组,包含 b 中的所有元素。

unique.ts

4、源码第四部分(24行代码):github.com/survivejs/w...

这部分的目的是在合并数组时,根据指定的键保持唯一性。它通过使用 Set 来追踪已有的键值,对合并后的数组进行处理,确保指定的键值在合并后的数组中是唯一的。

函数 mergeUnique:

  • 接受三个参数:
    • key: 一个字符串,表示用于判断唯一性的键。
    • uniques: 一个字符串数组,表示要保持唯一性的键的集合。
    • getter: 一个函数,接受对象 a 并返回一个字符串,表示如何获取 a 对象中的键值。
  • 返回一个合并函数,这个函数用于实际执行合并,并保持指定键的唯一性。

合并函数:

  • 接受两个数组 ab,以及一个键 k
  • 判断 k 是否等于传入的 key
  • 如果等于,执行以下步骤:
    • uniques 转换为一个 Set,以便快速检查某个键是否在唯一性集合中。
    • ab 合并成一个数组。
    • 对每个对象执行以下操作:
      • 使用 getter 函数获取对象的键值。
      • 创建一个新对象,包含 keyvalue 两个属性,其中 key 为获取的键值,value 为原对象。
      • 如果 uniquesSet 中已经存在该键值,则将 key 设置为键值,否则仍然使用原对象的键值。
    • 将上述处理后的对象数组转换为一个 Map 对象,以键值对的形式存储。
    • 通过 Map 的值获取新的对象数组。
  • 返回新的对象数组。

对比 Object.assign

Object.assign() 静态方法将一个或者多个源对象 中所有可枚举自有属性复制到目标对象,并返回修改后的目标对象。

语法

js 复制代码
Object.assign(target, ...sources)
  • target 需要应用源对象属性的目标对象,修改后将作为返回值。

  • sources 一个或多个包含要应用的属性的源对象。

js 复制代码
let dog = {
  color: 'yellow',
  habit: ['run', 'eat'],
  behavior: {
      character: 'lively',
      age: 7,
  },
};
let cat = {
  color: '#000',
  habit: ['sleep', 'eat'],
  behavior: {
      character: 'cute',
      age: 8,
  },
};
console.log(Object.assign(dog, cat));
// {
//   color: '#000',
//   habit: [ 'sleep', 'eat' ],
//   behavior: { character: 'cute', age: 8 }
// }

Tips:小技巧:如果只是想将两个或多个对象的属性合并到一起,不改变原有对象的属性,可以用一个空的对象作为target对象。

js 复制代码
Object.assign({}, target, source);

更多的使用技巧

更多的 Object.assign 使用方法规则,可移步到 MDN

算了,估计大家都懒得去移步。我还是把好东西拿过来,再次和大家一起学习一下吧,因为我看了,发现真的很好,有的都是平时没有注意到的东西,确实好,所以下面就 copy 过来,好好学一下。

js 复制代码
const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target);
// Expected output: Object { a: 1, b: 4, c: 5 }

console.log(returnedTarget === target);
// Expected output: true
// 注意这里

假如源对象是一个对象的引用,它仅仅会复制其引用值。

js 复制代码
const obj1 = { a: 0, b: { c: 0 } };
const obj2 = Object.assign({}, obj1);
console.log(obj2); // { a: 0, b: { c: 0 } }

obj1.a = 1;
console.log(obj1); // { a: 1, b: { c: 0 } }
console.log(obj2); // { a: 0, b: { c: 0 } }

obj2.a = 2;
console.log(obj1); // { a: 1, b: { c: 0 } }
console.log(obj2); // { a: 2, b: { c: 0 } }

obj2.b.c = 3;
console.log(obj1); // { a: 1, b: { c: 3 } }
console.log(obj2); // { a: 2, b: { c: 3 } }

// 深拷贝
const obj3 = { a: 0, b: { c: 0 } };
const obj4 = JSON.parse(JSON.stringify(obj3));
obj3.a = 4;
obj3.b.c = 4;
console.log(obj4); // { a: 0, b: { c: 0 } }
js 复制代码
const o1 = { a: 1 };
const o2 = { b: 2 };
const o3 = { c: 3 };

const obj = Object.assign(o1, o2, o3);
console.log(obj); // { a: 1, b: 2, c: 3 }
console.log(o1); // { a: 1, b: 2, c: 3 },目标对象本身发生了变化

拷贝 Symbol 类型属性

js 复制代码
const o1 = { a: 1 };
const o2 = { [Symbol("foo")]: 2 };

const obj = Object.assign({}, o1, o2);
console.log(obj); // { a : 1, [Symbol("foo")]: 2 } (cf. bug 1207182 on Firefox)
Object.getOwnPropertySymbols(obj); // [Symbol(foo)]

原型链上的属性和不可枚举的属性不能被复制

js 复制代码
const obj = Object.create(
  // foo 在 obj 的原型链上
  { foo: 1 },
  {
    bar: {
      value: 2, // bar 是不可枚举的属性
    },
    baz: {
      value: 3,
      enumerable: true, // baz 是可枚举的自有属性
    },
  },
);

const copy = Object.assign({}, obj);
console.log(copy); // { baz: 3 }

基本类型会被封装为对象

js 复制代码
const v1 = "abc";
const v2 = true;
const v3 = 10;
const v4 = Symbol("foo");

const obj = Object.assign({}, v1, null, v2, undefined, v3, v4);
// 基本类型将被封装,null 和 undefined 将被忽略。
// 注意,只有字符串封装对象才拥有可枚举的自有属性。
console.log(obj); // { "0": "a", "1": "b", "2": "c" }

异常会中断后续的复制

js 复制代码
const target = Object.defineProperty({}, "foo", {
  value: 1,
  writable: false,
}); // target.foo 是一个只读属性

Object.assign(target, { bar: 2 }, { foo2: 3, foo: 3, foo3: 3 }, { baz: 4 });
// TypeError: "foo" is read-only
// 这个异常会在给 target.foo 赋值的时候抛出

console.log(target.bar); // 2,第一个源对象成功复制。
console.log(target.foo2); // 3,第二个源对象的第一个属性也成功复制。
console.log(target.foo); // 1,异常在这里被抛出
console.log(target.foo3); // undefined,属性赋值已经结束,foo3 不会被复制
console.log(target.baz); // undefined,第三个源对象也不会被复制

拷贝访问器

js 复制代码
const obj = {
  foo: 1,
  get bar() {
    return 2;
  },
};

let copy = Object.assign({}, obj);
console.log(copy);
// { foo: 1, bar: 2 }
// copy.bar 的值是 obj.bar 的 getter 的返回值。

// 这是一个将完整描述符复制的赋值函数
function completeAssign(target, ...sources) {
  sources.forEach((source) => {
    const descriptors = Object.keys(source).reduce((descriptors, key) => {
      descriptors[key] = Object.getOwnPropertyDescriptor(source, key);
      return descriptors;
    }, {});

    // 默认情况下,Object.assign 也会复制可枚举的 Symbol 属性
    Object.getOwnPropertySymbols(source).forEach((sym) => {
      const descriptor = Object.getOwnPropertyDescriptor(source, sym);
      if (descriptor.enumerable) {
        descriptors[sym] = descriptor;
      }
    });
    Object.defineProperties(target, descriptors);
  });
  return target;
}

copy = completeAssign({}, obj);
console.log(copy);
// { foo:1, get bar() { return 2 } }

场景

webpack.config.js

js 复制代码
const commonConfig = { ... };
const productionConfig = { ... };
const developmentConfig = { ... };

module.exports = (env, args) => {
  switch(args.mode) {
    case 'development':
      return merge(commonConfig, developmentConfig);
    case 'production':
      return merge(commonConfig, productionConfig);
    default:
      throw new Error('No matching configuration was found!');
  }
}

项目中的真实使用场景,大家也可以看看自己的 webpack 项目,是否有类似的。

参考资料

  1. github.com/survivejs/w...
  2. developer.mozilla.org/zh-CN/docs/...
相关推荐
Martin -Tang36 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发36 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁2 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂2 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐3 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成5 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽5 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新6 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_7 小时前
【Linux】多线程(概念,控制)
linux·运维·前端