AngularJS:理解设计模式

150
在 AngularJS 领导 Igor Minar 的这篇this post中,上下文如下:
"MVC vs MVVM vs MVP". 这是一个很有争议的话题,许多开发者可以花费数小时来辩论和争论。几年来,AngularJS 更接近于 MVC(或者说是其客户端变体之一),但随着时间的推移和许多重构和 API 改进,它现在更接近于 MVVM——$scope 对象可以被认为是 ViewModel,由我们称为 Controller 的函数进行装饰。能够将框架分类并放入 MV* 桶中具有一些优点。它可以通过使开发人员更容易地创建代表使用该框架构建的应用程序的心理模型来帮助开发人员更加熟悉其 API。它还可以帮助建立开发人员使用的术语。然而,我更希望看到开发人员构建出设计良好、关注职责分离的强大应用程序,而不是看到他们浪费时间争论 MV* 的无聊问题。因此,我在此宣布 AngularJS 是 MVW 框架——Model-View-Whatever。其中 Whatever 代表“适合你的任何东西”。Angular 提供了很多灵活性,可以很好地将表示逻辑与业务逻辑和表示状态分离。请使用它来提高生产力和应用程序可维护性,而不是对于最终并不那么重要的事情进行激烈的讨论。
客户端应用程序中实现AngularJS MVW(Model-View-Whatever)设计模式是否有建议或指南?

点赞支持,胜过看他们浪费时间争论MV*的无聊话题。 - Farhan stands with Palestine
1
你不需要Angular来遵循世界级的设计模式。 - usefulBee
5个回答

225

感谢大量宝贵的资源,我得到了一些关于在AngularJS应用程序中实现组件的通用建议:


控制器

  • 控制器应该只是模型和视图之间的中间层。尽可能使其轻量化

  • 强烈建议在控制器中避免业务逻辑。应该将其移动到模型中。

  • 控制器可以使用方法调用(当子级想要与父级通信时)或$emit$broadcast$on方法与其他控制器通信。发出和广播的消息应最小化。

  • 控制器不应该关心演示或DOM操作。

  • 尽量避免嵌套控制器。在这种情况下,父控制器被解释为模型。而应该注入作为共享服务的模型。

  • 控制器中的作用域应该用于将模型与视图进行绑定和封装视图模型,以实现表示模型设计模式。


作用域

在模板中,将作用域视为只读;在控制器中,将其视为只写。作用域的目的是引用模型,而不是成为模型。

进行双向绑定(ng-model)时,请确保不直接绑定到作用域属性。


模型

在AngularJS中,模型是由服务定义的单例

模型提供了一种优秀的分离数据和显示的方式。

模型是单元测试的主要候选对象,因为它们通常具有一个依赖项(某种形式的事件发射器,在常见情况下是$rootScope),并包含高度可测试的领域逻辑

  • 模型应被视为特定单元的实现。它基于单一责任原则。单元是一个实例,负责其自身范围内的相关逻辑,可以在编程世界中以数据和状态的形式表示现实世界中的单个实体。

  • 模型应该封装应用程序的数据,并提供访问和操作该数据的 API。

  • 模型应该是可移植的,因此可以轻松地传输到类似的应用程序。

  • 通过将单元逻辑隔离在模型中,您已经使其更易于定位、更新和维护。

  • 模型可以使用更通用的全局模型的方法,这些方法对整个应用程序都是常见的。

  • 如果不是真正依赖于减少组件耦合并增加单元测试性和可用性,请避免将其他模型组合到您的模型中使用依赖注入。

  • 请避免在模型中使用事件监听器。它会使它们更难以测试,并且通常会破坏单一责任原则。

模型实现

由于模型应该在数据和状态方面封装一些逻辑,因此在架构上应限制对其成员的访问,从而可以保证松耦合。

在 AngularJS 应用程序中定义模型的方法是使用工厂服务类型进行定义。这将使我们能够非常轻松地定义私有属性和方法,并在单个位置返回公共可访问的属性和方法,这将使开发人员真正易读。

示例:

angular.module('search')
.factory( 'searchModel', ['searchResource', function (searchResource) {

  var itemsPerPage = 10,
  currentPage = 1,
  totalPages = 0,
  allLoaded = false,
  searchQuery;

  function init(params) {
    itemsPerPage = params.itemsPerPage || itemsPerPage;
    searchQuery = params.substring || searchQuery;
  }

  function findItems(page, queryParams) {
    searchQuery = queryParams.substring || searchQuery;

    return searchResource.fetch(searchQuery, page, itemsPerPage).then( function (results) {
      totalPages = results.totalPages;
      currentPage = results.currentPage;
      allLoaded = totalPages <= currentPage;

      return results.list
    });
  }

  function findNext() {
    return findItems(currentPage + 1);
  }

  function isAllLoaded() {
    return allLoaded;
  }

  // return public model API  
  return {
    /**
     * @param {Object} params
     */
    init: init,

    /**
     * @param {Number} page
     * @param {Object} queryParams
     * @return {Object} promise
     */
    find: findItems,

    /**
     * @return {Boolean}
     */
    allLoaded: isAllLoaded,

    /**
     * @return {Object} promise
     */
    findNext: findNext
  };
});

创建新实例

尽量避免使用返回新函数的工厂,因为这会破坏依赖注入,使库的行为变得尴尬,特别是对于第三方。

完成相同任务的更好方法是将工厂用作 API,返回附有 getter 和 setter 方法的对象集合。

angular.module('car')
 .factory( 'carModel', ['carResource', function (carResource) {

  function Car(data) {
    angular.extend(this, data);
  }

  Car.prototype = {
    save: function () {
      // TODO: strip irrelevant fields
      var carData = //...
      return carResource.save(carData);
    }
  };

  function getCarById ( id ) {
    return carResource.getById(id).then(function (data) {
      return new Car(data);
    });
  }

  // the public API
  return {
    // ...
    findById: getCarById
    // ...
  };
});

全局模型

通常情况下,尽量避免这种情况并正确设计您的模型,以便可以将其注入到控制器中并在视图中使用。

特殊情况下,某些方法需要应用程序内的全局访问权限。为了实现这一点,您可以在应用程序引导期间在$rootScope中定义“common”属性,并将其绑定到commonModel

angular.module('app', ['app.common'])
.config(...)
.run(['$rootScope', 'commonModel', function ($rootScope, commonModel) {
  $rootScope.common = 'commonModel';
}]);

所有的全局方法都将位于“common”属性内。这是某种命名空间

但不要在$rootScope中直接定义任何方法。这可能会导致使用ngModel指令时出现意外行为,通常会使您的作用域混乱并导致作用域方法被覆盖的问题。


资源

资源让您与不同的数据源交互。

应该使用单一职责原则(single-responsibility-principle)进行实现。

在特定情况下,它是一个可重复使用的代理,用于HTTP/JSON端点。

资源被注入到模型中,并提供发送/检索数据的可能性。

资源实现

创建资源对象的工厂,允许您与RESTful服务器端数据源进行交互。

返回的资源对象具有操作方法,提供高级行为而无需与低级别的$http服务进行交互。


服务

模型和资源都是服务

服务是松耦合的、不相关的功能单元,它们是自包含的。

服务是 Angular 将客户端 Web 应用程序从服务器端带来的一个特性,在服务器端,服务一直是常用的。

Angular 提供了不同类型的服务。每个服务都有自己独特的用例。请阅读理解服务类型获取详细信息。

尽量考虑应用程序中的服务体系结构主要原则

通常来说,根据Web 服务词汇表

  

服务是表示在提供者实体和请求者实体的角度上形成一致功能的任务的能力的抽象资源。为了使用,服务必须由一个具体的提供者代理实现。


客户端结构

通常情况下,应用程序的客户端被分成模块。每个模块应该作为一个单元进行可测试

尝试根据功能/特性视图定义模块,而不是按类型定义。请参见Misko的演示获取详细信息。

模块组件可以按照控制器、模型、视图、过滤器、指令等类型进行传统分组。

但是模块本身是可重用的、可转移的和可测试的。

开发人员也更容易找到代码的某些部分及其所有依赖项。

详细信息请参见《大型AngularJS和JavaScript应用程序中的代码组织》

文件夹结构示例:

|-- src/
|   |-- app/
|   |   |-- app.js
|   |   |-- home/
|   |   |   |-- home.js
|   |   |   |-- homeCtrl.js
|   |   |   |-- home.spec.js
|   |   |   |-- home.tpl.html
|   |   |   |-- home.less
|   |   |-- user/
|   |   |   |-- user.js
|   |   |   |-- userCtrl.js
|   |   |   |-- userModel.js
|   |   |   |-- userResource.js
|   |   |   |-- user.spec.js
|   |   |   |-- user.tpl.html
|   |   |   |-- user.less
|   |   |   |-- create/
|   |   |   |   |-- create.js
|   |   |   |   |-- createCtrl.js
|   |   |   |   |-- create.tpl.html
|   |-- common/
|   |   |-- authentication/
|   |   |   |-- authentication.js
|   |   |   |-- authenticationModel.js
|   |   |   |-- authenticationService.js
|   |-- assets/
|   |   |-- images/
|   |   |   |-- logo.png
|   |   |   |-- user/
|   |   |   |   |-- user-icon.png
|   |   |   |   |-- user-default-avatar.png
|   |-- index.html

一个良好的angular应用程序结构示例是由angular-app实现的 - https://github.com/angular-app/angular-app/tree/master/client/src

这也被现代应用程序生成器考虑 - https://github.com/yeoman/generator-angular/issues/109


5
我对以下内容有疑虑:“强烈建议避免在控制器中使用业务逻辑。应将其移至模型。”然而,从官方文件中可以看到:“一般来说,控制器不应尝试做太多事情。它应该只包含单个视图所需的业务逻辑。” 我们正在谈论同样的事情吗? - op1ekun
3
我会说 - 把控制器当作视图模型来对待。 - Artem Platonov
1
+1. 这里有一些很棒的建议! 2. 不幸的是,searchModel 的示例没有遵循可重用性建议。最好通过 constant 服务导入常量。 3. 这里的意思是尽量避免使用返回新函数的工厂。 - Dmitri Zaitsev
1
你是什么意思:“在进行双向绑定(ng-model)时,请确保不直接绑定到作用域属性。”? - Christian Aichinger
2
@ChristianAichinger,这是关于JavaScript原型链的本质问题,它强制你要么在双向绑定表达式中使用object来确保你写入到确切的属性或setter函数,要么在不使用点号的情况下直接使用作用域的属性时,你有可能会在原型链中最近的上层作用域中隐藏所需的目标属性并创建新的属性。这在Misko的演讲中有更好的解释。 - Artem Platonov
显示剩余6条评论

47
我相信伊戈尔的看法,正如你提供的引用所示,这只是一个更大问题的冰山一角。
MVC及其衍生物(MVP、PM、MVVM)在单个代理中都很好,但服务器-客户端架构在所有目的上都是一个双代理系统,人们常常对这些模式如此着迷,以至于忘记了手头的问题实际上更加复杂。尝试遵循这些原则,他们最终会得到一个有缺陷的架构。
让我们逐步来做这件事。
指南
视图
在Angular上下文中,视图是DOM。指南如下:
Do:
呈现作用域变量(只读)。
调用控制器进行操作。
Don't:
放置任何逻辑。
尽管这看起来很诱人、简短且无害:
ng-click="collapsed = !collapsed"

这基本上意味着任何开发人员现在都需要检查JavaScript文件和HTML文件才能了解系统的工作原理。

控制器

做:

  • 通过将数据放置在作用域上将视图绑定到“模型”。
  • 响应用户操作。
  • 处理演示逻辑。

不要:

  • 处理任何业务逻辑。

最后一项指南的原因是,控制器是视图的姐妹,而不是实体;它们也不可重用。

您可以争论指令是可重用的,但指令也是视图(DOM)的姐妹 - 它们从未旨在对应实体。

当然,有时视图代表实体,但那是一个相当特殊的情况。

换句话说,控制器应专注于演示 - 如果您将业务逻辑投入其中,不仅可能会得到膨胀且难以管理的控制器,而且还会违反关注点分离原则。

因此,在Angular中,控制器实际上更像是演示模型MVVM

因此,如果控制器不应该处理业务逻辑,那么谁应该处理呢?

什么是模型?

您的客户端模型通常是部分和过期的

除非您编写的是离线网络应用程序或者是非常简单的应用程序(少量实体),否则您的客户端模型很可能是:

  • 部分
    • 要么它没有所有实体(例如分页)
    • 要么它没有所有数据(例如分页)
  • 过期 - 如果系统具有多个用户,则在任何时候都无法确定客户端持有的模型是否与服务器持有的模型相同。

真正的模型必须持久化

在传统的MVC中,模型是唯一被持久化的东西。每当我们谈论模型时,这些模型必须在某个时刻被持久化。您的客户端可以随意操作模型,但在成功完成与服务器的往返之前,工作还没有完成。

后果

上述两点应该作为一个警告 - 您的客户端持有的模型只能涉及部分、大多数简单的业务逻辑。

作为客户端上下文的一部分,使用小写的M也许是明智的选择,因此真正的应该是mVCmVPmVVm。大写的M是为服务器准备的。

业务逻辑

关于业务模型,最重要的概念之一或许是将其分成两类(我省略了第三个视图-业务,因为那是另外一天的事):

  • 领域逻辑 - 又称企业业务规则,这是与应用程序无关的逻辑。例如,对于一个具有firstNamesirName属性的模型,像getFullName()这样的getter被认为是与应用程序无关的。
  • 应用逻辑 - 又称应用业务规则,这是特定于应用程序的逻辑。例如,错误检查和处理。
重要的是强调,在客户端环境中,这两种方式都不是“真正的”业务逻辑——它们只处理对客户端重要的部分。应用程序逻辑(而不是领域逻辑)应该负责促进与服务器的通信和大多数用户交互;而领域逻辑在很大程度上是小规模、实体特定和以呈现为驱动的。
问题仍然存在——在angular应用程序中,你把它们放在哪里?

3层架构 vs 4层架构

所有这些MVW框架使用3层:

Three circles. Inner - model, middle - controller, outer - view

但是,当涉及到客户端时,这种方法存在两个根本性问题:

  • 模型是部分的、过时的,且不持久。
  • 没有地方放置应用程序逻辑。

这种策略的替代方案是 4层策略

4 circles, from inner to outer - Enterprise business rules, Application business rules, Interface adapters, Frameworks and drivers

真正重要的是应用程序业务规则层(用例),这在客户端经常被忽略。

这一层由交互器(Uncle Bob)实现,这基本上就是Martin Fowler所称的操作脚本服务层

具体例子

考虑以下Web应用程序:

  • 应用程序显示用户的分页列表。
  • 用户点击“添加用户”。
  • 一个模型打开,其中包含一个填写用户详细信息的表单。
  • 用户填写表单并提交。

现在应该发生一些事情:

  • 表单应该进行客户端验证。
  • 将发送请求到服务器。
  • 如果有错误,应该处理错误。
  • 用户列表可能需要更新或不需要更新(由于分页)。

我们把所有这些放在哪里?

如果你的架构涉及调用$resource的控制器,则所有这些都将在控制器内发生。但是有一个更好的策略。

一个提出的解决方案

下面的图表显示了如何通过在Angular客户端中添加另一个应用程序逻辑层来解决上述问题:

4 boxes - DOM points to Controller, which points to Application logic, which points to $resource

所以我们在控制器和 $resource 之间添加了一层,这一层(称为交互层):
  • 是一个服务。在用户的情况下,它可能被称为UserInteractor
  • 提供相应于用例的方法,封装应用程序逻辑
  • 控制向服务器发出的请求。这一层确保向服务器发出的请求返回数据,以便域逻辑可以对其进行操作,而不是控制器使用自由形式参数调用 $resource。
  • 使用域逻辑原型修饰返回的数据结构。
因此,根据上述具体示例的要求:
  • 用户点击'添加用户'。
  • 控制器向交互器请求空用户模型,装饰业务逻辑方法,如 validate()
  • 提交时,控制器调用模型的 validate() 方法。
  • 如果失败,控制器处理错误。
  • 如果成功,控制器调用交互器的 createUser() 方法。
  • 交互器调用 $resource 。
  • 响应后,交互器将任何错误委托给控制器处理。
  • 在收到成功响应后,交互器确保如果需要,用户列表将更新。

AngularJS被定义为MVW(其中W代表任何内容),因为我可以选择使用控制器(其中包含所有业务逻辑)或视图模型/Presenter(没有业务逻辑,但只是一些用于填充视图的代码),并将BL放在单独的服务中。我说得对吗? - BAD_SEED
1
@RPallas,不,我没有(希望我有时间做这个)。我们目前正在尝试一种架构,其中“应用逻辑”只是一个边界交互器;它和控制器之间的解析器以及具有一些视图逻辑的视图模型之间的解析器。我们仍在进行实验,因此并不完全确定其优缺点。但是一旦完成,我希望能在某个地方写一篇博客。 - Izhaki
@Deminetix 这项服务会为模型添加一个 validate() 方法。因此,控制器仍然可以调用 validate() 并检查其返回值。验证逻辑在模型中实现,而不是在服务中;服务只是将其添加到原型中。 - Izhaki
1
@heringer 基本上,我们引入了模型 - 表示领域实体的面向对象构造。正是这些模型与资源进行通信,而不是控制器。它们封装领域逻辑。控制器调用模型,模型再调用资源。 - Izhaki
1
@alex440 不是。虽然我已经准备好了一个关于这个话题的严肃博客文章两个月了。圣诞节即将到来 - 可能那时候发布。 - Izhaki
显示剩余6条评论

5
与Artem的回答中提供的伟大建议相比,这只是一个小问题,但就代码可读性而言,我发现最好将API完全定义在return对象内部,以最小化来回查找变量定义的情况:
angular.module('myModule', [])
// or .constant instead of .value
.value('myConfig', {
  var1: value1,
  var2: value2
  ...
})
.factory('myFactory', function(myConfig) {
  ...preliminary work with myConfig...
  return {
    // comments
    myAPIproperty1: ...,
    ...
    myAPImethod1: function(arg1, ...) {
    ...
    }
  }
});

如果return对象变得“过于拥挤”,那就意味着服务正在做太多事情。

0

AngularJS并不是以传统的方式实现MVC,而是更接近于MVVM(Model-View-ViewModel),ViewModel也可以被称为绑定器(在Angular中它可以是$scope)。

模型-->正如我们所知道的,在Angular中,模型可以是普通的JS对象或应用程序中的数据。

视图-->在AngularJS中,视图是通过应用指令、指令或绑定解析和编译的HTML。这里的重点是在Angular中,输入不仅仅是纯HTML字符串(innerHTML),而是由浏览器创建的DOM。

ViewModel-->ViewModel实际上是在AngularJS中视图和模型之间的绑定器/桥梁,它是$scope。为了初始化和增强$scope,我们使用控制器。

如果我要总结答案:在AngularJS应用程序中,$scope引用数据,控制器控制行为,视图通过与控制器交互来处理布局以相应地表现。


-1
简单来说,Angular使用了我们在常规编程中已经遇到的不同设计模式。 1)当我们将控制器或指令、工厂、服务等注册到我们的模块时,它会隐藏全局空间中的数据。这就是模块模式。 2)当Angular使用其脏检查来比较作用域变量时,它使用观察者模式。 3)我们控制器中所有的父子作用域都使用原型模式。 4)在注入服务时,它使用工厂模式
总体而言,它使用了不同的已知设计模式来解决问题。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接