译者:这篇文章是在 medium 讲解前端架构分层系列的第一篇文章,分层和之前翻译的文章类似,相对普通项目多出来两层,实体层(从业务抽离出来领域模型)和用例层(实现业务逻辑)。
另外在编程范式上,相对面对对象,作者更倾向于采用函数式,读者可根据项目特点选择适合自己的方式。
你的前端项目是如何从软件架构中受益的?
这篇博客是《可扩展的前端》系列的一部分,你可以看到其他部分:#2 — Common Patterns 和#3 — The State Layer。
原文链接 blog.codeminer42.com/scalable-fr…
文章首发于我的博客 github.com/mcuking/blo…
关于软件开发的可扩展性这一概念有两个最常见的的意义:代码的性能和可维护性。你可以同时兼顾这两点,但是专注于良好的可维护性会让一件事情变得容易,那就是提升性能且不影响应用的其余部分。更重要的是,前端与后端有一个重要的区别:本地状态。
在这个系列博客中,我们将会讨论如何通过实际的经过验证的方法,来开发和维护可扩展的前端应用。我们大部分的例子将会使用 React 和 Redux,但是我们会经常与其他的技术栈对比较,来展示你如何达到同样的结果。让我们开始这个关于架构方面的系列讨论吧,这是你的软件中最重要的部分。
那么架构到底是什么?说架构是软件中的最重要的部分似乎很自以为是,但请耐新看下去。
架构是使软件的各个部分相互交互以突出必须要做出的最重要的决策,并且推迟次要的决策和实现细节的方式。设计一个软件的架构意味着将实际的应用从支持它的技术中分离开来。你的实际应用不知道数据库、AJAX 请求、或者 GUI;而是由用例和领域模型组成。这些用例和领域模型代表了你的软件所涵盖的概念,请忽略执行用例的角色或数据在哪里存储等。
区分重要与次要的一种方式就是使用层,每个层都有一些不同且特定的职责。基于分层的架构中一种常见的方式是将它分成四个层:application 层, domain 层, infrastructure 层, input 层。这四个层在另一个博客中有很好的解释, NodeJS and Good Practices 。我推荐在继续阅读下面的文章之前,先看下这篇文章的第一部分。你不需要阅读第二部分,因为那已经具体到 NodeJS 了。
其中 domain 层和 application 层在前端和后端之间是没有什么不同的,因为它们是与技术无关的,但是对于 input 层和 infrastructure 层我们不能这么说。在 Web 浏览器中 input 层通常只有一个角色--view。所以我们甚至可以称之为 view 层。同样在前端是无法操作数据库或队列引擎的,所以我们无法在前端的 infrastructure 层中找到它们。相反我们能够找的是封装 AJAX 请求、浏览器 cookie、LocalStorage,甚至是与 WebSocket 服务器交互的模块的抽象。
主要的区别是被抽象的内容,所以前端和后端的Repository 甚至可以有完全一致的接口而底层是不同的技术。你能看到一个好的抽象有多棒了吗?
你使用 React,Vue,Angular 或其他任何工具来操作视图都没有关系,重要的是遵守没有任何逻辑的 view 层规则,将输入参数委托给下一层。关于基于前端分层的架构,还有另一个重要规则:使 view 层始终与本地状态保持同步,你应该遵循单向数据流原则。这个概念是否听着很熟悉?我们可以通过添加第五个层来达到这个目的:state ,或者称为 store。
当遵循单向数据流原则时,我们永远不会在 view 内部直接更改 view 接收的数据。相反,我们从 view 中 dispatch 我们所谓的 “action”。它是这样的:一个 action 将消息发送到数据源,该数据源将更新自身,然后使用新数据重新渲染 view。需要注意的是,从 view 到 store 没有直接通道,因此如果两个子 view 使用了相同的数据,则可以从任何一个 view 中 dispatch 一个 action,这会导致两个子 view 都会用新据渲染。似乎我是在专门谈论 React 和 Redux,但事实并非如此;几乎可以通过所有现代的前端框架或库获得相同的结果,例如 React + context API, Vue + Vuex, Angular + NGXS, 甚至使用 data-down action-up 方式的 Ember。你甚至可以使用 jQuery 的事件系统来实现发送 action up!
该层负责管理前端的本地和不断变化的状态,例如从后端获取的数据,在前端操作但尚未持久化的临时数据,或者是瞬时信息,例如请求状态。
即使在 actions 内部,也会经常看到带有业务规则和用例定义的代码,如果你仔细阅读其它层的描述,你会看到我们已经有放置我们的用例和业务逻辑的地方了,而且不是 state 层。这是否意味着我们的 actions 现在是用例?没有!那么我们应该如何对待它们呢?
让我们考虑一下……我们说 action 不是用例,并且我们已经有了放置用例的层。view 应该 dispatch 一个 action,该 action 从视图中获取信息,将其交给用例,根据响应 dispatch 新 action,最后更新 state -- 更新 view 并关闭单向数据流。这些 action 现在看起来不像 controller 吗?他们不是一个从 view 中获取参数,委派给用例并根据用例结果进行响应的地方吗?那就是你应该看待它们的方式。不应有复杂的逻辑或直接的 AJAX 调用,因为这是另一层的职责。state 层应该只知道如何管理本地存储,仅此而已。
其中还有另一个重要因素,由于 state 层管理着 view 层依赖的本地存储,因此你会注意到这两者是以某种方式耦合在一起的。state 层中只会有一些数据供 view 使用,例如一个布尔类型的标志,指示请求是否仍在等待处理,以便视图可以显示 spinner,这完全可以。不要为此而烦恼,你不需要过度概括 state 层。
好的,分层很酷,但是它们如何互相通信?我们如何使一个层依赖另一个层而不耦合它们?是否可以在不执委派给用例的情况下测试 action 的所有可能输出?是否可以在不触发 AJAX 调用的情况下测试用例?可以肯定的是,我们可以通过依赖注入来做到这一点。
依赖注入是一种技术,该技术包括在创建一个模块的过程中接收另一个模块的耦合依赖关系作为参数。例如,在其构造函数中接收类的依赖项,或使用 React / Redux 将组件连接到 store 并注入必要的数据和 action 作为参数。这个理论并不复杂,对吧?相关的实践也不应该复杂,所以让我们以 React / Redux 应用程序为例。
我们刚刚说过,使用 React / Redux 的 connect 是一种在 view 和 state 层之间实现依赖注入的方法,而且它变得非常简单。但是我们之前也说过,action 将业务逻辑委托给用例,那么我们如何将用例(application 层)注入到 actions(state 层)中呢?
让我们想象一下,你有一个对象,其中包含针对你的应用程序的每个用例的方法。该对象通常称为 dependency container
。是的,看起来很奇怪,而且扩展性不好,但这并不意味着用例的实现就在该对象内。这些只是委托给用例的方法,这些用例在其他地方定义。应用程序的所有用例一起使用一个对象比将它们分布在整个代码库中要好得多,后者会使它们很难找到。有了这个对象,我们要做的就是将其注入到 actions 中,让每个 action 决定将触发什么用例,对吗?
如果你使用的是 redux-thunk,则使用 withExtraArgument 方法可以很容易地实现它,该方法允许你将容器中的每个 thunk 动作作为 getState 之后的第三个参数注入。如果你使用的是 redux-saga,则该方法应该很简单,在该方法中,我们将容器作为 run 方法的第二个参数进行传递。如果你使用的是 Ember 或 Angular,则内置的依赖项注入机制就足够了。
这样做会使 action 与用例解耦,因为你无需在定义 action 的每个文件中手动导入用例。而且将 actions 与用例分开进行测试现在变得非常简单:只需注入一个伪造的用例实现即可,该实现的行为完全符合你想要的方式。你是否想测试如果用例失败,将 dispatch 什么 action?注入一个总是失败的模拟用例,然后测试 action 如何对此做出响应。无需考虑实际用例如何工作。
太好了,我们将 state 层注入了 view 层,并将 application 层注入了 state 层。其余的呢?我们如何将依赖项注入用例来构建 dependency container
?这是一个重要的问题,有很多方法可以解决。首先,不要忘记检查你使用的框架是否内置了依赖项注入,例如 Angular 或 Ember。如果确实如此,则你不应该自己构造。如果没有,你可以通过两种方式来做到这一点:手动或在软件包的帮助下。
手动进行操作应该很简单:
将你的模块定义为类或闭包,
首先实例化没有依赖性的模块,
然后再实例化有依赖的的模块,将它们作为参数传递,
重复上述步骤,直到实例化所有用例为止,
导出它们。
太抽象了?看一些代码示例:
container.js
import api from './infra/api'; // has no dependencies import { validateUser } from './domain/user'; // has no dependencies import makeUserRepository from './infra/user/userRepository'; import makeArticleRepository from './infra/article/articleRepository'; import makeCreateUser from './app/user/createUser'; import makeGetArticle from './app/article/getArticle'; const userRepository = makeUserRepository({ api }); const articleRepository = makeArticleRepository({ api }); const createUser = makeCreateUser({ userRepository, validateUser }); const getArticle = makeGetArticle({ userRepository, articleRepository }); export { createUser, getArticle }; 复制代码
createUser.js
export default ({ validateUser, userRepository }) => async userData => { if (!validateUser(userData)) { throw new Error('Invalid user'); } try { const user = await userRepository.add(userData); return user; } catch (error) { throw error; } }; 复制代码
userRepository.js
export default ({ api }) => ({ async add(userData) { const user = await api.post('/users', userData); return user; } }); 复制代码
你会注意到,重要部分(用例)已在文件末尾实例化,并且是唯一导出的对象,因为它们将被注入到 actions 中。你的其余代码无需了解 repository 的操作方式和工作方式。这并不重要,而只是技术细节。对于用例,repository 是发送 AJAX 请求还是在 LocalStorage 中保留某些内容都没有关系;用例没有职责需要知道。如果你想在 API 仍在开发中时使用 LocalStorage,然后切换为使用通过网络 API 的调用,只要与 API 交互的代码遵循与 LocalStorage 交互的接口,而无需更改用例。
即使你有数十个 use cases(用例), repositories, services 等,也可以如上所述手动完成注入。如果太麻烦而无法构建所有依赖关系,则可以始终使用依赖注入的库,只要它不会增加耦合。
检验你的 DI(Dependency injection) 库是否足够好的一条经验法则是,检查从手动方法转移到使用库是否只需要操作 container 代码即可。如果不是这样,则说明库太过侵入,你应该选择其他库。如果你确实要使用库,我们建议你使用Awilix。它非常简单易用,无需手动操作,只需操作 container 文件即可。这个库的作者撰写了一系列有关如何使用以及为什么使用它的很好的文章,点击查看。