AngularMVC-入门
在设计带有用户界面的软件时,以易于扩展和维护的方式构建代码非常重要。 随着时间的推移,出现了一些方法来分离应用程序不同组件的职责。 尽管周围有很多关于这些设计模式的文献,但是对于初学者来说,理解不同模式的局限性特征以及它们之间的区别可能会非常混乱。
在本教程中,我想谈谈主要的两种方法,模型-视图-控制器 (MVC) 模式和模型-视图-视图模型 (MVVM) 模式。 在 MVVM 模式中,控制器被 ViewModel 替换。 这两个组件之间的主要区别在于一侧的 View 与另一侧的 Controller 或 ViewModel 之间的依赖方向。
我将使用 TypeScript 和 Angular 编写的浏览器应用程序通过示例来开发这些想法并解释模式。 TypeScript 是 JavaScript 的扩展,可将类型信息添加到代码中。 该应用程序将模仿 MacOS/iOS 上流行的 Notes 应用程序。 Angular 强制执行 MVVM 模式。 让我们深入了解 MVC 和 MVVM 模式之间的主要区别。
使用 Angular CLI 设置您的应用程序
首先,您需要安装 Angular CLI。 确保首先安装了 Node 和 npm
。 如果您还没有这样做,请访问 node.js.org 并按照说明下载并安装 Node。 然后,在您的计算机上打开一个终端并运行 npm
命令来安装 Angular CLI。
npm install -g @angular/cli@7.2.1
根据您的系统配置,您可能必须使用 sudo
以系统管理员身份运行此命令。 这将在您的系统上全局安装 ng
命令。 ng
用于创建、操作、测试和构建 Angular 应用程序。 您可以通过在您选择的目录中运行 ng new
来创建一个新的 Angular 应用程序。
ng new AngularNotes
这将启动一个向导,引导您完成有关新应用程序的几个问题,然后创建目录布局和一些带有框架代码的文件。 第一个问题是关于路由模块的包含。 路由允许您通过更改浏览器路径导航到应用程序中的不同组件。 您需要对这个问题回答 yes。 第二个问题让你选择你想要使用的 CSS 技术。 因为我只会包含一些非常简单的样式表,所以普通的 CSS 格式就足够了。 回答完问题后,向导将开始下载并安装所有必要的组件。
您可以使用 Material Design 及其组件使应用程序看起来更漂亮。 这些可以使用应用程序目录中的 npm
命令安装。 ng new
命令应该已经创建了一个名为 AngularNotes
的目录。 导航到它并运行以下命令。
npm install --save @angular/material@7.2.1 @angular/cdk@7.2.1 @angular/animations@7.2.0 @angular/flex-layout@7.0.0-beta.23
src
目录包含应用程序源代码。 这里,src/index.html
是浏览器的主要入口点。 在您选择的文本编辑器中打开此文件,然后将以下行粘贴到 <head>
部分。 这将加载材质图标所需的字体。
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
src/style.css
样式表包含全局样式。 打开此文件并将以下样式粘贴到其中。
@import "~@angular/material/prebuilt-themes/deeppurple-amber.css"; body { margin: 0; font-family: sans-serif; } h1, h2 { text-align: center; }
接下来,打开 src/app/app.module.ts
。 此文件包含您希望全局可用的所有模块的导入。 用以下代码替换此文件的内容。
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { FlexLayoutModule } from "@angular/flex-layout"; import { MatToolbarModule, MatMenuModule, MatIconModule, MatInputModule, MatFormFieldModule, MatButtonModule, MatListModule, MatDividerModule } from '@angular/material'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @NgModule({ declarations: [ AppComponent, ], imports: [ BrowserModule, BrowserAnimationsModule, FlexLayoutModule, FormsModule, ReactiveFormsModule, MatToolbarModule, MatMenuModule, MatIconModule, MatInputModule, MatFormFieldModule, MatButtonModule, MatListModule, MatDividerModule, AppRoutingModule, ], bootstrap: [AppComponent] }) export class AppModule { }
此时,我可以开始向您展示如何在文件 src/app/app.component.html
中创建应用程序布局。 但这已经让我深入讨论应用程序架构。 相反,在下一节中,我想先引导您完成模型的实现。 我将在下一节讨论 View 及其与 ViewModel 的关系。
该模型
该模型包含应用程序的业务端。 对于简单的 CRUD(创建读取更新删除)应用程序,模型通常是简单的数据模型。 对于更复杂的应用程序,模型自然会反映出复杂性的增加。 在您在这里看到的应用程序中,模型将包含一个简单的文本注释数组。 每个音符都有一个 ID、一个 标题 和一个 文本。 在 Angular 中,模型被编码在所谓的 services 中。 ng
命令可让您创建新服务。
ng generate service Notes
这将创建两个新文件,src/app/notes.service.ts
和 src/app/notes.service.spec.ts
。 您可以忽略本教程中的第二个文件,就像其他 .spec.ts
文件一样。 这些文件用于对代码进行单元测试。 在您想要发布用于生产的应用程序中,您可以在那里编写测试。 打开 src/app/notes.service.ts
并将其内容替换为以下代码。
import { Injectable } from '@angular/core'; import { BehaviorSubject, Observer } from 'rxjs'; export class NoteInfo { id: number; title: string; } export class Note { id: number; title: string; text: string; } @Injectable({ providedIn: 'root' }) export class NotesService { private notes: Note[]; private nextId = 0; private notesSubject = new BehaviorSubject<NoteInfo[]>([]); constructor() { this.notes = JSON.parse(localStorage.getItem('notes')) || []; for (const note of this.notes) { if (note.id >= this.nextId) this.nextId = note.id+1; } this.update(); } subscribe(observer: Observer<NoteInfo[]>) { this.notesSubject.subscribe(observer); } addNote(title: string, text: string): Note { const note = {id: this.nextId++, title, text}; this.notes.push(note); this.update(); return note; } getNote(id: number): Note { const index = this.findIndex(id); return this.notes[index]; } updateNote(id: number, title: string, text: string) { const index = this.findIndex(id); this.notes[index] = {id, title, text}; this.update(); } deleteNote(id: number) { const index = this.findIndex(id); this.notes.splice(index, 1); this.update(); } private update() { localStorage.setItem('notes', JSON.stringify(this.notes)); this.notesSubject.next(this.notes.map( note => ({id: note.id, title: note.title}) )); } private findIndex(id: number): number { for (let i=0; i<this.notes.length; i++) { if (this.notes[i].id === id) return i; } throw new Error(`Note with id ${id} was not found!`); } }
在文件顶部附近,您可以看到两个类定义,NoteInfo
和 Note
。 Note
类包含音符的完整信息,而 NoteInfo
仅包含 id
和 title
。 这个想法是 NoteInfo
更轻,可以在列表中使用,显示所有笔记标题。 Note
和 NoteInfo
都是简单的数据类,不包含业务逻辑。 逻辑包含在 NotesService
中,它充当应用程序的模型。 它包含许多属性。 notes
属性是 Notes
对象的数组。 该数组充当模型的真实来源。 函数 addNote
、getNote
、updateNote
和 deleteNote
定义了模型上的 CRUD 操作。 它们都直接作用于 notes
数组,创建、读取、更新和删除数组中的元素。 nextId
属性用作可以引用注释的唯一 ID。
您会注意到,每当修改 notes
数组时,就会调用私有的 update
方法。 这个方法做了两件事。 首先,它将笔记保存在本地存储中。 只要浏览器的本地存储没有被删除,这就会将数据持久化到本地。 这允许用户关闭应用程序并稍后打开它,并且仍然可以访问他们的笔记。 在实际应用程序中,CRUD 操作将访问不同服务器上的 REST API,而不是在本地保存数据。
update
执行的第二个操作是在 notesSubject
属性上发出一个新值。 notesSubject
是来自 RxJS 的 BehaviorSubject
,它包含一个压缩的 NoteInfo
对象数组。 BehaviorSubject
充当任何观察者都可以订阅的可观察对象。 通过 NotesService
的 subscribe
方法可以进行此订阅。 每当调用 update
时,任何已订阅的观察者都会收到通知。
从模型的实现中带走的主要内容是,模型是一个独立的服务,不知道任何视图或控制器。 这在 MVC 和 MVVM 架构中都很重要。 模型不得对其他组件有任何依赖。
风景
接下来,我想将您的注意力转向视图。 在 Angular 应用程序中,视图位于 .html
模板和 .css
样式表中。 我已经在文件 src/app/app.component.html
中提到了其中一个模板。 打开文件并将以下内容粘贴到其中。
<mat-toolbar color="primary" class="expanded-toolbar"> <span> <button mat-button routerLink="/">{{title}}</button> <button mat-button routerLink="/"><mat-icon>home</mat-icon></button> </span> <button mat-button routerLink="/notes"><mat-icon>note</mat-icon></button> </mat-toolbar> <router-outlet></router-outlet>
为什么不添加一些样式呢? 打开 src/app/app.component.css
并添加以下样式。
.expanded-toolbar { justify-content: space-between; align-items: center; }
app.component
包含主页布局,但没有任何有意义的内容。 您将不得不添加一些将呈现任何内容的组件。 像这样再次使用 ng generate
命令。
ng generate component Home ng generate component Notes
这会生成两个组件。 每个组件由 .html
、.css
和 .ts
文件组成。 现在,不用担心 .ts
文件。 我将在下一节中讨论。 (请记住,还有一个 .spec.ts
文件,我在本教程中完全忽略了它。)
打开src/app/home/home.component.html
,将内容改为如下。
<h1>Angular Notes</h1> <h2>A simple app showcasing the MVVM pattern.</h2>
接下来,打开src/app/notes/notes.component.html
,将内容替换为下面的代码。
<div fxLayout="row" fxLayout.xs="column" fxLayoutAlign="center" class="notes"> <mat-list fxFlex="100%" fxFlex.gt-sm="20%"> <mat-list-item *ngFor='let note of notes'> <a> {{note.title}} </a> </mat-list-item> </mat-list> <mat-divider fxShow="false" fxShow.gt-sm [vertical]="true"></mat-divider> <mat-divider fxShow="true" fxShow.gt-sm="false" [vertical]="false"></mat-divider> <div fxFlex="100%" fxFlex.gt-sm="70%" *ngIf="!editNote" class="note-container"> <h3>{{currentNote.title}}</h3> <p> {{currentNote.text}} </p> <div fxLayout="row" fxLayoutAlign="space-between center" > <button mat-raised-button color="primary">Edit</button> <button mat-raised-button color="warn">Delete</button> <button mat-raised-button color="primary">New Note</button> </div> </div> <div fxFlex="100%" fxFlex.gt-sm="70%" *ngIf="editNote" class="form-container"> <form [formGroup]="editNoteForm"> <mat-form-field class="full-width"> <input matInput placeholder="Title" formControlName="title"> </mat-form-field> <mat-form-field class="full-width"> <textarea matInput placeholder="Note text" formControlName="text"></textarea> </mat-form-field> <button mat-raised-button color="primary">Update</button> </form> </div> </div>
随附的 src/app/notes/notes.component.css
应如下所示。
.notes { padding: 1rem; } .notes a { cursor: pointer; } .form-container, .note-container { padding-left: 2rem; padding-right: 2rem; } .full-width { width: 80%; display: block; }
到现在为止还挺好!
查看代表应用程序主视图的 src/app/notes/notes.component.html
。 您会注意到诸如 模板:Note.title
之类的占位符,它们看起来可以用值填充。 在上面显示的版本中,视图似乎没有引用应用程序中的任何代码。
如果您要遵循 MVC 模式,视图将定义可以插入数据的插槽。 它还将提供用于在单击按钮时注册回调的方法。 在这方面,视图将完全不了解控制器。 控制器会主动填充值并向视图注册回调方法。 只有控制器会知道视图和模型,并将两者联系在一起。
正如您将在下面看到的,Angular 采用了一种不同的方法,称为 MVVM 模式。 在这里,Controller 被 ViewModel 替换。 这将是下一节的主题。
视图模型
ViewModel 位于组件的 .ts
文件中。 打开 src/app/notes/notes.component.ts
并填写下面的代码。
import { Component, OnInit } from '@angular/core'; import { Note, NoteInfo, NotesService } from '../notes.service'; import { BehaviorSubject } from 'rxjs'; import { FormGroup, FormBuilder, Validators } from '@angular/forms'; @Component({ selector: 'app-notes', templateUrl: './notes.component.html', styleUrls: ['./notes.component.css'] }) export class NotesComponent implements OnInit { notes = new BehaviorSubject<NoteInfo[]>([]); currentNote: Note = {id:-1, title: '', text:''}; createNote = false; editNote = false; editNoteForm: FormGroup; constructor(private formBuilder: FormBuilder, private notesModel: NotesService) { } ngOnInit() { this.notesModel.subscribe(this.notes); this.editNoteForm = this.formBuilder.group({ title: ['', Validators.required], text: ['', Validators.required] }); } onSelectNote(id: number) { this.currentNote = this.notesModel.getNote(id); } noteSelected(): boolean { return this.currentNote.id >= 0; } onNewNote() { this.editNoteForm.reset(); this.createNote = true; this.editNote = true; } onEditNote() { if (this.currentNote.id < 0) return; this.editNoteForm.get('title').setValue(this.currentNote.title); this.editNoteForm.get('text').setValue(this.currentNote.text); this.createNote = false; this.editNote = true; } onDeleteNote() { if (this.currentNote.id < 0) return; this.notesModel.deleteNote(this.currentNote.id); this.currentNote = {id:-1, title: '', text:''}; this.editNote = false; } updateNote() { if (!this.editNoteForm.valid) return; const title = this.editNoteForm.get('title').value; const text = this.editNoteForm.get('text').value; if (this.createNote) { this.currentNote = this.notesModel.addNote(title, text); } else { const id = this.currentNote.id; this.notesModel.updateNote(id, title, text); this.currentNote = {id, title, text}; } this.editNote = false; } }
在类的 @Component
装饰器中,可以看到对 View .html
和 .css
文件的引用。 另一方面,在课程的其余部分中,没有任何对视图的引用。 相反,包含在 NotesComponent
类中的 ViewModel 公开了 View 可以访问的属性和方法。 这意味着,与 MVC 架构相比,依赖关系是相反的。 ViewModel 不了解 View,但提供了一个 View 可以使用的类 Model API。 如果你再看一下src/app/notes/notes.component.html
你可以看到模板插值,例如模板:CurrentNote.text
直接访问NotesComponent
的属性。
使您的应用程序工作的最后一步是告诉路由器哪些组件负责不同的路由。 打开 src/app/app-routing.module.ts
并编辑内容以匹配下面的代码。
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { HomeComponent } from './home/home.component'; import { NotesComponent } from './notes/notes.component'; const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'notes', component: NotesComponent }, ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
这会将 HomeComponent
链接到默认路由,将 NotesComponent
链接到 notes
路由。
对于主要的应用程序组件,我将定义一些稍后将实现的方法。 打开 src/app/app.component.ts
并将内容更新为如下所示。
import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { public title = 'Angular Notes'; public isAuthenticated: boolean; ngOnInit() { this.isAuthenticated = false; } login() { } logout() { } }
该组件包含两个属性 title
和 isAuthenticated
。 其中第二个是指示用户是否已登录应用程序的标志。 现在,它只是设置为 false
。 两个空方法作为回调来触发登录或注销。 目前,我将它们留空,但稍后您将填写它们。
完成视图
有了关于依赖方向的这些知识,您可以更新 View,以便按钮和表单在 ViewModel 上执行操作。 再次打开 src/app/notes/notes.component.html
并将代码更改为如下所示。
<div fxLayout="row" fxLayout.xs="column" fxLayoutAlign="center" class="notes"> <mat-list fxFlex="100%" fxFlex.gt-sm="20%"> <mat-list-item *ngFor='let note of notes | async'> <a (click)="onSelectNote(note.id)"> {{note.title}} </a> </mat-list-item> </mat-list> <mat-divider fxShow="false" fxShow.gt-sm [vertical]="true"></mat-divider> <mat-divider fxShow="true" fxShow.gt-sm="false" [vertical]="false"></mat-divider> <div fxFlex="100%" fxFlex.gt-sm="70%" *ngIf="!editNote" class="note-container"> <h3>{{currentNote.title}}</h3> <p> {{currentNote.text}} </p> <div fxLayout="row" fxLayoutAlign="space-between center" > <button mat-raised-button color="primary" (click)="onEditNote()" *ngIf="noteSelected()">Edit</button> <button mat-raised-button color="warn" (click)="onDeleteNote()" *ngIf="noteSelected()">Delete</button> <button mat-raised-button color="primary" (click)="onNewNote()">New Note</button> </div> </div> <div fxFlex="100%" fxFlex.gt-sm="70%" *ngIf="editNote" class="form-container"> <form [formGroup]="editNoteForm" (ngSubmit)="updateNote()"> <mat-form-field class="full-width"> <input matInput placeholder="Title" formControlName="title"> </mat-form-field> <mat-form-field class="full-width"> <textarea matInput placeholder="Note text" formControlName="text"></textarea> </mat-form-field> <button mat-raised-button color="primary">Update</button> </form> </div> </div>
您可以在各个地方看到 (click)
处理程序,直接引用 NotesComponent
类的方法。 这意味着 View 需要了解 ViewModel 及其方法。 反转依赖的原因是减少了样板代码。 View 和 ViewModel 之间存在双向数据绑定。 View 中的数据始终与 ViewModel 中的数据同步。
向您的 Angular 应用程序添加身份验证
如果没有适当的用户身份验证,一个好的应用程序是不完整的。 在本节中,您将学习如何快速向现有的 Angular 应用程序添加身份验证。 Okta 提供单点登录身份验证,只需几行代码即可将其插入应用程序。
您需要使用 Okta 的 免费开发者帐户 。 只需在显示的表格中填写您的详细信息,接受条款和条件,然后按开始提交即可。 完成注册后,您将被带到 Okta 仪表板。 在这里,您可以查看使用 Okta 服务注册的所有应用程序的概览。
点击 Add Application 注册一个新的应用程序。 在出现的下一个屏幕上,您可以选择应用程序的类型。 单页应用程序 是您的 Angular 应用程序的正确选择。 在随后的页面上,您将看到应用程序设置。 当您使用 ng serve
测试您的应用程序时,您需要将端口号更改为 4200。
就是这样。 现在您应该会看到稍后需要的客户端 ID。 现在您已准备好将身份验证服务包含到您的代码中。 Okta 为 Angular 提供了一个方便的库。 您可以通过在应用程序根目录中运行以下命令来安装它。
npm install @okta/okta-angular@1.0.7 --save
打开app.module.ts
并导入OktaAuthModule
。
import { OktaAuthModule } from '@okta/okta-angular';
再往下,在同一个文件的 imports
列表中添加以下内容。
OktaAuthModule.initAuth({ issuer: 'https://{yourOktaDomain}/oauth2/default', redirectUri: 'http://localhost:4200/implicit/callback', clientId: '{clientId}' })
在此代码段中,需要将 {clientId}
替换为您刚刚在 Okta 开发人员仪表板中获得的客户端 ID。
为了保护特定路由不被无密码访问,您需要修改src/app/app-routing.module.ts
。 为 OktaCallbackComponent
和 OktaAuthGuard
添加导入。
import { OktaCallbackComponent, OktaAuthGuard } from '@okta/okta-angular';
接下来,将另一条路线添加到路线数组中。
{ path: 'implicit/callback', component: OktaCallbackComponent }
当用户完成登录过程时,Okta 将调用 implicit/callback
路由。 OktaCallbackComponent
处理结果并将用户重定向到请求身份验证过程的页面。 要保护单个路由,您现在可以简单地将 OktaAuthGuard
添加到该路由,如下所示。
{ path: 'notes', component: NotesComponent, canActivate: [OktaAuthGuard] }
请记住,您没有实现主应用程序 ViewModel。 再次打开 src/app/app.component.ts
并将以下导入添加到文件顶部。
import { OktaAuthService } from '@okta/okta-angular';
接下来,实现 AppComponent
类的所有方法。
constructor(public oktaAuth: OktaAuthService) {} async ngOnInit() { this.isAuthenticated = await this.oktaAuth.isAuthenticated(); } login() { this.oktaAuth.loginRedirect(); } logout() { this.oktaAuth.logout('/'); }
只剩下一件事要做了。 您现在可以将登录和注销按钮添加到顶部栏。 打开 src/app/app.component.html
并将这两行添加到 <mat-toolbar>
元素中,在关闭 </span>
之后。
<button mat-button *ngIf="!isAuthenticated" (click)="login()"> Login </button> <button mat-button *ngIf="isAuthenticated" (click)="logout()"> Logout </button>
Login 和 Logout 按钮链接到 app.component.ts
ViewModel 中的 login()
和 logout()
方法。 这两个按钮的可见性由 ViewModel 中的 isAuthenticated
标志确定。
这里的所有都是它的! 现在,您拥有了一个基于 MVVM 架构的完整应用程序,并完成了身份验证。 您可以通过启动应用程序根目录中的 Angular 测试服务器来测试它。
ng serve
打开浏览器并导航到 http://localhost:4200
。 你应该看到这样的东西。
了解有关 Angular 和安全应用程序开发的更多信息
在本教程中,我向您展示了 Angular 如何基于 MVVM 设计模式,以及该模式与更广为人知的 MVC 模式有何不同。 在 MVC 模式中,Controller 通过使用其他两个组件提供的 Observers 和 Observables 将 View 与 Model 简单地链接起来。 一旦控制器建立了连接,视图和模型直接通信,但不知道他们在与谁通信。 具体来说,控制器不拥有自己的应用程序状态。 它只是在 View 和 Model 之间建立连接的促进者。 在 MVVM 模式中,Controller 被 ViewModel 取代。 View 和 ViewModel 通过双向数据绑定链接。 它们共享相同的状态。
要了解有关 MVC 和 MVVM 设计模式的更多信息,您可能对以下链接感兴趣。
本教程的代码位于 oktadeveloper/okta-angular-notes-app-example。
如果您喜欢这篇文章,那么您很可能会喜欢我们发布的其他文章。 在 Twitter 上关注 @oktadev 并订阅 我们的 YouTube 频道 以获取更多优秀教程。