面试官问你Vue.extend时,怎么回答才能唬住他?(源码级详解)

前言

Vue.extend是 Vue 里的一个全局 API,它提供了一种灵活的挂载组件的方式,这个 API 在日常开发中很少使用,毕竟只在碰到某些特殊的需求时它才能派上用场,但是我们也要学习它,学习这个 API 可以让我们对 Vue 更加了解,更加熟悉 Vue 的组件初始化和挂载流程,除此之外,也经常会有面试官问到这个东西。下面我们就来从源码到应用彻彻底底的看一看这个 API。

Vue.extend 定义

引用一个官方的定义:

使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

data 选项是特例,需要注意 - 在 Vue.extend() 中它必须是函数

<div id="mount-point"></div>
// 创建构造器
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')

结果如下

<p>Walter White aka Heisenberg</p>

之前没用过这个 API 的小伙伴看到这个定义肯定会一头雾水,但是没关系,相信你看完这篇文章后以定会理解的。

这个 API 可以实现很灵活的功能,比如 ElementUI 里的$message,我们使用this.$message('hello')的时候,其实就是通过这种方式创建一个组件实例,然后再将这个组件挂载到了 body 上,本篇文章也会分析如何实现这个组件,下面我们先来看下Vue.extend的源码,从根源上来了解它。

源码分析

你可以在源码目录src/core/global-api/extend.js下找到这个函数的定义

export function initExtend(Vue: GlobalAPI) {
  // 这个cid是一个全局唯一的递增的id
  // 缓存的时候会用到它
  Vue.cid = 0
  let cid = 1

  /**
   * Class inheritance
   */
  Vue.extend = function(extendOptions: Object): Function {
    // extendOptions就是我我们传入的组件options
    extendOptions = extendOptions || {}
    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]
    }

    const name = extendOptions.name || Super.options.name
    if (process.env.NODE_ENV !== 'production' && name) {
      validateComponentName(name)
    }

    // 创建Sub构造函数
    const Sub = function VueComponent(options) {
      this._init(options)
    }

    // 继承Super,如果使用Vue.extend,这里的Super就是Vue
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++

    // 将组件的options和Vue的options合并,得到一个完整的options
    // 可以理解为将Vue的一些全局的属性,比如全局注册的组件和mixin,分给了Sub
    Sub.options = mergeOptions(Super.options, extendOptions)
    Sub['super'] = Super

    // 下面两个设置了下代理,
    // 将props和computed代理到了原型上
    // 你可以不用关心这个
    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // 继承Vue的global-api
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use

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

    // 在components里添加一个自己
    // 不是主要逻辑,可以先不管
    if (name) {
      Sub.options.components[name] = Sub
    }

    // 将这些options保存起来
    // 一会创建实例的时候会用到
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)

    // 设置缓存
    // 就是上文的缓存
    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做的事情很简单,就是继承 Vue,正如定义中说的那样,创建一个子类,最终返回的这个 Sub 是:

const Sub = function VueComponent(options) {
  this._init(options)
}

那么上文的例子中的new Profile()执行的就是这个方法了,因为继承了 Vue 的原型,这里的_init就是 Vue 原型上的_init方法,你可以在源码目录下src/core/instance/init.js中找到它:

Vue.prototype._init = function(options?: Object) {
  const vm: Component = this
  // a uid
  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)
  }

  vm._isVue = true
  // merge options
  if (options && options._isComponent) {
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  }
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
    initProxy(vm)
  } else {
    vm._renderProxy = vm
  }
  // expose real self
  vm._self = vmnext
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  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)
  }

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

这个函数里有很多逻辑,它主要做的事情就是初始化组件的事件,状态等,大多不是我们本次分析的重点,你目前只需要关心里面的这一段代码:

if (options && options._isComponent) {
  initInternalComponent(vm, options)
} else {
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
}

执行new Profile()的时候没有传任何参数,所以这里的 options 是 undefined,会走到 else 分值,然后resolveConstructorOptions(vm.constructor)其实就是拿到Sub.options这个东西,你可以在上文的Vue.extend源码中找到它,然后将Sub.optionsnew Profile()传入的options合并,再赋值给实例的$options,所以如果new Profile()的时候传入了一个 options,这个 options 将会合并到vm.$options上,然后在这个_init函数的最后判断了下vm.$options.el是否存在,存在的话就执行vm.$mount将组件挂载到 el 上,因为我们没有传 options,所以这里的 el 肯定是不存在的,所以你才会看到例子中的new Profile().$mount('#mount-point')手动执行了$mount方法,其实经过这些分析你就会发现,我们直接执行new Profile({ el: '#mount-point' })也是可以的,除了 el 也可以传其他参数,接着往下看就知道了。

$mount 方法会执行“挂载”,其实内部的整个过程是很复杂的,会执行 render、update、patch 等等,由于这些不是本次文章的重点,你只需要知道她会将组件的 dom 挂载到对应的 dom 节点上就行了,如$mount('#mount-point')会把组件 dom 挂载到#mount-point这个元素上。

如何使用

经过上面的分析,你应该大致了解了Vue.extend的原理以及初始化过程,以及简单的使用,其实这个初始化和平时的new Vue()是一样的,毕竟两个执行的同一个方法。但是在实际的使用中,我们可能还需要给组件传 props,slots 以及绑定事件,下面我们来看下如何做到这些事情。

使用 props

比如我们有一个 MessageBox 组件:

<template>
  <div class="message-box">
    {{ message }}
  </div>
</template>

<script>
  export default {
    props: {
      message: {
        type: String,
        default: ''
      }
    }
  }
</script>

它需要一个 props 来显示这个message,在使用Vue.extend时,要想给组件传参数,我们需要在实例化的时候传一个 propsData,
如:

const MessageBoxCtor = Vue.extend(MessageBox)
new MessageBox({
  propsData: {
    message: 'hello'
  }
}).$mount('#target')

你可能会不明白为什么要穿propsData,没关系,接下来就来搞懂它,毕竟文章的目的就是彻底分析。

在上文的_init函数中,在合并完$options后,还执行了一个函数initState(vm),它的作用就是初始化组件状态(props,computed,data):

export function initState(vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe((vm._data = {}), true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

别的不看,只看这个:

if (opts.props) initProps(vm, opts.props)
function initProps(vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = (vm._props = {})
  // ...省略其他逻辑
}

这里的 propsData 就是数据源,他会从vm.$options.propsData上取,所我们传入的propsData经过mergeOptions后合并到vm.$options,再到这里进行 props 的初始化。

绑定事件

可能有时候我们还想给组件绑定事件,其实这里应该很多小伙伴都知道怎么做,我们可以通过vm.$on给组件绑定事件,这个也是平时经常用到的一个 api

const MessageBoxCtor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBox({
  propsData: {
    message: 'hello'
  }
}).$mount('#target')
messageBoxInstance.$on('some-event', () => {
  console.log('success')
})

使用插槽

为了更加灵活的定制组件,我们还可以给组件传入插槽,比如组件可能是这样的:

<template>
  <div class="message-box">
    {{ message }}
    <slot name="footer"/>
  </div>
</template>

<script>
  export default {
    props: {
      message: {
        type: String,
        default: ''
      }
    }
  }
</script>

这里我们先来分析下,如何才能给组件传入插槽内容?其实这里写的 template 会被 Vue 的编译器编译成一个 render 函数,组件渲染时执行的是这个渲染函数,我们先来看下这个 template 编译后的 render 是什么:

function render() {
  with (this) {
    return _c(
      'div',
      {
        staticClass: 'message-box'
      },
      [_v(_s(message)), _t('footer')],
      2
    )
  }
}

这里的_t('footer')就是渲染插槽时执行的函数,_trenderSlot的缩写,你可以在源码目录的src/core/instance/render-helpers/render-slot.js中找到这个函数,为方便理解,我将这个函数做了些简化,去除掉了不重要的逻辑:

export function renderSlot(name, fallback, props) {
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes /** Array<VNode> */
  if (scopedSlotFn) {
    // scoped slot
    props = props || {}
    nodes = scopedSlotFn(props) || fallback
  } else {
    nodes = this.$slots[name] || fallback
  }
  return nodes
}

这个函数就是从$scopedSlots中取到对应的插槽函数,然后执行这个函数,得到虚拟节点,然后返回虚拟节点,需要注意的是,Vue 在2.6.x版本中已经将普通插槽和作用域插槽都整合在了$scopedSlots,所有的插槽都是返回虚拟节点的函数,renderSlot里面的else分支中从$slots取插槽是兼容以前的写法的,所以说如果你用的是Vue2.6.x版本的话,你是不需要去关心$slots的。

由于renderSlot执行在组件实例的作用域中,所以this.$scopedSlots这里的this是组件的实例vm,所以我们只需要在创建完组件实例后,在实例上添加$scopedSlots就可以了,再根据之前的分析,这个$scopedSlots是一个对象,其中的 key 是插槽名称,value 是一个返回虚拟节点数组的函数:

const MessageBoxCtor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBox({
  propsData: {
    message: 'hello'
  }
})
const h = this.$createElement
messageBoxInstance.$scopedSlots = {
  footer: function() {
    return [h('div', 'slot-content')]
  }
}
messageBoxInstance.$mount('#target')

这里需要注意的是$mount一定要在设置完$scopedSlots之后,因为$mount中会执行渲染函数,我们要保证在执行渲染函数时能获取到$scopedSlots

如果你想使用作用域插槽,也很简单,和普通插槽是一样的,只需要在函数中接收参数就可以了:

<slot name="head" :message="message"></slot>
messageBoxInstance.$scopedSlots = {
  footer: function(slotData) {
    return [h('div', slotData.message)]
  }
}

这样就可以成功渲染出message了。

总结

本文章针对Vue.extend这个 API,从源码到使用彻底进行了分析,相信你看过后应该能游刃有余地使用了。当然只有这些理论肯定还是不够的,我们需要在实际开发场景中找到它的应用场景,比如我们常用的命令式弹窗组件,在很多 UI 组件库里都能看到它,你也可以去看一下 ElementUI 的 Message 组件的源码,看看是怎么实现的,有了这篇文章的基础后相信你一定能看懂。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容

  • 前言 使用Vue在日常开发中会频繁接触和使用生命周期,在官方文档中是这么解释生命周期的: 每个 Vue 实例在被创...
    心_c2a2阅读 2,238评论 1 8
  • 此文章仅记录学习Vue中一些平常自己没有去学习到知识,很多东西都是基于自我的认知去写的。文中可能会有理解错误的地方...
    Mstian阅读 772评论 0 10
  • 前言 Vue基本用法很容易上手,但是有很多优化的写法你就不一定知道了,本文从列举了 36 个 vue 开发技巧; ...
    阿_军阅读 1,441评论 0 1
  • MVVM model和view层通过中间的vm连接和驱动。model层数据变化会改变视图,view改变通过事件来修...
    Super曲江龙Kimi阅读 699评论 0 1
  • 目录 课时01🍡安装渲染——声明式文本插值元素属性条件指令循环指令事件监听双向绑定组件应用定义使用渲染 课时02🍡...
    一点金光阅读 217评论 0 0