【实战系列】来看看原型、原型链在实际项目中如何大放异彩

258 阅读9分钟

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

无论是在我们业务开发项目中,还是热门的第三方库如:Vue2Vue3等。原型和原型链都有大量的运用。纸上得来终觉浅,绝知此事要躬行。理论加上实践,才能让我们真正理解掌握一门技能。接下来我们一起来看看原型原型链是如何在实际场景中发光发热的

tips: 如果你对原型原型链继承存在困惑,在阅读本篇文章前,强烈建议先阅读我之前继承系列文章

带你重新认识原型、原型链

js实现继承的多种方式

结合babel彻底理解class继承黑魔法

这可能是你见过的最详细的instanceof模拟实现

vue2

温馨提示:vue2源码版本为2.7.16。为了更方便理解,代码例子采用Options API的方式(vue2.7虽然支持和vue3一样Composition API的语法,但响应式底层仍采用原来的defineProperty方式,而不是Proxy,更多两者差异细节可以参考:vue2.7更新日志

vue实例与vue组件实例

vue实例

引用vue官方文档的概念:

一个 Vue 应用由一个通过new Vue 创建的根Vue实例,以及可选的嵌套的、可复用的组件树组成

image.png

image.png

Vue根实例是通过Vue构造函数new出来的

子组件实例

image.png

image.png

每一个vue组件都是通过VueComponent构造函数生成的实例, 同时也属于Vue构造函数的实例

整体vue组件相关的属性、方法可以大致分成两种:

  1. 组件自身的属性、方法(_uid、data里面的属性、methods里面的属性等)
  2. 所有组件共享的属性、方法($nextTick、$set等),包括我们经常用的vue-router($router)、vuex($store)等

每一个组件实例本身并不具有$nextTick、$set等这些方法。显然这些定义在了它的原型链的某个对象上使得可以访问成功

好了,到这里相信大家对原型原型链vue中的作用有了基础的认识。接下来,我们深入vue源码看看具体是怎么处理的

源码分析

温馨提示: 下面贴出的源码会适当简化,只关注原型原型链相关的部分

Vue构造函数

我们先关注Vue实例的生成过程,即Vue构造函数

image.png

_init方法用于实例化Vue实例。在这之前,Vue先对Vue构造函数本身做了一系列处理,我们具体看下做了哪些处理

image.png

我们可以发现常用的$set$watch$delete是直接定义在Vue原型对象上的

另外有个需要额外多提一嘴的点是$data$props,咦🤔,这些是vue组件的data与props,不应该每个vue组件实例自身独有的属性吗?怎么定义在Vue原型对象上了

image.png

仔细看这里其实做了一个简单的代理,当我们访问this.$data实际访问的是this._data以及this._props,而_data_props是组件实例自身的属性(get函数内部的this指向了vue组件实例)

image.png

那么vue为什么要这样处理呢,有种脱裤子放屁的感觉。直接在组件实例使用_data不就好了

实际上Vue是不会代理那些键名以$_开头的字段的,因为Vue自身的属性和方法都是以$_开头的,这么做是为了避免与 Vue 自身的属性和方法相冲突。并且本身Vue有意识的刻意隐藏这些属性。防止用户在使用过程中意外修改,而vue给我们暴露的$data$props并没有定义set函数。我们修改的话会静默失败(开发环境会有警告)

image.png

image.png

当然现在我们知晓了这一切的防范措施,直接对_data修改,就可以成功(但千万不要这么做,除非你自己作死)

image.png

image.png

显然,这块代理逻辑是每个组件实例都需要用到的。放到Vue.prototype

其他剩下的全局api都类似($nextTick$destory等),不过多描述了,对其中细节感兴趣的,可以自行查看源码

到这里我们简单做个总结:Vue构造函数的原型对象上会定义很多公用的属性方法便于各个组件实例使用

Vue组件构造函数(VueComponent)

我们有以下几种方式创建Vue组件

全局组件

// 注册组件,传入一个扩展过的构造器  
Vue.component('my-component', Vue.extend({ /* ... */ }))  
  
// 注册组件,传入一个选项对象 (自动调用 Vue.extend)  
Vue.component('my-component', { /* ... */ })  
  
// 获取注册的组件 (始终返回构造器)  
var MyComponent = Vue.component('my-component')

源码部分

// src/shared/constants.ts
export const ASSET_TYPES = ['component', 'directive', 'filter'] as const

// src/core/global-api/assets.ts
import { ASSET_TYPES } from 'shared/constants'
import type { GlobalAPI } from 'types/global-api'
import { isFunction, isPlainObject, validateComponentName } from '../util/index'

export function initAssetRegisters(Vue: GlobalAPI) {
  /**
   * Create asset registration methods.
   */
  ASSET_TYPES.forEach(type => {
    // @ts-expect-error function is not exact same type
    Vue[type] = function (
      id: string,
      definition?: Function | Object
    ): Function | Object | void {
      if (!definition) {
        xxx
      } else {
        xxxx
        if (type === 'component' && isPlainObject(definition)) {
          // @ts-expect-error
          definition.name = definition.name || id
          // 这里我们可以发现传入的是个配置对象(这里也可以直接传构造器,到最后其实都一样)
          // 会调用`extend`这个方法, 这个方法会生成一个`构造器`,至于细节,下面会讲到
          definition = this.options._base.extend(definition)
        }
        xxx
        return definition
      }
    }
  })
}

基础Vue构造器

const MyComponent = Vue.extend({
  render(h) {
    return h("div", "11");
  },
});

这里和上面一样,直接手动创建一个构造器,上面的全局组件声明是其内部调用了extend

SFC

<template>
  <div id="app">
    <Child />
  </div>
</template>

<script>
import Child from "./components/Child.vue";

export default {
  components: { Child },
};
</script>

SFC单文件组件是我们日常开发组件最常用的一种方法。我们知道template模版最终会被编译成render函数。其效果如下

<script>
import Child from "./components/Child.vue";

export default {
  components: { Child },
  render(h) {xxx},
};
</script>

我们写的SFC组件,最后导出的是一个对象

// src/core/vdom/create-component.ts
export function createComponent(
  Ctor: typeof Component | Function | ComponentOptions | void,
  data: VNodeData | undefined,
  context: Component,
  children?: Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }
  xxxx
  
  // 根实例 Vue.options._base = Vue
  const baseCtor = context.$options._base

  // 因为SFC组件最后导出的是一个对象,所以代码会执行下面逻辑,将我们传入的对象,转化为构造函数
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor as typeof Component)
  }
 
  xxx
  return vnode
}

我们发现以上三种方法创建组件的过程中都会调用Vue.extend方法,通过它生成一个构造器,然后这个构造器就是VueComponent!(上面我们也提到了每一个vue组件都是通过VueComponent构造函数生成的实例),这里我们通过源码得到了验证!

每个vue组件都可以访问Vue根实例原型对象上的方法,显然这是vue对VueComponentVue这两个构造函数做了关系处理,让VueComponent继承了Vue,我们来看看extend源码做了些什么

// src/core/global-api/extend.ts
 Vue.extend = function (extendOptions: any): typeof Component {
    extendOptions = extendOptions || {}
    const Super = this
    const SuperId = Super.cid
    const Sub = function VueComponent(this: any, options: any) {
      this._init(options)
    } as unknown as typeof Component
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    xxx
    return Sub
}

上面的代码有关继承的核心在:Sub.prototype = Object.create(Super.prototype),这不就是原型式继承嘛(Object.create将原型式继承规范化)。这里将VueComponent的原型引用了Vue的实例,也就是存在以下关系:

VueComponent.prototype.__proto__ === Vue.prototype

component(也就是我们日常在组件内使用的this).__proto__.__proto__ === Vue.prototype

这样我们在每个vue组件实例里this就可访问Vue原型对象上定义的全局方法、属性了

对于每一个vue组件完整的原型链条如下:

component -> VueComponent.prototype -> Vue实例(这个其实意义不大,内部临时生成的) -> Vue.prototype -> Object.prototype -> null

引用一个网图(原谅我偷懒了),这图非常清晰的描述了组件的原型链

image.png

数组响应式处理

我们知道以下修改数组的方式不具有响应式

var vm = new Vue({  
    data: {  
        items: ['a', 'b', 'c']  
    }  
})  
vm.items[1] = 'x' // 不是响应性的  
vm.items.length = 2 // 不是响应性的
  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength

至于原因,感兴趣的小伙伴可以自己去找资料,本篇文章不在展开讨论,尤大大的回复是:可以但没必要 ,具体可以看这个 issue

image.png

image.png

vue内部采用了重写数组方法的方式解决数组内部元素响应式

image.png

这里再次使用了Object.create(原型式继承)的方式生成了,然后让数组的实例原型指向arrayMethods

image.png

这样一来,当我们对data里面定义的数组调用push等方法的时候。实际调用的是arrayMethods.push

vue内部对其进行了拦截,在实现原有数组操作功能的同时,加入了自己定制化的功能(响应式)。 也许会有小伙伴问直接拦截Array.prototype不可以吗?

类似这种:

var originPush = Array.prototype.push

Array.prototype.push = function () {
    // 响应式逻辑 
    xxxx
    originPush.apply(this, arguments)
}

当然不可以这样!尽量不要修改内置对象提供的原生方法

业务场景

eventbus

组件之间的通信是我们业务中经常遇到的场景,当两个通信组件的层级比较复杂的时候,其推荐的方案是eventBus。我们通常这样使用

// A.vue
this.$eventbus.$emit('event-a', val)

// B.vue
this.$eventbus.$on('event-a', (val) => {})
this.$eventbus.$off('event-a')

// 如果监听只需要触发一次
this.$eventbus.$once('event-a', (val) => {})

eventBus本质就是个Vue实例,在Vue的原型对象上添加了这些属性

image.png

我们为了方便在各个组件实例访问该实例,还会额外将该实例添加到Vue原型对象上

// EventBus.js
import Vue from 'vue'
const EventBus = new Vue()

export default EventBus

// main.js
import Vue from 'vue'
import EventBus from '../EventBus'

Vue.prototype.$eventbus = EventBus

其他我们经常使用的工具库也可以统一使用this.$utils、项目埋点方法统一使用 this.$statistics等。原理都一致

我们公司项目使用的是qiankun微前端的框架,有特殊场景需要从一个子产品跳转到另外一个子产品(子应用间跳转),这时候我们需要借助主应用的router去跳转,而不能使用当前子应用的router。这时候我们的处理方式就是,将主应用的路由作为props传递给子应用,子应用接受后挂载到 vue原型上,这样在子应用任何一个组件都可以使用,简化代码如下:

// 主应用
app.props = {
  router: this.$router,
}
await mountMicroApp(app)

// 子应用
export async function mount(props) {
  Vue.prototype.$parentRouter = props.router
}

// 子应用组件使用
this.$parentRouter.push(xxx)

vue3

温馨提示:vue3源码版本为3.4.27。以下源码会适当简化,只关注原型、原型链相关逻辑代码

provide / inject

使用场景与vue2一样,当需要从父组件向深层子代组件传递数据时使用。具体使用可以参考官方文档,这里不过多描述。我们直接看相关源码

provide

import {
  createApp
} from 'vue'
import App from './App.vue'

const app = createApp(App)
app.mount('#app')
console.log(app._context.provides);

根实例的provides是一个使用Object.create(null)创建的对象

image.png

我们可以在源码中找到对应代码

image.png

当然,如果我们使用了类似piniavue-router等插件。根实例的provides对象会添加很多属性方法。当然这是pinia,vue-router做了额外处理,这里不过多描述

image.png

// packages/runtime-core/src/component.ts
export function createComponentInstance(
  vnode: VNode,
  parent: ComponentInternalInstance | null,
  suspense: SuspenseBoundary | null,
) {
  const type = vnode.type as ConcreteComponent
  // 继承根实例的appContext
  const appContext =
    (parent ? parent.appContext : vnode.appContext) || emptyAppContext

  // 每一个实例都会添加provides属性引用的是其父组件的provides
  // 如果已经是最顶层组件,则取根实例的appContext的provides
  const instance: ComponentInternalInstance = {
    provides: parent ? parent.provides : Object.create(appContext.provides),
  }

  return instance
}

组件实例的provides对象默认指向父组件的provides,最顶层组件没有父组件则继承根实例的provides,可以用以下代码简易描述下

// 根实例provides
const appProvides = Object.create(null)
// App组件实例provides
const AppComponentProvides = Object.create(appProvides)

Object.getPrototype(AppComponentProvides) === appProvides // true

也就是说所有组件默认情况provides都是指向根实例的providesObject.create(appProvides),我们用代码验证下这个结论

// App.vue
<script setup>
import {getCurrentInstance} from 'vue';
import Child from './components/Child.vue'
console.log('App:>> ', getCurrentInstance().provides);
</script>

<template>
  <header>
    <div class="wrapper">
      <Child />
    </div>
  </header>
</template>

// Child.vue
<script setup>
import {getCurrentInstance} from 'vue';
import GrandChild from './GrandChild.vue'

console.log('child :>> ', getCurrentInstance().provides);
</script>

<template>
  <div class="greetings">
   <GrandChild />
  </div>
</template>

打印结果如下:

image.png

那么如果组件本身需要额外增加provides属性呢(即父组件向子组件provide属性),答案在下面的源码里,我们来看看

// packages/runtime-core/src/apiInject.ts
function provide(
  key,
  value
) {
  let provides = currentInstance.provides
  const parentProvides =
    currentInstance.parent && currentInstance.parent.provides
  
  // 通过上面的源码我们知道组件默认的provides指向父组件的provides
  // 所以有且只有第一次执行provides,触发这段逻辑,因为后面provides指向改了
  // 顶层组件不会执行这部分逻辑,直接会赋值
  if (parentProvides === provides) {
    provides = currentInstance.provides = Object.create(parentProvides)
  }
  provides[key] = value
}

这段代码比较容易产生困惑的点在这段代码:provides = currentInstance.provides = Object.create(parentProvides),我们可以通过画图来理解这整个过程(忽略画功、字丑--||, 还有应该是 app._context, 偷懒不想重新画了)

image.png

当我们为App组件添加自身的provides属性的时候

<script setup>
import {getCurrentInstance, provide} from 'vue';
import Child from './components/Child.vue'

provide('appKey', 1)
console.log('App:>> ', getCurrentInstance().provides);
</script>

image.png

我们在回过头来看这段代码:provides = currentInstance.provides = Object.create(parentProvides)就很好理解了

我们再来看下控制台打印

image.png

代码第一次刚进来let provides = currentInstance.provides,表面上provides指向的是currentInstance.provides,但实际指向的是parentProvides,而后面修改currentInstance.provides指向必须要同时修改provides指向!!否则provides[key] = value这段代码就是给parentProvides添加属性了(后续再次指向函数,不会有这个考虑点,本身也不会再次进入if内的逻辑)

相信小伙伴已经清楚了provide的工作机制了,那么如果我在 Child.vue也有自身provide的属性会是怎么样的呢。这里直接给出打印

image.png

inject

export function inject(
  key,
  defaultValue,
  treatDefaultAsFactory = false
) {
  
  xxxx
  if (instance) {
    // 如果intance位于根目录下,则返回到appContext的provides,否则就返回父组件的provides
    const provides =
      instance.parent == null
        ? instance.vnode.appContext && instance.vnode.appContext.provides
        : instance.parent.provides

    if (provides && key in provides) {
      return provides[key]
    } else if (arguments.length > 1) {
      xxx
      return defaultValue
    }
  }
}

这段代码非常简单,就是基于原型链查找,不过多描述了

总结

每个组件都有一个provides属性,默认引用的父级组件的provides,而根组件的provides引用的是Object.create(appContext.provides)。如果自身添加provides属性,对于根组件直接添加。对于子组件会采用原型式继承(Object.crteate)的方式生成自身的一个provides对象,并在其身上添加。inject则是倒序,首先从子组件的provides查找是否有该属性。没有就查找父组件的,依次往上直到根实例,没有就为undefined

最后

回头看,我们发现在源码里面,原型式继承(Object.create)的使用场景非常多,相信经过了上面实战的案例分析,小伙伴们对原型原型链的理解更加深刻了。也能在我们日常业务开发中更加熟练、大胆的使用原型原型链

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论

参考文档

Vue组件化之VueComponent介绍