关于 frontend-architecture
frontend-architecture 是一套跨框架前端架构技能,面向需要统一模块、页面、状态和导入边界的开发团队。它强调按功能组织代码、区分服务端与 UI 状态,并将样式与页面或组件共置。
主要功能
- 01按功能划分自包含模块
- 02将页面与专属代码组织为目录
- 03分离服务端状态和 UI 状态
- 04通过公共出口控制跨模块导入
- 05将样式文件与页面或组件共置
适用场景
- 01为新前端项目制定目录结构
- 02统一团队的模块边界与导入规则
- 03规范服务端数据和客户端状态管理
- 04整理页面样式及专属组件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ stareezy-1/ frontend-architecture-skill --skill frontend-architecture安装后在 Claude Code 中输入/frontend-architecture确认已加载。
详细介绍
前端架构(可移植、基于模块)
可移植技能 — Claude Code、OpenCode、Codex、Cursor、Windsurf 等均可读取。 本技能描述的是一种结构和一套规则,而非组件库、状态库或视觉风格。 它具有通用性:同一套模块/页面/状态模型适用于 Next.js(App Router)、React + Vite(SPA)、Remix 和 Expo / React Native,也适用于 任意状态管理和样式技术栈。
目标是打造一个代码库,让任何贡献者都能立即回答三个问题 — “这段代码放在哪里?”、“哪些内容可以导入哪些内容?”、“这是服务端状态还是 UI 状态?” — 而无需询问他人。通过这种结构,答案一目了然。
0. 五个核心理念
- 功能模块拥有各自的边界。 每个功能都是一个自包含的
modules/{feature}/文件夹,包含自己的页面、组件、钩子、状态、类型,以及唯一的公共导出入口。 - 页面/屏幕是目录,不是文件。 路由是一个文件夹,将组件、样式以及仅供该页面使用的组件/钩子放在一起。
- 按来源拆分状态。 服务端数据放在查询/缓存层。UI/客户端状态放在 store 中。两者绝不重叠,不受所选库的影响。
- 跨边界导入只能通过公共导出入口。 禁止访问其他模块的内部内容;只能从
@/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; the only place that talks to the network
├── 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 导出入口(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";
精心维护导出入口。未导出的内容都是有意设为私有的。使用简短注释对导出项分组(页面、钩子、store、类型)— 后续阅读者可以将导出入口作为模块的 API 文档。
2.2 一个模块 = 一个有界上下文
不要创建 utils 模块或 components 模块。模块应对应产品能力,而不是技术层。技术构建块放在 shared/ 中。
2.3 模块 README
每个模块的 README.md 都应说明:模块负责的内容、哪些路由会渲染其页面、数据依赖(哪些端点/钩子),以及任何跨模块规则。这是新贡献者首先阅读的内容。
3. 页面/屏幕以目录形式组织
页面是路由器挂载的路由(在 React Native 中称为“屏幕”)。它始终是一个文件夹,而不是散落的文件 — 即使最初只有一个组件。这样可以让代码在原处扩展:当页面需要子组件或钩子时,已经有了合适的位置。
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 应简短且重点明确:路由路径、预期参数、所需权限/身份验证,以及依赖的钩子。它是页面与应用其余部分之间的契约。
为什么从一开始就用文件夹: 页面起初只有一个文件,但最终必然会增加子行组件、派生总计钩子和样式文件。如果页面是一个文件,这些内容就会被放到随意的位置。如果页面是一个文件夹,它们就有明确的归属,代码差异也更易于阅读。
4. 状态:按来源拆分(不可妥协,与库无关)
两类状态,各有归属。混用它们是本规范旨在避免的最常见架构错误。必须拆分;库由你选择。
| 状态类型 | 示例 | 存放位置 |
|---|---|---|
| 服务端状态 | 获取的实体、列表、聚合数据——任何由 API 管理的数据 | 查询/缓存层(例如 TanStack Query、RTK Query、SWR、Apollo) |
| UI / 客户端状态 | 打开的对话框、表格筛选/排序、向导步骤、正在输入的草稿、预览开关 | 客户端存储(例如 Zustand、Redux Toolkit、MobX、Jotai、Valtio 或 React Context) |
4.1 硬性规则(与库无关)
- 绝不将服务端响应镜像到客户端存储中。 不要将获取的实体复制到 Zustand/Redux/MobX 中。查询/缓存层是服务端数据的唯一事实来源。
- 绝不在组件中获取数据。 组件通过数据 Hook 读取服务端数据,通过存储选择器读取 UI 状态。它们不直接调用网络客户端。
- 绝不通过触发重新渲染的状态来驱动连续变化的值。 滚动进度、指针位置、拖动偏移——使用 refs / 动画值,而非渲染状态(后者会导致整个树每帧重新渲染)。
- 每个模块设置一个存储边界。 无论使用哪个库,都应为每个模块提供一个内聚的存储单元(Zustand Hook、Redux slice、MobX 类、Jotai atom 组),并通过模块入口文件访问。组件只订阅所需的最小状态片段,避免不必要的重新渲染。
4.2 选择客户端状态库——结构相同,语法不同
每个项目选择一个库并保持一致。每个库都能清晰地实现“每个模块一个存储单元”。请注意 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 状态相互分离,且每个模块拥有一个存储单元,而不是用哪个库来构建它。
4.3 数据层(服务端状态)
所有网络访问都通过 shared/api-client/ 中的一个类型化客户端进行。各模块通过查询/变更 Hook 和键工厂对其进行封装,确保缓存与失效处理保持一致。
// 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,导出 styled 组件。 - Tamagui(Web + 原生): 就近存放的
{name}.styles.ts,导出styled(...)组件,或是一组createStyledContext/useStyletoken;引用 Tamagui token($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。 - Hook:
useCamelCase——useInvoiceList、useFeatureUiStore。 - Store:
{feature}.store.ts(Zustand/MobX)、{feature}.slice.ts(Redux)、{feature}.atoms.ts(Jotai)。Hook 为use{Feature}{Purpose}Store。 - 样式:
{name}.styles.ts,与其所有者就近存放。 - 常量:值为
SCREAMING_SNAKE_CASE;文件为kebab-case或camelCase。 - Barrel 文件:始终为
index.ts。
7. 框架适配
模块/页面/状态模型始终不变。只有上层薄薄的路由层会变化。页面始终存放在 modules/ 中;路由层只是挂载它们。
7.1 Next.js(App Router)
src/app/存放路由段和路由组((marketing)、(app)、(public)),用于布局/鉴权边界。路由文件保持精简:从模块 barrel 导入页面组件并渲染它。- 默认使用 Server Components;将交互式末梢标记为
"use client"。Provider(query client、store、theme)位于"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),并将路径映射到模块页面组件。一切都是客户端渲染。在应用根部用 query-client 和 store/theme provider 包裹一次整棵树。
7.3 Remix
app/routes/中的路由模块保持精简,并重新导出/挂载模块页面组件;loader/action 委托给模块的services/。模块边界不变。
7.4 Expo / React Native
- 路由使用 Expo Router(基于文件,位于
app/)或 React Navigation(navigation/)。路由/屏幕文件应保持轻量,并从模块 barrel 导入屏幕组件。 - “页面”即“屏幕”——采用相同的目录模式:
pages/{screen}/{screen}.tsx+{screen}.styles.ts。 - 查询层 + 客户端 store 无需改动即可运行(TanStack Query、Zustand、Redux、MobX、Jotai 在 RN 中均可使用)。带类型的
api-client是共享逻辑,按原样即可工作。 - 样式使用 Tamagui(推荐用于 Web + 原生共享)、
StyleSheet或 Nativewind。保持模块逻辑不依赖 DOM。
// app/invoices/index.tsx (Expo Router) — thin screen file
import { InvoiceListScreen } from "@/modules/invoice";
export default InvoiceListScreen;
7.5 在 Web + 原生之间共享
如果你同时面向 Web 和 Expo,请将不依赖框架的代码(类型、校验器、格式化器、API 客户端契约)推入由两个应用共同使用的共享包,并优先使用 Tamagui 来编写必须在两端渲染的组件。模块边界在两端保持一致。
8. 约定检查清单(在评审中强制执行)
- 新功能 → 新建
modules/{feature}/,包含index.ts+README.md,而不是将文件散落到shared/。 - 新路由 → 一个页面/屏幕目录(
{page}.tsx+{page}.styles.ts+index.ts+README.md),而不是一个散落的文件。 - 跨模块导入通过 barrel(
@/modules/{feature})——不要使用深层内部路径。 - 服务端数据位于查询/缓存层;UI 状态位于模块 store;两者不得相互泄漏(无论选择什么库)。
- 组件中不得使用
fetch()——只能使用基于共享客户端构建的带类型数据 hook。 - 不得使用内联样式——使用同位置的
{name}.styles.ts(Tailwind/CSS Modules/Tamagui/StyleSheet/styled-components)。 - 组件/hook/工具放在最窄的作用域;只有当出现第二个使用者时才提升。
- 每个模块一个 store 单元,通过 barrel 访问,并带有 selector 和
reset。 - 接口使用
I前缀;组件/hook/文件遵循 §6。 - 查询键/标签来自每个模块的工厂;失效按层级进行。
- 路由文件保持轻量——它们挂载模块页面,只负责布局/鉴权边界。
- 当路由、参数或数据依赖发生变化时,更新模块/页面 README。
9. 组件提升(从局部开始,向外移动)
组件诞生于使用它的最窄作用域,只有当出现第二个使用者时才提升。绝不要“因为它可能被复用”而预先放置组件。
| 组件的使用者… | 位于 | 导入方式 |
|---|---|---|
| 仅一个页面 | pages/{page}/components/ | 页面内的相对路径 |
| 一个模块中的 2+ 个页面 | modules/{feature}/components/ | @/modules/{feature}(通过 barrel) |
| 2+ 个模块 | shared/components/ | @/shared/... |
| 2+ 个应用 / 仓库 | 已发布的设计系统包 | 包名 |
同样的阶梯适用于 hook、工具和常量:局部 → 模块 → 共享 → 包。提升是一次有意的移动(更新导入位置),而不是一开始就做出的猜测。
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.md格式的 Agent,可在AGENTS.md/CLAUDE.md中指向此处;Kiro 可将其镜像为 steering 文件。