mcpField
F
frontend-architecture

frontend-architecture

github.com/sickn33开发49.3k81 次安装1 天前更新
代码仓库

关于 frontend-architecture

面向 React 和 React Native 前端的框架无关架构技能,定义功能模块、导入边界、样式共置及服务端状态与 UI 状态分离,不限定库或视觉设计。

主要功能

  1. 01按功能模块组织代码
  2. 02通过 barrel 暴露模块 API
  3. 03将页面与屏幕代码共置
  4. 04分离服务端状态和 UI 状态
  5. 05将样式文件与组件共置

适用场景

  1. 01构建新的 React 应用架构
  2. 02统一 Web 与原生界面的组织方式
  3. 03明确增长中代码库的模块边界

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/sickn33/agentic-awesome-skills --skill frontend-architecture

安装后在 Claude Code 中输入/frontend-architecture确认已加载。

详细介绍

前端架构(可移植、基于模块)

适用场景

当你需要为任何 React 或 React Native 前端采用可移植、与框架无关的架构风格时,使用此技能。它将应用组织成包含页面/屏幕目录的功能模块,严格区分服务端状态与 UI 状态,要求跨模块导入只能通过 barrel 文件,并将样式与组件放在一起,同时明确组件提升规则。

可移植技能——Claude Code、OpenCode、Codex、Cursor、Windsurf 等均可读取。 此技能描述的是结构和一组规则,不是组件库、状态库或视觉风格。 它具有刻意的全局性:相同的模块/页面/状态模型适用于 Next.js(App Router)、React + Vite(SPA)、Remix 和 Expo / React Native,并且兼容 任何状态管理和样式技术栈。

目标是打造一个代码库,让任何贡献者都能立即回答三个问题—— “这段代码放在哪里?”、“哪些内容可以导入哪些内容?”、“这是服务端状态还是 UI 状态?”—— 而无需询问任何人。这样的结构会让答案一目了然。


0. 五个核心理念

  1. 功能模块管理自己的领域。 每个功能都是一个自包含的 modules/{feature}/ 文件夹,包含自己的页面、组件、钩子、状态、类型,以及唯一的公共 barrel 文件。
  2. 页面/屏幕是目录,而不是文件。 路由是一个文件夹,其中将组件、样式,以及仅供该页面使用的组件/钩子放在一起。
  3. 按来源划分状态。 服务端数据存放在查询/缓存层。UI/客户端状态存放在 store 中。权威的远程状态须与本地草稿/乐观投影区分开,并进行明确的协调。
  4. 跨越边界时只能通过 barrel 文件导入。 禁止访问其他模块的内部内容;应从 @/modules/{feature} 导入,不得深入其内部。
  5. 代码是逐步提升的,而非预先放置的。 代码应尽可能保持局部化,只有在出现第二个使用方时才向外移动。

以下内容都是这五个理念的具体应用。这些内容与任何特定库都无关——请在第 4 节和第 6 节中选择你的技术栈。


1. 目录结构

不同框架的目录形状相同;只有上层路由方式不同(参见第 7 节)。

src/
├── app/ or routes/ or navigation/   ← framework routing layer (thin — see §7)
├── modules/                         ← feature modules (the heart of the app)
│   └── {feature}/
│       ├── index.ts                 ← PUBLIC BARREL — the only cross-module entry point
│       ├── README.md                ← what this module owns, its routes, its data deps
│       ├── components/              ← components reused by 2+ pages IN THIS MODULE
│       ├── pages/                   ← page/screen directories (one per route)
│       │   └── {page}/
│       │       ├── {page}.tsx               ← the page/screen component
│       │       ├── {page}.styles.ts         ← ALL styling for this page
│       │       ├── index.ts                 ← re-exports the page component
│       │       ├── components/              ← components used ONLY by this page
│       │       ├── hooks/                   ← hooks used ONLY by this page
│       │       ├── constants/
│       │       └── README.md                ← route, params, permissions, data deps
│       ├── hooks/                   ← data hooks (query/mutation) + module hooks
│       ├── stores/                  ← UI/client state store(s) — never server data
│       ├── services/                ← data-access (API calls) for this feature
│       ├── utils/                   ← pure module utilities (co-located *.test.ts)
│       ├── constants/
│       └── types/                   ← module request/response + view-model types
└── shared/                          ← cross-module building blocks
    ├── components/                  ← components used by 2+ MODULES
    ├── hooks/                       ← cross-cutting hooks
    ├── api-client/                  ← one typed client; a shared transport contract; server-only and client-safe implementations must remain separate
    ├── store/                       ← root store wiring (if your state lib needs one — see §4)
    ├── utils/                       ← formatters, cn()/clsx, helpers
    ├── constants/
    └── types/

脚手架创建时可能为空的每个文件夹都保留一个 .gitkeep,以便从第一天起就能看到目录结构。


2. 功能模块

模块是产品的一个纵向切片(例如 auth、billing、dashboard、settings)。它包含该功能所需的一切,并且只暴露经过刻意控制的少量接口。

2.1 barrel 文件(index.ts)就是契约

modules/{feature}/index.ts 是其他模块和路由层唯一可以导入的内容。它会重新导出:

  • 路由器挂载的页面/屏幕组件。
  • 其他功能确实需要的数据钩子。
  • store 钩子/切片及其公共类型。
  • 其他功能所依赖的共享常量/类型。
// CORRECT — consume the public surface
import { InvoiceListPage, useInvoiceList } from "@/modules/invoice";

// WRONG — reaching into internals couples you to private structure
import { InvoiceListPage } from "@/modules/invoice/pages/invoice-list/invoice-list";

精心维护 barrel 文件的导出内容。如果某项未导出,它就是有意设为私有的。按类别添加简短注释来归组导出项(页面、钩子、store、类型)——后续阅读者会将 barrel 文件用作模块的 API 文档。

2.2 一个模块 = 一个明确定义的功能上下文

不要创建 utils 模块或 components 模块。模块对应产品功能,而不是技术分层。技术构建模块放在 shared/ 中。

2.3 模块 README

每个模块的 README.md 都应说明:该模块负责的内容、哪些路由会渲染其页面、其数据依赖项(哪些端点/钩子),以及任何跨模块规则。这是新贡献者首先阅读的内容。

3. 页面/屏幕采用目录形式

页面是路由器挂载的路由(在 React Native 中称为“屏幕”)。页面始终是一个文件夹,而不是单独的文件——即使最初只有一个组件也是如此。这样可以为后续扩展留出空间:页面需要子组件或 Hook 时,已经有合适的位置。

pages/{page}/
├── {page}.tsx          ← the page/screen component
├── {page}.styles.ts    ← every style for this page (no inline styles — see §5)
├── index.ts            ← export { PageComponent } from "./{page}"
├── components/         ← used ONLY by this page
├── hooks/              ← used ONLY by this page
├── constants/
└── README.md           ← route, params, permissions, data deps

页面 README 简短且信息明确:路由路径、预期参数、所需权限/身份验证,以及页面依赖的 Hook。它是页面与应用其余部分之间的契约。

**从一开始就使用文件夹的原因:**一个最初只有单个文件的页面必然会逐渐增加子行组件、派生总计的 Hook 和样式文件。如果页面是文件,这些内容就会散落在随意的位置。如果页面是文件夹,它们就有明确的归属位置,差异也更易读。


4. 按来源拆分状态(不可妥协,与库无关)

两类状态,各有归属。混用状态是这项实践旨在防止的最常见架构问题。拆分是强制要求;库由你选择。

状态类型示例所属位置
服务端状态获取的实体、列表、聚合数据——任何由 API 管理的数据查询/缓存层(例如 TanStack Query、RTK Query、SWR、Apollo)
UI/客户端状态打开的对话框、表格筛选/排序、向导步骤、正在输入的草稿、预览切换客户端 Store(例如 Zustand、Redux Toolkit、MobX、Jotai、Valtio 或 React Context)
4.1 硬性规则(与库无关)
  • **绝不将服务端响应镜像到客户端 Store 中。**不要把获取的实体复制到 Zustand/Redux/MobX 中。查询/缓存层是服务端数据的唯一事实来源。
  • **客户端组件应优先使用数据 Hook;服务端组件和框架加载器可以通过仅限服务端的服务获取数据。**组件通过数据 Hook 读取服务端数据,通过 Store 选择器读取 UI 状态。它们不会直接调用网络客户端。
  • **绝不通过触发重新渲染的状态驱动连续变化的值。**滚动进度、指针位置、拖动偏移量应使用 ref/动画值,而不是渲染状态(渲染状态会让整个组件树每帧都重新渲染)。
  • **每个模块仅设一个 Store 边界。**无论使用哪种库,都应为每个模块设置一个内聚的 Store 单元(Zustand Hook、Redux slice、MobX 类或 Jotai atom 组),并通过模块入口访问。组件只订阅所需的最小状态片段,以避免不必要的重新渲染。
4.2 选择客户端状态库——结构相同,语法不同

每个项目选择一种并保持一致。每种库都能轻松遵循“每个模块一个 Store 单元”的原则。请注意接口命名约定 I:状态接口以 I 为前缀(例如 IFeatureUiState)。

Zustand — modules/{feature}/stores/{feature}.store.ts

import { create } from "zustand";

export interface IFeatureUiState {
  isPreviewOpen: boolean;
  filter: string;
  togglePreview: () => void;
  setFilter: (filter: string) => void;
  reset: () => void;
}

const INITIAL_STATE = { isPreviewOpen: false, filter: "" } as const;

export const useFeatureUiStore = create<IFeatureUiState>()((set) => ({
  ...INITIAL_STATE,
  togglePreview: () => set((s) => ({ isPreviewOpen: !s.isPreviewOpen })),
  setFilter: (filter) => set({ filter }),
  reset: () => set({ ...INITIAL_STATE }),
}));

Redux Toolkit — modules/{feature}/stores/{feature}.slice.ts(在 shared/store/ 中注册)

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

export interface IFeatureUiState {
  isPreviewOpen: boolean;
  filter: string;
}

const initialState: IFeatureUiState = { isPreviewOpen: false, filter: "" };

export const featureUiSlice = createSlice({
  name: "featureUi",
  initialState,
  reducers: {
    togglePreview: (s) => {
      s.isPreviewOpen = !s.isPreviewOpen;
    },
    setFilter: (s, action: PayloadAction<string>) => {
      s.filter = action.payload;
    },
    reset: () => initialState,
  },
});

MobX — modules/{feature}/stores/{feature}.store.ts

import { makeAutoObservable } from "mobx";

export interface IFeatureUiState {
  isPreviewOpen: boolean;
  filter: string;
}

export class FeatureUiStore implements IFeatureUiState {
  isPreviewOpen = false;
  filter = "";
  constructor() {
    makeAutoObservable(this);
  }
  togglePreview = () => {
    this.isPreviewOpen = !this.isPreviewOpen;
  };
  setFilter = (filter: string) => {
    this.filter = filter;
  };
  reset = () => {
    this.isPreviewOpen = false;
    this.filter = "";
  };
}

Jotai — modules/{feature}/stores/{feature}.atoms.ts

import { atom } from "jotai";
export const isPreviewOpenAtom = atom(false);
export const filterAtom = atom("");

无论选择哪种库,都要遵守 §4.1 中的规则。关键在于分离服务端状态与 UI 状态,并让每个模块拥有一个 Store 单元——而不是用哪种库来实现。

4.3 数据层(服务端状态)

所有网络访问都通过 shared/api-client/ 中的一个类型化客户端。各模块将其封装为查询/变更钩子和键工厂,以确保缓存和失效处理保持一致。

// modules/invoice/hooks/invoiceKeys.ts — hierarchical key factory (TanStack Query style)
export const invoiceKeys = {
  all: ["invoices"] as const,
  lists: () => [...invoiceKeys.all, "list"] as const,
  list: (params: IListParams) => [...invoiceKeys.lists(), params] as const,
  details: () => [...invoiceKeys.all, "detail"] as const,
  detail: (id: string) => [...invoiceKeys.details(), id] as const,
} as const;

使 lists() 失效会刷新所有筛选后的页面;detail(id) 则针对单个实体。(RTK Query/SWR/Apollo 使用标签/键表达相同的概念。)组件绝不直接编写原始 fetch(),而是调用 useInvoiceList() / useCreateInvoice()。


5. 样式:就近组织,不使用内联样式(与样式库无关)

样式不要写在 JSX 中,也不要写在组件主体中。每个页面或组件都有一个与其并置的样式文件。规则始终不变;具体语法取决于你使用的样式方案。

  • Tailwind(Web): {name}.styles.ts 导出通过 cn()(clsx + tailwind-merge)组合的命名类字符串;变体使用 cva。JSX 引用 styles.header。
  • CSS Modules / vanilla-extract: 与组件并置的 {name}.module.css / {name}.css.ts;JSX 引用 styles.header。
  • styled-components / Emotion: 与组件并置的 {name}.styles.ts,用于导出样式化组件。
  • Tamagui(Web + 原生): 与组件并置的 {name}.styles.ts,用于导出 styled(...) 组件或 createStyledContext / useStyle 令牌集;引用 Tamagui 令牌($background、$space.4),绝不在内联样式中硬编码值。如果目标是用一个代码库同时支持 Web 和 React Native,推荐选择 Tamagui。
  • React Native StyleSheet / Nativewind: 与组件并置的 {name}.styles.ts,用于导出 StyleSheet.create({...})(或 Nativewind 类名)。JSX 引用 styles.header。
// invoice-list.styles.ts (Tailwind example)
export const invoiceListStyles = {
  page: "flex flex-col gap-8",
  header: "flex flex-col gap-1.5",
  title: "text-3xl font-semibold tracking-tight",
} as const;
// invoice-list.styles.ts (Tamagui example — works on web AND native)
import { styled, YStack, Text } from "tamagui";

export const InvoiceListPage = styled(YStack, { flex: 1, gap: "$8" });
export const InvoiceListHeader = styled(YStack, { gap: "$1.5" });
export const InvoiceListTitle = styled(Text, {
  fontSize: "$8",
  fontWeight: "600",
});

任何技术栈都不得在组件主体中使用内联 style={{...}} 字面量。原因:样式写在内联代码中时容易出现偏差和重复。与组件并置的样式文件可以提供一个统一位置,用于检查各界面的间距节奏、主题正确性和响应式行为。请在其中通过注释说明不明显的选择(强调色限制、断点)。

此技能不规定_视觉_设计,应与设计/组件技能搭配使用。它只规定_样式的存放位置_。


6. 命名约定

命名一致能让结构一目了然。

  • 接口以 I 为前缀——IFeatureUiState、IInvoiceListParams、IUserProfile。类型别名(联合类型、映射类型、基本类型)不加此前缀(type SortDirection = "asc" | "desc")。
  • **组件:**文件名和导出名称使用 PascalCase——InvoiceListPage.tsx、LineItemRow.tsx。
  • **页面/屏幕:**目录使用 kebab-case,组件文件名与目录匹配——pages/invoice-list/invoice-list.tsx。
  • **钩子:**使用 useCamelCase——useInvoiceList、useFeatureUiStore。
  • **状态仓库:**使用 {feature}.store.ts(Zustand/MobX)、{feature}.slice.ts(Redux)、{feature}.atoms.ts(Jotai)。钩子命名为 use{Feature}{Purpose}Store。
  • **样式:**使用与所属组件并置的 {name}.styles.ts。
  • **常量:**值使用 SCREAMING_SNAKE_CASE;文件名使用 kebab-case 或 camelCase。
  • **汇总导出文件:**始终命名为 index.ts。

7. 框架适配器

模块/页面/状态模型始终不变。只有上层的轻量路由层会变化。页面始终位于 modules/ 中;路由层只负责挂载页面。

7.1 Next.js(App Router)
  • src/app/ 存放路由段和路由组((marketing)、(app)、(public)),用于划分布局/身份验证边界。路由文件保持精简:从模块汇总导出文件导入页面组件并渲染。
  • 默认使用服务端组件;交互式叶子组件标记为 "use client"。Provider(查询客户端、状态仓库、主题)位于 "use client" 边界中。
// app/(app)/invoices/page.tsx — thin route file
import { InvoiceListPage } from "@/modules/invoice";
export default function Page() {
  return <InvoiceListPage />;
}
7.2 React + Vite(SPA)
  • 使用 src/routes/(或单个 router.tsx)声明路由表(React Router / TanStack Router),并将路径映射到模块页面组件。所有内容均在客户端运行。在应用根节点为组件树统一包装查询客户端和状态仓库/主题 Provider。
7.3 Remix
  • app/routes/ 中的路由模块保持精简,重新导出或挂载模块页面组件;loader/action 委托给模块的 services/。模块边界保持不变。
7.4 Expo / React Native
  • 路由使用 Expo Router(基于文件,位于 app/)或 React Navigation(navigation/)。路由/屏幕文件保持精简,并从模块的聚合导出文件导入屏幕组件。
  • “页面”就是“屏幕”——目录模式相同:pages/{screen}/{screen}.tsx + {screen}.styles.ts。
  • 查询层和客户端存储按原样运行(TanStack Query、Zustand、Redux、MobX、Jotai 都可在 RN 中使用)。可以共享有类型的客户端契约,但浏览器/RN 传输、存储、Cookie 和身份验证需要平台专用适配器。
  • 样式使用 Tamagui(推荐用于共享的 Web + Native)、StyleSheet 或 Nativewind。模块逻辑保持不依赖 DOM。
// app/invoices/index.tsx (Expo Router) — thin screen file
import { InvoiceListScreen } from "@/modules/invoice";
export default InvoiceListScreen;
7.5 Web + Native 之间共享

如果目标同时包含 Web 和 Expo,请将与框架无关的代码(类型、验证器、格式化器、API 客户端契约)放入由两个应用共同使用的共享包中,并优先使用 Tamagui 来实现必须在两端渲染的组件。两端的模块边界保持相同。


8. 约定检查清单(在评审中强制执行)

  • 新功能 → 新建 modules/{feature}/,包含 index.ts + README.md,不要将文件零散地放入 shared/。
  • 新路由 → 新建页面/屏幕目录({page}.tsx + {page}.styles.ts + index.ts + README.md),不要使用独立文件。
  • 跨模块导入通过聚合导出文件(@/modules/{feature})进行——不要使用深层内部路径。
  • 服务端数据位于查询/缓存层;UI 状态位于模块存储中;两者互不渗透(无论选择什么库)。
  • 组件中不使用 fetch()——只能使用基于共享客户端构建的有类型数据钩子。
  • 不使用内联样式——使用同目录的 {name}.styles.ts(Tailwind/CSS Modules/Tamagui/StyleSheet/styled-components)。
  • 组件/钩子/工具放在最小适用范围内;仅在出现第 2 个使用者时才提升作用域。
  • 每个模块一个存储单元,通过聚合导出文件访问,并提供选择器和 reset。
  • 接口使用 I 前缀;组件/钩子/文件遵循 §6。
  • 查询键/标签来自各模块的工厂;失效处理采用层级结构。
  • 路由文件保持精简——它们挂载模块页面,并且只负责布局/身份验证边界。
  • 路由、参数或数据依赖发生变化时,更新模块/页面 README。

9. 组件提升(从本地开始,逐步向外)

组件诞生于使用它的最小适用范围,只有在出现第 2 个使用者时才会提升。不要仅仅“因为它可能会复用”就预先放置组件。

使用组件的范围……所在位置导入方式
仅一个页面pages/{page}/components/页面内的相对路径
同一模块中的 2 个或更多页面modules/{feature}/components/@/modules/{feature}(通过聚合导出文件)
2 个或更多模块shared/components/@/shared/...
2 个或更多应用/仓库已发布的设计系统包包名称

同一层级规则也适用于钩子、工具和常量:本地 → 模块 → 共享 → 包。提升是有意进行的迁移(更新导入位置),而不是事先猜测。


10. 如何应用此技能

搭建新应用: 创建 src/modules/、src/shared/ 和框架路由层(§7)。添加共享的 api-client、查询层,以及你选择的客户端状态存储提供程序。将状态存储模板放入第一个模块。

添加功能: 创建 modules/{feature}/,并包含完整的子文件夹集合(pages/ components/ hooks/ stores/ services/ utils/ constants/ types/)、精心整理的 index.ts 和 README.md。将首个屏幕构建为页面目录。

决定代码放在哪里: 问“谁会使用这段代码?”→ 采用最小适用范围(§9)。问“这些数据来自哪里?”→ 服务端 = 查询层,UI = 状态存储(§4)。

审查结构: 执行 §8 中的检查清单。最值得关注的问题是状态来源泄漏(在客户端状态存储中放入服务端数据)和跨模块深层导入(绕过导出入口)——这两者会最快地侵蚀架构。


发布 / 安装此技能

此技能遵循 Anthropic 的 SKILL.md 格式,可在不同 Agent 之间移植。若要使其可安装且易于发现(例如在 skills.sh / npx skills 上):

  1. 将此文件夹放入公开的 GitHub 仓库中的 skills/ 目录下(路径类似 skills/frontend-architecture/SKILL.md)。
  2. 保留 frontmatter 中的 name 和高信号度的 description(见上文)——发现索引会根据此描述进行匹配。
  3. 在任意项目中通过以下命令安装:npx skills add <org>/<repo> --skill "frontend-architecture"。
  4. 对于非 SKILL.md Agent,可在 AGENTS.md / CLAUDE.md 中指向此文件;Kiro 可将其镜像为指导文件。

局限性

  • 仅当任务与其上游来源及本地项目上下文明确匹配时,才使用此技能。
  • 应用更改前,先验证命令、生成的代码、依赖项、凭据和外部服务行为。
  • 不要将示例视为环境专属测试、安全审查或用户批准破坏性或高成本操作的替代品。

运行时和隔离方面的例外

这些是约定,并不保证每个 React 框架的行为都完全一致。对于小任务,应保留现有的路由 / 构建约定,而不是强行进行全面重构。应将仅限服务端的导出与客户端钩子 / 组件分开:混合的导出入口可能会将凭据或服务端依赖带入客户端边界,或违反 RSC 规则。在适当情况下使用仅类型导出,并验证导入循环 / 树摇优化。应为每个请求和经过身份验证的身份分别创建 SSR 查询 / 状态存储实例,绝不可使用进程级全局用户缓存 / 状态存储;在缓存键中纳入租户 / 用户 / 权限及相关筛选条件,并在登出或身份变更时清除 / 重新验证。URL 筛选条件和隔离的组件状态不必成为全局状态存储;可编辑草稿 / 乐观更新需要明确的冲突 / 协调策略,而不是重复保存权威服务端状态。共享网络契约不能取代服务端授权或特定平台的安全凭据存储。浏览器 / RN API、无障碍、样式和导航需要分别验证;I 前缀或导出入口都不是安全边界。发布 / 安装外部技能是单独的授权操作,并非应用这些布局理念的前提。

评论

支持 Markdown