在 AngularJS 的负责人 Igor Minar 的 this post 中:
MVC vs MVVM vs MVP。这是一个多么有争议的话题,许多开发人员可以花费数小时进行辩论和争论。几年来,AngularJS 更接近于 MVC(或者更确切地说是它的客户端变体之一),但随着时间的推移,并且由于许多重构和 api 改进,它现在更接近于 MVVM——$scope 对象可以被认为是正在被由我们称为控制器的函数装饰。能够对框架进行分类并将其放入其中一个 MV* 存储桶具有一些优势。它可以帮助开发人员更轻松地使用它的 api,因为它可以更容易地创建一个代表正在使用该框架构建的应用程序的心理模型。它还可以帮助建立开发人员使用的术语。话虽如此,我宁愿看到开发人员构建设计良好并遵循关注点分离的出色应用程序,也不愿看到他们浪费时间争论 MV* 废话。出于这个原因,我在此声明 AngularJS 是 MVW 框架 - Model-View-Whatever。凡代表“任何对你有用的东西”。 Angular 为您提供了很大的灵活性,可以很好地将表示逻辑与业务逻辑和表示状态分开。请使用它来提高您的生产力和应用程序的可维护性,而不是就一天结束时无关紧要的事情进行激烈的讨论。
在客户端应用程序中实现 AngularJS MVW(Model-View-Whatever)设计模式是否有任何建议或指南?
感谢大量有价值的资源,我有一些关于在 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';
}]);
您所有的全局方法都将存在于“公共”属性中。这是某种命名空间。
但是不要直接在你的 $rootScope 中定义任何方法。在您的视图范围内与 ngModel 指令一起使用时,这可能会导致 unexpected behavior,通常会乱扔您的范围并导致范围方法覆盖问题。
资源
Resource 允许您与不同的数据源进行交互。
应该使用单一职责原则来实施。
在特定情况下,它是 HTTP/JSON 端点的可重用代理。
资源被注入模型并提供发送/检索数据的可能性。
资源实现
创建资源对象的工厂,可让您与 RESTful 服务器端数据源进行交互。
返回的资源对象具有提供高级行为而不需要与低级 $http 服务交互的操作方法。
服务
模型和资源都是服务。
服务是独立的、松散耦合的功能单元。
服务是 Angular 从服务器端为客户端 Web 应用程序带来的一项功能,服务已被普遍使用了很长时间。
Angular 应用程序中的服务是使用依赖注入连接在一起的可替代对象。
Angular 提供了不同类型的服务。每个都有自己的用例。请阅读Understanding Service Types了解详情。
尝试在您的应用程序中考虑 main principles of service architecture。
服务是一种抽象资源,表示执行任务的能力,从提供者实体和请求者实体的角度来看,这些任务形成了连贯的功能。要使用,服务必须由具体的提供者代理来实现。
客户端结构
通常,应用程序的客户端被拆分为模块。每个模块都应该作为一个单元进行测试。
尝试根据特性/功能或视图而不是类型来定义模块。有关详细信息,请参阅 Misko’s presentation。
模块组件通常可以按控制器、模型、视图、过滤器、指令等类型进行分组。
但是模块本身仍然是可重用、可转移和可测试的。
开发人员也更容易找到代码的某些部分及其所有依赖项。
详情请参阅Code Organization in Large AngularJS and JavaScript Applications。
文件夹结构示例:
|-- 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-app 实现了 Angular 应用程序结构的良好示例 - https://github.com/angular-app/angular-app/tree/master/client/src
现代应用程序生成器也考虑了这一点 - https://github.com/yeoman/generator-angular/issues/109
我相信 Igor 对此的看法,正如您提供的报价中所见,只是一个更大问题的冰山一角。
MVC 及其衍生产品(MVP、PM、MVVM)在单个代理中都很好而且很花哨,但是服务器-客户端架构在所有方面都是一个双代理系统,人们常常如此痴迷于这些模式,以至于他们忘记了这一点手头的问题要复杂得多。通过试图坚持这些原则,他们实际上最终会得到一个有缺陷的架构。
让我们一点一点地做这件事。
指导方针
意见
在 Angular 上下文中,视图是 DOM。指导方针是:
做:
当前范围变量(只读)。
调用控制器进行操作。
不:
放任何逻辑。
看起来很诱人,简短且无害:
ng-click="collapsed = !collapsed"
这几乎意味着任何开发人员现在要了解系统如何工作,他们需要检查 Javascript 文件和 HTML 文件。
控制器
做:
通过在范围上放置数据将视图绑定到“模型”。
响应用户操作。
处理表示逻辑。
不:
处理任何业务逻辑。
最后一条准则的原因是控制器是视图的姐妹,而不是实体。它们也不能重复使用。
您可能会争辩说指令是可重用的,但指令也是视图 (DOM) 的姐妹——它们从未打算与实体相对应。
当然,有时视图代表实体,但这是一个相当具体的情况。
换句话说,控制器应该专注于表现——如果你把业务逻辑放进去,不仅你最终可能会得到一个膨胀的、难以管理的控制器,而且你也违反了关注点分离原则。
因此,Angular 中的控制器实际上更像是 Presentation Model 或 MVVM。
所以,如果控制器不应该处理业务逻辑,谁应该呢?
什么是模型?
您的客户端模型通常不完整且陈旧
除非您正在编写一个离线 Web 应用程序,或者一个非常简单(很少实体)的应用程序,否则您的客户端模型很可能是:
部分 要么它没有所有实体(比如分页),要么它没有所有数据(比如分页)
要么它没有所有实体(比如在分页的情况下)
或者它没有所有数据(比如分页)
陈旧 - 如果系统有多个用户,在任何时候您都无法确定客户端持有的模型与服务器持有的模型相同。
真实模型必须坚持
在传统的 MCV 中,模型是唯一被持久化的东西。每当我们谈论模型时,这些都必须在某个时候坚持下去。您的客户端可以随意操作模型,但在成功完成与服务器的往返之前,工作还没有完成。
结果
上面两点应该作为一个警告——你的客户持有的模型只能涉及部分的,主要是简单的业务逻辑。
因此,在客户端上下文中,使用小写 M
可能是明智的 - 所以它实际上是 mVC、mVP 和 mVVm。大的 M
用于服务器。
商业逻辑
也许关于商业模式最重要的概念之一是你可以将它们细分为两种类型(我省略了第三种视图——商业模式,因为这是另一天的故事):
域逻辑 - 也称为企业业务规则,独立于应用程序的逻辑。例如,给一个具有 firstName 和 sirName 属性的模型,像 getFullName() 这样的 getter 可以被认为是独立于应用程序的。
应用程序逻辑 - 也就是应用程序业务规则,它是特定于应用程序的。例如,错误检查和处理。
需要强调的是,客户端上下文中的这两种逻辑都不是“真正的”业务逻辑——它们只处理对客户端重要的部分。应用程序逻辑(不是领域逻辑)应该负责促进与服务器的通信和大多数用户交互;而领域逻辑在很大程度上是小规模的、特定于实体的和表示驱动的。
问题仍然存在 - 您将它们放在角度应用程序中的什么位置?
3 vs 4层架构
所有这些 MVW 框架都使用 3 层:
https://i.stack.imgur.com/fsGn4.png
但是,当涉及到客户时,这有两个基本问题:
该模型是部分的、陈旧的并且不会持续存在。
没有地方放置应用程序逻辑。
此策略的替代方法是 4 layer strategy:
https://i.stack.imgur.com/6iZFu.jpg
这里真正的处理是应用程序业务规则层(用例),它经常在客户端出现问题。
该层由交互器(Bob 叔叔)实现,这几乎就是 Martin Fowler 所说的操作脚本服务层。
具体例子
考虑以下 Web 应用程序:
该应用程序显示用户的分页列表。
用户单击“添加用户”。
打开一个模型,其中包含一个填写用户详细信息的表单。
用户填写表格并点击提交。
现在应该发生一些事情:
表单应经过客户端验证。
应向服务器发送请求。
如果有错误,则应处理错误。
用户列表可能需要也可能不需要(由于分页)需要更新。
我们把这一切扔到哪里去了?
如果您的架构涉及调用 $resource
的控制器,那么所有这些都将在控制器内发生。但是有一个更好的策略。
建议的解决方案
下图显示了如何通过在 Angular 客户端中添加另一个应用程序逻辑层来解决上述问题:
https://i.stack.imgur.com/prEEe.png
所以我们在控制器和 $resource 之间添加了一个层,这个层(我们称之为交互器):
是一种服务。在用户的情况下,它可能被称为 UserInteractor。
它提供对应用例的方法,封装应用逻辑。
它控制对服务器的请求。该层不是使用自由格式参数调用 $resource 的控制器,而是确保向服务器发出的请求返回域逻辑可以操作的数据。
它用域逻辑原型装饰返回的数据结构。
因此,根据上面具体示例的要求:
用户单击“添加用户”。
控制器向交互者询问一个空白的用户模型,用业务逻辑方法装饰,如 validate()
提交后,控制器调用模型 validate() 方法。
如果失败,控制器将处理错误。
如果成功,控制器使用 createUser() 调用交互器
交互者调用 $resource
响应后,交互器将任何错误委托给控制器,由控制器处理。
成功响应后,交互者确保在需要时更新用户列表。
与 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
对象看起来“过于拥挤”,则表明该服务做得太多。
AngularJS 并没有以传统方式实现 MVC,而是实现了更接近 MVVM(Model-View-ViewModel)的东西,ViewModel 也可以称为 binder(在 angular 情况下可以是 $scope)。模型--> 正如我们所知,角度模型可以只是普通的旧 JS 对象或我们应用程序中的数据
View--> angularJS 中的视图是通过应用指令或指令或绑定由 angularJS 解析和编译的 HTML,这里的重点是 angular 输入不仅仅是普通的 HTML 字符串(innerHTML),而是是浏览器创建的DOM。
ViewModel--> ViewModel 实际上是 angularJS 中视图和模型之间的绑定器/桥梁,它是 $scope,用于初始化和扩充我们使用 Controller 的 $scope。
如果我想总结一下答案:在 angularJS 应用程序中,$scope 引用了数据,Controller 控制行为,View 通过与控制器交互来处理布局以相应地表现。
为了明确这个问题,Angular 使用了我们在常规编程中已经遇到的不同设计模式。 1) 当我们为我们的模块注册控制器或指令、工厂、服务等时。这里它隐藏了全局空间中的数据。这是模块模式。 2)当 Angular 使用它的脏检查来比较范围变量时,这里它使用观察者模式。 3)我们控制器中的所有父子范围都使用原型模式。 4)在注入服务的情况下,它使用工厂模式。
总的来说,它使用不同的已知设计模式来解决问题。
searchModel
的示例没有遵循可重用性建议。最好通过constant
服务导入常量。 3.任何解释这里的意思是什么?:Try to avoid having a factory that returns a new able function
prototype
属性会破坏继承,而是可以使用Car.prototype.save = ...
object
以确保您写入确切的属性或setter
函数。如果使用作用域的直接属性(不带点),您有可能在写入时将所需的目标属性隐藏在原型链中最近的上层作用域中.这在 Misko's presentation 中有更好的解释