Skip to content

组件渲染过程

组件允许我们将UI划分为独立的,可复用的部分。每个组件都有自己的生命周期,包括初始化、挂载、更新和销毁等阶段。

从用户的角度,一个有状态组件就是一个选项对象。 如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟DOM节点,在vnode中,vnode.type是一个组件选项对象。

ts
const componentVnode = {
  type: componentOptions,
}

组件的构成

组件在渲染中必须包含一个 render 函数,并且 render 函数必须返回值是虚拟DOM节点。 如果组件没有定义 render 函数,vue会编码组件的模板字符串为 render 函数。

ts
const MyComponent = {
  name: 'MyComponent',
  props: {
    msg: {
      type: String,
      default: '默认消息',
    },
  },
  render() {
    return {
      type: 'div',
      children: `我是文本内容`,
    }
  }
}

props 与组件的被动更新

组件中需要关心的props有两部分:

  • 为组件传递的 props 数据,即组件的 vnode.props 对象;
  • 组件选项对象中定义的 props 选项,即 MyComponent.props 对象。
ts
function resolveProps(propsOptions, propsData) {
  const props = {};
  const attrs = {};

  // 遍历为组件传递的 props 数据
  for (const key in propsData) {
    // 如果 propsOptions 中不存在该属性,则将其添加到 attrs 对象中
    // 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
    if (key in propsOptions || key.startsWith('on')) {

      // 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
      props[key] = propsData[key];
    } else {
      //  否则将其作为 attrs
      attrs[key] = propsData[key];
    }
  }

  return [props, attrs];
}

vue里将组件选项中定义的props对象和 为组件传递的 props 数据 进行合并, 合并规则如下:

  • 如果组件选项中定义的props对象中不存在该属性,则将其添加到 attrs 对象中;
  • 如果组件选项中定义的props对象中存在该属性,则将其视为合法的 props,将其添加到 props 对象中;

props 是父组件的响应式数据,当父组件的响应式数据发生变化时,父组件会自动更新。在更新过程中,渲染器发现父组件的subTree中包含了组件类型的虚拟节点,所以会调用patchComponent函数进行子组件的更新。 由父组件更新引发的子组件更新,被称为子组件的被动更新。

当发生子组件的被动更新时, 渲染器要做的是:

  • 检测子组件是否需要更新,因为子组件的props可能是不变的;
  • 如果子组件需要更新, 则更新子组件的props,slots等内容;
ts
function patchComponent(n1, n2, anchoг) {
  // 获取组件实例,即n1.component, 同时让新的组件虚拟节点n2.component 也指向组件实例
  const instance = (n2.component = n1.component)

  // 获取当前的 props 数据
  const { props } = instance

  // 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
  if (hasPropsChanged(n1.props, n2.props)) {
    // 调用 resolveProps 函数重新获取 props 数据
    const [ nextProps ] = resolveProps(n2.type.props, n2.props)
    // 将解析出的 props 数据包装为 shalloReactive 并定义到组件实例上

    // 更新 props

    for (const k in nextProps) {
      props[k] = nextProps[k]
    }

    // 删除不存在的 props
    for (const k in props) {
      if (!(k in nextProps)) delete props [k]
    }
  }
}

上面是子组件的被动更新的实现过程,需要注意:

  • 需要将组件实例添加到新的组件vnode对象上,即n2.component = n1.component,否则在后续的更新过程中,渲染器无法获取到组件实例,导致更新失败。
  • instance.props 对象是浅响应式的(shallowReactive) ,当组件的props 发生变化时,子组件会自动更新。

setup 函数的作用与实现

组件的 setup 函数是 Vue.js 3 新增的组件选项,它有别于 Vue.js 2 中存在的其他组件选项。setup函数主要用于配置组合式API。

在组件的生命周期中,setup函数只会在被挂载时执行一次,它的返回值可以有两种情况:

  • 返回一个函数,该函数将作为组件的 render 函数:
ts
function setup(props, context) {
  // 组件的 setup 函数可以返回一个函数,该函数将作为组件的 render 函数
  return function render() {
    // 组件的 render 函数可以访问组件的 props 数据
    return h('div', {}, props.msg)
  }
}

这种方式常用于组件写tsx或jsx的情况,比写模板内容更灵活。如果组件写模板内容,setup函数不可以再返回函数,否则会与模板编译生成的渲染函数冲突。

  • 如返回一个对象,该对象中包含的数据将暴露给模板使用:
ts
function setup(props, context) {
  const count = ref(0)
  // 组件的 setup 函数可以返回一个对象,该对象中包含的数据将暴露给模板使用
  return {
    count,
  }
}

function render() {
  // 通过 this 可以访问 setup 暴露出来的响应式数据
  return { type: 'div', children: `count is: ${this.count}` }
}

setup 函数接收两个参数。第一个参数是props 数据对象,第二个参数也是一个对象, 通常称为setupContext, 它包含了组件实例的一些属性和方法,如:

  • attrs: 组件的 attrs 对象,包含了组件的非 props 属性,上面的resolveProps函数中已经介绍过;
  • slots: 组件的 slots 对象,包含了组件的插槽内容;
  • emit: 组件的 emit 方法,用于触发组件的自定义事件;
  • expose: 组件的 expose 方法,用于暴露组件的公共方法;

组件事件与emit 的实现

emit 用来发射组件的自定义事件,如下面的代码所示:

ts
function setup(props, context) {
  const emit = context.emit

  function handleClick() {
    emit('change', 1, 2)
  }

  return {
    handleClick,
  }
}

当使用该组件时,我们可以监听由 emit 函数发射的自定义事件:

html
<MyСomponent @change="handler" />

上面这段模板对应的虚拟 DOM为:

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

由上面可知,自定义事件change被编译成名为onChange事件了,并存储在props数据对象中。这是vue内部的约定,会把组件中自定义事件编译成 onEventName,放到props中。

插槽的工作原理与实现

组件的插槽是批组件会预留一个槽位,用于接收父组件传递的插槽内容,如下面给出的MyComponent 组件的模板所示:

html
<!-- MyComponent 组件模板 -->
<template>
  <header><slot name="header" :msg="msg" /></header>
  <div>
  <slot name="body" />
  </div>
  <footer><slot name="footer" /></footer>
</template>

当父组件中使用 MyComponent 组件时,可以传递插槽内容,如下面所示:

html
<MyComponent>
  <template #header="{msg}">
    <h1>{{msg}}</h1>
  </template>
  <template #body>
    <p>这是 MyComponent 组件的内容</p>
  </template>
  <template #footer>
    <p>Footer</p>
  </template>
</MyComponent>

上面的父组件的模板会被编译如下的渲染函数:

ts
// 父组件的渲染函数
function render(){
  return {
    type: MyComponent,
    // 组件的 children 会被编译成一个对象
    children: {
      header() {
        return { type: 'h1', children:'Hello World'}
      },
      body() {
        return { type: 'p', children:'这是 MyComponent 组件的内容'}
      },
      footer(){
        return { type: 'p', children:'Footer'}
      }
    }
  }
}

由父组件的渲染函数可以看出,组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容(虚拟 DOM)。

组件 MyComponent 的模板则会被编译为如下渲染函数:

ts
// MyComponent 组件模板的编译结果
function render(){
  return [
    { type: 'header', children: [this.$slots.header({msg: this.msg})] },
    { type: 'div', children: [this.$slots.body()] },
    { type: 'footer', children: [this.$slots.footer()] },
  ]
}

可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由基返回的内容的过程。

子组件给插槽函数传递数据时, 可以在插槽函数中通过参数接收父组件传递的数据。这也就是插槽函数的参数。

注册生命周期

在vue3中有部分组合式API可以注册组件的生命周期钩子函数,如onMounted、onUpdated、onUnmounted等。 看下面代码:

ts
import { onMounted } from 'vue'

function setup(props, context) {
  onMounted(() => {
    console.log('组件挂载完成1')
  })

// 可以注册多个
  onMounted(() => {
    console.log('组件挂载完成2')
  })
}

在setup函数中注册的生命周期钩子函数会在组件挂载完成时被调用。

这里存在一个问题,就是setup中如何知道注册钩子函数到哪个一个组件实例上?

vue内部维护了一个变量currentInstance, 用来指向当前正在渲染的组件实例,每当初始化组件并执行组件的setup函数前, 先将当前组件实例赋值给currentInstance,再执行组件的setup函数,这样就可以在setup函数中通过currentInstance来获取当前正在被初始化的组件实例,从而将那些通过onMounted函数注册的钩子函数与组件实例进行关联。

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

function onMounted(fn) {

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

可以看到,在onMounted函数中,我们判断了currentInstance是否存在,如果不存在,则抛出错误。这是因为onMounted函数只能在setup函数中调用,而setup函数只能在组件实例初始化时调用。

组件的挂载过程(简化源码)

缓存任务队列

ts
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set()

// 一个标志,代表是否正在刷新任务队列
let isFlushing = false

// 创建一个立即 resolve 的 Promise 实例
const nextTickPromise = Promise.resolve()

// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {

  // 将 job 添加到任务队列 queue 中
  queue.push(job);

  // 如果还没有开始刷新队列,则刷新之
  if (!isFlushing) {

    // 将该标志设置为 true 以避免重复刷新
    isFlushing = true;

    // 在微任务中刷新缓冲队列
    nextTickPromise.then(() => {

      try {

        // 执行所有任务
        job.forEach(job => job());
      } finally {

        // 清空任务队列,并将标志重置为 false
        isFlushing = false;
        
        // 清空任务队列
        job.length = 0;
      }
      
    });
  }
}

组件的挂载过程

ts

// 简化版本的组件挂载过程
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type;
  const { render, data, beforeCreate, created, beforeMount, mounted 
    props: propsOption
    setup,
  } = componentOptions;

  // 调用 beforeCreate 钩子函数,如果存在的话
  beforeCreate && beforeCreate()

  // 调用data函数得到原始数据,并调用reactive函数将其转换为响应式数据
  const state = reactive(data());


  // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
  const [props, attrs] = resolveProps (propsOption, vnode.props);

  // 直接使用编译好的 vnode.children 对象作为 slots 对象即可
  const slots = vnode.children || {}

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // // 组件自身的状态数据,即 data
    state,

    // 将解析出的 props 数据包装为 shalloReactive 并定义到组件实例上
    props: shallowReactive(props),

    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,

    // 组件所渲染的内容,即子树(subTree)
    subTree: null,

    slots,
  }

  //  定义 emit 函数,它接收两个参数
  //  event:事件名称
  //   payload: 传递给事件处理函数的参数
  function emit(event, ...payload) {
    // 根据约定对事件名称进行处理,例如 change --> onChange
    const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
    // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
    const handler = instance.props[eventName]
    if (handler) {
      // 调用事件处理函数,并传递 payload 参数
      handler(...payload)
    } else {
      console.warn(`不存在事件处理函数: ${eventName}`)
    }
  }

  // setupContext 是一个对象,包含了组件实例的一些属性和方法
  const setupContext = {
    attrs,
    slots,
    emit,
  }

  // 在调用 setup 函数之前,设置当前组件实例
  setCurrentInstance(instance)

  // 调用 Setup 函数,将只读版本的 props 作为第一个参数传递,避免用戶意外地修改 props 的值,
  // 将 setupContext 作为第二个参数传递
  const setupResult = setup(shallowReadonly(instance.props), setupContext)

  // 在 setup 函数执行完毕之后,重置当前组件实例
  setCurrentInstance(null)

  // setupState 用来存储由 setup 返回的数据
  let setupState = null

  // 如果 setup 函数的返回值是函数,则将其作为渲染函数
  if (isFunction(setupResult)) {
    // 报告冲突
    if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略)
    // 将 setupResult 作为渲染函数
    render = setupResult
  } else {
    //  如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
    setupState = setupResult
  }

  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance;

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r)) {
      //  取得组件自身状态与 props 数据
      const { state, props, slots } = t;

      //  当k 的值为 $slots 时,直接返回组件实例上的 slots
      if (k === '$slots') return slots;

      if (state && k in state) {
        //  先尝试读取自身状态数据
        return state[k]
      } else if (props && k in props) {
        // 如果组件自身没有该数据,则尝试从 props 中读取
        return props[k]
      } else if (setupState && k in setupState) {
        // 渲染上下文需要增加对 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 (props && k in props) {
        // props[k] = v
        console.error('不能直接修改 props')
      } else if (setupState && k in setupState) {
        //  如果 setupState 有该数据,则直接赋值
        setupState[k] = v
      } else {
        console.error('不存在')
      }
    },
  })

  // 调用 created 钩子函数,如果存在的话
  created && created.call(renderContext)

// 调用effect函数,将render函数包装为响应式函数,
// 当响应式数据发生变化时,会自动触发render函数重新执行
  effect(function render() {
    // 调用render函数,将this绑定为响应式数据,将响应式数据渲染为虚拟DOM节点
    // 调用组件的渲染函数,获得子树
    const subTree = render.call(renderContext, state);

// 检查组件是否已经被挂载
    if (!instance.isMounted) {

      // 调用 beforeMount 钩子函数,如果存在的话
      beforeMount && beforeMount.call(renderContext)

      // 初次挂载,调用 patch 函数第一个参数传递 null
      patch(null, subTree, container, anchor);
      // 重点:将组件实例的isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
      // 而是直接调用 patch 函数进行更新
      instance.isMounted = true;

      // 调用 mounted 钩子函数,如果存在的话
      // mounted && mounted.call(renderContext)
      // 调用 mounted 钩子函数数组中的每个函数
      instance.mounted && instance.mounted.forEach(fn => fn.call(renderContext))
    } else {

      // 调用 beforeUpdate 钩子函数,如果存在的话
      beforeUpdate && beforeUpdate.call(renderContext)

      // 当isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
      // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
      // 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
      patch(instance.subTree, subTree, container, anchor);

      // 调用 updated 钩子函数,如果存在的话
      updated && updated.call(renderContext)
    }

    // 更新组件实例的子树
    instance.subTree = subTree;
  }, {
    // 当响应式数据发生变化时,调用scheduler函数将render函数添加到任务队列中,
    // 并在微任务中刷新任务队列
    // effect是同步执行的,添加到缓存队列中,并去重,也可以避免多次执行副作用函数。
    scheduler: queueJob,
  });
}