使用 Clean Architecture 摆脱前端的 ReactJs 和 VueJs
本文是我博客中原文的英文翻译:Alejándonos de ReactJs y VueJs en el front end usando Clean Architecture。
使用 Clean Architecture 的优点之一是能够将我们的应用程序的交付机制与用户分离,即与 UI 框架或库分离。
这种长期应用中的优势使我们能够在未来适应库和框架中必然发生的变化。
在本文中,我们将通过应用两种交付机制:ReactJS 和 VueJs,将 Clean Architecture 在前端发挥到极致。
我们将在两个实现之间重复使用尽可能多的代码。
通过创建 ReactJs 和 VueJs 的域、数据和远程显示逻辑,这将成为可能。
为什么要脱离框架?
我运用 Clean Architecture 开发过不同的技术,例如 .Net、Android、iOS 和 Flutter。长期以来,我也在从事前端编程和写作。
在开发应用程序时最大的问题之一是与 UI 框架的耦合。
在前端,随着时间的推移,这种类型的应用程序所承担的责任逐渐增加,以更结构化的方式进行开发变得越来越有意义,而且要解决的问题与其他前端(如后端或移动开发)存在的问题非常相似。
ReactJs 和 VueJs 等框架可以让我们更轻松地应对前端的这些挑战。
如今,前端应用程序在很多情况下都是后端的独立应用程序,因此需要有自己的架构。
此外,这种架构必须在以下几点上帮助我们:
- 独立于 UI、框架、API 休息和持久性、数据库或第三方服务。
- 可扩展性。
- 可测试性。
这意味着,如果我们改变拥有 ReactJs 或 VueJs 应用程序的愿景,拥有使用 ReactJs 或 VueJs 来渲染的前端应用程序,这将使我们未来的生活变得更加轻松。
因此,例如,将你的 ReactJS 应用程序从以前使用类的方式改进为现在使用函数和钩子的方式,会变得简单得多。如果你在 VueJS 中从使用选项 API 切换到组合 API,也会发生同样的情况。
它更加简单,因为您只将框架用于绝对必要的情况,例如渲染,因此您不会过度使用它,使其远离任何类型的逻辑,无论是域、数据还是表示逻辑。
框架不断发展,你无法控制它,但你可以控制你与它们的耦合以及它们的变化如何影响你。
但在这种情况下,我们将超越如何适应框架中可能发生的变化,并且我们将看到,如果我们使用清洁架构和分离职责,当我们用 VueJS 修改 ReactJS 时,代码量不会改变。
如果您使用Clean Architecture进行开发,请记住这张图片。
如果你对 Clean Architecture 的概念还不太清楚,我推荐你阅读这篇文章。
最重要的部分是依赖规则,所以如果你不知道我在说什么,我建议你阅读这篇文章。
我们将要看到的示例基于我们在本文中看到的示例。
我们的场景
这是一个购物车,功能足够强大,看起来像一个真实的例子。我们将包含一个全局状态和一个非全局状态,并模拟对远程服务的调用。
建筑学
在项目结构层面,我们将使用yarn 工作区的 monorepo ,这样我们就可以将项目拆分为模块或包,并在它们之间共享代码。
我们有几种套餐:
- 核心:在这个包中,我们将拥有由 ReactJS 渲染的应用程序和由 VueJs 渲染的应用程序之间的所有共享代码。
- React:在这个包中找到了 React 应用程序版本。
- Vue:在此包中找到了 Vue 应用程序版本。
哪些代码被重复使用了?
我们将重用必须与 UI 框架分离的所有代码,因为作为同一应用程序的不同版本,共享这些代码并且不需要编写两次是有意义的。
这是 Clean Architecture 潜力的一次演示练习,但即使在开发真正的应用程序时,这种 UI 框架的解耦也是必要的。
将 UI 框架用于绝对必要的情况可以让我们更好地适应框架未来版本的变化。
这是因为包含应用程序逻辑的代码(最重要的部分)随时间变化较小,并且是可能在同一应用程序的两个版本之间共享的代码(如本例中所示),它是解耦的,不依赖于 UI 框架。
在 Clean Architecture 中,领域层是企业和应用程序业务逻辑所在的位置。
数据层是我们与持久层进行通信的地方。
呈现逻辑决定了显示哪些数据,是否显示某些内容,是否应该向用户显示我们正在加载数据,或者是否应该显示错误。组件的状态也由呈现逻辑管理。
这 3 个部分中的每一个都包含我们必须解耦的逻辑,并且位于核心包中。
领域层
领域层是企业和应用程序业务逻辑所在的地方。
用例
用例是意图,包含应用程序的业务逻辑,它们是动作,在这个例子中,我们有以下内容:
- 获取产品用例
- 获取购物车用例
- 添加产品到购物车用例
- 编辑购物车商品数量用例
- RemoveItemFromCart用例
我们来看 GetProductsUseCase 的例子:
export class GetProductsUseCase {
private productRepository: ProductRepository;
constructor(productRepository: ProductRepository) {
this.productRepository = productRepository;
}
execute(filter: string): Promise<Either<DataError, Product[]>> {
return this.productRepository.get(filter);
}
}
这个用例很简单,因为它包含对数据层的简单调用,在其他情况下,例如,在创建产品时,我们必须验证不再有具有相同 SKU 的产品,这样就会有更多的逻辑。
用例返回 Either 类型,如果您不确定它是什么,那么我建议您阅读这篇文章和这篇文章。
这样,错误处理就不是使用承诺的捕获来完成的,而是承诺本身的结果对象告诉你结果是否成功。
与传统的 try-catch 相比,使用 Either 有几个优点:
- 当发生错误时,执行流程更容易遵循,无需在调用者之间跳转。
- 明确指出可能会出错。明确指出可能发生的错误。
- 通过使用穷举开关,如果您将来添加更多错误,TypeScript 将会在您未考虑到这个新错误的地方发出警告。
错误类型如下:
export interface UnexpectedError {
kind: "UnexpectedError";
message: Error;
}
export type DataError = UnexpectedError;
将来,它可能会演变成如下的样子:
export interface ApiError {
kind: "ApiError";
error: string;
statusCode: number;
message: string;
}
export interface UnexpectedError {
kind: "UnexpectedError";
message: Error;
}
export interface Unauthorized {
kind: "Unauthorized";
}
export interface NotFound {
kind: "NotFound";
}
export type DataError = ApiError | UnexpectedError | Unauthorized;
而在表示层,如果我使用详尽的开关,Typescript 会警告我,我应该为每个新错误添加更多案例。
实体
实体包含企业业务逻辑。
我们来看购物车的例子:
type TotalPrice = number;
type TotalItems = number;
export class Cart {
items: readonly CartItem[];
readonly totalPrice: TotalPrice;
readonly totalItems: TotalItems;
constructor(items: CartItem[]) {
this.items = items;
this.totalPrice = this.calculateTotalPrice(items);
this.totalItems = this.calculateTotalItems(items);
}
static createEmpty(): Cart {
return new Cart([]);
}
addItem(item: CartItem): Cart {
const existedItem = this.items.find(i => i.id === item.id);
if (existedItem) {
const newItems = this.items.map(oldItem => {
if (oldItem.id === item.id) {
return { ...oldItem, quantity: oldItem.quantity + item.quantity };
} else {
return oldItem;
}
});
return new Cart(newItems);
} else {
const newItems = [...this.items, item];
return new Cart(newItems);
}
}
removeItem(itemId: string): Cart {
const newItems = this.items.filter(i => i.id !== itemId);
return new Cart(newItems);
}
editItem(itemId: string, quantity: number): Cart {
const newItems = this.items.map(oldItem => {
if (oldItem.id === itemId) {
return { ...oldItem, quantity: quantity };
} else {
return oldItem;
}
});
return new Cart(newItems);
}
private calculateTotalPrice(items: CartItem[]): TotalPrice {
return +items
.reduce((accumulator, item) => accumulator + item.quantity * item.price, 0)
.toFixed(2);
}
private calculateTotalItems(items: CartItem[]): TotalItems {
return +items.reduce((accumulator, item) => accumulator + item.quantity, 0);
}
}
在这个例子中,实体很简单,具有原始类型的属性。但在实际需要验证的例子中,我们可以将实体和值对象定义为类,并使用工厂方法来执行验证。我们使用 Either 来返回错误或结果。
边界
边界是适配器的抽象,例如,在六边形架构中,它们被称为端口。它们在领域用例层中定义,指示我们将如何与适配器进行通信。
例如,为了与数据层通信,我们使用存储库模式。
export interface ProductRepository {
get(filter: string): Promise<Either<DataError, Product[]>>;
}
数据层
数据层是适配器所在的位置,适配器负责在域和外部系统之间转换信息。
外部系统可能是 Web 服务、数据库等……
在这个简单的例子中,我使用了代表表示层、域层和数据层之间的产品、购物车和购物车项目的相同实体。
在实际应用中,通常每个层都有不同的数据结构,甚至使用数据传输对象(DTO)在层之间传递数据。
在这个例子中,我们有返回存储在内存中的数据的存储库。
const products = [
...
];
export class ProductInMemoryRepository implements ProductRepository {
get(filter: string): Promise<Either<DataError, Product[]>> {
return new Promise((resolve, _reject) => {
setTimeout(() => {
try {
if (filter) {
const filteredProducts = products.filter((p: Product) => {
return p.title.toLowerCase().includes(filter.toLowerCase());
});
resolve(Either.right(filteredProducts));
} else {
resolve(Either.right(products));
}
} catch (error) {
resolve(Either.left(error));
}
}, 100);
});
}
}
重要的是要理解存储库是一个适配器,并且它的抽象或端口是在域中定义的,因此依赖关系的传统方向是颠倒的。
这是清洁架构最重要的部分,领域不应该对外部层有任何依赖,这样它就被解耦了,并且将来甚至出于测试目的都可以更容易地用另一个适配器替换一个适配器。
这样,如果我们用调用 Web 服务的实现替换适配器实现,则域不会受到影响,因此我们隐藏了实现细节。
表示层——适配器
表示层的适配器是我们核心包的最后一个重用部分,也是我们连接 UI React 或 Vue 层的地方。
这些适配器也可以在应用程序的两个版本之间重复使用,它们是 UI 组件和域层之间的中介。
它们包含表示逻辑,决定显示什么信息、什么应该可见等等……
状态管理由此层执行,不依赖于 React 或 Vue。
我们可以使用不同的表示模式。在本例中,我使用 BLoC 模式,因为它与 React 和 Vue 等声明式框架非常契合。
如果您想深入研究 BLoC 模式,我建议您阅读这篇文章。
正如我在那篇文章中所讨论的,当你将 BLoC 与 Clean Architecture 结合使用时,将它们称为 PLoC(Presentation Logic Component,表示逻辑组件)更有意义。因此,在本例中,它们被这样命名。
让我们看一下购物车示例:
export class CartPloc extends Ploc<CartState> {
constructor(
private getCartUseCase: GetCartUseCase,
private addProductToCartUseCase: AddProductToCartUseCase,
private removeItemFromCartUseCase: RemoveItemFromCartUseCase,
private editQuantityOfCartItemUseCase: EditQuantityOfCartItemUseCase
) {
super(cartInitialState);
this.loadCart();
}
closeCart() {
this.changeState({ ...this.state, open: false });
}
openCart() {
this.changeState({ ...this.state, open: true });
}
removeCartItem(item: CartItemState) {
this.removeItemFromCartUseCase
.execute(item.id)
.then(cart => this.changeState(this.mapToUpdatedState(cart)));
}
editQuantityCartItem(item: CartItemState, quantity: number) {
this.editQuantityOfCartItemUseCase
.execute(item.id, quantity)
.then(cart => this.changeState(this.mapToUpdatedState(cart)));
}
addProductToCart(product: Product) {
this.addProductToCartUseCase
.execute(product)
.then(cart => this.changeState(this.mapToUpdatedState(cart)));
}
private loadCart() {
this.getCartUseCase
.execute()
.then(cart => this.changeState(this.mapToUpdatedState(cart)))
.catch(() =>
this.changeState({
kind: "ErrorCartState",
error: "An error has ocurred loading products",
open: this.state.open,
})
);
}
mapToUpdatedState(cart: Cart): CartState {
const formatOptions = { style: "currency", currency: "EUR" };
return {
kind: "UpdatedCartState",
open: this.state.open,
totalItems: cart.totalItems,
totalPrice: cart.totalPrice.toLocaleString("es-ES", formatOptions),
items: cart.items.map(cartItem => {
return {
id: cartItem.id,
image: cartItem.image,
title: cartItem.title,
price: cartItem.price.toLocaleString("es-ES", formatOptions),
quantity: cartItem.quantity,
};
}),
};
}
}
所有 PLoC 的基类负责存储状态并在状态发生变化时发出通知。
type Subscription<S> = (state: S) => void;
export abstract class Ploc<S> {
private internalState: S;
private listeners: Subscription<S>[] = [];
constructor(initalState: S) {
this.internalState = initalState;
}
public get state(): S {
return this.internalState;
}
changeState(state: S) {
this.internalState = state;
if (this.listeners.length > 0) {
this.listeners.forEach(listener => listener(this.state));
}
}
subscribe(listener: Subscription<S>) {
this.listeners.push(listener);
}
unsubscribe(listener: Subscription<S>) {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
}
}
UI 组件所需的所有信息都必须从状态、表格或列表中呈现的元素中进行解释,而且还要解释某些内容是否应该可见,例如购物车、加载或显示的错误。
export interface CommonCartState {
open: boolean;
}
export interface LoadingCartState {
kind: "LoadingCartState";
}
export interface UpdatedCartState {
kind: "UpdatedCartState";
items: Array<CartItemState>;
totalPrice: string;
totalItems: number;
}
export interface ErrorCartState {
kind: "ErrorCartState";
error: string;
}
export type CartState = (LoadingCartState | UpdatedCartState | ErrorCartState) & CommonCartState;
export interface CartItemState {
id: string;
image: string;
title: string;
price: string;
quantity: number;
}
export const cartInitialState: CartState = {
kind: "LoadingCartState",
open: false,
};
在这种情况下,通过 typescript 的联合类型,我们可以使用和代数数据类型更安全、更实用地对我们的状态进行建模。
这种建模方式不太容易出错,因为您可以非常清晰地表明状态有 3 种主要可能性:
- 正在加载信息
- 发生了错误
- 更新数据
表示层 — UI
此层包含组件以及与 React 或 Vue 相关的所有内容,例如组件、钩子、应用程序等。
组件非常简单和轻量,因为它们可以自由地管理任何类型的逻辑或状态管理,这是核心包中每一层的责任。
React 应用
在 React 中,我们将拥有呈现产品列表的组件、带有购物车中产品数量的应用栏以及呈现为侧边栏的产品购物车。
让我们看一下呈现购物车内容的组件的示例。
import React from "react";
import { makeStyles, Theme } from "@material-ui/core/styles";
import { List, Divider, Box, Typography, CircularProgress } from "@material-ui/core";
import CartContentItem from "./CartContentItem";
import { CartItemState } from "@frontend-clean-architecture/core";
import { useCartPloc } from "../app/App";
import { usePlocState } from "../common/usePlocState";
const useStyles = makeStyles((theme: Theme) => ({
totalPriceContainer: {
display: "flex",
alignItems: "center",
padding: theme.spacing(1, 0),
justifyContent: "space-around",
},
itemsContainer: {
display: "flex",
alignItems: "center",
padding: theme.spacing(1, 0),
justifyContent: "space-around",
minHeight: 150,
},
itemsList: {
overflow: "scroll",
},
infoContainer: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
},
}));
const CartContent: React.FC = () => {
const classes = useStyles();
const ploc = useCartPloc();
const state = usePlocState(ploc);
const cartItems = (items: CartItemState[]) => (
<List className={classes.itemsList}>
{items.map((item, index) => (
<CartContentItem key={index} cartItem={item} />
))}
</List>
);
const emptyCartItems = () => (
<React.Fragment>
<Typography variant="h6" component="h2">
Empty Cart :(
</Typography>
</React.Fragment>
);
switch (state.kind) {
case "LoadingCartState": {
return (
<div className={classes.infoContainer}>
<CircularProgress />
</div>
);
}
case "ErrorCartState": {
return (
<div className={classes.infoContainer}>
<Typography display="inline" variant="h5" component="h2">
{state.error}
</Typography>
</div>
);
}
case "UpdatedCartState": {
return (
<React.Fragment>
<Box flexDirection="column" className={classes.itemsContainer}>
{state.items.length > 0 ? cartItems(state.items) : emptyCartItems()}
</Box>
<Divider />
<Box flexDirection="row" className={classes.totalPriceContainer}>
<Typography variant="h6" component="h2">
Total Price
</Typography>
<Typography variant="h6" component="h2">
{state.totalPrice}
</Typography>
</Box>
</React.Fragment>
);
}
}
};
export default CartContent;
钩子
使用 Clean Architecture,不使用 hooks?是的,它们确实需要用到,但只用于绝对必要的情况。
状态不会用钩子来管理,副作用也不会从钩子触发,这是核心包中 PloC 的责任。
但是我们将使用它们来存储其 PloC 返回给我们的组件的最终状态,并且我们将使用它们在组件之间共享上下文或对 PloC 返回给我们的状态变化做出反应。
让我们看看组件中使用的 usePLocState 钩子是如何定义的:
export function usePlocState<S>(ploc: Ploc<S>) {
const [state, setState] = useState(ploc.state);
useEffect(() => {
const stateSubscription = (state: S) => {
setState(state);
};
ploc.subscribe(stateSubscription);
return () => ploc.unsubscribe(stateSubscription);
}, [ploc]);
return state;
}
这个自定义钩子负责订阅 PloC 状态变化并存储最终状态。
Vue 应用
在 Vue 中,我们还将拥有与 React 版本相同的组件。
现在我们来看一下 Vue 版本中渲染购物车内容的组件:
<template>
<div id="info-container" v-if="state.kind === 'LoadingCartState'">
<ProgressSpinner />
</div>
<div id="info-container" v-if="state.kind === 'ErrorCartState'">Error</div>
<div id="items-container" v-if="state.kind === 'UpdatedCartState'">
<div v-if="state.items.length > 0" style="overflow: scroll">
<div v-for="item in state.items" v-bind:key="item.id">
<CartContenttItem v-bind="item" />
</div>
</div>
<h2 v-if="state.items.length === 0">Empty Cart :(</h2>
</div>
<Divider />
<div id="total-price-container">
<h3>Total Price</h3>
<h3>{{ state.totalPrice }}</h3>
</div>
</template>
<script lang="ts">
import { defineComponent, inject } from "vue";
import { CartPloc } from "@frontend-clean-architecture/core";
import { usePlocState } from "../common/usePlocState";
import CartContenttItem from "./CartContenttItem.vue";
export default defineComponent({
components: {
CartContenttItem,
},
setup() {
const ploc = inject<CartPloc>("cartPloc") as CartPloc;
const state = usePlocState(ploc);
return { state };
},
});
</script>
<style scoped>
#info-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
#items-container {
display: flex;
flex-direction: column;
align-items: center;
min-height: 150px;
justify-content: space-around;
}
#total-price-container {
display: flex;
align-items: center;
padding: 8px 0px;
justify-content: space-around;
}
</style>
如您所见,它看起来很像使用组合 API 的 React 版本。
组合 API
在 Vue 版本中我们还将有钩子,例如管理对 PLoC 状态更改的订阅的钩子:
import { Ploc } from "@frontend-clean-architecture/core";
import { DeepReadonly, onMounted, onUnmounted, readonly, Ref, ref } from "vue";
export function usePlocState<S>(ploc: Ploc<S>): DeepReadonly<Ref<S>> {
const state = ref(ploc.state) as Ref<S>;
const stateSubscription = (newState: S) => {
state.value = newState;
};
onMounted(() => {
ploc.subscribe(stateSubscription);
});
onUnmounted(() => {
ploc.unsubscribe(stateSubscription);
});
return readonly(state);
}
依赖注入
从 React 和 Vue 应用程序中,我们必须为每个组件创建或重用 PloC 结构:用例和存储库。
如果这些概念是在核心包中定义的,那么负责创建它们的部分也可能在核心包中。
这次我静态地使用服务定位器模式:
function provideProductsPloc(): ProductsPloc {
const productRepository = new ProductInMemoryRepository();
const getProductsUseCase = new GetProductsUseCase(productRepository);
const productsPloc = new ProductsPloc(getProductsUseCase);
return productsPloc;
}
function provideCartPloc(): CartPloc {
const cartRepository = new CartInMemoryRepository();
const getCartUseCase = new GetCartUseCase(cartRepository);
const addProductToCartUseCase = new AddProductToCartUseCase(cartRepository);
const removeItemFromCartUseCase = new RemoveItemFromCartUseCase(cartRepository);
const editQuantityOfCartItemUseCase = new EditQuantityOfCartItemUseCase(cartRepository);
const cartPloc = new CartPloc(
getCartUseCase,
addProductToCartUseCase,
removeItemFromCartUseCase,
editQuantityOfCartItemUseCase
);
return cartPloc;
}
export const dependenciesLocator = {
provideProductsPloc,
provideCartPloc,
};
我们还可以将动态服务定位器与 Composition Root或依赖注入库一起使用。
在 React 应用中,有一个必须共享的全局状态,那就是购物车。因此,管理此状态的 CartPloc 必须被所有组件共享并访问。
反应
在 React 中,我们使用 createContext 和使用 useContext 的自定义钩子来解决这个问题。
export function createContext<T>() {
const context = React.createContext<T | undefined>(undefined);
function useContext() {
const ctx = React.useContext(context);
if (!ctx) throw new Error("context must be inside a Provider with a value");
return ctx;
}
return [context, useContext] as const;
}
const [blocContext, usePloc] = createContext<CartPloc>();
export const useCartPloc = usePloc;
const App: React.FC = () => {
return (
<blocContext.Provider value={dependenciesLocator.provideCartPloc()}>
<MyAppBar />
<ProductList />
<CartDrawer />
</blocContext.Provider>
);
};
export default App;
使用自定义的 useCartPloc,我们可以从任何组件访问此 PloC 及其状态。
Vue 应用
在 Vue 中,我们通过使用提供功能来解决这个问题。
<template>
<div id="app">
<MyAppBar />
<ProductList searchTerm="Element" />
<CartSidebar />
</div>
</template>
<script lang="ts">
import { dependenciesLocator } from "@frontend-clean-architecture/core";
import { defineComponent } from "vue";
import MyAppBar from "./appbar/MyAppBar.vue";
import ProductList from "./products/ProductList.vue";
import CartSidebar from "./cart/CartSidebar.vue";
export default defineComponent({
name: "App",
components: {
ProductList,
MyAppBar,
CartSidebar,
},
provide: {
cartPloc: dependenciesLocator.provideCartPloc(),
},
});
</script>
稍后,我们可以从任何组件使用以下方式访问 PLoC 及其状态:
const cartPloc = inject <CartPloc> (“cartPloc”) as CartPloc;
源代码
源代码可以在这里找到:frontend-clean-architecture。
相关文章和资源
- 西班牙语新闻通讯:https://xurxodev.com/#/portal/signup
- 清晰架构:软件结构与设计工匠指南
- 清洁架构课程。
- 为什么在我的项目中使用 Clean Architecture?
- 清洁架构中的 Bloc 模式
- ReactJS 清洁架构中的 BLoC 模式
- Flutter 清洁架构中的 BLoC 模式
- 清洁架构:代码异味(第一部分)
- 整洁架构:代码异味(二)
- 我购买了一本超乎寻常的《清洁架构》
结论
在本文中,我们看到了前端的 Clean Architecture 实现。
我们有一个 React 和 Vue 应用程序版本,在两者之间尽可能多地重用代码并将其放在核心包中。
通过将所有逻辑与框架分离的核心包的练习,我们可以体会到 Clean Architecture 在前端为我们提供的强大功能。
对于此示例来说,将项目组织为 monorepo 并拥有核心包是必要的,但在开发 React 或 Vue 应用程序时这不是必需的。
然而,强制您与 UI 框架分离是一个有趣的练习,因为有时很难看出您正在耦合,尤其是在开始的时候。
文章来源:https://dev.to/xurxodev/moving-away-from-reactjs-and-vuejs-on-front-end-using-clean-architecture-3olk