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

发布于:2025-06-28 ⋅ 阅读:(24) ⋅ 点赞:(0)

目录

12.1 组件的渲染

12.2 组件状态与自更新

12.3 组件实例与生命周期

2.4 Props 与组件被动更新


在上一章节,我们详细探讨了渲染器的基本概念和实现方式,它的主要作用是将虚拟 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)的生命周期钩子函数,因此我们通常需要将它们序列化为一个数组,但核心原理并不会改变。

2.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 等选项中定义的数据和方法,这些内容都应在渲染上下文对象中处理。


网站公告

今日签到

点亮在社区的每一天
去签到