Skip to content
medium
封装ReactVue

Day 01

实现一个 Button 按钮

用于触发操作的基础组件,支持多种外观和尺寸,提供加载、图标。

  • 基于 Tailwind
  • 默认渲染为 button
  • API 精简,类型友好

用法示例

Default

tsx
import Button from './Button'

export default function Problem() {
  <Button>button</Button>
}
vue
<script setup lang='ts'>
import Button from './Button'
</script>

<template>
  <Button>button</Button>
</template>

Variant

tsx
import Button from './Button'

export default function Problem() {
  return (
    <Button variant="secondary">Secondary</Button>
  )
}
vue
<script setup lang='ts'>
import Button from './Button'
</script>

<template>
  <Button variant="secondary">
    Secondary
  </Button>
</template>

Icon

tsx
import { ChevronRightIcon } from 'lucide-react'
import Button from './Button'

export default function Problem() {
  <Button size="icon">
    <ChevronRightIcon />
  </Button>
}
vue
<script setup lang='ts'>
import { ChevronRight } from 'lucide-vue-next'
import Button from './Button'
</script>

<template>
  <Button size="icon">
    <ChevronRight />
  </Button>
</template>

With Icon

tsx
import { IconGitBranch } from '@tabler/icons-react'
import Button from './Button'

export default function Problem() {
  <Button leftIcon={<IconGitBranch />}>
    New Branch
  </Button>
}
vue
<script setup lang='ts'>
import { ChevronRight } from 'lucide-vue-next'
import Button from './Button'
</script>

<template>
  <Button size="sm" :loading="true" :disabled="true">
    <template #left>
      <ChevronRight />
    </template>
  </Button>
</template>

属性

React Props

继承 React.ButtonHTMLAttributes<HTMLButtonElement>

属性名说明类型可选值默认值
variant外观风格default | secondary | destructive | outline | ghost | link同左default
size尺寸sm | md | lg | icon同左md
loading加载中booleantrue | falsefalse
loadingText加载时替代文案string
leftIcon左侧图标ReactNode
rightIcon右侧图标ReactNode
fullWidth占满整行宽度booleantrue | falsefalse
className自定义类名string
type原生类型button | submit | resetbutton
disabled禁用booleantrue | falsefalse
Vue Props
属性名类型默认值说明
variant'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link''default'按钮风格类型。
size'default' | 'sm' | 'lg' | 'icon''lg'按钮尺寸。
asstring-渲染为自定义元素标签
loadingbooleanfalse是否处于加载中状态
loadingTextstringundefined加载时按钮中显示的文本内容
fullWidthbooleanfalse是否宽度撑满父容器。
type'button' | 'submit' | 'reset''button'常用于表单。
disabledbooleanfalse是否禁用按钮

类型定义

ts
import type React from 'react'

export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  loading?: boolean
  loadingText?: string
  leftIcon?: React.ReactNode
  rightIcon?: React.ReactNode
  fullWidth?: boolean
}
ts
export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'

export interface ButtonProps {
  variant?: ButtonVariant
  size?: ButtonSize
  as?: string
  loading?: boolean
  loadingText?: string
  fullWidth?: boolean
  type?: 'button' | 'submit' | 'reset'
  disabled?: boolean
}

样式(通用)

tailwind 类名

按钮基础样式:

css
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive

不同 variant 对应的类名

css
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',

按钮大小尺寸

css
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',

题目模版

React

tsx
import { ProblemAnswer, ProblemDescription, Problems } from '../../layout'
import Button from './Button'
import Description from './README.mdx'

export default function Problem() {
  return (
    <Problems>
      <ProblemDescription>
        <Description />
      </ProblemDescription>
      <ProblemAnswer>
        {/* Your answer goes here */}
        <Button />
      </ProblemAnswer>
    </Problems>
  )
}
tsx
function Button() {
  return (
    <div>Button</div>
  )
}

export default Button
ts
import type React from 'react'

export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  loading?: boolean
  loadingText?: string
  leftIcon?: React.ReactNode
  rightIcon?: React.ReactNode
  fullWidth?: boolean
}

Vue

vue
<script setup lang="ts">
import { ProblemAnswer, ProblemDescription, Problems } from '../../layout'
import Button from './Button.vue'
import Description from './README.md'
</script>

<template>
  <Problems>
    <ProblemDescription>
      <Description />
    </ProblemDescription>
    <ProblemAnswer>
      <!-- your answer goes here -->
      <Button />
    </ProblemAnswer>
  </Problems>
</template>
vue
<script setup lang="ts"></script>

<template>
  <div>
    button
  </div>
</template>

<style scoped></style>
ts
export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'

export interface ButtonProps {
  variant?: ButtonVariant
  size?: ButtonSize
  as?: string
  loading?: boolean
  loadingText?: string
  fullWidth?: boolean
  type?: 'button' | 'submit' | 'reset'
  disabled?: boolean
}

测试代码

tsx
import type { ButtonProps } from './types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Button from './Button'
import '@testing-library/jest-dom'

function setup(props: Partial<ButtonProps> = {}) {
  const utils = render(<Button {...props}>Button</Button>)
  const btn = utils.getByRole('button')
  return { ...utils, btn }
}

describe('button 组件', () => {
  describe('当渲染一个按钮时', () => {
    it('应该看到传入的子内容被正确渲染', () => {
      render(<Button>Hello</Button>)
      expect(screen.getByRole('button')).toHaveTextContent('Hello')
    })

    it('应该默认具有 type="button"', () => {
      const { btn } = setup()
      expect(btn).toHaveAttribute('type', 'button')
    })

    it('应该保留自定义 className 以便样式扩展', () => {
      const { btn } = setup({ className: 'custom-class' })
      expect(btn).toHaveClass('custom-class')
    })
  })

  describe('当处理原生属性与事件时', () => {
    it('应该在点击时调用传入的 onClick 回调一次', async () => {
      const onClick = vi.fn()
      const { btn } = setup({ onClick })
      await fireEvent.click(btn)
      expect(onClick).toHaveBeenCalledTimes(1)
    })

    it('应该在禁用状态下阻止点击回调被触发', async () => {
      const onClick = vi.fn()
      render(
        <Button disabled onClick={onClick}>
          Button
        </Button>,
      )
      const btn = screen.getByRole('button')
      expect(btn).toBeDisabled()
      await fireEvent.click(btn)
      expect(onClick).not.toHaveBeenCalled()
    })

    it('应该尊重显式传入的 type 值(例如 submit)', () => {
      render(<Button type="submit">Submit</Button>)
      expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
    })
  })

  describe('当切换外观与尺寸时', () => {
    it('应该根据 variant 切换到对应的样式(如 default -> outline)', () => {
      const { rerender } = render(<Button variant="default">Btn</Button>)
      const btn = screen.getByRole('button')
      expect(btn.className).toContain('default') // 根据你的实现调整断言关键字
      rerender(<Button variant="outline">Btn</Button>)
      expect(btn.className).toContain('outline')
    })

    it('应该根据 size 切换到对应的尺寸(如 sm -> lg)', () => {
      const { rerender } = render(<Button size="sm">Btn</Button>)
      const btn = screen.getByRole('button')
      expect(btn.className).toContain('sm') // 根据你的实现调整断言关键字
      rerender(<Button size="lg">Btn</Button>)
      expect(btn.className).toContain('lg')
    })

    it('应该在 fullWidth=true 时占满整行宽度', () => {
      const { btn } = setup({ fullWidth: true })
      expect(btn.className).toMatch(/w-full/)
    })
  })

  describe('当进入加载状态时', () => {
    it('应该通过 aria-busy 标记忙碌并禁止交互', async () => {
      const onClick = vi.fn()
      render(
        <Button loading onClick={onClick}>
          Button
        </Button>,
      )
      const btn = screen.getByRole('button')
      expect(btn).toHaveAttribute('aria-busy', 'true')
      expect(btn).toBeDisabled()
      await fireEvent.click(btn)
      expect(onClick).not.toHaveBeenCalled()
    })

    it('应该在提供 loadingText 时用其替换原有文本', () => {
      render(
        <Button loading loadingText="Please wait">
          Submit
        </Button>,
      )
      const btn = screen.getByRole('button')
      expect(btn).toHaveTextContent('Please wait')
      expect(btn).not.toHaveTextContent('Submit')
    })
  })

  describe('当渲染图标时', () => {
    it('应该在非加载状态下同时渲染 leftIcon 与 rightIcon', () => {
      const Left = () => <span>L</span>
      const Right = () => <span>R</span>
      render(
        <Button leftIcon={<Left />} rightIcon={<Right />}>
          Button
        </Button>,
      )
      expect(screen.getByTestId('left')).toBeInTheDocument()
      expect(screen.getByTestId('right')).toBeInTheDocument()
    })

    it('应该在加载状态下隐藏右侧图标以让位加载反馈', () => {
      const Right = () => <span data-testid="right">R</span>
      render(
        <Button loading rightIcon={<Right />}>
          Button
        </Button>,
      )
      expect(screen.queryByTestId('right')).toBeNull()
    })

    it('应该在仅图标尺寸(size="icon")时依赖 aria-label 保证可访问性', () => {
      render(
        <Button size="icon" aria-label="Next">
          <svg data-testid="icon" />
        </Button>,
      )
      const btn = screen.getByLabelText('Next')
      expect(btn).toBeInTheDocument()
      expect(screen.getByTestId('icon')).toBeInTheDocument()
    })
  })
})
ts
import type { ButtonSize, ButtonVariant } from './types'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Button from './answer.vue'

describe('button 组件', () => {
  // 基本渲染测试
  it('应该正确渲染默认按钮', () => {
    const wrapper = mount(Button, {
      slots: {
        default: '测试按钮',
      },
    })

    expect(wrapper.text()).toContain('测试按钮')
    expect(wrapper.attributes('type')).toBe('button')
    expect(wrapper.attributes('data-variant')).toBe('default')
    expect(wrapper.attributes('data-size')).toBe('lg')
    expect(wrapper.attributes('data-loading')).toBeUndefined()
    expect(wrapper.attributes('data-full-width')).toBeUndefined()
  })

  // 变体(variant)测试
  it('应该根据variant属性应用正确的样式类', () => {
    const variants: ButtonVariant[] = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']

    variants.forEach((variant) => {
      const wrapper = mount(Button, {
        props: { variant },
        slots: { default: '变体按钮' },
      })

      expect(wrapper.attributes('data-variant')).toBe(variant)

      // 验证每个变体都应用了正确的类
      if (variant === 'default') {
        expect(wrapper.classes()).toContain('bg-primary')
      }
      else if (variant === 'destructive') {
        expect(wrapper.classes()).toContain('bg-destructive')
      }
      else if (variant === 'outline') {
        expect(wrapper.classes()).toContain('border')
      }
      else if (variant === 'secondary') {
        expect(wrapper.classes()).toContain('bg-secondary')
      }
      else if (variant === 'ghost') {
        expect(wrapper.classes()).toContain('hover:bg-accent')
      }
      else if (variant === 'link') {
        expect(wrapper.classes()).toContain('text-primary')
      }
    })
  })

  // 尺寸(size)测试
  it('应该根据size属性应用正确的样式类', () => {
    const sizes: ButtonSize[] = ['default', 'sm', 'lg', 'icon']

    sizes.forEach((size) => {
      const wrapper = mount(Button, {
        props: { size },
        slots: { default: '尺寸按钮' },
      })

      expect(wrapper.attributes('data-size')).toBe(size)

      // 验证每个尺寸都应用了正确的类
      if (size === 'default') {
        expect(wrapper.classes()).toContain('h-9')
      }
      else if (size === 'sm') {
        expect(wrapper.classes()).toContain('h-8')
      }
      else if (size === 'lg') {
        expect(wrapper.classes()).toContain('h-10')
      }
      else if (size === 'icon') {
        expect(wrapper.classes()).toContain('size-9')
      }
    })
  })

  // 全宽(fullWidth)测试
  it('应该在fullWidth为true时应用全宽样式', () => {
    const wrapper = mount(Button, {
      props: { fullWidth: true },
      slots: { default: '全宽按钮' },
    })

    expect(wrapper.attributes('data-full-width')).toBe('true')
    expect(wrapper.classes().some(cls => cls.includes('w-full'))).toBe(true)
  })

  // 加载状态测试
  it('应该正确处理加载状态', () => {
    const wrapper = mount(Button, {
      props: { loading: true },
      slots: { default: '加载按钮' },
    })

    expect(wrapper.attributes('aria-busy')).toBe('true')
    expect(wrapper.attributes('data-loading')).toBe('true')
    expect(wrapper.attributes('disabled')).toBe('')
  })

  // 加载文本测试
  it('应该在加载状态且提供loadingText时显示加载文本', () => {
    const wrapper = mount(Button, {
      props: {
        loading: true,
        loadingText: '加载中...',
      },
      slots: { default: '按钮内容' },
    })

    expect(wrapper.text()).toContain('加载中...')
    expect(wrapper.text()).not.toContain('按钮内容')
  })

  // 禁用状态测试
  it('应该正确处理禁用状态', () => {
    const wrapper = mount(Button, {
      props: { disabled: true },
      slots: { default: '禁用按钮' },
    })

    expect(wrapper.attributes('disabled')).toBe('')
  })

  // 按钮类型测试
  it('应该设置正确的按钮类型', () => {
    const types = ['button', 'submit', 'reset'] as const

    types.forEach((type) => {
      const wrapper = mount(Button, {
        props: { type },
        slots: { default: '类型按钮' },
      })

      expect(wrapper.attributes('type')).toBe(type)
    })
  })

  // 插槽测试
  it('应该正确渲染左右插槽内容', () => {
    const wrapper = mount(Button, {
      slots: {
        default: '按钮内容',
        left: '<span data-test="left-icon">L</span>',
        right: '<span data-test="right-icon">R</span>',
      },
    })

    expect(wrapper.text()).toContain('按钮内容')
    expect(wrapper.find('[data-testid="left"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="right"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="left-icon"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="right-icon"]').exists()).toBe(true)
  })

  // 加载状态下右侧插槽不显示
  it('应该在加载状态下隐藏右侧插槽', () => {
    const wrapper = mount(Button, {
      props: { loading: true },
      slots: {
        default: '按钮内容',
        right: '<span data-test="right-icon">R</span>',
      },
    })

    expect(wrapper.find('[data-testid="right"]').exists()).toBe(false)
  })

  // 点击事件测试
  it('应该在点击时触发click事件', async () => {
    const onClick = vi.fn()
    const wrapper = mount(Button, {
      slots: { default: '点击按钮' },
      attrs: { onClick },
    })

    await wrapper.trigger('click')
    expect(onClick).toHaveBeenCalledTimes(1)
  })

  // 禁用状态下不触发点击事件
  it('应该在禁用状态下不触发点击事件', async () => {
    const onClick = vi.fn()
    const wrapper = mount(Button, {
      props: { disabled: true },
      slots: { default: '点击按钮' },
      attrs: { onClick },
    })

    await wrapper.trigger('click')
    expect(onClick).not.toHaveBeenCalled()
  })

  // 加载状态下不触发点击事件
  it('应该在加载状态下不触发点击事件', async () => {
    const onClick = vi.fn()
    const wrapper = mount(Button, {
      props: { loading: true },
      slots: { default: '点击按钮' },
      attrs: { onClick },
    })

    await wrapper.trigger('click')
    expect(onClick).not.toHaveBeenCalled()
  })

  // 自定义类名测试
  it('应该正确合并自定义类名', () => {
    const wrapper = mount(Button, {
      slots: { default: '自定义类名按钮' },
      attrs: { class: 'custom-class' },
    })

    expect(wrapper.classes()).toContain('custom-class')
  })

  // 属性透传测试
  it('应该正确透传剩余属性', () => {
    const wrapper = mount(Button, {
      slots: { default: '属性透传按钮' },
      attrs: {
        'data-testid': 'test-button',
        'aria-label': '测试按钮',
      },
    })

    expect(wrapper.attributes('data-testid')).toBe('test-button')
    expect(wrapper.attributes('aria-label')).toBe('测试按钮')
  })

  // 引用暴露测试
  it('应该正确暴露按钮引用', () => {
    const wrapper = mount(Button)

    // 验证是否正确暴露了 el 引用
    expect(wrapper.vm.el).toBeDefined()
  })
})

答案

类型路径
reactproblems/react/01
vueproblems/vue/01
Review待补充

内容基于 MIT 许可 | 保持节奏 · 持续积累