suemor

suemor

前端萌新
telegram
github
twitter

Building a Simple Component Library with React + Vite

Introduction#

Recently, I read the documentation for Vite and found that it has a library mode which is quite convenient for packaging, so I decided to write a blog post to document the process.

Basic Configuration#

Execute the following command to create a React + TypeScript project

pnpm create vite

Delete the src and public folders, and create example and packages folders, where example stores component examples or debugging components, and packages stores component source code. Also, don't forget to modify the script path in the root directory's index.html.

├── 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>

Note: For related eslint, prettier, and tsconfig configurations, please refer to the git repository at the end; this is not the focus of this article.

Next, let's open vite.config.ts and configure the packaging (remember to install @types/node first).

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: {
    // Output folder
    outDir: 'dist',
    lib: {
      // Entry file for the component library source code
      entry: resolve('packages/index.tsx'),
      // Component library name
      name: 'demo-design',
      // File name, packaging result example: suemor.cjs
      fileName: 'suemor',
      // Packaging format
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      // Exclude unrelated dependencies
      external: ['react', 'react-dom', ...Object.keys(globals)],
    },
  },
})

At this point, if you export some code in the packages/index.tsx folder, it should be correctly packaged into CommonJS and ESM.

Component Development#

To keep it simple, we will create a Tag component that has type support and can change colors.

image-20221202141137213

Install dependencies

pnpm i less clsx -D

The following React code will not be introduced.

Create 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 Set the background color of the tag
   * @en The background color of Tag
   */
  color?: Colors
}

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

Create 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 }

Create 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;
    }
  });
}

Create packages/Tag/style/index.ts

import './index.less';

Create packages/index.tsx

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

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

Note: At this point, if we try to package, it will throw an error because we have not installed the @rollup/plugin-typescript plugin, which is needed to package TypeScript types and generate d.ts files.

pnpm i @rollup/[email protected] -D    // The latest version seems to have some strange issues, so we will install version 8.5.0 for now.

Go to vite.config.ts and import the plugin

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,
    }),
  ],

Now, when we execute pnpm build, the packaging is complete, generating the following directory

image-20221202145135814

Publish to npm#

However, at this point, if we publish the package to npm, users still won't be able to use it. We also need to define some basic entry information and type declarations in 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"
  },
    // Specify the folder you want to upload to npm
  "files": [
    "dist"
  ],
  ...
}

After completing this, execute the following command to publish to npm.

npm publish

Then, in your other projects, you can import it, and it will display correctly with TypeScript type hints.

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

const App = () => {
  return (
    <div>
      <Tag color="orange">I am a tag</Tag>
    </div>
  );
};

export default App;
image-20221202151736637

Thus, the main part of a simple component library has been developed (though it is not very complete), and now we will introduce unit testing.

Adding Unit Tests#

We will use Vitest for unit testing:

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

Open the vite.config.ts file and add type declarations at the first line of the file, and add a few lines of configuration to defineConfig to let rollup handle .test files:

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

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

Then open package.json and add npm commands:

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

Generally, we will place the test code in the __test__ folder, so create packages/Tag/__test__/index.test.tsx with the following code:

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()
  })
})

Execute pnpm test to run the unit tests successfully.

Test Cases

Complete Code#

Complete code repository: https://github.com/suemor233/suemor-design-demo

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.