【山竹记账前端-vue】4.组件封装与插槽


大纲链接 §

[toc]


组件封装与插槽:减少代码的重复

1. 快速完成四个欢迎页面 ⇧

参照 @/modules/welcome/Welcome1stPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {defineComponent} from 'vue'
import pig from '@/assets/icons/pig.svg'
import {RouterLink} from 'vue-router'

export const Welcome1stPage = defineComponent({
  name: 'Welcome1stPage',
  setup(/*props, ctx*/) {
    return () => (
      <>
        <img
          src={pig}
          alt="pig"
          class="w-128px h-130px mt-25%"/>
        <div class="description flex flex-col items-center text-[2em]">
          <h2>会挣钱</h2>
          <h2>还要会省钱</h2>
        </div>
        <div class="go-next text-primaryColor mb-84px text-[2em] font-bold">
          <RouterLink to="/welcome/2">下一页</RouterLink>
        </div>
      </>
    )
  },
})

@/modules/welcome/Welcome2ndPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {defineComponent} from 'vue'
import clock from '@/assets/icons/clock.svg'
import {RouterLink} from 'vue-router'

export const Welcome2ndPage = defineComponent({
  name: 'Welcome2ndPage',
  setup(/*props, ctx*/) {
    return () => (
      <>
        <img
          src={clock}
          alt="clock"
          class="w-128px h-130px mt-25%"/>
        <div class="description flex flex-col items-center text-[2em]">
          <h2>每日提醒</h2>
          <h2>不会遗漏每一笔账单</h2>
        </div>
        <div class="go-next text-primaryColor mb-84px text-[2em] font-bold">
          <RouterLink to="/welcome/3">下一页</RouterLink>
        </div>
      </>
    )
  },
})

@/modules/welcome/Welcome3rdPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {defineComponent} from 'vue'
import chart from '@/assets/icons/chart.svg'
import {RouterLink} from 'vue-router'

export const Welcome3rdPage = defineComponent({
  name: 'Welcome3rdPage',
  setup(/*props, ctx*/) {
    return () => (
      <>
        <img
          src={chart}
          alt="chart"
          class="w-128px h-130px mt-25%"/>
        <div class="description flex flex-col items-center text-[2em]">
          <h2>数据可视化</h2>
          <h2>收支一目了然</h2>
        </div>
        <div class="go-next text-primaryColor mb-84px text-[2em] font-bold">
          <RouterLink to="/welcome/4">下一页</RouterLink>
        </div>
      </>
    )
  },
})

@/modules/welcome/Welcome4thPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {defineComponent} from 'vue'
import cloud from '@/assets/icons/cloud.svg'
import {RouterLink} from 'vue-router'

export const Welcome4thPage = defineComponent({
  name: 'Welcome4thPage',
  setup(/*props, ctx*/) {
    return () => (
      <>
        <img
          src={cloud}
          alt="cloud"
          class="w-128px h-130px mt-25%"/>
        <div class="description flex flex-col items-center text-[2em]">
          <h2>云备份</h2>
          <h2>再也不怕数据丢失</h2>
        </div>
        <div class="go-next text-primaryColor mb-84px text-[2em] font-bold">
          <RouterLink to="/start">开启应用</RouterLink>
        </div>
      </>
    )
  },
})

2. TSX插槽语法 ⇧

注意: 在 jsx 中,应该使用 v-slots 代替 v-slot

2.1. 默认插槽与后备内容 ⇧

SlotProvider.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import {defineComponent} from 'vue'

export const SlotProvider = defineComponent({
  name: 'SlotProvider',
  setup(_props, {slots}) {
    return () => (
      <>
        <h1>{slots.default ? slots.default() : '默认内容'}</h1>
      </>
    )
  },
})

SlotConsumer.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import {defineComponent} from 'vue'
import {SlotProvider} from './SlotProvider.tsx'

export const SlotConsumer = defineComponent({
  name: 'SlotConsumer',
  setup() {
    return () => (
      <>
        <SlotProvider/>
        <SlotProvider>自定义内容覆盖</SlotProvider>
      </>
    )
  },
})

2.2. 具名插槽与后备内容 ⇧

SlotProvider.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import {defineComponent} from 'vue'

export const SlotProvider = defineComponent({
  name: 'SlotProvider',
  setup(_props, {slots}) {
    return () => (
      <>
        <h2>{slots.bar?.() ?? slots.default?.() ?? '保底后备内容'}</h2>
        {/* 等价于 */}
        {
          slots.bar?.()
            ? slots.bar?.()
            : slots.default?.()
              ? slots.default?.()
              : <span>保底后备内容</span>
        }
      </>
    )
  },
})
  • slots.default?.() 为插槽消费组件提供的后备内容,也可能未提供

SlotConsumer.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import {defineComponent} from 'vue'
import {SlotProvider} from './SlotProvider.tsx'

export const SlotConsumer = defineComponent({
  name: 'SlotConsumer',
  setup() {
    const slots = {
      bar: () => <span>Bar的内容</span>,
    }
    return () => (
      <>
        {/* 显示 保底后备内容 */}
        <SlotProvider/>
        {/* 显示 default默认后备内容 */}
        <SlotProvider v-slots={null}>
          <div>default默认后备内容</div>
        </SlotProvider>
        {/* 显示 具名插槽Bar的内容 */}
        <SlotProvider v-slots={slots}>
          <div>默认后备内容</div>
        </SlotProvider>
        {/* 另外的写法:显示 具名插槽Bar的内容 */}
        <SlotProvider>
          {{slots}}
        </SlotProvider>
        {/* 当 `vueJsx({ enableObjectSlots: true, })` 不是 `false` 时,可以直接使用对象插槽 */}
        <SlotProvider>
          {{
            bar: () => <span>Bar的内容</span>,
          }}
        </SlotProvider>
      </>
    )
  },
})

2.3. 多个具名插槽 ⇧

SlotProvider.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import {defineComponent} from 'vue'

export const SlotProvider = defineComponent({
  name: 'SlotProvider',
  setup(_props, {slots}) {
    return () => (
      <>
        <h1>{slots.default?.() ?? '默认default内容'}</h1>
        <h2>{slots.bar?.() ?? '默认bar内容'}</h2>
        <h2>{slots.foo?.() ?? '默认foo内容'}</h2>
      </>
    )
  },
})

SlotConsumer.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import {defineComponent} from 'vue'
import {SlotProvider} from './SlotProvider.tsx'

export const SlotConsumer = defineComponent({
  name: 'SlotConsumer',
  setup() {
    const slots = {
      default: () => <span>default</span>,
      bar: () => <span>bar</span>,
      foo: () => <span>foo</span>,
    }
    return () => (
      <>
        <SlotProvider v-slots={null}/>
        <SlotProvider v-slots={slots}/>
        {/* 当 `vueJsx({ enableObjectSlots: true, })` 不是 `false` 时,可以直接使用对象插槽 */}
        <XxWelcome
          v-slots={{
            default: () => <span>default</span>,
            bar: () => <span>bar</span>,
            foo: () => <span>foo</span>,
          }}
        />
      </>
    )
  },
})

2.4. 动态插槽名 ⇧

SlotProvider.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {defineComponent} from 'vue'

export const SlotProvider = defineComponent({
  name: 'SlotProvider',
  setup(_props, {slots}) {
    // 1. 获取所有传进来的插槽名称数组 (例如: ['header', 'default', 'footer'])
    const slotNames = Object.keys(slots)
    console.log('slots_______________________')
    console.log(slots)
    console.log(slotNames)
    console.log('_______________________slots')
    return () => (
      slotNames.length === 0
        ? <>默认空</>
        : <>
          {slotNames.map((slotName) => {
            console.log('正在渲染插槽:', slotName);
            console.log('正在渲染插槽:', slots[slotName]?.());

            return (
              <div key={slotName} class={`slot-wrapper-${slotName}`}>
                {/* 执行对应的插槽函数,并传入兜底内容 */}
                {slots[slotName]?.() ?? '该插槽无内容'}
              </div>
            )
          })}
        </>
    )
  },
})

SlotConsumer.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import {defineComponent} from 'vue'
import {SlotProvider} from './SlotProvider.tsx'

export const SlotConsumer = defineComponent({
  name: 'SlotConsumer',
  setup() {
    const slots = {
      default: () => <span>default</span>,
      bar: () => <span>bar</span>,
      foo: null,
      duo: () => null
    }
    return () => (
      <>
        <SlotProvider v-slots={null} />
        <SlotProvider v-slots={slots} />
      </>
    )
  },
})

2.5. 作用域插槽传参 ⇧

SlotProvider.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import {defineComponent} from 'vue'

export const SlotProvider = defineComponent({
  name: 'SlotProvider',
  setup(_props, {slots}) {
    return () => (
      <>
        <h1>{slots.default?.({msg: 'default内容'})}</h1>
        <h2>{slots.foo?.({title: 'foo内容'})}</h2>
      </>
    )
  },
})

SlotConsumer.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {defineComponent, type SlotsType} from 'vue'
import type { JSX } from 'vue/jsx-runtime'
import {SlotProvider} from './SlotProvider.tsx'

export const SlotConsumer = defineComponent({
  name: 'SlotConsumer',
  // 可选 🌟 使用 SlotsType 声明组件支持的插槽及其接收的参数类型
  slots: Object as SlotsType<{
    default: (props: { msg: string }) => JSX.Element;
    foo: (props: { title: string }) => JSX.Element;
  }>,
  setup() {
    const slots = {
      default: (props: { msg: string, }) => <span>{props.msg}</span>,
      foo: (props: { title: string, }) => <span>{props.title}</span>,
    }
    return () => (
      <>
        <XxWelcome v-slots={slots} />
      </>
    )
  },
})

在 TypeScript 中使用 tsconfig.json:

1
2
3
4
5
{
  "compilerOptions": {
  "jsx": "preserve"
  }
}

参考


3. 消除重复,封装 WelcomeLayout 组件 ⇧

代码重构基本原则

  • 我与重复不共戴天
  • DRY

code_compare_duplicated

分析有变化的部分

  • 图标 icon
  • 中间文字的文案
  • 按钮(跳转链接)

使用插槽 与 使用Props 对比

  • 插槽更灵活,使用子组件的形式传任意内容
  • 使用Props更简洁,但对数据一致性要求更高;需要在标签里传属性

参考

  • vue 插槽官方与生态最通用的称呼
    • 插槽模板组件 / 定义插槽的组件:称为 子组件 (Child Component) 或 “提供者” (Provider / Slot Provider)
    • 或称为 骨架组件 (Skeleton Component)、外壳组件 (Shell Component) 或 容器组件 (Container Component)
    • 只负责外层抽象的搭建布局、处理通用逻辑、提供状态,不关心具体的视觉呈现与具体数据
    • 消费插槽的组件 / 使用插槽的组件:称为 父组件 (Parent Component) 或 “消费者” (Consumer / Slot Consumer)
    • 或称为 内容组件 (Content Component)、视图组件 (View Component) 或 注入组件 (Injector Component)
    • 决定了具体数据最终以什么样的 UI 样式呈现出来,即内部的内容由父组件提供

XxWelcome.tsx 作为组件插槽入口 ⇧

创建 @/modules/welcome/XxWelcome.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import {defineComponent} from 'vue'

export const XxWelcome = defineComponent({
  name: 'XxWelcome',
  setup(_props, _ctx) {
    const {slots} = _ctx
    console.log('slots_______________________')
    console.log(slots)
    console.log('_______________________slots')
    return () => (
      <>
        {slots?.icon?.()}
        {<div class="description flex flex-col items-center text-[2em]">{slots?.title?.()}</div>}
        <div class="go-next text-primaryColor mb-84px text-[2em] font-bold">
          {slots?.buttons?.()}
        </div>
      </>
    )
  },
})
  • 使用具名插槽占位
    • 原本图标,改为插槽占位 {slots?.icon?.()}
    • 原本文字描述,改为插槽占位 {slots?.title?.()}
    • 原本跳转,改为插槽占位 {slots?.buttons?.()}
  • 无默认插槽 slots?.default?.()
  • 直接调用具名插槽方法即可,可传实参

改造 Welcome1stPage.tsx ⇧

@/modules/welcome/Welcome1stPage.tsx

  • 默认的配置即 vueJsx({ enableObjectSlots: true, }),,可省略
  • 当 enableObjectSlots 不是 false 时,可以直接使用对象插槽
  • 可解构出插槽作用域中的形参使用

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
    import {defineComponent} from 'vue'
    import pig from '@/assets/icons/pig.svg'
    import {RouterLink} from 'vue-router'
    import {XxWelcome} from '@/modules/welcome/XxWelcome.tsx'
    
    export const Welcome1stPage = defineComponent({
    name: 'Welcome1stPage',
    setup(/*props, ctx*/) {
    return () => (
      <XxWelcome
        v-slots={{
          icon: () => <img
            src={pig}
            alt="pig"
            class="w-128px h-130px mt-25%"/>,
          title: () => (
            <>
              <h2>会挣钱</h2>
              <h2>还要会省钱</h2>
            </>
          ),
          buttons: () => <RouterLink to="/welcome/2">下一页</RouterLink>,
        }}
      />
    )
    },
    })
    

重命名 XxWelcometsx 为 WelcomeLayout.tsx


改造 Welcome2ndPage.tsx ⇧

@/modules/welcome/Welcome2ndPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineComponent } from 'vue'
import clock from '@/assets/icons/clock.svg'
import { RouterLink } from 'vue-router'
import { WelcomeLayout } from '@/modules/welcome/WelcomeLayout.tsx'

export const Welcome2ndPage = defineComponent({
  name: 'Welcome2ndPage',
  setup(/*props, ctx*/) {
    return () => (
      <WelcomeLayout>
        {{
          icon: () => <img src={clock} alt="clock" class="w-128px h-130px mt-25%" />,
          title: () => (
            <>
              <h2>每日提醒</h2>
              <h2>不会遗漏每一笔账单</h2>
            </>
          ),
          buttons: () => <RouterLink to="/welcome/3">下一页</RouterLink>,
        }}
      </WelcomeLayout>
    )
  },
})

4. 还可以优化 ⇧

  • 使用配置抽离4个组件独有部分,用动态路由实现
    • 暂不实现

直接使用无状态的函数式组件 @/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type {FunctionalComponent} from 'vue'
import { RouterView } from 'vue-router'
import { cn } from 'cn'
import s from '@/modules/welcome/WelcomeView.module.scss'
import logo from '@/assets/icons/mangosteen.svg'

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>
      <img src={logo} alt="logo" />
      <h1>山竹记账</h1>
    </header>
    <main
      class={cn([
        'bg-welcomeCardBg mb-62px ml-16px mr-16px rounded-lg',
        'flex flex-col flex-grow items-center justify-around',
      ])}>
      <RouterView />
    </main>
    <section class={s['to-last-page']}>跳过</section>
  </div>
)
WelcomeView.displayName = 'WelcomeView'

@/modules/welcome/Welcome1stPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import type {FunctionalComponent} from 'vue'
import pig from '@/assets/icons/pig.svg'
import { RouterLink } from 'vue-router'
import { WelcomeLayout } from '@/modules/welcome/WelcomeLayout.tsx'

export const Welcome1stPage: FunctionalComponent = () => (
  <WelcomeLayout
    v-slots={{
      icon: () => <img src={pig} alt="pig" class="w-128px h-130px mt-25%" />,
      title: () => (
        <>
          <h2>会挣钱</h2>
          <h2>还要会省钱</h2>
        </>
      ),
      buttons: () => <RouterLink to="/welcome/2">下一页</RouterLink>,
    }}
  />
)
Welcome1stPage.displayName = 'Welcome1stPage'

添加 Welcome1stPage.displayName = 'Welcome1stPage' 防止报警告

1
2
3
# main.ts:12 [VUE_ROUTER_R0030] Component "default" in record with path "/welcome" is a function that does not return a Promise. This will break in production if not fixed.
# ├▶ fix: Return a dynamic import (`() => import("./MyPage.vue")`) from the function, or add a `displayName` if it is a functional component.
# ╰▶ see: https://router.vuejs.org/guide/advanced/lazy-loading.html


·未完待续·

参考文章

相关文章

  • 无

  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名