Skip to content

渲染器是将虚拟 dom 转换为真实 dom

编译器将组件转换为虚拟 dom

渲染组件

本小节我们来实现一个最基本的组件化方案,这包括:

  • 开发人员如何书写组件
  • 在渲染器的层面如何描述组件
  • 渲染器如何处理组件

我们一个一个的来解决,首先从用户层面来看,组件就是一个选项对象,用来描述页面内容,所以,组件必须包含一个渲染函数,即render函数,并且render函数的返回值是虚拟 dom。

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

从渲染器角度来看,组件是一个特殊类型的虚拟 dom 节点,我们使用 vnode.type 属性来存储这个选项对象,此外还需要在 patch 函数中对组件类型的 vnode 进行特殊处理:

typescript
// 该 vnode 用来描述组件,type 属性存储组件的选项对象
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') {
    // vnode.type 的值是选项对象,作为组件来处理
    if (!n1) {
      // 挂载组件
      mountComponent(n2, container, anchor)
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor)
    }
  }
}

解决了如何在用户层面和虚拟 dom层面描述组件,接下来我们实现如何渲染组件,我们用mountComponet函数来渲染组件:

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

组件状态与自更新

上一节实现了组件渲染的基本方案,但没有实现组件的自更新,即当响应式数据变化时,页面内容也自动变化。

本节的内容是实现组件状态和自更新,核心内容有两点:

  • 在组件选项中增加data 属性存储组件状态,实现组件状态管理
  • mountComponent函数中将渲染逻辑包括在effect函数中,实现自更新
  • 使用微任务队列避免组件不必要的更新
typescript
const MyComponent = {
  name: 'myComponent',
  data() {
    return {
      foo: 'hello world'
    }
  },
  render() {
    return {
      type: 'div',
      children: `foo的值为:${this.foo}`
    }
  }
}


function mountComponent(vnode: VNode, container: HTMLElement, anchor: HTMLElement) {
  const componentOptions = vnode.type
  // 获取组件渲染函数
  const { render, data } = componentOptions as ComponentOptions
  // 调用data函数获取响应式数据
  const state = reactive(data())

  // 将渲染函数的执行包裹在effect函数中,从而实现自更新
  effect(() => {
    // 执行渲染函数,获取虚拟dom
    // 将render函数内的this指向state
    const subTree = render.call(state, state)
    // 调用patch函数来挂载组件
    patch(null, subTree, container, anchor)
  }, {
    // 指定调度器为一个微任务队列,减少不必要的刷新
    scheduler: queueJob
  })
}

组件实例和组件生命周期

本节的要点:

  • 在 mountComponent 函数中增加存储组件实例状态的instance 变量,用于存储组件状态相关内容,包括isMountedsubtree等内容,
  • 在 mountComponent 函数中合适的位置调用组件的生命周期钩子
typescript
function mountComponent(vnode: VNode, container: HTMLElement, anchor: HTMLElement) {
  const componentOptions = vnode.type
  // 获取组件渲染函数
  const { render, data,
         beforeCreate, created, beforeMountd, mounted, beforeUpdate, updated
        } = componentOptions as ComponentOptions

  // 调用beforeCreate钩子 
  beforeCreate && beforeCreate()

  // 调用data函数获取响应式数据
  const state = reactive(data())

  // 调用created钩子
  created && created.call(state)
  // 组件实例,用于存储组件的状态信息
  const instance = {
    // 组件状态数据
    state,
    // 是否挂载
    isMounted: false,
    // 组件虚拟dom
    subtree: null
  }
  // 将组件实例设置到vnode中,便于后续更新
  vnode.component = instance

  // 将渲染函数的执行包裹在effect函数中,从而实现自更新
  effect(() => {
    // 执行渲染函数,获取虚拟dom
    // 将render函数内的this指向state
    const subTree = render.call(state, state)
    if (!instance.isMounted) {
      // 调用beforeMountd钩子
      beforeMountd && beforeMountd.call(state)

      // 调用patch函数来挂载组件
      patch(null, subTree, container, anchor)
      instance.isMounted = true
      // 调用mounted钩子
      mounted && mounted.call(state)
    } else {
      // 调用beforeUpdate钩子
      beforeUpdate && beforeUpdate.call(state)

      // 当组件已经挂载,说明此时是更新操作,拿旧的虚拟dom与新的虚拟dom进行打不定操作
      patch(instance.subtree, subTree, container, anchor)

      // 调用updated钩子
      updated && updated.call(state)
    }
    // 更新实例中的虚拟子树
    instance.subtree = subTree
  }, {
    // 指定调度器为一个微任务队列,减少不必要的刷新
    scheduler: queueJob
  })
}

props 与组件被动更新

本节的内容关于当 props 的值变化时,子组件如何更新。

props 本质上是属于父组件的数据,我们把这种父组件自更新所引起的子组件的更新叫做子组件的被动更新。

本节要点:

  • props 的解析,包括定义在组件选项对象中props 和没有定义的 props 如何处理
  • 组件被动更新的实现

首先是** props 的解析**,封装了单独的函数resolveProps函数:

typescript
/**
   * 解析props
   * @param options 为子组件传递的props选项
   * @param propsData 子组件中定义的props
   * @returns 
   */
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]
    }
  }
  return [props, attrs]
}

并在mountComponent函数中将解析出的 props 挂载到组件实例上:

typescript
/**
   * 挂载组件
   * @param n2 
   * @param container 
   * @param anchor 
   */
function mountComponent(vnode: VNode, container: HTMLElement, anchor: HTMLElement) {
  const componentOptions = vnode.type
  // 获取组件渲染函数
  const { render, data, props: propsOptions,
         beforeCreate, created, beforeMountd, mounted, beforeUpdate, updated
        } = componentOptions as ComponentOptions
  // 省略代码......

  // 12.4 新增
  const [props, attrs] = resolveProps(propsOptions, vnode.props)
  // 组件实例,用于存储组件的状态信息
  const instance = {
    // 组件状态数据
    state,
    // 12.4新增:将解析出的props包装为shallowReactive并定义在组件实例上
    props: shallowReactive(props),
    // 是否挂载
    isMounted: false,
    // 组件虚拟dom
    subtree: null
  }
  // 省略代码......
}

前边我们实现了 props 的解析,接下来需要解决当 props 变化时,触发组件的被动更新。

前边的实现中我们把组件的响应式数据 state 传递给渲染函数(渲染函数包裹在副作用函数 effect 中),这样当 state 变化时,渲染函数就可以执行,从而触发组件的更新。

这样看,问题变为了如何将 props 也放在渲染函数内,这里我们创建一个渲染上下文 renderContext,其实就是组件实例 instance 的代理,通过这个代理来处理渲染函数对stateprops以及未来可能出现的对组件状态数据的访问:

typescript

/**
   * 挂载组件
   * @param n2 
   * @param container 
   * @param anchor 
   */
function mountComponent(vnode: VNode, container: HTMLElement, anchor: HTMLElement) {
  const componentOptions = vnode.type
  // 获取组件渲染函数
  const { render, data, props: propsOptions,
         // ...省略部分
        } = componentOptions as ComponentOptions

  // 调用data函数获取响应式数据
  const state = reactive(data ? data() : {})

  // 12.4 新增
  const [props, attrs] = resolveProps(propsOptions, vnode.props)

  // 组件实例,用于存储组件的状态信息
  const instance = {
    // 组件状态数据
    state,
    // 12.4新增:将解析出的props包装为shallowReactive并定义在组件实例上
    props: shallowReactive(props),
    // 是否挂载
    isMounted: false,
    // 组件虚拟dom
    subtree: null
  }

  // 12.4新增: 创建渲染上下文对象,暴漏props和state状态数据给渲染函数,使得渲染函数可以通过this访问
  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.warn(`${String(k)}不存在`)
      }
    },
    set(t, k, v, r) {
      const { state, props } = t
      if (state && k in state) {
        state[k] = v
      } else if (k in props) {
        console.warn(`props是只读的`)
      } else {
        console.warn('不存在')
      }

    }
  })
  // 省略代码......
}

本部分内容收获挺多的,了解 props 在 vue3 中是如何传递的,props 和 data 选项的数据是如何处理的

setup 函数的作用和实现

**setup 函数的作用: **

setup 函数主要用于配合组合式 API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况:

  • 返回一个函数,该函数将作为组件的 render 函数
  • 返回一个对象,该对象中的数据将暴漏给模板使用

另外 setup 函数接受两个参数:第一个参数是 props 数据对象,第二个参数也是一个对象,通常称为 setupContext:

typescript
const Comp = {
  props: {
    foo: String
  },
  setup(props, setupContext) {
    props.foo // 访问传入的 props 数据
    // setupContext 中包含与组件接口相关的重要数据
    const { slots, emit, attrs, expose } = setupContext
    // ...
  }
}

组件事件与 emit 的实现

本部分内容使用场景是:

在子组件中触发(emit)一个自定义事件,在父组件中监听这个自定义事件,并绑定事件处理函数:

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

在父组件中:

html
<MyComponent @change="handler" />

这部分功能实现的基本思路是:

  1. **收集:**收集组件中绑定的自定义事件,并存储在 props 中
  2. **触发:**封装 emit 函数,当子组件触发自定义事件中,从 props 中找出并执行
typescript
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 }

  // 省略部分代码
}
/**
* 解析props
* @param options 为子组件传递的props选项
* @param propsData 子组件中定义的props
* @returns 
*/
function resolveProps(options = {}, propsData = {}) {
  const props = {}
  const attrs = {}
  // 遍历为组件传递的props
  for (const key in propsData) {
    // 12.6新增:以字符串on开头的的props,无论是否显示声明,都将其添加到props对象中
    if (key in options || key.startsWith('on')) {
      // 如果为组件传递的props在组件自身的props中存在,则将其添加到props对象中
      props[key] = propsData[key]
    } else {
      // 否则将其添加到attrs对象中
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

插槽 slot 的工作原理及实现

插槽的实现更多是编译层面的:

举例来说,对于子组件 MyComponent

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

父组件中使用:

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

父组件会编译为:

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

子组件 MyComponent 会编译为:

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

注册生命周期实现

本节主要是实现 vue3 中的生命周期钩子函数的特性,如onMounted等函数。

typescript
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
  currentInstance = instance
}
// onMounted函数实现
function onMounted(fn) {
  if (currentInstance) {
    // 将生命周期函数添加到 instance.mounted 数组中
    currentInstance.mounted.push(fn)
  } else {
    console.error('onMounted 函数只能在 setup 中调用')
  }
}
//
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)

  // 省略部分代码
  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
  })
}

整体实现思路非常简洁明了,通过全局currentInstance变量存储当前组件实例,并实现 onMounted 等生命周期钩子函数,在生命周期钩子函数中添加到当前当前组件实例上,最后在 mountComponent 函数合适的位置上添加这些钩子函数。