mcpField
F
frontend-architecture

frontend-architecture

github.com/stareezy-1开发1040 次安装20 周前更新
代码仓库

关于 frontend-architecture

frontend-architecture 是一套跨框架前端架构技能,面向需要统一模块、页面、状态和导入边界的开发团队。它强调按功能组织代码、区分服务端与 UI 状态,并将样式与页面或组件共置。

主要功能

  1. 01按功能划分自包含模块
  2. 02将页面与专属代码组织为目录
  3. 03分离服务端状态和 UI 状态
  4. 04通过公共出口控制跨模块导入
  5. 05将样式文件与页面或组件共置

适用场景

  1. 01为新前端项目制定目录结构
  2. 02统一团队的模块边界与导入规则
  3. 03规范服务端数据和客户端状态管理
  4. 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. 五个核心理念

  1. 功能模块拥有各自的边界。 每个功能都是一个自包含的 modules/{feature}/ 文件夹,包含自己的页面、组件、钩子、状态、类型,以及唯一的公共导出入口。
  2. 页面/屏幕是目录,不是文件。 路由是一个文件夹,将组件、样式以及仅供该页面使用的组件/钩子放在一起。
  3. 按来源拆分状态。 服务端数据放在查询/缓存层。UI/客户端状态放在 store 中。两者绝不重叠,不受所选库的影响。
  4. 跨边界导入只能通过公共导出入口。 禁止访问其他模块的内部内容;只能从 @/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; 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 / useStyle token;引用 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 上):

  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 可将其镜像为 steering 文件。

评论

支持 Markdown