Skip to content

Vue Mixin 合并策略

核心结论

Vue 的 mixin 不是"混入一段代码",是对组件选项对象做分层合并

入口函数 mergeOptions(parent, child, vm) 用策略模式对每种 option 类型走不同的合并规则。组件自身选项优先级最高,mixin 次之,全局 mixin 最低。同名 key 的行为完全取决于 option 类型——data 是递归深合并,hooks 是数组拼接,methods 是直接覆盖。

这个合并发生一次:组件定义时(Vue.extend / defineComponent),不在运行时。


mergeOptions 入口

Vue2 源码位置:src/core/util/options.js

js
export function mergeOptions(parent, child, vm) {
  // 1. 规范化 props/inject/directives
  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  // 2. 递归处理 extends 和 mixins(先 extends,后 mixins)
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0; i < child.mixins.length; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  // 3. 逐字段按策略合并
  const options = {}
  let key
  for (key in parent) { mergeField(key) }
  for (key in child) {
    if (!hasOwn(parent, key)) { mergeField(key) }
  }
  function mergeField(key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

几个关键点:

  • child._base 检查防止重复合并。mergeOptions 的结果有 _base 标记,递归时跳过已合并的对象。
  • extends 先于 mixins 合并。这意味着 mixins 中同名 key 会覆盖 extends。
  • 策略函数从 strats 表查找,未注册的 key 走 defaultStrat(child 覆盖 parent)。

六种合并策略

1. data / provide:递归深合并

js
strats.data = function(parentVal, childVal, vm) {
  if (!vm) {
    // Vue.extend / Vue.mixin 场景:两者必须是函数
    if (childVal && typeof childVal !== 'function') {
      warn('The "data" option should be a function...')
      return parentVal
    }
    return mergeDataOrFn(parentVal, childVal)
  }
  // 组件实例化场景:传入 vm
  return mergeDataOrFn(parentVal, childVal, vm)
}

function mergeDataOrFn(parentVal, childVal, vm) {
  // 返回一个函数,调用时才真正合并
  return function mergedDataFn() {
    const parentData = parentVal.call(this, this)
    const childData = childVal.call(this, this)
    return mergeData(parentData, childData)
  }
}

function mergeData(to, from) {
  for (let key in from) {
    const fromVal = from[key]
    const toVal = to[key]
    if (!hasOwn(to, key)) {
      set(to, key, fromVal)  // 通过 Vue.set 保证响应式
    } else if (isPlainObject(toVal) && isPlainObject(fromVal)) {
      mergeData(toVal, fromVal)  // 两者都是纯对象 → 递归
    }
    // 否则以 to(组件自身)为准,from 被丢弃
  }
  return to
}

data 合并的特点:

  • 不立即求值mergeDataOrFn 返回一个包装函数,在组件实例化时调用 data.call(vm) 后才合并。
  • 同 key 时组件优先。mixin 的 data key 如果在组件 data 中已存在,被静默丢弃。
  • 嵌套对象递归合并{ user: { name: 'A' } }{ user: { age: 20 } } 会合并成 { user: { name: 'A', age: 20 } }
  • 通过 Vue.set 添加新 key。确保 mixin 新增的 data 属性也是响应式的。

2. 生命周期 hooks:数组连接

js
function mergeHook(parentVal, childVal) {
  const res = childVal
    ? parentVal
      ? parentVal.concat(childVal)    // 两者都有 → 拼成数组
      : Array.isArray(childVal)
        ? childVal                     // 只有 child → 直接返回
        : [childVal]
    : parentVal
  return res ? dedupeHooks(res) : res
}

LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})

LIFECYCLE_HOOKS 列表:

js
['beforeCreate', 'created', 'beforeMount', 'mounted',
 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed',
 'activated', 'deactivated', 'errorCaptured', 'serverPrefetch']

执行顺序:全局 mixin → 局部 mixins(按数组顺序)→ extends → 组件自身。

text
// 三个来源的 created 钩子执行顺序:
mixin.created()        // 先
extendsComp.created()  // 中
this.created()         // 后

callHook(vm, 'created') 遍历合并后的数组逐个调用,不是 Promise.all,是同步串行。一个钩子抛错不影响后续钩子执行——每个钩子都被 try/catch 包裹。

3. methods / props / computed / inject:覆盖

js
strats.methods = 
strats.props =
strats.computed = 
strats.inject = function(parentVal, childVal) {
  if (!parentVal) return childVal
  const ret = Object.create(null)
  extend(ret, parentVal)    // 先拷贝 parent
  if (childVal) extend(ret, childVal)  // child 覆盖同名 key
  return ret
}

child 覆盖 parent,无警告。这是 mixin 命名冲突最危险的来源——两个 mixin 定义了同名 method,后合并的静默胜出,没有报错。

4. components / directives / filters:原型链

js
function mergeAssets(parentVal, childVal) {
  const res = Object.create(parentVal || null)
  if (childVal) {
    // 非生产环境检查同名覆盖并 warn
    for (let key in childVal) {
      res[key] = childVal[key]
    }
  }
  return res
}

用原型链而非浅拷贝。res.__proto__ === parentVal,查找时先找 child 再沿原型链找 parent。这样全局注册的组件对所有子组件可见,但子组件可以同名覆盖。

5. watch:数组连接(不去重)

js
strats.watch = function(parentVal, childVal) {
  // 同 key 的 watcher 合并为数组,不覆盖
  if (!childVal) return Object.create(parentVal || null)
  if (!parentVal) return childVal
  const ret = {}
  extend(ret, parentVal)
  for (let key in childVal) {
    let parent = ret[key]
    let child = childVal[key]
    if (parent && !Array.isArray(parent)) {
      parent = [parent]
    }
    ret[key] = parent
      ? parent.concat(child)
      : Array.isArray(child) ? child : [child]
  }
  return ret
}

同一 key 的多个 watcher 被合并成数组,按合并顺序依次触发。组件自身的 watcher 不覆盖 mixin 的 watcher——这和 methods 的行为不同。

6. defaultStrat:child 优先

js
const defaultStrat = function(parentVal, childVal) {
  return childVal === undefined ? parentVal : childVal
}

覆盖 eltemplatepropsData 等未注册策略的 option。child 有值就用 child,否则回退到 parent。


全局 mixin vs 局部 mixin

全局 mixin:Vue.mixin()

js
// src/core/global-api/mixin.js
export function initMixin(Vue) {
  Vue.mixin = function(mixin) {
    this.options = mergeOptions(this.options, mixin)
    return this
  }
}

Vue.options 是 Vue 构造函数的静态属性,保存全局基准配置。全局 mixin 把内容合并到 Vue.options,之后每个组件实例化时都会用 Vue.options 作为 parent 参数传入 mergeOptions

所以全局 mixin 影响之后创建的所有组件实例。典型用途是全局注入 $store$router 这类实例方法——但 Vue2 的 vue-router 和 vuex 不是用 mixin 实现的,它们用的是 Vue.prototype 或者在 beforeCreate 中直接挂载属性。

js
// vue-router 2.x 的实际做法(不是 mixin)
Vue.mixin({
  beforeCreate() {
    if (this.$options.router) {
      this._routerRoot = this
      this._router = this.$options.router
      this._router.init(this)
      Vue.util.defineReactive(this, '_route', this._router.history.current)
    }
  }
})

vue-router 用全局 mixin 在 beforeCreate 中安装 router 实例——这是少数合理使用全局 mixin 的场景。

局部 mixin:组件 mixins 选项

js
export default {
  mixins: [formMixin, permissionMixin],
  data() { /* ... */ }
}

局部 mixin 在 mergeOptions 的步骤 2 被递归展开:

text
初始 parent = Vue.options(全局基准)
  → mergeOptions(parent, component.extends)
    → mergeOptions(parent, mixins[0])
      → mergeOptions(parent, mixins[1])
        → mergeOptions(parent, component自身options)

数组顺序决定优先级:mixins[1] 覆盖 mixins[0] 的同名 key,组件自身覆盖所有 mixin。


Vue3 的处理

Vue3 保留了 mixin 支持(Options API 兼容),但合并逻辑重写。入口在 packages/runtime-core/src/componentOptions.tsapplyOptions

js
// Vue3 简化逻辑
function applyOptions(instance, options, ...) {
  // 1. 先递归处理 mixins 数组(从右向左合并到 ctx)
  if (options.mixins) {
    for (let i = 0; i < options.mixins.length; i++) {
      applyOptions(instance, options.mixins[i], ...)
    }
  }
  // 2. 再处理 extends
  if (options.extends) {
    applyOptions(instance, options.extends, ...)
  }
  // 3. 最后处理组件自身选项
  // data / computed / methods / watch / lifecycle hooks ...
}

Vue3 的核心变化:

  • 不再有集中的 mergeOptions 输出一个合并后的 options 对象。
  • 改为 applyOptions 直接操作组件实例——按 mixins → extends → 自身选项的顺序逐层 apply 到实例上。
  • data 的合并逻辑保留:mixin data 中组件没有的 key 会通过 set() 添加到组件实例。
  • Composition API 的 setup() 与 mixins 可以共存——setup() 中的 ref / reactive 和 mixin 合并后的 data 都会出现在组件实例上。但别这么做。

为什么 mixin 被放弃

1. 属性来源不透明

读这段代码时你无法判断 this.count 从哪里来:

js
export default {
  mixins: [pagination, search, permission],
  methods: {
    handleClick() {
      this.count++       // 是 data?computed?哪个 mixin 注入的?
      this.fetchData()   // 同名 method 被哪个 mixin 覆盖了?
    }
  }
}

必须逐个打开 mixin 文件查看。三个 mixin 各 200 行,排查一个属性来源的时间可以很长。

2. 隐式依赖

mixin 可能依赖组件提供某个 data 或 method:

js
// formMixin.js
export default {
  methods: {
    submit() {
      if (!this.validate()) return   // 假设组件有 validate()
      this.loading = true             // 假设组件有 loading
      api.submit(this.formData)       // 假设组件有 formData
    }
  }
}

这些依赖没有声明、没有类型检查、没有编译提示。重构组件时删掉了 formDatasubmit() 静默失败——运行时访问 undefined 的属性不报错。

3. 命名冲突无提示

js
const mixinA = { methods: { log(msg) { console.log('[A]', msg) } } }
const mixinB = { methods: { log(msg) { console.log('[B]', msg) } } }

export default {
  mixins: [mixinA, mixinB]
  // this.log('test') → '[B] test'
  // mixinA 的 log 被静默覆盖,不报错不警告
}

4. 无法重命名

Composition API 的解构天然支持别名:

js
const { count: pageCount } = usePagination()
const { count: searchCount } = useSearch()

mixin 做不到——注入的属性名就是最终名。


Mixin → Composable 迁移

典型 mixin 模式

js
// 旧:searchMixin.js
export default {
  data() {
    return { keyword: '', results: [], loading: false }
  },
  watch: {
    keyword: {
      handler: 'search',
      immediate: false
    }
  },
  methods: {
    async search() {
      this.loading = true
      this.results = await api.search(this.keyword)
      this.loading = false
    }
  }
}

对应 composable

js
// 新:useSearch.js
import { ref, watch } from 'vue'

export function useSearch(api) {
  const keyword = ref('')
  const results = ref([])
  const loading = ref(false)

  watch(keyword, async (val) => {
    if (!val) return
    loading.value = true
    results.value = await api.search(val)
    loading.value = false
  })

  function clearKeyword() {
    keyword.value = ''
    results.value = []
  }

  return { keyword, results, loading, clearKeyword }
}

// 组件中
const { keyword, results, clearKeyword } = useSearch(api)

差异:

维度mixincomposable
依赖声明隐式(假设组件有什么)显式(函数参数)
来源追踪搜所有 mixin 文件函数调用即声明
命名冲突静默覆盖解构时重命名
TypeScript需要额外声明自动推导
作用域组件实例全局函数闭包隔离
测试需要挂载组件可独立单测

迁移原则

  • 同一个业务关注点的逻辑 → 一个 composable。
  • composable 的参数化程度要高于 mixin。mixin 通常写死 API 调用,composable 应该把依赖作为参数传入。
  • beforeRouteLeave 这类与组件生命周期耦合的逻辑,放在 onBeforeRouteLeave 中比 mixin 更清晰——至少你能看到哪个 composable 注册了路由守卫。

仍然可以(小心)使用 mixin 的场景

  • Options API 老项目中已有大量 mixin 基础设施。贸然迁移的风险大于收益。渐进式:新功能用 composable,不改动已有 mixin。
  • 全局注入少量实例属性。Vue2 中 Vue.mixin({ beforeCreate() { ... } }) 用于安装插件(如 router)是合理的——这是一次性注入,不存在命名冲突问题。
  • 非逻辑复用的代码共享。如全局注册组件(Vue.component)底层是 mergeAssets 策略,原型链式查找天然适合这类场景。

一个容易被忽略的细节

mergeDataOrFn 返回的是一个函数,不是合并后的 data 对象:

js
// mergeOptions 阶段
const mergedData = mergeDataOrFn(parentData, childData)
// mergedData 是一个函数

// 组件实例化时才调用(src/core/instance/state.js → initData)
const data = mergedDataFn.call(vm, vm)

这意味着每次创建组件实例时,data 合并都会重新执行一次。如果 mixin 的 data 函数内部做了昂贵计算,每个组件实例都会承担这个开销。composable 的 ref() 调用同样在每次 setup() 时执行,行为一致——但至少显式可见。


与 React 类比

React 没有 mixin 等价物。React 社区在 2016 年就已经抛弃了 React.createClass 的 mixins——原因和 Vue 完全相同:隐式依赖、命名冲突、来源不透明。React 的答案是 HOC → render props → Hooks,演进路径和 Vue 的 mixin → composable 一致。

Create React Class mixins 在 React 15.5 被标记为 deprecated,React 16 正式移除。Vue 的动作慢了 5 年——mixin 在 Vue3 中仍然能用,但不推荐新代码使用。


补充追踪入口

  • Vue2:src/core/util/options.jsmergeOptionsmergeDataOrFnmergeDatamergeHook
  • Vue2:src/core/global-api/mixin.jsinitMixin
  • Vue3:packages/runtime-core/src/componentOptions.tsapplyOptions
  • Vue3:packages/runtime-core/src/component.tssetupComponent 中调用 applyOptions
  • 策略注册:src/core/config.jsoptionMergeStrategies(允许用户自定义合并策略)