关于 frontend-architecture
面向 React 和 React Native 前端的框架无关架构技能,定义功能模块、导入边界、样式共置及服务端状态与 UI 状态分离,不限定库或视觉设计。
主要功能
- 01按功能模块组织代码
- 02通过 barrel 暴露模块 API
- 03将页面与屏幕代码共置
- 04分离服务端状态和 UI 状态
- 05将样式文件与组件共置
适用场景
- 01构建新的 React 应用架构
- 02统一 Web 与原生界面的组织方式
- 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. 五个核心理念
- 功能模块管理自己的领域。 每个功能都是一个自包含的
modules/{feature}/文件夹,包含自己的页面、组件、钩子、状态、类型,以及唯一的公共 barrel 文件。 - 页面/屏幕是目录,而不是文件。 路由是一个文件夹,其中将组件、样式,以及仅供该页面使用的组件/钩子放在一起。
- 按来源划分状态。 服务端数据存放在查询/缓存层。UI/客户端状态存放在 store 中。权威的远程状态须与本地草稿/乐观投影区分开,并进行明确的协调。
- 跨越边界时只能通过 barrel 文件导入。 禁止访问其他模块的内部内容;应从
@/modules/{feature}导入,不得深入其内部。 - 代码是逐步提升的,而非预先放置的。 代码应尽可能保持局部化,只有在出现第二个使用方时才向外移动。
以下内容都是这五个理念的具体应用。这些内容与任何特定库都无关——请在第 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 上):
- 将此文件夹放入公开的 GitHub 仓库中的
skills/目录下(路径类似skills/frontend-architecture/SKILL.md)。 - 保留 frontmatter 中的
name和高信号度的description(见上文)——发现索引会根据此描述进行匹配。 - 在任意项目中通过以下命令安装:
npx skills add <org>/<repo> --skill "frontend-architecture"。 - 对于非
SKILL.mdAgent,可在AGENTS.md/CLAUDE.md中指向此文件;Kiro 可将其镜像为指导文件。
局限性
- 仅当任务与其上游来源及本地项目上下文明确匹配时,才使用此技能。
- 应用更改前,先验证命令、生成的代码、依赖项、凭据和外部服务行为。
- 不要将示例视为环境专属测试、安全审查或用户批准破坏性或高成本操作的替代品。
运行时和隔离方面的例外
这些是约定,并不保证每个 React 框架的行为都完全一致。对于小任务,应保留现有的路由 / 构建约定,而不是强行进行全面重构。应将仅限服务端的导出与客户端钩子 / 组件分开:混合的导出入口可能会将凭据或服务端依赖带入客户端边界,或违反 RSC 规则。在适当情况下使用仅类型导出,并验证导入循环 / 树摇优化。应为每个请求和经过身份验证的身份分别创建 SSR 查询 / 状态存储实例,绝不可使用进程级全局用户缓存 / 状态存储;在缓存键中纳入租户 / 用户 / 权限及相关筛选条件,并在登出或身份变更时清除 / 重新验证。URL 筛选条件和隔离的组件状态不必成为全局状态存储;可编辑草稿 / 乐观更新需要明确的冲突 / 协调策略,而不是重复保存权威服务端状态。共享网络契约不能取代服务端授权或特定平台的安全凭据存储。浏览器 / RN API、无障碍、样式和导航需要分别验证;I 前缀或导出入口都不是安全边界。发布 / 安装外部技能是单独的授权操作,并非应用这些布局理念的前提。