【山竹记账前端-vue】9.使用 v-model 实现 Tabs 组件


大纲链接 §

[toc]


1. 新增 item 相关路由 ⇧

@/router/routes.ts

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
import type { RouteRecordRaw } from 'vue-router'
import { WelcomeView } from '@/views/WelcomeView.tsx'
import { Welcome1stPage } from '@/modules/welcome/Welcome1stPage.tsx'
import { Welcome2ndPage } from '@/modules/welcome/Welcome2ndPage.tsx'
import { Welcome3rdPage } from '@/modules/welcome/Welcome3rdPage.tsx'
import { Welcome4thPage } from '@/modules/welcome/Welcome4thPage.tsx'
import { StartAccountsView } from '@/views/StartAccountsView.tsx'
import { ItemsView } from '@/views/ItemsView.tsx'
import { ItemList } from '@/views/ItemList.tsx'
import { ItemCreateView } from '@/views/ItemCreateView.tsx'

export const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/welcome/1',
  },
  {
    path: '/welcome',
    name: 'welcome',
    component: WelcomeView,
    children: [
      { path: '', name: 'welcomeDefault', redirect: '/welcome/1' },
      { path: '1', name: 'welcomePig', components: { main: Welcome1stPage } },
      { path: '2', name: 'welcomeClock', components: { main: Welcome2ndPage } },
      { path: '3', name: 'welcomeChart', components: { main: Welcome3rdPage } },
      { path: '4', name: 'welcomeCloud', components: { main: Welcome4thPage } },
    ],
  },
  { path: '/start', name: 'startAccounts', component: StartAccountsView },
  /* 记账项目相关:项目列表页、创建页 */
  {
    path: '/item',
    name: 'item',
    component: ItemsView,
    children: [
      {
        path: '', // 默认子路由
        name: 'itemList',
        component: ItemList,
      },
      {
        path: 'create',
        name: 'itemCreate',
        component: ItemCreateView,
      },
    ],
  },
]

2. 制作 MainLayout 组件,重构 StartAccountsView,完成 ItemsView 框架 ⇧

抽离相似的顶部导航部分,封装布局组件,使用插槽的形式传入不同部分的内容:@/components/layout/MainLayout.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineComponent /* type PropType */ } from 'vue'
import { NavBar } from '@/components/NavBar.tsx'
import { cn } from 'cn'

export const MainLayout = defineComponent({
  name: 'MainLayout',
  setup( _props, _ctx ) {
    return () => (
      <div class={cn('w-full h-full position-relative')}>
        <NavBar
          direct-scoped
          class="navbar bg-gradient-to-b from-[var(--navbar-bg-start)] to-[var(--navbar-bg-end)]">
          {{
            default: () => _ctx.slots?.title?.(),
            icon: () => _ctx.slots?.icon?.(),
          }}
        </NavBar>
        {_ctx.slots?.default?.()}
      </div>
    )
  },
})

重构到 @/views/StartAccountsView.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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { defineComponent } from 'vue'
import { cn } from 'cn'
import { BarButton } from '@/components/BarButton.tsx'
import { FloatButton } from '@/components/FloatButton.tsx'
import { PlaceCenter } from '@/components/PlaceCenter.tsx'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { FloatOverlay } from '@/modules/overlay/FloatOverlay.tsx'
import { storeToRefs } from 'pinia'
import { useOverlayStore } from '@/stores/useOverlayStore.ts'
import { useRouter } from 'vue-router'
import { MainLayout } from '@/components/layout/MainLayout.tsx'

export const StartAccountsView = defineComponent({
  name: 'StartAccountsView',
  setup(/*props, ctx*/) {
    const { overlayVisible } = storeToRefs(useOverlayStore())
    const { toggleOverlay } = useOverlayStore()
    const onClickMenu = () => {
      toggleOverlay()
    }
    const router = useRouter()
    const routerToCreateItemView = async () => {
      await router.push('/item')
    }
    return () => (
      <MainLayout>
        {{
          title: () => '山竹记账',
          icon: () => (<SvgIcon name="menu" class="w-2em h-2em" onClick={onClickMenu} />),
          default: () => (
            <>
              <PlaceCenter class="p-y-10em">
                <SvgIcon class="w-8em h-8em" name="pig" />
              </PlaceCenter>

              <div class={cn('p-x-1em')}>
                <BarButton onClick={routerToCreateItemView}>开始记账</BarButton>
              </div>

              <FloatButton onClick={routerToCreateItemView} iconName="add" class="fixed bottom-1em right-1em" />

              {
                overlayVisible.value &&
                <FloatOverlay direct-scoped />
              }
            </>
          )
        }}
      </MainLayout>
    )
  },
})

@/views/ItemsView.tsx

1
2
3
4
5
6
7
8
9
import { defineComponent } from 'vue'
import { RouterView } from 'vue-router'

export const ItemsView = defineComponent({
  name: 'ItemsView',
  setup(/* _props, _ctx */) {
    return () => <RouterView/>
  },
})

完成 ItemsView 框架 @/views/ItemList.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { defineComponent } from 'vue'
import { MainLayout } from '@/components/layout/MainLayout.tsx'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'

export const ItemList = defineComponent({
  name: 'ItemList',
  setup(/* _props, _ctx */) {
    return () => (
      <MainLayout>
        {{
          title: () => '记一笔',
          icon: () => <SvgIcon name="back-arrow" class="w-2em h-2em" />,
          default: () => <div>main</div>,
        }}
      </MainLayout>
    )
  },
})
  • 注意这里写法格式有一个坑:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    export const ItemList = defineComponent({
    name: 'ItemList',
    setup(/* _props, _ctx */) {
    return () => (
      <MainLayout> {{
          title: () => '记一笔',
          icon: () => <SvgIcon name="back-arrow" class="w-2em h-2em" />,
          default: () => <div>main</div>,
        }}
      </MainLayout>
    )
    },
    })
    
  • 如果组件标签 <MainLayout> {{/* ... */}} 之间有空格,编译会被认为是两个子节点的数组,一个空格文本节点 加上一个对象,插槽不接受数组

  • 导致页面显示内容显示为空,见 路由命名视图

  • 导致警告报错:

    • [VUE_ROUTER_R0103] The route named "item" has a child without a name, an empty path, and no children.
    • Using that name won't render the empty path child, so this is probably a mistake.
  • 仅在 JSX/TSX 组件中出现,区别于 SFC 组件中的 {{}} 模板插值语法

插槽语法错误


3. 设计 Tabs 接口 ⇧

交互:

  • 当点击支出(默认显示),下方内容为支出相关记账项目
  • 当点击收入,下方内容为收入相关记账项目
  • 最下方的键盘输入区域则不随tab切换,作为表单输入

ItemList.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const ItemList = defineComponent({
  name: 'ItemList',
  setup(/* _props, _ctx */) {
    const onUpdateSeleted = (name: string) => {kind.value = name}
    return () => (
      <MainLayout>
        {{
          title: () => '记一笔',
          icon: () => <SvgIcon name="back-arrow" class="w-2em h-2em" />,
          default: () => (
            <>
              <SwitchTabs selected={kind.value} onUpdateSeleted={onUpdateSeleted}>
                <ItemTab name="支出">支出icon 列表</ItemTab>
                <ItemTab name="收入">收入icon 列表</ItemTab>
              </SwitchTabs>
            </>
          ),
        }}
      </MainLayout>
    )
  },
})
  • 需要保证 SwitchTabs 子组件必须是 ItemTab,否则报错
  • ItemTab 接受默认插槽内容,作为 tab 对应的内容显示

@/components/tab/SwitchTabs.tsx

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

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  setup( _props, _ctx ) {
    const defaultSlotsArray = _ctx.slots?.default?.() ?? []
    const ifAllItemTab = defaultSlotsArray
      .every(slot => slot.type === ItemTab)
    if (!ifAllItemTab) {
      throw new Error(`SwitchTabs: 子组件必须是 ItemTab`)
    }
    console.log('SwitchTabs_______________________')
    console.log(_ctx.slots?.default?.())
    console.log(_ctx.slots?.default?.()?.[0]?.type === ItemTab)
    console.log("%c 2 --> ifAllItemTab: ","color:#58c4dc;", ifAllItemTab);
    console.log('_______________________SwitchTabs')
    return () => {
      return (<div>SwitchTabs</div>)
    }
  },
})

@/components/tab/ItemTab.tsx

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

export const ItemTab = defineComponent({
  name: 'ItemTab',
  props: {
    tabName: {
      type: String as PropType<string>,
      required: true,
    }
  },
  setup( _props, _ctx ) {
    return () => (
      <div>{_ctx.slots?.default?.()}</div>
    )
  },
})
  • SwitchTabs 通过 _ctx.slots?.default?.() 获取子组件,相当于 react 的 children
    • 插槽内容可能为多个VNode元素的数组
    • 打印出查看

SwitchTabsSlots

  • 可以对比 _ctx.slots?.default?.()?.[0]?.type === ItemTab 来判断子组件类型是否为 ItemTab
  • 遍历判断数组中所有 VNode 元素的 type 是否为 ItemTab,如果不是则抛出异常
  • 注意插件 _ctx.slots?.default?.() 必须写在 setup 的返回函数中,否则会报警告:
    • installHook.js:1 [Vue warn]: Slot "default" invoked outside of the render function: this will not track dependencies used in the slot.
    • Invoke the slot function inside the render function instead.

@/components/tab/SwitchTabs.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 { ItemTab } from '@/components/tab/ItemTab.tsx'

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  setup( _props, _ctx ) {
    // `_ctx.slots?.default?.()` 必须写在 `setup` 的返回函数中,否则会报警告
    return () => {
      const defaultSlotsArray = _ctx.slots?.default?.() ?? []
      const ifAllItemTab = defaultSlotsArray
        .every(slot => slot.type === ItemTab)
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab`)
      }
      console.log('SwitchTabs_______________________')
      console.log(_ctx.slots?.default?.())
      console.log(_ctx.slots?.default?.()?.[0]?.type === ItemTab)
      console.log("%c 2 --> ifAllItemTab: ","color:#58c4dc;", ifAllItemTab);
      console.log('_______________________SwitchTabs')

      return (<div>SwitchTabs</div>)
    }
  },
})

4. 实现 Tabs 功能 ⇧

  • SwitchTabs 通过默认插槽获取子组件 ItemTab,并通过 props 传入 selected 属性,来控制当前选中的 tab
  • props.selected 和 遍历的 slot.props?.tabName 进行比较,来判断当前 tab 是否被选中,添加 selected 样式
  • 添加 onUpdateSelected 事件处理函数,更新 selected 的值,来实现 tab 切换功能
    • 通过 props 中的 onUpdateSelected 回调函数,执行时传入当前 tab 的名称,来更新父组件的状态

@/components/tab/SwitchTabs.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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { defineComponent, type PropType } from 'vue'
import { ItemTab } from '@/components/tab/ItemTab.tsx'
import { cn } from 'cn'

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  props: {
    selected: {
      type: String as PropType<string>,
    },
    onUpdateSeleted: {
      type: Function as PropType<(name: string) => void>,
    },
  },
  setup( _props, _ctx ) {
    return () => {
      const defaultSlotsArray = _ctx.slots?.default?.() ?? []
      const ifAllItemTab = defaultSlotsArray
        .every(slot => slot.type === ItemTab &&
          !!slot.props?.tabName)
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab,且必须有 tabName 属性`)
      }
      return (
        <>
          {/* 切换标签 */}
          <nav>
            <menu>
              {defaultSlotsArray
                .map((slot, index) => (
                  <li
                    key={index + slot?.props?.tabName}
                    class={cn(
                      _props.selected === slot?.props?.tabName ? 'bg-red' : ''
                    )}
                    onClick={() => {_props.onUpdateSeleted?.(slot?.props?.tabName)}}>
                    {slot?.props?.tabName}
                  </li>
                ))}
            </menu>
          </nav>

          {/* 标签内容 */}

        </>
      )
    }
  },
})

@/views/ItemList.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
import { defineComponent, ref } from 'vue'
import { MainLayout } from '@/components/layout/MainLayout.tsx'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { SwitchTabs } from '@/components/tab/SwitchTabs.tsx'
import { ItemTab } from '@/components/tab/ItemTab.tsx'

export const ItemList = defineComponent({
  name: 'ItemList',
  setup(/* _props, _ctx */) {
    const kind = ref('支出')
    const onUpdateSeleted = (name: string) => {kind.value = name}
    return () => (
      <MainLayout>
        {{
          title: () => '记一笔',
          icon: () => <SvgIcon name="back-arrow" class="w-2em h-2em" />,
          default: () => (
            <>
              <SwitchTabs selected={kind.value} onUpdateSeleted={onUpdateSeleted}>
                <ItemTab tabId="expenses" tabName="支出">支出 icon 列表</ItemTab>
                <ItemTab tabId="incomes" tabName="收入">收入 icon 列表</ItemTab>
              </SwitchTabs>
            </>
          ),
        }}
      </MainLayout>
    )
  },
})

参考


5. 在 TSX 里使用 v-model ⇧

使用 v-model 语法糖,替换”将父组件状态变更函数传入子组件,再由子组件执行该函数并传参”的写法:@/views/ItemList.tsx

  • 父组件 改为 <SwitchTabs selected={kind.value} v-model:selected={kind.value}>,并取出多余的 onUpdateSeleted
  • 子组件 _ctx.emit('update:selected', slot?.props?.tabName)

     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 ...
    
    export const ItemList = defineComponent({
    name: 'ItemList',
    setup(/* _props, _ctx */) {
    const kind = ref('支出')
    return () => (
      <MainLayout>
        {{
          title: () => '记一笔',
          icon: () => <SvgIcon name="back-arrow" class="w-2em h-2em" />,
          default: () => (
            <>
              <SwitchTabs selected={kind.value} v-model:selected={kind.value}>
                <ItemTab tabId="expenses" tabName="支出">支出 icon 列表</ItemTab>
                <ItemTab tabId="incomes" tabName="收入">收入 icon 列表</ItemTab>
              </SwitchTabs>
            </>
          ),
        }}
      </MainLayout>
    )
    },
    })
    

@/components/tab/SwitchTabs.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
36
37
38
39
40
41
42
43
44
// import ...

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  props: {
    selected: {
      type: String as PropType<string>,
    },
  },
  setup( _props, _ctx ) {
    return () => {
      const defaultSlotsArray = _ctx.slots?.default?.() ?? []
      const ifAllItemTab = defaultSlotsArray
        .every(slot => slot.type === ItemTab &&
          !!slot.props?.tabName)
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab,且必须有 tabName 属性`)
      }
      return (
        <>
          {/* 切换标签 */}
          <nav>
            <menu>
              {defaultSlotsArray
                .map((slot, index) => (
                  <li
                    key={index + slot?.props?.tabName}
                    class={cn(
                      _props.selected === slot?.props?.tabName ? 'bg-red' : ''
                    )}
                    onClick={() => {_ctx.emit('update:selected', slot?.props?.tabName)}}>
                    {slot?.props?.tabName}
                  </li>
                ))}
            </menu>
          </nav>

          {/* 标签内容 */}

        </>
      )
    }
  },
})

参考


6. 实现渲染tab内容,完成 Tabs 组件 ⇧

让 SwitchTabs 显示插槽中子组件的默认插槽内容,且只显示当前选中的 tab 内容

children.default

  • 查看 SwitchTabs 中默认插槽 VNode 数组每一项的 children.default,即 ItemTab 的默认插槽内容
  • 直接渲染对应的VNode即可 defaultSlotsArray.find(slot => slot.props?.tabName === _props.selected)

@/components/tab/SwitchTabs.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
36
37
38
39
40
41
42
43
44
45
46
47
// import ...

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  props: {
    selected: {
      type: String as PropType<string>,
    },
  },
  setup(_props, _ctx) {
    return () => {
      const defaultSlotsArray = _ctx.slots?.default?.() ?? []
      const ifAllItemTab = defaultSlotsArray.every(
        (slot) => slot.type === ItemTab && !!slot.props?.tabName,
      )
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab,且必须有 tabName 属性`)
      }

      return (
        <>
          {/* 切换标签 */}
          <nav>
            <menu>
              {defaultSlotsArray.map((slot, index) => (
                <li
                  key={index + slot?.props?.tabName}
                  class={cn(_props.selected === slot?.props?.tabName ? 'bg-red' : '')}
                  onClick={() => {
                    _ctx.emit('update:selected', slot?.props?.tabName)
                  }}>
                  {slot?.props?.tabName}
                </li>
              ))}
            </menu>
          </nav>

          {/* 标签内容 */}
          <section>
            {defaultSlotsArray
              .find((slot) => slot.props?.tabName === _props.selected)}
          </section>
        </>
      )
    }
  },
})

完善样式 @/assets/styles/vars.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * @Description: 全局变量
 * @Author: xmasuhai <xmasuhai@163.com>
 * @Date: 2026-09-25 15:53:59
 * @LastEditors: xmasuhai <xmasuhai@163.com>
 * @LastEditTime: 2026-10-14 22:20:37
 * @FilePath: content/post/山竹记账/【山竹记账前端-vue】9.使用 v-model 实现 Tabs 组件.md
 */

/* color palette from <https://github.com/vuejs/theme> */
:root {
  // ...
  // 划出浮层 遮罩层
  --z-index-float-overlay: 20;
  --overlay-mask-bg: rgba(0, 0, 0, 0.5);
  --overlay-bg: white;
  --overlay-user-bg: #5C33BE;
  --overlay-user-text: white;
  --top-safe-area-height: 24px;
  // tabs
  --tabs-indicator-bg: rgba(255, 255, 255, 0.5);
}

@/components/tab/SwitchTabs.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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { defineComponent, type PropType } from 'vue'
import { ItemTab } from '@/components/tab/ItemTab.tsx'
import { cn } from 'cn'

export const SwitchTabs = defineComponent({
  name: 'SwitchTabs',
  props: {
    selected: {
      type: String as PropType<string>,
    },
  },
  setup(_props, _ctx) {
    const selectedClass = (tabName: string) => _props.selected === tabName
      ? cn(
        'relative',
        `after:(content-[""] block absolute bottom-0 left-0 w-full h-4px bg-[var(--tabs-indicator-bg)])`,
      )
      : ''

    return () => {
      const defaultSlotTabs = _ctx.slots?.default?.() ?? []
      const ifAllItemTab = defaultSlotTabs.every(
        (slot) => slot.type === ItemTab && !!slot.props?.tabName,
      )
      if (!ifAllItemTab) {
        throw new Error(`SwitchTabs: 子组件必须是 ItemTab,且必须有 tabName 属性`)
      }

      return (
        <>
          {/* 切换标签 */}
          <nav>
            <menu class="flex justify-between items-center text-color-[var(--navbar-text-color)]">
              {defaultSlotTabs.map((slot, index) => (
                <li
                  key={index + slot?.props?.tabName}
                  class={cn(
                    'flex-grow-1 flex-shrink-0 text-center',
                    'p-y-12px bg-[var(--navbar-bg-end)]',
                    selectedClass(slot?.props?.tabName)
                  )}
                  onClick={() => {
                    _ctx.emit('update:selected', slot?.props?.tabName)
                  }}>
                  {slot?.props?.tabName}
                </li>
              ))}
            </menu>
          </nav>

          {/* 标签内容 */}
          <section>
            {defaultSlotTabs
              .find((slot) => slot.props?.tabName === _props.selected)}
          </section>
        </>
      )
    }
  },
})
  • 使用 unocss 的 transformerVariantGroup 配置 来表示 after:() 伪元素语法中多个样式
  • 给选中的 tab 添加下划线样式 after:(content-[""] block absolute bottom-0 left-0 w-full h-4px bg-[var(--tabs-indicator-bg)])

·未完待续·

参考文章

相关文章

  • 无

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