Skip to content
Back to blog

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 when mixins are nested or multiple mixins coexist?
  • After refactoring, what should be done with the original mixin? If it is deprecated, every component that used that mixin must be refactored into a Composition API component; if it is not deprecated, synchronizing changes involving the mixin during 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 API references vue3's safety restrictions and also hides data and other content that is not used in the template, so it cannot be obtained directly through getCurrentInstance.

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 as Options API and vue-class.
  • How to handle nested mixins.
  • Whether extends, which plays a similar role to mixin, can also be supported; otherwise, scenarios where a mixin nests an extends cannot 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 this binding problem in components; a new object needs to be constructed to hold the mixin content.

Unified Input

Components in Vue2 come in multiple forms:

  1. const mixin = {}
  2. const mixin = Vue.extends({})
  3. @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 $createElement and $router
  • options configurations such as props, data, inject, computed, watch, and methods

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 Array and Object.
  • props may be overridden in the child component.
  • attrs does not perform any style conversion on propName.

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 options configuration names.
  • For beforeCreate and created, which were removed in composables, 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.

Summary