vue2组件实现原理

1,751 阅读3分钟

vue2组件原理

前面几章讲了vue2的响应式原理异步更新原理首次渲染原理更新渲染原理,没了解的可以先去看看。这章主要讲vue2的组件原理,先看一个使用了组件的简单案例:

案例

先看一个简单的局部组件使用案例,全局组件先放着,后面讲

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
  <script src="../../dist/vue.js"></script>
</head>
<body>
  <div id="app">
    <div>
      <child-component :name="childName"></child-component>
    </div>
  </div>
  <script>
    const childComponent = {
      props: {
        name: {
          type: String
        }
      },
      template: `
        <head>
          <p> I am component</p>
          <div>{{ name }}</div>          
        </head>
      `
    }
​
    new Vue({
      el: '#app',
      components: {
        childComponent
      },
      data() {
        return {
          childName: '张三'
        }
      }
    })
  </script>
</body>
</html>

回顾下我之前讲的首次渲染流程:

new Vue(options) -> this._init 初始化 -> this.$mounttemplate编译成 render -> mountComponent创建渲染watcher -> this._render 调用刚刚的render 生成 VNode -> this._update -> patch -> createElm生成真实dom 并挂载

先看this._init

// core/instance/init.jsVue.prototype._init = function (options?: Object) {
    // 实例
    const vm: Component = this
​
    // 省略部分无关代码
​
    // 当前是子组件, 暂时忽略
    if (options && options._isComponent) {
        // 子组件的部分处理
        initInternalComponent(vm, options)
        // 当前是根组件,new Vue()是根组件,所以我们先看这里
    } else {
        // 合并构造函数的options和当前实例的options,并挂载到实例的$options上
        vm.$options = mergeOptions(
            // 获取定义在构造函数上的options配置
            // 全局的组件、指令、过滤器配置就是直接定义在Vue上的,也就是构造函数上
            // 所以这里就将全局的配置和当前配置合并
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
        )
    }
    
    // 省略部分无关代码
    
    if (vm.$options.el) {
        vm.$mount(vm.$options.el)
    }
}
    
// 获取定义在构造函数上的options配置
function resolveConstructorOptions (Ctor: Class<Component>) {
  let options = Ctor.options
  // 省略部分代码 
  return options
}

new Vue(options)后,调用根组件的this._init,然后合并构造函数的options和传入的options,赋值给 this.$options ,此时this.$options长这样:

image-20230118165254440.png

可以看到,我们写的childComponent 已经挂载在$options.componets 上了,同时vue2内置的3个全局组件之前是定义在构造函数上的,现在也挂载在了$options.components的原型链上了。

继续调完根实例的this._init后就是调this.$mounttemplate编译成 render

看下此时生成的render函数的结构:

function anonymous() {
  with(this){
      return _c(
          'div',
          {attrs:{"id":"app"}},
          [
              _c(
                  'div',
                  [
                      // 子组件编译后的渲染函数, 重点!!!
                      _c('child-component',{attrs:{"name":childName}})
                  ],
                  1
              )
          ]
      )
  }
}

可以看到子组件被编译成了_c('child-component',{attrs:{"name":childName}}),讲首次渲染那章时有讲过_c就是 createElement

// core/vdom/create-element.js
// 省略部分开发环境代码和其他功能代码// createElement会处理下入参后,调用_createElement并添加参数vm
// 此时的参数顺序就 vm, 'child-component', {attrs:{"name": '张三'}}
function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
 
  // 省略xxxxx
      
  let vnode
  if (typeof tag === 'string') {
    let Ctor
    if (config.isReservedTag(tag)) {
      // html节点
      // 省略xxxxx
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 组件节点, 重点看这里!!!!
      // 此时的tag是‘child-component’,字符串且非html节点
      // 调用resolveAsset就是去 this.$options.components 上匹配 是否存在 tag
      // 匹配成功即返回组件的配置,它可能是子组件的选项配置对象(本案例就是这种),也可能是
      // 子组件的构造函数(引入其他单文件组件时是这种)
      // 最后调用 createComponent 去生成子组件 Vnode
      vnode = createComponent(Ctor, data, context, children, tag)
    
    } else {
      // 未知节点
      // 省略xxxxx
    }
  } else {
    // 直接试 component options 或者 constructor 的情况, 暂时忽略
    vnode = createComponent(tag, data, context, children)
  }
  // 返回 vnode
  if (isDef(vnode)) {
    return vnode
  } else {
    return createEmptyVNode()
  }
}
​
export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  if (typeof id !== 'string') {
    return
  }
  const assets = options[type]
  // 直接匹配
  if (hasOwn(assets, id)) return assets[id]
  // 驼峰、中划线 转换后 去匹配
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
  return res
}

根据上面的注释,可以知道,此时子组件child-component 会匹配成功,并且Ctor 返回的就是

我们之前写的子组件的选项参数配置

image-20230118181826514.png

然后就是调用createComponent 生成vnode

// core/vdom/create-component.jsexport function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }
​
  // 如果还是个选项配置对象, 就调用Vue.extend生成组件构造函数
  // Vue.extend 是不是很熟
  // 官网中介绍就是: 使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。
  // 就是 选项配置转构造函数
  if (isObject(Ctor)) {
    Ctor = Vue.extend(Ctor)
  }
​
  // 简化省略下代码,这里逻辑有点多涉及到
  // 异步组件、函数组件、抽象组件、v-model语法糖转换 等处理逻辑
  
​
  data = data || {}
​
  // 处理props,就是将 data -> {attrs:{"name":'张三'}} 中 
  // attrs中的key 和 Ctor中有的子组件的配置选项中的 props中的key 做比对
  // 找出来并赋值给 propsData
  // 此时propsData -> {"name":'张三'}
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)
​
  const listeners = data.on
  
​
  // 注册一些组件运行中需要的hook
  // installComponentHooks(data)
  // 这里简化一下,只留一个初始化的钩子 init
  // 这里很重要,init方法就是后面渲染时调用用来生成子组件实例和真实dom的
  data.hook = {
      init(vnode) {
          const child = vnode.componentInstance = createComponentInstanceForVnode(vnode)
          child.$mount()
      } 
  }
​
  // 创建vnode并返回
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
​
  return vnode
}

此时生成的VNode的简化结构:

{
    tag: "vue-component-1-child-component",
    data: {
        attrs: {},
        on: undefined,
    },
    children: undefined,
    text: undefined,
    key: undefined,
    componentOptions: {
        propsData: {
            name: "张三",
        },
        listeners: undefined,
        tag: "child-component",
        children: undefined,
    }
}

还留了个问题,上面 子组件的选项配置转构造函数,我们使用的是 Vue.extend(options),看下

// core/global-api/extend.jsVue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    const Super = this
​
    const name = extendOptions.name || Super.options.name
​
    // 定义子类构造函数
    const Sub = function VueComponent (options) {
        this._init(options)
    }
    // 子类继承父类
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++
    
    // 合并当前选项参数配置和全局的配置 (全局组件之类的配置)
    Sub.options = mergeOptions(
        Super.options,
        extendOptions
    )
    Sub['super'] = Super
​
    // 后面全是 将父类的静态属性方法 拷贝到 子类, 让子类也能使用这些方法
    Sub.extend = Super.extend
    Sub.mixin = Super.mixin
    Sub.use = Super.use
    ASSET_TYPES.forEach(function (type) {
        Sub[type] = Super[type]
    })
    if (name) {
        Sub.options.components[name] = Sub
    }
    Sub.superOptions = Super.options
    Sub.extendOptions = extendOptions
    Sub.sealedOptions = extend({}, Sub.options)
​
   
    return Sub
}

可以看到 extend 方法就是继承,让子组件能直接使用父类的各种方法。不懂的可以去查看下js的继承和原型链之类的文章。

回到上面 通过_render生成了vnode,接下来就是调用_update 根据vnode生成真实dom。回顾下之前首次渲染的逻辑

 Vue.prototype._update = function (vnode: VNode) {
    const vm: Component = this
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
  }

首次渲染就是调用patch生成dom

// core/vdom/patch.js
// 省略部分代码 
function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 只有旧节点,没有新节点,则毁旧节点
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
​
    // 没有旧节点, 直接渲染新节点
    if (isUndef(oldVnode)) {
      createElm(vnode)
    } else {
      // oldVnode是否是真实节点,存在即上面首次渲染时直接传$el的情况
      const isRealElement = isDef(oldVnode.nodeType)
      
      // oldVnode是虚拟节点,且和新建节点是同一节点
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 对比新旧节点
        patchVnode(oldVnode, vnode)
      } else {
        
        // oldVnode是真实节点,即根节点首次渲染
        if (isRealElement) {
          // 根据oldVnode的真实dom,生成一个无子节点的空节点赋给oldVnode
          oldVnode = emptyNodeAt(oldVnode)
        }
​
        // 获取旧真实dom,和其父dom
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
​
        // 这里!!!!
        // 无论是首次渲染还是,新旧节点不一致的情况,都会根据新vnode创建新的dom
        createElm(
          vnode,
          parentElm,
          nodeOps.nextSibling(oldElm)
        )
​
        // 最后销毁旧节点
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }
    // 返回生成好的新真实dom
    return vnode.elm
  }

首次渲染就是调用createElm

function createElm (
 vnode,
 insertedVnodeQueue,
 parentElm
) {
   
    vnode.isRootInsert = !nested // for transition enter check
     // 创建子组件,并挂载子组件, 子组件的逻辑后面的章节会讲
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        return
    }
     
    // 省略
}

继续接着是 调用 createComponent

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
    
      // 重点!!! 这里就是调用刚刚在生成组件vnode时注册的init构造
      /**
      data.hook = {
          init(vnode) {
              const child = vnode.componentInstance = createComponentInstanceForVnode(vnode)
              child.$mount()
          } 
      }
      **/
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        i(vnode)
      }
      // 实例生成成功
      if (isDef(vnode.componentInstance)) {
        // 挂载真实dom
        vnode.elm = vnode.componentInstance.$el
        insert(parentElm, vnode.elm, refElm)
        return true
      }
    }
  }
​
function createComponentInstanceForVnode (
  // we know it's MountedComponentVNode but flow doesn't
  vnode: any,
  // activeInstance in lifecycle state
  parent: any
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  // check inline-template render functions
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  return new vnode.componentOptions.Ctor(options)
}

至此组件虚拟dom转真实dom并挂载成功。

还有个问题,父组件传给子组件的props -> name: '张三',是怎么处理了?

上面我们在调用createComponent 生成vnode 时将 父组件传给子组件的 props,赋值到了 propsData 变量,然后传给 VNode 类的倒数第二个参数,去生成组件的vnode

// 传propsData
const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
// 生成VNode
class VNode {
    ...
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    ...
    this.componentOptions = componentOptions
    ...
  }
}

也就是将{ Ctor, propsData, listeners, tag, children }等数据放到了vnode.componentOptions里。

然后在组件初始化调_init的是把 componentOptionspropsData,isteners, tag, children 等信息赋值到组件的this.$options 上,

 Vue.prototype._init = function (options) {
    ...
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } 
    ...
    initState(this)
}
function initInternalComponent (vm, options) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  // doing this because it's faster than dynamic enumeration.
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode
​
  const vnodeComponentOptions = parentVnode.componentOptions
  opts.propsData = vnodeComponentOptions.propsData
  opts._parentListeners = vnodeComponentOptions.listeners
  opts._renderChildren = vnodeComponentOptions.children
  opts._componentTag = vnodeComponentOptions.tag
​
  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

最后再在initState 里调 initProps

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    defineReactive(props, key, value)
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

initProps 里校验好父组件传给子组件的propsData并赋值给对应的props

总结

整体流程的代码有点多,流程有点长,可能没看懂,不慌最后我们总结下整体流程,看完后可以再回过头看看

组件的渲染流程:

父组件调this._init 初始化父组件 -> 将父组件选项配置中的components内容挂载到组件的this.$options上 -> 接着调this.$mount -> 调compileToFunctions 将父组件的template编译成render -> new watcher 生成父组件的渲染wacther 并首次渲染执行一次 -> 调_render利用 createElementrender函数解析并生成父组件的vnode树 -> 解析过程中遇到在父组件的this.$options.components上配置过的tag时 ->利用配置的component 信息去调用createComponent生成子组件的vnode -> 此时配置的componet信息,如果直接就是子组件的构造函数(即components里直接传的就是子组件的构造函数,例如我们直接引入一个单位件组件) 就会直接利用构造函数Ctor生成vnode,如果配置信息还是只是个选项配置对象(本案例的情况)就会调用 Vue.extend方法继承全局Vue的构造函数生成子组件的构造函数,返回回来龙利用这个构造函数生成vnode -> 生成过程中将能利用vnode 生成对应组件实例以及生成真实dom并挂载的方法init钩子预先放在vnode上 ->生成完父组件的所有vnode后,调用父组件的this,_update方法-> 调用patch -> 调用createElm去利用vnode生成真实dom ->此时遇到子组件的vnode就会调用刚刚预先挂载在vnode上的init钩子生成子组件的真实dom并挂载 -> init 钩子主要逻辑就是 -> 调用createComponentInstanceForVnode 利用挂载在vnode上的子组件构造函数Ctor去new 生成子组件的实例 -> 在调用子组件的$mount 去生成子组件的真实dom并挂载在父组件的对应位置。

组件传参原理:

  1. 在上面将父组件template编译成render的过程中,会将父组件传给子组件的props解析为具体数据并挂载在子组件的attrs上。
  2. 在调用createComponet生成子组件vnode时,调用extractPropsFromVNodeData方法根据在子组选项配置对象中的props里配置过的keyattr中相同key的数据,集中并赋值给propsData 然后挂载到生成的子组件vnode
  3. createEle生成真实dom阶段,调用createComponentInstanceForVnodenew Ctor生成子组件实例的过程中,调子组件的_init -> initState -> initProps时将propsData中的数据赋值给子组件的_props,并响应式化_props,然后将_props直接代理到子组件的this,至此父组件的数据传递到了子组件里。

组件更新原理:

值得一提的是,我们在响应式化_props时,其实只给最外层的数据做了get\set处理。那么修改props值时就存在两种情况。

  1. 父组件传给子组件的数据是原始数据类型,也就是本身只有第一层。父组件里更改这个值会直接触发子组件的_props的set,导致子组件重新渲染。

  2. 父组件传给子组件的数据是引用类型,也就是对象或者数组。父组件更改这个值时,此时又分两种情况。

    • 父组件直接将整个引用给改了,也就是把整个对象换了个值,这就和第一种情况一样,会直接触发子组件的_props的set,导致子组件重新渲染。
    • 父组件没有改掉整个引用,而是只改了这个对象跟深层次的值时,因为引用没变,所以此时不会触发子组件的_props的set,也就不会通过这种方式导致子组件重新渲染。但是我们实际使用过程中发现这种情况是会导致子组件重新渲染的,那么vue2是怎么实现的呢?其实原理就是,配置在父组件上的数据本身是有被递归设置响应式化的,那么传给子组件后,子组件使用该值的深层属性时,就会触发对应值的get,这时候直接就将子组件的渲染watcher给收集了,然后在父组件改变其值触发其set时,直接就触发了子组件的渲染watcher更新。

那么当父组件没有更改传给子组件的props的值,但是修改了其他数据导致父组件重新渲染时,子组件会重新渲染吗?

答案是不会,当父组件更新时,对比子组件vnode时,由于子组件没有变化,所以会触发sameVnode,然后调用patchVnode复用子组件vnode,然后更新子组件的属性,这其中包括将

propsData的值赋值给_props,触发_propsset,但由于值没变,所以不会触发子组件的重新渲染。

所以vue的渲染级别是组件级的。

最后贴下updateChildComponent 的简化代码

// core/instance/lifecycle.jsexport function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
 
  // update props
  if (propsData && vm.$options.props) {
    // 避免递归响应式化
    toggleObserving(false)
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
      const key = propKeys[i]
      const propOptions: any = vm.$options.props 
      // 将propsData的值赋值给 _props, 同时触发其set
      props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }
}

最后简单讲讲vue中的异步组件

异步组件就是在父组件首次渲染时,然后创建组件vnode的过程中判断当前组件是异步组件,则返回一个空vnode先占位,然后调用异步方法,请求异步组件,待异步组件返回后调用父组件的$forceUpdate 重新渲染整个父组件包括子组件。