1.環境建置

前言

這次挑戰主要受到 30 天擁有一套自己手刻的 React UI 元件庫為你自己寫 Vue Component 的啟發!

這件事在我 2024 年剛轉職成功時就想要做做看,但總是虎頭蛇尾,這幾年的環境變化也很大,當初愛用的 CSS in JS 已經式微 QQ

這次想嘗試的方向主要有:

  1. 參考 Headless UI,不是做大而全的元件庫,而是有目的性地探索
  2. 不導入 TailwindCSS:拯救一下失憶的 CSS 基礎,不要依賴方便的 utility class
  3. 同時開發 React 與 Vue 的版本
  4. 練習無障礙
  5. 練習建置 Storybook
  6. 練習測試

看起來有點貪心,不過有前輩們的專欄與 AI 輔助開發,一步一步做出來應該不是太困難的事!


架構設計

因為是走 Monorepo 的路線,基礎樣式與純邏輯都要盡可能抽離成共同的程式碼,讓 boilerplate 收斂到 React JSX 與 Vue Template 之中。

資料夾大概會先初分成:

  • packages/core: 純 TS 邏輯層
  • packages/theme: 樣式層,存放 SCSS、CSS Variables 與 Design Tokens
  • packages/ui-react: React 元件實作層,包含 Storybook 與單元測試
  • packages/ui-vue: Vue 元件實作層,包含 Storybook 與單元測試
  • 根目錄: 工具鏈設定

根目錄設定

老樣子!先初始化:

pnpm init

然後新增 pnpm-workspace

packages:
  - 'packages/*'

先安裝 TypeScript 與 SASS 就好,接下來要先確定子專案的設定可以打通,再回頭調整設定:

pnpm add -D typescript sass

子專案

core

存放共通的 TS 邏輯,包含單元測試,所以安裝以下套件:

pnpm add -D typescript vitest tsup
{
  "name": "@my-component-lib/core",
  "version": "0.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "dev": "tsup src/index.ts --format cjs,esm --watch --dts",
    "build": "tsup src/index.ts --format cjs,esm --clean --dts",
    "test": "vitest run"
  }
}

新增範例 src/index.tssrc/index.test.ts,確認測試有通過後就算完成 core 的建置:

export const getButtonClass = (variant: 'primary' | 'secondary') => {
  const baseClass = 'btn-base';
  return `${baseClass} btn-${variant}`;
};
import { describe, expect, it } from 'vitest';
import { getButtonClass } from './index';

describe('Button Core Logic', () => {
  it('應根據 variant 回傳正確的 class', () => {
    expect(getButtonClass('primary')).toBe('btn-base btn-primary');
    expect(getButtonClass('secondary')).toBe('btn-base btn-secondary');
  });
});

theme

這層主要是放 .scss 檔案,並提供給 React 與 Vue 專案直接匯入,所以不用安裝任何東西, 匯出設定可以不寫,但是寫了的話就要設定 ./*,讓元件可以單獨匯入個別的 .scss 檔:

{
  "name": "@my-component-lib/theme",
  "version": "0.0.1",
  "private": true,
  "main": "./index.scss",
  "files": ["*.scss", "**/*.scss"],
  "exports": {
    ".": {
      "import": "./index.scss",
      "default": "./index.scss"
    },
    "./*": "./*"
  }
}

新增:

  • base/_normalize.scss
  • tokens/_variables.scss
  • components/_button.scss
// tokens/_variables.scss
:root {
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-white: #fff;
  --radius-md: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
}

// components/_button.scss
.my-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--spacing-sm) var(--spacing-md);
  color: var(--color-white);
  cursor: pointer;
  background-color: var(--color-primary);
  border: none;
  border-radius: var(--radius-md);
  transition: background-color 0.2s;

  &:hover {
    background-color: var(--color-primary-hover);
  }
}

新增 index.scss 提供元件庫設定:

@forward './tokens/variables';
@forward './base/normalize';

新增 components.scss,匯出所有元件,因為後續開發都是單獨匯入元件的 .scss 檔這步不一定要做:

@forward './components/button';

react/vue

用 Vite 建好後,在兩個專案中都安裝 core 與 theme :

{
  "dependencies": {
    "@my-component-lib/core": "workspace:*",
    "@my-component-lib/theme": "workspace:*"
	}
}

App.jsxApp.vue 中匯入 core 與 theme 的檔案來測試看看。注意 core 要先 build 過一次

import { getButtonClass } from '@my-component-lib/core';
import '@my-component-lib/theme/index.scss';
import '@my-component-lib/theme/components.scss';

function App() {
  return (
    <div>
      <h1>React 測試</h1>
      <button className={`my-button ${getButtonClass('primary')}`}>共用樣式按鈕</button>
    </div>
  );
}

export default App;
<script setup lang="ts">
  import { getButtonClass } from '@my-component-lib/core';
  import '@my-component-lib/theme/index.scss';
  import '@my-component-lib/theme/components.scss';
</script>

<template>
  <div>
    <h1>Vue 測試</h1>
    <button :class="getButtonClass('primary')" class="my-button">共用樣式按鈕</button>
  </div>
</template>

gh


Storybook

在 React 與 Vue 中都執行安裝,建置完的範例可以全部刪掉:

pnpm dlx storybook init

src/components/Button/ 新增測試用的元件 Button.{vue, jsx}Button.stories.{ts, tsx}(Vue 是 .ts):

import '@my-component-lib/theme/src/components/_button.scss';

function Button() {
  return <button className="my-button">Button</button>;
}

export default Button;
<script setup lang="ts">
  import '@my-component-lib/theme/src/components/_button.scss';
</script>

<template>
  <button class="my-button">Button</button>
</template>

Story 物件內容都一樣:

import type { Meta, StoryObj } from '@storybook/react-vite';

import Button from '../components/Button';

const meta: Meta<typeof Button> = {
  component: Button,
  title: 'Components/Button',
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Default: Story = {
  args: {},
};

接下來就不需要啟動原本的 SPA,而是 Storybook,進入點在 .storybook/preview.ts,所以要在這裡載入 index.scss

import type { Preview } from '@storybook/react-vite';
import '@my-component-lib/theme/index.scss';

const preview: Preview = {
  // 略
};

export default preview;

重新整理後 Storybook 應該都能各自展示元件了!


小結

這次的練習在架構上要留意純 TS 邏輯、樣式與框架完全分離,以及每一層子專案的設定。

雖然透過 AI 建置環境也很方便,但我覺得練習階段要大概理解每個套件的作用,減少一些未知或多餘的設定!