什么是可扩展前端架构?


关于软件开发,可扩展性这个词的两个最常见的含义与代码库的性能和长期可维护性有关。你可以同时拥有这两点,但专注于良好的可维护性会使你更容易调整性能而不影响应用程序的其他部分。在前台更是如此,在这里我们有一个与后台的重要区别:本地状态。

在这一系列的文章中,我们将谈论如何用现实生活中经过测试的方法来开发和维护一个可扩展的前端应用程序。我们的大多数例子将使用React和Redux,但我们会经常与其他技术栈进行比较,以说明你如何能达到同样的效果。让我们开始讨论这个系列的架构,这是你的软件中最重要的部分。

什么是软件架构?
架构到底是什么?说架构是你的软件中最重要的部分似乎很自命不凡,但请听我说。

架构是你如何使你的软件的各个单元相互作用,以突出你必须做出的最重要的决定,并推迟次要决定和实施细节。设计一个软件的架构意味着将实际的应用程序与它的支持技术分开。你的实际应用不知道数据库、AJAX请求或GUI;相反,它是由代表你的软件所涵盖的概念的用例和领域单元组成的,而不考虑执行用例的角色或数据被持久化的地方。

关于架构,还有一些重要的事情要谈:它并不意味着文件组织,也不是你如何命名文件和文件夹。

前端开发中的分层
将重要的东西和次要的东西分开的一种方法是使用层,每个层都有不同的和特定的责任。在基于层的架构中,一个常见的方法是把它分成四层:应用、领域、基础设施和输入。这四层在另一篇文章《NodeJS和良好实践》中得到了更好的解释。我们建议你在继续之前阅读关于它们的帖子的第一部分。你不需要阅读第二部分,因为它是专门针对NodeJS的。

前端和后端之间的领域和应用层并没有什么不同,因为它们是技术无关的,但我们不能对输入和基础设施层说同样的话。在网络浏览器中,输入层通常只有一个角色,即视图,所以我们甚至可以把它称为视图层。另外,前端没有访问数据库或队列引擎的权限,所以我们不会在我们的前端基础设施层中找到这些。我们将找到的是封装AJAX请求、浏览器cookies、LocalStorage的抽象,甚至是与WebSocket服务器交互的单元。主要的区别只是被抽象出来的东西,所以你甚至可以拥有界面完全相同但下面有不同技术的前端和后端仓库。你能看到一个好的抽象可以有多棒吗?

如果你使用React、Vue、Angular或其他工具来创建你的视图,这并不重要。重要的是要遵循输入层的规则,不要有任何逻辑,因此要把输入参数委托给下一层。关于基于前端层的架构,还有一个重要的规则:为了让输入/视图层始终与本地状态保持同步,你应该遵循单向数据流。这个术语听起来很熟悉吗?我们可以通过添加一个专门的第五层来实现:状态,也称为存储。

状态层
当遵循单向数据流时,我们永远不会直接在视图中改变或变异视图所接收的数据。相反,我们从视图中调度我们称之为 "行动 "的东西。它是这样的:一个动作向数据源发送一个消息,数据源更新自己,然后用新的数据重新提交给视图。请注意,从视图到存储空间从来没有一个直接的通道,所以如果两个子视图使用相同的数据,你可以从任何一个子视图中派发动作,这将导致两个子视图用新数据重新渲染。看起来我是在专门谈论React和Redux,但事实并非如此;你可以用几乎所有的现代前端框架或库来实现同样的结果,比如React + context API,Vue + Vuex,Angular + NGXS,甚至Ember,使用数据下行动上的方法(又称DDAU)。你甚至可以用jQuery来做,用它的事件系统来发送动作!

这一层负责管理你的前端的本地和不断变化的状态,比如从后端获取的数据,在前端创建但尚未持久化的临时数据,或者像请求的状态这样的瞬时信息。如果你想知道,这就是负责更新状态的动作和处理程序所在的层。

尽管我们经常看到代码库中的业务规则和用例定义直接放在动作中,但如果你仔细阅读其他层的描述,你会发现我们已经有一个地方可以放置我们的用例和业务规则,而不是状态层。这是否意味着我们的行动现在就是用例?不是!是的。那么我们应该如何对待它们呢?

让我们想一想......我们说行动Action不是用例,而且我们已经有一个层来放置我们的用例。视图应该调度行动Action,行动Action接收来自视图的信息,将其交给用例,根据响应调度新的行动,最后更新状态--更新视图并关闭单向数据流。这些动作现在听起来是不是像控制器?它们不是从视图中获取参数,委托给用例,并根据用例的结果进行响应的地方吗?这正是你应该对待它们的方式。这里不应该有复杂的逻辑或直接的AJAX调用,因为这些是另一个层的职责。状态层应该只知道如何管理本地存储,仅此而已。

还有一个重要的因素在起作用。由于状态层管理着视图层所消耗的本地存储,你会注意到这两者在某种程度上是耦合的。在状态层中会有一些只针对视图的数据,比如一个布尔标志,表示一个请求是否还在等待中,以便视图可以显示一个旋转器,这完全没有问题。不要因此而自责,你不需要把状态层过度泛化。

依赖性注入
好吧,层是很酷,但它们如何相互交流呢?我们如何使一个层依赖另一个层而不使它们耦合?有没有可能在不执行用例的情况下测试一个动作的所有可能输出?有没有可能在不触发AJAX调用的情况下测试一个用例?当然可以,我们可以通过依赖性注入来实现这一点。

依赖注入是一种技术,它包括在创建统一体的过程中接收该统一体的耦合依赖关系作为参数。例如,在一个类的构造函数中接收该类的依赖关系,或者使用React/Redux将一个组件连接到商店并作为道具注入所需的数据和动作。理论并不复杂,对吗?实践也不应该如此,所以让我们用一个React/Redux应用程序作为例子。

我们刚刚说过,使用React/Redux的connect是实现视图和状态层之间的依赖注入的一种方式,这就很直接了当了。但我们之前也说过,动作将业务逻辑委托给用例,那么我们如何将用例(应用层)注入动作(状态层)?

让我们想象一下,你有一个对象,它包含了你应用程序中每个用例的方法。这个对象通常被称为依赖性容器。是的,这看起来很奇怪,而且不会有很好的扩展,但这并不意味着用例的实现就在这个对象里面。这些只是委托给用例的方法,用例是在其他地方定义的。有一个包含你的应用程序的所有用例的单一对象比让它们散布在你的代码库中,使它们真的很难被找到要好得多。有了这个对象,我们需要做的就是把它注入到行动中,让它们各自决定触发什么用例,对吗?

如果你使用redux-thunk,用withExtraArgument方法实现它真的很简单,它允许你将容器注入到每个thunk动作中,作为getState之后的第三个参数。如果你使用redux-saga,方法应该也很简单,我们把容器作为run方法的第二个参数来传递。如果你使用的是Ember或Angular,内置的依赖注入机制应该足够了。

这样做将使动作与用例分离,因为你不需要在定义动作的每个文件中手动导入用例。此外,现在测试动作与用例分开是非常简单的:简单地注入一个假的用例实现,它的行为方式正是你想要的。你想测试如果用例失败会派发什么动作吗?注入一个总是失败的模拟用例,然后测试该动作如何响应。不需要考虑实际用例是如何工作的。

很好,我们把状态层注入到视图层,把应用层注入到状态层。那其他的呢?我们如何将依赖注入到用例中以构建依赖容器?这是一个很重要的问题,有很多方法可以做到这一点。首先,不要忘记检查你所使用的框架是否有内置的依赖注入,比如Angular或Ember。如果有,你就不应该建立你自己的。如果没有,你可以用两种方法来做:要么手动,要么借助软件包的帮助。

手动做应该是很简单的:

  • 将你的单元定义为类或闭包。
  • 首先实例化那些没有依赖关系的单元。
  • 实例化依赖它们的单元,将它们作为参数传递。
  • 反复进行,直到你把所有的用例都实例化了。
  • 导出它们。

代码:

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
};
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;
  }
};
export default ({ api }) => ({
  async add(userData) {
    const user = await api.post('/users', userData);

    return user;
  }
});

你会注意到重要的部分,即用例createUser 等,在文件的最后被实例化了,并且是唯一被导出的对象,因为它们将被注入到动作Action中。你的其他代码不需要知道存储库是如何创建的,以及它是如何工作的。这并不重要,这只是一个技术细节。
对于用例来说,版本库是否发送AJAX请求或在LocalStorage中持久化一些东西并不重要;用例没有责任知道这些。如果你想在你的API还在开发中时使用LocalStorage,然后转而使用对API的线上调用,只要与API交互的代码与与LocalStorage交互的代码遵循相同的接口,你就不需要改变用例。

即使你有几十个用例、存储库、服务等,你也可以像上面描述的那样,手动进行注入。如果建立所有的依赖关系太乱,你可以使用依赖注入包,只要它不增加耦合度。

测试你的DI包是否足够好的一个经验法则是,检查从手动方法到使用该库是否不需要接触比容器代码更多的东西。如果是这样,那么这个包就太麻烦了,你应该选择一个不同的包。如果你真的想使用一个包,我们推荐Awilix。它的使用相当简单,而且脱离了手动方式,只需要触碰容器文件即可。关于如何使用它和为什么使用它,有一个非常好的系列,由包的作者写的。