⌈Vue3&React18⌋ 动手实践构建组件库之❣️Button组件❣️

144 阅读3分钟

预览

Button | Vue3xy (chengbotao.github.io)
Button | Reactxy (chengbotao.github.io)

介绍

常用的操作按钮&文本链接
支持设置按钮尺寸
支持设置按钮类型

Props 属性

支持原生 ButtonA 属性

属性名属性类型说明默认值
disabledboolean设置 Button 禁用false
btnTypeoneOf "primary" | "danger" | "link" | "default"设置 Button 类型default
hrefstring当 Button 为 link 类型时的地址-
classNamestring自定义CSS类名-
sizeoneOf "sm" | "lg"设置 Button 尺寸-

Event 事件

事件名参数类型说明
clickMouseEvent点击事件

呈现

Vue3 实现

<template>
    <button v-if="!isLink" key="button" v-bind="$attrs" :disabled="disabled" :class="classes" @click="handleClick">
        <slot></slot>
    </button>
    <a v-else key="a" v-bind="$attrs" :href="href" :class="classes" @click="handleClick">
        <slot></slot>
    </a>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
    name: 'XyButton',
    inheritAttrs: false,
})

</script>

<script setup lang="ts">
import { computed } from "vue";
import classNames from "classnames";

type ButtonSize = "lg" | "sm";
type ButtonType = "primary" | "default" | "danger" | "link";

interface ButtonProps {
    disabled?: boolean
    href?: string
    className?: string
    size?: ButtonSize
    btnType?: ButtonType
}

interface ButtonEmits {
    (event: "click", payload: MouseEvent): void
}

// defineOptions({
//     name: 'XyButton',
//     inheritAttrs: false,
// })

// Props
const props = withDefaults(defineProps<ButtonProps>(), {
    btnType: "default"
});

// Emits
const emits = defineEmits<ButtonEmits>();

// computed
const classes = computed(() => {
    return classNames("xy-button", props.className, {
        [`xy-button-${props.btnType}`]: props.btnType,
        [`xy-button-${props.size}`]: props.size,
        disabled: props.btnType === "link" && props.disabled,
    });
});

const isLink = computed(() => {
    return props.btnType === "link" && !!props.href;
})

// methods
const handleClick = (event: MouseEvent) => {
    emits("click", event)
}

// public 方法
defineExpose({})

</script>

vitest & @testing-library/vue 单元测试

import { render } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import Button from './index';

describe('Button Component', () => {
  // 应呈现正确的默认按钮
  test('should render the correct default button', async () => {
    const { getByText, emitted } = render(Button, {
      slots: {
        default: 'Default Button',
      },
    });

    const element = getByText('Default Button') as HTMLButtonElement;

    expect(element.tagName).toEqual('BUTTON');
    expect(element.disabled).toBeFalsy();
    expect(element.className).toMatch(/xy-button xy-button-default/);
    await userEvent.click(element);
    expect(emitted('click')).toBeTruthy();
  });

  // 应该根据不同的props渲染正确的组件
  test('should render the correct component based on different props', () => {
    const { getByText } = render(Button, {
      slots: {
        default: 'Primary Button',
      },
      props: {
        btnType: 'primary',
        size: 'lg',
        className: 'xy-custom-btn',
      },
    });

    const element = getByText('Primary Button') as HTMLButtonElement;

    expect(element.className).toMatch(/xy-button xy-custom-btn xy-button-primary xy-button-lg/);
  });

  // 当 btnType 等于 link 并提供 href 时,应该呈现一个 A 标签
  test('should render a link when btnType equals link and href is provided', () => {
    const { getByText } = render(Button, {
      slots: {
        default: 'Link Button',
      },
      props: {
        btnType: 'link',
        href: '#',
      },
    });
    const element = getByText('Link Button') as HTMLAnchorElement;

    expect(element.tagName).toEqual('A');
    expect(element.className).toMatch(/xy-button xy-button-link/);
  });

  // 应呈现禁用按钮当 disabled 设置为 true
  test('should render disabled button when disabled set to true', async () => {
    const { getByText, emitted } = render(Button, {
      slots: {
        default: 'Disabled Button',
      },
      props: {
        disabled: true,
      },
    });
    const element = getByText('Disabled Button') as HTMLButtonElement;

    expect(element.disabled).toBeTruthy();
    await userEvent.click(element);
    expect(emitted('click')).toBeFalsy();
  });
});


React 实现

import React, { FC, AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import classNames from 'classnames';

export type ButtonSize = 'lg' | 'sm';
export type ButtonType = 'primary' | 'default' | 'danger' | 'link';

export interface ButtonProps
  extends Partial<ButtonHTMLAttributes<HTMLElement> & AnchorHTMLAttributes<HTMLElement>> {
  /** 设置 Button 禁用 */
  disabled?: boolean;
  /** 设置 Button 尺寸 */
  size?: ButtonSize;
  /** 设置 Button 类型 */
  btnType?: ButtonType;
  /** 当 Button 为 link 类型时的地址 */
  href?: string;
  /** 自定义 css 类名 */
  className?: string;
  children?: React.ReactNode;
}

/**
 * Button 组件
 *
 *
 */
export const Button: FC<ButtonProps> = (props) => {
  const { btnType, disabled, className, size, href, children, ...restProps } = props;
  const classes = classNames('xy-button', className, {
    [`xy-button-${btnType}`]: btnType,
    [`xy-button-${size}`]: size,
    disabled: btnType === 'link' && disabled,
  });

  if (btnType === 'link' && href) {
    return (
      <a className={classes} href={href} {...restProps}>
        {children}
      </a>
    );
  } else {
    return (
      <button className={classes} disabled={disabled} {...restProps}>
        {children}
      </button>
    );
  }
};

Button.defaultProps = {
  disabled: false,
  btnType: 'default',
};

export default Button;

jest & @testing-library/react 单元测试

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Button, { ButtonProps } from './button';

describe('Button Component', () => {
  // 应呈现正确的默认按钮
  test('should render the correct default button', () => {
    const defaultProps = {
      onClick: jest.fn(),
    };
    const wrapper = render(<Button {...defaultProps}>Default Button</Button>);
    const element = wrapper.getByText('Default Button') as HTMLButtonElement;
    expect(element).toBeInTheDocument();
    expect(element.tagName).toEqual('BUTTON');
    expect(element.disabled).toBeFalsy();
    expect(element).toHaveClass('xy-button xy-button-default');
    fireEvent.click(element);
    expect(defaultProps.onClick).toHaveBeenCalled();
  });

  // 应该根据不同的props渲染正确的组件
  test('should render the correct component based on different props', () => {
    const differentProps: ButtonProps = {
      btnType: 'primary',
      size: 'lg',
      className: 'xy-custom-btn',
    };
    const wrapper = render(<Button {...differentProps}>Primary Button</Button>);
    const element = wrapper.getByText('Primary Button') as HTMLButtonElement;
    expect(element).toBeInTheDocument();
    expect(element).toHaveClass('xy-button xy-button-primary xy-custom-btn');
  });

  // 当 btnType 等于 link 并提供 href 时,应该呈现一个 A 标签
  test('should render a link when btnType equals link and href is provided', () => {
    const linkandhrefProps: ButtonProps = {
      btnType: 'link',
      href: '#',
    };
    const wrapper = render(<Button {...linkandhrefProps}>Link Button</Button>);
    const element = wrapper.getByText('Link Button') as HTMLAnchorElement;
    expect(element).toBeInTheDocument();
    expect(element.tagName).toEqual('A');
    expect(element).toHaveClass('xy-button xy-button-link');
  });

  // 应呈现禁用按钮当 disabled 设置为 true
  test('should render disabled button when disabled set to true', () => {
    const disabledProps: ButtonProps = {
      disabled: true,
      onClick: jest.fn(),
    };
    const wrapper = render(<Button {...disabledProps}>Disabled Button</Button>);
    const element = wrapper.getByText('Disabled Button') as HTMLButtonElement;
    expect(element).toBeInTheDocument();
    expect(element.disabled).toBeTruthy();
    fireEvent.click(element);
    expect(disabledProps.onClick).not.toHaveBeenCalled();
  });
});

请教

有以下问题向大佬们请教,请不吝赐教

  1. 如何开发 Vue3 组件的类型声明?

    了解了elementUI的实现方式,但在实现中不顺畅搁浅了,后续会再研究

  2. vue3 使用 defineOptions 来声明name等属性时,导出的组件打印出来实例还是没有声明属性?

后记

Vue-Button源码
React-Button源码
Introduction | Testing Library (testing-library.com)
user-event | Testing Library (testing-library.com)
个人博客 | Botaoxy (chengbotao.github.io)

感谢阅读,敬请斧正!