likes
comments
collection
share

全面解析 Vue.extend 及其应用

作者站长头像
站长
· 阅读数 41

前言

在项目加载的过程中缺少了 loading 就像失去了灵魂😂,让用户感到困惑的体验那实在是太糟糕了,

如何在不引入 ElementUI 组件库的情况下自己来实现一个 loading 效果呢?

经过翻阅 Element 的源码,我发现了其中的奥秘,在 message 的实现中用到了一个 Vue.extend 的全局API 实现了 message 的全局注册,Message组件的源码位置: packages\message\src

那么我也也试着用 Vue.extend 来实现一个全屏loading加载效果吧!!!

首先来看一下 Vue.extend 是什么:

Vue.extend

Vue.extend 是 Vue 的全局 API,它提供了一种灵活的挂载组件的方式。相比常用的 Vue.component 写法,使用 Vue.extend 步骤更加繁琐一些,但是常用于一些独立组件开发场景中(例如 ElementUI 的 message),通过Vue.extend+message ),通过 Vue.extend + message),通过Vue.extend+mount 这对组合使一些动态渲染或使用 js 全局调用的组件变得更加灵活 。

全面解析 Vue.extend 及其应用

Vue.extend 用于局部注册组件并创建子类,方法返回一个组件构造器,通过组件构造器创建组件实例,该实例的参数是一个包含组件选项的对象,用来在实例上扩展属性和方法。

官方提供的使用方式:

  1. 页面中的元素,即组件的挂载位置
<div id="mount-point"></div>
  1. 通过 Vue.extend 创建构造器,并实现元素的挂载
// 创建构造器
var Profile = Vue.extend({
  template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
  data: function () {
    return {
      firstName: 'Walter',
      lastName: 'White',
      alias: 'Heisenberg'
    }
  }
})

// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount('#mount-point')

extend 创建的是 Vue 构造器,而不是我们平时常写的组件实例,需要通过实例化后再使用

需要通过 new Profile() 进行组件的实例化,再将 Vue 实例挂载到指定的元素('#mount-point)上。

  1. 最终页面得到的效果如下
<div id="mount-point">
  <p>Walter White aka Heisenberg</p>
</div>

vue.component

Vue.component 是一个全局注册的方法,用于注册一个全局组件。

全面解析 Vue.extend 及其应用

Vue.component 的使用方法相对比较简单,将写好的组件引入到 main.js 中

使用 Vue.component('xx-xxx',xxx) 即可实现组件的全局注册。

实际开发中,我们可以根据具体的需求选择使用 Vue.component 或者 Vue.extend。

  • 创建全局组件且组件数量不会太多的情况下,可以使用 Vue.component;
  • 如果需要创建多个实例且每个实例之间互不干扰,可以使用 Vue.extend。

为什么使用 extend ?

在 vue 项目中,有了初始化的根实例后,所有页面基本上都是通过 router 来管理注册,组件中也可以通过 import 实现组件的局部注册,所以组件的创建我们不需要去关注,但是这样做会有几个缺点:

  1. 组件模板都是事先定义好的,如果我要从接口动态渲染组件怎么办?
  2. 所有内容都是在 #app 下渲染,注册组件都是在当前位置渲染。如果实现的是一个函数形式调用 window.alert() 全局提示组件,该如何实现呢?

这时候,Vue.extend + vm.$mount 组合就派上用场了。

如何利用 Vue.extend 来实现全局的组件 ?

Vue.extend 的具体使用

实现 loading 的挂载

前提准备:在 component 目录下创建一个loading 文件夹,用于存放 loading 组件的相关内容。

loading 样式

先写一个loading.vue页面,用于实现 loading 样式

<template>
  <!-- 打开弹框的动画 -->
  <transition name="animation">
    <div
      v-if="showLoading"
      class="loadingWrap"
      :style="{ background: backgroundColor }">
      <div class="loadingContent">
        <div class="iBox">
          <img src="@/assets/imgs/loading.svg" alt="" class="loading">
        </div>
        <div class="text">{{ text }}</div>
      </div>
    </div>
  </transition>
</template>

<script>
export default {
  data () {
    return {
      showLoading: false, // 控制显示与隐藏的标识
      backgroundColor: 'rgba(0, 0, 0, .1)', // 默认背景色
      text: '', // 默认文字
    }
  },
}
</script>

  <style lang="scss" scoped>
.loadingWrap {
   position: absolute;
   top: 0;
   left: 0;
   width: 100%;
   height: 100%;
   display: flex;
   justify-content: center;
   align-items: center;
   .loadingContent {
      color:  #1762ef;
      text-align: center;
    .iBox {
      margin-bottom: 6px;
      .loading {
        width: 26px;
        height: 26px;
        transform: rotate(360deg);
        animation: rotation 3s linear infinite;
      }
    }
  }
  .text {
    color: #1762ef;
  }
}
  // 加一个过渡效果
  .animation-enter, .animation-leave-to { opacity: 0;}
  .animation-enter-active, .animation-leave-active { transition: opacity .5s; }

  @keyframes rotation{
    from {-webkit-transform: rotate(0deg);}
    to {-webkit-transform: rotate(360deg);}
  }

  </style>

挂载 loading

在loading同级目录下创建 loading.js 将组件挂载到全局

// 引入vue
import Vue from 'vue'

// 引入loading组件
import Loading from './loading'

// 通过Vue的extend方法继承这个引入的 loading 组件,继承后会返回一个vue子类,需要使用实例化即可
const LoadingConstructor = Vue.extend(Loading)

// 创建实例并且挂载到 div上
const loading = new LoadingConstructor().$mount(document.createElement('div'))

// 显示loading效果
function showLoad (options) {
  // 初始化调用传递过来的参数赋值更改组件内内部值
  for (const key in options) {
    loading[key] = options[key]
  }
  // 让其显示
  loading.showLoading = true
  // 并将 Vue.extend 创建的 dom 元素插入body中
  document.getElementById('app').appendChild(loading.$el)
}

// 关闭loading效果
function hideLoad () {
  // 因为是v-if去控制,所以将标识showLoading置为false,就会自动把弹框dom删掉
  loading.showLoading = false
}

// 将控制 loading 的方法挂载到 Vue 原型
Vue.prototype.$showLoad = showLoad
Vue.prototype.$hideLoad = hideLoad

在 main.js 中引入

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import '@/components/loading/index.js'

new Vue({
  router,
  render: h => h(App),
}).$mount('#app')

通过 this.$showLoad() 即可实现页面的 loading 效果

全面解析 Vue.extend 及其应用

全局 toast 实现

仿照 element-ui 的 Message 实现全局 toast 效果。

toast 样式

<template>
  <div id="toast">
    <p :class="[type ? `toast-${type}` : '', size ? `toast-${size}` : '']">
      {{ message }}
      <svg t="1684223217446" @click="handleClick" class="close-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2372" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M572.16 512l183.466667-183.04a42.666667 42.666667 0 1 0-60.586667-60.586667L512 451.84l-183.04-183.466667a42.666667 42.666667 0 0 0-60.586667 60.586667l183.466667 183.04-183.466667 183.04a42.666667 42.666667 0 0 0 0 60.586667 42.666667 42.666667 0 0 0 60.586667 0l183.04-183.466667 183.04 183.466667a42.666667 42.666667 0 0 0 60.586667 0 42.666667 42.666667 0 0 0 0-60.586667z" p-id="2373" :fill="color[type]"></path></svg>
    </p>
  </div>
</template>

<script>
export default {
  data () {
    return {
      // 显示类型(success,info,warning,error),默认为info
      type: 'info',
      // 显示大小(default,small,big),默认为default
      size: 'default',
      // 显示文字内容
      message: '',
      // 显示时间,默认为3000
      duration: 3000,
      timer:null,
      color:{
        'error':'#F56C6C',
        'success': '#67C23A',
        'info': '#909399',
        'warning':'#E6A23C',
      }

    }
  },
  beforeDestroy() {
    clearTimeout(this.timer)
  },
  mounted () {
    // 指定时间后销毁组件
    this.timer =setTimeout(() => {
      this.$destroy(true) // 销毁组件
      this.$el.parentNode.removeChild(this.$el) // 父元素中移除dom元素($el为组件实例)
    }, this.duration)
   
  },
  methods: {
    // 点击 icon 触发组件的销毁, 同时触发自定义事件
    handleClick() {
      if(this.timer){
        clearTimeout(this.timer)
      }
      this.$destroy(true) // 销毁组件
      this.$el.parentNode.removeChild(this.$el) 
      this.$emit('close-event')
    }
  },

}
</script>

  <style lang="scss" scoped>
  #toast {
    position: fixed;
    top: 10px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 9999;
    p {
        border-radius: 4px;
    }
    .toast-success {
        background-color: #F0F9EB;
        color: #67C23A;
        border: 1px solid #E1F3D8;
    }
    .toast-info {
        background-color: #F4F4F5;
        color: #909399;
        border: 1px solid  #EBEEF5;
    }
    .toast-warning {
        background-color: #FDF6EC;
        color: #E6A23C;
        border: 1px solid #FAECD8;
    }
    .toast-error {
        background-color: #FEF0F0;
        color: #F56C6C;
        border: 1px solid #FDE2E2;
    }
    .toast-default {
        padding: 12px 28px;
        font-size: 16px;
    }
    .toast-small {
        padding: 10px 16px;
        font-size: 14px;
    }
    .toast-big {
        padding: 14px 40px;
        font-size: 18px;
    }
    .close-icon {
      width: 20px;
      height: 20px;
      vertical-align: -4px;
    }
    
}
  </style>

挂载到全局

通过 vm.$on 给组件绑定事件,这个也是平时经常用到的一个 api

import Vue from 'vue'
import Toast from './toast.vue'

// 创建Toast构造器
const ToastConstructor = Vue.extend(Toast)
let instance

function toast (options = {}) {
  // 设置默认参数为对象,如果参数为字符串,参数中message属性等于该参数
  if (typeof options === 'string') {
    options = {
      message: options,
    }
  }
  // 创建实例
  instance = new ToastConstructor({
    data: options,
  })
  
  // 注册组件的监听事件
  instance.$on('close-event', () => {
    console.log('success')
  })
  
  // 将实例挂载到body下
  document.body.appendChild(instance.$mount().$el)
}

// 将Toast组件挂载到vue原型上
Vue.prototype.$toast = toast

在 main 中引入

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import '@/components/toast/index.js'

new Vue({
  router,
  render: h => h(App),
}).$mount('#app')

使用方式

调用提供的全局方法 $toast() 即可

this.$toast({
  type: 'error',
  size: 'default',
  message: '数据请求出错请联系管理员!',
  duration: 3000,
})

全面解析 Vue.extend 及其应用

Vue.extend 源码实现

在了解了 Vue.extend 的使用方式后,有没有兴趣来了解一下它的实现原理呢?

知其然知其所以然,从这里开始就对自己的能力有了新的要求,深入源码的实现,从根源上了解它。

Vue.extend 的源码位置:src/core/global-api/extend.js

// 定义 Vue.extend 方法   基于 Vue 构造器,创建一个 Vue 的“子类” 
export function initExtend (Vue: GlobalAPI) {

  // 每个实例构造函数,包括 Vue,都有一个唯一的 cid。
  // 这使我们能够为原型继承创建包装的“子构造函数”并缓存它们。
  // 这个cid是一个全局唯一的递增的id, 缓存的时候会用到它,形成闭包
  Vue.cid = 0
  let cid = 1

  /**
   * Class inheritance
   * 基于 Vue 去扩展子类,该子类同样支持进一步的扩展
   * 扩展时可以传递一些默认配置,就像 Vue 也会有一些默认配置
   * 默认配置如果和基类有冲突则会进行选项合并(mergeOptions)
   */
  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    // 这里的Super就是Vue
    const Super = this
    const SuperId = Super.cid

    // 每次创建完Sub构造函数后,都会把这个函数储存在extendOptions上的_Ctor中
    // 下次如果用再同一个extendOptions创建Sub时
    // 就会直接从_Ctor返回
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }

  /**
   * 利用缓存,如果存在则直接返回缓存中的构造函数
   * 什么情况下可以利用到这个缓存?
   *   如果你在多次调用 Vue.extend 时使用了同一个配置项(extendOptions),这时就会启用该缓存
   */
    
  // 验证组件名称
    const name = extendOptions.name || Super.options.name
    if (process.env.NODE_ENV !== 'production' && name) {
      validateComponentName(name)
    }


    // 创建 Sub 构造函数,和 Vue 构造函数一样
    const Sub = function VueComponent (options) {
      // 调用 Vue.prototype._init,之后的流程就和首次加载保持一致
      this._init(options)
    }


    // 通过原型继承的方式继承 Vue,这里的Super就是Vue
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++

    
    // 选项合并,合并 Vue 的配置项到 自己的配置项上来
    Sub.options = mergeOptions(
      Super.options,   // Vue 的 options
      extendOptions   //  组件的 options
    )
    Sub['super'] = Super

    // 初始化 props,将 props 配置代理到 Sub.prototype._props 对象上
    // 在组件内通过 this._props 方式可以访问
    if (Sub.options.props) {
      initProps(Sub)
    }


    // 初始化 computed,将 computed 配置代理到 Sub.prototype 对象上
    // 在组件内可以通过 this.computedKey 的方式访问
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // allow further extension/mixin/plugin usage
    // 继承 Vue 的 global-api:extend、mixin、use 这三个静态方法,允许在 Sub 基础上再进一步构造子类
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use

    // 继承assets的api,比如注册组件(component),指令(filter),过滤器(directive) 静态方法
    ASSET_TYPES.forEach(function (type) {
      Sub[type] = Super[type]
    })


    // 递归组件的原理,如果组件设置了 name 属性,则将自己注册到自己的 components 选项中
    if (name) {
      Sub.options.components[name] = Sub
    }


    // 在扩展时保留对基类选项的引用。
    // 稍后在实例化时,我们可以检查 Super 的选项是否具有更新
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)

    // cache constructor 设置缓存
    cachedCtors[SuperId] = Sub
    return Sub
  }
}

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

function initComputed (Comp) {
  const computed = Comp.options.computed
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key])
  }
}

Vue.extend 中实现的流程如下:

  1. 创建一个子类构造函数 Sub,并继承父类构造函数 Super 的原型对象。
  2. 将子类构造函数的 cid 属性自增,以确保每个组件拥有唯一的 cid。
  3. 将父类的选项和子类的选项合并到子类的 options 属性中。
  4. 如果子类有 props 或 computed 属性,则在子类的原型对象上定义代理属性,避免每次实例化时都需要调用 Object.defineProperty。
  5. 将父类的 extend、mixin 和 use 方法复制到子类上,以便子类可以继续扩展。
  6. 继承父类身上的assets的api。
  7. 如果子类有 name 属性,则将子类注册到父类的 components 选项中。
  8. 缓存子类构造函数,避免重复创建。

在 Vue.extend 中实现 Vue 的继承,创建了一个子类 Sub ,将 Vue上的各种配置项合并到自身最终将子类返回。

// 创建 Sub 构造函数,和 Vue 构造函数一样
const Sub = function VueComponent (options) {
  // 调用 Vue.prototype._init,之后的流程就和首次加载保持一致
  this._init(options)
}

Vue.extend 返回的子类实例化时,执行的就是上面从 Vue 原型上继承到的  _init() 方法

_init() 组件初始化

原型上的 _init() 在 initMixin 中被定义 源码位置位于src\core\instance\init.js

export function initMixin(Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid 每个实例都有一个 uid 每实例化一个 uid + 1
    vm._uid = uid++

    let startTag, endTag
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }

    // a flag to avoid this being observed
    vm._isVue = true
    // 合并选项
    if (options && options._isComponent) {
      // 子组件的初始化
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        // 根组件走这里  选项合并  将全局配置选项合并到根组件的局部配置上
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    if (process.env.NODE_ENV !== 'production') {
      // initProxy给实例创建proxy代理 ,代理的位置在vm._renderProxy
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    
    //   初始化组件实例关系属性,比如 $parent、$children、$root、$refs 等
    initLifecycle(vm)  
    
    /**
     * 初始化自定义事件,这里需要注意一点,我们在 <comp @click="handleClick" /> 上注册的事件,
     * 监听者不是父组件,而是子组件本身,也就是说事件的派发和监听者都是子组件本身,和父组件无关
    */
    initEvents(vm)

    //  渲染初始化 初始化插槽  获取 this.$slots 定义 this._c 即 createElement() 用于创建 VNode
    //  通常也称为 h 函数
    initRender(vm)

    // 执行 beforeCreate 生命周期钩子函数
    callHook(vm, 'beforeCreate')

    // 初始化组件的 inject 配置项,得到 result[key] = val 形式的配置对象,
    // 对结果数据进行响应式处理,并代理每个 key 到 vm 实例
    initInjections(vm)

    // 数据响应式的重点,处理 props、methods、data、computed、watch
    initState(vm)

    // 解析组件配置项上的 provide 对象,将其挂载到 vm._provided 属性上
    initProvide(vm) // resolve provide after data/props

    // 执行 created 生命周期钩子函数
    callHook(vm, 'created')

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }

    // 如果发现配置项上有 el 选项,则自动调用 $mount 方法
    // el 选项为 Vue 实例的挂载目标,
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

_init() 方法是 Vue  实例初始化过程中的核心方法,它完成了组件实例的初始化、状态的初始化、事件的初始化以及渲染相关的属性和方法的初始化等工作。

实例化时的合并选项过程中判断 会对实例化的传参进行判断

// 合并选项
if (options && options._isComponent) {
  // 初始化组件实例的内部属性
  initInternalComponent(vm, options)
} else {
  /*
  *  mergeOptions 合并构造函数和用户传入的选项,得到最终的选项对象,
  *  并将其赋值给 vm.$options 属性。
  */
  vm.$options = mergeOptions(
    // 获取构造函数上的选项
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
}

在合并选项时通过 resolveConstructorOptions 获取构造函数上的配置项 即 Sub.options,

将 Sub.options 和 new Profile() 传入的 options 合并,再赋值给实例的 vm.$options 属性,得到最终配置项。

在 _init 的最后判断是否需要将组件进行挂载

  // 如果发现配置项上有 el 选项,则自动调用 $mount 方法
  // el 选项为 Vue 实例的挂载目标,
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }

由于我们在实例化时没有传入任何配置项,所以在 _init 的最后是不会进行挂载的,此时需要我们手动执行进行挂载,所以你才会看到例子中的new Profile().$mount('#mount-point')的挂载方法,

其中 $mount('#mount-point') 表示将组件创建的实例挂载到 #mount-point 元素上。

那么通过源码的解读我们能发现另一种挂载方式:在组件实例化时配置挂载的 el 选项 new Profile({ el: '#mount-point' })

$mount 的实现原理

这里简单的介绍一下 $mount 的原理:在 vm.$mount 方法中,会根据传入的 el 参数获取到对应的 DOM 元素,然后通过 updateComponent 方法创建一个渲染 Watcher 对象,并将其挂载到 Vue 实例上。

渲染 Watcher 对象会在数据发生变化时重新渲染视图,并将渲染结果更新到对应的 DOM 元素上。

总结

在阅读了本文的内容之后,相信你对 Vue.extend 这个API 有了一定的了解和使用,像我们常用的命令式弹窗组件,大多都是基于 Vue.extend 实现的,并且我们能在很多 UI 组件库中看到它的运用。如果你还想做进一步的提升可以看一下 ElementUI 的 Message 组件的源码,有了本文的基础后相信你会更容易理解其中的具体实现。

在此记录一下学习的过程,希望对大家也能有所帮助 😀

参考

  1. 全局挂载组件之Vue.extend
  2. 面试官问你Vue.extend时
转载自:https://juejin.cn/post/7239715295484821565
评论
请登录