如何在 React 中使用 Hooks 和 TypeScript 实现 MVVM
软件应用通常起步很简单,但很快就会变得庞大。无论开发者如何努力保持代码的精简,代码最终总会发展成一个功能齐全的应用。在许多编程语言中,我们遵循诸如 MVVM 和 Clean Architecture 之类的结构化模式,但这在如今混乱的 Web 世界中却并不常见。
本文将探讨如何按照 MVVM 架构设计模式设计 React 应用,从而打造一个可扩展且易于维护的 React 应用。我们将使用 TypeScript 和函数式 React 组件,并为 ViewModel 和 Model 编写自定义 Hook。当然,稍作修改后,相同的代码也适用于 React Native。
如果您在阅读文章时有任何疑问,文章末尾附有详细的常见问题解答。
在本文中
MVVM概述
如果您不了解 MVVM,可以上网搜索一下,也可以继续阅读本文。无论哪种方式,您都应该能够理解文章内容。简而言之,MVVM 是一种设计模式,它能够清晰地分离关注点,从而简化软件应用程序的开发、测试和维护。
MVVM 的作用是将应用程序拆分为视图、视图模型和模型。其中,视图负责向用户呈现数据并捕获用户输入;视图模型充当视图和模型之间的中介;而模型则管理应用程序数据。
应用概述
本文中我们将构建的应用就是您在下方看到的这个小型应用。之所以保持小巧,是为了让我们能够专注于 MVVM 架构。该应用包含一个用于列出文章的视图,以及一个用于添加指定名称的新文章的输入框。
从代码层面来说,该应用程序包含一个用于文章视图的 React 组件,以及一些用于 ViewController、ViewModel 和 Model 的 Hooks。下图是我们即将讲解的代码架构图。暂时忽略 ViewController,因为它并非 MVVM 的正式组成部分,我们稍后会讨论它。
当然,单个视图对于一个实用的应用程序来说是不够的。如果示例规模更大,每个视图都将拥有自己的视图控制器,如下图所示。所有使用同一模型的视图控制器都将使用同一个视图模型。
随着应用程序规模的扩大,它可能会包含更多的模型和视图模型。我想你应该明白我的意思。总之,让我们开始研究应用程序的实现吧。
查看实现
在应用程序中,视图可以是屏幕上的任何屏幕或可视组件。例如,如果要构建的网站是一个博客平台,我们可以创建用于列出文章、查看单篇文章和撰写新文章的屏幕。这些屏幕可以通过诸如 ArticlesView、ArticleView 和 CreateArticleView 之类的视图来实现。
视图负责显示用户界面并根据用户交互触发操作。虽然传统的 MVVM 模式中没有视图控制器,但本 MVVM 指南将视图部分拆分为两个部分:视图和视图控制器。其中,视图组件的唯一职责是渲染用户界面,而视图控制器则处理视图的所有逻辑。
视图组件
正如我们之前看到的,经过一些 CSS 处理后,视图看起来会像这样。
为了实现视图,我们使用了一个常规的函数式 React 组件来渲染用户界面。为了避免在视图组件中处理视图逻辑,我们使用了一个视图控制器,它导出了所有可能的用户操作所需的函数。
// view/article/articles-view/ArticlesView.tsx
import React from 'react'
import useArticlesViewController from 'view/article/articles-view/useArticlesViewController'
const ArticlesView: React.FC = () => {
const {
articleName,
articles,
navigateToArticle,
onArticleNameChange,
onCreateArticleClick,
} = useArticlesViewController()
return (
<div>
{!!articles &&
articles.map(({ id, title }) => (
<div key={id} onClick={() => navigateToArticle(id)}>
{title}
</div>
))}
<div>
<input
type="text"
onChange={onArticleNameChange}
value={articleName}
placeholder="Add an article..."
/>
<button onClick={onCreateArticleClick}>Create article</button>
</div>
</div>
)
}
export default ArticlesView
上面的 ArticlesView 组件会渲染一个包含文章的视图。当用户与 UI 交互时,此视图组件会将操作转发给 useArticlesViewController 钩子中的一个函数。
由于此文件仅包含纯粹的 UI 组件,并且仅通过将用户交互转发到钩子函数来响应用户交互,因此测试和阅读都非常简单。此组件的单元测试只需测试所有文章是否已渲染以及从 useArticlesViewController 导出的函数是否已调用。useArticlesViewController 本身最好是模拟的,因为我们将单独测试该钩子函数。
要测试与视图的交互,我推荐使用React Testing Library之类的交互测试框架。你可以在我另一篇关于单元测试的文章中了解更多相关信息,包括它的优势以及与基于代码的单元测试的比较。
视图控制器钩子
View 组件中使用的 ViewController Hook 负责处理该 View 的视图逻辑。每个 View 组件都有自己的 ViewController Hook。ViewController 中包含多种类型的视图逻辑。
- 用于更新视图组件内部状态的视图逻辑(通常是 useState、useReducer 或 useRef)
- 更新应用程序状态的视图逻辑(例如,导航到其他屏幕)
- 与 ViewModel 交互的视图逻辑
通常,视图逻辑负责管理用户界面和处理用户交互。这包括维护组件的内部状态以及处理与视图模型交互的条件。通过将所有这些视图逻辑放在视图控制器中,我们就不需要在视图模型中编写任何视图逻辑了。
// view/article/articles-view/useArticlesViewController.tsx
import { useCallback, useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { HOME_SCREEN, ROOM_SCREEN } from 'routing/screens'
import useArticleViewModel from 'viewmodel/useArticleViewModel'
const useArticlesViewController = () => {
const history = useHistory()
const [articleName, setArticleName] = useState('')
const { articles, createArticle, getArticles } = useArticleViewModel()
const onCreateArticleClick = useCallback(async () => {
await createArticle({ name: articleName })
}, [createArticle, articleName])
const navigateToArticle = useCallback((articleId: number) => {
history.push(`${ROOM_SCREEN}/${articleId}`)
}, [history])
useEffect(() => {
getArticles()
}, [getArticles])
return {
articleName,
articles,
navigateToHome,
navigateToArticle,
onCreateArticleClick,
onArticleNameChange: setArticleName
}
}
export default useArticlesViewController
useArticlesViewController 比对应的 View 组件稍微复杂一些。它包含使用 React Router 在屏幕之间进行导航的代码。它还维护视图的内部状态,该状态既可用于处理内部逻辑条件,也可传递回 View 组件用于渲染。
最后,我们使用 useArticleViewModel 钩子,它会返回此视图所使用的 ViewModel。只有此视图需要使用的函数才需要从该 ViewModel 中解构。
这个文件包含比 View 组件更多的待测试逻辑。好消息是,完全不需要测试任何 UI 元素。你可以直接使用React Hooks 测试库来测试这个 hook ,无需使用任何 UI 元素匹配器,也无需检查数据是否已渲染。只需调用 hook 返回的函数并断言结果是否符合预期即可。
ViewModel 实现
ViewModel 负责处理 View 和 Model 之间的交互,充当它们之间的桥梁。通常情况下,View 直接与 ViewModel 交互,但正如我们之前看到的,我们将 View 拆分成了 View 组件和 ViewController。这意味着 ViewController 才是使用 ViewModel 的组件。
与 ViewController 一样,ViewModel 也是以钩子(hook)的形式实现的。在我们的文章示例中,它的代码量并不多。这是因为我们的应用示例根本不包含任何业务逻辑。但如果需要的话,我们会把它放在这里。
// viewmodel/useArticleViewModel.tsx
import useArticleModel from 'model/useArticleModel'
const useArticleViewModel = () => {
const { article, articles, createArticle, getArticles } = useArticleModel()
return {
article,
articles,
createArticle,
getArticles
}
}
export default useArticleViewModel
是的,就是这样。它只是通过重新导出来包装 useArticleModel hook 中的函数。如果其中一些操作需要任何业务逻辑,我们会创建一个包装函数来在这个 hook 中执行所需的计算。
那么测试呢?嗯,就目前来看,我认为没有必要测试这个文件。如果逻辑更清晰一些,你也可以在这里使用React Hooks 测试库。
模型实现
模型负责从数据源获取数据,并将其提供给视图模型。视图模型则反过来使用模型来获取和修改数据。
模型可以用多种方式编写。可以使用 Redux、Apollo、useSWR 等钩子函数。此外,数据源可能不止一个,复杂的应用程序可以从多个不同的数据源获取数据。在这种情况下,在 ViewModel 和 Model 之间放置一个 Repository 可能是一个好主意。Repository 并非 MVVM 设计模式的一部分,因此本文不会使用。
由于模型的具体实现方式可能因项目而异,本指南不会深入探讨其实现细节。相反,我们将提供一个简单的示例,该示例使用 REST API 函数来检索和发布文章。
// model/useArticleModel.tsx
import { useCallback, useState } from 'react'
import { getAllArticles, postArticle } from 'model/api/article'
import { ArticleDTO, CreateArticleDTO } from 'model/api/article'
const useArticleModel = () => {
const [articlesData, setArticlesData] = useState<ArticleDTO[] | null>(null)
const getArticles = useCallback(async () => {
const articles = await getAllArticles()
setArticlesData(articles)
}, [])
const createArticle = useCallback(async (createData: CreateArticleDTO) => {
if (Array.isArray(articlesData)) {
const response = await postArticle(createData)
if (response !== null) {
setArticlesData([...articlesData, { id: response.id, name: response.name }])
}
}
}, [articlesData])
return {
articles: articlesData,
createArticle,
getArticles
}
}
export default useArticleModel
如您所见,这部分逻辑更复杂一些。不过我们仍然保持了简洁。虽然逻辑不够智能或健壮,但我们可以看到它具备获取和创建文章的基本功能。这段代码应该可以像之前的钩子函数一样轻松测试。
REST API
在上面的模型示例中,我们没有看到 REST API 函数是如何实现的。虽然 MVVM 示例并不需要,但我们仍然会查看一些简单的示例代码,了解它的实现方式。
这段代码在你的项目中很可能不会完全相同,它只是用来举例说明的。你的实际代码应该比这写得更好,通常你需要处理错误、程序退出、缓存等问题,并确保代码的幂等性。
// model/api/article.ts
export interface ArticleDTO {
id: number
name: string
}
export interface CreateArticleDTO {
name: string
}
const API_URL = 'https://example.com/api'
export const getAllArticles = async (): Promise<ArticleDTO[] | null> => {
const response = await fetch(`${API_URL}/articles`)
if (!response.ok) {
throw new Error('Failed to get all articles')
}
const data = await response.json()
return data
}
export const postArticle = async (createData: CreateArticleDTO): Promise<ArticleDTO | null> => {
const response = await fetch(`${API_URL}/articles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(createData)
})
if (!response.ok) {
throw new Error('Failed to create article')
}
const data = await response.json()
return data
}
常问问题
问:什么是观点?
视图是用户界面的重要组成部分。它可以是整个视口,也可以是视口内的一个组件,该组件需要处理自身的状态和数据。我不知道你在 React 中是如何使用容器和组件的,或者你对智能组件和哑组件(或其他任何称呼)的理解。但如果你把组件看作是哑组件,那它就不是视图,而只是视图的一部分。按钮、输入框和小型组件都是组件,而不是视图。
问:为什么MVVM实现中需要ViewController?
将视图控制器添加到视图中,是为了将用户界面实现与其逻辑分离。这样一来,视图的测试就更加容易,因为视图逻辑和用户界面可以分别进行测试。缺点是需要测试、渲染和维护的组件数量会增加。
添加这一额外层的另一个原因是,它可以作为视图层和视图模型层之间的桥梁。这样一来,我们可以将更多视图逻辑保留在视图层内,而不是将其扩展到多个视图共享的视图模型中。视图模型无需了解使用它的视图的内部状态。
问:我的每个视图组件真的都需要一个视图控制器吗?
这取决于你对“视图”的定义。请阅读“什么是视图?”这个问题。如果你编写的组件不被视为视图,那么你既不需要也不应该为其编写视图控制器。为一个简单的按钮组件添加视图控制器绝对是多此一举。如果你实现的组件确实是一个视图,我建议将其视图逻辑添加到视图控制器中,以便进行测试并保持视图实现方式的一致性。
问:如何在不使用 ViewController 的情况下在 React 中实现 MVVM?
如果你不想将视图拆分成视图和视图控制器,只需将视图控制器中的所有视图逻辑都放到视图组件本身即可。另一种方法是将部分或全部逻辑放在视图模型中,但这样一来,视图模型就会包含大量特定于某些视图的视图逻辑,这会使它变得混乱且非常庞大。
问:为什么我们要使用 hooks 来实现 ViewController?
ViewController 可以通过多种方式实现。例如,我们可以将 View 和 ViewController 实现为两个组件,其中 ViewController 组件作为主组件,负责将 props 传递给 View 组件。这种方案会增加虚拟 DOM 的大小,甚至可能增加物理 DOM 的大小,因为它需要两个组件而不是一个。
问:我们能否在 React 中使用旧式类组件来实现 MVVM?
当然可以。但本文中使用的是函数式组件。类组件已经过时,不应该再使用了。
问:我见过在 React 中使用依赖注入来实现 MVVM 架构。
做得好!我见过一些类组件也采用这种方法,效果很不错。在 React 中使用函数式组件实现 MVVM 则需要其他解决方案。对于 React 开发者来说,Hooks 比依赖注入模式更熟悉。因此,本示例使用 React Hooks 来实现 MVVM。
问:我的 React MVVM 应用程序是否应该只包含一个模型?
可能不会。你的应用程序中很可能包含多个模型。每个模型都会附带一个使用该模型的 ViewModel。
问:我的 React MVVM 应用程序是否应该只包含一个 ViewModel?
不。每个模型都会有一个视图模型。如果有多个模型,也可以有多个视图模型。
问:每个 ViewModel 应该对应一个还是多个 ViewController?
每个视图都应该对应一个视图控制器 (ViewController)。多个视图控制器将使用同一个视图模型 (ViewModel)。例如,`useArticleViewController`、`useArticlesViewController` 和 `useCreateArticleViewController` 等视图控制器都将共享同一个 `ArticleViewModel`。
问:一个 ViewController 中只能使用一个 ViewModel 吗?
不,你可以使用多个。ViewController 和 ViewModel 之间的关系是多对多关系,而不是多对一关系。一个视图可以使用来自多个模型的数据,在这种情况下,它会使用多个 ViewModel。例如,useArticleViewController 可以同时使用 ArticleViewModel 和 AuthorViewModel。
问:为什么要在 ViewController 的 onCreateArticleClick 事件中包裹 createArticle 函数?
这样做有两个原因。首先是为了命名规范,将视图逻辑(例如点击事件处理)放在 ViewController 中,而不是 ViewModel 中。其次,ViewController 可以访问视图的内部状态。这使得可以使用视图中的逻辑(例如输入字段中的文本)来更新 onCreateArticleClick 方法。
问:我有一个组件不需要模型,我还需要把视图模型放在视图控制器中吗?
如果我回答“是”,你会怎么写?组件没有 ViewModel 完全没问题,甚至很正常。
问:我还需要像 React Test Renderer、Enzyme 和 React Testing Library 这样的测试库吗?
算是,也不算。你仍然需要测试 UI。但你只需要在视图文件中使用这类 UI 测试库,而视图文件只包含纯粹的 UI 元素,你可能并不需要测试它们。ViewController、ViewModel 和 Model 的 Hooks 都可以使用 React Hooks Testing Library 进行测试。这有什么了不起的?看看它简洁的文档吧!说实话,它已经足够用了。
问:您推荐什么样的文件夹结构?
你可以通过查看导入语句和文件名来了解我在这个例子中使用的文件夹结构。总的来说,我认为有好几种文件夹结构都可行,你可以选择你(以及至少你的一些同事……)认为最合理的任何一种。
问:如果我不想使用 TypeScript 怎么办?
那取决于你。直接删除这些类型描述就行了。它们只是用来辅助说明的。
问:如果我使用 React Native 呢?
只需稍作修改,同样的代码也可以用于 React Native。
问:MVVM 如何帮助进行测试?
文章来源:https://dev.to/perssondennis/how-to-use-mvvm-in-react-using-hooks-and-typescript-3o4m决定测试会给你带来多大负担或节省多少成本的因素有两个。一是代码架构。MVVM 架构通过分离应用程序中每一层的职责,从设计上就使代码更易于测试。
另一个对你有帮助的因素是你如何组织你的测试以及你使用哪些库来编写测试。







