suemor

suemor

前端萌新
telegram
github
twitter

React + Vite 建立一個簡單的組件庫

前言#

最近閱讀了下 vite 的文檔,發現它有個庫模式用來打包挺方便的,因此寫篇博客記錄下折騰過程。

基本配置#

執行如下命令創建一個 React + TypeScript 的項目

pnpm create vite

刪除 src 和 public 文件夾,創建 example 和 packages 文件夾,其中 example 存放組件示例或者調試組件,packages 存放組件源碼。另外別忘了修改根目錄 index.html script路徑。

├── node_modules
├── packages
├── example
├── index.html
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

// index.html
<script type="module" src="/example/main.tsx"></script>

注:相關 eslint prettier tsconfig 的配置請自行查看末尾 git 倉庫,這不是本文的重點。

下面我們打開 vite.config.ts,對打包進行配置(記得先安裝下 @types/node )

import { readFileSync } from 'fs'
import path from 'path'
import { defineConfig } from 'vite'

import react from '@vitejs/plugin-react'

const packageJson = JSON.parse(
  readFileSync('./package.json', { encoding: 'utf-8' }),
)
const globals = {
  ...(packageJson?.dependencies || {}),
}

function resolve(str: string) {
  return path.resolve(__dirname, str)
}

export default defineConfig({
  plugins: [react()],
  build: {
    // 輸出文件夾
    outDir: 'dist',
    lib: {
      // 組件庫源碼的入口文件
      entry: resolve('packages/index.tsx'),
      // 組件庫名稱
      name: 'demo-design',
      // 文件名稱, 打包結果舉例: suemor.cjs
      fileName: 'suemor',
      // 打包格式
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      //排除不相關的依賴
      external: ['react', 'react-dom', ...Object.keys(globals)],
    },
  },
})

此時你在 packages/index.tsx 文件夾中任意 export 些代碼,它應該可以被正確打包成 CommonJS 與 ESM 了。

組件編寫#

為了簡單起見,我們組件就編寫一個有類型支持且可以切換顏色的 Tag。

image-20221202141137213

安裝依賴

pnpm i less clsx -D

下面這些 react 代碼就不介紹了

編寫 packages/Tag/interface.ts

import { CSSProperties, HTMLAttributes } from 'react'

/**
 * @title Tag
 */
export interface TagProps
  extends Omit<HTMLAttributes<HTMLDivElement>, 'className' | 'ref'> {
  style?: CSSProperties
  className?: string | string[]
  /**
   * @zh 設置標籤背景顏色
   * @en The background color of Tag
   */
  color?: Colors
}

type Colors = 'red' | 'orange' | 'green' | 'blue'

編寫packages/Tag/index.tsx

import clsx from 'clsx'
import { forwardRef } from 'react'
import './style'
import { TagProps } from './interface'

const Tag: React.ForwardRefRenderFunction<HTMLDivElement, TagProps> = (
  props,
  ref,
) => {
  const { className, style, children, color, ...rest } = props

  return (
    <div
      ref={ref}
      style={style}
      {...rest}
      className={clsx(className,'s-tag', `s-tag-${color}`)}
    >
      {children}
    </div>
  )
}

const TagComponent = forwardRef<unknown, TagProps>(Tag)

TagComponent.displayName = 'Tag'

export default TagComponent
export { TagProps }

編寫 packages/Tag/style/index.less

@colors: red, orange, green, blue;

.s-tag {
  display: inline;
  padding: 2px 10px;
  each(@colors, {
    &-@{value} {
      background-color: @value;
      color: #fff;
    }
  });
}

編寫 packages/Tag/style/index.ts

import './index.less';

編寫 packages/index.tsx

export type { TagProps } from './Tag/interface'

export { default as Tag } from './Tag'

注意:此時如果我們進行打包會報錯,因為我們沒有安裝 @rollup/plugin-typescript 插件,無法打包 ts 類型,生成 d.ts 。

pnpm i @rollup/[email protected] -D    //這裡最新版本似乎有些奇怪問題,所以我們先安裝下 8.5.0 版本

vite.config.ts 引入插件

import typescript from '@rollup/plugin-typescript'

plugins: [
    react(),
    typescript({
      target: 'es5',
      rootDir: resolve('packages/'),
      declaration: true,
      declarationDir: resolve('dist'),
      exclude: resolve('node_modules/**'),
      allowSyntheticDefaultImports: true,
    }),
  ],

此時我們執行 pnpm build ,就完成了打包,生成如下目錄

image-20221202145135814

發布 npm#

但此時我們把包發布到 npm 上,用戶依舊是無法使用的,我們還需在 package.json上定義一下基礎入口信息和類型聲明:

{
  "name": "@suemor/demo-design",
  "version": "0.0.1",
  "type": "module",
  "main": "./dist/suemor.cjs",
  "module": "./dist/suemor.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/suemor.cjs",
      "import": "./dist/suemor.js"
    },
    "./style": "./dist/style.css"
  },
  "publishConfig": {
    "access": "public"
  },
    //指定你要上傳到 npm 上的文件夾
  "files": [
    "dist"
  ],
  ...
}

完成之後執行,即可發布到 npm 上。

npm publish

之後在你的其它項目中引入,即可正常顯示,且具備 TypeScript 的類型提示。

import { Tag } from "@suemor/demo-design";
import '@suemor/demo-design/style'

const App = () => {
  return (
    <div>
      <Tag color="orange">我是標籤</Tag>
    </div>
  );
};

export default App;
image-20221202151736637

自此一個簡單的組件庫主體部分開發完畢(雖然很不完善),下面引入單元測試。

添加單元測試#

我們使用 vitest 進行單元測試:

pnpm i vitest jsdom @testing-library/react -D

打開 vite.config.ts文件,在文件第一行添加類型聲明,並在defineConfig加幾行配置,讓 rollup處理.test文件:

/// <reference types="vitest" />

test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      reporter: [ 'text', 'json', 'html' ]
    }
  }

再打開 package.json 添加 npm 命令:

  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest"
  }

一般我們會把單測的代碼放在 __test__ 文件夾下,所以新建 packages/Tag/__test__/index.test.tsx,代碼如下:

import { describe, expect, it, vi } from 'vitest'

import { fireEvent, render, screen } from '@testing-library/react'

import { Tag, TagProps } from '../..'

const defineColor: Array<Pick<TagProps, 'color'> & { expected: string }> = [
  { color: 'red', expected: 's-tag-red' },
  { color: 'orange', expected: 's-tag-orange' },
  { color: 'green', expected: 's-tag-green' },
  { color: 'blue', expected: 's-tag-blue' },
]

const mountTag = (props: TagProps) => {
  return render(<Tag {...props}>Hello</Tag>)
}

describe('tag click', () => {
  const handleCallback = vi.fn()
  const tag = mountTag({ onClick: handleCallback })
  it('tag click event excuted correctly', () => {
    fireEvent.click(tag.container.firstChild as HTMLDivElement)
    expect(handleCallback).toHaveBeenCalled()
  })
})

describe.each(defineColor)('Tag color test', ({ color, expected }) => {
  it('tag color', () => {
    const tag = mountTag({ color })
    const element = tag.container.firstChild as HTMLDivElement
    expect(element.classList.contains(expected)).toBeTruthy()
  })
})

執行 pnpm test即可正常單元測試。

測試用例

完整代碼#

完整代碼倉庫: https://github.com/suemor233/suemor-design-demo

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。