Vue 的组件化是如何实现的?

335 阅读6分钟

前言:

本篇文章是《Vue.js设计与实现》第 12 章 组件的实现原理 笔记,其中的代码和图片来源于本书,用于记录学习收获并且分享。

一、组件

image.png 组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考

二、如何去表示和渲染一个组件

1.表示一个组件(选项对象):

对于使用者来说,可以使用一个选项对象去表示一个组件:

const MyComponent = { 
    //组件名称
    name: 'MyComponent', 
    ...
}

为了能够去描述组件内部的内容并返回虚拟DOM,需要在组件vnode中包含一个渲染函数render

const MyComponent = {
    //组件名称
    name:'MyComponent',
    render(){
        //返回虚拟DOM
        return {
            type:'div',
            children: '内容'
        }
    }
}

使用vnode描述时,我们将选项对象作为vnode的type:

const vnode = {
  type: MyComponent,
}

2.渲染一个组件:

对渲染器的patch函数进行调整,增加一个判断,当vnode的type为对象时证明是一个组件,应该使用渲染组件的方法进行处理。

 function patch(n1, n2, container, anchor) {
    if (n1 && n1.type !== n2.type) {
      unmount(n1)
      n1 = null
    }

    const { type } = n2

    if (typeof type === 'string') {
      //普通节点
      ...
    } else if (type === Text) {
      //文本节点
      ...
    } else if (type === Fragment) {
      //Fragment组件节点
      ...
    } else if (typeof type === 'object') {
      if (!n1) {
          //挂载组件的方法
        mountComponent(n2, container, anchor)
      } else {
          //更新组件的方法
        patchComponent(n1, n2, anchor)
      }
    }
  }

mountCompoent

function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
    const { render } = componentOptions
    const subTree = render()
    patch(null, subTree, container, anchor)
}

通过vnode.type获取到选项对象,取到选项对象中的render函数再将其中返回的虚拟DOM放入patch中去挂载。

三、完善选项对象和渲染器去支持组件的功能

一个vue组件应该具备的基础功能有:

  • 状态(组件内部的数据、属性和方法等)
  • 组件实例
  • 生命周期
  • 参数props
  • 组件传参emits
  • 插槽

这些功能都需要完善对组件选项对象和渲染器的功能才能够支持

四、组件的状态和自更新

1.使用选项对象去描述组件的状态

我们在选项对象中添加一个data函数用于定义组件的状态,并在render函数中使用

const MyComponent = {
    //组件名称
    name:'MyComponent',
    data() { 
    return { 
        foo: 'hello world' 
        } 
    },
    render(){
        return {
            type:'div',
            children: `foo 的值是: ${this.foo}`
        }
    }
}

如上定义了一个数据foo,然后在render中进行使用。

2.渲染

调整mountComponent函数

function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
    const { render, data } = componentOptions
    const state = reactive(data())
    const subTree = render.call(state, state)
    
    effect(() => { 
        const subTree = render.call(state, state) 
        patch(null, subTree, container, anchor) 
    })
}

在mountComponent函数中获取到data,为了能够在状态变化时更新渲染组件,需要将data中的数据转化为一个响应式数据,并且使用effect将调用渲染的操作放入副作用函数中,这样只要数据变化就会更新渲染。为了能够在render函数中使用this访问到状态数据,使用render.call(state, state)处理state。

3.使用任务缓存队列去处理重复渲染

为了防止状态数据多次变化就会去重复触发更新,我们需要使用任务队列的方式进行去重

const p = Promise.resolve()
const queue = new Set()
let isFlushing = false
function queueJob(job) {
    queue.add(job)
    if (!isFlushing) {
      isFlushing = true
      p.then(() => {
        try {
          queue.forEach(jon => job())
        } finally {
          isFlushing = false
        }
      })
    }
}

function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
    const { render, data } = componentOptions
    const state = reactive(data())
    const subTree = render.call(state, state)
    
    effect(() => { 
        const subTree = render.call(state, state) 
        patch(null, subTree, container, anchor) 
    },
    {
      scheduler: queueJob
    }
    )
}

代码如上,我们让副作用函数不是立即执行,而是去使用调度器,副作用函数添加到一个任务队列中,使用Set对其进行去重,然后在任务队列执行时将isFlushing置为false,暂时不执行此次更新,当任务队列执行完之后置为true,再去执行下一个队列的渲染任务,这样就避免了重复的副作用函数执行。

五、组件的实例以及生命周期

为什么需要组件实例?

上一节中实现的组件存在问题,我们调用patch函数传入的第一个参数是null,这意味着每一次的更新,组件都会进行一次全新的挂载,但是理想的做法应该是:
更新组件时我们应该只去更新上一次组件的状态即可,这就需要我们去维护组件最新的实例,其中添加isMounted标识可以让我们根据其值去判断组件是挂载还是更新,从而可以确定生命周期钩子函数的调用时机

function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
    const { render, data } = componentOptions
    
    const state = reactive(data())

    const instance = {
      state,
      isMounted: false,
      subTree: null,
    }

    vnode.component = instance

    effect(() => {
      const subTree = render.call(state, state)
      if (!instance.isMounted) {
        patch(null, subTree, container, anchor)
        instance.isMounted = true
      } else {
        patch(instance.subTree, subTree, container, anchor)
      }
      instance.subTree = subTree
    }, {
      scheduler: queueJob
    })
  }

我们在mountComponent中添加了instance常量去表示组件实例,其中储存了组件实例的信息:

  • state:组件状态
  • isMounted:标识组件是否挂载
  • subTree:组件渲染函数返回的虚拟DOM

在实例中还添加isMounted标识的原因在于: 有了在实例中维护的isMounted标识,就可以判断组件是挂载还是更新,从而可以确定生命周期钩子函数的调用时机

生命周期

先来回顾一下Vue的生命周期钩子: image.png

确认如何在mountComponent中适当的时机去处理生命周期

  • beforeCreate : 取得选项式对象componentOptions之后:
  • created: 组件状态state处理完成之后,获得组件实例之后;
  • beforeMount:isMounted为false 组件调用patch进行首次挂载之前;
  • mounted:组件调用patch进行挂载之后,isMounted为true;
  • beforeUpdate:isMounted为true,再次调用patch对组件进行更新之前;
  • updated:isMounted为true再次调用patch对组件进行更新之后;

选项对象中传入生命周期函数

const MyComponent = { 
    //组件名称
    name: 'MyComponent', 
    beforeCreate:()=>{},
    created:()=>{}
    ... 省略部分代码
}

mountComponent中通过合适的时机调用选项对象中获得的钩子函数

function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
    const { render, data } = componentOptions
    
    //beforeCreate 钩子
    beforeCreate && beforeCreate()
    
    const state = reactive(data())

    const instance = {
      state,
      isMounted: false,
      subTree: null,
    }

    vnode.component = instance
    
    //created钩子
    created && created.call(state)

    effect(() => {
      const subTree = render.call(state, state)
      if (!instance.isMounted) {
        //beforeMount钩子
        beforeMount && beforeMount.call(state)
        patch(null, subTree, container, anchor)
        //mounted钩子
        mounted && mounted.call(state)
        instance.isMounted = true
      } else {
        //beforeUpdate钩子
        beforeUpdate && beforeUpdate.call(state)
        patch(instance.subTree, subTree, container, anchor)
        //updated 钩子
        updated && updated.call(state)
      }
      instance.subTree = subTree
    }, {
      scheduler: queueJob
    })
  }

六、组件参数props以及被动更新

处理props

组件的参数由两部分构成,一部分是组件内部对props及其类型的定义,另一部分是在使用组件过程中传入的props的值。 假设有如下组件:

<MyComponent title="A Big Title" :other="val" />

1.选项式对象以及vnode

const MyComponent = {
  name: 'MyComponent',
  //定义了props及其类型
  props: {
    title: String
  },
  render() { 
      return { 
          type: 'div', 
          children: `count is: ${this.title}` // 访问 props 数据 
          } 
      }
}

const vnode = {
    type: MyComponent, 
    //传入的props数据
    props: { 
        title: 'A big Title', 
        other: this.val 
    }
}

2.渲染

mountComponent中处理props,由于组件中未定义的参数会被接收到atters中 ,需要添加一个处理props的函数resolveProps用于区分props和atters

  function mountComponent(vnode, container, anchor) {

    //省略代码...
    
    //解析props和attrs
    const [props, attrs] = resolveProps(propsOption, vnode.props)
    
    const instance = {
      state,
      
      //将得到的props定义到组件实例上
      props: shallowReactive(props),
      isMounted: false,
      subTree: null,
    }
    
    //省略代码...
  }


  function resolveProps(options, propsData) {
    const props = {}
    const attrs = {}
    for (const key in propsData) {
      if ((options && key in options) || key.startsWith('on')) {
        props[key] = propsData[key]
      } else {
        attrs[key] = propsData[key]
      }
    }

    return [ props, attrs ]
  }

被动更新

我们给子组件传递的props中的值实际上是来自于父组件,当父组件中的响应式数据变化时,父组件会进行更新,此时会调用patchComponent对子组件也进行更新。这种因为父组件更新引起的子组件的更新就叫做被动更新。 子组件被动更新时props不一定产生变化了,所以我们需要去判断props是否改变再更新。 新增一个hasPropsChanged函数去判断新旧props,主要采取判断数量是否变化或者遍历的方法去对比

  function hasPropsChanged(
    prevProps,
    nextProps
  ) {
    const nextKeys = Object.keys(nextProps)
    //判断新旧props数量是否相等 不相等不用遍历对比
    if (nextKeys.length !== Object.keys(prevProps).length) {
      return true
    }
    
    //遍历对比
    for (let i = 0; i < nextKeys.length; i++) {
      const key = nextKeys[i]
      return nextProps[key] !== prevProps[key]
    }
    return false
  }

根据对比结果在patchComponent中更新props:

  function patchComponent(n1, n2, anchor) {
    //赋值给组件实例
    const instance = (n2.component = n1.component)
    //获取到的props
    const { props } = instance
    if (hasPropsChanged(n1.props, n2.props)) {
      //对旧的props进行更新
      const [ nextProps, nextAttrs ] = resolveProps(n2.type.props, n2.props)
      for (const k in nextProps) {
        props[k] = nextProps[k]
      }
      for (const k in props) {
        if (!(k in nextProps)) delete props[k]
      }
    }
  }

在更新过程中同时需要旧子组件的实例赋值给新的子组件,确保下次更新时获得子组件

处理渲染函数中需要使用this访问组件状态和props的情况

新增一个上下文对象renderContext用于处理这种情况

const renderContext = new Proxy(instance, {
      get(t, k, r) {
        const { state, props } = t

        if (state && k in state) {
          return state[k]
        } else if (k in props) {
          return props[k]
        } else {
          console.error('不存在')
        }
      },
      set (t, k, v, r) {
        const { state, props } = t
        if (state && k in state) {
          state[k] = v
        } else if (k in props) {
          props[k] = v
        } else {
          console.error('不存在')
        }
      }
})

...

// created生命周期函数调用时绑定渲染上下文对象
created && created.call(renderContext)

代码如上所示,我们使用proxy对instance进行了代理这样,当在渲染函数中或者生命周期函数内部使用this读取组件状态时,会优先从实例中获取,没有的情况下再去从props中获取

七、setup函数

setup函数是Vue3中新增的用于处理组合式API的方法。

1.setup函数不同的返回值

  1. 返回一个函数: 该函数会直接作为组件的render函数
const Comp = { 
    setup() { 
        return () => { 
            return { type: 'div', children: 'hello' } 
        } 
  }
}
  1. 返回一个对象: 对象中的数据可以在组件的render函数中使用
const Comp = { 
    setup() { 
        const msg = ref('title')
        return () => { 
            return { type: 'div', children: 'hello' } 
      },
    render(){
        return { type: 'div', children: `msg is: ${this.msg}` }
    }
  }
}

setup函数的参数

  1. props:给组件传递的props数据对象
  2. setupContext:保存和组件接口相关的数据和方法:如slots、emit、attrs、expose等

上述功能的实现

由于setup函数只在组件挂载的时候运行一次所以我们只需要处理mountComponent

 function mountComponent(vnode, container, anchor) {
    let componentOptions = vnode.type
    let { render, data, setup } = componentOptions

    beforeCreate && beforeCreate()

    const state = data ? reactive(data()) : null
    const [props, attrs] = resolveProps(propsOption, vnode.props)

    const instance = {
      state,
      props: shallowReactive(props),
      isMounted: false,
      subTree: null,
      slots,
      mounted: []
    }
    
    let setupState = null
    if (setup) {
      const setupContext = { attrs }
      const setupResult = setup(shallowReadonly(instance.props), setupContext)
      let setupState = null
      if (typeof setupResult === 'function') {
        if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
        render = setupResult
      } else {
        setupState = setupContext
      }
    }

    vnode.component = instance

    const renderContext = new Proxy(instance, {
      get(t, k, r) {
        const { state, props } = t
        if (state && k in state) {
          return state[k]
        } else if (k in props) {
          return props[k]
        } else if (setupState && k in setupState) {
          return setupState[k]
        } else {
          console.error('不存在')
        }
      },
      set (t, k, v, r) {
        const { state, props } = t
        if (state && k in state) {
          state[k] = v
        } else if (k in props) {
          props[k] = v
        } else if (setupState && k in setupState) {
          setupState[k] = v
        } else {
          console.error('不存在')
        }
      }
    })

    // created
    created && created.call(renderContext)
    
    // 省略部分代码
    
  }

代码如上所:

  1. 首先从组件的选项对象中拿到setup函数;
  2. 通过resolveProps解析得到props和attr;
  3. 判断setup函数的返回值: 若返回值为函数,则直接将返回值作为组件的render函数,否则将返回值放入到setupState中,并且在加入到上下文对象renderContext中,从而能够在组件的渲染函数中通过this去访问到setup函数返回的对象中的数据。

八、组件事件和emit

emit的使用过程

假设有选项对象

const MyComponent = { 
name: 'MyComponent', 
    setup(props, { emit }) {
        emit('change', 1, 2) 
        return () => { 
            return // ... 
        } 
    } 
}

组件

<MyComponent @change="handler" />

该组件会被编译成如下虚拟DOM

const CompVNode = { 
    type: MyComponent,
    props: { 
        onChange: handler 
    } 
}

从上面的代码可以观察得出,emit调用一个自定义事件,其过程就是去props中寻找对应的事件处理对象并执行,额外需要处理的就是自定义事件名称change会被编译成onChange

实现

mountComponent进行处理, 在其中增加一个emit函数,从instance.props中取得事件处理函数并执行:

 function mountComponent(vnode, container, anchor) {
 
    //省略部分代码...
    
    const instance = {
      state,
      props: shallowReactive(props),
      isMounted: false,
      subTree: null,
    }
    
    //定义emit
    function emit(event, ...payload) {
      const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
      const handler = instance.props[eventName]
      if (handler) {
        handler(...payload)
      } else {
        console.error('事件不存在')
      }
    }
    
    const setupContext = { attrs, emit }
    
    // 省略部分代码
    
  }

由于on开头的事件在我们使用组件时是不会在内部的props中去定义的,按照之前实现的逻辑,事件会被放入到attrs中,这样我们就不能在instance.props中取得事件处理函数,所以我们需要在区分props和attrs的函数resolveProps中做处理,将on开头的事件放入其返回的props中

  function resolveProps(options, propsData) {
    const props = {}
    const attrs = {}
    for (const key in propsData) {
      if ((options && key in options) || key.startsWith('on')) {
        props[key] = propsData[key]
      } else {
        attrs[key] = propsData[key]
      }
    }

    return [ props, attrs ]
  }

九、插槽

1.插槽的使用

假设有一个组件 MyComponent 提供了如下插槽

<template> 
   <header><slot name="header" /></header> 
   <div> 
     <slot name="body" /> 
  </div> 
  <footer><slot name="footer" /></footer> 
</template>

其会被编译成如下如下渲染函数:

function render() { 
return [ 
        { 
            type: 'header', 
            children: [this.$slots.header()] 
        },
        { 
            type: 'body', 
            children: [this.$slots.body()] 
        },
        { 
            type: 'footer', 
            children: [this.$slots.footer()] 
        },
    ]
}

在使用插槽时:

 <MyComponent> 
   <template #header> 
     <h1>我是标题</h1> 
   </template> 
   <template #body> 
     <section>我是内容</section> 
   </template> 
   <template #footer> 
     <p>我是注脚</p> 
   </template> 
</MyComponent>

当在编译MyComponent会被编译成如下渲染函数:

// 父组件的渲染函数 
function render() { 
   return { 
     type: MyComponent, 
     // 组件的 children 会被编译成一个对象 
     children: { 
       header() { 
         return { type: 'h1', children: '我是标题' } 
       }, 
       body() { 
         return { type: 'section', children: '我是内容' } 
       }, 
       footer() { 
         return { type: 'p', children: '我是注脚' } 
       } 
     } 
   } 
}

当组件使用了插槽时其中的内容会被编译为插槽函数,函数的返回值就是插槽内容

2.实现插槽

通过观察上面的渲染结果,我们可以得到处理插槽的思路

  • 首先需要获取到插槽对象,即vnode.children
  • 然后将插槽对象添加到组件实例上
  • 最后通过renderContext处理,使得在组件渲染函数中可以通过this访问到插槽对象中的数据 在mountComponent中具体的过程如下:
 function mountComponent(vnode, container, anchor) {
 
    //省略部分代码...
    
    //获取插槽对象
    const slots = vnode.children || {}
    
    //将slots对象放入上下文setupContext
    const setupContext = { attrs, emit, slots }
    
    const instance = {
      state,
      props: shallowReactive(props),
      isMounted: false,
      subTree: null,
      //插槽添加到实例上
      slots
    }
    
    const setupContext = { attrs, emit }
    
    const renderContext = new Proxy(instance, {
      get(t, k, r) {
        const { state, props, slots } = t
        
        //如果key是$slots直接返回实例上的slots
        if (k === '$slots') return slots
        
        if (state && k in state) {
          return state[k]
        } else if (k in props) {
          return props[k]
        } else if (setupState && k in setupState) {
          return setupState[k]
        } else {
          console.error('不存在')
        }
      },
      set (t, k, v, r) {
        const { state, props } = t
        if (state && k in state) {
          state[k] = v
        } else if (k in props) {
          props[k] = v
        } else if (setupState && k in setupState) {
          setupState[k] = v
        } else {
          console.error('不存在')
        }
      }
    })

    
    // 省略部分代码
    
  }

十、注册生命周期

在setup函数中,我们可以使用onMounted等API来注册生命周期钩子函数,并且可以允许多个相同生命周期钩子函数的存在。

1.在不同组件中调用注册钩子函数的API如何区别

const MyComponent = { 
    setup() { 
    onMounted(() => { 
        console.log('mounted 1') 
    }) 
    // 可以注册多个 
    onMounted(() => { 
        console.log('mounted 2') 
    })
    // ... 
    } 
}

onMounted函数并不是在组件中被单独定义的,而是在不同的组件中执行时,钩子函数都会被注册到当前组件上,如何实现呢。 为了能够使得onMounted会被正确的挂载到调用它的组件内部,需要定义一个全局的变量currentInstance用于维护当前的组件实例,使其与onMounted产生关联,如下所示。

 // 全局变量,存储当前正在被初始化的组件实例 
 let currentInstance = null 
 // 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
 function setCurrentInstance(instance) { 
     currentInstance = instance 
 }

此时再调整mounteComponent函数考虑钩子函数的执行

  function mountComponent(vnode, container, anchor) {
    
    // 省略部分代码
    const instance = {
      state,
      props: shallowReactive(props),
      isMounted: false,
      subTree: null,
      slots,
      mounted: []
    }

      const setupContext = { attrs, emit, slots }
      const prevInstance = setCurrentInstance(instance)
      const setupResult = setup(shallowReadonly(instance.props), setupContext)
      setCurrentInstance(null)
      
      //省略部分代码
  }

代码如上:在调用setup函数之前需要将currentInstance设置为当前实例,再执行setup之后将其清空。

2.onMounted如何将其中的内容维护进组件的实例中

只需要拿到组件实例再push进实例对应的钩子函数数组即可

function onMounted(fn) { 
    if (currentInstance) { 
        // 将生命周期函数添加到 instance.mounted 数组中 
        currentInstance.mounted.push(fn) 
     } else { 
        console.error('onMounted 函数只能在 setup 中调用') 
     } 
}

在合适的时机调用注册的钩子函数

mountComponent的effect中,在组件虚拟DOM被patch处理之后遍历调用

 function mountComponent(vnode, container, anchor) {
   //省略部分代码
   effect(() => {
      const subTree = render.call(renderContext, renderContext)
      if (!instance.isMounted) {
        beforeMount && beforeMount.call(renderContext)
        patch(null, subTree, container, anchor)
        instance.isMounted = true
        mounted && mounted.call(renderContext)
        instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
      } else {
      //省略部分代码
      instance.subTree = subTree
    }, {
      scheduler: queueJob
    })
 }

其他用于注册生命周期钩子的API的处理类似于onMounted


总结

  1. 组件表示和渲染

    • 使用选项对象表示组件,包括组件名称和渲染函数。
    • 在渲染器的patch函数中,通过判断vnode的类型为对象来来区分组件。
    • 通过mountComponent函数挂载组件,获取选项对象并调用render函数生成虚拟DOM,进行patch操作。
  2. 概述一个组件应该有的基础功能

    • 状态管理、组件实例、生命周期、参数props、组件传参emits和插槽等。
  3. 组件的状态和自更新

    • 在选项对象中使用data函数定义组件状态,并在render函数中使用。
    • mountComponent函数中将data数据转为响应式,并使用effect函数监听数据变化,自动更新渲染。
    • 使用任务缓存队列处理重复渲染,避免状态数据多次变化导致的重复渲染。
  4. 组件实例和生命周期管理

    • 用组件实例维护组件最新状态,包括stateisMountedsubTree等信息。
    • 通过在适当时机调用生命周期钩子函数,实现组件生命周期管理。
  5. 组件参数props和被动更新

    • 通过解析propsattrs区分处理,并将props放入组件实例中。
    • 对比新旧props判断是否需要更新组件,避免不必要的更新。
    • 使用Proxy代理组件实例,确保在render函数中通过this访问组件状态和props
  6. setup函数

    • setup函数可以返回函数作为组件的render函数,也可以返回对象用于组件内部使用。
    • setup函数接收propssetupContext两个参数,setupContext中包含attrs、emit、slots等信息。
    • mountComponent函数中处理setup函数的返回值,并将其加入渲染上下文中,使render函数中通过this访问setup函数返回的数据。
  7. 组件事件和emit

    • emit函数用于触发组件事件,通过props中的事件处理函数调用。
    • mountComponent函数中定义emit函数,从instance.props中获取事件处理函数并执行。
  8. 插槽机制

    • 插槽函数返回插槽内容,并在组件渲染函数中通过this.$slots访问。
    • mountComponent函数中获取插槽对象,并将其添加到组件实例和渲染上下文中,确保在渲染函数中通过this访问插槽数据。
  9. 注册生命周期钩子函数

    • 通过全局变量currentInstance维护当前组件实例,使生命周期钩子函数正确关联组件实例。
    • 将生命周期函数添加到实例的相应数组中,实现多个相同生命周期钩子函数的注册。
    • mountComponent的effect中,合适时机调用注册的生命周期钩子函数。

通过这些步骤,实现了Vue组件化的基本功能和生命周期管理,使得组件能够正常渲染、更新和处理各种功能需求。