vue3 源码学习,实现一个 mini-vue(十一):组件的设计原理与渲染方案

本文涉及的产品
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
简介: 在实现了 `ELEMENT`、`COMMENT`、`TEXT` 节点的挂载后,我们最后再来实现一下组件的挂载与更新

前言

在实现了 ELEMENTCOMMENTTEXT 节点的挂载后,我们最后再来实现一下组件的挂载与更新

开始实现组件之前,我们需要明确 vue 中一些关于组件的基本概念:

  1. 组件本身是一个对象(仅考虑对象的情况,忽略函数式组件)。它必须包含一个 render 函数,该函数决定了它的渲染内容。
  2. 如果我们想要定义数据,那么需要通过 data 选项进行注册。data 选项应该是一个 函数,并且 renturn 一个对象,对象中包含了所有的响应性数据。
  3. 除此之外,我们还可以定义例如 生命周期计算属性watch 等对应内容。

1. 无状态组件的挂载

Vue 中通常把 状态 比作 数据 的意思。我们所谓的无状态,指的就是 无数据 的意思。

我们先定一个小目标,本小节 仅关注无状态组件的处理逻辑

创建以下测试实例:

<script>
  const { h, render } = Vue

  const component = {
    render() {
      return h('div', 'hello component')
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

上面的代码很简单:

  1. 使用 h 函数 生成组件的 vnode
  2. 使用 render 函数将组件 挂载到 dom

下面我们先从 vue 源码中分析它是如何处理的:

1.1 源码阅读

  1. 根据之前的经验,我们知道 vue 的渲染逻辑,都会从 render 函数进入 patch 函数,所以我们可以直接在 patch 函数中进入 debugger

image.png

  1. 可以看到在 patch 方法中,最终会进入到 processComponent 方法去:

image.png

  1. 接着在 processComponent 方法中,代码最终会进入到 mountComponent 方法去挂载组件,我们进入到 mountComponent 中:

image.png

  1. mountComponent 中代码会先执行 createComponentInstance 方法创建 instance,这个 instance 就是组件实例
const instance: ComponentInternalInstance = { ... }
  1. 通过以上代码最终 生成了 component 组件实例,并且把 组件实例绑定到了 vnode.component 中 ,即:initialVNode.component = instance = 组件实例,接下来我们从 createComponentInstance 方法返回到 mountComponent 方法中执行代码:

image.png

  1. 根据上图可知在 setupComponent 方法中执行了 setupStatefulComponent(instance, isSSR),我们进入 setupStatefulComponent 方法:

image.png

  1. setupStatefulComponent 方法中最终会执行 finishComponentSetup 方法,我们进入 finishComponentSetup 方法:

image.png

  1. 可以看到,在 finishComponentSetup 方法中,最终使 instance 具备了 render 属性。
  2. 我们目前只关注渲染的逻辑,接着 setupStatefulComponent 会返回到 setupComponentsetupComponent 再返回到 mountComponent 方法继续执行:

image.png

  1. 我们总结一下上图的逻辑,在 mountComponent 方法中程序接着会进入到一个 很重要 的方法: setupRenderEffect。这个方法内部主要做了以下几件事:

    • 定义了一个更新函数 componentUpdateFn
    • 创建了一个 ReactiveEffect 实例
    • updateinstance.update 都绑定到一个匿名函数,而这个函数就是用来执行上面的 componentUpdateFn 函数。
    • 最后执行 update 函数。'

我们现在进入 componentUpdateFn 看看里面到底执行了什么:

image.png

  1. 根据上图可知当前 instance.isMounted === false 表示组件没挂载。会执行 patch 方法进行挂载操作,而这个 patch 方法我们也很熟悉了。它是一个 打补丁 函数,我们知道对于 patch 函数来说,第一个参数是 n1,第二个参数是 n2,此时的 n1null,当第一个函数为 null 时就会去挂载 n2,而此时的 n2(subTree) 又是什么呢?往上翻一下代码,我们找到了 subTree 的创建:

image.png

  1. 根据上图我们知道了 subTree 实际上就是 render 调用返回的 vnode,最终执行 patch 函数将 vnode 挂载到 dom 上去,patch 的逻辑就不在过了,之前过很多遍了。
  2. 至此,我们的组件 挂载成功

总结:

重新捋一遍整个组件的挂载过程

  1. 首先整个组件的挂载开始于 mountComponent 方法
  2. mountComponent 方法的内部会通过 createComponentInstance 得到一个组件的实例。组件的实例会和 vnode 进行一个双向绑定的关系。
vnode.component = instance
instance.vnode = initialVNode
  1. 接着,代码执行 setupComponent,在这里会初始化 prop slot 等等属性。对于我们当前测试实例而言,最重要的就是执行了 setupStatefulComponent 方法为 instance.render 赋值
  2. 接着执行 setupRenderEffect 方法,在 setupRenderEffect 中创建了一个 ReactiveEffect 对象,利用 update 方法 触发了 componentUpdateFn 方法
  3. componentUpdateFn 方法中,根据当前的状态 isMounted,生成了 subTreesubTree 本质上就是 render 函数生成的 vnode,最后通过 patch 函数进行挂载

1.2 代码实现

明确好了源码的无状态组件挂载之后,那么接下来我们来进行一下对应实现。

  1. packages/runtime-core/src/renderer.tspatch 方法中,创建 processComponent 的触发:
else if (shapeFlag & ShapeFlags.COMPONENT) {
  // 组件
  processComponent(oldVNode, newVNode, container, anchor)
}
  1. 创建 processComponent 函数:
/**
 * 组件的打补丁操作
 */
const processComponent = (oldVNode, newVNode, container, anchor) => {
  if (oldVNode == null) {
    // 挂载
    mountComponent(newVNode, container, anchor)
  }
}
  1. 创建 mountComponent 方法:
const mountComponent = (initialVNode, container, anchor) => {
  // 生成组件实例
  initialVNode.component = createComponentInstance(initialVNode)
  // 浅拷贝,绑定同一块内存空间
  const instance = initialVNode.component

  // 标准化组件实例数据
  setupComponent(instance)

  // 设置组件渲染
  setupRenderEffect(instance, initialVNode, container, anchor)
}
  1. 创建 packages/runtime-core/src/component.ts 模块,构建 createComponentInstance 函数逻辑:\
let uid = 0

/**
 * 创建组件实例
 */
export function createComponentInstance(vnode) {
  const type = vnode.type

  const instance = {
    uid: uid++, // 唯一标记
    vnode, // 虚拟节点
    type, // 组件类型
    subTree: null!, // render 函数的返回值
    effect: null!, // ReactiveEffect 实例
    update: null!, // update 函数,触发 effect.run
    render: null // 组件内的 render 函数
  }

  return instance
}
  1. packages/runtime-core/src/component.ts 模块,创建 setupComponent 函数逻辑:
/**
 * 规范化组件实例数据
 */
export function setupComponent(instance) {
  // 为 render 赋值
  const setupResult = setupStatefulComponent(instance)
  return setupResult
}

function setupStatefulComponent(instance) {
  finishComponentSetup(instance)
}

export function finishComponentSetup(instance) {
  const Component = instance.type

  instance.render = Component.render
}
  1. packages/runtime-core/src/renderer.ts 中,创建 setupRenderEffect 函数:
/**
 * 设置组件渲染
 */
const setupRenderEffect = (instance, initialVNode, container, anchor) => {
  // 组件挂载和更新的方法
  const componentUpdateFn = () => {
    // 当前处于 mounted 之前,即执行 挂载 逻辑
    if (!instance.isMounted) {
      // 从 render 中获取需要渲染的内容
      const subTree = (instance.subTree = renderComponentRoot(instance))

      // 通过 patch 对 subTree,进行打补丁。即:渲染组件
      patch(null, subTree, container, anchor)

      // 把组件根节点的 el,作为组件的 el
      initialVNode.el = subTree.el
    } else {
    }
  }

  // 创建包含 scheduler 的 effect 实例
  const effect = (instance.effect = new ReactiveEffect(
    componentUpdateFn,
    () => queuePreFlushCb(update)
  ))

  // 生成 update 函数
  const update = (instance.update = () => effect.run())

  // 触发 update 函数,本质上触发的是 componentUpdateFn
  update()
}
  1. 创建 packages/runtime-core/src/componentRenderUtils.ts 模块,构建 renderComponentRoot 函数:
 import { ShapeFlags } from 'packages/shared/src/shapeFlags'
 
 /**
  * 解析 render 函数的返回值
  */
 export function renderComponentRoot(instance) {
     const { vnode, render } = instance
 
     let result
     try {
         // 解析到状态组件
         if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
             // 获取到 result 返回值
             result = normalizeVNode(render!())
         }
     } catch (err) {
         console.error(err)
     }
 
     return result
 }
 
 /**
  * 标准化 VNode
  */
 export function normalizeVNode(child) {
     if (typeof child === 'object') {
         return cloneIfMounted(child)
     }
 }
 
 /**
  * clone VNode
  */
 export function cloneIfMounted(child) {
     return child
 }

至此代码完成。

创建 packages/vue/examples/runtime/render-component.html 测试实例:

<script>
  const { h, render } = Vue

  const component = {
    render() {
      return h('div', 'hello component')
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

此时,组件渲染完成。

2 无状态组件的更新与卸载

此时我们的无状态组件挂载已经完成,接下来我们来看一下 无状态组件更新 的处理逻辑。

创建测试实例:

<script>
  const { h, render } = Vue

  const component = {
    render() {
      return h('div', 'hello component')
    }
  }

  const vnode = h(component)

  render(vnode, document.querySelector('#app'))

  setTimeout(() => {
    const component2 = {
      render() {
        return h('div', 'update component')
      }
    }

    const vnode2 = h(component2)

    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

2.1 源码阅读

render 中进入 debugger

  1. 第一次 进入 render 方法,执行组件挂载,这里不在复述。
  2. 第二次 进入 render 方法,此时是第二个 component 的挂载,即: 更新
  3. 同样进入 patch,此时的参数为:

image.png

  1. 此时存在两个不同的 VNode,所以 if (n1 && !isSameVNodeType(n1, n2)) 判断为 true,此时将执行 卸载旧的 VNode 逻辑

image.png

  1. 执行 ·unmount(n1, parentComponent, parentSuspense, true)· ,触发 卸载逻辑
  2. 代码继续执行,经过 switch,再次执行 processComponent ,因为 旧的 VNode 已经被卸载,所以此时 n1 = null
  3. 代码继续执行,发现 再次触发 mountComponent ,执行 挂载操作
  4. 后续省略

至此,组件更新完成。

由以上代码可知:

  1. 所谓的组件更新,其实本质上就是一个 卸载、挂载 的逻辑

    1. 对于这样的卸载逻辑,我们之前已经完成过。
    2. 所以,目前我们的代码 支持 组件的更新操作。

2.2 代码实现

因为目前我们的代码 支持 组件的更新操作

所以可以直接可创建测试实例 packages/vue/examples/imooc/runtime/render-component-update.html

<script>
  const { h, render } = Vue

  const component = {
    render() {
      return h('div', 'hello component')
    }
  }

  const vnode = h(component)

  render(vnode, document.querySelector('#app'))

  setTimeout(() => {
    const component2 = {
      render() {
        return h('div', 'update component')
      }
    }

    const vnode2 = h(component2)

    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

测试通过

3. 局部总结

那么到现在我们已经完成了 无状态组件的挂载、更新、卸载 操作。

从以上的内容中我们可以发现:

  1. 所谓组件渲染,本质上指的是 render 函数返回值的渲染
  2. 组件渲染的过程中,会生成 ReactiveEffect 实例 effect
  3. 额外还存在一个 instance 的实例,该实例表示 组件本身,同时 vnode.component 指向它
  4. 组件本身额外提供了很多的状态,比如:sMounted

但是以上的内容,全部都是针对于 无状态 组件来看的。

在我们的实际开发中,组件通常是 有状态(即:存在 data 响应性数据 ) 的,那么有状态的组件无状态组件他们之间的渲染存在什么差异呢?让我们继续来往下看。

4. 有状态的响应性组件

和之前一样,我们先创建一个 有状态的组件 测试实例,从源码上分析:

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

该组件存在一个 data 选项,data 选项对外 return 了一个包含 msg 数据的对象。然后我们可以在 render 中通过 this.msg 来访问到 msg 数据。

这样的一种包含 data 选项的组件,我们就把它叫做有状态的组件。

4.1 源码阅读

那么下面,我们对当前实例进行 debugger 操作。

剔除掉之前的重复逻辑,我们之前的关注点在 渲染,现在我们把关注点放在 data 上。直接从 mountComponent 方法开始进入 debugger

image.png

  1. 进入 mountComponent 方法,首先会通过 createComponentInstance 生成 instance 实例,代码继续执行:

image.png

  1. 接着会执行 setupComponent,这个方法是用来初始化组件实例 instance 的,我们跳过 propsslots,程序会进入到 setupStatefulComponent 方法:

image.png

  1. 可以看到 setupStatefulComponent 方法执行了 finishComponentSetup,进入 finishComponentSetup 方法:

image.png

  1. 我们在第 842 行看了一个 applyOptions,很明显的名称告诉我们就是我们想要找的方法,直接进入:

image.png

  1. 根据上图可以看到,在 applyOptions 方法中,首先将 datarender 取了出来,还有很多我们熟悉的属性比如生命周期等等,应该可以意识到 vue 会在这个方法中对他们进行一一处理。代码接着执行:

image.png

  1. 接着调用了通过 const data = dataOptions.call(publicThis, publicThis) 调用 data 函数返回了对象,而且还将 this 传给了 data,最后将 data 通过 reactive 转换为响应式的 proxy 代理对象
  2. 至此 setupComponent 完成。完成之后 instance 将具备 data 属性,值为 proxy,被代理对象为 {msg: 'hello component'}
  3. 代码继续执行,触发 setupRenderEffect 方法,我们知道该方法为组件的渲染方法,最终会通过 renderComponentRoot 生成的 subTree(一个 vnodepatchdom 上。setupRenderEffect 这里的逻辑就不在多复述。

image.png

  1. 到这里 我们已经成功解析了 render,把 this.msg 成功替换为了 hello component
  2. 后面的逻辑,就与 无状态组件 挂载完全相同了。

至此,代码解析完成。

总结:

由以上代码可知:

  1. 有状态的组件渲染,核心的点是:让 render 函数中的 this.xx 得到真实数据
  2. 那么想要达到这个目的,我们就必须要 改变 this 的指向
  3. 改变的方式就是在:生成 subTree 时,通过 call 方法,指定 this

4.2 代码实现

明确好了有状态组件的挂载逻辑之后,我们接下里就进行对应的实现。

  1. packages/runtime-core/src/component.ts 中,新增 applyOptions 方法,为 instance 赋值 data
function applyOptions(instance: any) {
  const { data: dataOptions } = instance.type

  // 存在 data 选项时
  if (dataOptions) {
    // 触发 dataOptions 函数,拿到 data 对象
    const data = dataOptions()
    // 如果拿到的 data 是一个对象
    if (isObject(data)) {
      // 则把 data 包装成 reactiv 的响应性数据,赋值给 instance
      instance.data = reactive(data)
    }
  }
}
  1. finishComponentSetup 方法中,触发 applyOptions
export function finishComponentSetup(instance) {
  const Component = instance.type

  instance.render = Component.render

  // 改变 options 中的 this 指向
  applyOptions(instance)
}
  1. packages/runtime-core/src/componentRenderUtils.ts 中,为 render 的调用,通过 call 方法修改 this 指向:
 /**
  * 解析 render 函数的返回值
  */
 export function renderComponentRoot(instance) {
 +    const { vnode, render, data } = instance
 
     let result
     try {
         // 解析到状态组件
         if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
             // 获取到 result 返回值,如果 render 中使用了 this,则需要修改 this 指向
 +            result = normalizeVNode(render!.call(data))
         }
     } catch (err) {
         console.error(err)
     }
 
     return result
 }

至此,代码完成。

我们可以创建对应测试实例 packages/vue/examples/runtime/render-comment-data.html

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

5. 组件生命周期的回调处理

在前面几节,我们其实已经在源码中查看到了对应的一些生命周期处理逻辑。

我们知道 vue 把生命周期叫做生命周期回调钩子,说白了就是一个:在指定时间触发的回调方法

我们查看 packages/runtime-core/src/component.ts 中 第 213 行可以看到 ComponentInternalInstance 接口,该接口描述了组件的所有选项,其中包含:

  /**
   * @internal
   */
  [LifecycleHooks.BEFORE_CREATE]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.CREATED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.BEFORE_MOUNT]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.MOUNTED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.BEFORE_UPDATE]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.UPDATED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.BEFORE_UNMOUNT]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.UNMOUNTED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.RENDER_TRACKED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.RENDER_TRIGGERED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.ACTIVATED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.DEACTIVATED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.ERROR_CAPTURED]: LifecycleHook
  /**
   * @internal
   */
  [LifecycleHooks.SERVER_PREFETCH]: LifecycleHook<() => Promise<unknown>>

以上全部都是 vue 生命周期回调钩子的选项描述,大家可以在 官方文档 中查看到详细的生命周期钩子描述。

这些生命周期全部都指向 LifecycleHooks 这个 enum 对象:

export const enum LifecycleHooks {
  BEFORE_CREATE = 'bc',
  CREATED = 'c',
  BEFORE_MOUNT = 'bm',
  MOUNTED = 'm',
  BEFORE_UPDATE = 'bu',
  UPDATED = 'u',
  BEFORE_UNMOUNT = 'bum',
  UNMOUNTED = 'um',
  DEACTIVATED = 'da',
  ACTIVATED = 'a',
  RENDER_TRIGGERED = 'rtg',
  RENDER_TRACKED = 'rtc',
  ERROR_CAPTURED = 'ec',
  SERVER_PREFETCH = 'sp'
}

LifecycleHooks 中,对生命周期的钩子进行了简化的描述,比如:created 被简写为 c。即:c 方法触发,就意味着 created 方法被回调

那么明确好了这个之后,我们来看一个测试实例:

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    },
    // 组件初始化完成之后
    beforeCreate() {
      alert('beforeCreate')
    },
    // 组件实例处理完所有与状态相关的选项之后
    created() {
      alert('created')
    },
    // 组件被挂载之前
    beforeMount() {
      alert('beforeMount')
    },
    // 组件被挂载之后
    mounted() {
      alert('mounted')
    },
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

5.1 源码阅读

根据之前的经验,我们知道,vue 对 options 选项的处理都在全部都是在位于 packages/runtime-core/src/componentOptions.ts 这个文件第 549 行的 applyOptions 方法中处理的,在执行的执行顺序为 mountComponent() -> setupComponent() -> setupStatefulComponent() -> finishComponentSetup() -> applyOptions(instance),我们直接跳到 applyOptions 进行调试代码:

image.png

  1. 可以看到代码首先会进入 callHook 方法中:

image.png

  1. 关于 callHook 里面的执行,上图应该讲得很清楚了,在 cakkWithErrorHandling 中执行了 fn 函数,也就代表此时我们的 beforeCreate 钩子函数执行,执行了 alert('beforeCreate')页面弹出弹框
  2. 此时 if (options.beforeCreate) 中的 callHook 代码执行完成,我们继续回到 applyOptions 中:

image.png

  1. 我们忽略其他属性的设置,直接来到 第 745 行,可以看到此时代码触发 if (created) {...},和刚才的 beforeCreate 触发一样,此时 在组件实例处理完所有与状态相关的选项之后,触发了 create 生命周期回调
  2. 至此,我们在 applyOptions 方法中,触发了 beforeCreatecreated,代码继续执行~~~

image.png

  1. 代码接着会执行 11registerLifeHook,我们先从第一个进去看,由上图可知最终会执行 injectHook 方法,我们再进入这个方法看下:

image.png

  1. 到这已经清楚了 injectHook 方法的作用了,它的最终目的就是在当前组件的实例上的生命周期钩子上注入一个 wrappedHook 函数,至于这个函数里面的逻辑我们可以先不分析,但是我们应该也能清楚它是会执行我们在 beforeMounted 的代码的,后面的 10 个 registerLifeHook 原理相同我们就直接跳过了。
  2. 至此我们当前实例整个 setupComponent 方法执行完成,接下来会执行 setupRenderEffect 渲染 dom,我们再次进入分析一下,现在重点关注钩子函数的执行时期吗,我们直接来到 componentUpdateFn 函数:

image.png

  1. setupRenderEffect 方法最后调用 update 触发的 compoentUpdateFn 方法中,程序先是从 instance 中拿出了 bmm 也就是 beforeMountedMounted 两个钩子,然后执行了 invokeArrayFns 方法,而 invokeArrayFns 方法很简单就是循环调用了 bm 数组中的函数,此时调用的函数也就是我们在第 7 步 创建的 wrappedhook,在 wrappedhook 中 主要就是通过执行 callWithAsyncErrorHandling,这个方法我们在 beforeCreated 时就碰到过。至此 beforeMounted 生命周期函数执行完成,执行了 alert('beforeMount'),页面显示弹窗。我们接着执行代码:

image.png

  1. 接着程序会在 patch 方法后面之后判断 m 也就是 created 钩子是否存在,我们当前肯定是存在的,所以会执行 queuePostRenderEffect,而在 queuePostRenderEffect 中最终执行了 queuePostFlushCb,而这个函数我们之前也是接触过的,它是一个 Promise 的任务队列,最终函数会循环执行钩子函数的。最终执行了 mounted 中的代码,执行 alert('mounted'),页面显示弹窗。
  2. 至此,代码执行完成。

总结:

由以上源码阅读可知:

整个源码可以分为两大块:

  1. 第一块是 beforeCreatedcreated,它俩的执行主要是在 applyOptions 中执行的,我们直接通过 options.beforeCretadoptions.created 来判断是否有这两个钩子,在通过 callHook 执行。
  2. 第二块是对于其余的 11 个生命周期,我们都是通过 registerLifecycleHook 方法将这些生命周期注入到 instance 里面,然后在合适的时机去触发

5.2 代码实现

明确好了源码的生命周期处理之后,那么接下来我们来实现一下对应的逻辑。

我们本小节要处理的生命周期有四个,首先我们先处理前两个 beforeCreatecreated,我们知道这两个回调方法是在 applyOptions 方法中回调的:

  1. packages/runtime-core/src/component.tsapplyOptions 方法中:
function applyOptions(instance: any) {
    const {
        data: dataOptions,
        beforeCreate,
        created,
        beforeMount,
        mounted
    } = instance.type

    // hooks
    if (beforeCreate) {
        callHook(beforeCreate)
    }

    // 存在 data 选项时
    if (dataOptions) {
        ...
    }

    // hooks
    if (created) {
        callHook(created)
    }
}
  1. 创建对应的 callHook
/**
 * 触发 hooks
 */
function callHook(hook: Function) {
  hook()
}

至此, beforeCreatecreated 完成。

接下来我们来去处理 beforeMountmounted,对于这两个生命周期而言,他需要先注册,在触发。

那么首先我们先来处理注册的逻辑:

首先我们需要先创建 LifecycleHooks

  1. packages/runtime-core/src/component.ts 中:
/**
 * 生命周期钩子
 */
export const enum LifecycleHooks {
  BEFORE_CREATE = 'bc',
  CREATED = 'c',
  BEFORE_MOUNT = 'bm',
  MOUNTED = 'm'
}
  1. 在生成组件实例时,提供对应的生命周期相关选项:
/**
 * 创建组件实例
 */
export function createComponentInstance(vnode) {
    const type = vnode.type

    const instance = {
        ...
+    // 生命周期相关
+    isMounted: false, // 是否挂载
+    bc: null, // beforeCreate
+    c: null, // created
+    bm: null, // beforeMount
+    m: null // mounted
    }

    return instance
}
  1. 创建 packages/runtime-core/src/apiLifecycle.ts 模块,处理对应的 hooks 注册方法:
import { LifecycleHooks } from './component'

/**
 * 注册 hook
 */
export function injectHook(
  type: LifecycleHooks,
  hook: Function,
  target
): Function | undefined {
  // 将 hook 注册到 组件实例中
  if (target) {
    target[type] = hook
    return hook
  }
}

/**
 * 创建一个指定的 hook
 * @param lifecycle 指定的 hook enum
 * @returns 注册 hook 的方法
 */
export const createHook = (lifecycle: LifecycleHooks) => {
  return (hook, target) => injectHook(lifecycle, hook, target)
}

export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT)
export const onMounted = createHook(LifecycleHooks.MOUNTED)

这样我们注册 hooks 的一些基础逻辑完成。

那么下面我们就可以 applyOptions 方法中,完成对应的注册:

function applyOptions(instance: any) {
    ...
    function registerLifecycleHook(register: Function, hook?: Function) {
            register(hook, instance)
    }

    // 注册 hooks
    registerLifecycleHook(onBeforeMount, beforeMount)
    registerLifecycleHook(onMounted, mounted)
}

bmm 注册到组件实例之后,下面就可以在 componentUpdateFn 中触发对应 hooks 了:

// 组件挂载和更新的方法
const componentUpdateFn = () => {
  // 当前处于 mounted 之前,即执行 挂载 逻辑
  if (!instance.isMounted) {
    // 获取 hook
    const { bm, m } = instance

    // beforeMount hook
    if (bm) {
      bm()
    }

    // 从 render 中获取需要渲染的内容
    const subTree = (instance.subTree = renderComponentRoot(instance))

    // 通过 patch 对 subTree,进行打补丁。即:渲染组件
    patch(null, subTree, container, anchor)

    // mounted hook
    if (m) {
      m()
    }

    // 把组件根节点的 el,作为组件的 el
    initialVNode.el = subTree.el
  } else {
  }
}

至此,生命周期逻辑处理完成。

可以创建对应测试实例 packages/vue/examples/runtime/redner-component-hook.html

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    },
    // 组件初始化完成之后
    beforeCreate() {
      alert('beforeCreate')
    },
    // 组件实例处理完所有与状态相关的选项之后
    created() {
      alert('created')
    },
    // 组件被挂载之前
    beforeMount() {
      alert('beforeMount')
    },
    // 组件被挂载之后
    mounted() {
      alert('mounted')
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

4.gif

测试成功

6. 生命回调钩子中访问响应性数据

对于我们当前的代码,还不能生命周期钩子中访问响应式数据,那么要如何解决这个问题呢?

我们从源码中分析一下:

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    },
    // 组件实例处理完所有与状态相关的选项之后
    created() {
      console.log('created', this.msg)
    },
    // 组件被挂载之后
    mounted() {
      console.log('mounted', this.msg)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

6.1 源码阅读

created

通过之前的代码我们已经知道,created 的回调是在 applyOptions 中触发的,所以我们可以直接在这里进行 debugger

  1. 进入 applyOptions
  2. 剔除之前相同的逻辑,代码执行 if (created) {...}

image.png

通过上他我们很容易分析 created 能获取到响应数据的原因。

mounted

对于 mounted 而言,我们知道它的 生命周期注册 是在 applyOptions 方法内的 registerLifecycleHook 方法中,我们可以直接来看一下源码中的 registerLifecycleHoo 方法:

function registerLifecycleHook(
  register: Function,
  hook?: Function | Function[]
) {
  if (isArray(hook)) {
    hook.forEach(_hook => register(_hook.bind(publicThis)))
  } else if (hook) {
    register((hook as Function).bind(publicThis))
  }
}

该方法中的逻辑非常简单,可以看到它和 created 的处理几乎一样,都是通过 bind 方法来改变 this 指向

总结:

无论是 created 也好,还是 mounted 也好,本质上都是通过 bind 方法来修改 this 指向,以达到在回调钩子中访问响应式数据的目的。

6.2 代码实现

根据上一小节的描述,我们只需要 改变生命周期钩子的 this 指向即可

  1. packages/runtime-core/src/component.ts 中为 callHook 方法增加参数,以此来改变 this 指向:
/**
 * 触发 hooks
 */
function callHook(hook: Function, proxy) {
  hook.bind(proxy)()
}
  1. applyOptions 方法中为 callHoo 的调用,传递第二个参数:
// hooks
if (beforeCreate) {
    callHook(beforeCreate, instance.data)
}

...

// hooks
if (created) {
    callHook(created, instance.data)
}
  1. registerLifecycleHook 中,为 hook 修改 this 指向
function registerLifecycleHook(register: Function, hook?: Function) {
    register(hook?.bind(instance.data), instance)
}

至此,代码完成。

创建对应测试实例 packages/vue/examples/runtime/redner-component-hook-data.html

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    },
    // 组件实例处理完所有与状态相关的选项之后
    created() {
      console.log('created', this.msg)
    },
    // 组件被挂载之后
    mounted() {
      console.log('mounted', this.msg)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

数据访问成功

7. 响应性数据改变,触发组件的响应性变化

虽然目前我们已经完成了在生命周期中访问响应性数据,但是还有个问题就是:响应性数据改变,没有触发组件发生变化。

再来看这一块内容之前,首先我们需要先来明确一些基本的概念:

组件的渲染,本质上是 render 函数返回值的渲染。
所谓响应性数据,指的是:

  1. getter 时收集依赖
  2. setter 时触发依赖

那么根据以上概念,我们所需要做的就是:

  1. 在组件的数据被触发 getter 时,我们应该收集依赖。那么组件什么时候触发的 getter 呢?在 packages/runtime-core/src/renderer.tssetupRenderEffect 方法中,我们创建了一个 effect,并且把 effectfn 指向了 componentUpdateFn 函数。在该函数中,我们触发了 getter,然后得到了 subTree,然后进行渲染。所以依赖收集的函数为 componentUpdateFn
  2. 在组件的数据被触发 setter 时,我们应该触发依赖。我们刚才说了,收集的依赖本质上是 componentUpdateFn 函数,所以我们在触发依赖时,所触发的也应该是 componentUpdateFn 函数。

明确好了以上内容之后,我们就去分析一下源码是怎么做的:

<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    },
    // 组件实例处理完所有与状态相关的选项之后
    created() {
      setTimeout(() => {
        this.msg = '你好,世界'
      }, 2000)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

7.1 源码阅读

componentUpdateFn 中进行 debugger,等待 第二次 进入 componentUpdateFn 函数(注意: 此时我们仅关注依赖触发,生命周期的触发不再关注对象,会直接跳过):

  1. 第二次进入 componentUpdateFn,因为这次组件已经挂载过了,所以会执行 else,在 else 中将下一次要渲染的 vnode 赋值给 next,我们继续往下执行:

image.png

  1. 在 else 中,代码最终会执行 renderComponentRoot, 而对于 renderComponentRoot 方法,我们也很熟悉了,它内部会调用
result = normalizeVNode(
  render!.call(
    proxyToUse,
    proxyToUse!,
    renderCache,
    props,
    setupState,
    data,
    ctx
  )
)

同样通过 call 方法,改变 this 指向,触发 render。然后通过 normalizeVNode 得到 vnode,这次得到的 vnode 就是 下一次要渲染的 subTree。接着跳出renderComponentRoot 方法继续执行代码:

image.png

  1. 可以看到,最终触发 patch(...) 方法,完成 更新操作
  2. 至此,整个 组件视图的更新完成。

总结:

所谓的组件响应性更新,本质上指的是: componentUpdateFn 的再次触发,根据新的 数据 生成新的 subTree,再通过 path 进行 更新 操作

## 7.2 代码实现

  1. packages/runtime-core/src/renderer.tscomponentUpdateFn 方法中,加入如下逻辑:
// 组件挂载和更新的方法
const componentUpdateFn = () => {
    // 当前处于 mounted 之前,即执行 挂载 逻辑
    if (!instance.isMounted) {
        ...
    // 修改 mounted 状态
    instance.isMounted = true
    } else {
        let { next, vnode } = instance
        if (!next) {
            next = vnode
        }

        // 获取下一次的 subTree
        const nextTree = renderComponentRoot(instance)

        // 保存对应的 subTree,以便进行更新操作
        const prevTree = instance.subTree
        instance.subTree = nextTree

        // 通过 patch 进行更新操作
        patch(prevTree, nextTree, container, anchor)

        // 更新 next
        next.el = nextTree.el
    }
}

至此,代码完成。

创建对应测试实例 packages/vue/examples/runtime/redner-component-hook-data-change.html

<body>
  <div id="app"></div>
</body>
<script>
  const { h, render } = Vue

  const component = {
    data() {
      return {
        msg: 'hello component'
      }
    },
    render() {
      return h('div', this.msg)
    },
    // 组件实例处理完所有与状态相关的选项之后
    created() {
      setTimeout(() => {
        this.msg = '你好,世界'
      }, 2000)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

5.gif

得到响应性的组件更新。

8. composition API ,setup 函数挂载逻辑

到现在我们已经处理好了组件非常多的概念,但是我们还知道对于 vue3 而言,提供了 composition API,即 setup 函数的概念。

那么如果我们想要通过 setup 函数来进行一个响应性数据的挂载,那么又应该怎么做呢?

我们继续从源码中找答案:

<script>
  const { reactive, h, render } = Vue

  const component = {
    setup() {
      const obj = reactive({
        name: '张三'
      })

      return () => h('div', obj.name)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

在上面的代码中,我们构建了一个 setup 函数,并且在 setup 函数中 return 了一个函数,函数中返回了一个 vnode

上面的代码运行之后,浏览器会在一个 div 中渲染 张三

8.1 源码阅读

我们知道,vue 对于组件的挂载,本质上是触发 mountComponent,在 mountComponent 中调用了 setupComponent 函数,通过此函数来对组件的选项进行标准化。

那么 setup 函数本质上就是一个 vue 组件的选项,所以对于 setup 函数处理的核心逻辑,就在 setupComponent 中。我们在这个函数内部进行 debugger

image.png

  1. 由上图我们看到了setup 函数最终被执行了,由此得到 setupResult 的值为 () => h('div', obj.name)。即:setup 函数的返回值。我们代码继续执行:

image.png

  1. 可以看到,先是触发了 handleSetupResult 方法, 在 handleSetupResult 方法中会将 setupResult 赋值给 instance.render,最后进行了 finishComponentSetup
  2. 后面的逻辑就是 有状态的响应性组件挂载逻辑 的逻辑了。这里就不再详细说了。

总结:

  1. 对于 setup 函数的 composition API 语法的组件挂载,本质上只是多了一个 setup 函数的处理
  2. 因为 setup 函数内部,可以完成对应的 自洽 ,所以我们 无需 通过 call 方法来改变 this 指向,即可得到真实的 render
  3. 得到真实的 render 之后,后面就是正常的组件挂载了

8.2 代码实现

明确好了 setup 函数的渲染逻辑之后,那么下面我们就可以进行对应的实现了。

packages/runtime-core/src/component.ts 模块的 setupStatefulComponent 方法中,增加 setup 判定:

function setupStatefulComponent(instance) {
  const Component = instance.type
  const { setup } = Component
  // 存在 setup ,则直接获取 setup 函数的返回值即可
  if (setup) {
    const setupResult = setup()
    handleSetupResult(instance, setupResult)
  } else {
    // 获取组件实例
    finishComponentSetup(instance)
  }
}
  1. 创建 handleSetupResult 方法:
export function handleSetupResult(instance, setupResult) {
  // 存在 setupResult,并且它是一个函数,则 setupResult 就是需要渲染的 render
  if (isFunction(setupResult)) {
    instance.render = setupResult
  }
  finishComponentSetup(instance)
}
  1. finishComponentSetup 中,如果已经存在 render,则不需要重新赋值:
export function finishComponentSetup(instance) {
    const Component = instance.type

    // 组件不存在 render 时,才需要重新赋值
    if (!instance.render) {
           instance.render = Component.render
    }

    // 改变 options 中的 this 指向
    applyOptions(instance)
}

至此,代码完成。

创建对应测试实例 packages/vue/examples/runtime/redner-component-setup.html

<script>
  const { reactive, h, render } = Vue

  const component = {
    setup() {
      const obj = reactive({
        name: '张三'
      })

      setTimeout(() => {
        obj.name = '李四'
      }, 2000)

      return () => h('div', obj.name)
    }
  }

  const vnode = h(component)
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

挂载更新 都可成功

9. 总结

在本章中,我们处理了 vue 中组件对应的 挂载、更新 逻辑

我们知道组件本质上就是一个对象(或函数),组件的渲染本质上是 render 函数返回值的渲染。

组件渲染的内部,构建了 ReactiveEffect 的实例,其目的是为了实现组件的响应性渲染。

而当我们期望在组件中访问响应性数据时,分为两种情况:

  1. 通过 this 访问:对于这种情况我们需要改变 this 指向,改变的方式是通过 call 方法或者 bind 方法
  2. 通过 setup 访问:这种方式因为不涉及到 this 指向问题,反而更加简单

当组件内部的响应性数据发生变化时,会触发 componentUpdateFn 函数,在该函数中根据 isMounted 的值的不同,进行了不同的处理。

组件的生命周期钩子,本质上只是一些方法的回调,当然,如果我们希望在生命周期钩子中通过 this 访问响应式数据,那么一样需要改变 this 指向。

相关文章
|
11天前
|
存储 JavaScript 开发者
Vue 组件间通信的最佳实践
本文总结了 Vue.js 中组件间通信的多种方法,包括 props、事件、Vuex 状态管理等,帮助开发者选择最适合项目需求的通信方式,提高开发效率和代码可维护性。
|
11天前
|
存储 JavaScript
Vue 组件间如何通信
Vue组件间通信是指在Vue应用中,不同组件之间传递数据和事件的方法。常用的方式有:props、自定义事件、$emit、$attrs、$refs、provide/inject、Vuex等。掌握这些方法可以实现父子组件、兄弟组件及跨级组件间的高效通信。
|
23天前
|
缓存 JavaScript UED
Vue 中实现组件的懒加载
【10月更文挑战第23天】组件的懒加载是 Vue 应用中提高性能的重要手段之一。通过合理运用动态导入、路由配置等方式,可以实现组件的按需加载,减少资源浪费,提高应用的响应速度和用户体验。在实际应用中,需要根据具体情况选择合适的懒加载方式,并结合性能优化的其他措施,以打造更高效、更优质的 Vue 应用。
|
24天前
|
监控 JavaScript 前端开发
Vue 异步渲染
【10月更文挑战第23天】Vue 异步渲染是提高应用性能和用户体验的重要手段。通过理解异步渲染的原理和优化策略,我们可以更好地利用 Vue 的优势,开发出高效、流畅的前端应用。同时,在实际开发中,要注意数据一致性、性能监控和调试等问题,确保应用的稳定性和可靠性。
|
26天前
|
JavaScript 前端开发 测试技术
组件化开发:创建可重用的Vue组件
【10月更文挑战第21天】组件化开发:创建可重用的Vue组件
24 1
|
4天前
|
缓存 JavaScript 前端开发
vue学习第四章
欢迎来到我的博客!我是瑞雨溪,一名热爱JavaScript与Vue的大一学生。本文介绍了Vue中计算属性的基本与复杂使用、setter/getter、与methods的对比及与侦听器的总结。如果你觉得有用,请关注我,将持续更新更多优质内容!🎉🎉🎉
vue学习第四章
|
4天前
|
JavaScript 前端开发
vue学习第九章(v-model)
欢迎来到我的博客,我是瑞雨溪,一名热爱JavaScript与Vue的大一学生,自学前端2年半,正向全栈进发。此篇介绍v-model在不同表单元素中的应用及修饰符的使用,希望能对你有所帮助。关注我,持续更新中!🎉🎉🎉
vue学习第九章(v-model)
|
4天前
|
JavaScript 前端开发 开发者
vue学习第十章(组件开发)
欢迎来到瑞雨溪的博客,一名热爱JavaScript与Vue的大一学生。本文深入讲解Vue组件的基本使用、全局与局部组件、父子组件通信及数据传递等内容,适合前端开发者学习参考。持续更新中,期待您的关注!🎉🎉🎉
vue学习第十章(组件开发)
|
10天前
|
JavaScript 前端开发
如何在 Vue 项目中配置 Tree Shaking?
通过以上针对 Webpack 或 Rollup 的配置方法,就可以在 Vue 项目中有效地启用 Tree Shaking,从而优化项目的打包体积,提高项目的性能和加载速度。在实际配置过程中,需要根据项目的具体情况和需求,对配置进行适当的调整和优化。
|
10天前
|
存储 缓存 JavaScript
在 Vue 中使用 computed 和 watch 时,性能问题探讨
本文探讨了在 Vue.js 中使用 computed 计算属性和 watch 监听器时可能遇到的性能问题,并提供了优化建议,帮助开发者提高应用性能。
下一篇
无影云桌面