ui-package-standards.md 21 KB

UI包开发规范

版本信息

版本 日期 描述 作者
1.0 2025-12-03 基于史诗008经验创建UI包规范 Claude Code

概述

UI包是独立的前端模块包,用于封装特定业务功能的React组件、API客户端和状态管理逻辑。每个UI包作为一个独立的npm包发布,可以被主应用或其他UI包引用。

包结构规范

标准目录结构

packages/<module-name>-ui/
├── package.json                    # 包配置
├── tsconfig.json                   # TypeScript配置
├── vite.config.ts                  # Vite构建配置
├── src/
│   ├── index.ts                    # 主入口文件
│   ├── components/                 # React组件
│   │   ├── <ComponentName>.tsx     # 组件实现
│   │   ├── <ComponentName>.test.tsx # 组件测试
│   │   └── index.ts                # 组件导出
│   ├── api/                        # API客户端
│   │   ├── <module>Client.ts       # RPC客户端管理器
│   │   └── index.ts                # API导出
│   ├── hooks/                      # 自定义Hooks
│   │   ├── use<HookName>.ts        # Hook实现
│   │   └── index.ts                # Hook导出
│   ├── types/                      # TypeScript类型定义
│   │   ├── index.ts                # 类型导出
│   │   └── <type>.ts               # 具体类型定义
│   └── utils/                      # 工具函数
│       └── index.ts                # 工具导出
├── __tests__/                      # 测试文件
│   └── integration/                # 集成测试
└── README.md                       # 包文档

package.json配置

{
  "name": "@d8d/<module-name>-ui",
  "version": "1.0.0",
  "description": "UI包描述",
  "type": "module",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "import": "./src/index.ts",
      "require": "./src/index.ts"
    },
    "./components": {
      "types": "./src/components/index.ts",
      "import": "./src/components/index.ts",
      "require": "./src/components/index.ts"
    },
    "./hooks": {
      "types": "./src/hooks/index.ts",
      "import": "./src/hooks/index.ts",
      "require": "./src/hooks/index.ts"
    },
    "./api": {
      "types": "./src/api/index.ts",
      "import": "./src/api/index.ts",
      "require": "./src/api/index.ts"
    }
  },
  "files": [
    "src"
  ],
  "scripts": {
    "build": "unbuild",
    "dev": "tsc --watch",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint src --ext .ts,.tsx",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@d8d/shared-types": "workspace:*",
    "@d8d/shared-ui-components": "workspace:*",
    "@d8d/<module-name>-module": "workspace:*",
    "@hookform/resolvers": "^5.2.1",
    "@tanstack/react-query": "^5.90.9",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "date-fns": "^4.1.0",
    "hono": "^4.8.5",
    "lucide-react": "^0.536.0",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-hook-form": "^7.61.1",
    "sonner": "^2.0.7",
    "tailwind-merge": "^3.3.1",
    "zod": "^4.0.15"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.8.0",
    "@testing-library/react": "^16.3.0",
    "@testing-library/user-event": "^14.6.1",
    "@types/node": "^22.10.2",
    "@types/react": "^19.2.2",
    "@types/react-dom": "^19.2.3",
    "@typescript-eslint/eslint-plugin": "^8.18.1",
    "@typescript-eslint/parser": "^8.18.1",
    "eslint": "^9.17.0",
    "jsdom": "^26.0.0",
    "typescript": "^5.8.3",
    "unbuild": "^3.4.0",
    "vitest": "^4.0.9"
  },
  "peerDependencies": {
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  }
}

RPC客户端实现规范

客户端管理器模式

每个UI包必须实现一个ClientManager类来管理RPC客户端生命周期:

// src/api/<module>Client.ts
import { <module>Routes } from '@d8d/<module-name>-module';
import { rpcClient } from '@d8d/shared-ui-components/utils/hc'

export class <Module>ClientManager {
  private static instance: <Module>ClientManager;
  private client: ReturnType<typeof rpcClient<typeof <module>Routes>> | null = null;

  private constructor() {}

  public static getInstance(): <Module>ClientManager {
    if (!<Module>ClientManager.instance) {
      <Module>ClientManager.instance = new <Module>ClientManager();
    }
    return <Module>ClientManager.instance;
  }

  // 初始化客户端
  public init(baseUrl: string = '/'): ReturnType<typeof rpcClient<typeof <module>Routes>> {
    return this.client = rpcClient<typeof <module>Routes>(baseUrl);
  }

  // 获取客户端实例
  public get(): ReturnType<typeof rpcClient<typeof <module>Routes>> {
    if (!this.client) {
      return this.init()
    }
    return this.client;
  }

  // 重置客户端(用于测试或重新初始化)
  public reset(): void {
    this.client = null;
  }
}

// 导出单例实例
const <module>ClientManager = <Module>ClientManager.getInstance();

// 导出默认客户端实例(延迟初始化)
export const <module>Client = <module>ClientManager.get()

export {
  <module>ClientManager
}

API导出文件

// src/api/index.ts
export {
  <Module>ClientManager,
  <module>ClientManager,
  <module>Client
} from './<module>Client';

组件开发规范

组件结构

// src/components/<ComponentName>.tsx
import React from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { <module>ClientManager } from '../api/<module>Client';
import type { <Module>Response, <Module>SearchParams } from '../types';

interface <ComponentName>Props {
  // 组件属性定义
}

export const <ComponentName>: React.FC<<ComponentName>Props> = (props) => {
  // 使用客户端管理器获取客户端实例
  const client = <module>ClientManager.get();

  // 数据查询示例
  const { data, isLoading } = useQuery({
    queryKey: ['<module>', searchParams],
    queryFn: async () => {
      const res = await client.index.$get({
        query: {
          page: searchParams.page,
          pageSize: searchParams.limit,
          keyword: searchParams.search
        }
      });
      if (res.status !== 200) throw new Error('获取数据失败');
      return await res.json();
    }
  });

  // 数据变更示例
  const mutation = useMutation({
    mutationFn: async (data: CreateRequest) => {
      const res = await client.index.$post({ json: data });
      if (res.status !== 201) throw new Error('创建失败');
      return await res.json();
    },
    onSuccess: () => {
      // 成功处理
    },
    onError: (error) => {
      // 错误处理
    }
  });

  return (
    // 组件JSX
  );
};

export default <ComponentName>;

组件导出

// src/components/index.ts
export { <ComponentName> } from './<ComponentName>';
export type { <ComponentName>Props } from './<ComponentName>';

类型定义规范

类型文件结构

// src/types/index.ts
import type { InferResponseType, InferRequestType } from 'hono';
import type { <module>Routes } from '@d8d/<module-name>-module';
import { <module>Client } from '../api/<module>Client';

// 使用导出的client进行类型推导
export type <Module>Response = InferResponseType<typeof <module>Client.index.$get>;
export type Create<Module>Request = InferRequestType<typeof <module>Client.index.$post>;
export type Update<Module>Request = InferRequestType<typeof <module>Client[':id']['$put']>;

// 搜索参数类型
export interface <Module>SearchParams {
  page: number;
  limit: number;
  search?: string;
  // 其他搜索参数
}

// 组件属性类型
export interface <ComponentName>Props {
  // 属性定义
}

状态管理规范

React Query配置

// 在组件中使用React Query进行状态管理
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 查询示例
const { data, isLoading, error, refetch } = useQuery({
  queryKey: ['<module>', id],
  queryFn: async () => {
    const res = await client[':id'].$get({ param: { id } });
    if (res.status !== 200) throw new Error('获取详情失败');
    return await res.json();
  },
  enabled: !!id, // 条件查询
});

// 变更示例
const queryClient = useQueryClient();
const mutation = useMutation({
  mutationFn: async (data: UpdateRequest) => {
    const res = await client[':id']['$put']({
      param: { id },
      json: data
    });
    if (res.status !== 200) throw new Error('更新失败');
    return await res.json();
  },
  onSuccess: () => {
    // 使相关查询失效
    queryClient.invalidateQueries({ queryKey: ['<module>'] });
    queryClient.invalidateQueries({ queryKey: ['<module>', id] });
  }
});

测试规范

测试文件结构

packages/<module-name>-ui/
├── tests/
│   ├── integration/                    # 集成测试
│   │   └── <component-name>.integration.test.tsx
│   ├── unit/                          # 单元测试
│   │   └── <hook-name>.test.tsx
│   └── components/                    # 组件测试
│       └── <ComponentName>.test.tsx

Mock响应工具函数

// 在测试文件中使用的标准mock响应函数
const createMockResponse = (status: number, data?: any) => ({
  status,
  ok: status >= 200 && status < 300,
  body: null,
  bodyUsed: false,
  statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
  headers: new Headers(),
  url: '',
  redirected: false,
  type: 'basic' as ResponseType,
  json: async () => data || {},
  text: async () => '',
  blob: async () => new Blob(),
  arrayBuffer: async () => new ArrayBuffer(0),
  formData: async () => new FormData(),
  clone: function() { return this; }
});

组件集成测试

// tests/integration/<component-name>.integration.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { <ComponentName> } from '../../src/components/<ComponentName>';
import { <module>ClientManager, <module>Client } from '../../src/api/<module>Client';

// Mock RPC客户端
vi.mock('../../src/api/<module>Client', () => {
  const mock<Module>Client = {
    index: {
      $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
        data: [
          {
            id: 1,
            name: '测试数据',
            // 其他字段
          }
        ],
        pagination: {
          page: 1,
          pageSize: 10,
          total: 1,
          totalPages: 1
        }
      }))),
      $post: vi.fn(() => Promise.resolve(createMockResponse(201, {
        id: 2,
        name: '新建数据'
      }))),
    },
    ':id': {
      $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
        id: 1,
        name: '测试数据详情'
      }))),
      $put: vi.fn(() => Promise.resolve(createMockResponse(200, {
        id: 1,
        name: '更新后的数据'
      }))),
      $delete: vi.fn(() => Promise.resolve(createMockResponse(204))),
    },
  };

  const mock<Module>ClientManager = {
    get: vi.fn(() => mock<Module>Client),
  };

  return {
    <module>ClientManager: mock<Module>ClientManager,
    <module>Client: mock<Module>Client,
  };
});

// Mock其他依赖
vi.mock('sonner', () => ({
  toast: {
    success: vi.fn(),
    error: vi.fn(),
    info: vi.fn(),
    warning: vi.fn(),
  }
}));

describe('<ComponentName>集成测试', () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  });

  beforeEach(() => {
    vi.clearAllMocks();
    queryClient.clear();
  });

  it('渲染组件并加载数据', async () => {
    render(
      <QueryClientProvider client={queryClient}>
        <<ComponentName> />
      </QueryClientProvider>
    );

    await waitFor(() => {
      expect(screen.getByText('测试数据')).toBeInTheDocument();
    });
  });

  it('创建新数据', async () => {
    render(
      <QueryClientProvider client={queryClient}>
        <<ComponentName> />
      </QueryClientProvider>
    );

    const createButton = screen.getByText('新建');
    fireEvent.click(createButton);

    const nameInput = screen.getByLabelText('名称');
    fireEvent.change(nameInput, { target: { value: '新数据' } });

    const submitButton = screen.getByText('提交');
    fireEvent.click(submitButton);

    await waitFor(() => {
      expect(<module>Client.index.$post).toHaveBeenCalledWith({
        json: expect.objectContaining({ name: '新数据' })
      });
    });
  });
});

Hook单元测试

// tests/unit/use<HookName>.test.tsx
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { use<HookName> } from '../../src/hooks/use<HookName>';
import { <module>Client } from '../../src/api/<module>Client';

// Mock RPC客户端
vi.mock('../../src/api/<module>Client', () => {
  const mock<Module>Client = {
    index: {
      $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
        data: [{ id: 1, name: '测试数据' }],
        pagination: { total: 1 }
      }))),
    },
  };

  return {
    <module>Client: mock<Module>Client,
  };
});

describe('use<HookName>', () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  });

  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  beforeEach(() => {
    vi.clearAllMocks();
    queryClient.clear();
  });

  it('加载数据', async () => {
    const { result } = renderHook(() => use<HookName>({ page: 1, limit: 10 }), { wrapper });

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.data).toEqual([
      { id: 1, name: '测试数据' }
    ]);
    expect(<module>Client.index.$get).toHaveBeenCalledWith({
      query: { page: 1, pageSize: 10 }
    });
  });
});

组件单元测试

// tests/components/<ComponentName>.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { <ComponentName> } from '../../src/components/<ComponentName>';

// Mock子组件
vi.mock('../ChildComponent', () => ({
  ChildComponent: () => <div>Mock子组件</div>
}));

describe('<ComponentName>', () => {
  it('渲染组件', () => {
    render(<<ComponentName> />);
    expect(screen.getByText('组件标题')).toBeInTheDocument();
  });

  it('显示传入的属性', () => {
    render(<<ComponentName> name="测试名称" />);
    expect(screen.getByText('测试名称')).toBeInTheDocument();
  });
});

构建和发布规范

构建配置

UI包在PNPM工作空间中直接使用TypeScript源码,不需要构建步骤。主入口直接指向src/index.ts,TypeScript会自动处理类型检查和编译。

TypeScript配置

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2020"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

构建脚本说明

  • "build": "unbuild": 使用unbuild进行构建(可选,用于生产环境发布)
  • "dev": "tsc --watch": 开发模式下监听TypeScript文件变化
  • "typecheck": "tsc --noEmit": 类型检查,不生成输出文件

集成规范

在主应用中使用

// 主应用中的使用示例
import { <ComponentName> } from '@d8d/<module-name>-ui';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <<ComponentName> />
    </QueryClientProvider>
  );
}

环境配置

UI包应该支持以下环境变量配置:

  • VITE_API_BASE_URL: API基础URL(默认为/
  • VITE_APP_ENV: 应用环境(development/production)

错误处理规范

API错误处理

// 统一的错误处理模式
try {
  const res = await client.index.$get({ query: params });
  if (res.status !== 200) {
    const errorData = await res.json();
    throw new Error(errorData.message || '请求失败');
  }
  return await res.json();
} catch (error) {
  if (error instanceof Error) {
    toast.error(error.message);
  } else {
    toast.error('未知错误');
  }
  throw error;
}

组件错误边界

import React, { Component, ErrorInfo, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  public state: State = {
    hasError: false
  };

  public static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('UI组件错误:', error, errorInfo);
  }

  public render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>组件加载失败</div>;
    }

    return this.props.children;
  }
}

开发流程规范

1. 创建新UI包

# 复制模板
cp -r packages/template-ui packages/<module-name>-ui

# 更新包名和依赖
cd packages/<module-name>-ui
# 修改package.json中的name和dependencies

2. 开发组件

# 启动开发模式
pnpm dev

# 运行测试
pnpm test

# 类型检查
pnpm typecheck

# 代码检查
pnpm lint

3. 构建和发布

# 构建包
pnpm build

# 本地测试
# 在主应用中引用本地构建的包进行测试

# 发布到npm(由CI/CD流程处理)

参考实现

现有UI包参考

  • 广告管理UI包: packages/advertisement-management-ui

    • 组件: src/components/AdvertisementManagement.tsx
    • API客户端: src/api/advertisementClient.ts
    • 类型定义: src/types/index.ts
  • 区域管理UI包: packages/area-management-ui

    • 组件: src/components/AreaManagement.tsx
    • API客户端: src/api/areaClient.ts

关键代码片段

// RPC客户端管理器实现参考
import { advertisementRoutes } from '@d8d/advertisements-module';
import { rpcClient } from '@d8d/shared-ui-components/utils/hc'

export class AdvertisementClientManager {
  private static instance: AdvertisementClientManager;
  private client: ReturnType<typeof rpcClient<typeof advertisementRoutes>> | null = null;

  public static getInstance(): AdvertisementClientManager {
    if (!AdvertisementClientManager.instance) {
      AdvertisementClientManager.instance = new AdvertisementClientManager();
    }
    return AdvertisementClientManager.instance;
  }

  public get(): ReturnType<typeof rpcClient<typeof advertisementRoutes>> {
    if (!this.client) {
      return this.init()
    }
    return this.client;
  }
}

版本管理

版本号规则

  • 主版本号: 不兼容的API变更
  • 次版本号: 向后兼容的功能性新增
  • 修订号: 向后兼容的问题修正

变更日志

每个版本更新必须包含变更日志,记录:

  1. 新增功能
  2. 问题修复
  3. 破坏性变更
  4. 依赖更新

性能优化

代码分割

// 使用React.lazy进行代码分割
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <React.Suspense fallback={<LoadingSpinner />}>
      <LazyComponent />
    </React.Suspense>
  );
}

组件优化

  • 使用React.memo避免不必要的重渲染
  • 使用useMemouseCallback缓存计算和函数
  • 实现虚拟列表处理大量数据

安全规范

输入验证

  • 所有用户输入必须在前端进行验证
  • 使用Zod schema进行表单验证
  • 敏感数据不存储在客户端状态中

XSS防护

  • 使用React的自动转义机制
  • 避免使用dangerouslySetInnerHTML
  • 对动态内容进行清理

文档要求

每个UI包必须包含:

  1. README.md: 包概述、安装、使用示例
  2. API文档: 组件Props和API接口说明
  3. 示例代码: 完整的使用示例
  4. 变更日志: 版本更新记录

本规范基于史诗008(AllIn UI模块移植)的经验总结,确保UI包开发的一致性和可维护性。