欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

从基础开始了解 Vue3 组件中的插槽

最编程 2024-07-14 12:38:20
...

Vue 组件的插槽机制受原生 Web Component 元素的启发而诞生。Vue 组件通过插槽的方式实现内容的分法,它允许我们在父组件中编写 DOM 并在子组件渲染时把 DOM 添加到子组件的插槽中,使用起来非常方便。

在实现上,Vue 组件的插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。<slot> 标签则会被编译为插槽函数的调用,通过执行对应的插槽函数,得到外部向槽位填充的内容(即虚拟 DOM),最后将该内容渲染到槽位中。

插槽的用法

假设我们有一个名为 Demo 的子组件,它定义了一个插槽,并添加了 name 属性为 header (没有设置 name 属性则默认 name 是 default),它的模板如下

<header>
  <slot name="header" />
</header>

然后在父组件中,可以这样使用这个子组件

<demo>
  <template #header>
    <h1>我是标题</h1>
  </template>
</demo>

???? 其中 #v-slot 指令的简写,具体可见 v-slot

这里使用 template 标签(<template>)和 v-slot 指令把父组件的 DOM 分发到子组件对应的插槽中,最终子组件(即,Demo 组件)渲染的 HTML 如下:

<header>
  <h1>我是标题</h1>
</header>

这个例子演示了具名插槽的用法。

Tips :使用具名插槽,可以在组件中定义多个插槽,将多个插槽内容传入到各自目标插槽的出口。

有些时候,我们希望父组件填充插槽内容的时候,可以使用子组件的一些数据,为了实现这个需求,Vue 提供了作用域插槽。我们可以像对组件传递 props 那样,向一个插槽的出口(slot 标签 <slot>)上传递属性(attributes),而这个属性中的数据可以在父组件中被访问到!

假设我们有一个叫 Demo 的子组件,它的模板定义如下

<div>
  <slot name="greet" msg="hello" />
</div>

其中 msg 为子组件在插槽的出口定义的一个属性,该属性可被父组件访问到。在父组件中可以这样使用这个子组件(即 Demo 组件)

<demo>
  <template #greet="greetProps">
    {{ greetProps.msg }}
  </template>
</demo>

???? 其中 #v-slot 指令的简写,具体可见 v-slot

注意,这里 v-slot 的值为 greetProps ,它是一个对象,它的值包含了子组件往 slot 标签中添加的属性,在我们这个例子中,v-slot 就包含了 msg 属性,然后我们就可以在父组件使用 greetProps.msg 获得子组件的数据,最终这个子组件(即 Demo 组件)渲染的 HTML 如下:

<div>hello</div>

上述例子就是作用域插槽的用法,它实现了在父组件填写子组件插槽内容的时候,可以使用子组件传递的数据的需求。

插槽的实现

其实插槽就是在父组件中去编写子组件插槽部分的模板,然后在子组件渲染的时候,子组件会调用插槽函数,得到父组件向槽位填充的内容,最后将该内容渲染到子组件的槽位中。

插槽的实现原理是:插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。slot 标签(<slot>)则会被编译为插槽函数的调用,通过执行对应的插槽函数,得到外部向槽位填充的内容(即虚拟 DOM),最后将该内容渲染到槽位中。

例如下面的两个父子组件中的模板

<!-- 父组件的模板 -->
<demo>
  <template #header>
    <h1>我是标题</h1>
  </template>
</demo>
<!-- 子组件 Demo 中的模板 -->
<header>
  <slot name="header" />
</header>

然后我们借助 Vue 提供的模板编译工具看一下它们编译后得到的 render 函数:

// 父组件的模板编译后得到的 render 函数

import { createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_demo = _resolveComponent("demo")

  return (_openBlock(), _createBlock(_component_demo, null, {
    header: _withCtx(() => [
      _createElementVNode("h1", null, "我是标题")
    ]),
    _: 1 /* STABLE */
  }))
}

Tips:Vue 为我们提供了模板编译工具,将 Vue 源码 clone 到本地并运行 pnpm install 命令安装完依赖后,只需要在 Vue 源码根目录下面运行 pnpm run dev-compiler 命令,然后在浏览器中打开 http://localhost:5000/packages/template-explorer/local.html 链接,即可打开 Vue 为我们提供的模板编译工具。

// 子组件 Demo 中的模板编译后得到的 render 函数

import { renderSlot as _renderSlot, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("header", null, [
    _renderSlot(_ctx.$slots, "header")
  ]))
}

父组件中的插槽内容,被编译为了插槽函数,而插槽函数的返回值就是具体的插槽内容:

{
  header: _withCtx(() => [
    _createElementVNode("h1", null, "我是标题")
  ]),
}

而子组件被编译为插槽函数的调用,子组件会通过 renderSlot 函数来调用父组件模板编译生成的插槽函数:

[
  _renderSlot(_ctx.$slots, "header")
]

withCtx 函数的作用是包裹插槽函数,缓存当前渲染的组件实例,主要是用到了闭包的原理, withCtx 函数的源码实现如下:

// packages/runtime-core/src/componentRenderContext.ts

export function withCtx(
  fn: Function,
  ctx: ComponentInternalInstance | null = currentRenderingInstance,
) {
  if (!ctx) return fn

  const renderFnWithContext: ContextualRenderFn = (...args: any[]) => {
    const prevInstance = setCurrentRenderingInstance(ctx)
    let res
    try {
      res = fn(...args)
    } finally {
      setCurrentRenderingInstance(prevInstance)
    }

    return res
  }
  return renderFnWithContext
}

本文中的源码均摘自 Vue.js 3.2.45,为了方便理解,会省略与本文主题无关的代码

withCtx 函数会返回一个函数,该函数调用时,会先保存当前渲染的组件实例(setCurrentRenderingInstance(ctx)) ,保证渲染函数执行的时候的实例是当前组件的实例,然后执行传入的插槽函数 fn ,最后将组件实例恢复为之前的组件实例。

setCurrentRenderingInstance 的作用是将当前组件实例保存到一个全局变量(currentRenderingInstance)上

// packages/runtime-core/src/componentRenderContext.ts

export let currentRenderingInstance: ComponentInternalInstance | null = null

export function setCurrentRenderingInstance(
  instance: ComponentInternalInstance | null
): ComponentInternalInstance | null {
  const prev = currentRenderingInstance
  currentRenderingInstance = instance
  return prev
}

renderSlot 函数的作用是调用父组件编译生产的插槽函数:

// packages/runtime-core/src/helpers/renderSlot.ts

export function renderSlot(
  slots: Slots,
  name: string,
  props: Data = {},
): VNode {

  let slot = slots[name]

  const validSlotContent = slot && ensureValidVNode(slot(props))
  const rendered = createBlock(
    Fragment,
    {
      key:
        props.key ||
        // slot content array of a dynamic conditional slot may have a branch
        // key attached in the `createSlots` helper, respect that
        (validSlotContent && (validSlotContent as any).key) ||
        `_${name}`
    },
  )
  return rendered
}

由子组件模板的编译出来的 render 函数 (_renderSlot(_ctx.$slots, "header")),可以知道 renderSlot 函数会从组件上下文对象(ctx)中获得组件定义的插槽数据($slots),然后通过插槽名(没有插槽名则为 default)在组件上下文对象的 $slots 中取得对应的插槽函数。如果是作用域插槽,即子组件传递了数据给插槽的出口(slot 标签 <slot>),则子组件传给 slot 标签(<slot>)的数据会作为插槽函数的入参(render 函数中的 prop 参数) ,当插槽函数调用的时候,会将子组件传给 slot 标签的数据传给插槽函数。因此当插槽函数调用的时候,是可以访问得到子组件传递给插槽标签的数据的,这样从用户的角度来说,就像是在父组件中访问到了子组件的数据,其实从这里分析可知,插槽函数是在子组件渲染的时候调用的,即插槽函数是在子组件域内执行的,那插槽函数可以访问子组件的数据是很正常的是,只是对外表现像是父组件访问了子组件的数据。

子组件传给插槽的数据会被作为参数传给插槽函数,从而实现了在父组件的插槽内容中,可以访问到子组件传递给插槽出口(即,slot 标签 <slot>)上的数据,这也是作用域插槽的实现原理。

如下面子组件中的模板,子组件向插槽的出口(slot 标签 <slot>)传入的 msg 属性

<div>
  <slot name="greet" msg="hello" />
</div>

该子组件模板的编译结果如下:

import { renderSlot as _renderSlot, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _renderSlot(_ctx.$slots, "greet", { msg: "hello" })
  ]))
}

可以看到子组件传递给插槽出口(slot 标签 <slot>)的数据被当作 renderSlot 函数的参数了,该参数最终会传给插槽函数。

可见,在作用域插槽中,插槽内容可以同时使用父组件域和子组件域内的数据的原理是参数传递。原理特别的简单。

这个例子中的父组件模板如下:

<demo>
  <template #greet="greetProps">
    {{ greetProps.msg }}
  </template>
</demo>

该模板的编译结果如下:

import { toDisplayString as _toDisplayString, createTextVNode as _createTextVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_demo = _resolveComponent("demo")

  return (_openBlock(), _createBlock(_component_demo, null, {
    greet: _withCtx((greetProps) => [
      _createTextVNode(_toDisplayString(greetProps.msg), 1 /* TEXT */)
    ]),
    _: 1 /* STABLE */
  }))
}

其中传递给 withCtx 函数的函数就是插槽函数,该插槽函数会在子组件渲染的时候执行。

组件渲染的流程会执行 setupComponent 函数,在 setupComponent 函数中会初始化组件 props 和插槽。

// packages/runtime-core/src/component.ts

export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  isInSSRComponentSetup = isSSR

  const { props, children } = instance.vnode
  // 判断是否是有状态组件
  const isStateful = isStatefulComponent(instance)
  // 初始化 props
  initProps(instance, props, isStateful, isSSR)
  // 初始化插槽
  initSlots(instance, children)

  // 设置有状态组件的组件实例(eg:执行 setup 函数等)
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined
  isInSSRComponentSetup = false
  return setupResult
}

在初始化插槽的时候会将插槽相关数据存储到组件实例中,所以在组件渲染的时候,可以从组件实例的 $slots 属性中得到插槽函数并执行,从而得到插槽需要渲染的内容。

// packages/runtime-core/src/componentSlots.ts

export const initSlots = (
  instance: ComponentInternalInstance,
  children: VNodeNormalizedChildren
) => {
  if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const type = (children as RawSlots)._
    if (type) {
      // users can get the shallow readonly version of the slots object through `this.$slots`,
      // we should avoid the proxy object polluting the slots of the internal instance
      instance.slots = toRaw(children as InternalSlots) // 将插槽数据存储到组件实例
      // make compiler marker non-enumerable
      def(children as InternalSlots, '_', type)
    } else {
      normalizeObjectSlots(
        children as RawSlots,
        (instance.slots = {}),
        instance
      )
    }
  } else {
    instance.slots = {}
    if (children) {
      normalizeVNodeSlots(instance, children)
    }
  }
  def(instance.slots, InternalObjectKey, 1)
}

toRaw 函数用于获取当前被观察对象的源对象。因为用户可以通过 this.$slots 获取浅只读版本的 slots 对象,Vue3 为了避免代理对象(用户传入的插槽数据)污染内部实例的 slots ,因此使用 toRaw 函数获取插槽数据(children)的源数据存储到组件实例的 slots 属性中。

// packages/reactivity/src/reactive.ts

export const enum ReactiveFlags {
  SKIP = '__v_skip',
  IS_REACTIVE = '__v_isReactive',
  IS_READONLY = '__v_isReadonly',
  IS_SHALLOW = '__v_isShallow',
  RAW = '__v_raw'
}

export function toRaw<T>(observed: T): T {
  const raw = observed && (observed as Target)[ReactiveFlags.RAW]
  return raw ? toRaw(raw) : observed
}

在 Vue3 中,使用响应式 API 创建的响应式对象会被添加一些标记,其中的 RAW 标记用于记录对象的原始值。toRaw 函数就是通过获取 RAW 标记来获取到对象的原始值。

  • __v_skip: 是否无效标识,用于跳过监听
  • __v_isReactive: 是否已被 reactive 相关 api 处理过
  • __v_isReadonly: 是否被 readonly 相关 api 处理过
  • __v_isShallow: 是否为浅层响应式对象
  • __v_raw: 当前被观察对象的源对象,即 target

总结

Vue 组件的插槽可以理解为一个占位,在开始时,组件不知道插槽的内容应该渲染什么内容,所以先用一个标签在组件模板中占一个位置,然后在使用该组件时,插槽的内容由父组件中定义。

在某些场景下,插槽的内容可能需要同时使用父组件域内和子组件域内的数据,这时可以向对组件传递 props 那样,向 slot 标签(<slot>,即插槽的出口)上传递属性(attributes),这样在父组件的插槽内容中也可以使用子组件的数据了。

插槽的实现原理其实就是父组件中定义的插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。子组件中的 slot 标签(<slot>)则会被编译为插槽函数的调用,子组件在渲染的时候,会执行对应的插槽函数,得到外部向槽位填充的内容(即虚拟 DOM),最后将该内容渲染到槽位中。

如果子组件向 slot 标签传递了数据,则该数据会作为参数传给插槽函数,因此在插槽的内容中(父组件模板内)也能够使用到子组件域内的数据,这其实就是简单的参数传递,这也是作用域插槽的实现原理。

参数资料

  1. 《Vue.js 设计与实现》霍春阳·著

  2. 《Vue.js 3.0 核心源码内参》 HuangYi·著

  3. 插槽 Slots

  4. vue3——深入了解reactive()

推荐阅读