mixin在vue框架中的定位是实现逻辑复用的功能,可以类比javascript中的混合继承方式。实现逻辑复用的方式有很多种,比如react提倡的高阶组件、hooks
等等,当然,在Vue中官方推荐的且使用频次最高的还是mixin。
本篇文章将会探讨Vue底层如何实现mixin,且mixin对vue各个配置项是如何处理的,以及混合的顺序如何等等问题。
Mixin实现方式
组件调用mixin的方式有两种:
- Vue.mixin():直接调用组件构造函数上的mixin静态方法。
- vue options->{ mixins: [] }:在组件的配置对象中挂载mixins的成员。
无论以上使用了哪种方式,最终调用的都是mergeOptions
这个工具方法。
以Vue.mixin举例:
// src/core/global-api/mixin.js
import { mergeOptions } from '../util/index'
export function initMixin (Vue: GlobalAPI) {
Vue.mixin = function (mixin: Object) {
this.options = mergeOptions(this.options, mixin)
return this
}
}
可以看到mergeOptions同字面意义一样,将多个options进行合并,生成一个新的options。
mergeOptions是vue中比较重要的辅助函数之一,除了在mixin中使用外还在extend、实例化阶段使用到:
// src/core/global-api/extend.js
Vue.extend = function (extendOptions: Object): Function {
...
Sub.options = mergeOptions(
Super.options,
extendOptions
)
}
// src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
...
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
mergeOptions
首先来看一下mergeOptions的主体代码:
// src/core/util/options.js
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
...
// 规范化props
normalizeProps(child, vm)
// 规范化inject
normalizeInject(child, vm)
// 规范化指令
normalizeDirectives(child)
// Apply extends and mixins on the child options,
// but only if it is a raw options object that isn't
// the result of another mergeOptions call.
// Only merged options has the _base property.
// 未合并的options不带有_base
if (!child._base) {
if (child.extends) {
parent = mergeOptions(parent, child.extends, vm)
}
if (child.mixins) {
for (let i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm)
}
}
}
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
}
从代码逻辑上看,mergeOptions主要经历了两个步骤:
- 规范化配置项。
- 未合并的extends和mixins,针对不同字段选择不同合并策略递归合并每个options。
normalize
normalize同字面意思一样,用来规范化属性,比如props,可以使用对象语法,可以使用数组语法,而数组又可以是函数数组或者是字符串数组。所以normalize的作用就是统一将这些不同的类型处理成对象类型的格式。
normalizeProps
function normalizeProps (options: Object, vm: ?Component) {
const props = options.props
if (!props) return
const res = {}
let i, val, name
// props是数组类型
// props: [ 'someObjA', 'someObjB' ]
if (Array.isArray(props)) {
i = props.length
while (i--) {
val = props[i]
if (typeof val === 'string') {
// 将-改驼峰命名
name = camelize(val)
// string类型规范化为 { someObjA: { type: null } }
res[name] = { type: null }
} else if (process.env.NODE_ENV !== 'production') {
warn('props must be strings when using array syntax.')
}
}
} else if (isPlainObject(props)) {
// 对象类型的props
// props: { someObjA: String }
// props: { someObjA: [ Number, String ] }
// props: { someObjA: { type: Number, default: 1 } }
for (const key in props) {
// 如果是纯对象形式,如props类型3则直接使用,否则将属性后面的值作为type(如String)
val = props[key]
name = camelize(key)
res[name] = isPlainObject(val)
? val
: { type: val }
}
} else if (process.env.NODE_ENV !== 'production') {
warn(
`Invalid value for option "props": expected an Array or an Object, ` +
`but got ${toRawType(props)}.`,
vm
)
}
options.props = res
}
在normalizeProps中,字符串数组类型的props都会处理成type为null的类型,这里要注意的是,props会根据定义的type不同,而给传进来的props给予不同的默认值,比如我们直接在组件模版上写require这个属性:
- type为Boolean:默认给require赋值为true
- type为null:默认赋值为undefined
- type为非Boolean:赋值为""
normalizeInject
function normalizeInject (options: Object, vm: ?Component) {
const inject = options.inject
if (!inject) return
const normalized = options.inject = {}
if (Array.isArray(inject)) {
for (let i = 0; i < inject.length; i++) {
// 字符串数组处理成 { bar: { from: 'bar' }}格式
normalized[inject[i]] = { from: inject[i] }
}
} else if (isPlainObject(inject)) {
for (const key in inject) {
const val = inject[key]
normalized[key] = isPlainObject(val)
? extend({ from: key }, val)
: { from: val }
}
} else if (process.env.NODE_ENV !== 'production') {
warn(
`Invalid value for option "inject": expected an Array or an Object, ` +
`but got ${toRawType(inject)}.`,
vm
)
}
}
inject/provide是Vue 2.2.0版本引入特性,normalizeInject对inject的处理同props过程相似,都是处理成对象类型格式,但不同的是,面对对象类型时normalizeInject又做了一层处理:
inject: {
foo: { someProperty: 'bar' }
}
// 处理后
inject: {
'foo': { from: 'foo', someProperty: 'bar' }
}
这里对象的处理依旧是再次规范化了一下。
normalizeDirectives
function normalizeDirectives (options: Object) {
const dirs = options.directives
if (dirs) {
for (const key in dirs) {
const def = dirs[key]
if (typeof def === 'function') {
dirs[key] = { bind: def, update: def }
}
}
}
}
normalizeDirectives对指令进行了规范化处理,同样统一处理成了对象类型。我们知道Vue针对指令,提供了两种定义但方式:
// 处理前
directives: {
b: function () {
console.log('v-b')
}
}
// 处理后
directives: {
b: {
bind: function(){
console.log('v-b')
},
update: function(){
console.log('v-b')
}
}
}
综上,props、inject、directive统一处理成了扩展度较高的对象类型格式,并且格式化后的数据会被重新赋值给传入的第一个参数(这里是child),之后就是递归处理被合并项的extends和mixins成员了,再递归合并之前先做了一次_base
的判断,这里的_base
指向Vue构造函数,_base
属性存在于Vue.options
上,由于组件初始化阶段一定会merge Vue options并返回一个新的options,所以被合并的options一定会存在_base
属性。
合并策略
以上是进行合并前的数据处理阶段,而mixin真正重要的阶段其实是mergeField
阶段,我们知道两个组件options可以存在相同的选项,比如都具有methods
对象,但methods
对象挂载的方法可能相同可能不同,其它选项也可以类比。mergeField
的作用就是考虑使用何种策略去处理这些选项,返回我们需要的配置。
在mergeOptions函数逻辑最后,首先申请了一个新的存储对象options,将parant与child都经过mergeField处理再合并进options中。
相关代码:
function mergeField (key) {
// 策略模式-根据key选择不同的处理函数
const strat = strats[key] || defaultStrat
// 调用处理函数,合并两个选项
options[key] = strat(parent[key], child[key], vm, key)
}
当mixin两个普通的对象的时候,可以使用深度优先去一层一层拷贝比对来合并值,但在vue中,简单的拷贝赋值并不能满足组件构造函数的需求,还需要将传进来的配置项进行处理。
因此组件实例化的过程,可以看作一个工厂,data、props、methods这些可以看作原材料,经过各个流水线工人的处理加工,就拿到了生产组件所需要的成品,这里的strat可以看到是加工原材料的工人。
所以理解mergeOptions的核心其实就是理解strate的过程。
首先来看Vue是如何定义strates的:
/**
* Option overwriting strategies are functions that handle
* how to merge a parent option value and a child option
* value into the final value.
* 选项覆盖策略是用来合并父选项值与子选项值到最终值的函数
*/
const strats = config.optionMergeStrategies
defaultStrat
const defaultStrat = function(parentVal: any, childVal: any): any {
return childVal === undefined
? parentVal
: childVal
}
defaultStrat表示默认的合并策略,childVal是需要处理的选项,当选项值不为undefined时,直接返回该选项。这也就意味着,那些未命中合并策略的选项将会被child中的选项直接覆盖。比如parent与child的组件options中均存在 demo: { ... } 属性,mixin后,parent中的demo无论定义为何值都会被child中的demo覆盖。
合并el与propsData
strats.el = strats.propsData = function(parent, child, vm, key) {
if (!vm) {
warn(
`option "${key}" can only be used during instance ` +
'creation with the `new` keyword.'
)
}
return defaultStrat(parent, child)
}
对el和propsData处理是直接返回了默认策略,但返回之前加了对vm的判断,通过看warn可以得知el和propsData是通过new关键字实例化组件才可以使用的属性。只要未传入vm变量,就不能声明这两个字段。
传入了vm的场景:
- init:new实例化调用时的初始化阶段:
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
未传入vm的场景:
- Vue.mixin
- Vue.extends
也就是说,子组件和mixin对象不能定义el和propsData这两个字段。
合并data与provide
strats.data = function(
parentVal: any,
childVal: any,
vm?: Component
): ?Function {
if (!vm) {
if (childVal && typeof childVal !== 'function') {
process.env.NODE_ENV !== 'production' && warn(
'The "data" option should be a function ' +
'that returns a per-instance value in component ' +
'definitions.',
vm
)
return parentVal
}
return mergeDataOrFn(parentVal, childVal)
}
return mergeDataOrFn(parentVal, childVal, vm)
}
// provide
strats.provide = mergeDataOrFn
合并data的逻辑异常繁琐,这也是非常必要的,因为data作为本地组件的状态管理器,挂载各种类型的状态,同时需要合并的data类型可能为对象也可能是函数,但返回结果,必须为函数类型。
data字段的合并策略依旧是首先判断了子组件data的类型必须为函数。之后继续调用了mergeDataOrFn。
export function mergeDataOrFn(
parentVal: any,
childVal: any,
vm?: Component
): ?Function {
if (!vm) {
...
// extend或者mixin调用
return function mergedDataFn() {
return mergeData(
typeof childVal === 'function' ? childVal.call(this, this) : childVal,
typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
)
}
} else {
// new调用
return function mergedInstanceDataFn() {
// instance merge
const instanceData = typeof childVal === 'function'
? childVal.call(vm, vm)
: childVal
const defaultData = typeof parentVal === 'function'
? parentVal.call(vm, vm)
: parentVal
if (instanceData) {
return mergeData(instanceData, defaultData)
} else {
return defaultData
}
}
}
}
mergeDataOrFn函数最终返回了一个函数mergedDataFn或者mergedInstanceDataFn,函数值函数赋值给options.data属性,也就是说data属性最终会被处理成一个函数(防止引用传递),data属性真正合并的阶段放到了组件的初始化阶段。但无论是哪种函数,mergeData拿到都是需要合并的两个对象。
function mergeData(to: Object, from: ?Object): Object {
if (!from) return to
let key, toVal, fromVal
// Object.keys拿不到不可枚举的属性
const keys = hasSymbol
? Reflect.ownKeys(from)
: Object.keys(from)
for (let i = 0; i < keys.length; i++) {
key = keys[i]
// in case the object is already observed...
// __ob__不合并
if (key === '__ob__') continue
toVal = to[key]
fromVal = from[key]
if (!hasOwn(to, key)) {
// 若to不存在该属性,则使用set赋值
set(to, key, fromVal)
} else if (
// 对象类型则深度合并
toVal !== fromVal &&
isPlainObject(toVal) &&
isPlainObject(fromVal)
) {
mergeData(toVal, fromVal)
}
}
return to
}
从上面的过程可以看到真正执行data合并策略的过程在mergeData内部,而该函数却是在组件初始化阶段才调用,这样做的主要目的其实是为了保证data中可以访问到props对应的属性。
provide的合并策略与data相同。
合并生命周期
function mergeHook(
parentVal: ?Array<Function>,
childVal: ?Function | ?Array<Function>
): ?Array<Function> {
// res返回了一个数组
// parentVal在与vue.options合并阶段不存在,所以不会命中parentVal.concat方法,则返回[childVal]
// 与之后的options合并的时候,parentVal一定是数组
const res = childVal
? parentVal
? parentVal.concat(childVal)
// 生命周期钩子函数为数组形式,直接返回该数组,
// 按数组顺序执行
: Array.isArray(childVal)
? childVal
: [childVal]
: parentVal
return res
? dedupeHooks(res)
: res
}
function dedupeHooks(hooks) {
const res = []
for (let i = 0; i < hooks.length; i++) {
if (res.indexOf(hooks[i]) === -1) {
res.push(hooks[i])
}
}
return res
}
// 'beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed', 'activated', 'deactivated', 'errorCaptured', 'serverPrefetch'
LIFECYCLE_HOOKS.forEach(hook => {
// { created(){}} -> {created: [function created(){}]}
strats[hook] = mergeHook
})
mergeHook函数会将parent与child的生命周期钩子函数合并成数组形式,比如:
parent.options.created = function() {
console.log('parentCreated')
}
child.options.created = function() {
console.log('childCreated')
}
// 合并后
[
{
created: function() {
console.log('parentCreated')
}
},
{
created: function() {
console.log('childCreated')
}
}
]
最后,生命周期的钩子函数会在callhook
中依次调用。虽然是依次执行的,但关于函数放置的顺序有一些需要注意的事项。
回到开头提到的mixin使用方式:
- Vue.mixin()
- vue options->{ mixins: [] }
我们编写一个demo来测试一下这两种方式的生命周期钩子函数调用的顺序:
// a.js
export default {
created() {
console.log('a')
}
}
// b.js
import mixinA from './a.js'
export default {
mixins: [mixinA],
created() {
console.log('b')
}
}
// a
// b
可以看到调用顺序同官网说明的方式一样。
// a.js
export default {
created() {
console.log('a')
}
}
// b.js
export default {
created() {
console.log('b')
}
}
// index.js
import Vue from 'vue'
import A from './a.js'
import B from './b.js'
const BComponent = Vue.extends('BComponent', B)
B.mixin(A)
new BComponent()
// b
// a
调用顺序与在options内挂载mixins方式调用顺序相反,这是为什么呢?
这是因为,组件在实例化的初始阶段,一定会与Vue.options进行一次mergeOptions,Vue.options并不存在任何生命周期钩子函数,且mergeOptions内会优先处理组件options上挂载的mixins,所以mixins内的钩子函数会优先被push到对应生命周期hook的第一位,所以在调用的时候,mixins混入组件的生命周期会优先调用。
而方式二中,首先调用了extends与Vue.options进行了merge,拿到了BComponent,BComponent中的hook数组第一位是B组件中的hook函数,之后再mixin其它任何组件都只能排在B组件的hook后面。
合并props、methods、inject、computed
strats.props =
strats.methods =
strats.inject =
strats.computed = function(
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): ?Object {
if (childVal && process.env.NODE_ENV !== 'production') {
assertObjectType(key, childVal, vm)
}
if (!parentVal) return childVal
const ret = Object.create(null)
extend(ret, parentVal)
if (childVal) extend(ret, childVal)
return ret
}
props、methods、inject、computed这几个固定都是对象类型(props与inject会被规范化成对象类型),直接进行拷贝赋值即可,同名的选项会被覆盖。在组件内部mixins引入混合组件时,由于宿主组件总是最后处理,所以当混合组件和宿主组件在props、methods、inject、computed中存在同名属性时,会被宿主组件对应的选项覆盖。而调用mixin方法混合的方式则相反,这一点需要注意。
合并directives、filters、components
function mergeAssets(
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): Object {
const res = Object.create(parentVal || null)
if (childVal) {
process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
return extend(res, childVal)
} else {
return res
}
}
// component、directive、filter
ASSET_TYPES.forEach(function(type) {
strats[type + 's'] = mergeAssets
})
乍看之下,directives、filters、components与methods等合并策略很相似,唯独声明res变量的方式不一样,mergeAssets中使用的是const res = Object.create(parentVal || null)
而不是直接覆盖,这是为什么呢?
我们知道,vue内部提供了一些内置的指令如v-for、v-if等,和一些内置的组件如KeepAlive、Transition等,我们可以直接在组件内部使用他们,但奇怪的是,vue并没有显式的去注册他们,这实现的关键就在const res = Object.create(parentVal || null)
。
这些内置属性其实就存在Vue.options上,经过mergeAssets处理后会变成:
options = {
...
components: {
...
__proto__: {
...
Transition
}
}
}
这样的结构。使用对应的选项时,会顺着对应选项的原型链一层一层向上寻找选项。
实现的非常巧妙,既满足了同名属性'覆盖',又可以内置选项。
合并watch
// watch挂载的选项不可以直接进行覆盖,需要将每个选项处理成函数数组形式。
strats.watch = function(
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): ?Object {
// Firefox中存在原生的Object.prototype.watch函数
// 为定义watch选项却访问到了watch属性,则重置parentVal与childVal
if (parentVal === nativeWatch) parentVal = undefined
if (childVal === nativeWatch) childVal = undefined
/* istanbul ignore if */
if (!childVal) return Object.create(parentVal || null)
if (process.env.NODE_ENV !== 'production') {
assertObjectType(key, childVal, vm)
}
if (!parentVal) return childVal
// 如果定义了watch选项
// 将选项的每个成员处理成数组类型
const ret = {}
extend(ret, parentVal)
for (const key in childVal) {
let parent = ret[key]
const child = childVal[key]
if (parent && !Array.isArray(parent)) {
parent = [parent]
}
ret[key] = parent
? parent.concat(child)
: Array.isArray(child) ? child : [child]
}
return ret
}
由于在Firefox中存在原生的Object.prototype.watch函数,在合并watch函数过程中如果访问到了原生函数,需要做兼容性处理。
合并watch同合并声明周期存在相似之处,都是将选项合并为数组类型。
总结
合并步骤:
- 规范化选项
- 优先合并extends和mixins的选项。
- 合并
- defaultStrat:defaultStrat是默认到合并策略,未命中其它策略到话将会走默认策略,childVal将会覆盖parentVal。
- 合并el和propsData:el与propsData只有new场景可以定义,子组件中不能使用。
- 合并data:data首先会被合并成一个函数,该函数会在组件初始化阶段调用,再执行真正的合并逻辑(深度合并)。
- 合并声明周期:声明周期会被处理成数组形式,parentVal始终处于数组开始位置。
- 合并props、methods、inject、computed:先合并parentVal,childVal同名属性会进行覆盖。
- 合并watch:合并过程与合并声明周期函数类似,parentVal与childVal对应属性合并成数组类型。
转载请注明出处!感谢!