Using Mixins in Composables in vue2
Considering historical debt, composables and mixins coexisting in vue2 code is a long-term situation, and mixins restrict components from using composables. This article explores how to remove the restrictions mixins place on composables.
Updated
This article is divided into the following parts:
Background
The vue2 legacy codebase contains a large number of mixins, in several different writing styles:
const mixin = {}const mixin = Vue.extends({})@Component class Mixin extends Vue {}
Among the new APIs, the Composition API, with its finer-grained reuse capability, has risen rapidly and swept mixin into the dustbin of history.
However, in order to use the Composition API, a project has to give up mixin, because there is no compatible API to convert between the two. So when you need to reuse the functionality of old code, you either stick with the original Options API or vue-class style, or repackage the mixin, converting the feature into a composable.
Refactoring mixins
Refactoring is naturally the approach that better fits the technology's development, but it also faces quite a few problems:
- Is there enough development time to refactor the
mixins, especially whenmixins are nested or multiplemixins coexist? - After refactoring, what should be done with the original
mixin? If it is deprecated, every component that used thatmixinmust be refactored into aComposition APIcomponent; if it is not deprecated, synchronizing changes involving themixinduring later maintenance becomes another new burden.
Keeping the Options API
Keeping the Options API avoids the problems above, but it also blocks the adoption of the Composition API.
Adopting the Composition API in old modules faces many problems. The most painful scenario is implementing something with the Composition API first, and then, due to a requirement change, needing to reuse a specific capability—which means using a mixin.
The Solution Approach
The root of the problem is that the Composition API leaves no place for mixin. As a result, in a composable, reusing a mixin's lifecycle and similar functionality is still solvable, but if you need to interact with the mixin, you will face situations where values cannot be obtained or type errors are reported.
The
Composition APIreferencesvue3's safety restrictions and also hidesdataand other content that is not used in thetemplate, so it cannot be obtained directly throughgetCurrentInstance.
That being the case, as long as there is some way to convert a mixin's options into the corresponding Composition API through a composable, you can keep using mixins without affecting the old code.
The useMixin() Concept
To parse a mixin's options into a composable, you can start from the following angles:
- How to make
mixins of different writing styles compatible, such asOptions APIandvue-class. - How to handle nested
mixins. - Whether
extends, which plays a similar role tomixin, can also be supported; otherwise, scenarios where amixinnests anextendscannot be supported. - Different configurations require different parsing methods; some APIs can be solved directly through existing
composables, such as lifecycle hooks, so different configurations need to be distinguished and handled separately. - Merging the
mixin's content with the child component's configuration, such as lifecycle, data, and so on. - The
thisbinding problem in components; a new object needs to be constructed to hold themixincontent.
Unified Input
Components in Vue2 come in multiple forms:
const mixin = {}const mixin = Vue.extends({})@Component class Mixin extends Vue {}
Styles 2 and 3 can be treated as one, since both use Vue.extends under the hood, and style 1 is exactly the parameter of Vue.extends. Calling Vue.extends once more unifies the various styles into one, as follows:
const component = (mixin && 'prototype' in mixin && mixin.prototype instanceof Vue)
? mixin
: Vue.extend(mixin)
Vue.extends also enables the merging of options: scenarios where a mixin contains a mixin or an extends can all be merged, and the component's $options is the merged options, so you only need to process that.
Split Content Handling
Handling the this binding
In the Options API, accessing this is a very common operation, and in practice a mixin basically cannot do without this. So fixing this is mandatory; otherwise the mixin will lose a large amount of functionality.
When considering how to fix the binding, first consider what is on this:
- Built-in or later-mounted global variables such as
$createElementand$router optionsconfigurations such asprops,data,inject,computed,watch, andmethods
So you need to construct a new object and integrate the above content into it. Everything the object needs to contain can be obtained from the component generated in the Unified Input section.
Since the actual business has no hard IE11 compatibility requirement, and to simplify complexity and facilitate permission control (such as forbidding modification of computed, props, and so on), proxy can be used for the handling.
import type { AnyObject, Mixin } from '../types'
import { isRef, unref } from 'vue-demi'
/** Create a proxy */
function createProxy(data: Mixin, getObj: () => AnyObject) {
const proxy = new Proxy(data, {
get: makeGetComponentDataValue(getObj),
set: setComponentDataValue,
})
return proxy
}
function makeGetComponentDataValue(getObj: () => AnyObject) {
return (target: Mixin, key: string | symbol) => {
if (typeof key === 'string' && key.startsWith('$')) {
const obj = getObj()
return obj[key]
}
const inject = target.inject ?? {}
if (key in inject) {
return unref(inject[key])
}
const propsData = target.propsData ?? {}
if (key in propsData) {
return unref(propsData[key])
}
const data = target.data ?? {}
if (key in data) {
return unref(data[key])
}
const computed = target.computed ?? {}
if (key in computed) {
return unref(computed[key])
}
const methods = target.methods ?? {}
if (key in methods) {
return unref(methods[key])
}
return undefined
}
}
function setComponentDataValue(target: Mixin, key: string | symbol, value: any) {
const data = target.data
if (key in data) {
if (isRef(data[key])) {
data[key].value = value
} else {
data[key] = value
}
}
return true
}
The usage code is as follows:
// Get the merged options
const componentData: Mixin = { ...component.options }
const vm = getCurrentInstance()
// Use proxy to handle the this binding problem of vue options functions
const proxy = createProxy(
componentData,
() => {
const obj: AnyObject = {}
// Mount the globally registered functions onto the proxy object
Object.keys(vm?.proxy ?? {}).forEach((key) => {
if (key.startsWith('$')) {
obj[key] = vm?.proxy[key] ?? {}
}
})
obj.$options = componentData
return obj
},
)
Handling props
Because vue2 hard-codes this internally, props are determined when the component is initialized, and this function is never called again to update props afterwards, props cannot be dynamically added or removed.
In this case, returning to real business scenarios: when using a mixin, if you need to access props, props provides the following capabilities:
- It can get the values passed in by the parent component, and supports reactive updates.
- When the parent component does not pass a value, a predefined default value can be used.
- It supports validating values.
As the duck test suggests, as long as the above capabilities are provided, no matter where the data comes from, it can be considered props.
According to the vue documentation, we can see that props fall through into attrs, so you only need to associate the props in the mixin with the corresponding values in attrs. Points worth noting:
- The default value may be a function, such as defaults for types like
ArrayandObject. propsmay be overridden in the child component.attrsdoes not perform any style conversion onpropName.
The code is as follows (since validation is basically not involved in the business scenarios, the concrete validation code is not implemented; you can add validation yourself after retrieving the value):
import type { AnyObject, Mixin } from '../types'
import { getCurrentInstance, reactive, set, watchEffect } from 'vue-demi'
export function convertProps(mixin: Mixin) {
const propsData = reactive({})
const vm = getCurrentInstance()
if (!vm || !mixin.props) {
return propsData
}
const mixinPropKeys = Object.keys(mixin.props)
const defaultCache = mixinPropKeys.reduce((obj, prop) => {
obj[prop] = getDefault(mixin.props[prop])
return obj
}, {} as AnyObject)
watchEffect(() => {
const attrs = formatAttrs(vm.proxy.$attrs)
mixinPropKeys.forEach((prop) => {
set(
propsData,
prop,
// Prefer data from props, adapting to scenarios where the child component redefines props
vm.proxy.$props[prop]
// If not found in props, the props are defined in the mixin, so use data from attrs
?? attrs[prop]
// If not found in attrs, use the default value
?? defaultCache[prop],
)
})
})
return propsData
}
function getDefault({ type, default: defaultValue }: any = {}) {
const val = (
[Array, Object].includes(type)
&& typeof defaultValue === 'function'
)
? defaultValue()
: defaultValue
return val
}
function formatAttrs(attrs: AnyObject) {
const data: AnyObject = {}
Object.keys(attrs)
.forEach((attr) => {
const attrName = formatAttrName(attr)
data[attrName] = attrs[attr]
})
return data
}
function formatAttrName(name: string) {
const [first, ...rest] = name.split('-')
return [first]
.concat(rest.map(word => word[0].toUpperCase() + word.slice(1)))
.join('')
}
Handling inject / provide
Handling inject and provide is fairly simple: after parsing the configuration object, use it just like a composable, as follows:
import type { AnyObject, Mixin } from '../types'
import { inject } from 'vue-demi'
export function convertInjectData(mixin: Mixin) {
let injectData: AnyObject = {}
if (mixin.inject) {
if (Array.isArray(mixin.inject)) {
injectData = getInjectDataFromArray(mixin.inject)
} else {
injectData = getInjectDataFromObject(mixin.inject)
}
}
return injectData
}
function getInjectDataFromArray(injectOptions: string[]) {
const injectData: AnyObject = {}
injectOptions.forEach((name) => {
injectData[name] = inject(name)
})
return injectData
}
function getInjectDataFromObject(injectOptions: AnyObject) {
const injectData: AnyObject = {}
Object.keys(injectOptions).forEach((key) => {
const val = injectOptions[key]
if (typeof val === 'object') {
const injectKey = val.from ?? key
injectData[key] = inject(injectKey, val.default)
} else {
injectData[key] = inject(val)
}
})
return injectData
}
import type { AnyObject, Mixin } from '../types'
import { provide } from 'vue-demi'
export function convertProvideData(mixin: Mixin, proxy: any) {
if (mixin.provide) {
const provideData: AnyObject = typeof mixin.provide === 'function'
? mixin.provide.bind(proxy)()
: mixin.provide
Reflect.ownKeys(provideData)
.forEach((key) => {
provide(key, provideData[key])
})
}
}
Handling data
Handling data is similar to inject; just parse the data configuration in the options.
import type { AnyObject, Mixin } from '../types'
import { ref } from 'vue-demi'
export function convertData(mixin: Mixin) {
const data: AnyObject = {}
if (mixin.data) {
const componentData = typeof mixin.data === 'function'
? mixin.data()
: mixin.data
for (const key of Object.keys(componentData)) {
const initVal = componentData[key]
data[key] = ref(initVal)
}
}
return data
}
Handling computed
Same as above
import type { AnyObject, Mixin } from '../types'
import { computed } from 'vue-demi'
export function convertComputed(mixin: Mixin, proxy: any) {
const computedData: AnyObject = {}
if (mixin.computed) {
for (const name of Object.keys(mixin.computed)) {
const fn = mixin.computed[name]
if (typeof fn === 'function') {
computedData[name] = computed(fn.bind(proxy))
}
}
}
return computedData
}
Handling watch
Same as above
import type { WatchStopHandle } from 'vue-demi'
import type { AnyObject, Mixin } from '../types'
import { watch } from 'vue-demi'
export function convertWatch(mixin: Mixin, proxy: any) {
const watchs: AnyObject = {}
if (mixin.watch) {
Object.keys(mixin.watch)
.forEach((varName) => {
const option = mixin.watch[varName]
if (typeof option === 'function') {
watchs[varName] = watch(() => proxy[varName], option)
return
}
const { handler, ...rest } = option
watchs[varName] = watch(() => proxy[varName], handler, rest)
})
}
return watchs
}
Handling methods
Handling methods is even simpler: just fix this through bind.
import type { AnyObject, Mixin } from '../types'
export function convertMethods(mixin: Mixin, proxy: any) {
const methods: AnyObject = {}
if (mixin.methods) {
for (const name of Object.keys(mixin.methods)) {
methods[name] = mixin.methods[name].bind(proxy)
}
}
return methods
}
Handling lifecycle hooks
Lifecycle handling is similar to methods in that this needs to be fixed. In addition, note the following:
- After merging, a lifecycle function may be an array or a function; the formats need to be unified before processing.
- Consider the problem that lifecycle function names differ from the
optionsconfiguration names. - For
beforeCreateandcreated, which were removed incomposables, they can be called directly.
import type { Mixin } from '../types'
import {
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onServerPrefetch,
onUnmounted,
onUpdated,
} from 'vue-demi'
import { ensureArray } from '../utils'
const commonLifeCycles = {
beforeMount: onBeforeMount,
mounted: onMounted,
beforeUpdate: onBeforeUpdate,
updated: onUpdated,
activated: onActivated,
deactivated: onDeactivated,
beforeDestroy: onBeforeUnmount,
destroyed: onUnmounted,
errorCaptured: onErrorCaptured,
serverPrefetch: onServerPrefetch,
}
type LifeCycleName = keyof typeof commonLifeCycles
const lifeCycleNames = Object.keys(commonLifeCycles)
export function convertLifeCycle(mixin: Mixin, proxy: any) {
Object.keys(mixin)
.filter((key): key is LifeCycleName => lifeCycleNames.includes(key))
.forEach((cycleName) => {
ensureArray(mixin[cycleName])
.filter((fn: (() => void) | undefined) => fn)
.forEach((fn: () => void) => {
commonLifeCycles[cycleName](
fn.bind(proxy),
)
})
})
const cycles = [mixin.beforeCreate, mixin.created]
cycles.forEach((cycle = []) => {
ensureArray(cycle)
.forEach((fn: () => void) => fn.call(proxy))
})
}
Handling other configurations
The remaining configurations mainly depend on business requirements. For example, mixin scenarios basically never inherit render, so I did not consider implementing configurations like render and el at all. As a starting point, here are implementations of two configurations; basically all of them require modifying currentInstance to override existing configurations. The code is as follows:
import type { ComponentInternalInstance } from 'vue-demi'
import type { Mixin } from '../types'
import { getCurrentInstance } from 'vue-demi'
export function convertComponentConfig(mixin: Mixin) {
const vm = getCurrentInstance()
if (!vm) {
return
}
configComponentName(vm, mixin)
configInheritAttrs(vm, mixin)
}
function configComponentName(vm: ComponentInternalInstance, mixin: Mixin) {
if (mixin.name && !vm.type._componentTag) {
vm.type._componentTag = mixin.name
}
}
function configInheritAttrs(vm: ComponentInternalInstance, mixin: Mixin) {
if ('inheritAttrs' in mixin) {
const ctor: any = vm.proxy.$vnode.componentOptions?.Ctor
if (!ctor?.options || typeof ctor.options.inheritAttrs !== 'undefined') {
return
}
// @see https://github.com/vuejs/vue/blob/main/packages/server-renderer/src/modules/attrs.ts#L20
ctor.options.inheritAttrs = mixin.inheritAttrs
}
}
Limitations
This solution only applies to vue2 + Composition API; the Proxy used in it can also be replaced with a Map or a plain object as needed.
The reasons this solution uses proxy are:
- No need to consider IE11.
- It provides simpler permission control, while other solutions require handling access permissions yourself, which is more painful in nested-object scenarios.
In theory vue3 could undergo this kind of transformation too, but since you are already using vue3, such scenarios are unlikely to appear.
If you do need this kind of scenario in vue3, you need to enable VUE_PROD_DEVTOOLS to get the relevant information in production.