为什么要谈架构?

做前端的同学可能都有这种体会:项目刚开始的时候,代码写得挺清爽的。但随着需求越堆越多、人越换越多,代码慢慢就变成了”屎山”——

  • 目录结构乱,找个文件要找半天;
  • 组件复制粘贴,改一个地方要改N处;
  • 状态满天飞,不知道数据从哪来、到哪去;
  • TypeScript 写成了 AnyScript,类型名存实亡;
  • 新人接手项目,看一周代码都不敢改。

这些问题,本质上都是架构设计的问题。项目小的时候看不出来,等项目大了、团队大了,架构的好坏直接决定了开发效率和代码质量。

我们前端团队这几年做了不少中大型的React项目,踩了很多坑,也慢慢总结出了一套比较成熟的架构规范。今天就来聊聊,React + TypeScript 项目,到底应该怎么搭。

目录结构:先把家收拾好

很多项目的目录结构是怎么来的?想到哪建到哪,缺什么加什么。结果就是目录越来越多、越来越乱。

我们的原则是:按功能组织,不是按技术类型组织。

什么意思?很多人喜欢这样组织:

src/
  components/    # 所有组件
  pages/         # 所有页面
  hooks/         # 所有hooks
  utils/         # 所有工具函数
  types/         # 所有类型定义
  api/           # 所有接口

这种结构,小项目还行,大项目就麻烦了——你要做一个功能,得在七八个目录之间来回跳。

我们推荐的是按领域/功能组织

src/
  assets/              # 静态资源
  components/          # 通用组件(跨业务复用的)
  hooks/               # 通用hooks
  utils/               # 通用工具函数
  types/               # 全局类型
  services/            # API 服务层
  store/               # 全局状态
  layouts/             # 布局组件
  pages/               # 页面
    user/              # 用户模块
      components/      # 用户模块专用组件
      hooks/           # 用户模块专用hooks
      types.ts         # 用户模块类型
      api.ts           # 用户模块接口
      index.tsx        # 用户页面
    order/             # 订单模块
      components/
      hooks/
      types.ts
      api.ts
      index.tsx
  App.tsx
  main.tsx

核心思路:跟某个功能强相关的代码,就放在那个功能的目录里。只有真正通用的、多个模块都要用的,才提到最外层。

这样做的好处:

  • 高内聚:一个功能的代码都在一起,改一个功能不用到处找文件;
  • 低耦合:模块之间界限清晰,不容易乱引用;
  • 可拆分:哪天模块太大了要拆出去,直接把整个目录搬走就行。

技术栈选型:用什么,不用什么

技术选型没有标准答案,但有一些我们的经验:

构建工具:Vite

不用多说了,Vite已经是事实标准了。开发体验比Webpack好太多,启动快、热更新快。

路由:React Router v6

React Router 还是最成熟、生态最好的路由方案。v6 的 hooks 写法也挺舒服的。

状态管理:看场景

状态管理是最容易纠结的。我们的经验是:不要上来就上 Redux,大部分项目根本不需要。

我们的选型策略:

  • 小项目:React 自带的 useState + useContext 就够了;
  • 中等项目:用 Zustand 或者 Jotai,轻量、简单、好用;
  • 大项目、复杂状态:用 Redux Toolkit,比原生 Redux 简洁太多了。

我们现在大部分项目用 Zustand,真香——API简单、代码量少、TS支持好、性能也不错。

样式方案:CSS Modules + 少量全局样式

我们试过很多方案:styled-components、emotion、Tailwind、CSS Modules……最后还是觉得 CSS Modules 最平衡:

  • 样式和组件分离,代码不混乱;
  • 天然作用域隔离,不用担心类名冲突;
  • 学习成本低,就是写普通CSS;
  • 性能好,没有运行时开销。

Tailwind 我们也用,但一般是用在快速原型或者后台管理系统里,不适合对样式定制要求高的项目。

请求库:Axios + React Query / SWR

数据请求这块,强烈推荐用 React Query(现在叫 TanStack Query)或者 SWR。

不要自己写 useEffect 去发请求、管理 loading、管理缓存——这些 React Query 都帮你做好了,而且做得比你自己写的好得多。

用了之后你会发现,项目里一大半的状态管理代码都不需要了。

组件设计:写好组件的几个原则

组件是 React 的核心。组件写得好不好,直接决定了代码质量。

我们总结了几个原则:

1. 组件要小,职责要单一

一个组件做一件事。如果一个组件代码超过300行、props超过10个,大概率是可以拆的。

拆组件的几个信号:

  • 组件里有好几个互不相关的大段 JSX;
  • 有多个独立的 state,互相之间没什么关系;
  • 某个逻辑块有自己的状态和副作用,可以独立出来。

拆完之后,每个组件都很小,逻辑清晰,也好测试、也好复用。

2. Props 设计要合理

Props 是组件的”接口”。设计得好,组件用起来舒服;设计得不好,用起来难受。

几个要点:

  • 命名要清晰:不要用缩写,不要用模糊的名字。onUserClickonClick 好,isLoadingloading 好。
  • 类型要明确:能用联合类型就不要用 boolean。比如 variant: 'primary' | 'secondary' | 'danger'isPrimary: boolean, isDanger: boolean 好得多。
  • 不要传太多 props:props 太多说明组件职责太多了,该拆了。
  • 提供合理的默认值:常用的 props 给默认值,减少使用成本。

3. 区分展示组件和容器组件

这个概念有点老了,但依然有用。

  • 展示组件:只负责渲染,不关心数据从哪来、业务逻辑是什么。props 输入,JSX 输出。纯、好测试、好复用。
  • 容器组件:负责数据获取、状态管理、业务逻辑,然后把数据传给展示组件。

当然不用严格区分,但脑子里要有这个意识——尽量把业务逻辑和渲染逻辑分开。这样展示组件可以复用,业务逻辑改的时候也不会影响渲染。

4. 组合优于继承

React 里不要搞什么组件继承,那是反模式。用组合——通过 props 传递、通过 children 嵌套、通过自定义 hook 复用逻辑。

状态管理:状态放哪?

状态管理是 React 项目里最容易搞乱的地方。很多人什么状态都往全局 store 里塞,结果全局状态越来越臃肿,改都不敢改。

我们的原则:状态尽量下放,能放在组件里就放在组件里,实在需要共享再往上提。

具体来说:

1. 组件本地状态

只有这个组件用的状态,就放在组件里,用 useState/useReducer。不要什么都往全局塞。

2. 父子组件共享状态

父子之间共享的状态,提升到共同的父组件里,通过 props 传递。

3. 跨多层组件共享状态

跨了好几层,props drilling 太麻烦了,用 Context。

4. 全局状态

真正的全局状态,比如用户信息、主题、权限,才放到全局 store 里。

5. 服务端状态

从后端拿的数据,不要放到全局 store 里管理!用 React Query / SWR 来管。它们自带缓存、自动刷新、重试、loading 状态,比你自己写强一万倍。

很多项目的全局 store 里塞了一大堆从后端拿的数据,其实完全没必要,用 React Query 就好了,能减少一大半的状态管理代码。

TypeScript:不要写成 AnyScript

用 TypeScript 不用类型,等于白用。但很多项目里,TypeScript 写成了 AnyScript——到处都是 any,类型名存实亡。

我们的几个实践:

1. 严格模式一定要开

tsconfig.jsonstrict: true 一定要开。不开严格模式,TypeScript 的威力少一半。

刚开始可能会觉得麻烦,到处报错。但习惯了之后,你会发现很多 bug 在编译阶段就被抓住了,省了大量调试时间。

2. 尽量少用 any,多用 unknown

any 是类型系统的逃生舱,用多了就等于没有类型了。

如果真的不知道类型是什么,用 unknown 而不是 any。unknown 更安全——你不能随便操作它,必须先做类型检查。

3. 善用联合类型和类型守卫

联合类型是 TypeScript 非常强大的功能。比如:

type ButtonProps = {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'sm' | 'md' | 'lg';
};

比用一堆 boolean 清晰多了,而且有类型提示,不容易写错。

再配合类型守卫,可以写出非常安全的代码:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

4. 接口定义要规范

API 返回的数据类型,一定要定义 interface。不要图省事写 any。

而且接口定义要和后端对齐,最好是有统一的地方维护,不要每个文件里自己写一套。我们一般是每个模块一个 types.ts,集中定义这个模块的类型。

5. 善用工具类型

TypeScript 内置了很多工具类型:Partial、Pick、Omit、Record、ReturnType……这些工具类型能帮你写出更简洁、更灵活的类型定义。

比如你有一个 User 类型,想做一个更新的表单,只需要部分字段:

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

type UpdateUserDto = Partial<Pick<User, 'name' | 'email' | 'age'>>;

一行搞定,不用重新写一遍。

性能优化:不要过早优化,但要知道怎么优化

性能优化这件事,不要过早优化,但要知道怎么优化

大部分时候,React 默认的性能已经够好了,不需要特意优化。但当你遇到性能问题的时候,要知道从哪下手。

几个常见的优化点:

1. 避免不必要的重渲染

这是最常见的性能问题。父组件一渲染,所有子组件都跟着渲染,即使子组件的 props 根本没变。

优化手段:

  • React.memo:包一下组件,props 没变就不重新渲染;
  • useMemo:缓存计算结果,依赖没变就不重新算;
  • useCallback:缓存函数引用,避免子组件因为函数引用变了而重渲染。

但要注意:这些优化本身也是有成本的。不要什么都 memo,大部分时候不需要。先 profiling,找到真正的瓶颈再优化。

2. 列表优化

长列表是性能重灾区。几百上千条数据,全部渲染出来,卡得要死。

优化手段:

  • 虚拟滚动:只渲染可视区域的元素。推荐用 react-window 或者 react-virtuoso。
  • key 要正确:不要用 index 当 key,要用唯一的 id。不然列表顺序变了的时候,会有很多莫名其妙的问题。

3. 懒加载

代码太大了,首屏加载慢?用懒加载:

  • 路由懒加载:React.lazy + Suspense,每个路由的代码分开打包,访问的时候再加载;
  • 组件懒加载:大组件也可以懒加载。

4. 避免在渲染里做重计算

不要在组件渲染函数里做很重的计算——每次渲染都要算一遍,很慢。

useMemo 缓存起来,依赖没变就不用重新算。

工程化:规范和工具

最后说工程化。项目大了,光靠人自觉是不行的,必须有工具和规范来保证。

1. ESLint + Prettier

代码风格和代码质量,不要靠 review 的时候说,要靠工具自动检查和修复。

ESLint 负责代码质量检查,Prettier 负责代码格式化。保存的时候自动修复,既保证了代码风格统一,又省了 review 的时候纠结格式的时间。

2. Husky + lint-staged

只在提交的时候检查代码:

  • Husky:Git hooks,在 commit 之前自动跑检查;
  • lint-staged:只检查暂存区的文件,速度快。

这样既保证了提交的代码质量,又不会因为检查太慢影响开发体验。

3. 单元测试

重要的工具函数、复杂的业务逻辑、通用组件,要写单元测试。

不用追求100%覆盖率,但核心逻辑一定要有测试。有了测试,重构的时候才有底气。

我们一般用 Vitest + React Testing Library,体验很好。

4. 提交规范

用 Conventional Commits 规范:feat、fix、docs、style、refactor、test、chore……

这样提交历史清晰,而且可以自动生成 changelog。

配合 commitlint 来检查提交信息,保证大家都按规范来。

写在最后

架构这件事,没有最好的,只有最合适的。

小项目就用简单的架构,不要搞太复杂——架构本身也是有成本的。项目大了、团队大了,再逐步完善架构。

最重要的不是用了多少先进技术,而是代码清晰、好维护、好扩展。新人来了能快速上手,需求变了能快速响应,出了问题能快速定位。

这才是好架构的标准。

希望这篇文章能给你一些参考。如果你们团队也有自己的最佳实践,欢迎交流。

Why Talk About Architecture?

Front-end developers have probably all had this experience: when a project starts, the code is nice and clean. But as requirements pile up and people come and go, the code slowly turns into a “shit mountain” —

  • Directory structure is a mess, finding a file takes forever.
  • Components are copy-pasted, changing one place means changing N places.
  • State is everywhere, you don’t know where data comes from or where it goes.
  • TypeScript becomes AnyScript, types exist in name only.
  • New people joining the project stare at the code for a week and dare not change anything.

These problems are essentially architecture design problems. You don’t notice them in small projects. But when the project grows and the team grows, good or bad architecture directly determines development efficiency and code quality.

Our front-end team has built quite a few medium-to-large React projects over the years. We stepped in many pitfalls, and gradually summarized a relatively mature set of architecture standards. Today let’s talk about how a React + TypeScript project should be structured.

Directory Structure: Tidy Up First

How do many projects end up with their directory structure? You create directories as you think of them, add things as you need them. The result: more and more directories, increasingly chaotic.

Our principle: organize by feature, not by technical type.

What does that mean? Many people like to organize like this:

src/
  components/    # all components
  pages/         # all pages
  hooks/         # all hooks
  utils/         # all utility functions
  types/         # all type definitions
  api/           # all APIs

This structure works for small projects, but for large projects it’s troublesome — to work on one feature, you have to jump between 7-8 directories.

We recommend organizing by domain/feature:

src/
  assets/              # static assets
  components/          # common components (reused across business)
  hooks/               # common hooks
  utils/               # common utilities
  types/               # global types
  services/            # API service layer
  store/               # global state
  layouts/             # layout components
  pages/               # pages
    user/              # user module
      components/      # user module specific components
      hooks/           # user module specific hooks
      types.ts         # user module types
      api.ts           # user module APIs
      index.tsx        # user page
    order/             # order module
      components/
      hooks/
      types.ts
      api.ts
      index.tsx
  App.tsx
  main.tsx

Core idea: code strongly related to a feature goes in that feature’s directory. Only truly generic code used by multiple modules gets promoted to the outer level.

Benefits of this approach:

  • High cohesion: code for one feature is all together — changing a feature doesn’t require searching everywhere for files.
  • Low coupling: clear boundaries between modules, less messy cross-references.
  • Splittable: when a module gets too big and needs to be split out, just move the whole directory.

Tech Stack Selection: What to Use, What Not to Use

There’s no standard answer for tech stack selection, but here’s some of our experience:

Build Tool: Vite

Needless to say, Vite is already the de facto standard. Development experience is so much better than Webpack — fast startup, fast HMR.

Routing: React Router v6

React Router is still the most mature, best ecosystem routing solution. v6’s hooks approach is also pretty comfortable.

State Management: Depends on the Scenario

State management is what people agonize over the most. Our experience: don’t reach for Redux right away — most projects don’t need it.

Our selection strategy:

  • Small projects: React’s built-in useState + useContext is enough.
  • Medium projects: Use Zustand or Jotai — lightweight, simple, easy to use.
  • Large projects, complex state: Use Redux Toolkit — way cleaner than vanilla Redux.

We use Zustand for most projects now — it’s great. Simple API, less code, good TS support, good performance.

Styling: CSS Modules +少量 Global Styles

We’ve tried many approaches: styled-components, emotion, Tailwind, CSS Modules… In the end we feel CSS Modules is the best balance:

  • Styles and components are separate, code doesn’t get messy.
  • Natural scope isolation, no worry about class name conflicts.
  • Low learning curve — just write normal CSS.
  • Good performance, no runtime overhead.

We also use Tailwind, but generally for rapid prototyping or admin systems — not ideal for projects with high styling customization requirements.

Data Fetching: Axios + React Query / SWR

For data fetching, we strongly recommend React Query (now called TanStack Query) or SWR.

Don’t write your own useEffect to make requests, manage loading, manage caching — React Query does all that for you, and does it better than what you’d write yourself.

After using it, you’ll find that half the state management code in your project is unnecessary.

Component Design: Principles for Writing Good Components

Components are the core of React. How well you write components directly determines code quality.

We’ve summarized several principles:

1. Components Should Be Small, Single Responsibility

One component does one thing. If a component is over 300 lines or has more than 10 props, it can probably be split.

Signals it’s time to split a component:

  • There are several unrelated large JSX blocks in the component.
  • There are multiple independent states with no relation to each other.
  • Some logic block has its own state and effects and can stand alone.

After splitting, each component is small, logic is clear, easier to test, easier to reuse.

2. Props Design Should Be Reasonable

Props are a component’s “interface.” Well designed, the component is comfortable to use; poorly designed, it’s a pain.

Key points:

  • Clear naming: No abbreviations, no vague names. onUserClick is better than onClick, isLoading is better than loading.
  • Clear types: Use union types instead of booleans when possible. For example, variant: 'primary' | 'secondary' | 'danger' is way better than isPrimary: boolean, isDanger: boolean.
  • Don’t pass too many props: Too many props means the component has too many responsibilities — time to split.
  • Provide reasonable defaults: Give common props default values to reduce usage cost.

3. Distinguish Presentational and Container Components

This concept is a bit old, but still useful.

  • Presentational components: Only responsible for rendering, don’t care where data comes from or what the business logic is. Props in, JSX out. Pure, easy to test, easy to reuse.
  • Container components: Responsible for data fetching, state management, business logic — then pass data to presentational components.

Of course you don’t have to strictly separate them, but keep this in mind — try to separate business logic from rendering logic. This way presentational components can be reused, and when business logic changes it doesn’t affect rendering.

4. Composition Over Inheritance

Don’t do component inheritance in React — that’s an anti-pattern. Use composition: pass props, nest children, reuse logic with custom hooks.

State Management: Where to Put State?

State management is the most easily messed up part of React projects. Many people stuff everything into the global store, and the global state gets more and more bloated, too scary to change.

Our principle: keep state as low as possible. If it can live in a component, keep it in the component. Only lift it up when you really need to share it.

Specifically:

1. Component Local State

State only used by this component — keep it in the component with useState/useReducer. Don’t stuff everything into global.

2. Parent-Child Shared State

State shared between parent and child — lift it up to the common parent component, pass via props.

3. Cross-Multi-Level Shared State

When it crosses multiple levels and props drilling is too painful — use Context.

4. Global State

Truly global state, like user info, theme, permissions — only then put it in the global store.

5. Server State

Data from the backend — don’t put it in the global store! Use React Query / SWR to manage it. They come with caching, auto-refresh, retries, loading states — way better than anything you’d write yourself.

Many projects’ global stores are stuffed with data from the backend, but it’s completely unnecessary. Use React Query and you can reduce half your state management code.

TypeScript: Don’t Write AnyScript

Using TypeScript without types is a waste. But in many projects, TypeScript becomes AnyScript — any everywhere, types exist in name only.

A few of our practices:

1. Always Enable Strict Mode

In tsconfig.json, strict: true must be enabled. Without strict mode, TypeScript loses half its power.

At first it might feel annoying, errors everywhere. But once you get used to it, you’ll find that many bugs are caught at compile time — saving tons of debugging time.

2. Use any Sparingly, Prefer unknown

any is the escape hatch of the type system. Use it too much and you might as well not have types.

If you really don’t know the type, use unknown instead of any. unknown is safer — you can’t just operate on it arbitrarily, you have to do type checking first.

3. Make Good Use of Union Types and Type Guards

Union types are a very powerful feature of TypeScript. For example:

type ButtonProps = {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'sm' | 'md' | 'lg';
};

Way clearer than using a bunch of booleans, and you get type hints — hard to make mistakes.

Combined with type guards, you can write very safe code:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

4. Interface Definitions Should Be Standardized

Types for API response data must have interfaces defined. Don’t be lazy and write any.

And interface definitions should align with the backend — ideally maintained in a unified place, not each file writing its own set. We generally have one types.ts per module, centrally defining types for that module.

5. Make Good Use of Utility Types

TypeScript has many built-in utility types: Partial, Pick, Omit, Record, ReturnType… These utility types help you write cleaner, more flexible type definitions.

For example, you have a User type and want an update form that only needs some fields:

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

type UpdateUserDto = Partial<Pick<User, 'name' | 'email' | 'age'>>;

One line, no need to rewrite everything.

Performance Optimization: Don’t Optimize Prematurely, But Know How

Performance optimization — don’t optimize prematurely, but know how to optimize when you need to.

Most of the time, React’s default performance is good enough — no need for special optimization. But when you encounter performance issues, you need to know where to start.

Several common optimization points:

1. Avoid Unnecessary Re-renders

This is the most common performance problem. When a parent component renders, all child components re-render too — even if their props haven’t changed.

Optimization tools:

  • React.memo: Wrap a component — if props haven’t changed, don’t re-render.
  • useMemo: Cache computation results — if dependencies haven’t changed, don’t recalculate.
  • useCallback: Cache function references — prevent child components from re-rendering because function reference changed.

But note: these optimizations themselves have costs. Don’t memo everything — most of the time you don’t need to. Profile first, find the real bottleneck, then optimize.

2. List Optimization

Long lists are performance killers. Hundreds or thousands of items, all rendered at once — super laggy.

Optimization tools:

  • Virtual scrolling: Only render elements in the visible area. We recommend react-window or react-virtuoso.
  • Correct keys: Don’t use index as key — use a unique id. Otherwise when list order changes, you get all kinds of weird issues.

3. Lazy Loading

Codebase too big, first screen slow? Use lazy loading:

  • Route lazy loading: React.lazy + Suspense — each route’s code is split into separate chunks, loaded when visited.
  • Component lazy loading: Large components can also be lazy loaded.

4. Avoid Heavy Computation During Render

Don’t do heavy computation inside the component render function — it runs every render, very slow.

Use useMemo to cache it — if dependencies haven’t changed, no need to recalculate.

Engineering: Standards and Tools

Finally, engineering. When projects get big, relying on people’s self-discipline doesn’t work — you need tools and standards to ensure quality.

1. ESLint + Prettier

Code style and code quality — don’t argue about it during review, use tools to automatically check and fix.

ESLint handles code quality checks, Prettier handles code formatting. Auto-fix on save — ensures consistent code style and saves time arguing about format during reviews.

2. Husky + lint-staged

Only check code on commit:

  • Husky: Git hooks — automatically run checks before commit.
  • lint-staged: Only check staged files — fast.

This ensures committed code quality without slowing down development with slow checks.

3. Unit Testing

Important utility functions, complex business logic, common components — write unit tests.

No need to chase 100% coverage, but core logic must have tests. With tests, you have confidence when refactoring.

We generally use Vitest + React Testing Library — great experience.

4. Commit Standards

Use Conventional Commits: feat, fix, docs, style, refactor, test, chore…

This keeps commit history clean, and you can auto-generate changelogs.

Use commitlint to check commit messages — ensure everyone follows the standard.

Closing Thoughts

When it comes to architecture, there’s no “best” — only what’s most suitable.

For small projects, use a simple architecture. Don’t overcomplicate it — architecture itself has costs. As projects and teams grow, gradually refine the architecture.

The most important thing isn’t how many advanced technologies you use — it’s code that’s clear, maintainable, and extensible. New people can get up to speed quickly, requirements can change fast, problems can be diagnosed quickly.

That’s the standard for good architecture.

Hope this article gives you some reference. If your team also has its own best practices, feel free to share.