React 项目架构
我使用 React 开发应用程序已经很久了,而且越来越喜欢它了。React 是一个非常棒的库,可以用来创建应用程序架构,而且它的设计也非常棒。它提供了在项目中应用基本软件原则(例如SOC、SOLID等)的机会,即使项目规模扩大也能保持代码库的整洁。尤其是在有了 Hooks 之后,它变得如此美味!
在本文中,我想讨论如何使用 React 创建项目结构和架构。您可以认为这将是一篇融合最佳实践和 React 基础知识的文章。当然,它们并非“规则”或其他内容,您可以根据自己的喜好继续,我只是想提点思路 :)
这篇文章会比较长,但我相信它会有所帮助。
此外,我将在 React Native 上提供一些示例,但您可以将其理解为 Web 上的 ReactJS 结构。
如果你准备好了,那就出发吧!🤟
我最初在 Medium 上写了这篇文章,打算分享一些文章,并翻译我以前的土耳其语文章给大家。如果你想支持我,可以在那里给我鼓掌,让我继续加油!
导航
导航是应用程序的支柱。导航越简洁、越平衡,当新的需求、新的页面出现时,集成就越容易,也就越省时地思考“在哪里以及如何实现新的更改?”。
在开发应用程序时,所有项目架构都会在设计阶段展现出来。诸如“将会有哪些屏幕?它将用于什么用途?应用程序中的页面将如何分组?”等所有问题都会得到解答;此时,您可以创建导航架构。您可以通过查看屏幕设计来创建整个架构。
如果您的应用程序包含不同用途的屏幕,您可以将它们集中在单独的 Stack 架构上。例如,如果应用程序包含个人资料、消息传递、时间线等主要模块;
- App
- ProfileStack
- MessageStack
- TimeLineStack
...
...
...
- ProfileStack
- ProfilePage
- UpdatePreferencesPage
- AddNewPhotoPage
- MessageStack
- InboxPage
- NewMessagePage
- TrashCanPage
- TimelineStack
- TimelinePage
- PostPage
- CommentsPage
- LikesPage
你可以创建一个类似的结构。
主导航栏包含“个人资料”、“消息”和“时间线”堆栈。这样,我们应用程序的主要模块就明确了,并且它们都有独立的子屏幕。
例如,MessageStack模块仅与消息传递部分相关,如果明天需要任何新屏幕,只需更新该部分即可。我们可以从任何屏幕导航到任何地方。React -navigation为我们提供了无限的自由,我们只需要做好规划。
嵌套堆叠没有限制。具有相似上下文的模块可以聚集到同一个堆栈结构中。例如,如果设置部分的通知页面包含 4 个页面中的 3 个,则可以将它们聚集在同一个堆栈中。因为在SettingsStack上看到带有NotificationPreferences、NotificationDetail、BlockedAppNotifications名称的页面并不是什么好主意。它们听起来像是需要通知堆栈。此外,像这样放置它们意味着我们将在每个新页面中实现相同的导航理念。毕竟,我们应该坚持某种开发方法,对吧?如果明天出现 10 个分页模块怎么办?
一个项目就是因为没有遵循一定的开发方式或者遵循了错误的开发方式而夭折的。
成分
当您开发模块时,复杂性结构或开放可重用性结构应该设计为单独的组件。
在使用 React 开发页面或模块时,务必考虑拆分。React 为您提供了这种机会,您应该尽可能地利用它。您当前的组件今天可能看起来很简单,您可能不会想到要拆分它,但对于之后的开发人员来说,如果继续这样开发,如果该组件增长到 200-300 行代码,修改它所花费的时间将比开发它的时间要多得多。
就像厕所一样,你应该像想要找到它一样离开它。
那么,什么时候应该划分一个部件呢?
在设计应用程序时,为了吸引眼球,我们会选择一个固定的设计原则。按钮、输入框和模态框的设计应保持一致,外观也应相似。这样,您看到的按钮就不会有十种不同的设计,而是一个按钮的十种不同变体。这就是一致性,它能够在用户的眼球记忆中建立应用程序的签名,并且在用户浏览设计时,您应该(实际上,您应该)创建一致的组件结构。
例如,如果有一个使用频率很高的按钮设计,您可以创建它的变体并将其存储在通用组件目录中。您也可以将那些在其他任何地方都没有使用但看起来可重复使用的组件存储在同一个目录中。
但是,如果某个组件只使用一个屏幕,最好将其与相关屏幕存储在同一个目录中。举个例子:如果图形和表格组件只在分析屏幕
使用,并且完全遵循分析逻辑,那么最好将它们保存在同一目录中。因为相互依赖的模块应该彼此靠近。但在这个例子中,列表模式和按钮组件可以存储在通用组件中并从那里调用。它们正是为此而创建的。
然后,我们的文件目录将会像这样;
- components
- Button
- Button.tsx
- Button.style.ts
- Button.test.tsx
- Button.stories.tsx
- index.ts
- ListModal
- ListModal.tsx
- ListModal.style.ts
- ListModal.test.tsx
- ListModal.stories.tsx
- index.ts
...
...
- pages
- Analyze
- components
- AnalyzeGraph
- AnalyzeGraph.tsx
- AnalyzeGraph.style.ts
- AnalyzeGraph.test.tsx
- AnalyzeGraph.stories.tsx
- index.ts
- AnalyzeDataTable
- AnalyzeDataTable.tsx
- AnalyzeDataTable.style.ts
- AnalyzeDataTable.test.tsx
- AnalyzeDataTable.stories.tsx
- index.ts
- Analyze.tsx
- Analyze.style.tsx
- index.ts
那。
与分析模块相关且仅为其服务的组件位于该模块附近。
注意:我认为命名时,以相关模块名称作为前缀是更好的选择。因为您可能需要在完全不同的模块上使用另一个图形和表格组件,而如果您仅使用 DataTable 作为名称,则可能会有十个不同的 DataTable 组件,并且您可能很难找到哪个组件在哪个模块上使用。
第二种方式:造型阶段
编写简洁代码的最重要基本原则是为变量和值赋予正确的名称。样式也是我们的价值观,因此也应该正确命名。在为组件编写样式时,命名越正确,编写的代码就越易于维护。因为后续开发人员可以轻松找到各个样式的对应位置。
如果你在命名样式时频繁使用相同的前缀,那么你应该将该部分视为另一个组件。
所以,如果你的UserBanner.style.ts文件如下所示:
contanier: {...},
title: {...},
inner_container: {...},
avatar_container: {...},
avatar_badge_header: {...},
avatar_title: {...},
input_label: {...},
你可能会觉得你需要一个像Avatar.tsx这样的组件。因为如果在样式阶段进行分组,就意味着一个不断发展的结构即将出现。没有必要重复三五遍才能将一个结构视为另一个组件。你可以在编码时遵循它并进行推断。
此外,没有规定所有组件都必须有逻辑。模块划分得越多,控制力就越强,编写测试的难度也就越大。
就当是一条小路的提示吧🧳
钩子
在生命周期中发挥作用并代表工作逻辑的结构应该抽象为一个钩子。
为此,他们需要有自己的逻辑,并且像定义一样,他们应该处于生命周期中。
这样做的主要原因是减轻了通用结构的工作负担,并创建了可复用的工作部件。就像我们创建自定义组件来降低代码复杂度一样,自定义钩子也可以用同样的方式创建。重要的是确保创建的结构能够正常工作。
我们如何知道我们需要一个自定义钩子?
让我们用一个例子来解释一下;
假设您需要一个项目范围内的搜索结构。您需要一个SearchBox组件,该组件可以在任何地方使用,并使用fuse.js包进行搜索操作。首先,让我们将搜索结构实现到两个示例组件中。
(我没有保留太长的代码,但你可以认为三个点部分是组件自己的部分)
function ProductPage() {
const fuse = new Fuse<Product>(data, searchOptions);
const [searchKey, setSearchKey] = useState<string>("");
const [searchResult, setSearchResult] = useState<Product[]>([]);
...
...
useEffect(() => {
if (!data) {
return;
}
if (searchKey === "" || typeof searchKey === "undefined") {
return setSearchResult([...data]);
}
const result = fuse.search(searchKey);
if (!result) {
return;
}
setSearchResult(result.map((r) => r.item));
}, [data, searchKey]);
...
...
function search(pattern: string) {
setSearchKey(pattern);
}
...
...
return (
<Layout>
<ProductSearchBox onSearch={setSearchKey} />
<ProductInfo />
...
...
<View>
<ProductDetail />
<List data={searchResult} item={ProductCard} />
</View>
...
...
</Layout>
);
}
export default ProductPage;
function MemberPage() {
const fuse = new Fuse<Member>(data, searchOptions);
const [searchKey, setSearchKey] = useState<string>("");
const [searchResult, setSearchResult] = useState<Member[]>([]);
...
...
useEffect(() => {
if (!data) {
return;
}
if (searchKey === "" || typeof searchKey === "undefined") {
return setSearchResult([...data]);
}
const result = fuse.search(searchKey);
if (!result) {
return;
}
setSearchResult(result.map((r) => r.item));
}, [data, searchKey]);
...
...
function search(pattern: string) {
setSearchKey(pattern);
}
...
...
return (
<Layout>
<MemberSearchBox onSearch={setSearchKey} />
...
...
<View>
<Header />
<List data={searchResult} item={MemberCard} />
</View>
...
...
</Layout>
);
}
export default MemberPage;
当我们检查组件时,我们注意到的主要问题是,它们实现了相同的搜索结构,并且代码重复现象清晰可见。如果一个结构上有如此多的代码重复,就意味着其中存在问题。
除此之外,当有人打开任何文件时,它都只想看到与文件名相关的代码。当您打开CommentsScreen.tsx文件时,您只希望看到与注释相关的代码,而不是任何其他分组逻辑。是的,在示例中,我们的搜索结构与Product和Member组件相关,并且它们为它们工作。但是从现在开始,它们代表了它们自己的逻辑,而且它们可以转换为可重用的结构。因此,我们需要自定义钩子或组件结构。
回到例子;搜索操作显然需要状态,并且它在生命周期中占有一席之地。当用户开始在搜索输入框中输入内容时,该字符串会存储在searchKey状态中,而当需要更新主列表时,也会进行过滤。
那么我们如何才能设计得更好呢?
我们可以将搜索结构集中在一个名为 useSearch 的钩子上。我们应该创建一个不依赖于任何模块、具有可复用结构的钩子,以便在任何地方自由使用。
因为我们将使用 fuse.js 进行搜索,所以我们可以发送数据和搜索条件作为输入,然后我们可以返回稍后将触发的搜索结果和搜索功能。
然后,我们要创建的钩子是;
interface Props<T> {
data?: Readonly<T[]>;
options?: Fuse.IFuseOptions<T>;
}
interface ReturnType<P> {
search: (s: string) => void;
result?: P[];
}
function useSearch<K>({data, options}: Props<K>): ReturnType<K> {
const fuse = new Fuse<K>(data || [], options);
const [searchKey, setSearchKey] = useState<string>('');
const [searchResult, setSearchResult] = useState<K[]>(data || []);
useEffect(() => {
if (!data) {
return;
}
if (searchKey === '' || typeof searchKey === 'undefined') {
setSearchResult([...data]);
return;
}
const result = fuse.search(searchKey);
if (!result) {
return;
}
setSearchResult(result.map(r => r.item));
}, [data, searchKey]);
function search(pattern: string) {
setSearchKey(pattern);
}
return {search, result: searchResult};
}
export default useSearch;
将会是这个。
有了 TypeScript 支持,我们的钩子可以与类型一起使用。这样,我们就可以在使用它的同时发送和接收任何类型。钩子内部的工作流程与我们之前讨论的相同,查看代码时您就会明白。
如果我们想在我们的组件上使用它;
function ProductPage() {
const {result, search} = useSearch<Product>(data, searchOptions);
...
...
return (
<Layout>
<ProductSearchBox onSearch={search} />
<ProductInfo />
...
...
<View>
<ProductDetail />
<List data={result} item={ProductCard} />
</View>
...
...
</Layout>
);
}
export default ProductPage;
function MemberPage() {
const {result, search} = useSearch<Member>(data, searchOptions);
...
...
return (
<Layout>
<MemberSearchBox onSearch={search} />
...
...
<View>
<Header />
<List data={result} item={MemberCard} />
</View>
...
...
</Layout>
);
}
export default MemberPage;
从现在可以看出,搜索结构已从组件中抽象出来。代码复杂度降低了,而且每当我们需要搜索结构时,我们都可以自定义钩子。
通过这种方式,我们创建了一个更加干净且易于测试的结构。
顺便说一句,就像我说的,钩子可以创建用于依赖上下文,也可以像组件一样用于通用用途。在那个例子中,我们创建了用于通用用途的自定义钩子,但我们也可以为特定的任务或上下文创建自定义钩子。例如,对于特定页面上的数据获取或操作,您可以创建自己的钩子,并将该任务从主组件中抽象出来。
我是说;
- hooks
- useSearch
- useSearch.ts
- useSearch.test.tsx
- index.ts
...
...
- pages
- Messages
- hooks
- useMessage
- useMessage.ts
- useMessage.test.tsx
- index.ts
- useReadStatus
- useReadStatus.tsx
- useReadStatus.test.tsx
- index.ts
- Messages.tsx
- Messages.style.tsx
- index.ts
useSearch在项目规模上使用时, useMessage负责数据获取,useReadStatus用于获取订阅者对消息的读取状态。逻辑与组件相同。
这就是 Hooks
在《代码整洁之道》这本书里,对函数的单一职责要求有很好的描述。如果我们把它用在 React 中:一个组件或一个钩子应该只做一件事,并且要做好这件事。
语境
您应该为不能直接通信但从内容上连接的模块创建不同的上下文结构。
上下文不应被视为“整个项目的包装器”。当项目复杂度增加时,与逻辑相关的结构数量也会增加,并且这些部分应该彼此独立。上下文充当这些部分之间的通信角色。例如,如果您需要在消息模块的组件和页面之间进行通信,您可以创建MessagesContext结构,并将其包装到消息模块中,从而创建独立的工作逻辑。在同一个应用程序中,如果您有一个可以查找周围朋友的Nearest模块,并且该模块包含多个工作部分,您可以创建NearestContext并将其从其他模块中抽象出来。
因此,如果我们需要一个像这样的全局结构,可以在任何地方访问;我们不能用上下文来包装主应用程序吗?
当然可以。
这就是全球状态管理的意义所在。
在这一点上,你需要注意的是不要重载 context 。你不应该只用AppContext来包装应用,并把所有状态(例如用户信息、样式主题和消息传递)都放进去。因为你已经为它们创建了工作模块,并且清楚地看到它们是不同的结构。
此外,上下文会在任何状态更新时更新与其连接的每个组件。
例如,您在AppContext上创建了成员和消息状态,并且您仅监听Profile.tsx上的成员状态和MessageList.tsx组件上的消息状态。当您收到新消息并更新消息状态时,个人资料页面也会进行更新。因为它监听AppContext,并且上下文中有一个与之相关的更新(实际上并非如此)。您认为消息和个人资料模块之间真的有关系吗?为什么当有新消息时,个人资料部分也要进行更新?这意味着不必要的刷新(渲染、更新,无论您如何命名),当它们像雪崩一样增长时,它们将导致很多性能问题。
因此,您应该为不同的工作内容创建不同的上下文,并确保整个逻辑结构的安全。更重要的是,当应用程序进入维护阶段时,负责任何模块更新的人员应该能够轻松地选择相关上下文,并轻松理解架构。实际上,在这里,整洁代码原则最基本的教材再次发挥作用:正如我们刚才提到的,正确的变量命名。
当你以正确的方式命名你的上下文时,你的结构也会保持健康。因为看到 UserContext 的人会知道它应该从这里获取或保存用户信息。它也会知道不要从 UserContext 管理与设置或消息传递相关的工作。因此,整洁代码原则非常重要。
另外,之前有用户提过一个关于 Context API 的问题,他们希望:监听 Context 状态的组件应该像 Redux 一样,只有当订阅的状态更新时才进行刷新。Dan Abramov 的这个回答其实很好地概括了 Context API 的工作逻辑。
监听上下文的组件必须需要该上下文。如果您发现从上下文中调用了不必要的状态,则意味着该状态不存在于该上下文中,或者您错误地设置了该上下文结构。这完全取决于您创建的架构。
使用 Context 时,务必确保组件确实需要调用的状态。这样可以减少出错的可能性。
举一个小例子;
[ App.tsx ]
<AppProvider> (member, memberPreferences, messages, language)
<Navigation />
</AppProvider>
如果我们分开;
[ App.tsx ]
<i18nProvider> (language)
<MemberProvider> (member, memberPreferences)
<Navigation />
</MemberProvider>
</i18nProvider>
...
...
...
[ MessageStack.tsx ]
<MessagesProvider> (messages)
<Stack.Navigator>
<Stack.Screen .../>
<Stack.Screen .../>
<Stack.Screen .../>
</Stack.Navigator>
</MessagesProvider>
那样会好得多。正如你所猜测的,我们拆分了MessagesProvider,但没有将其放在入口点。因为i18n和 Member 提供程序需要用于一般访问,而 Messages 只会用于消息范围,并且只会触发该部分的更新。所以我们可以预期消息上下文会更新消息部分,对吗?
结论
好吧,我尝试用我自己的方式稍微解释一下 React 的一些关键问题。希望这篇文章对各位读者有所帮助。
正如我上面所说,React 是一个非常优秀的库,可以用来创建这类架构。当你希望保持简洁时,它会尽可能地为你提供机会。你可以用高质量的代码库创建实用且性能良好的 Web/移动应用程序。
如果你有任何反馈,我很乐意听取。
下篇文章再见,注意安全!✌
文章来源:https://dev.to/ezranbayantemur/react-project-architecture-25m“干净的代码总是看起来像是由一个用心的人写的”
——迈克尔·费瑟斯