1.環境建置
前言
這次挑戰主要受到 30 天擁有一套自己手刻的 React UI 元件庫 與 為你自己寫 Vue Component 的啟發!
這件事在我 2024 年剛轉職成功時就想要做做看,但總是虎頭蛇尾,這幾年的環境變化也很大,當初愛用的 CSS in JS 已經式微 QQ
這次想嘗試的方向主要有:
- 參考 Headless UI,不是做大而全的元件庫,而是有目的性地探索
- 不導入 TailwindCSS:拯救一下失憶的 CSS 基礎,不要依賴方便的 utility class
- 同時開發 React 與 Vue 的版本
- 練習無障礙
- 練習建置 Storybook
- 練習測試
看起來有點貪心,不過有前輩們的專欄與 AI 輔助開發,一步一步做出來應該不是太困難的事!
架構設計
因為是走 Monorepo 的路線,基礎樣式與純邏輯都要盡可能抽離成共同的程式碼,讓 boilerplate 收斂到 React JSX 與 Vue Template 之中。
資料夾大概會先初分成:
packages/core: 純 TS 邏輯層packages/theme: 樣式層,存放 SCSS、CSS Variables 與 Design Tokenspackages/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.ts 與 src/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.scsstokens/_variables.scsscomponents/_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.jsx 與 App.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>

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 建置環境也很方便,但我覺得練習階段要大概理解每個套件的作用,減少一些未知或多餘的設定!