目前前端組件化已經成為前端開發的核心思想之一,在這篇文章中將深入探討如何規劃一個規范的Button組件,讓它不僅能高效支持不同的功能需求還能確保跨項目、跨團隊的一致性,拋磚引玉的方式引出后面組件庫的其他組件的開發!
目錄
Button組件搭建
bem命名規范
組件正式編碼
樣式解決方案
組件單元測試
組件文檔搭建?
GitHub Actions
changeset版本
Button組件搭建
????????Button按鈕本質:一個button按鈕之所以有需要樣式的選擇,究其原因就是class名稱的組合,根據賦值的不同類名來實現不同的樣式結果,如下所示:
class="ease-button--primary ease-button--larger is-plain is-disabled"
完成一個組件庫需要考慮的問題有以下幾個方面,根據任務進行需求分析,然后初始化項目確定項目文件結構,接下來就是規范基礎寫法、樣式解決方案以及色彩系統等基礎方面內容了:
代碼結構;樣式解決方案;組件需求分析和編碼;組件測用例和編碼;代碼打包輸出和發布;CI/CD及文檔生成等
像Button組件大部分關注的只是樣式而沒有交互,根據分析我們可以得到如下具體的屬性列表:
type:不同的樣式(default、primary、danager、info、success、warning)
plain:樣式的不同展現模式boolean
round:圓角boolean
circle:圓形按鈕,適合圖標boolean
disabled:禁用boolean
icon:圖標添加
loading:加載樣式添加
? ? ? ? 組件目錄結構:后期所有組件的開發按照如下目錄進行規范進行,styles文件存儲當前組件所有關聯的樣式;types文件存儲當前組件所有關聯的類型;其他tsx文件存儲當前組件所有的核心代碼;index.tsx文件存儲當前組件暴露出去的封裝組件及類型,如下所示:
bem命名規范
????????bem:(Block, Element, Modifier)是一種CSS命名規范,用于使前端代碼更加結構化和可維護,它通過三個主要部分來命名CSS類名,如下所示:
1)Block(塊):代表一個獨立的功能模塊,可以是頁面中的一個組件或者布局的一部分。例如header、button、menu
2)Element(元素):表示一個塊內的子元素,通常是該塊的一部分依賴于塊的存在,例如header__title(title是header塊的元素),button__icon。
3)Modifier(修飾符):用于描述塊或元素的不同狀態或外觀變化,例如button--primary(代表一種主要按鈕樣式),header__title--large(代表較大的標題樣式)
brm的命名規則讓類名具有清晰的層級關系和可讀性,有助于避免命名沖突和使代碼更容易維護,這里我將其抽離到項目的utils文件夾下用來供全局組件進行調用,代碼如下所示:
// block代碼塊 element元素 modifier裝飾 state狀態
/*** 示例:* ease-button--primary* is-checked*/// 創建BEM命名規范
const createBEM = (prefixName: string) => {const block = (blockSuffix: string = '') => _bem(prefixName, blockSuffix, '', '')const element = (element: string) => element ? _bem(prefixName, '', element, '') : ''const modifier = (modifier: string | undefined) => modifier ? _bem(prefixName, '', '', modifier) : ''const be = (blockSuffix: string = '', element: string = '') => blockSuffix && element ? _bem(prefixName, blockSuffix, element, '') : ''const bm = (blockSuffix: string = '', modifier: string = '') => blockSuffix && modifier ? _bem(prefixName, blockSuffix, '', modifier) : ''const em = (element: string = '', modifier: string = '') => element && modifier ? _bem(prefixName, '', element, modifier) : ''const bem = (blockSuffix: string = '', element: string = '', modifier: string = '') => blockSuffix && element && modifier ? _bem(prefixName, blockSuffix, element, modifier) : ''const is = (name: string, state: boolean | undefined) => (state ? `is-${name}` : "")return { block, element, modifier, be, bm, em, bem, is }
};// BEM命名規范實現
const _bem = (prefixName: string, blockSuffix: string, element: string, modifier: string) => {if (blockSuffix) prefixName += `-${blockSuffix}`;if (element) prefixName += `__${element}`;if (modifier) prefixName += `--${modifier}`;return prefixName;
}// 創建命名空間
export const createNameSpace = (name: string) => {const prefixName = `ease-${name}`return createBEM(prefixName)
};
組件正式編碼
????????接下來我們開始對button進行正式代碼書寫,當我們封裝一個對應的原生組件的時候,原生屬性必須要進行考量是否添加好,這里我們可以參考?MDN?官網提供的文檔詳細了解原生組件擁有哪些原生屬性以及其具體的作用是什么:
typescript類型:當我們編寫一個原生組件給其他人使用的時候ts類型尤為重要,只有我們寫了ts類型之后用戶才會知道我們開發的組件到底有哪些核心參數可以使用,這里我們完全可以參考其他熱門組件庫文件提供的一些范例來給我們開發組件庫提供一些思路:
對于一個button按鈕來講,他有許多常量類型供我們選擇、如按鈕類型、形狀、html類型、 尺寸等常量類型,這里我們可以將其抽離出一個constant.ts文件里面用于定義常量類型,如下所示:
友情提示!!!:我們可以定義代碼塊用于快速折疊相似功能代碼:
// #region 規范按鈕組件的常量類型
// 定義包含五種按鈕類型的元組
const _ButtonTypes = ['default', 'primary', 'dashed', 'link', 'text'] as const;
export type ButtonType = (typeof _ButtonTypes)[number]; // 限制組件屬性的取值范圍// 定義包含三種按鈕形狀的元組
const _ButtonShapes = ['default', 'circle', 'round'] as const;
export type ButtonShape = (typeof _ButtonShapes)[number];// 定義包含三種按鈕HTML類型的元組
const _ButtonHTMLTypes = ['submit', 'button', 'reset'] as const;
export type ButtonHTMLType = (typeof _ButtonHTMLTypes)[number];// 定義包含三種按鈕尺寸的元組
const _ButtonSizes = ['large', 'middle', 'small'] as const;
export type ButtonSize = (typeof _ButtonSizes)[number];
// #endregion
定義完常量類型之后,我們在types類型文件夾下的入口index.ts文件中開始引入這些常量類型,然后設置props類型并給其賦值,常見的props類型我們可以參考熱門組件庫文檔給出的范例,最終代碼如下所示:
import React from 'react'
import { ButtonHTMLType, ButtonSize, ButtonShape, ButtonType } from './constant'interface BaseButtonProps {type: ButtonType; // 按鈕類型,默認為 'default'shape: ButtonShape; // 按鈕形狀,默認為 'default'htmlType: ButtonHTMLType; // 按鈕的 HTML 類型,默認為 'button'size: ButtonSize; // 按鈕尺寸,默認為 'middle'plain: boolean; // 是否為樸素按鈕,默認為 falsedisabled: boolean; // 是否禁用狀態,默認為 falseloading: boolean; // 是否為加載中狀態,默認為 falsecolor: string; // 按鈕顏色children: React.ReactNode; // 按鈕內容,默認為 null
}// 合并HTMLAttributes和 ButtonHTMLAttributes的屬性,但不包括type、color、disabled、children屬性
type MergedHTMLAttributes = Omit<React.HTMLAttributes<HTMLElement> &React.ButtonHTMLAttributes<HTMLElement> &React.AnchorHTMLAttributes<HTMLElement>,'type' | 'color' | 'disabled' | 'children'
>;export interface inheritProps extends BaseButtonProps, MergedHTMLAttributes {href: string;autoInsertSpace: boolean;
}// 使所有屬性可選(Partial)且只讀(Readonly)
export type ButtonProps = Readonly<Partial<inheritProps>>;
核心代碼實現:編寫完button組件的ts類型之后,接下來我們開始正式對原生button按鈕進行組件化開發,這里我們定義一個組件的基本結構,通過props傳遞數據,并且借助函數將實例暴露出去,通過bem規范給組件動態設置類名:
import React, { forwardRef, useCallback } from 'react';
import clsx from 'clsx';
import { createNameSpace } from "@ztk63lrd/utils/create"
import type { ButtonProps } from './types';const EButton = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {// #region 組件變量聲明const bem = createNameSpace('button');const { type, shape, htmlType = 'button', size, disabled, loading, color, children, ...rest } = props;// #endregion// #region 組件樣式構建const classes = clsx( // 使用 clsx 動態構建類名bem.block(), // 基礎類名bem.modifier(type), // type 修改器bem.modifier(shape), // shape 修改器bem.modifier(size), // size 修改器bem.is('disabled', disabled), // disabled 狀態bem.is('loading', loading) // loading 狀態);// #endregion// #region 組件事件處理const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>) => {if (disabled) {e.preventDefault();return;}props.onClick?.('href' in props? (e as React.MouseEvent<HTMLAnchorElement, MouseEvent>): (e as React.MouseEvent<HTMLButtonElement, MouseEvent>),);}, [props.onClick, disabled]);// #endregionreturn (<buttonclassName={classes} // 直接應用動態類名type={htmlType}disabled={disabled || loading}onClick={handleClick}ref={ref}{...rest}>{children}</button>);
});export default EButton;
接下來我們在組件文件夾的入口文件index.tsx處將封裝組件及其類型暴露出去,然后在packages文件夾下的入口文件將所有components文件夾下寫的所有組件再暴露一遍出去就可以了:
接下來我們開始在根目錄終端執行pnpm run build即可,打包后就會在packages文件下生成build文件目錄,并且會按照問你設置的組件目錄結構生成相應的js文件和.d.ts類型聲明文件,如下:
接下來我們終端運行pnpm run dev命令來執行review文件下測試組件項目的代碼,在該文件下設置如下代碼來查看我們編寫的組件情況,如果我們直接導入ease-reactify就是我們打包好的實際組件代碼,如果導入@ztk63lrd/components/button就是我們實時編寫的組件代碼:
import EButton from "@ztk63lrd/components/button"
// import { EButton } from "ease-reactify";const ButtonView = () => {return (<div><EButton size="middle" type="primary" shape="round" disabled>按鈕</EButton></div>)
}export default ButtonView
通過上面我們給封裝好的button組件添加類型限制之后,我們的bem規范已經被成功執行并且也出現在我們的運行項目上面了:
樣式解決方案
????????大家都知道作為一個組件庫我們要采用什么樣的色彩是尤為重要的,每一個熱門的組件庫都有它一套特定的色譜或者說是色彩系統,所以這時候我們就需要了解一下顏色對一個組件庫的構成模式,這里我們可以參考antd的對于色彩的一些講解:地址?文章還是比較詳細的介紹了系統色以及產品色的一些概念內容,對我們設計組件庫顏色方案給到了一個很好的啟發:
? ? ? ? 了解好樣式解決方案之后,接下來我們可以訪問?地址?來選擇我們開發組件的一個默認樣式:
? ? ? ? 除上面我們可以選擇顏色內容之后,我們也可以訪問如下網站來設計一下我們的button組件:
確認好最終的一個樣式方案之后,接下來我們開始正式對組件進行樣式的調整,這里我們采用的css預處理scss進行樣式調整,后期有精力的話也按照antd來設計全部用ts來寫樣式,目前的方案還是采用css處理器解決樣式問題,目前的話還是根據不同的作用劃分css文件,然后定義一些常用的方法和變量名來實現css樣式內容:
然后根據我們要設計的組件,給button設計不同狀態下的css樣式,其中用到了封裝好的公共的變量和css方法,如下所示:
最終我們在預覽界面中實現的效果如下所示:
組件單元測試
????????當我們編寫完組件之后,為了確保后期組件的穩定性,即使后面項目的迭代和變更也不會影響原組件的功能,這里我們需要借助組件的單元測試來確保組件的穩定性,我們組件開發使用單元測試的重要性主要有以下幾點原因:
1)高質量代碼
2)更早的發現bug,減少成本
3)讓重構和升級變得更加容易和可靠
4)讓開發流程更加敏捷
? ? ? ? 單元測試的方法有很多,因為我們采用的是vite作為打包器進行構建項目,所以這里我們也采用vite一派的單元測試工具 vitest 進行操作,詳情可以參考我之前的文章:地址?,vitest是兼容Jest的,所以說Vitest的api與Jest非常相似,用戶從Jest遷移到Vitest基本上沒啥學習成本:
接下來我們終端執行如下命令安裝測試插件:
pnpm install -D vitest
安裝完成之后,我們在vite的配置文件當中配置一下單元測試的一些關鍵配置:
當我們在相關組件下新建tests文件夾,就可以實現對組件的一些單元測試了:
瀏覽器模式與框架無關因此不提供任何渲染組件的方法。不過可以使用框架的測試工具包,這里我們采用如下的包進行組件測試,終端執行如下命令安裝:
pnpm install @testing-library/react @testing-library/jest-dom @testing-library/user-event -D
然后這里我們使用該三方庫中的render函數來測試渲染的組件庫內容,如下可以看到我們給按鈕設置的名稱通過函數拿到按鈕的文本,測試案例也正常通過了:
當然testing library旗下還有許多其他的工具,通過地址我們可以了解更多的測試工具的使用:
了解了一些工具的使用之后,接下來我們開始正式的對EButton組件進行單元測試,如下:
import { expect, test, describe, it, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react"
import EButton, { ButtonProps } from "../index";
import '@testing-library/jest-dom'const defaultProps = {onClick: vi.fn()
}
const testProps: ButtonProps = {type: "primary",size: 'large',className: 'ease-test',
}
const disabledProps: ButtonProps = {disabled: true,onClick: vi.fn()
}describe("test EButton components", () => {it("應呈現正確的默認按鈕", () => {const wrapper = render(<EButton {...defaultProps}>測試按鈕</EButton>);const element = wrapper.getByText("測試按鈕") as HTMLButtonElement; // 獲取文本為"測試按鈕"的元素expect(element).toBeInTheDocument(); // 斷言該元素存在于文檔中expect(element.tagName).toBe("BUTTON"); // 斷言該元素的標簽名為"BUTTON"expect(element).toHaveClass("ease-button"); // 斷言該元素具有類名"ease-button"expect(element.disabled).toBeFalsy(); // 斷言該元素未被禁用fireEvent.click(element);expect(defaultProps.onClick).toHaveBeenCalled(); // 斷言onClick函數被調用});it("應根據不同的props渲染正確的組件", () => {const wrapper = render(<EButton {...testProps}>測試按鈕</EButton>);const element = wrapper.getByText("測試按鈕");expect(element).toBeInTheDocument();expect(element).toHaveClass("ease-button ease-button--primary ease-button--large ease-test");});it("禁用時應呈現禁用按鈕設置為true", () => {const wrapper = render(<EButton {...disabledProps}>測試按鈕</EButton>);const element = wrapper.getByText("測試按鈕") as HTMLButtonElement;expect(element).toBeInTheDocument(); // 斷言該元素存在于文檔中expect(element.disabled).toBeTruthy(); // 斷言該元素已被禁用expect(disabledProps.onClick).not.toHaveBeenCalled(); // 斷言onClick函數未被調用})
});
最終可以看到我們的測試用例代碼都通過了:
組件文檔搭建?
????????項目采用組件文檔進行代碼和具體相關展示,組件庫文檔有很多選擇,可以參考文章了解一下其他組件庫插件的實現,這里我們還是以vite衍生的組件庫文檔vitePress來實現,VitePress可以單獨使用也可以安裝到現有項目中,在這兩種情況下都可以使用以下方式安裝它:
npm add -D vitepress
npx vitepress init
安裝完插件之后根據自身情況進行選擇即可,如果正在構建一個獨立的VitePress站點可以在當前目錄 (./) 中搭建站點,但是如果在現有項目中與其他源代碼一起安裝VitePress,建議將站點搭建在嵌套目錄(例如 ./docs)中以便它與項目的其余部分分開:
安裝好插件之后,vitepress會自動幫助我們配置好環境命令,我們直接運行即可:
pnpm install markdown-it markdown-it-container markdown-it-mathjax3 -D
然后根據具體的一些官網配置進行調配即可:
GitHub Actions
????????GitHub Actions:是GitHub提供的一個自動化工具,它可以幫助開發者自動執行構建、測試、部署等任務,通過GitHub Actions開發者可以定義一系列的工作流(workflow),并且這些工作流可以在GitHub上的代碼庫發生特定事件時觸發,比如推送代碼、發起 pull 請求、發布版本等。
一個工作流通常由多個步驟(steps)組成,每個步驟可以執行不同的命令或者運行一個自定義的腳本,工作流是由yaml文件描述的,存放在項目的.github/workflows 目錄中,其特點主要包括:
1)自動化流程:自動執行重復的任務減少手動操作
2)GitHub緊密集成:可以直接在GitHub上定義和管理工作流
3)多平臺支持:支持Linux、macOS 和 Windows環境
4)自定義工作流:可以根據自己的需求創建自定義的工作流和步驟
5)與其他工具服務集成:可以與其他工具API或者云服務集成,擴展功能
此次我們組件庫的工作流,目前主要有兩個,如下所示:
部署組件庫文檔:該工作流設置了名稱,通過on來構建觸發工作流的時機是提交代碼
name: 'Deploy Docs' # 工作流名稱on:push:branches:- masterjobs:build-and-deploy:runs-on: ubuntu-latestpermissions:contents: write # 添加寫入權限steps:- name: 讀取倉庫內容uses: actions/checkout@v4 # 必須有uses來獲取代碼- name: 安裝pnpmuses: pnpm/action-setup@v2 # 添加這一步安裝pnpm- name: 安裝依賴run: pnpm install --no-frozen-lockfile # 運行pnpm install來安裝依賴- name: 項目測試run: pnpm run test # 運行pnpm測試項目- name: 構建項目run: pnpm run docs:build # 運行構建文檔的命令- name: 部署GitHub Pagesuses: JamesIves/github-pages-deploy-action@v4 # 使用GitHub Actions來部署到gh-pageswith:branch: gh-pages # 指定部署的分支folder: 'docs/dist' # 指定構建后的文件夾路徑clean: true # 清理舊的部署文件
發布npm平臺:該工作流設置了名稱,通過on來構建觸發工作流的時機是package發生變化
name: Release Packageon:push:branches:- masterpaths:- 'packages/package.json' # 如果是 monorepo,監聽所有子包的 package.jsonjobs:release:runs-on: ubuntu-lateststeps:- name: 讀取倉庫內容uses: actions/checkout@v4with:fetch-depth: 0 # 獲取完整歷史,用于版本計算- name: Setup Node.jsuses: actions/setup-node@v3with:node-version: 21registry-url: https://registry.npmjs.org/- name: 安裝pnpmuses: pnpm/action-setup@v2- name: 安裝依賴(跳過鎖文件檢查)run: pnpm install --no-frozen-lockfile- name: 項目測試run: pnpm run test- name: 構建項目run: pnpm run build- name: 檢查待發布的包run: npx changeset status- name: 創建版本號run: npx changeset version --snapshot ${{ github.event.inputs.release-type }}- name: 登錄到npmrun: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc- name: 發布到npmrun: npx changeset publish --only ease-reactifyenv:NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
當我們提交代碼的時候就會觸發自動化流程,GitHub Actions就會自動幫我們執行對應的部署:
changeset版本
與git結合使用的一個特定文件或目錄結構,用于跟蹤和管理代碼的變更集,這通常出現在需要對代碼進行分組提交的場景或者在使用某些自動化工具、工作流、或 CI/CD過程中,終端執行如下命令進行安裝:
pnpm add @changesets/cli -D
其常用命令如下所示:
# 初始化
npx changeset init# 添加 changeset
npx changeset add
npx changeset# 版本
npx changeset version# 發布
npx changeset publish
這里我將其命令放在package中的配置腳本中,想要發布的時候執行如下命令即可:
目前部署的組件庫文檔:地址?,后期內容會不斷完善!