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 executed 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

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。