Vue3.2 vDOM diff 流程之一:插槽的初始化和更新

1,070 阅读6分钟

7b00685c2a004fd7b0765e477b533e5b.png

前言

传递插槽内容的方式

  • 模板插槽

image.png

image.png

直接往组件标签里传递内容,会经由complie模块编译,vue会给添加一个内部属性_。这是一个内部属性,指这是经由编译器生成的插槽对象,使用保留属性而不是vnode patchFlag,因为在手动渲染函数中可以直接传递给子组件,并且优化提示需要保留在插槽对象本身。

  • 函数插槽

image.png

image.png

这是手动渲染函数,用户可以手动写一个插槽对象,可以直接传递子组件,给但是需要注意keyvalue的对应关系,这是因为具名插槽有对应关系,如果keyvalue不对应,会导致在子组件中渲染出错。vue在规范化children(这里的children是插槽对象)的时候会给其添加一个上下文实例_ctx,(在已编译或已规范化的children具有上下文实例)。

  • 函数插槽(vnode数组)

第三种方式就比较少用了,vue中也不推荐这样使用,这里就简单的提一下。这种方式是在函数插槽的基础做了一些改变,children由一个插槽函数对象变成了vnode数组

image.png

image.png

用户自己编写的渲染函数,但是传递的不是插槽函数对象,而是一个vnode数组,这种方式也是可以通过的,但是vue不推荐这种写法,会提示用户去使用插槽函数对象,

工具函数

image.png

检查插槽函数执行完返回的vnode数组中,返回其中正确的vnode,

插槽的标识

image.png

简单说明一下SlotFlags,在什么情况下会是稳定的Fragment,什么情况下不是稳定的Fragment

第一种情况是,如果模板插槽中传递的内容是<slot>,这就是FORWARDED。意思是正在转发<slot>到子组件中。父项是否需要更新子项取决于父项本身收到的插槽类型。必须在运行时(在“normalizeChildren”中)创建子节点时对其进行优化,如下例子

<Comp>
    <slot></slot>
</Comp>

第二种情况是插槽引用范围变量(v-for或外部插槽属性)或具有条件结构(v-if、v-for)的插槽。父级需要强制子级更新,因为插槽没有完全捕获其依赖项。这就是DYMAMIC,如下例子:

<Comp>
    <template v-if="counter===1" v-slot:default="slotProps"></template>
</Comp>

第三种情况是稳定的FragmentSTABLE,仅引用插槽道具或上下文状态的稳定插槽。插槽可以完全捕获自己的依赖项,因此在传递时,父级不需要强制子级进行更新。如下例子:

  const App = {
    template: `
      <Comp>
        <p>{{counter}}</p>
        <button @click="increment"></button>
      </Comp>
    `,
    setup() {
      const counter = ref(0)
      function increment() {
        counter.value++
      }
      return { counter, increment }
    },

    components: {
      Comp
    }
  }

插槽内容数据的格式

image.png

插槽内容数据除了指定的插槽函数,还有三个保留属性,$stable_ctx_,这里简单的介绍一下。

  • $stable:手写渲染函数时,跳过强制子级更新。
  • _ctx:渲染上下文,也就是当前组件实例
  • _SlotsFlags标识符,优化插槽的更新,这也是区分是不是使用了模板形式的插槽

初始化插槽

image.png

进入initProps,第一个区分要点就是是不是SLOTS_CHILDREN,不是SLOTS_CHILDREN是指用户直接指定了vnode数组,插槽的vnode数组应该是由函数执行返回,为了确保规范会走normalizeVNodeSlots对用户指定的vnode数组进行规范化。

type是来自children中的_属性,这是代表用户是使用内置组件<slot>,仅由complie模块编译过的,并且是已经是符合标准,可以直接放入组件实例上的slots属性中,还有一种情况就是用户自己写了插槽函数对象,没有经过complie模块编译,也就不会由_属性,也要进行规范化,走normalizeObjectSlots

标准化插槽

插槽内容的传递方式有很多种,需要将它们统一成一种形式方便处理。通过组件标签传递内容经过编译的不需要规范化,我们只需要规范化用户手动写的渲染函数和传递的vnode数组。

image.png

我们一种种情况来分析,第一种先看用户手动赋予的插槽函数对象,在这种情况下会执行normalizeObjectSlots,对用户传递的插槽对象进行统一的规范化,遍历插槽对象中的属性,首先要确保这不是vue保留属性:__ctx$stable之中的一个。

image.png

如果是函数,就会走normalizeSlot标准化传递的插槽函数,内部会对用户传递的函数进行包装,这个后面再说,最后标记这是一个不是经由complie编译的插槽。

image.png

但是有的用户就是喜欢搞骚操作,就是不传函数,传一个vnode,使用normalizeSlotValue规范化用户传递的vnode,可以传递单个vnode或者是一个vnode数组,内部会判断,最终都是交给normalizeVNode去规范化vnode。最后以函数的形式放入实例上的slots属性中。

第二种情况类似渲染函数插槽的变体,传递的不是插槽函数,是一个vnode数组,走normalizeVNodeSlots进行规范化,走的也是normalizeSlotValue的流程,最后只有产生一个defulat插槽的插槽函数。

包装插槽函数

image.png

插槽函数产生时,并不是直接执行用户传递的函数,就算是经由编译产生的也会包装,交给了withCtx函数(位置在runtime-core/src/componentRenderContext.ts)

这个函数需要传递的一个函数(插槽函数)和渲染上下文ctx还有一个isNonScopedSlot用于区分是不是作用域插槽,ctx是必须要存在的,不然开头直接结束,里面会产生一个renderFnWithContext函数,这个函数作用的有两个,一个是在插槽函数执行时可以找到对应的渲染上下文,第二个作用是暂停块跟踪,因为当用户手动去调用编译产生的插槽函数可能回扰乱块跟踪。

image.png

后面会给renderFnWithContext函数上几个属性,_n是表明是否包装过了,前面的判断如果是true就会直接退出。_c表示已经编译过了,_d是指默认禁用块跟踪,_ns表示这是作用域插槽?,最后将renderFnWithContext函数返回。

解析插槽中的内容

插槽内容的vnode产生的地方实在渲染函数执行完成之后产生的,不是直接执行包装过后的插槽函数,而是交给一个vue私有函数执行,这个函数是renderSlot,位置在runtime-core/src/helpers/renderSlot.ts,假设组件如下面接受插槽内容,

<slot :attribute="'我是向外传递的内容'">我是备用内容</slot>

image.png

image.png

经过编译器编译成渲染函数就会如图所示,renderSlot函数接受五个参数,第一个是实例上的插槽函数对象slots,第二个是插槽的名字,第三个是插槽作用域接收的props,第四个是插槽的后备内容渲染函数,第五个我猜是样式是否使用伪类选择器:slotted(ps:如果说的不对,希望大佬可以在评论区说明)。

image.png

首先开头处理自定义元素,isCE是当前实例是自定义元素实例的标识,直接返回VNode,结束。不是自定义元素走下面的流程,取出对应名称的插槽函数赋值给slot

image.png

到了这个位置,说明这是基于模板调用不是用户调用,不用担心扰乱块跟踪,启用块跟踪。在vue中,插槽内容是一个块,这里打开一个块。

现在把props传递给slot函数执行得到vnode(因为每一个插槽都会有一个单独的函数去产生vnode,传递的props只能在当前的插槽函数中使用,这就形成了一个作用,也就是作用域插槽的原理),交给ensureValidVNodevnode进行检查,这当然是在slot存在的情况下,validSlotContent是检查之后返回的正确的vnode,下面就是产生一个vnode block,类型是Fragment

这里有几个地方说一下,如果props中带有key,会替代name作为key使用,如果validSlotContent是假值(没有正确的vnode),这个时候fallback内容就将其替换。只要validSlotContent是真值并且slots上有属性_值为SlotFlags.STABLE就代表这是一个稳定的Fragment,不然就是BAIL

image.png

接着是确认插槽的样式的作用域id,再将块跟踪禁用,返回产生的vnode block。只有的的渲染就和普通的vnode初始化挂载没啥区别了,依然走patch

插槽内容的更新

一个插槽内容的更新其实非常的简单,当数据发生变化时,只需要靠重新执行插槽函数,得到新的vnode block,就可以交给patch去进行DOM diff了。

也有可能是因为父组件更新导致子组件更新,在这种情况会重新确认一次vnode(位置在runtime-core/src/renderer.ts中的updateComponentPreRender函数),在这个过程中就会调用updateSlots去更新插槽(位置在runtime-core/src/componentSlots.ts)进行更新,这和数据改变导致的插槽更新的区别是整个插槽的vnode都更新了,也就是插槽函数的更新,看下面的简单例子

const Comp = defineComponent({
    template: `
    <div>
        <slot name="default">我是default的备用内容</slot>
        <slot name="footer">我是footer的备用内容</slot>  
    </div>
    `,
})

const App = {
    template: `
        <Comp>
            <template #[slotName]>
              <div>我是传递过来的{{slotName}}传递的内容</div>
            </template>
        </Comp>
    `,
    setup() {
        const slotName = ref('header')
        function changeSlotName() {
            slotName.value = slotName.value === 'header' ? 'default' : 'header'
        }
        return {
            slotName,
            changeSlotName
        }
    },

    components: {
        Comp,
    }
}
const app = createApp(App).mount('#app')

应该为父组件App更新,但是传递子组件Comp的插槽使用到父组件的状态slotName,在这个例子中,slotName作为插槽的动态插槽名称,发生改变代表插槽的渲染位置会发生变化。

image.png

updateSlots函数接收三个参数,当前组件实例,children在这里是新的插槽函数对象,optimized是是否是优化模式,needDeletionCheck是用于后面要不要删除旧的插槽函数,默认是删除的。deletionComparisonTarget是对比的目标 也就是新的插槽函数对象。

image.png

这是属于编译插槽(模板的方式)的更新分支,分为很多种边界。

  • 第一种:如果是热更新,父组件可能已经更新,强制更新插槽
  • 第二种:优化且稳定不需要更新
  • 第三种:已编译但是是动态的插槽,更新插槽但是跳过规范化,需要注意的是,当使用手写渲染函数渲染优化插槽时,如果有必要,我们需要删除slots._标记,以便以后可靠的更新,即让renderSlots创建bailed Fragment

image.png

这第二种情况是用户手写渲染函数,用户自己去配置插槽函数对象,这里执行规范化函数目的是为了将新的插槽函数放入slots中。前面说明了$stable的作用是是否跳过更新,真值就不会删除旧的插槽函数,假值到后面就会删除旧的插槽函数。

image.png

上面的两种情况,到最后删除的目标永远是新的children,不在新的children之中说明这是旧的插槽函数需要删除。

image.png

这最后一种情况,是用户手写渲染函数,传递是不是插槽函数对象而是一个vnode数组,这里执行规范化是利用了里面的将具体的值变成一个函数返回这个值的流程。所以比较目标一定是{default: 1}

image.png

这是这个函数的最后一个流程,删除旧的插槽函数,这里主要依靠是前面产生的needDeletionCheckdeletionComparisonTarget,判断条件是不是保留属性和不在比较对象中就删除。

这种更新情况的先前条件一定是父组件更新导致子组件更新,这已经不是数据更新,而是直接的vnode更新。

渲染函数接收插槽内容的改变

image.png

image.png

在v3中,this.$slots有了一些改变,第一张图是v2的$slots,第二张图是v3的$slots,可以看到两者差别很大。在v3中,在组件中获取插槽内容模板的方式也有了变化,v2只需要通过vm.$slots.default,就可以拿到default插槽的vnode,v3则由属性变成了函数,需要this.$slots.default()才会拿到default插槽的vnode

image.png

这张图就是this.$slots.default()执行的返回结果,这样看起来其实和v2获取的其实没有什么区别,这里就会由一个疑问,为什么v3要将$slots的内容获取改成需要函数执行,我目前知道的一点就是作用于插槽,如果按照v2的写法,就无法从组件往插槽内部传递数据,v3提供了在渲染函数中给插槽传递数据方法。看如下例子:

image.png

这个例子的先前条件是使用h函数渲染结构,App组件和Comp组件是父子组件关系,在渲染函数中传递插槽内容,需要在函数h的第三个函数中传递,此时第三个参数不在是数组而是对象,对应着具名插槽

上述例子中我指定了default插槽的内容,是一个函数返回vnode,在Comp组件中就可以使用this.$slots.default()执行后拿到返回的vnode。如果我在执行时传递一个参数,那么在App组件中的插槽函数中,我就可以在插槽函数中拿到在Comp组件中传递的数据,可以设置多个形参接受、解构参数拿到其中一个,也可以使用剩余运算符拿到所有的,并且这也形成了一个闭包。这样我们就可以手写渲染函数时,对数据进行拦截,然后自行决定如何渲染。

总结

插槽的初始化和更新分析完毕,在这过程中,知道了vue是如何将插槽内容渲染到指定位置,在有多个插槽的情况,如何按照名字找到渲染位置,如何处理用户传递的内容,作用域插槽的数据是如何传递和使用,在开发中可以更好的使用插槽。

如果有缺少或者是有误的,欢迎大家在评论区指导和纠正,谢谢大家