collins

V1

2022/04/12阅读:10主题:自定义主题1

温故而知新,浅入 Vue Mixin 底层原理

前言

本文你将收获:

  • 混入(mixin) 的时机。
  • 混入(mixin) 对于不同情况的策略:
    • 函数叠加混入(data、provide)
    • 数组叠加混入(hook、watch)
    • 原型链叠加混入(components,filters,directives)
    • 对象覆盖混入(props,methods,computed,inject )
    • 替换覆盖混入(el,template,propData)

在使用 Vue 开发的时候,经常使用 混入(mixin) 发现真的好用,混入(mixin) 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能。一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

虽然好用,但是一直停留在看文档会用,「知其然不知其所以然」,并且在之前组内分享时,有同学也分享了关于 混入(mixin) 的问题,所以最近有兴趣去看了一下实现原理,发现还是有点绕,是真的有点绕(菜鸡一枚 😆)。这篇文章分享一下自己的一些探索希望对你有帮组。总体来说其实就是探索两个问题:

  • 什么时候 混入(mixin)
  • 混入(mixin) 的策略是什么?

我们带着问题往下看。

前置的知识

1. 何如使用

  • 全局混入
import Vue from "vue";
import App from "./App.vue";
Vue.config.productionTip = false;
Vue.mixin({
  data() {
    return {
      a: 1
    };
  }
});
new Vue({
  render: (h) => h(App)
}).$mount("#app");

测试源码

  • 局部混入(组件混入)
<template>
  <div class="hello">
    <h1>{{ a }}</h1>
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String,
  },
  mixins: [
    {
      data() {
        return {
          a: 2,
        };
      },
    },
  ],
  data() {
    return {
      // a: 2,
    };
  },
};
</script>

测试源码

2. 基础全局 options 是什么?

基础 options 就是:components、directives、filters 三兄弟,这三兄弟在初始化全局 API 的时候就设置在 Vue.options 上。所以这三个是最先存在全局 options。

什么时候混入 (mixin) ?

混入分为两种情况。

1.全局 mixin 和 基础全局 options 混入

不过全局混入,需要注意的是,混入的操作应该是在初始化实例之前,而不是之后,这样混入 (mixin) 才能合并上你的自定义 options。

2. 自定义 options 和 基础全局 options 混入

每一个组件在初始化的时候都会生成一个 vm (组件实例)。在创建组件实例之前,全局注册的 options,其实会被传递引用到每个组件中,目的是将和 全局 options组件 options 合并起来,组件便能访问到全局选项。所以的时机就是创建好组件实例之前。

对于全局注册的 options ,Vue 背后偷偷给组件都合并一个全局选项的引用。但是为保证全局 options 不被污染,又不可能每个组件都深度克隆一份全局选项导致开销过大,所以会根据不同的选项,做不同的处理。下面我们就来看看混入合并的策略是什么?

混入 (mixin) 的策略是什么?

在这之前,回到上面的两种混入,我们发现混入合并最后都调用了 mergeOptions 这个方法。这个方法就是混入的重点。

// 合并刚才从类的继承链中获取的配置对象及你自己在代码中编写的配置对象(从第一次合并肯定是new Vue(options)这个 options
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
 ...
  // 组件属性中的 props、inject、directive 等进行规范化
  // 验证开发者的代码是否符合规范
  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  if (!child._base) {
     // 遍历mixins,parent 先和 mixins 合并,然后在和 child 合并
    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
  // 处理 parent 的 key
  // 先遍历合并 parent 中的 key,存储在 options
  // 初始化时:parent 就是全局选项
  for (key in parent) {
    mergeField(key)
  }
  // 处理 child 的 key
  // 在遍历 child,合并补上 parent 中没有的 key ,存储在 options
  // 初始化时:child 就是组件自定义选项
  for (key in child) {
    if (!hasOwn(parent, key)) { // 排除已经处理过的 parent 中的 key
      mergeField(key)
    }
  }
  // 得到类型的合并函数,进行合并字段
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

源码很长,关键代码在最后的函数,这函数就是 「得到类型的合并函数,进行合并字段」 ,这里的类型可能是:'data'、hook、'props'、'methods'、'inject'、'computed'、'provide'等等,也就是类型的不同,进行的合并策略也是不一样的。当然如果都不存在,就走默认的处理 defaultStrat 。

1. defaultStrat

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

组件 options > 全局 options

2. data 的混入策略

data,我们在开发时,一般使用函数来定义,当然也可以使用对象(比较少)。我们以函数为为主线来讨论混入策略。

这里简单解释一下为什么一般情况下,我们使用函数来定义 data: 在 Vue 中组件是可以复用的,一个组件被创建好之后,就可以被用在其他各个地方,而组件不管被复用了多少次,组件中的 data 数据应该是相互不影响的。基于数据不影响的理念,组件被复用一次,data 数据就应该被复制一次,data 是函数,每一个函数都会有自己的存储空间,函数每次执行都会创建自己的执行上下文,相互不影响。函数类似于给每个组件实例创建一个私有的数据空间,让各个组件实例维护各自的数据。而单纯的写成对象形式,就使得所有组件实例共用了一份 data,就会造成一个变了全都会变的结果。

function mergeData (to: Object, from: ?Object): Object {
  if (!from) return to
  let key, toVal, fromVal

  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...
    if (key === '__ob__'continue
    toVal = to[key]
    fromVal = from[key]
    // 如果存在这个属性,重新设置
    if (!hasOwn(to, key)) {
      set(to, key, fromVal)
    } else if (
      // 存在相同的属性,就合并对象
      toVal !== fromVal &&
      isPlainObject(toVal) &&
      isPlainObject(fromVal)
    ) {
      mergeData(toVal, fromVal)
    }
  }
  return to
}

/**
 * Data
 */
export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    if (!childVal) {
      return parentVal
    }
    if (!parentVal) {
      return childVal
    }
    return function mergedDataFn () {
      return mergeData(
        typeof childVal === 'function' ? childVal.call(this, this) : childVal,
        typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    return function mergedInstanceDataFn () {
      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
      }
    }
  }
}

strats.data = function (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    if (childVal && typeof childVal !== 'function') {
      return parentVal
    }
    return mergeDataOrFn(parentVal, childVal)
  }

  return mergeDataOrFn(parentVal, childVal, vm)
}

源码很长,如果你一行一行去看,真的难受。抽象一下其实就是:两个 data 函数合并成一个函数返回,data 函数执行返回的数据对象也进行合并。

  • 函数合并为一个
  • 函数返回数据合并,优先级高的被应用

但是注意这里的合并数据也是有优先级的。我们通过一个例子来看看。

// 全局配置
Vue.mixin({
  data() {
    return {
      a: 1
    };
  }
});
// 子组件
<template>
  <div class="child">
    <h1>{{ a }}</h1>
  </div>
</template>
<script>
export default {
  name: "Child",
  mixins: [
    {
      data() {
        return {
          a: 5,
        };
      },
      mixins: [
        {
          data() {
            return {
              a: 4,
            };
          },
        },
      ],
    },
  ],
  data() {
    return {
      a: 6,
    };
  },
};
</script>

测试源码

这例子中,设置 4 类 data option 函数:

  • 组件自己的 data 函数,A
  • 组件 mixin data 函数 ,B
  • 组件 mixin,在 mixin data 函数,C
  • 全局 mixin data 函数,D 其实无论这里 嵌套 mixin 多少个 data 函数,最后都只会返回一个合并函数,合并函数返回一个合并的对象,合并对象的合并数据优先级, 组件 data > 组件 mixin data > 组件 mixin -mixin data > ... > 全局 mixin data

3. provide 的混入策略

provide 的混入策略和 data 的混入策略一致。底层都是调用 mergeDataOrFn 函数实现。

4. hook 的混入策略

// Vue 中所有的 hook 函数
export const LIFECYCLE_HOOKS = [
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeUpdate',
  'updated',
  'beforeDestroy',
  'destroyed',
  'activated',
  'deactivated',
  'errorCaptured',
  'serverPrefetch'
]

// 为所有的 hook 注册回调,回调都是 mergeHook
LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})

// mergeHook 协同 dedupeHooks 的作用就是将 hook 函数存入数组
function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  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
}

hook 的混入,相对 data 的混入来说,要简单一些,就是把所有的钩子函数保存进数组,虽然顺序执行。

// 全局
Vue.mixin({
  created() {
    console.log(1);
  }
});
// 子组件
<template>
  <div class="child">
  </div>
</template>
<script>
export default {
  name: "Child",
  mixins: [
    {
      created() {
        console.log(3);
      },
      mixins: [
        {
          created() {
            console.log(4);
          },
        },
      ],
    },
  ],
  created() {
    console.log(2);
  },
};
</script>

测试源码 hook 混入是存放在数组中,最后就变成了:

[
  全局 mixin hook,
  ... ,
  组件mixin-mixin hook,
  组件mixin hook,
  组件 hook
],

执行的时候,按照这个数组 顺序执行

5. watch 的混入策略

watch 的混入策略和 hook 的混入策略思想是一致的,都是按照

[
    全局 mixin watch,
    ... ,
    组件 mixin-mixin watch,
    组件 mixin watch,
    组件 watch
]

这个顺序混入合并 watch, 最后执行的时候顺序执行(注意:虽然混入测试和 hook 一样,但是底层实现还是不一样的,这里就不贴源码了)。

6. component、directives、filters 的混入策略

component、directives、filters 这三者是放在一起来讲哈,主要是这三者合并测试一样,并且这三者最开始初始化全局 API 的时候就设置在 Vue.options 上。

// 中转函数
function mergeAssets (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): Object {
  const res = Object.create(parentVal || null)
  if (childVal) {
    return extend(res, childVal)
  } else {
    return res
  }
}

// 为 component、directives、filters 绑定回调
ASSET_TYPES.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

//
export function extend (to: Object, _from: ?Object): Object {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

这里最重要的就是 「const res = Object.create(parentVal || null) 」 这一行代码,component、directives、filters 混入策略的精髓。

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的proto

什么意思了,简单来说就是通过使用 Object.create 来创建对象,并且实现继承,两个对象继承混入,通过原型链的方式不会相互覆盖,而是 权重小 被放到 权重大 的原型上 (大佬的实现,就是牛逼)。

<script>
    // 全局 filter
    Vue.filter("g_filter",function (params) {})
    // mixin 的 mixin
    var mixin_mixin_filter={
        filters:{
            mixin_mixin_filter(){}
        }
    }
    // mixin filter
    var mixins_filter={
        mixins:[mixin_mixin_filter],
        filters:{
            mixins_filter(){}
        }
    }
    // 组件 filter
    var vue = new Vue({
        mixins:[mixins_filter],
        filters:{
            self_filter(){}
        }
    })
    console.log(vue.$options);
</script>

在实现这个例子演示时,发生了一个小插曲。本想在 codesandbox 实现这个例子的,发现在 codesandbox 上实现的例子,发现和实际不太一样,如果有兴趣可以研究一下,https://codesandbox.io/s/vue-mixin-filter-0rd43?file=/src/components/HelloWorld.vue

7. props、computed、methods、inject 的混入策略

这四者的混入策略也是一样的,所以放在一起来说。而且它们的混入策略,也相对来说比较简单。

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
}

简单的对象合并,key 值相同,优先级高的覆盖优先级低的。组件 对象 > 组件 mixin 对象 > 组件 mixin -mixin 对象 > ... > 全局 mixin 对象。

以 methods 为例:

// 全局配置
Vue.mixin({
  methods: {
    test() {
      console.log(1);
    }
  }
});
// 子组件
<template>
  <div class="hello"></div>
</template>

<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String,
  },
  mixins: [
    {
      methods: {
        test() {
          console.log(3);
        },
      },
      mixins: [
        {
          methods: {
            test() {
              console.log(4);
            },
          },
        },
      ],
    },
  ],
  methods: {
    test() {
      console.log(2);
    },
  },
  created() {
    this.test();
  },
};
</script>

测试源码

8. el、template、propData 混入策略

这是默认的处理方式,也相当于一种兜底的方案,当上面所有的混入策略不存在的时候,就会用这种兜底方式,如 el,template,propData。他们的混入策略就是权重大的覆盖权重小的。组件 > 组件 mixin > 组件 mixin -mixin > ... > 全局 mixin。

总结

本文带大家一起探索了 Vue mixin 的策略,在不同场景有不同的混入策略,涉及到 data、provide、钩子函数、watch、component、directives、filters、props、computed、methods、inject、el、template、propData 。从混入的方式来说,我们可以总结为 5 个大的方向:

  • 函数叠加混入(data、provide)
  • 数组叠加混入(hook、watch)
  • 原型链叠加混入(components,filters,directives)
  • 对象覆盖混入(props,methods,computed,inject )
  • 替换覆盖混入(el,template,propData)

「知其然知其所以然」,抓着源码研究了很久,也看了很多文章,总结出来,希望对你有帮助。

参考

  • https://note.youdao.com/web/#/file/3B4DFEA7A26D4FC0BE54FCA3212EA247/markdown/D9482901E08B469FAB492EC68DAB8150/?search=%E5%87%BD%E6%95%B0
  • https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/create
  • https://mp.weixin.qq.com/s/uW8i0rkTMLcDiCdBphtRFA
  • https://mp.weixin.qq.com/s/IVqb9vkOd8sH3BQtRn9W6g
  • https://ustbhuangyi.github.io/vue-analysis/v2/components/merge-option.html#%E5%A4%96%E9%83%A8%E8%B0%83%E7%94%A8%E5%9C%BA%E6%99%AF
  • https://cn.vuejs.org/v2/guide/mixins.html

分类:

前端

标签:

JavaScript

作者介绍

collins
V1