【手写 Vue2.x 源码】第三十六篇 - 组件部分 - Vue.extend 实现

298 阅读10分钟

一,前言

上篇,主要介绍了 Vue 初始化流程中的 Vue.component 实现:

  • Vue.component 全局 API 的初始化处理;
  • Vue.component 的定义和参数说明;
  • 组件构造函数全局存储的方式和作用;

本篇,组件部分 - Vue.extend 实现;


二,Vue.extend 简介

以下示例引用自 Vue 官网

1,前文回顾

上篇,介绍 Vue.component 实现时提到,第二个参数组件定义 definition 支持两种形式:

  • 既可以传入函数、
  • 也可以传入对象;

以下代码引用自官方文档:

// 写法 1:注册组件,传入一个扩展过的构造器 
Vue.component('my-component', Vue.extend({ /* ... */ })) 

// 写法 2:注册组件,传入一个选项对象 (自动调用 Vue.extend) 
Vue.component('my-component', { /* ... */ }) 

// 获取注册的组件 (始终返回构造器) 
var MyComponent = Vue.component('my-component')

两种形式的差异点:若组件定义 definition 为对象,则在 Vue.component 内部会通过 Vue.extend 进行一次包装,结果会产生一个组件的构造函数;(在 Vue.component 处理结束时,被保存到全局的 Vue.options.components 上备用)

2,Vue.extend 简介

Vue.extend(options) 方法:使用基础 Vue 构造器,创建一个“子类”;

  • options 参数:是一个包含组件选项的对象。

注意:在 Vue.extend() 中,data必须是函数;

Vue.extend 示例(以下示例引用自 Vue 官网):

<div id="mount-point"></div>

// 创建构造器
var Profile = Vue.extend({
  template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
  // data 必须是函数
  data: function () { 
    return {
      firstName: 'Walter',
      lastName: 'White',
      alias: 'Heisenberg'
    }
  }
})

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


// 组件挂载后的结果:
<p>Walter White aka Heisenberg</p>

三,Vue.extend 实现的分析与代码框架

1,当前代码

在上一篇介绍 Vue.component 时提到:“若组件定义 definition 为对象时,需要通过 Vue.extend 处理为组件的构造函数”,Vue.extend 方法尚未实现:

// src/global-api/index.js

export function initGlobalAPI(Vue) {

  Vue.options = {}; // 全局属性:Vue.options
  Vue.options.components = {};// 存放全局组件 name: definition

  /**
   * 使用基础的 Vue 构造器,创造一个子类
   *
   * @param {*} definition 
   */
  Vue.extend = function (definition) {
      // todo...
  }
  
  /**
   * Vue.component
   *
   * @param {*} id          组件名(默认)
   * @param {*} definition  组件定义:可能是对象或函数
   */
  Vue.component = function (id, definition) {
  
    // 获取组件名 name:优先使用definition.name,默认使用 id
    let name = definition.name || id;
    definition.name = name;

    // 如果传入的 definition 是对象,需要用 Vue.extend 处理
    if(isObject(definition)){
      definition = Vue.extend(definition)
    }

    // 将 definition 对象保存到全局:Vue.options.components
    Vue.options.components[name] = definition;
  }
}

Vue.component 方法执行完成的最终结果,就是将组件定义(子类)保存到 Vue.options.components 中;

2,Vue.extend 的逻辑分析

Vue.extend 的官方定义:Vue.extend 会使用基础Vue构造器,生成一个子类;

所以,在 Vue.extend 内部,需要生成一个继承自 Vue 的子类 Sub

  • 需要先拿到父类 Vue:即 Vue.extend 中的 this
  • 创建子类继承父类:创建一个子类 Sub 继承 Vue 的原型方法;

3,Vue.extend 的实现框架

以上分析的代码框架如下:

// src/global-api/index.js#initGlobalAPI

Vue.extend = function () {

    // 父类 Vue:当前 this 就是 Vue;
    const Super = this;
    
    // 创建子类 Sub
    const Sub = function (options) {

    }
    
    // 子类 Sub 继承 Vue 的原型方法
    Sub.prototype = Object.create(Super.prototype);
    
    // 返回生成的子类
    return Sub;
}

其中,const Sub = function (options) {} 就是组件的构造函数;


四,组件初始化的分析与实现

1,组件初始化的分析

提问:如何创造一个组件?

实际上,创造一个组件就是要得到一个组件的实例,即实例化组件的构造函数:

组件实例 = new 组件类

这里所说的组件的类组件的构造函数,正是 Vue.extend 处理后返回的子类 Sub;(Vue.extend 返回的子类 Sub 继承自 Vue

当组件被实例化时,需要进行组件的初始化操作,即执行 Vue 原型上的 _init 方法;

这里,先来回顾一下 new Vue 的初始化流程:

1)通过 initMixin,在 Vue 的原型上扩展了 _init 方法:

// src/index.js

/**
 * 在vue 中所有的功能都通过原型扩展(原型模式)的方式来添加
 * @param {*} options vue 实例化传入的配置对象
 */
function Vue(options) {
    this._init(options);  // 调用 Vue 原型上的方法 _init
}

initMixin(Vue)            // 扩展 Vue,添加原型方法 _init
renderMixin(Vue) 
lifeCycleMixin(Vue)
initGlobalAPI(Vue)

2)Vue 的原型方法 _init:选项合并、初始化状态、挂载...

// src/init.js#initMixin

Vue.prototype._init = function (options) {

  const vm = this;  // this 指向当前 vue 实例
  
  // 使用 options 与 mixin 合并后的全局 options,再进行一次合并
  vm.$options = mergeOptions(vm.constructor.options, options);
  
  // 目前在 vue 实例化时,传入的 options 只有 el 和 data 两个参数
  initState(vm);  // 状态的初始化

  if (vm.$options.el) {
    // 将数据挂在到页面上(此时,数据已经被劫持)
    vm.$mount(vm.$options.el)
  }
}

2,组件初始化的实现

new Sub(options) 时,要进行组件的初始化操作,即执行 _init 方法;

所以,在组件实例化时,需要调用 this._init 执行组件的初始化操作:

Vue.extend = function () {

    // 父类 Vue 即当前 this;
    const Super = this;
    
    // 创建子类 Sub   ==>   new Sub(options)
    const Sub = function (options) {
        // 当 new 组件时,执行组件初始化 
        this._init(options);
    }
    
    // 子类 Sub 继承 Vue 的原型方法
    Sub.prototype = Object.create(Super.prototype);
    
    // 返回生成的子类
    return Sub;
}

通过 Vue.extend 方法会产生一个子类,new 这个子类时,就会执行组件的初始化流程;


五,子类继承父类的分析与实现

1,子类继承父类的分析

提问:那么,子类如何才能继承自父类呢?

让子类 Sub 继承自父类,即继承 Vue 的原型方法:

2,子类继承父类的实现

Vue.extend = function () {

    // 父类 Vue,即当前 this;
    const Super = this;
    
    // 创建子类 Sub
    const Sub = function (options) {
        // new 组件时,就会执行组件的初始化;
        // 由于 Sub 继承自 Vue,所以会执行 Vue._init 方法
        this._init(options);
    }
    
    // Sub 继承 Vue 的原型方法:Sub.prototype.__proto__ = Supper.prototype(父类的原型)
    Sub.prototype = Object.create(Super.prototype);
    
    return Sub;
}

原型继承,能够通过链拿到父类上的所有属性,常用以下两种方式:

  • Object.create()
  • ES6 方法 Object.setPrototypeOf()

说明:

  • Sub.prototype:指 Sub 的原型;
  • Sub.__proto__:指向所属类的原型,即:Sub.__proto__ = Function.prototype
  • Sub.prototype = Object.create(Super.prototype),相当于 Sub.prototype.__proto__ = Super.prototype

这样,Sub 就能够通过链找到 Super 上的原型方法;

3,关于 JS 实现继承的补充

JS 的属性分为以下三种:

  • 原型方法:公共方法,比如 Vue._init 方法;
  • 静态属性:可以直接通过静态类进行调用;
  • 实例属性:每 new 出一个实例,就会产生一份;
function A(){ ... }
function B(){ ... }

// 1,继承原型上的属性
A.prototype = Object.create(B.prototype)

// 2,继承类上的属性
A.__proto__ = B // 类

// 3,call 继承实例上的属性
function B(){
    A.call(this, {})
}

4,constructor 指向问题的分析

原型继承 Object.create 方法的实现原理分析:

// Object.create:会生成一个具有父类原型的新实例
function create(parentPrototype) {

  // 声明一个空函数 Fn
  const Fn = function () {};
  
  // 将 Fn 的 prototype 赋值为父类原型
  Fn.prototype = parentPrototype;
  
  // 返回 Fn 的实例 fn
  return new Fn();    // fn.prototype.__proto__ = parentPrototype
}

当调用 Object.create 时,内部会构建一个具有父类原型的新实例:

// 通过 new Fn 产生的实例 fn,fn 的原型指向父类的原型;
let fn = Object.create(Super.prototype);

// Sub.prototype 指向 fn
Sub.prototype = fn;

这样,子类就可以通过链拿到父类上的方法了;

但是,这种写法也产生了一个严重的问题:

  • Sub.prototypeSub 类的原型,被赋值为 fn
  • 但是,fn.constructor 指向的却还是Fn!!!

此时,fn.constructor 应该指向当前的子类 Sub

5,constructor 指向问题的修复

经以上分析可知:由于 Object.create 内部会产生一个新的实例作为子类的原型,这会导致子类的constructor 指向错误(没有指向子类自己,而是指向这个新产生的实例 fn 所属的类 Fn);

修复constructor指向问题,让子类 Subconstructor 指向自己即可:

// src/global-api/index.js

export function initGlobalAPI(Vue) {

  /**
   * 使用基础的 Vue 构造器,创造一个子类
   * 当 new 子类时,将进行组件的初始化,执行 _init 方法
   */
  Vue.extend = function () {
    const Super = this;
    const Sub = function (options) {
      this._init(options);
    }
    Sub.prototype = Object.create(Super.prototype);
    
    // 修复 constructor 指向问题:使子类 Sub 的 constructor 指向 Sub 自己
    // Object.create 会产生一个新的实例作为子类的原型,导致 constructor 指向错误
    Sub.prototype.constructor = Sub;
    
    return Sub;
  }
}

这样,通过 Vue.extend 就生成了正确的子类,得到了组件的构造函数;

Vue.extend 处理完成后,回到 Vue.component 中,会将返回的组件构造函数保存到全局 Vue.options.components 上备用;


六,Vue.extend 功能测试

添加断点,查看两次合并的情况:

Vue.extend = function (extendOptions) {
    const Super = this;
    const Sub = function (options) {
      this._init(options);
    }
    
    Sub.prototype = Object.create(Super.prototype);
    Sub.prototype.constructor = Sub;
    
    debugger
    // 合并父类和子类的选项:为了让子类能够拿到 Vue 定义的全局组件
    Sub.options = mergeOptions(Super.options, extendOptions);

    return Sub;
  }

添加日志输出,查看最终输出结果:

// src/global-api/index.js#initGlobalAPI

  Vue.component = function (id, definition) {

    let name = definition.name || id;
    definition.name = name;

    if(isObject(definition)){
      definition = Vue.extend(definition)
    }

    Vue.options.components[name] = definition;

    // 打印全局组件信息
    console.log(Vue.options.components)
  }

声明两个全局组件:

<body>
  <div id="app">
    <my-button></my-button>
  </div>

  <script>
    // 全局组件 1
    Vue.component('my-button1', {
      name:'my-button1',
      template:'<button>Hello Vue 1 全局组件</button>'
    })

    // 全局组件 2
    Vue.component('my-button2', {
      name:'my-button2',
      template:'<button>Hello Vue 2 全局组件</button>'
    })
  </script>
</body>

通过控制台,查看执行结果:

image.png

日志共输出 2 次,最终两个全局组件被注册到 Vue.options.components 中;

此时,如果 new Vue.options.components[my-button1] 就会执行组件的初始化流程,即调用 Vue 上原型方法 _init


七,子类 Sub 的选项合并

这部分放到“Vue.extend功能测试”之后进行说明的原因:

  • Vue.extend 实现中,选项的合并是非常重要的核心功能;
  • 当前 mergeOptions 选项合并方法中,组件的合并策略尚未实现;

1,选项合并的必要性

所有的组件,不管是全局组件还是局部组件,最终都要通过 Vue.extend 的处理并生成组件的构造函数,即子类 Sub

  • Vue 全局上的 Vue.options 中,存在的父类属性;
  • Vue.extend() 入参中,存在的组件自身属性(可能是全局组件,也可能是局部组件)

Vue.extend() 中,父类全局的 Vue.options 和子类全局的 extendOptions,也需要合并在一起,合并到子类 Sub 上,即 Sub.options

所以,给子类 Sub 也增加一个 options 属性;(这样,父类有一个 options ,子类也有一个 options

2,Sub.options 逻辑分析

Sub.options 的作用:合并父类的选项和(组件)自身的选项,将合并后的选项放到子类中;

场景分析:在父类中已经定义了一个组件的前提下,在定义第二组件时,选项合并的逻辑:

  • Vue.extend(extendOptions) 中通过 mergeOptions 方法,将组件的选项 extendOptions 与父类的选项 Super.options 进行合并,结果就是子类 Sub 的选项;

3,选项合并的代码实现

  Vue.extend = function (extendOptions) {
    const Super = this;
    const Sub = function (options) {
      this._init(options);
    }
    
    Sub.prototype = Object.create(Super.prototype);
    Sub.prototype.constructor = Sub;
    
    // 将全局上的属性,全部合并到子类上:让子类能拿到在Vue上定义的全局组件
    // 父类中的选项:Super.options
    // 组件自身的选项:extendOptions
    Sub.options = mergeOptions(Super.options, extendOptions);
    
    return Sub;
  }

这样一来,在子组件中,就拥有了在全局中定义的选项;

通过全局的 Vue 来声明组件(Vue.component),当创造子组件时,他也会把Vue 全局上的属性放到自身子组件的 options 上;

mergeOptions 方法中,关于组件合并策略的实现,将在下一篇集中进行介绍;


八,Vue.extend 补充说明

关于 Vue.extend 方法,除本文已介绍的核心操作外,在 Vue.extend 内部还进行了以下处理:

  • 通过组件的 cid 对结果进行缓存,当多次执行相同的Vue.extend(options)操作时,会返回已缓存的组件子类;
  • 会在子类 Sub 上标记父类是谁:Sub[super] = Super
  • 会对其他属性也进行初始化和合并,比如:
    • 属性的初始化:initProps(Sub)
    • 计算属性的初始化:initComputed(Sub)
    • extend 继承:Sub.extend = Super.extend
    • mixin 继承:Sub.mixin = Super.mixin
    • use 继承:Sub.use = Super.use
  • 会将子类本身也放入到 Sub.options.components 中,以实现组件的递归访问:Sub.options.components[name] = Sub

重要概念:

  • Vue.options 的作用:全局对象,会把当前定义的组件放到
  • Vue.options.components 中,所有全局 API 都会保存在此;
  • Vue.component 的作用:把定义好的组件,放到 Vue.options.components
  • Vue.extend 的作用:根据入参 extendOptions 对象,生成并返回一个继承自 Vue 的子类,并将全局选项合并到子类上;
  • mergeOptions 的作用: 将全局属性合并到子类 Sub 上;

九,相关面试题

问题 1:组件中的 data 为什么必须是一个函数,而不能是对象?

回答:通过对 Vue.extend 的了解可知,组件的 options 会在 Vue.extend 中被合并到子类的选项 Sub.options 上;

  • 如果 data 是对象,由于对象是引用类型,指向同一个引用地址,执行 new Sub 创建组件实例后的 data 对象就是共用的;此时,多个组件会共享同一个对象,任何一个组件修改了 data 中的值,会导致所有组件被更新;
  • 如果 data 是函数,那么每次执行 new Sub 时,组件实例化时通过调用 data 函数返回一个新的对象;这样,对 data 进行修改,多个组件的实例之间各不影响;

十,结尾

本篇,介绍了 Vue.extend 实现,主要涉及以下几个点:

  • Vue.extend 简介;
  • Vue.extend 实现的分析与代码框架;
  • 组件初始化的分析与实现;
  • 子类继承父类的分析与实现;
  • Vue.extend 的功能测试;
  • 组件的选项合并;
  • Vue.extend 的补充说明;

下一篇,组件的合并;


维护日志:

  • 20210810:
    • 微调部分语句描述与排版;
    • 添加部分三级标题,使内容划分更清晰易懂;
  • 20210812:
    • 添加“修复 constructor 指向问题-问题分析”部分内容,通过 Object.create 实现原理分析constructor 指向问题产生原因;
  • 20230224:
    • 添加了内容中的代码和关键字高亮;
    • 优化了部分内容描述,使表达更加准确易懂;
    • 优化了代码注释和换行,使逻辑更加清晰;
  • 20230226:
    • 补充了关于“原型继承”的说明;
    • 添加了“全局属性与子类的合并”部分;
    • 调整了目录结构;
    • 更新了文章摘要;
  • 20230303:
    • 重新梳理本篇,调整了部分内容描述,使表述和语义更加准确,更易理解;
    • 添加 todo:本篇 Vue.extend 到 4 截止,5、6 组件合并内容放到下一篇;
  • 20230304:
    • 对“组件部分”整体进行了重新梳理,重新设计了本篇的重点内容;
    • 调整了文章的目录结构:
      • 对核心问题进行了拆分说明,将“Vue.extend的实现”拆分为三个部分进行说明;
      • 添加了“功能测试”和“补充说明”部分;
      • 移除了“组件合并”部分,与下一篇组件合并内容进行合并;
    • 调整了部分内容的描述和相关代码示例,删除了若干冗余描述;
    • 解决了之前遗留的两个 todo 问题;
    • 更新了文章摘要;
  • 20230305:
    • 调整了文章的目录结构:
      • 添加了“Vue.extend 生成子类 Sub 时的选项合并”部分;
    • 更新了文章摘要;
  • 20230316:
    • 添加“关于 JS 实现继承的补充”部分;