Skip to content

第十章:runtime 运行时 - 组件的设计原理与渲染方案

01:前言

在本章中,我们将要关注于 component 组件的渲染逻辑。对于组件而言,本身就比较复杂所以我们单独拿出来一章来进行讲解。

在学习本章之前,我们需要需要先来回忆一下在学习 h 函数时,我们所学习到的内容。

组件本身是一个对象(仅考虑对象的情况,忽略函数式组件)。它必须包含一个 render 函数,该函数决定了它的渲染内容。

如果我们想要定义数据,那么需要通过 data 选项进行注册。data 选项应该是一个 函数,并且 renturn 一个对象,对象中包含了所有的响应性数据。

除此之外,我们还可以定义例如 生命周期、计算属性、watch 等对应内容。

以上是关于组件的一些基本概念,这些是需要大家首先能够明确的。

组件的处理非常复杂,所以我们依然会采用和之前一样的标准:没有使用就当做不存在最少代码的实现逻辑 。来实现我们的组件功能。

02:源码阅读:无状态基础组件挂载逻辑

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

我们先来定一个目标:本小节我们 仅关注无状态基础组件挂载逻辑,而忽略掉其他所有。

基于以上目标我们创建对应测试实例 packages/vue/examples/imooc/runtime/render-component.html

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

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

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

基于以上代码我们知道,vue 的渲染逻辑,都会从 render 函数,进入 patch 函数,所以我们可以直接在 patch 函数中进入 debugger

  1. 进入 patch 函数

  2. 触发 switch,执行 if (shapeFlag & ShapeFlags.COMPONENT),触发 processComponent 方法。该方法即为 组件渲染 方法:

    1. 进入 processComponent,此时各参数为:

      image-20230813090748041

    2. 该函数内部逻辑分为三块:

      1. Keep alive
      2. 组件挂载
      3. 组件更新
    3. 我们当前处于 组件挂载 状态,所以代码会进入 mountComponent 方法

      1. 进入 mountComponent 方法,此时各参数为:

        image-20230813090835416

      2. 代码执行:

        js
        const instance: ComponentInternalInstance =
              compatMountInstance ||
              (initialVNode.component = createComponentInstance(
                initialVNode,
                parentComponent,
                parentSuspense
              ))

        该代码通过 createComponentInstance 方法生成了 instance 实例,我们来看一下 createComponentInstance 方法:

        1. 进入 createComponentInstance 方法

        2. 该方法中,最重要的内容就是生成了 instance 实例:

          js
          const instance: ComponentInternalInstance = { ... }
        3. instance 实例就是 component 组件实例

      3. 通过以上代码,我们 生成了 component 组件实例,并且把 组件实例绑定到了 vnode.component 中 *,即:*initialVNode.component = instance = 组件实例

      4. 执行 setupComponent,该方法主要为了初始化组件的各个数据,比如 props、slot、render

        1. 进入 setupComponent 方法,仅关注 render

        2. 执行 setupStatefulComponent(instance, isSSR)

          1. 进入 finishComponentSetup,因为我们当前没有 setup 函数

          2. 所以会执行 finishComponentSetup

            1. 进入 finishComponentSetup

            2. 查看当前 instance,因为不存在 render,所以我们需要为 instancerender 赋值

            3. 执行:

              js
               instance.render = (Component.render || NOOP) as InternalRenderFunction
          3. 至此 instance 组件实例,具备 render 属性

      5. 执行 setupRenderEffect 方法,这个方法 非常重要,我们进入来看一下:

        1. 进入 setupRenderEffect 方法,此时参数为:

          image-20230813091136936

        2. 首先:创建 componentUpdateFn 函数。

          1. 这个函数因为现在没有执行,所以我们先不需要去管它。但是我们需要知道:我们创建了一个函数 componentUpdateFn
        3. 第二段:创建了 ReactiveEffect 实例。

          1. ReactiveEffect 我们应该是了解的,它可以帮助我们生成一个 响应性的 effect 实例。

            1. 当执行 run 时,会触发 fn。即:第一个参数
            2. 提供了 scheduler 调度器的功能。
          2. 明确好了以上内容,我们来看这段代码:

            js
            const effect = (instance.effect = new ReactiveEffect(
            	componentUpdateFn,
            	() => queueJob(update),
            	instance.scope // track it in component's effect scope
            ))
            1. 在这段代码中,我们把 componentUpdateFn 作为第一个参数传入,它将承担 fn 的作用。
            2. 第二个参数:是一个匿名函数,它将承担 scheduler 调度器的功能。
              1. 其中的 queueJob 方法,我们是遇见过的,它是 packages/runtime-core/src/scheduler.ts 中的函数,是一个基于 Promise.resolve()微任务队列处理 的函数,因为通过 Set 构建的队列,所以具备去重的能力。
              2. 那么 update 是什么呢?我们来看下一行代码
        4. 第三段:创建 update 对象:const update: SchedulerJob = (instance.update = () => effect.run())

          1. 通过以上代码可以看出:我们把 updateinstance.update 绑定到了同一块内存空间。
          2. 它们都指向一块函数,即 () => effect.run(),而 run 函数的触发,其实是 fn 的触发。而 fn 又是 componentUpdateFn
          3. 所以,通过以上的代码我们也知道:当 update 函数被触发时,其实触发的是 componentUpdateFn 函数。
        5. 第四段:触发 update() 函数。

          1. 根据刚才所说,udpate 的触发,标志着 componentUpdateFn 的触发。

          2. 所以此时代码会 进入 componentUpdateFn 函数。

            1. 进入 componentUpdateFn 函数。

            2. 观察函数代码可以发现,整个 componentUpdateFn 的代码被分成了两部分:

              1. if(!instance.isMounted)
              2. else {}
            3. 我们知道在 vue 中存在一个生命周期钩子 mounted ,根据这个可以理解为:

              1. instance.isMounted === false 时:表示 组件挂载前
              2. instance.isMounted === true 时:表示 组件挂载后
            4. 我们此时处于 instance.isMounted === false ,所以为 挂载前 状态。接下来要进行的就是 挂载逻辑

            5. 忽略 与挂载无关 的逻辑之后,代码最终执行:

              js
              patch(...)
            6. 对于 patch 函数,我们应该是熟悉的,它是一个 打补丁 函数

              1. 我们知道 render 函数其实就是触发了 patch 来完成的渲染。
              2. 对于 patch 函数我们知道,它的第二个参数表示为 新节点。即:要渲染的内容。
              3. 那么大家想一下对于当前测试实例的组件而言,它要渲染的内容是什么呢?
                1. 是不是就是 组件 render 函数的返回值啊
              4. 那么在这里 第二个参数为 subTree。那么我们来看看这个 subTree 是什么,能不能验证我们的猜想。
            7. subTree 变量的创建在 patch 函数上面:

              js
              const subTree = (instance.subTree = renderComponentRoot(instance))
              1. subTree 通过 renderComponentRoot 方法创建。

              2. 我们再次 debugger,进入这个方法:

                1. 进入 renderComponentRoot 方法

                  1. 进入之后可以发现,这个函数相当复杂。但是不用担心,记住我们的目标:我们只关注组件挂载 相关的。
                  2. 根据我们刚才的猜想(6-4),我们知道:组件挂载本质上是 render 函数返回值的挂载,所以我们只关心 render 函数。
                2. 代码首先创建了两个变量:

                  1. render:这是从 instance 组件实例中结构出来的。
                  2. result:这是该函数的返回值,即:subTree
                3. 代码执行:

                  js
                  result = normalizeVNode(
                    render!.call(
                      proxyToUse,
                      proxyToUse!,
                      renderCache,
                      props,
                      setupState,
                      data,
                      ctx
                    )
                  )
                  1. 在这个代码中,我们触发了两个函数:

                  2. render!.call

                  3. 对于 call 函数大家应该是比较熟悉的,它的作用是改变 this 指向。

                    1. 它存在一个返回值,就是:this 值和参数调用该函数的返回值。即:render 函数的返回值
                    2. 那么代入到我们的测试实例代码,这里的返回值为:h('div', 'hello component')
                  4. normalizeVNode:明确好了参数之后,我们进入 normalizeVNode 函数

                  5. 进入 normalizeVNode 函数,此时它的参数为:

                    js
                    h('div', 'hello component')
                  6. 代码执行 cloneIfMounted(child)

                  7. 进入 cloneIfMounted 函数:

                    js
                    return child.el === null || child.memo ? child : cloneVNode(child)
                  8. 可以看到触发了 cloneVNode

                  9. 进入 cloneVNode

                  10. 从内部代码可以看到,cloneVNode 内就是创建了一个新的 vnode

                  11. 针对于我们当前的 挂载 场景,clonedchild没有区别

                4. 那么根据以上描述,normalizeVNode 最终的返回值其实就是 h('div', 'hello component')。即:result = h('div', 'hello component')

              3. 那么到这里,整个 renderComponentRoot 方法中和渲染相关的逻辑就已经全部完成了,最终返回值即为:h('div', 'hello component')

            8. 那么到这里我们就知道了,subTree = renderComponentRoot() ,即:subTree = h('div', 'hello component')

            9. 明确好了 subTree 之后,我们再看一下 patch(...) 函数,并比较清晰了。等同于我们触发了:

              js
              patch(
              	null,
                h('div', 'hello component'),
                container,
                null
              )
            10. 即:组件 render 返回值的渲染

          3. 至此,我们明确好了整个组件挂载逻辑

        由以上代码可知:

        1. 组件的挂载主要通过 mountComponent 完成
        2. 内部通过 createComponentInstance 生成了组件实例
        3. 通过 ReactiveEffect 生成了 effect 实例,并支持微任务队列的调度器
        4. effect.run 执行时,触发了组件的挂载
        5. 组件挂载本质上是
          1. 拿到 render 函数返回值
          2. 通过 patch 方法进行挂载操作

03:框架实现:完成无状态基础组件的挂载逻辑

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

  1. packages/runtime-core/src/renderer.tspatch 方法中,创建 processComponent 的触发:

    js
    else if (shapeFlag & ShapeFlags.COMPONENT) {
      // 组件
      processComponent(oldVNode, newVNode, container, anchor)
    }
  2. 创建 processComponent 函数:

    js
     /**
      * 组件的打补丁操作
      */
     const processComponent = (oldVNode, newVNode, container, anchor) => {
     	if (oldVNode == null) {
     		// 挂载
     		mountComponent(newVNode, container, anchor)
     	}
     }
  3. 创建 mountComponent 方法:

    js
      const mountComponent = (initialVNode, container, anchor) => {
    	// 生成组件实例
    	initialVNode.component = createComponentInstance(initialVNode)
    	// 浅拷贝,绑定同一块内存空间
    	const instance = initialVNode.component
    
    	// 标准化组件实例数据
    	setupComponent(instance)
    
    	// 设置组件渲染
    	setupRenderEffect(instance, initialVNode, container, anchor)
    }
  4. 创建 packages/runtime-core/src/component.ts 模块,构建 createComponentInstance 函数逻辑:

    js
     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
     }
  5. packages/runtime-core/src/component.ts 模块,创建 setupComponent 函数逻辑:

    js
     /**
      * 规范化组件实例数据
      */
     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
     }
  6. packages/runtime-core/src/renderer.ts 中,创建 setupRenderEffect 函数:

    js
     /**
      * 设置组件渲染
      */
     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()
     }
  7. 创建 packages/runtime-core/src/componentRenderUtils.ts 模块,构建 renderComponentRoot 函数:

    js
     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 测试实例:

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

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

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

此时,组件渲染完成。

04:源码阅读:无状态基础组件更新逻辑

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

创建如下测视案例 packages/vue/examples/imooc/runtime/render-component-update.html

js
<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>

render 中进入 debugger

  1. 第一次 进入 render,执行 组件挂载 逻辑

    1. 第一次 触发 patch 函数,执行组件挂载
      1. 进入 patch 函数
      2. 因为当前是组件挂载,所以会触发 processComponent 方法
        1. 进入 processComponent
        2. 触发 mountComponent
          1. 进入 mountComponent
          2. 生成组件实例 instanceinitialVNode.component
          3. 执行 setupComponent(instance) ,为 instance.render 赋值
          4. 执行 setupRenderEffect 方法
            1. 进入 setupRenderEffect 方法
            2. 生成 effect 实例,绑定 fncomponentUpdateFn
            3. 创建 update,绑定到到 effect.run
            4. 执行 update 方法,从而触发 componentUpdateFn
              1. 进入 componentUpdateFn
              2. 通过 renderComponentRoot 方法,触发 render 拿到 subTree
              3. 通过 patch 方法进行挂载
    2. 第二次 触发 patch,此时为 componentrender 渲染
      1. 因为 renderELEMENT 的渲染操作
      2. 所以会触发 processElement
      3. ...
  2. 此时第一次 component 的挂载操作完成

  3. 延迟两秒之后,再次进入 render,此时是第二个 component 的挂载,即: 更新

    1. 同样进入 patch,此时的参数为:

      image-20230813092841912

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

    3. 执行 unmount(n1, parentComponent, parentSuspense, true) ,触发 卸载逻辑

    4. 代码继续执行,经过 switch,再次执行 processComponent ,因为 旧的 VNode 已经被卸载,所以此时 n1 = null

      1. 进入 processComponent 方法,此时的参数为:

        image-20230813092933175

      2. 代码继续执行,发现 再次触发 mountComponent ,执行 挂载操作

      3. 后续省略…

至此,组件更新完成。

由以上代码可知:

  1. 所谓的组件更新,其实本质上就是一个 卸载挂载 的逻辑
    1. 对于这样的卸载逻辑,我们之前已经完成过。
    2. 所以,目前我们的代码 支持 组件的更新操作。

可以在 vue-next-mini 中 直接通过测试实例进行测试 packages/vue/examples/imooc/runtime/render-component-update.html

js
<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', '你好,世界')
      }
    }

    const vnode2 = h(component2)
    // 挂载
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

05:局部总结:无状态组件的挂载、更新、卸载总结

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

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

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

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

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

06:源码阅读:有状态的响应性组件挂载逻辑

和之前一样,我们先创建一个 有状态的组件 packages/vue/examples/imooc/runtime/render-component-data.html:

js
<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 选项的组件,我们就把它叫做有状态的组件。

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

剔除掉之前的重复逻辑,我们从 mountComponent 方法开始进入 debugger

  1. 进入 mountComponent 方法,此时的参数为:

    image-20230813093448977

  2. 通过 createComponentInstance 方法,生成 instance 实例

  3. 代码执行 setupComponent 方法,我们知道这个方法是用来初始化组件实例 instance 的,我们进入到这个方法来看一下

    1. 进入 setupComponent 方法

    2. 执行 const isStateful = isStatefulComponent(instance) ,判断当前是否是一个有状态的组件。那么它是怎么进行判定的呢?

      1. 进入 isStatefulComponent 方法

      2. 该方法判断的逻辑比较简单:

        js
         return instance.vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
      3. 即:直接通过 shapeFlag 进行 位与运算 即可

    3. 因为我们知道当前是有状态的,此时得到 isStateful = 4

    4. 跳过 propsslots

    5. 因为当前 isStateful = 4,所以会执行 setupStatefulComponent 方法

      1. 进入 setupStatefulComponent 方法

      2. 执行 const Component = instance.type,得到 Component 实例为:

        js
        {
            data() {
              return {
                msg: 'hello component'
              }
            },
            render() {
              return h('div', this.msg)
            }
          }
      3. 执行 const { setup } = Component,从上面的 Component 可以看出,我们并不存在 setup 函数

      4. 进入 if 判断逻辑,将执行 else 操作

        1. 执行 finishComponentSetup(instance, isSSR)

          1. 进入 finishComponentSetup 函数

          2. 同样执行 const Component = instance.type,得到 Component 实例。

          3. 执行 instance.render = (Component.render || NOOP),为组件实例的 render 赋值

          4. 代码继续执行,触发 applyOptions 方法

            1. options 进行解构,解构之后,得到两个关键属性:

              1. dataOptions

                image-20230813093722812

              2. render:

                image-20230813093757218

            2. 因为 dataOptions 存在,所以 if (dataOptions),被判定为 true,处理 data 相关逻辑

              1. 执行 const data = dataOptions.call(publicThis, publicThis) 方法
                1. 我们知道 call 方法会改变 this 指向,即把 dataOptions 中的 this,修改为 publicThis
                2. dataOptions 的值,我们已经知道了(见 4-4-1-1
              2. 所以此时的 data 值为 {msg: 'hello component'}。即:data 函数返回值
              3. 因为 data 当前是一个对象,所以会执行 instance.data = reactive(data)
                1. 即:通过 reactive 方法,构建 dataproxy 实例
                2. 此时 instance.data 的值为 proxy 实例,它的被代理对象为 {msg: 'hello component'}
  4. 至此 setupComponent 完成。完成之后 instance 将具备 data 属性,值为 proxy,被代理对象为 {msg: 'hello component'}

  5. 代码继续执行,触发 setupRenderEffect 方法,我们知道该方法为组件的渲染方法

    1. 进入 setupRenderEffect 方法

    2. 创建 ReactiveEffect 实例 effect

    3. 最后触发 update,我们知道 update 的触发,本质上是 componentUpdateFn 的触发。

    4. 所以,此时代码会进入 componentUpdateFn

      1. 进入 componentUpdateFn

      2. 执行 const subTree = (instance.subTree = renderComponentRoot(instance)),获取 subTree

        1. 我们知道此时 subTree 即为 真实渲染的节点

        2. 那么 render 函数的值为:

          image-20230813093944444

        3. 所以真实渲染节点时,我们必须要this.msg 替换为 hello component

        4. 那么这一步怎么做的呢?

        5. 进入 renderComponentRoot 方法,我们来一探究竟:

          1. 进入 renderComponentRoot 方法,此时 instance 中:

            1. data 的值为:

              image-20230813094024557

            2. render 的值为:

              image-20230813094048996

          2. 代码执行:

            js
            result = normalizeVNode(
                    render!.call(
                      proxyToUse,
                      proxyToUse!,
                      renderCache,
                      props,
                      setupState,
                      data,
                      ctx
                    )
                  )
          3. 这个代码我们之前是见过的,大家应该眼熟。

          4. 在这里使用了一个 call 方法,对于 call 现在大家应该已经熟悉了:它会改变 this 指向

          5. 那么我们期望 this 指向改变为什么呢?

            1. 我们期望 this.msg 变为 hello component
            2. 那么 this 的指向是不是就应该为 data
          6. 所以,该代码执行完成之后,result 的值为:

            image-20230813094158546

          7. 至此,我们已经成功解析了 render,把 this.msg 成功替换为了 hello component

          8. 后面的逻辑,就与 无状态组件 挂载完全相同了。

至此,代码解析完成。

由以上代码可知:

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

07:框架实现:有状态的响应性组件挂载逻辑

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

  1. packages/runtime-core/src/component.ts 中,新增 applyOptions 方法,为 instance 赋值 data

    js
    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)
    		}
    	}
    }
  2. finishComponentSetup 方法中,触发 applyOptions

    js
    export function finishComponentSetup(instance) {
    	const Component = instance.type
    
    	instance.render = Component.render
    
    	// 改变 options 中的 this 指向
    	applyOptions(instance)
    }
  3. packages/runtime-core/src/componentRenderUtils.ts 中,为 render 的调用,通过 call 方法修改 this 指向:

    js
     /**
      * 解析 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

js
<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>

可以发现,渲染成功。

08:源码阅读:组件生命周期回调处理逻辑

在前面我们查看《有状态的响应性组件挂载逻辑》时,其实已经在源码中查看到了对应的一些生命周期处理逻辑。

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

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

js
  /**
   * @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 对象:

js
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 方法被回调

那么明确好了这个之后,我们来看一个测试实例 packages/vue/examples/imooc/runtime/redner-component-hook.html

js
<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>

我们知道对于组件的挂载其实会触发 mountComponent 方法,所以本次,我们直接从该方法进行 debugger注意: 本次我们仅关心生命周期回调的逻辑:

  1. 进入 mountComponent 方法

  2. 触发 setupComponent(instance) 方法

    1. 进入 setupComponent(instance) 方法

    2. 触发 setupStatefulComponent 方法

      1. 进入 setupStatefulComponent 方法

      2. 触发 finishComponentSetup 方法

        1. 进入 finishComponentSetup 方法

        2. 触发 applyOptions(instance) 方法

          1. 进入 applyOptions 方法

          2. 执行 if (options.beforeCreate)

            1. 我们知道 beforeCreate 是生命周期回调钩子,我们当前是存在这个回调钩子的

            2. 所以接下来会执行 callHook(options.beforeCreate, instance, LifecycleHooks.BEFORE_CREATE),我们进入到该方法来看一下

              1. 进入 callHook,此时的参数为:

                image-20230813094624087

                1. 在参数中,我们可以很清楚的看到 hook 的值就是我们写入到 beforeCreate 函数
              2. 接下来触发 callWithAsyncErrorHandling

                1. 对于该方法我们是熟悉的,它本质上就是:通过 try...catch 捕获函数执行 的一个方法
                2. 所以我们可以直接理解为 在组件初始化完成之后,触发了 beforeCreate 方法
            3. 代码继续向下进行,此时触发了 beforeCreate,进行 alert 打印

            4. 接下来代码触发 if (created) {...}

              1. 和刚才的 beforeCreate 触发一样
              2. 此时 在组件实例处理完所有与状态相关的选项之后,触发了 create 生命周期回调
        3. 至此,我们在 applyOptions 方法中,触发了 beforeCreatecreated

        4. 代码继续执行~~~

        5. 触发 registerLifecycleHook(onBeforeMount, beforeMount) 方法

          1. 进入 registerLifecycleHook 方法,此时传入的 hook 的值为:

            image-20230813094851631

          2. 执行 register((hook as Function).bind(publicThis))

            1. 这段代码可以分成两块来看:

              1. (hook as Function).bind(publicThis):我们知道对于 bind 方法而言,它本身是可以改变 this 指向,并且返回一个新的函数

              2. register(新的函数):该方法从名字来看是注册的意思。那么我们进入这个方法,看看它内部做了什么事情:

                1. 进入 register

                2. 进入之后可以发现,它本质上是触发了 createHook 方法

                  1. 进入 createHook 方法

                  2. 内部触发了 injectHook 方法

                    1. 进入 injectHook 方法,此时各参数的值为:

                    image-20230813095000462

                    1. . 执行 const hooks = target[type] || (target[type] = []) 方法,即完成 bm 初始化

                    2. 代码执行 hooks.push(wrappedHook)

                    3. 此时 wrappedHook 的值为

                      image-20230813095055070

                    4. 它内部比较复杂,做了什么我们也暂时不需要去管

                    5. 我们只需要知道,它被放入到了对应的 hooks 中即可

        6. registerLifecycleHook(onBeforeMount, beforeMount) 方法执行完成之后,我们查看 instance 的值,可以发现此时 instance 中已经存在 bm 属性(即:beforeCreate

          image-20230813095201145

        7. 后面的代码相同,不再意义关注。总之: registerLifecycleHook 方法执行完成之后,组件中将具备对应回调钩子。

  3. 至此 setupComponent 全部执行完成

  4. 接下来,需要触发的是 beforeMountmounted

  5. 这两个与 挂载 相关的回调方法,在 componentUpdateFn 方法被回调之后触发

    1. 进入 componentUpdateFn 方法
    2. 代码执行 const { bm, m, parent } = instance
      1. 根据 LifecycleHooks 的值我们知道
        1. bm 代表 beforeMount
        2. m 代表 mounted
    3. 执行 if (bm) {...}
      1. 存在 bm 选项,进入 if 判断
      2. 执行 invokeArrayFns 方法
        1. 进入 invokeArrayFns 方法
        2. 这里的代码非常简单,就是一个 for 循环触发 beforeMount 方法。
        3. 至此:beforeMount 被触发,打印对应 alert
    4. 代码继续执行,在 patch 方法被触发之后,执行 if (m){ ... }
      1. 执行 queuePostRenderEffect 方法,
        1. 进入 queuePostRenderEffect 方法
        2. 我们当前不存在 queuePostFlushCb,所以会直接触发 queuePostFlushCb
          1. 这个方法我们是熟悉的,它就是通过 微任务队列 完成的一个循环触发
        3. 至此:mounted 方法被触发,打印对应 alert
  6. 至此:beforeMountmounted 被触发。

由以上代码可知:

  1. 对于 beforeCreatecreated 而言,他们的触发主要是在 applyOptions 中触发,它们的触发非常简单,直接通过 try...catch 触发即可。
  2. 而对于 beforeMountmounted 的触发相对比较复杂,分为两步:
    1. 注册:registerLifecycleHook
    2. 触发:bm || m

源码的处理逻辑颇为复杂,主要是因为源码需要应对足够多的边缘情况。如果我们期望自己进行实现,就不需要这么复杂了,我们只需要保证在合适的时机,触发对应的回调即可。

09:框架实现:组件生命周期回调处理逻辑

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

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

  1. packages/runtime-core/src/component.tsapplyOptions 方法中:

    js
    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)
    	}
    }
  2. 创建对应的 callHook

    js
     /**
      * 触发 hooks
      */
     function callHook(hook: Function) {
     	hook()
     }

至此, beforeCreatecreated 完成。

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

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

  1. 首先我们需要先创建 LifecycleHooks

    1. packages/runtime-core/src/component.ts 中:

      js
       /**
        * 生命周期钩子
        */
       export const enum LifecycleHooks {
       	BEFORE_CREATE = 'bc',
       	CREATED = 'c',
       	BEFORE_MOUNT = 'bm',
       	MOUNTED = 'm'
       }
    2. 在生成组件实例时,提供对应的生命周期相关选项:

      diff
      /**
       * 创建组件实例
       */
      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
      }
    3. 创建 packages/runtime-core/src/apiLifecycle.ts 模块,处理对应的 hooks 注册方法:

      js
      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 方法中,完成对应的注册:

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

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

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

js
// 组件挂载和更新的方法
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

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>

测试成功

10:源码阅读:生命回调钩子中访问响应性数据

在实际开发中,我们通常都会在生命周期钩子中访问响应式数据,比如我们来看如下测试实例 packages/vue/examples/imooc/runtime/redner-component-hook-data.html

js
<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>

这样的一个代码,在我们的 vue-next-mini 中是无法打印出对应数据的。

那么本小节我们期望的就是:如何可以在生命钩子中访问响应性数据

对于这样的一个需求,大家应该是感觉有一点似曾相识的。不知道大家还记不记得,我们之前在 render 函数中访问过 this.msg。当时的做法是通过 call 方法改变了 this 指向。

那么对于当前的场景而言,也是一样的。

我们这里分别去看 createdmounted

created

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

  1. 进入 applyOptions

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

    1. 进入 if,触发 callHook 方法

    2. 我们可以发现,该方法中存在这样一个三元表达式:

      js
      isArray(hook)
            ? hook.map(h => h.bind(instance.proxy!))
            : hook.bind(instance.proxy!),
    3. 因为我们当前的 hook 不存在数组的情况,所以,我们直接看 hook.bind(instance.proxy!) 即可

      1. 对于 bind 方法,大家应该也是熟悉的,它会改变 this 指向,并且返回一个方法的引用
      2. 那么换句话而言,也就是在 此时,改变了 this 指向,和 render 一样,我们通过一个新的 this 指向了数据源

由以上代码可知:

  1. 对于 created 而言,想要在它这里访问响应式数据,我们只需要通过 bind 改变 this 指向即可。

mounted

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

js
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 指向

由以上代码可知:

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

11:框架实现:生命回调钩子中访问响应性数据

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

  1. packages/runtime-core/src/component.ts 中为 callHook 方法增加参数,以此来改变 this 指向:

     /**
      * 触发 hooks
      */
     function callHook(hook: Function, proxy) {
     	hook.bind(proxy)()
     }
  2. applyOptions 方法中为 callHook 的调用,传递第二个参数:

    js
    // hooks
    if (beforeCreate) {
    	callHook(beforeCreate, instance.data)
    }
    
    ...
    
    // hooks
    if (created) {
    	callHook(created, instance.data)
    }
  3. registerLifecycleHook 中,为 hook 修改 this 指向

    js
    function registerLifecycleHook(register: Function, hook?: Function) {
    	register(hook?.bind(instance.data), instance)
    }

至此,代码完成。

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

js
<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>

数据访问成功

12:源码阅读:响应性数据改变,触发组件的响应性变化

此时我们已经可以在生命周期回调钩子中访问到对应的响应性数据了,根据响应性数据的概念,当数据发生变化时,视图应该跟随发生变化,所以我们接下来就要来看一下 组件中响应性数据引起的视图改变。

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

  1. 组件的渲染,本质上是 render 函数返回值的渲染。
  2. 所谓响应性数据,指的是:
    1. getter 时收集依赖
    2. setter 时触发依赖

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

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

明确好了以上内容之后,下面我们就可以创建对应的测试案例 packages/vue/examples/imooc/runtime/redner-component-hook-data-change.html

html
<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>

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

  1. 第二次进入 componentUpdateFn 函数,此时因为 组件已经被挂载,所以 不再 执行 if (!instance.isMounted),而是会直接进入 else

  2. 执行 let { next, bu, u, parent, vnode } = instance,从 instance 中获取 nextvnode

    1. 此时拿到的 next,表示下一次的 subTree,现在为 null
    2. 此时拿到的 vnode,为当前组件的 vnode
  3. 执行 if (next),因为 next = null,所以会进入 else

    1. 进入 else
    2. 执行 next = vnode。即:下一次的渲染为 vnode
  4. 代码执行 const nextTree = renderComponentRoot(instance),这个代码我们是熟悉的,并且非常关键,表示我们下一次要渲染的 VNode,我们进入 renderComponentRoot 方法

    1. 进入 renderComponentRoot 方法

    2. 执行 if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT),因为当前是 有状态的数据,所以会进入 if

      1. 进入 if 之后的代码我们就比较熟悉了:

        js
        result = normalizeVNode(
          render!.call(
            proxyToUse,
            proxyToUse!,
            renderCache,
            props,
            setupState,
            data,
            ctx
          )
        )
      2. 同样通过 call 方法,改变 this 指向,触发 render。然后通过 normalizeVNode 得到 vnode

      3. 这次得到的 vnode 就是 下一次要渲染的 subTree

  5. 跳出 renderComponentRoot 方法,此时得到的 nextTree 的值为:

    image-20230813100409730

  6. 代码执行:

    js
    const prevTree = instance.subTree
    instance.subTree = nextTree

​ 保存上一次的 subTree,同时赋值新的 subTree。之所以要保存上一次的 subTree 是因为我们后面要进行 更新 操作

  1. 触发 patch(...) 方法,完成 更新操作

至此,整个 组件视图的更新完成。

由以上代码可知:

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

13:框架实现:响应性数据改变,触发组件的响应性变化

明确好了组件响应性更新,那么下面我们来实现下 vue-next-mini 的对应代码逻辑。

  1. packages/runtime-core/src/renderer.tscomponentUpdateFn 方法中,加入如下逻辑:

    js
    // 组件挂载和更新的方法
    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

html
<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>

得到响应性的组件更新。

14:源码阅读:composition API ,setup 函数挂载逻辑

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

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

我们来看下面的这个测试案例 packages/vue/examples/imooc/runtime/redner-component-setup.html

html
<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 中渲染 张三

那么对于以上的代码,vue 内部又是如何进行处理的呢?

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

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

  1. 进入 setupComponent

  2. 关注 setupStatefulComponent 函数的触发

    1. 进入 setupStatefulComponent 函数

    2. 代码执行 const Component = instance.type as ComponentOptions,此时得到的 Component 的值为:

      image-20230813100626716

    3. 代码执行 const { setup } = Component,由上面 component 的值可知 setup 是存在的,所以会进入 if

      1. 代码执行:

        js
        const setupResult = callWithErrorHandling(
              setup,
              instance,
              ErrorCodes.SETUP_FUNCTION,
              [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
            )
      2. 我们知道 callWithErrorHandling 本质上是一个 try...catch,所以以上代码 “约等于” setup() 的执行。

      3. 由此得到 setupResult 的值为 () => h('div', obj.name)。即:setup 函数的返回值

      4. 代码继续执行,触发 handleSetupResult(instance, setupResult, isSSR)

        1. 进入 handleSetupResult,此时各参数为:

          image-20230813100729866

        2. 执行 if (isFunction(setupResult)) {...},由以上参数可知 setupResult 是一个函数,所以会进入 if 判断

          1. 执行 instance.render = setupResult 。此时:instance.render 有值
            1. 回顾一下我们之前所学习的 有状态的响应性组件挂载逻辑,当 instance.render 存在值时,我们后面的渲染就会变得非常简单了。
        3. 代码继续执行,触发 finishComponentSetup,这个方法我们之前是熟悉的

          1. 进入 finishComponentSetup 方法
          2. 执行 if (!instance.render),因为当前 instance 已经存在 render,所以 不会再次为 render 赋值
        4. 后面的逻辑就是 有状态的响应性组件挂载逻辑 的逻辑了。这里就不再详细说了。

由以上代码可知:

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

15:框架实现:composition API ,setup 函数挂载逻辑

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

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

    js
    function setupStatefulComponent(instance) {
    	const Component = instance.type
    	const { setup } = Component
    	// 存在 setup ,则直接获取 setup 函数的返回值即可
    	if (setup) {
    		const setupResult = setup()
    		handleSetupResult(instance, setupResult)
    	} else {
    		// 获取组件实例
    		finishComponentSetup(instance)
    	}
    }
  2. 创建 handleSetupResult 方法:

    js
    export function handleSetupResult(instance, setupResult) {
    	// 存在 setupResult,并且它是一个函数,则 setupResult 就是需要渲染的 render
    	if (isFunction(setupResult)) {
    		instance.render = setupResult
    	}
    	finishComponentSetup(instance)
    }
  3. finishComponentSetup 中,如果已经存在 render,则不需要重新赋值:

    js
    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

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>

挂载更新 都可成功

16:总结

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

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

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

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

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

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

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

哪怕我们说了那么多,我们所说的依然只是 vue 中组件挂载的冰山一角,我们知道对于组件而言,除了我们所讲到的内容之外,还包括很多东西,比如:

  1. computed
  2. watch
  3. composition API 的生命周期方法

但是因为课程篇幅的问题,我们也没有办法完全讲完 vue 中所有的边缘情况(这样课程可能会超过 200 小时了)。所以我们只能够挑选重要的内容来进行讲解。对于其他的一些没有讲到的知识点,大家可以根据在课程中所学到的知识进行对应的 debugger ,也可以在问答区留言,我们一起来进行讨论。

那么下一章,我们将要来看渲染器的最后一个环节 diff 算法,大家准备好了吗?

Released under the MIT License.