Appearance
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
}覆盖 el、template、propsData 等未注册策略的 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.ts 的 applyOptions:
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
}
}
}这些依赖没有声明、没有类型检查、没有编译提示。重构组件时删掉了 formData,submit() 静默失败——运行时访问 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)差异:
| 维度 | mixin | composable |
|---|---|---|
| 依赖声明 | 隐式(假设组件有什么) | 显式(函数参数) |
| 来源追踪 | 搜所有 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.js→mergeOptions、mergeDataOrFn、mergeData、mergeHook - Vue2:
src/core/global-api/mixin.js→initMixin - Vue3:
packages/runtime-core/src/componentOptions.ts→applyOptions - Vue3:
packages/runtime-core/src/component.ts→setupComponent中调用applyOptions - 策略注册:
src/core/config.js→optionMergeStrategies(允许用户自定义合并策略)
