关注【索引目录】服务号,更多精彩内容等你来探索!
设计模式不仅仅适用于那些总是离不开架构或代码质量的后端开发者。前端开发者也可以从中受益,尤其是在 React 中,组件组合和钩子为实现可靠的 React 设计模式和改进代码架构提供了绝佳的机会。
本文涵盖了 21 种设计模式和原则,它们将使你的 React 代码更易于维护、更易于测试、更可扩展。每种模式都包含简要说明以及何时以及如何使用它们的实例。
在本文中
- React 中的核心设计模式
- 组件组合模式
- 自定义钩形图案
- 控制道具模式
- 提供者模式
- 常见的 React 设计模式
- 容器展示模式
- 复合组件模式
- 无头组件
- 原子设计模式
- 错误边界模式
- 门户模式
- 渲染道具模式
- Props Getters 模式
- 遗留模式
- 高阶组件模式(HOC)
- 通用模式
- DRY 原则
- SOLID 原则
- 依赖注入
- 关注点分离(SoC)
- MVVM
- 稳定依赖原则(SDP)
- KISS原则
- 要避免的设计模式
- 清洁架构
- 关于现代 React 模式和框架的说明
React 中的核心设计模式
这些是 React 构建所基于的基本 React 设计模式。如果您正在编写 React 代码,那么无论您是否了解,您可能已经在使用这些模式了。有时,即使我们没有读过这本书,我们也能做出一些好事。这些模式构成了我们构建的一切的基石,也是 React 的最佳实践。
Lintlemore 总是按规矩办事
组件组合模式
组件组合是 React 最基础的设计模式。它是编写 React 组件时的主要思维方式,也是你开始学习 React 时应该了解的第一个设计模式。
设计模式的理念是,应用程序并非一个庞大的单体应用,而是由数十甚至数百个组件共同协作构成。每个组件都有其存在的理由,并且可以使用或被其他一个或多个组件使用。
这里所说的组件,本质上和 React 组件是一样的,要么是组件树中的一个小叶子组件,比如一个按钮,要么是高层组件,比如整个页面。
在 Google 和 GPT 上可以找到很多关于组件组合的文章,所以我就不在这里赘述了。但在本文中,我们将看到许多 React 设计模式都遵循了这种模式,其中原子设计模式可能是最明显的一种。每个开发者的开发之旅都是从这种基本模式开始的。
何时使用:始终适用。这是 React 开发的基础。我有时看到一些 React 项目几乎不使用组件,而是使用一些占满整个页面的大型组件。这样做就是滥用 React……
自定义钩形图案
如果你接触过 React,你可能写过一些自定义钩子。这样做意味着你遵循了自定义钩子模式,这是 React 最重要的最佳实践之一。如果你没有这样做,那么你就写出了不合格的 React 代码。
自定义钩子模式意味着将 useEffects 和 useStates 等逻辑提取并封装到自定义钩子中,以抽象出 React 逻辑,从而支持可读钩子。
太多的 React 开发人员在组件中保留了过多的逻辑,导致代码重复、维护成本增加以及测试用例复杂化。遵循 React 最佳实践意味着要正确地分离关注点。将组件中的逻辑分解到钩子中几乎总是可以节省时间。
看看下面的组件,它从 API 获取数据并进行渲染。阅读并理解它需要一点时间。
const PostsComponent = () => {
const [posts, setPosts] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await fetch('/api/posts')
const data = await response.json()
setPosts(data)
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [])
if (loading) return <div>Loading...</div>
if (error) return <div>{error.message}</div>
if (!posts) return null
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
}
现在查看下面的相同组件,但组件中的逻辑已被提升到钩子上。
// The hook's logic is easy to test
const usePosts = () => {
const [posts, setPosts] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await fetch('/api/posts')
const data = await response.json()
setPosts(data)
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [])
return { posts, loading, error }
}
// Main component is much easier to read
const PostsComponent = () => {
const { posts, loading, error } = usePosts()
if (loading) return <div>Loading...</div>
if (error) return <div>{error.message}</div>
if (!posts) return null
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
}
当然,它包含相同的代码。只不过,这次 usePosts hook 的名称暗示了 useEffect 和三个 useStates 的作用,这使得阅读起来更快一些。
下次您将 usePosts 钩子重新用于另一个组件时,您甚至不必再次读取或编写该钩子中的代码。
同时,您现在有一个可重复使用的钩子,您只需测试一次,而且它实际上也非常容易测试,因为它是一个纯钩子,没有任何理由检查正在渲染的内容。
如果您测试 UI 组件,则可能也需要测试 PostsComponent 组件,并且在执行此操作时,您可以简单地模拟 usePosts 钩子的响应,而不是模拟 usePosts 钩子的内部,例如 axios 请求,这意味着现在即使是组件测试也更容易编写。
何时使用:当你拥有多个组件都需要的可复用逻辑,或者你的组件过于复杂时。可以作为一种模式,从任何组件中提取逻辑。自定义钩子是 React 中实现可维护代码的最重要概念之一。
有人说,定制钩图案起源于 Hooktail 衍生产品
控制道具模式
在 React 中,Props 总是会被传递给组件,这是不可避免的。控制 Props 模式决定了你的组件是受控的还是非受控的,它允许你选择是传递更多 props,还是使用更少 props 来构建自控组件,使其能够独立工作。
如果组件有状态,它要么是受控的,要么是不受控的,或者两者兼而有之。同时允许两者则比较棘手——你会在 UI 组件库中看到这种情况,或许你也会自己编写一些,但大多数你自己的组件要么是受控的,要么是不受控的。
// Uncontrolled - manages its own state
const UncontrolledInput = () => {
const [value, setValue] = useState('')
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}
// Controlled - state managed by parent
const ControlledInput = ({ value, onChange }) => {
return (
<input
value={value}
onChange={onChange}
/>
)
}
我个人更喜欢受控组件,因为不受控组件不那么模块化和可重用,因为它们更难以覆盖。
记住 SOLID 中的 O——组件不应该被修改,而应该可扩展。不受控制的组件不太符合这一模式。
然而,关于应该使用什么,存在不同的争论。非受控组件看起来不错,并且允许你重用组件内的代码,但这可能意味着你需要编写一个类似的新组件,而不是重用现有的组件,或者根据未来的需求进行修改。
何时使用:当需要从父组件控制组件状态时。例如,如果您有一个表单,需要读取或修改其中的字段,或者您有一个上下文菜单、下拉菜单或类似菜单,需要从其外部以编程方式关闭它们。
提供者模式
提供程序模式本质上就是 React Contexts 和 useContext。大多数主流库都使用它来处理主题、路由和全局状态管理等任务。
这是跨组件共享状态最常见的 React 模式之一。
const ThemeContext = createContext()
// Provides the theme
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider
value={{ theme, setTheme }}
>
{children}
</ThemeContext.Provider>
)
}
// Hook to use the theme
const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
// To provide the theme to the app
const App = () => {
return <ThemeProvider>
<AppContent />
</ThemeProvider>
}
这种模式在 React 开发中随处可见。React Router 使用它来设置导航上下文,styled-components 使用它来设置主题,Redux 使用它来设置全局状态。
您肯定会使用这种模式,主要是通过库,但大多数应用程序也会受益于一些定制的模式。
重要提示: Context 不适用于全局状态管理,它们会导致不必要的重新渲染。请使用 Zustand、Redux 或 Jotai 进行适当的状态管理。也不要过度使用它们作为标准的依赖注入系统。
何时使用:当你需要共享组件子树的数据而无需 prop 钻取时,或者当使用需要上下文提供程序的第三方库时。最常见的用例是集中管理很少更改的应用级配置(主题、国际化、身份验证等)。
常见的 React 设计模式
这些模式在 React 应用中非常常见,但它们通常作为架构选择而非基本要求。它们都是广泛使用的设计模式,用于构建可扩展的 React 应用并提高代码的可维护性。
容器展示模式
也称为容器组件模式,它将组件分为两类:处理逻辑和数据的容器,以及仅接受道具并呈现它们的展示组件。
容器展示模式可能是最常见的用于分离关注点的 React 设计模式。
// Presentational - only renders
const UserList = ({ users, onUserClick }) => (
<div>
{users.map(user => (
<div
key={user.id}
onClick={() => onUserClick(user)}
>
{user.name}
</div>
))}
</div>
)
// Container - handles logic and data
const UserListContainer = () => {
const { users } = useUsers()
const navigate = useNavigate()
const handleUserClick = (user) => {
navigate(`/users/${user.id}`)
}
return <UserList
users={users}
onUserClick={handleUserClick} />
}
展示组件通常是无状态且可重用的,而容器则处理与应用程序其他部分、导航、React 上下文和网络的连接。
此模式可帮助您控制哪些组件负责获取数据和执行导航,以及哪些组件可以在应用程序的任何位置自由重用。
有些人可能认为像 TanStack Query 和 SWR 这样的钩子使得这种模式变得多余,但它仍然解决了单一责任原则等重要问题。
何时使用:你可能最常考虑这种模式。你不必严格遵循它,但你需要某种模式将业务逻辑与 UI 分离,并构建可复用的组件。这是实现这一点的标准方法,以及自定义钩子模式。
Intelliton 和 Glimmabelle 可能很难区分,但一个是美貌,一个是智慧
复合组件模式
如果您在 React 中使用 UI 组件库,很可能会遇到复合组件模式。由于名称和用途相似,该模式很容易与组件组合模式混淆。但它们并不相同。
组件组合模式解释了如何将应用程序划分为更小的组件,而复合组件模式解释了如何让一组组件像单个组件一样协同工作。
// Normal modal component
<Modal
isOpen={isOpen}
title="Delete User"
body="Are you sure you want to delete this user?"
onConfirm={handleDelete}
onCancel={handleCancel}
confirmText="Delete"
cancelText="Cancel"
/>
// Modal consisting of compound components
<Modal isOpen={isOpen}>
<Modal.Header>Delete User</Modal.Header>
<Modal.Body>
Are you sure you want to delete this user?
</Modal.Body>
<Modal.Footer>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleDelete}>Delete</Button>
</Modal.Footer>
</Modal>
此模式主要是为了解决 prop 钻取的问题,以及需要添加 React 上下文来在需要协同工作的组件之间共享数据。此外,它还具有极佳的可读性。
主要思想是多个组件协同工作以实现单个实体的功能。
何时使用:在构建复杂、灵活、高度可定制且组件共享通用状态的 UI 组件时,它非常适用。虽然它有点固执己见,但它是一种非常适合组件库的设计模式。设置起来可能有点棘手,但另一方面,如果某个组件库的所有者已经为你实现了它,那就太好了。
无头组件
一些 UI 组件库(如 Radix UI 和 Ark UI)采用了无头组件模式,它们提供复杂的逻辑而没有任何样式,将视觉呈现完全交给您。
这是将逻辑与表示分离的现代 React 设计模式之一。
// Headless component of pure logic
const useDropdown = () => {
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const toggleDropdown = () => setIsOpen(!isOpen)
const selectItem = (index) => {
setSelectedIndex(index)
setIsOpen(false)
}
return {
isOpen,
selectedIndex,
toggleDropdown,
selectItem
}
}
// Stylig is handled by the consumer
const Dropdown = () => {
const {
isOpen, selectedIndex,
toggleDropdown, selectItem
} = useDropdown()
return (
<div className="dropdown">
<button onClick={toggleDropdown}>
{selectedIndex >= 0
? `Item ${selectedIndex + 1}`
: 'Select an item'
}
</button>
{isOpen && (
<ul className="menu">
<li
className="menu-item"
onClick={() => selectItem(0)}
>
Item 1
</li>
<li
className="menu-item"
onClick={() => selectItem(1)}
>
Item 2
</li>
</ul>
)}
</div>
)
}
这种模式非常适合那些对选择要锁定的 UI 库持怀疑态度的人,因为它将为您提供对复杂组件的支持,这些组件很难自己编写,而不会将您锁定在特定的样式和外观中。
无头组件让你可以完全控制样式,并将 UI 逻辑和 UI 样式的关注点分离。它非常适合 React,因为 Hooks 主要用于逻辑,而 JSX 主要用于 UI。
何时使用:当你需要复杂的组件逻辑,但希望完全控制样式时,或者构建需要与框架无关的组件库时。如果你喜欢遵循最佳实践并构建可扩展的 React 架构,那么这种设计模式非常适合你。
何时不宜使用:不要用无头模式过度设计简单的组件。这种模式适用于逻辑更复杂的组件。对于简单的按钮或开关切换来说,它完全没有必要。
Bareboneux 生活在壳里,它不在乎你把它涂成粉红色,也不在乎你戳它的尖刺
原子设计模式
原子设计模式是新兴的模式之一,其灵感来自化学,可能是因为 Web 开发还不够复杂。
它是一种创建设计系统的模式,将组件组织成清晰的层次结构:原子、分子、有机体、模板和页面。这种设计模式既易于理解,又具有很强的可扩展性。
从最小的碎片(原子)开始,将它们组合成稍大的碎片(分子),然后构建复杂的结构(有机体),最后创建完整的布局(模板和页面)。
// Atoms - basic building blocks
const Button = ({ children, onClick }) => (
<button className="btn" onClick={onClick}>{children}</button>
)
const Input = ({ value = '' }) => (
<input className="input" value={value} />
)
// Molecules - combinations of atoms
const SearchBox = ({ searchText, onSearch }) => (
<div className="search-box">
<Input value={searchText} />
<Button onClick={onSearch}>Search</Button>
</div>
)
// Organisms - complex UI components
const Header = () => (
<header className="header">
<Logo />
<SearchBox onSearch={handleSearch} />
<Navigation />
</header>
)
// Templates & Pages - full layouts
const HomePage = () => (
<div>
<Header />
<main>
<HeroSection />
<SomeContent />
</main>
<Footer />
</div>
)
此模式可帮助您系统地思考 UI 组件,并创建清晰易懂、易于维护的层次结构。对于需要在 UI 各个部分之间保持一致性的大型应用程序,它尤其有用。
关键在于,通过这种方式组织组件,可以确保你的设计系统既一致又可扩展。原子在任何地方都可以重复使用,分子以有意义的方式组合原子,生物体可以创建复杂但可重复使用的 UI 部分。
只是别太沉迷于化学的比喻。你的组件不需要有原子序数,你也绝对不需要创建一个 UI 元素周期表(虽然我见过有人尝试这么做)。
何时使用:构建需要一致设计系统的大型应用程序,或与需要清晰组件组织准则的团队合作时。即使您没有正式遵循此思维模式,我也建议您保持这种思维模式。它是 React 设计模式之一,非常适合需要可扩展设计系统的 React 应用程序。
不要被 Atomunculus 的大小所迷惑;即使是黑洞从外面看起来也是微不足道的
错误边界模式
你是否遇到过这样的情况:React 应用中一个小 bug 就导致整个应用崩溃?一个组件抛出错误,突然间你的用户就盯着空白的屏幕,怀疑是不是网络断了。
这正是错误边界要解决的问题。React 通过错误边界来捕获组件树中任意位置的 JavaScript 错误,从而防止整个应用崩溃。
错误边界看起来像这样,或者如果你使用任何钩子库。
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error) {
return { hasError: true }
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
const App = () => (
<ErrorBoundary>
<UserProfile />
<ProductList />
</ErrorBoundary>
)
错误边界仅捕获组件树中位于其下方的组件中的错误。它们不会捕获以下组件中的错误:
-
事件处理程序(改用 try-catch) -
异步代码,例如 setTimeout 和 promises -
服务器端渲染 -
在组件树之外运行的代码
对于这些情况,您将需要 try-catch 块或其他错误处理策略。
关键在于,要策略性地在应用中可能独立失败的部分周围设置错误边界。你可以在主应用周围设置一个,在用户仪表盘周围设置一个,在一些交互式 AI 聊天区域周围也设置一个。
何时使用:一种应该被更多使用的模式。我见过的大多数应用程序实际上并没有使用错误边界,但它们确实有助于构建稳定的应用程序。对于具有多个独立功能的应用程序来说,它们是完美的安全保障,可以确保整个应用程序不会因为其中一个功能失败而崩溃。
门户模式
如果您没有构建被其父容器剪切的上下文菜单或下拉菜单,或者没有解决模式出现在其他元素后面的 z-index 问题,那么您就不是真正的前端开发人员。
这正是 Portal 所要解决的问题。它提供了一种方法,可以将子组件渲染到父组件 DOM 层级结构之外的 DOM 节点中。这扇神奇的门,让你的 UI 元素脱离容器,出现在你想要的准确位置。
import { createPortal } from 'react-dom'
const Modal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<button
className="modal-close"
onClick={onClose}
>×</button>
{children}
</div>
</div>,
document.body
)
}
const App = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<div>
<button
onClick={() => setIsModalOpen(true)}
>Open Modal</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
>
<h2>Modal Content</h2>
<p>Rendered in body, not in parent</p>
</Modal>
</div>
)
}
当您需要渲染应该显示在所有内容之上的内容(例如模态框或通知)时,Portal 尤其有用。它们解决了 CSS z-index 问题,并确保您的模态框不会被使用 overflow: hidden 的父容器所覆盖。
该模式对于需要突破父级溢出约束的工具提示和下拉菜单也很有用。只需记住,即使 DOM 节点不同,React 事件冒泡仍然能够按预期工作。
除了使用门户网站,另一种选择就是摆弄 z-index 及其堆叠上下文。说实话,大多数 Web 开发者并不真正了解 z-index 的堆叠上下文是如何运作的,这也是这类问题如此常见的原因。
何时使用:构建模态框、工具提示、上下文菜单、下拉菜单等需要避开父容器 CSS 约束的组件时。这是构建叠加层和弹出窗口的常用 React 模式之一。如果您发现组件因 z-index 问题而被截断,那么门户网站或许能提供解决方案。
如果你经常使用传送门,也许有一天你会发现虚空啃噬者,但没有人会相信你
渲染道具模式
渲染属性模式 (Render Props Pattern) 指的是将一个函数作为 prop 传递,并返回 JSX 代码。你肯定会用到它,否则你可能过度使用了其他设计模式,比如复合组件模式 (Compound Components Pattern)。
乍一看感觉有点丑,但这完全正常,而且威力很大。
const TodosFetcher = ({ url, render }) => {
const [todos, setTodos] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(url)
.then(response => response.json())
.then(data => {
setTodos(data)
setLoading(false)
})
}, [url])
return render({ todos, loading })
}
const App = () => (
<TodosFetcher
url="/api/todos"
render={({ todos, loading }) => (
loading
? <div>Loading...</div>
: (
<div>
{todos.map(todo => (
<div key={todo.id}>{todo.name}</div>
))}
</div>
)
)}
/>
)
该模式允许你在组件之间共享代码,同时让用户控制渲染内容。当你有需要共享的复杂逻辑,但 UI 可能有所不同时,它尤其有用。
实际上,App 组件代码将使用其自己的组件利用适当的组件组合来创建:
const LoadingSpinner = () => <div>Loading...</div>
const TodoList = ({ todos }) => (
<div>
{todos.map(todo => (
<div key={todo.id}>{todo.name}</div>
))}
</div>
)
const App = () => (
<TodosFetcher
url="/api/todos"
render={({ todos, loading }) => (
loading
? <LoadingSpinner />
: <TodoList todos={todos} />
)}
/>
)
此示例展示了渲染属性模式如何与组件组合完美配合。每个 UI 关注点(加载、数据显示)都拥有其自身的焦点组件,从而显著提升代码的可读性和可维护性。
虽然您可以将所有这些组件放在与 App 组件相同的文件中,但更好的做法是将它们放在各自的文件中,以便更好地组织和重用。
何时使用:如果您喜欢使用它。自定义钩子、复合组件和 props getter 模式已多次取代此模式,但它仍然有其用例。
Props Getters 模式
Props getters 模式与我们刚刚读到的渲染 props 模式类似。与其传递一个渲染 UI 组件的函数,不如使用一个函数来提供组件所需的 props。
您会在 React Hook Form 等库中看到这种模式,在这些库中,您无需手动设置所有表单道具,而是获得返回完美配置的道具对象的函数。
const useToggle = (initialValue = false) => {
const [on, setOn] = useState(initialValue)
const toggle = () => setOn(!on)
const getTogglerProps = (props = {}) => ({
onClick: toggle,
'aria-pressed': on,
...props
})
const getIndicatorProps = (props = {}) => ({
children: on ? 'On' : 'Off',
...props
})
return { on, toggle, getTogglerProps, getIndicatorProps }
}
const Toggle = () => {
const { on, getTogglerProps, getIndicatorProps } = useToggle()
return (
<div>
<button {...getTogglerProps()}>
Toggle
</button>
<div {...getIndicatorProps()} />
</div>
)
}
如你所见,我们使用了一个 hook 来提供所需的数据,而不是像 TodosFetcher 那样使用组件。我们还在该 hook 中嵌入了一些 props getter,用于返回我们通常与数据一起使用的 props。
如果我们愿意,我们可以轻松地覆盖属性,但大多数情况下,我们可以简单地使用 prop getter 函数来获取 UI 元素所需的 props。
当你拥有需要管理多个相关 props 的复杂组件时,这尤其有用。消费者无需了解所有内部状态和配置,只需调用相应的 getter 函数即可获取所需的信息。
何时使用:构建需要管理复杂 prop 配置和可访问性属性的可复用组件时。不太常见,但很有用。
遗留模式
一些模式在过去在 React 中被广泛使用,但随着时间的推移已经被淘汰并被传递给其他模式。
这并不意味着它们是反模式,但它们的最佳使用日期已经到了。
高阶组件模式(HOC)
HOC 模式是 React 中提取可重用代码的旧方法,它在相当长的一段时间内是 React 最基本的设计模式之一。
在函数式组件引入 hooks 之前,它主要用于旧的类组件。在 hooks 出现之后,它确实还存在了一段时间,但现在已经很少见了。
HOC 的一个典型示例是 withAuth 钩子,它将用户注入到组件中。
const withAuth = (Component) => {
return (props) => {
const { user } = useContext(AuthContext)
if (!user) {
return <LoginComponent />
}
return <Component {...props} user={user} />
}
}
const Profile = ({ user }) => {
return <div>Welcome, {user.name}!</div>
}
// withAuth provides the user to Profile component
const ProfileWithAuth = withAuth(Profile)
如今,这个钩子通常会被替换成自定义钩子,就像这样。它更易读,也更容易测试。
const useAuth = () => {
const { user } = useContext(AuthContext)
if (!user) {
// Redirect to login
}
return { user }
}
const Profile = () => {
const { user } = useAuth()
return <div>Welcome, {user.name}!</div>
}
何时使用:旧模式 - 建议使用自定义钩子。仅在处理较旧的代码库或难以轻松转换的类组件时使用。
Overcloak 来自一个古老的时代,在那个时代,所有的学徒都服从他
通用模式
这些模式很少与 React 一起讨论,但在后端开发人员中却经常讨论,我们知道他们通常一句话都不能不提到设计模式、架构或代码质量。
然而,这绝对不意味着你不应该关注这些。如果你已经很了解 React,那么这些可能是你成为更优秀的 React 开发者最重要的模式。
现在是不是该告诉他,这个项目两个月前就被取消了?
DRY 原则
这项原则比大多数开发者意识到的要微妙得多——它并非黑白分明、可以普遍认定为好或坏的模式。虽然 DRY 原则很多时候确实很好,但过度应用它带来的问题可能比它解决的问题还要多。
一些明显的 DRY 实现候选对象包括原子组件,例如按钮、输入框和图标,它们可以在任何地方复用。诸如格式化程序、验证器和助手之类的实用函数也很理想,因为它们是解决常见问题的纯函数。当在多个位置发出相同请求时,API 集成逻辑是理想的选择;当多个组件需要相同的状态逻辑时,复杂的状态管理也是理想的选择。
但对于可能出现分歧的代码,使用此模式时要谨慎。我见过一些代码库变得难以导航,因为开发人员为只使用一次的代码创建了层层抽象。简单的解决方案变成了难以理解的接口、工厂和抽象基类迷宫,唯一的目的就是为了将来能省去几行重复的代码。
关键在于,DRY 原则应该经过深思熟虑后才应用,而非墨守成规。有时,重复实际上比错误的抽象更划算。当你有 2-4 个类似的实现时,提取通用模式通常比预先猜测抽象应该是什么样子更容易。
现代开发工具也改变了这一现状。借助人工智能驱动的代码补全和重构工具,消除重复代码可能只需一步即可完成。这意味着创建和维护 DRY 代码的成本显著降低。
何时使用:当你拥有完全相同的逻辑,且这些逻辑在多个地方使用且不太可能出现分歧时,或者当你构建可复用的实用函数和组件时。应用的核心应该尽可能保持 DRY 原则,而应用的外层如果是 WET 原则则完全没问题。因为你很可能随时都会修改这些代码。
重复数据删除可能会受益于稍微更湿的环境
SOLID 原则
SOLID 原则是一些非常适用于 React 开发的基本设计模式。虽然它们通常用于后端开发,但对于构建可维护的 React 应用程序来说,它们也极具价值。
单一职责原则(SRP)
每个组件和钩子都应该有明确的职责。当开发人员在一个庞大的组件中获取数据、处理验证、管理状态、执行计算并渲染复杂的 UI 时,他们经常违反这一原则。
这样的组件无法测试,难以复用,调试起来更是噩梦。容器组件模式和自定义钩子是解决 SRP 违规的最佳帮手。
何时使用:始终使用。这是简洁、可维护的 React 代码的基础。将复杂的组件分解成更小、更集中的部分。
开放/封闭原则(OCP)
组件应该对扩展开放,但对修改关闭。这听起来很花哨,但实际上很简单——你希望能够在不破坏现有代码的情况下添加新功能。
如果我们合理地设计组件,React 的组件组合模型可以帮助我们解决这个问题。关键在于遵循两个简单的规则:
-
倾向于组合多个小组件,而不是构建大型整体组件 -
通过 props 使常用组件可配置
第二条规则的一个例子是删除按钮的组件。这是一个普通的按钮,可能是红色的,上面有“Delete”的文字。你不需要在按钮中硬编码它的文字或颜色,而是可以传递一个 text 和 type 属性给它,这样你就可以重复使用同一个按钮作为取消或确定按钮,而无需更新按钮组件。
遵循这些规则,您将不需要经常修改旧组件,并且通常可以重用旧组件来组成新的、更高级的组件。
在未遵循这些规则的代码库中,开发人员需要不断修改已经存在的组件并重写测试,这既耗时又容易导致以前已被证明有效的旧功能出现错误。
何时使用:确保遵循 React 的组件组合模型,您将自然而然地构建可重用的组件,这些组件对于扩展开放但对于修改关闭。
里氏替换原则(LSP)
Liskov 提到,扩展或继承自其他组件的组件应该与其基础组件完全可互换。例如,如果您有一个 Button 组件,并且还有 PrimaryButton 和 SecondaryButton 等子组件,则该原则适用。Liskov 还指出,子组件至少应该采用与 Button 组件相同的 props。
这样做的实际好处是,你可以重构和交换组件,而不必担心破坏消费者的功能。在 React 中,像这样使用继承可能并不常见,因为 React 本质上使用的是组合而不是继承,但这个原则告诉我们一些关于接口的重要信息。
即使没有按钮的层级结构,您也可能拥有多个可点击的组件,例如按钮、芯片、切换按钮等等。或者一组不同的下拉组件,例如列表组件、可搜索组件、带复选框的组件等等。
如果你有类似的组件,请保持它们的界面相似,以便在需要时轻松更换。你永远不知道用户体验设计师什么时候会改变主意。
何时使用:我不建议像 LSP 那样使用实际继承。但是,当创建一组具有相似性的组件时,请确保所有组件都保持相同或相似的接口和行为。
接口隔离原则(ISP)
许多项目都有一些超级组件/钩子,它们几乎用于应用程序中的每个组件。它们有 15 多个 props,你唯一的任务就是弄清楚在你的项目中应该使用哪些,而你一天中的大部分时间都花在了如何检索所有需要的数据,甚至让它编译时不出现类型错误上。
这典型地违反了 ISP 原则,该原则规定组件不应被强制依赖它们不使用的 props。最好使用多个 hooks,并减少 props 的数量,然后根据具体情况进行组合。或者,从总体上审视整个解决方案,看看是否可以简化。很有可能有人选择了一个糟糕的架构方案,而一半的 props 可以自然地被重构掉。
何时使用:设计组件接口时,应使其集中并具体到每个组件的实际需求,避免臃肿的 prop 接口。
依赖倒置原则(DIP)
依赖倒置原则告诉我们,高级组件不应该依赖于低级实现细节。简而言之,这意味着你应该在应用程序中使用抽象进行分层,这样高级组件就不会依赖于低级功能。
例如,如果你有一个用户列表组件,那么该组件不应该包含获取用户的逻辑。相反,它应该依赖于某个为其获取用户的钩子,或者通过状态管理器或类似的工具将用户注入到组件中。或者更准确地说,依赖于一个接口,该接口定义了如何检索数据,并带有明确定义的 TypeScript 属性。
通过这种方式,您可以用 fetch 替换低级 axios 请求,或者添加像 SWR 这样的花哨钩子,而不必重构用户列表组件,因为用户列表组件仅依赖于像 useUsers 这样的抽象钩子,而不是其中具有特定于 axios 的逻辑。
何时使用:构建需要与不同数据源或实现兼容的组件时,或者希望提高代码的可测试性和灵活性时。未来某一天,你可能需要替换代码库中的低级功能。
依赖注入
虽然在 React 中不像在后端开发中那样常见,但依赖注入可以通过 Context API 和自定义钩子实现。Context API 是显而易见的选择,但钩子也可以解决部分相同的问题。
我关于SOLID React hooks的文章中也介绍了依赖注入。
何时使用:当您需要交换实现以进行测试或构建需要与不同服务或 API 协同工作的应用程序时。
关注点分离(SoC)
关注点分离是指组织代码,使不同类型的逻辑保持独立且有针对性。虽然它与单一职责原则 (SRP) 相关,但 SoC 的架构层次更高。
SRP 表示每个组件应该承担一项职责,而 SoC 表示不同类型的关注点(数据、业务逻辑、表示)应该位于具有明确界限的单独层中。
下面是一个典型的 SoC 违规行为。你其实不必读它,我会帮你省去很多麻烦。重要的是要意识到,你甚至会犹豫是否要读它。
// Bad - all concerns mixed together
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState(false)
const [formData, setFormData] = useState({})
useEffect(() => {
setLoading(true)
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(user => {
setUser(user)
setLoading(false)
})
}, [userId])
const handleSave = async () => {
const updatedUser = {
...user,
...formData,
displayName: `${formData.firstName} ${formData.lastName}`,
isActive: formData.status === 'active'
}
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(updatedUser)
})
if (response.ok) {
setUser(updatedUser)
setEditing(false)
toast.success('User updated successfully')
}
}
if (loading) return <div>Loading...</div>
if (!user) return <div>User not found</div>
return (
<div>
{editing ? (
<div>
<input
value={formData.firstName || user.firstName}
onChange={(e) => setFormData({...formData, firstName: e.target.value})}
/>
<select
value={formData.status || user.status}
onChange={(e) => setFormData({...formData, status: e.target.value})}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<button onClick={handleSave}>Save</button>
</div>
) : (
<div>
<h2>{user.displayName}</h2>
<p>Status: {user.isActive ? 'Active' : 'Inactive'}</p>
<button onClick={() => setEditing(true)}>Edit</button>
</div>
)}
</div>
)
}
这个组件负责处理所有事情:获取数据、转换数据、管理 UI 状态、处理表单逻辑以及渲染。如果其中任何一个方面发生变化,您都必须修改这个组件。
可以通过将每个关注点分离到其自己的层来应用 SoC 设计原则:
// Presentation layer - only rendering
const UserProfile = ({ userId }) => {
const { user, loading } = useUserState(userId)
if (loading) {
return <LoadingSpinner />
}
if (!user) {
return <NotFound message="User not found" />
}
return <UserDisplayCard user={user} />
}
// State management layer
const useUserState = (userId) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
const { getUser } = useUserApi()
const { transformUserForDisplay } = useUserBusinessLogic()
const loadUser = async () => {
setLoading(true)
try {
const userData = await getUser(userId)
setUser(transformUserForDisplay(userData))
} finally {
setLoading(false)
}
}
useEffect(() => {
loadUser()
}, [userId])
return { user, loading }
}
// Business logic layer - data transformations
const useUserBusinessLogic = () => {
const transformUserForDisplay = (user) => ({
...user,
displayName: `${user.firstName} ${user.lastName}`,
isActive: user.status === 'active'
})
return { transformUserForDisplay }
}
// Data layer - API communication
const useUserApi = () => {
const getUser = async (userId) => {
const response = await fetch(`/api/users/${userId}`)
return response.json()
}
return { getUser }
}
现在每一层都有明确、重点突出的职责:
- 数据层
:知道如何与 API 通信 - 业务逻辑层
:处理数据转换和业务规则 - 状态管理层
:协调数据和状态变化 - 表示层
:仅关注渲染UI
这种分离使代码更易于理解、测试和维护。您可以测试业务逻辑,而无需担心 API 调用或渲染。您可以更改 API 结构而无需触及业务逻辑。而且,您可以重新设计 UI,而不会影响数据处理。
关键在于,不同类型的逻辑会因不同的原因和不同的速度而发生变化。通过将它们分开,可以在需要更改时最大限度地减少连锁反应。
何时使用:任何重要的应用程序都应该像这样分离逻辑类型。这对于项目规模化和团队高效协作至关重要。
MVVM
MVVM(模型-视图-视图模型)在许多框架中非常流行,但在 React 中却很少被提及。然而,无论从整体上看,还是从中汲取灵感,它都非常有用。
您可以阅读我关于在 React 中实现 MVVM 的详细文章,以获得有关如何使用此模式构建更大的 React 应用程序的完整指南。
何时使用:我不认为在 React 中遵循这种模式很常见,也不建议严格遵循。但许多应用从中汲取灵感,并使用了类似的分层结构。其目标是以某种可扩展的方式分离关注点。
稳定依赖原则(SDP)
React 应用程序中总是有一些稳定且经过充分测试的组件和钩子 - SDP 表示这些应该构成我们应用程序的基础。
当然,不稳定的组件是存在的,它们经常被更新或替换。SDP 的重点在于避免其他组件依赖于这些高风险且不稳定的组件,从而避免整个应用程序因为一个看似不重要的组件的微小更改而崩溃。
例如,浏览器的内置功能很少变化——这是可以放心依赖的。React 中使用本地存储的 useLocalStorage hook 应该很少变化,也可以被认为是稳定的。
另一方面,我的同事 Brad 实现的 useStoreAnythingYouWant 钩子可能更容易改变 - 认为它是不稳定的(但不要让 Brad 知道我这么说)。
重点是,避免使用那些你知道可能会改变的东西。例如,库中的私有变量通常以下划线开头。之所以这样标记,是因为它们可能会在没有任何通知的情况下发生变化。如果你看到它,就不要使用它!
在使用 Cypress 或类似工具进行端到端测试时,请使用 data-test-id 属性,不要使用 CSS 选择器来抓取 HTML 元素或类名。CSS 类名的消失速度就像 Brad 看到有人走向乒乓球室时一样快。
何时使用:始终使用。选择稳定、完善的依赖项,而不是实验性的或频繁更改的依赖项,以避免应用程序中出现重大更改。
https://dev.to/perssondennis
KISS原则
这份清单中的最后一条原则非常简单,但如果不遵循,那就太愚蠢了。KISS 原则代表“保持简单,愚蠢”。
设计任何类型的系统时,都应该保持简洁。不要把事情搞得太复杂!保持用户界面和代码的简洁。如果你真的需要完成你想做的事情,请再三考虑。
您可能不需要添加的功能可能是价值不大但会增加应用程序复杂性的功能。或者,它可能是自定义的用户身份验证机制,而不是使用第三方提供程序进行身份验证。
或者它可能是一个带有额外配置文件和额外 100 kB 捆绑大小的库,而您实际上只使用该库中的一个或两个函数。
这种复杂性也可能体现在代码本身。一个让 React 代码变得过于复杂的完美例子就是在不需要 useEffect 的时候使用它。
const SomeComponent = () => {
const [fetchData, setFetchData] = useState(false)
const [data, setData] = useState()
useEffect(() => {
if (fetchData) {
const dataFromSomewhere = getData()
setData(dataFromSomewhere)
}
}, [fetchData])
return (
<>
<button
onClick={() => setFetchData(true)}
>Fetch data</button>
<div>{data}</div>
</>
)
}
开发者在类似上述场景中使用 useEffect 是很常见的。实际上,使用下面的简单解决方案,代码效果会更好。
const SomeComponent = () => {
const [data, setData] = useState()
const fetchData = () => {
const dataFromSomewhere = getData()
setData(dataFromSomewhere)
}
return (
<>
<button
onClick={() => fetchData()}
>Fetch data</button>
<div>{data}</div>
</>
)
}
在我关于反应式开发人员的文章中,您可以看到反应式开发人员在实际上没有必要时倾向于编写更复杂的代码,以及这样做的后果。
何时使用:始终适用。除非复杂性能够带来明显显著的益处,否则优先考虑简单直接的解决方案,而非复杂的方案。对许多人来说,这听起来可能不像是一个设计模式,但它却是最简单、最有效的设计模式之一,可以防止过度设计。
要避免的设计模式
这当然只是我的个人观点,但请理解我的观点,因为我绝对不是唯一持有这种观点的人。在为你的项目选择 React 设计模式时,了解这一点非常重要。有些自然之力应该保持其自然属性,并保留在其固有的领域中。
清洁架构
我之所以在这里列出它,是因为我见过后端开发人员在 React 中使用它。我觉得 MVVM 还行,但 Clean Architecture 对 React 来说有点太过了。
问题在于层级太多,会惹恼你的前端同事。而且,它通常纯粹抄袭了“清晰架构”(Clean Architecture),破坏了 React 的许多其他核心设计模式。
React 本身就拥有行之有效的架构模式。不要将之前工作经验中的架构模式强行应用到不合适的新代码库中。应该坚持 React 的最佳实践和常用的 React 模式。每个生态系统都有其自身的自然规律。
Modulynx 非常小,这使得她能够清洁最小的界面
关于现代 React 模式和框架的说明
如果您认为 21 种模式还不够,您可能需要继续关注,React 18 和 19 实际上带来了许多新模式。
您可能已经熟悉它们,特别是如果您使用 Next.js 或 Remix 之类的框架。
你更期待这些吗:
-
服务器组件 -
Suspense 数据获取 -
并发特性
这些模式仍在不断发展,但它们代表了 React 开发的未来方向。无论您使用的是更知名的 React
框架之一,还是纯粹使用 React Vite。
关注【索引目录】服务号,更多精彩内容等你来探索!

