Vue Mixins 合并策略踩坑:命名冲突、来源不透明与替代方案

一个列表页的重置按钮点下去,页码只回到了一半该回的位置——不是完全没反应,而是page变了、但和它绑定的另一个筛选字段没变。组件自己的代码里,reset方法写得清清楚楚,逻辑也没错。问题出在这个组件同时混入了两个 Mixin,其中一个也定义了同名的reset,最终执行的是数组里排在后面的那个版本,跟组件自身那份撞了名,行为却按合并规则悄悄地被换掉了。

这类问题在用 Mixin 复用逻辑的项目里迟早会遇到一次。Vue 2.6.x 的 Mixin 机制写起来很顺手:抽一段公共逻辑,几个组件混入就能复用,不需要多余的封装。可选项合并这套规则一旦叠加了两三层,行为就不再是「读代码就能确定」的事情,得先弄清楚合并优先级才能判断到底谁生效了。

合并规则先说清楚

Vue 的mixins选项在合并组件对象时,不同字段走的是不同策略:

  • data:递归合并,键冲突时以组件自身的为准。
  • methodscomputedcomponentsfilters:合并成一个对象,键冲突时组件自身覆盖 Mixin。
  • 生命周期钩子(createdmounted等):不是覆盖关系,是合并成数组,按 Mixin 声明顺序在前、组件自身在后,依次全部执行。
  • watch:和生命周期一样,同名的 watcher 会合并成数组,全部触发,不存在互相覆盖。

这套规则本身在文档里写得明白,单独看没有歧义。问题出在“组件自身覆盖 Mixin”这条规则的隐蔽性——它只在键名冲突时才会体现出效果,而键名冲突这件事从当前组件的代码里根本看不出来。拿开头那个reset举例:

1const pageMixin = {
2  data() {
3    return {
4      page: 1,
5    };
6  },
7  methods: {
8    reset() {
9      this.page = 1;
10    },
11  },
12};
13
14export default {
15  mixins: [pageMixin],
16  data() {
17    return {
18      page: 10,
19    };
20  },
21  methods: {
22    reset() {
23      this.page = 10;
24    },
25  },
26};

组件自身的reset会覆盖pageMixin里的reset,最终生效的是组件这份,data合并后page也是 10。这个例子里两边逻辑相似,看不出问题。真实场景里,pageMixinreset可能同时重置了分页和排序两个字段,组件自己写的reset只想覆盖分页那部分逻辑、顺手也叫了reset这个名字——组件开发者写的时候完全没打算覆盖谁,纯粹是命名撞车,结果排序字段的重置就这么被吞掉了。排查这种问题最麻烦的地方在于,看当前组件代码不会怀疑到 Mixin 头上,因为代码表现「正常」,只是少做了一半事。最后是靠全局搜索reset这个方法名,才看到还有一个 Mixin 里也定义了它。

data的合并规则值得再展开说一层。递归合并意味着如果两边都返回了同名的对象字段,Vue 不会简单地整体替换,而是往下一层继续合并同名的子字段:

1const filterMixin = {
2  data() {
3    return {
4      query: {
5        keyword: '',
6        status: 'all',
7      },
8    };
9  },
10};
11
12export default {
13  mixins: [filterMixin],
14  data() {
15    return {
16      query: {
17        status: 'active',
18      },
19    };
20  },
21};

合并之后query{ keyword: '', status: 'active' }——keyword来自 Mixin,status是组件自身的覆盖了 Mixin 里的默认值。这一步递归发生在对象内部,数组不会做这种递归合并,数组类型的字段只会整体按"组件覆盖 Mixin"处理,不会去合并数组元素。团队里有人一开始以为query这种嵌套对象在合并时会整体被组件那份取代,照着这个错误理解调试了半天,才发现keyword字段其实是从 Mixin 里"漏"进来的,只是因为两边都没显式声明keyword就相安无事,一旦组件这边也想显式清空keyword,必须显式写出来,不能指望"整体覆盖"帮忙清掉。

methodscomputed这类合并成一个对象的字段,冲突时是整体覆盖,不存在data这种递归合并;换句话说,同名方法只有"组件版本生效"和"Mixin 版本生效"两种结果,不会出现"各取一半"的中间状态。这也是开头reset那个问题的根源——如果reset合并规则像data一样是递归的,两边的逻辑说不定还能拼到一起,但方法合并没有这种"各留一部分"的余地,冲突了就是整个方法被替换,没被替换的那份逻辑连痕迹都不会留下。

如果混入的是多个 Mixin 之间同名,规则是数组里后声明的覆盖前面的,和"组件覆盖 Mixin"是两条不同的优先级,叠在一起判断起来更绕:组件本身 > 后面的 Mixin > 前面的 Mixin。这层优先级平时不会记在脑子里,只有出问题时才会去查文档确认。

生命周期钩子不是覆盖,是全部执行

reset这类方法冲突至少还有个明确的胜负——组件的赢,Mixin 的被吃掉。生命周期钩子完全是另一套规则,很容易被拿methods的思路去套,套错了反而更难查。写一段能实际验证的例子:

1const logMixin = {
2  created() {
3    console.log('mixin created');
4  },
5  mounted() {
6    console.log('mixin mounted');
7  },
8};
9
10const trackMixin = {
11  created() {
12    console.log('track mixin created');
13  },
14};
15
16export default {
17  mixins: [logMixin, trackMixin],
18  created() {
19    console.log('component created');
20  },
21  mounted() {
22    console.log('component mounted');
23  },
24};

这个组件挂载时,控制台按顺序打出:

mixin created
track mixin created
component created
mixin mounted
component mounted

created被调用了三次,不是"组件的那次生效、其余两次被覆盖"。顺序也很规律:mixins数组里排在前面的 Mixin 先执行,数组里排在后面的 Mixin 接着执行,组件自身的钩子永远排在最后。mounted同理,只是这个例子里只有logMixin和组件自己定义了它,trackMixin没有就不会被调用。

这条规则单独看很直观,真正容易踩坑的地方是混入了多个都做初始化的 Mixin 时,顺序依赖会被隐藏起来。有一次两个 Mixin 都在created里往this上挂计算属性用到的中间状态,一个假定另一个已经初始化完,写的时候两人各自单独测试都没问题,合到同一个组件后,因为在数组里的声明顺序不对,其中一个读到的还是undefined。这种问题在methods合并规则下不会出现——方法覆盖之后只有一份逻辑在跑,不存在"谁先跑、谁后跑"的时序问题;生命周期钩子合并成数组之后,等于是把顺序耦合引入进来,而这层耦合只体现在mixins数组的书写顺序上,代码之外没有任何显式标注。

watch选项也是同样的数组合并规则。如果 Mixin 和组件都对同一个字段声明了 watcher,两个 watcher 函数都会在字段变化时被调用,不存在互相覆盖:

1const watchMixin = {
2  watch: {
3    keyword() {
4      console.log('mixin watch keyword');
5    },
6  },
7};
8
9export default {
10  mixins: [watchMixin],
11  watch: {
12    keyword() {
13      console.log('component watch keyword');
14    },
15  },
16};

keyword变化时两条日志都会打出来。这在做埋点、日志这类"副作用不冲突"的场景里没问题,但如果两个 watcher 都要对同一个字段做写操作——比如都要修正它的值——写操作的先后顺序就会直接决定最终结果,排查起来比methods覆盖更绕,因为现象是"两段逻辑都生效了、但顺序不对",而不是"某段逻辑整个消失了",一开始更容易被当成业务逻辑本身的问题去查。

Mixin 的另一重成本是来源不透明

除了合并规则本身的隐蔽性,Mixin 还有一个更日常的问题:组件里只有一行mixins: [listMixin],但模板里能直接用的字段可能远不止组件自己声明的那些。

1export default {
2  data() {
3    return {
4      loading: false,
5      list: [],
6    };
7  },
8  methods: {
9    async fetchList() {
10      this.loading = true;
11      const result = await this.requestList();
12      this.loading = false;
13
14      if (!result.ok) {
15        this.errorMessage = result.message;
16        return;
17      }
18
19      this.list = result.data;
20    },
21  },
22};

组件里写的是:

1export default {
2  mixins: [listMixin],
3};

模板里却能直接用loadinglistfetchList。第一次读这段代码的人,没有 IDE 跳转或者不熟悉项目里有哪些 Mixin 的话,很难判断这几个字段是组件自己的、来自 Mixin、还是来自另一个更早混入的 Mixin。项目早期 Mixin 数量少,这个问题不明显;Mixin 数量上来之后,一个组件身上叠三四个 Mixin 很常见,这时候要搞清楚某个字段的来源,经常得把用到的 Mixin 逐个打开翻一遍。

Mixin 还有一个容易被忽略的关联特性是extends——extends本质上是"单个来源"的 Mixin,合并规则和mixins基本一致,只是只能指定一个对象或函数组件、优先级也比数组里的 Mixin 更低(extends先合并,mixins数组再合并到组件上,组件自身最后)。项目里有一处用了extends做基础组件,一直没和团队里其他 Mixin 混着用,加进mixins数组做排查时才发现这条优先级链比想象中长一环。

props 也会合并,且冲突时静默失败

props选项同样会参与合并,这一点容易被忽略,因为大部分 Mixin 都只往datamethods里塞东西。如果 Mixin 里也声明了props

1const sizeMixin = {
2  props: {
3    size: {
4      type: String,
5      default: 'medium',
6    },
7  },
8};
9
10export default {
11  mixins: [sizeMixin],
12  props: {
13    size: {
14      type: String,
15      default: 'large',
16    },
17  },
18};

组件自身的size定义覆盖 Mixin 里的,最终生效的默认值是large,这一点和methods的覆盖规则一致。真正麻烦的地方在于父组件传入这个prop时的表现——如果只看组件自身代码,会以为size就是这个组件唯一定义的入口;一旦 Mixin 里也声明了同名props,两边的type校验、validator规则可能不一致,Vue 不会报错提示"这个 prop 被重复定义了",只会静默地用组件自身那份生效。团队里一个基础表单 Mixin 给多个字段都声明了宽松的props定义(不限制type),某个业务组件自己又给同名字段加了严格的type校验,结果传参类型错误时校验没有触发报警,因为组件自身那份定义看起来什么限制都没写,实际生效的是被覆盖前那份宽松定义留下的运行时行为,两份定义谁在真正把关一时说不清楚。排查这类问题得先确认这个prop到底是不是也在某个 Mixin 里声明过,光看组件的props对象容易漏掉。

全局 Mixin:影响面最大,也最容易失控

Vue.mixin()注册的是全局 Mixin,会混入之后创建的每一个组件实例,包括项目里所有的业务组件、甚至一些第三方组件库内部用到的组件。这个 API 存在的场景很窄,基本只适合插件作者给所有组件统一注入某个约定好的行为,比如埋点插件的初始化:

1Vue.mixin({
2  created() {
3    if (this.$options.trackName) {
4      trackPageView(this.$options.trackName);
5    }
6  },
7});

这段代码利用了created生命周期钩子全局混入的特性,只要某个组件的$options上声明了trackName字段,就会自动触发埋点上报,不需要每个页面手动调用。这类场景确实好用——业务组件完全不用感知埋点逻辑的存在,只要加一个约定好的选项字段。

但全局 Mixin 的风险也正是"影响面最大"这四个字。它对项目里的每一个组件生效,包括那些跟埋点毫不相关的基础 UI 组件、甚至组件库内部的私有子组件。之前排查过一次页面卡顿,最后定位到是某个全局 Mixin 里的mounted钩子对document做了一次同步的 DOM 查询,这个操作在业务组件上毫无问题,但页面里用到的第三方表格组件内部渲染了几百个子组件实例,每个实例都要跑一遍这段mounted逻辑,几百次同步 DOM 查询叠加起来就成了明显的卡顿。这类问题定位起来格外费劲,因为全局 Mixin 的注册代码往往只在入口文件里出现一次,业务代码里完全搜不到任何痕迹,得先反应过来"这个卡顿会不会跟全局注册的东西有关"才会去翻main.js团队内部的共识是全局 Mixin 只用来处理跟所有组件都相关、且逻辑足够轻量的横切关注点,业务逻辑一律不往全局 Mixin 里放,需要复用的业务逻辑仍然走局部mixins数组,哪怕要在多个组件里重复写mixins: [xxxMixin]这一行,也比全局生效来得可控。

自定义合并策略

前面提到的所有合并规则——data递归合并、methods覆盖、生命周期钩子转数组——都是 Vue 内置的默认策略,写在Vue.config.optionMergeStrategies这个对象里。这个对象是可以扩展的,如果某个自定义选项想要一套不同于默认覆盖规则的合并逻辑,可以自己注册一个合并函数:

1Vue.config.optionMergeStrategies.trackEvents = function (parentVal, childVal) {
2  const parent = parentVal || [];
3  const child = childVal || [];
4  return parent.concat(child);
5};

这样声明之后,任何组件和它混入的 Mixin 都可以各自声明一个trackEvents数组选项,最终合并结果是两边数组拼接在一起,而不是互相覆盖:

1const trackMixin = {
2  trackEvents: ['mixin_view'],
3};
4
5export default {
6  mixins: [trackMixin],
7  trackEvents: ['component_click'],
8};

组件实例上最终能拿到的是['mixin_view', 'component_click']。这个机制平时用得不算多,团队目前只在一个内部埋点方案里用到——业务组件想在原有埋点事件列表的基础上追加自己的事件,而不是被 Mixin 里预设的默认事件列表整个覆盖掉。知道这个配置项存在的好处是,遇到"合并规则不符合预期"的时候,除了怀疑是自己对默认规则理解错了,也要想到项目里可能有人注册过自定义合并策略,Vue.config.optionMergeStrategies打印出来看一眼就知道。

命名冲突的团队约定:加前缀

排查完开头那次reset命名冲突之后,团队里达成了一个不算严格但确实有效的约定:所有 Mixin 暴露的方法和字段,命名上要能看出"这是哪个 Mixin 提供的",不能用resetloadingfetchData这类过于通用、容易和组件自身逻辑撞车的名字。具体做法是给方法和字段加上和 Mixin 用途相关的前缀,比如分页相关的 Mixin 统一用page前缀:

1const pageMixin = {
2  data() {
3    return {
4      pagePagination: {
5        page: 1,
6        pageSize: 20,
7      },
8    };
9  },
10  methods: {
11    pageReset() {
12      this.pagePagination.page = 1;
13    },
14  },
15};

组件自己要写重置逻辑,就不会再无意间用reset这个名字去撞车,因为 Mixin 暴露的是pageReset,两者一眼就能区分开。这不是什么高深的技术方案,纯粹是命名规范层面的约定,但效果立竿见影——上线之后同类命名冲突的问题基本没再出现过。唯一的成本是要求团队里所有人写 Mixin 时都遵守这条约定,新人入职通常不知道这条隐性规则,得在 code review 里提醒一两次才能养成习惯。相比之下,普通函数和后面会提到的组合式函数天然不存在这个问题——调用方自己决定解构出来的变量叫什么名字,命名冲突在写代码的当下就能看见,不需要靠团队约定去弥补。

判断一段逻辑该不该继续用 Mixin

真正需要判断的问题不是"能不能抽出来",而是抽出来之后依赖关系是不是还看得清。一个组件混入多个 Mixin 之后,回答不了"某个方法是谁提供的""某个生命周期钩子做了什么""这个字段为什么会突然出现",就说明这层抽象已经不透明了。

比较直接的判断方式:看这个 Mixin 暴露了哪些字段和方法、是否要求组件必须实现某个约定方法(比如约定组件必须有一个requestList)、是否会直接修改组件已有的状态、是否包含异步请求或者别的副作用、和项目里其他 Mixin 一起用会不会冲突。这些问题如果只能靠口头约定或者翻源码才能回答,说明该拆了。

普通函数:把隐式依赖变成显式导入

很多原本写成 Mixin 的逻辑,改成普通函数会更清楚。把上面fetchList的请求部分抽成一个纯函数:

1export async function requestJson(url) {
2  try {
3    const response = await fetch(url);
4
5    if (!response.ok) {
6      return { ok: false, message: '请求失败,请稍后重试' };
7    }
8
9    return { ok: true, data: await response.json() };
10  } catch (error) {
11    return { ok: false, message: '网络异常,请检查连接' };
12  }
13}

组件里显式调用:

1import { requestJson } from '@/services/request';
2
3export default {
4  data() {
5    return {
6      list: [],
7      errorMessage: '',
8    };
9  },
10  async created() {
11    const result = await requestJson('/api/list');
12
13    if (!result.ok) {
14      this.errorMessage = result.message;
15      return;
16    }
17
18    this.list = result.data;
19  },
20};

跟 Mixin 比,差别就在"显式"两个字上。requestJson从一个明确的路径 import 进来,鼠标点过去就能跳到定义;返回值结构{ ok, data, message }写在那儿一目了然;组件自己的listerrorMessage也实打实声明在data里。这三件事 Mixin 全是隐式的——得知道有这么个 Mixin、得去翻它内部、还得在脑子里模拟合并后的结果。普通函数把这些都摆到了明面上。

代价是普通函数没法帮你管组件的响应式状态,loadinglist这些字段还得组件自己在data里声明,它也接不进生命周期。这恰恰划出了它的边界:纯逻辑、纯计算、纯请求,普通函数最合适;一旦涉及"要往组件实例上挂响应式状态"或者"要挂生命周期钩子",普通函数就不够用了。

组件组合:把 UI 复用还给组件

有一类 Mixin 其实不是在复用逻辑,是在复用 UI 表现——加载中、空状态、错误提示这些。这类东西更适合抽成组件,而不是 Mixin。之前有个listMixin,里面塞了 loading 状态、空状态判断、错误文案、重试按钮逻辑,混进了十几个列表页,一开始接入很快。后来要改空状态的展示样式,得先确认这十几个页面能不能接受同一套改动,改完还得一个个验证有没有副作用。把展示层抽成一个StateView组件之后,状态该长什么样集中在一处维护:

1<StateView :loading="loading" :error="errorMessage" :empty="list.length === 0">
2  <UserList :items="list" />
3</StateView>

StateView只负责展示状态,页面只负责准备数据。改一处全部生效,比把loadingerrorMessagefetchList全部混进组件更容易控制影响范围。判断一段 Mixin 该往哪个方向拆,简单的标准是:主要在复用"长什么样"就抽组件,主要在复用"怎么算、怎么请求"就抽函数,两样都占的 Mixin,先把这两部分拆开再说——Mixin 之所以让人觉得难治理,很大程度就是因为它把这两类完全不同性质的东西糊在了一起。

Composition API 作为另一种解法

@vue/composition-api这个官方兼容插件今年二月就发布了,团队最近在小范围试用,还没有铺开到所有新页面。它对 Mixin 命名冲突这个问题给出的是另一个方向的答案:不靠"混入后自动合并",而是靠显式的函数调用和返回值。同样是列表加载逻辑,写成一个组合式函数:

1import { ref } from '@vue/composition-api';
2
3export function useList(requestList) {
4  const loading = ref(false);
5  const list = ref([]);
6  const errorMessage = ref('');
7
8  async function fetchList() {
9    loading.value = true;
10    errorMessage.value = '';
11
12    const result = await requestList();
13
14    loading.value = false;
15
16    if (!result.ok) {
17      errorMessage.value = result.message;
18      return;
19    }
20
21    list.value = result.data;
22  }
23
24  return {
25    loading,
26    list,
27    errorMessage,
28    fetchList,
29  };
30}

调用方拿到的是useList显式返回的几个字段,想暴露什么、返回什么结构,函数签名上写得明明白白,不会有字段是"混进来的、事先不知道"。多个组合式函数一起用,命名冲突在导入或者解构的那一行就会立刻暴露出来,不需要等到运行时靠合并规则去猜谁盖了谁。上个月 Vue 3 正式发布之后,社区对组合式函数的讨论更多了,但目前团队用的组件库大多还没适配 Vue 3,短期内还是 Vue 2 加这个兼容插件的组合,评估阶段,暂时没有替换现有 Mixin 的计划,只在个别新页面试着用。

Mixin 还能用,但要收窄范围

Mixin 不是不能用,适合它的场景通常是短小、稳定、没有太多隐式依赖的逻辑——统一页面标题、简单埋点、极少量的生命周期增强,继续留着完全没问题。真正该拆的是那种同时带来数据、请求、校验、权限、跳转好几件事的大 Mixin。

比较务实的做法是新逻辑尽量少写新的 Mixin,老的 Mixin 不用急着一次性重构掉,修改某个组件时顺手把用到的依赖显式化,把通用的请求和格式化逻辑先移到普通函数里,把纯 UI 状态抽成组件。不为了"去 Mixin"专门发起一次大范围重写,这类重写的风险相对它换来的收益通常不成比例。