likes
comments
collection
share

精读《Vuejs设计与实现》第 12 章(组件实现原理)

作者站长头像
站长
· 阅读数 29

在上一章节,我们详细探讨了渲染器的基本概念和实现方式,它的主要作用是将虚拟 DOM 渲染为真实 DOM。然而,当我们处理复杂页面时,虚拟 DOM 描述页面结构的代码量可能会剧增,导致页面模板臃肿。为此,我们引入了组件化的概念,通过组件,我们可以将大型页面划分为多个模块,每个模块都独立为一个组件,最终组成完整的页面。本章将详细介绍 Vue.js 中的组件化实现。

12.1 组件的渲染

对用户而言,有状态组件可以理解为一个选项对象,如下:

// MyComponent 是一个组件,它的值是一个选项对象
const MyComponent = {
  name: 'MyComponent',
  data() {
    return { foo: 1 }
  }
}

对于渲染器来说,组件实际上是一种特殊的虚拟 DOM 节点。例如,我们可以通过 vnode.type 属性描述不同类型的节点,如普通标签、片段或者文本:

// 普通标签
const vnode = { type: 'div' }

// 片段
const vnode = { type: Fragment }

// 文本节点
const vnode = { type: Text }

渲染器的 patch 函数就用于处理这些不同类型的节点:

function patch(n1, n2, container, anchor) {
  // 省略部分代码...
  const { type } = n2
  if (typeof type === 'string') {
    // 普通元素
  } else if (type === Text) {
    // 文本节点
  } else if (type === Fragment) {
    // 片段
  }
  // 省略部分代码...
}

为了描述组件,我们可以将 vnode.type 属性设置为组件的选项对象:

// 该 vnode 用来描述组件,type 属性存储组件的选项对象
const vnode = {
  type: MyComponent
  // ...
}

然后在 patch 函数中添加逻辑以处理组件类型的虚拟节点:

// 描述组件
const vnode = { type: MyComponent }

// 处理组件类型的虚拟节点
function patch(n1, n2, container, anchor) {
  // 省略部分代码...
  const { type } = n2
  if (typeof type === 'string') {
    // 普通元素
  } else if (type === Text) {
    // 文本节点
  } else if (type === Fragment) {
    // 片段
  } else if (typeof type === 'object') {
    // 组件
    if (!n1) {
      mountComponent(n2, container, anchor) // 挂载组件
    } else {
      patchComponent(n1, n2, anchor) // 更新组件
    }
  }
  // 省略部分代码...
}

组件在用户层面的接口设计包括:如何编写组件?组件的选项对象必须包含哪些内容?以及组件拥有哪些能力?等等。组件本身是对页面内容的封装,它用来描述页面内容的一部分。因此一个组件必须包含一个 render 函数,该函数的返回值是虚拟 DOM,用于描述组件的渲染内容:

const MyComponent = {
  name: 'MyComponent', // 组件名称,可选
  // 组件的渲染函数,其返回值必须为虚拟 DOM
  render() {
    return {
      type: 'div',
      children: `我是文本内容`
    }
  }
}

有了组件的基础结构,渲染器就能够渲染组件:

// 描述组件的 VNode 对象,type 属性为组件的选项对象
const CompVNode = { type: MyComponent }

// 调用渲染器来渲染组件
renderer.render(CompVNode, document.querySelector('#app'))

具体的组件渲染任务是由 mountComponent 函数完成的:

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const { render } = componentOptions
  // 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
  const subTree = render()
  // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
  patch(null, subTree, container, anchor)
}

至此,我们实现了最基本的组件化方案。

12.2 组件状态与自更新

在前一节中,我们实现了组件的初始渲染。此次我们将研究如何设计组件的自身状态和自更新。请参考以下代码:

const MyComponent = {
  name: 'MyComponent',
  // 用 data 函数来定义组件状态
  data() {
    return {
      foo: 'hello world'
    }
  },
  render() {
    return {
      type: 'div',
      children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
    }
  }
}

上述代码,我们规定用户必须使用 data 函数来定义组件状态,同时可以在渲染函数中通过 this 访问由 data 函数返回的状态数据。我们如下实现组件状态的初始化:

function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  const { render, data } = componentOptions
  // 通过调用 data 函数获取原始数据,然后使用 reactive 函数将其转化为响应式数据
  const state = reactive(data())
  // 在调用 render 函数时,将其 this 指向为 state,以便 render 函数可以通过 this 访问组件状态
  const subTree = render.call(state, state)
  patch(null, subTree, container, anchor)
}

实现组件自身状态的初始化需要两个步骤:

  1. 执行 data 函数并利用 reactive 函数使得返回的状态成为响应式数据。
  2. 调用 render 函数时,将 this 指向响应式数据 state,同时将 state 作为 render 函数的第一个参数。

当组件状态变化,我们需能触发组件自更新。为实现此功能,我们将渲染任务包装到一个 effect 中:

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

  const state = reactive(data())

  // 将组件的 render 函数调用包装到 effect 内
  effect(() => {
    const subTree = render.call(state, state)
    patch(null, subTree, container, anchor)
  })
}

这样,只要组件的响应式数据发生改变,将自动执行渲染函数,完成更新。但存在一个问题,多次修改响应式数据将导致渲染函数多次执行,这显然是不必要的。因此,我们需要实现一个调度器,将任务缓冲到微任务队列中,等待执行栈清空后再执行,从而避免多次执行带来的性能消耗:

// 创建任务缓存队列,用 Set 结构实现任务去重
const queue = new Set()
// 标记是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例
const p = Promise.resolve()

// 调度器主函数,将任务添加到缓冲队列并开始刷新队列
function queueJob(job) {
  // 将 job 添加到任务队列
  queue.add(job)
  // 如果还未开始刷新队列,开始刷新
  if (!isFlushing) {
    isFlushing = true // 避免重复刷新
    // 在微任务中刷新缓冲队列
    p.then(() => {
      try {
        queue.forEach(job => job())
      } finally {
        isFlushing = false
        queue.clear = 0
      }
    })
  }
}

利用上述代码,我们便实现了调度器,从而将渲染任务缓冲至微任务队列中。以下是在创建渲染副作用时使用调度器的代码:

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

  const state = reactive(data())

  effect(() => {
    const subTree = render.call(state, state)
    patch(null, subTree, container, anchor)
  }, {
    // 指定该副作用函数的调度器为 queueJob
    scheduler: queueJob
  })
}

这样,当状态变化时,副作用函数将在微任务中执行,而非立即同步执行。然而,上述代码存在一个问题。每次更新时,都会全新挂载而不是打补丁。我们应该在每次更新时,用新的 subTree 对比上次渲染的 subTree,并打补丁。因此,我们需要实现一个组件实例,用它来维护组件整个生命周期的状态,从而让渲染器在正确的时机执行合适的操作。

12.3 组件实例与生命周期

在前一节,我们通过引入组件实例的概念,解决了组件更新的问题。组件实例本质是一个对象,它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data)等。为解决组件更新的问题,我们需要引入组件实例的概念,以及与之相关的状态信息:

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

  const state = reactive(data())

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null
  }

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

  effect(
    () => {
      // 调用组件的渲染函数,获得子树
      const subTree = render.call(state, state)
      // 检查组件是否已经被挂载
      if (!instance.isMounted) {
        // 初次挂载,调用 patch 函数第一个参数传递 null
        patch(null, subTree, container, anchor)
        // 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
        // 而是会执行更新
        instance.isMounted = true
      } else {
        // 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
        // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
        // 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
        patch(instance.subTree, subTree, container, anchor)
      }
      // 更新组件实例的子树
      instance.subTree = subTree
    },
    { scheduler: queueJob }
  )
}

在这段代码中,我们创建了一个对象来标识组件实例,它包含三个主要的属性:

  • state 保存组件自身的状态数据,即 data。
  • isMounted 用于标识组件是否已挂载,。
  • subTree 存储组件的渲染函数返回的虚拟 DOM,即组件的子树(subTree)。

我们可以在组件实例对象上添加更多的属性,但应保持其轻量,以减小内存占用。要注意的是,我们使用了 instance.isMounted 属性来区分组件的挂载和更新操作。通过这种方式,我们可以在正确的时机调用相应的生命周期钩子,如下:

function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  // 从组件选项对象中取得组件的生命周期函数
  const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = 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)
        instance.isMounted = true
        // 在这里调用 mounted 钩子
        mounted && mounted.call(state)
      } else {
        // 在这里调用 beforeUpdate 钩子
        beforeUpdate && beforeUpdate.call(state)
        patch(instance.subTree, subTree, container, anchor)
        // 在这里调用 updated 钩子
        updated && updated.call(state)
      }
      instance.subTree = subTree
    },
    { scheduler: queueJob }
  )
}

上述代码,我们获取了组件选项中的生命周期钩子函数,并在相应的时机执行它们。这其实就是组件生命周期的实现原理。实际上,一个组件可能有多个相同的生命周期钩子,例如来自混入(mixins)的生命周期钩子函数,因此我们通常需要将它们序列化为一个数组,但核心原理并不会改变。

12.4 Props 与组件被动更新

虚拟 DOM 层面上,组件的 props 与 HTML 标签的属性相似。考虑以下模板:

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

对应的虚拟 DOM 为:

const vnode = {
  type: MyComponent,
  props: {
    title: 'A Big Title',
    other: this.val
  }
}

我们可以看到模板与虚拟 DOM 几乎是“同构”的。在编写组件时,我们需明确指定组件接收的 props 数据:

const MyComponent = {
  name: 'MyComponent',
  // 组件接收名为 title 的 props,并且该 props 的类型为 String
  props: {
    title: String
  },
  render() {
    return {
      type: 'div',
      children: `count is: ${this.title}` // 访问 props 数据
    }
  }
}

对于组件,需要关注的 props 内容有两部分:

  1. 给组件传递的 props 数据,即组件的 vnode.props 对象;
  2. 组件选项对象中定义的 props 选项,即 MyComponent.props 对象。

我们需要将这两部分结合起来,以解析出组件在渲染时需用到的 props 数据。具体实现如下:

function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  // 从组件选项对象中取出 props 定义,即 propsOption
  const { render, data, props: propsOption /* 其他省略 */ } = componentOptions

  beforeCreate && beforeCreate()

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

  const instance = {
    state,
    // 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
    props: shallowReactive(props),
    isMounted: false,
    subTree: null
  }
  vnode.component = instance

  // 省略部分代码
}

// resolveProps 函数用于解析组件 props 和 attrs 数据
function resolveProps(options, propsData) {
  const props = {}
  const attrs = {}
  // 遍历为组件传递的 props 数据
  for (const key in propsData) {
    if (key in options) {
      // 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
      props[key] = propsData[key]
    } else {
      // 否则将其作为 attrs
      attrs[key] = propsData[key]
    }
  }

  // 最后返回 props 与 attrs 数据
  return [props, attrs]
}

上述代码中,我们将 MyComponent.props 对象和 vnode.props 对象相结合,解析出组件在渲染时需要的 props 和 attrs 数据。注意以下两点:

  1. 在 Vue3 中,没有定义在 MyComponent.props 选项中的 props 数据将存储到 attrs 对象中。
  2. 上述实现并未包含默认值和类型校验等处理。实际上,这些处理都是围绕 MyComponent.props 和 vnode.props 对象进行的,其实现并不复杂。

在探讨完组件的 props 数据之后,我们进一步探讨 props 数据的变化对组件的影响。props 本质是父组件的数据,一旦它变化,将触发父组件的重新渲染。以一个具有如下模板的父组件为例:

<template>
  <MyComponent :title="title"/>
</template>

在此,响应式数据 title 的初始值设为 "A big Title",因此,首次渲染时,父组件的虚拟 DOM 表示为:

// 父组件渲染内容
const vnode = {
  type: MyComponent,
  props: {
    title: 'A Big Title'
  }
}

如果响应式数据 title 改变,父组件的渲染函数将重新执行。例如,假设 title 的值变为 "A Small Title",则新生成的虚拟DOM为:

// 父组件渲染内容
const vnode = {
  type: MyComponent,
  props: {
    title: 'A Small Title'
  }
}

父组件会接着进行自更新。在更新过程中,渲染器发现父组件的 subTree 包含组件类型的虚拟节点,因此会调用 patchComponent 函数来完成子组件的更新。以下是该 patch 函数的代码:

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) {
    // 省略部分代码
  } else if (typeof type === 'object') {
    // vnode.type 的值是选项对象,作为组件处理
    if (!n1) {
      mountComponent(n2, container, anchor)
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor)
    }
  }
}

patchComponent 函数主要负责子组件的更新。由父组件自更新触发的子组件更新我们称之为子组件的被动更新。在子组件被动更新时,我们需要:

  1. 检查子组件是否确实需要更新,因为子组件的 props 可能是没有变化的。
  2. 如果需要更新,则更新子组件的 props、slots 等内容。

以下是 patchComponent 函数的实现:

function patchComponent(n1, n2, anchor) {
  // 获取组件实例,并让新的组件虚拟节点也指向组件实例
  const instance = (n2.component = n1.component)
  // 获取当前的props数据
  const { props } = instance
  // 检查是否有props的变化,如果没有,则无需更新
  if (hasPropsChanged(n1.props, n2.props)) {
    // 重新获取props数据
    const [ nextProps ] = resolveProps(n2.type.props, n2.props)
    // 更新props
    for (const k in nextProps) {
      props[k] = nextProps[k]
    }
    // 删除不存在的props
    for (const k in props) {
      if (!(k in nextProps)) delete props[k]
    }
  }
}

function hasPropsChanged(prevProps, nextProps) {
  const nextKeys = Object.keys(nextProps)
  // 如果新旧props的数量变了,则说明有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  // 只有不相等的props,才说明有变化
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    if (nextProps[key] !== prevProps[key]) return true
  }
  return false
}

注意点有两个:

  1. 必须将组件实例添加到新的组件 vnode 对象上,即 n2.component = n1.component,否则下次更新时将无法取得组件实例;
  2. instance.props 对象本身是浅响应的。因此,在更新组件的 props 时,只需设置 instance.props 对象下的属性值即可触发组件重新渲染。

上述实现并未处理 attrs 与 slots 的更新。attrs 更新本质上与更新 props 的原理类似,而对于 slots,我们会在后续章节中讲解。实际上,完全实现Vue.js中的 props 机制需要编写大量边界代码,但其基本原理是根据组件的 props 选项定义和传递给组件的 props 数据处理。

由于 props 数据和组件自身的状态数据都需要在渲染函数中暴露,并使渲染函数能通过 this 访问它们,我们需要封装一个渲染上下文对象,如下:

function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null
  }

  vnode.component = instance

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      // 取得组件自身状态与 props 数据
      const { state, props } = t
      // 先尝试读取自身状态数据
      if (state && k in state) {
        return state[k]
      } else if (k in props) {
        // 如果组件自身没有该数据,则尝试从 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) {
        console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
      } else {
        console.error('不存在')
      }
    }
  })

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

  // 省略部分代码
}

上述代码,我们为组件实例创建了一个代理对象,即渲染上下文对象。它的意义在于拦截数据状态的读取和设置操作。每当在渲染函数或生命周期钩子中通过 this 来读取数据时,都会优先从组件的自身状态中读取,如果组件本身没有对应的数据,则从 props 数据中读取。最后我们将【渲染上下文】作为渲染函数以及生命周期钩子的 this 值即可实际上,除了组件自身的数据以及 props 数据之外,完整的组件还包含 methods、computed 等选项中定义的数据和方法,这些内容都应在渲染上下文对象中处理。

12.5 setup函数的使用与实现

Vue3 引入了新的组件选项—— setup 函数,区别于 Vue2 的组件选项。setup 函数是组合式 API 的核心,提供一处创建组合逻辑、创建响应式数据、定义通用函数和注册生命周期钩子等。它仅在组件挂载时执行一次,有两种可能的返回类型:

  1. 它可以返回一个函数作为组件的渲染函数:
const Comp = {
  setup() {
    // setup 函数返回一个作为组件的渲染函数的函数
    return () => {
      return { type: 'div', children: 'hello' }
    }
  }
}

这种情况适用于当组件不使用模板表示其渲染内容。如果组件使用模板,那么 setup 不能再返回函数,否则会与模板编译的渲染函数冲突。

  1. 它也可以返回一个对象,该对象中的数据将供模板使用:
const Comp = {
  setup() {
    const count = ref(0)
    // 返回的对象中的数据会暴露给渲染函数
    return {
      count
    }
  },
  render() {
    // 通过 this 可以访问 setup 暴露的响应式数据
    return { type: 'div', children: `count is: ${this.count}` }
  }
}

上述代码,通过 this,我们可以在 render 函数中访问由 setup 暴露的数据。

setup 函数接收两个参数:props 数据对象和一个称为 setupContext 的对象,如下所示:

const Comp = {
  props: {
    foo: String
  },
  setup(props, setupContext) {
    props.foo // 访问传入的 props 数据
    const { slots, emit, attrs, expose } = setupContext
  }
}

setup 函数可以通过其第一个参数获取外部传递的 props 数据对象。同时,接收第二个参数 setupContext 对象,其中包含与组件接口相关的数据和方法,例如 slots(插槽)、emit(发射自定义事件)、attrs(属性)、expose(暴露组件数据,仍在设计讨论中)等。在 Vue3 中,我们更推崇组合式 API,故 setup 函数应与 Vue.js 2 中的其他组件选项,如 data、watch、methods 等分开使用,避免混用带来的语义和理解困扰。现在,我们将基于上述功能实现 setup 组件选项:

function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  // 从组件选项中取出 setup 函数
  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
  }

  // setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要 attrs
  const setupContext = { attrs }
  // 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
  // 将 setupContext 作为第二个参数传递
  const setupResult = setup(shallowReadonly(instance.props), setupContext)
  // setupState 用来存储由 setup 返回的数据
  let setupState = null
  // 如果 setup 函数的返回值是函数,则将其作为渲染函数
  if (typeof setupResult === 'function') {
    // 报告冲突
    if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
    // 将 setupResult 作为渲染函数
    render = setupResult
  } else {
    // 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
    setupState = setupResult
  }

  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) {
        // 渲染上下文需要增加对 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) {
        console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
      } else if (setupState && k in setupState) {
        // 渲染上下文需要增加对 setupState 的支持
        setupState[k] = v
      } else {
        console.error('不存在')
      }
    }
  })

  // 省略部分代码
}

上述代码,实现了最简的 setup 函数。这里需要注意的是,setupContext 是一个对象,仅包含 attrs;我们通过检测 setup 函数的返回值类型来确定处理方式。如果返回函数,直接将其作为组件的渲染函数。并确保组件选项中没有现有的 render 选项,否则打印警告。renderContext 需要正确处理 setupState,因为 setup 函数返回的数据状态也应暴露到渲染环境。

12.6 组件事件与 emit 的实现

在 Vue.js 中,我们可以使用 emit 函数来发射组件的自定义事件。例如:

const MyComponent = {
  name: 'MyComponent',
  setup(props, { emit }) {
    // 发射 change 事件,并向事件处理函数传递两个参数
    emit('change', 1, 2)

    return () => {
      return //...
    }
  }
}

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

<MyComponent @change="handler" />

对应的虚拟 DOM 结构如下:

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

在这里,我们可以看到自定义事件 change 已经被编译成一个名为 onChange 的属性,并存储在 props 数据对象中。这是 Vue.js 的默认约定,但如果自己做框架,可以根据需要进行修改。实现 emit 的过程中,本质上就是寻找与事件名对应的处理函数并执行。如下代码所示:

function mountComponent(vnode, container, anchor) {
	// 省略部分代码

	const instance = {
		state,
		props: shallowReactive(props),
		isMounted: false,
		subTree: null,
	}

	// 定义 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) {
			// 调用事件处理函数并传递参数
			handler(...payload)
		} else {
			console.error('事件不存在')
		}
	}

	// 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
	const setupContext = { attrs, emit }

	// 省略部分代码
}

上述代码,我们首先定义了一个 emit 函数,并将其添加到 setupContext 对象中,这样用户就可以在 setup 函数中使用 emit。当 emit 函数被调用时,我们根据命名约定在 props 数据对象中找到对应的事件处理函数。然后,执行这个处理函数并将参数传递给它。但需要注意的是,我们之前在讨论 props 的时候提到过,没有明确声明为 props 的属性会被存储在 attrs 中。这意味着我们如果声明,就不能在 instance.props 中找到任何以 on 开头的属性,即使它们是事件处理器。为了解决这个问题,我们需要对处理 props 的方法做特殊处理:

function resolveProps(options, propsData) {
  const props = {}
  const attrs = {}

  for (const key in propsData) {
    // 如果属性以'on'开头,无论是否显式声明,都将其添加到props中,而不是添加到attrs中
    if (key in options || key.startsWith('on')) {
      props[key] = propsData[key]
    } else {
      attrs[key] = propsData[key]
    }
  }

  return [ props, attrs ]
}

上述代码,我们检查 propsData 的键是否以 'on' 开头,如果是,我们认为该属性是组件的自定义事件,即使组件没有显式声明它为 props,我们也将其添加到最后解析的 props 数据对象中,而不是添加到 attrs 对象中。

12.7 插槽的工作原理与实现

插槽是组件的重要特性,它允许用户插入自定义内容到组件中的预留位置。例如,我们有一个名为 MyComponent 的组件,其模板如下:

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

当我们在父组件中使用 MyComponent 组件时,可以根据插槽名称插入自定义内容:

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

这段父组件的模板编译成的渲染函数如下:

function render() {
  return {
    type: MyComponent,
    children: {
      header() { return { type: 'h1', children: '我是标题' } },
      body() { return { type: 'section', children: '我是内容' } },
      footer() { return { type: 'p', children: '我是注脚' } }
    }
  }
}

这里,插槽内容被编译为插槽函数,这些函数返回的就是插槽的具体内容。而在 MyComponent 组件的模板中,插槽内容则通过调用插槽函数来获得。其编译结果为:

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

可以看到,渲染插槽的过程实际上就是调用插槽函数并渲染其返回的内容,这与 React 中的 render props 模式非常类似。在实现插槽时,我们需要依赖于 setupContext 中的 slots 对象,如下所示:

function mountComponent(vnode, container, anchor) {
	// 省略部分代码

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

	// 将 slots 对象添加到 setupContext 中
	const setupContext = { attrs, emit, slots }
}

在这里,我们直接将编译好的 vnode.children 对象用作 slots 对象,并将其添加到 setupContext 中。为了能在 render 函数和生命周期钩子函数中通过 this.slots访问插槽内容,我们还需要在renderContext中特别处理slots 访问插槽内容,我们还需要在 renderContext 中特别处理 slots访问插槽内容,我们还需要在renderContext中特别处理slots 属性:

function mountComponent(vnode, container, anchor) {
	// 省略部分代码

	const slots = vnode.children || {}

	const instance = {
		state,
		props: shallowReactive(props),
		isMounted: false,
		subTree: null,
		// 将插槽添加到组件实例上
		slots,
	}

	// 省略部分代码

	const renderContext = new Proxy(instance, {
		get(t, k, r) {
			const { state, props, slots } = t
			// 当 k 的值为 $slots 时,直接返回组件实例上的 slots
			if (k === '$slots') return slots

			// 省略部分代码
		},
		set(t, k, v, r) {
			// 省略部分代码
		},
	})

	// 省略部分代码
}

在这里,我们为渲染上下文的代理对象 get 方法添加了一个特殊的处理:如果读取的属性是 slots,那么我们直接返回组件实例上的slots对象,这样用户就可以通过this.slots,那么我们直接返回组件实例上的 slots 对象,这样用户就可以通过 this.slots,那么我们直接返回组件实例上的slots对象,这样用户就可以通过this.slots 来访问插槽内容了。

12.8 注册生命周期

Vue3 引入了一些组合式 API,用来注册生命周期钩子函数,例如 onMounted,onUpdated 等。以下是它们的用法:

import { onMounted } from 'vue'

const MyComponent = {
  setup() {
    onMounted(() => {
      console.log('mounted 1')
    })

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

在 setup 函数中,你可以通过多次调用 onMounted 函数来注册多个 mounted 生命周期钩子函数。它们会在组件被挂载后执行。这里的关键在于:在A组件的 setup 函数中调用 onMounted 会将该钩子函数注册到 A 组件上;而在 B 组件的 setup 函数中调用 onMounted 则会将钩子函数注册到 B 组件上。这一功能是如何实现的呢?要实现这个,我们需要一个全局变量 currentInstance 来存储当前的组件实例。在执行组件的 setup 函数前,我们先设置 currentInstance 为当前组件实例,这样就能关联起通过 onMounted 函数注册的钩子函数与当前组件实例。首先,我们创建一个全局变量 currentInstance 和一个设置该变量的函数 setCurrentInstance。

// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null

// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
  currentInstance = instance
}

接着,我们修改 mounteComponent 函数,设置 currentInstance,并在 setup 函数执行完毕后重置 currentInstance:

function mountComponent(vnode, container, anchor) {
	// 省略部分代码

	const instance = {
		state,
		props: shallowReactive(props),
		isMounted: false,
		subTree: null,
		slots,
		// 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
		mounted: [],
	}

	// 省略部分代码

	// setup
	const setupContext = { attrs, emit, slots }

	// 在调用 setup 函数之前,设置当前组件实例
	setCurrentInstance(instance)
	// 执行 setup 函数
	const setupResult = setup(shallowReadonly(instance.props), setupContext)
	// 在 setup 函数执行完毕之后,重置当前组件实例
	setCurrentInstance(null)

	// 省略部分代码
}

接下来实现 onMounted 函数:

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

最后一步,需要在合适的时机调用这些注册到 instance.mounted 数组中的生命周期钩子函数:

function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  effect(() => {
    const subTree = render.call(renderContext, renderContext)
    if (!instance.isMounted) {
      // 省略部分代码

      // 遍历 instance.mounted 数组并逐个执行即可
      instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
    } else {
      // 省略部分代码
    }
    instance.subTree = subTree
  }, {
    scheduler: queueJob
  })
}

我们只需要在合适的时机遍历 instance.mounted 数组,并逐个执行该数组内的生命周期钩子函数即可。除 mounted 生命周期钩子以外的其他生命周期钩子,其实现原理与此相同。

12.9 小结

本章首先介绍了如何借助虚拟节点来描述组件,其中,vnode.type 属性用于存储组件对象,通过这个属性,渲染器能够判断它是否是组件。如果是,渲染器将通过 mountComponent 和 patchComponent 完成组件的挂载和更新。然后,我们探讨了组件的自更新机制。在组件挂载阶段,我们为组件创建了一个用于渲染其内容的副作用函数,该函数与组件自身的响应式数据建立了关联。当响应式数据变化时,触发重新执行副作用函数,从而重新渲染组件。为了避免额外的性能开销,我们使用了一个自定义调度器,将渲染副作用函数缓存到微任务队列中,实现渲染任务的去重。接下来,我们介绍了组件实例,这是一个包含组件运行过程中状态的对象,例如是否已挂载、响应式数据以及渲染内容等。有了组件实例,我们就可以根据实例的状态来决定是进行新的挂载还是进行补丁操作。此外,我们讨论了组件的 props 和被动更新。引起子组件更新的副作用更新被称为子组件的被动更新。我们也介绍了渲染上下文(renderContext),这实际上是组件实例的代理对象,我们通过它来访问组件实例的数据。我们进一步讨论了 setup 函数,它是组合式 API 的核心,我们避免将它与 Vue.js 2 中的传统组件选项混用。setup 函数的返回值可以是函数或数据对象,前者作为组件的渲染函数,后者会暴露到渲染上下文中。我们还讨论了 emit 函数,它包含在 setupContext 对象中,用于发射组件的自定义事件。经过编译后,组件绑定的事件会以 onXxx 的形式存储到 props 对象中。当 emit 函数执行时,会在 props 对象中寻找对应的事件处理函数并执行。我们还探讨了组件的插槽机制,这是借鉴了 Web Component 中的 <slot> 标签。插槽内容会被编译为插槽函数,返回值即为填充的内容。<slot> 标签会被编译为插槽函数的调用,通过执行相应的插槽函数,我们能得到填充内容的虚拟 DOM,最后将该内容渲染到槽位中。最后,我们介绍了 onMounted 等生命周期钩子函数的注册方法。注册的生命周期函数会被添加到当前组件实例的 instance.mounted 数组中。为了追踪当前初始化的组件实例,我们定义了全局变量 currentInstance 和相应的 setCurrentInstance 函数。

转载自:https://juejin.cn/post/7270822872712609832
评论
请登录