第一節:Angular 2.0 從0到1 (一)
第二節:Angular 2.0 從0到1 (二)
第三節:Angular 2.0 從0到1 (三)
第四節:Angular 2.0 從0到1 (四)
第五節:Angular 2.0 從0到1 (五)
第五節:多用戶版本的待辦事項應用
第四節我們完成的Todo的基本功能看起來還不錯,但是有個大問題,就是每個用戶看到的都是一樣的待辦事項,我們希望的是每個用戶擁有自己的待辦事項列表。我們來分析一下怎么做,如果每個todo對象帶一個UserId屬性是不是可以解決呢?好像可以,邏輯大概是這樣:用戶登錄后轉到/todo,TodoComponent得到當前用戶的UserId,然后調用TodoService中的方法,傳入當前用戶的UserId,TodoService中按UserId去篩選當前用戶的Todos。
但可惜我們目前的LoginComponent還是個實驗品,很多功能的缺失,我們是先去做Login呢,還是利用現有的Todo對象先試驗一下呢?我個人的習慣是先進行試驗。
數據驅動開發
按之前我們分析的,給todo加一個userId屬性,我們手動給我們目前的數據加上userId屬性吧。更改todo\todo-data.json
為下面的樣子:
{"todos": [{"id": "bf75769b-4810-64e9-d154-418ff2dbf55e","desc": "getting up","completed": false,"userId": 1},{"id": "5894a12f-dae1-5ab0-5761-1371ba4f703e","desc": "have breakfast","completed": true,"userId": 2},{"id": "0d2596c4-216b-df3d-1608-633899c5a549","desc": "go to school","completed": true,"userId": 1},{"id": "0b1f6614-1def-3346-f070-d6d39c02d6b7","desc": "test","completed": false,"userId": 2},{"id": "c1e02a43-6364-5515-1652-a772f0fab7b3","desc": "This is a te","completed": false,"userId": 1}]
}
如果你還沒有啟動json-server的話讓我們啟動它: json-server ./src/app/todo/todo-data.json
,然后打開瀏覽器在地址欄輸入http://localhost:3000/todos/?userId=2
你會看到只有userId=2
的json被輸出了
[{"id": "5894a12f-dae1-5ab0-5761-1371ba4f703e","desc": "have breakfast","completed": true,"userId": 2},{"id": "0b1f6614-1def-3346-f070-d6d39c02d6b7","desc": "test","completed": false,"userId": 2}
]
有興趣的話可以再試試http://localhost:3000/todos/?userId=2&completed=false
或其他組合查詢。現在todo
有了userId
字段,但我們還沒有User對象,User的json表現形式看起來應該是這樣:
{"id": 1,"username": "wang","password": "1234"}
當然這個表現形式有很多問題,比如密碼是明文的,這些問題我們先不管,但大概樣子是類似的。那么現在如果要建立User數據庫的話,我們應該新建一個user-data.json
{"users": [{"id": 1,"username": "wang","password": "1234"},{"id": 2,"username": "peng","password": "5678"}]
}
但這樣做的話感覺單獨為其建一個文件有點不值得,我們干脆把user和todo數據都放在一個文件吧,現在刪除./src/app/todo/todo-data.json
刪除,在src\app
下面新建一個data.json
//src\app\data.json
{"todos": [{"id": "bf75769b-4810-64e9-d154-418ff2dbf55e","desc": "getting up","completed": false,"userId": 1},{"id": "5894a12f-dae1-5ab0-5761-1371ba4f703e","desc": "have breakfast","completed": true,"userId": 2},{"id": "0d2596c4-216b-df3d-1608-633899c5a549","desc": "go to school","completed": true,"userId": 1},{"id": "0b1f6614-1def-3346-f070-d6d39c02d6b7","desc": "test","completed": false,"userId": 2},{"id": "c1e02a43-6364-5515-1652-a772f0fab7b3","desc": "This is a te","completed": false,"userId": 1}],"users": [{"id": 1,"username": "wang","password": "1234"},{"id": 2,"username": "peng","password": "5678"}]
}
當然有了數據,我們就得有對應的對象,基于同樣的理由,我們把所有的entity對象都放在一個文件:刪除src\app\todo\todo.model.ts
,在src\app
下新建一個目錄domain,然后在domain下新建一個entities.ts
,請別忘了更新所有的引用。
export class Todo {id: string;desc: string;completed: boolean;userId: number;
}
export class User {id: number;username: string;password: string;
}
驗證用戶賬戶的流程
我們來梳理一下用戶驗證的流程
存儲要訪問的URL
根據本地的已登錄標識判斷是否此用戶已經登錄,如果已登錄就直接放行
如果未登錄導航到登錄頁面 用戶填寫用戶名和密碼進行登錄
系統根據用戶名查找用戶表中是否存在此用戶,如果不存在此用戶,返回錯誤
如果存在對比填寫的密碼和存儲的密碼是否一致,如果不一致,返回錯誤
如果一致,存儲此用戶的已登錄標識到本地
導航到原本要訪問的URL即第一步中存儲的URL,刪掉本地存儲的URL
看上去我們需要實現
UserService:用于通過用戶名查找用戶并返回用戶
AuthService:用于認證用戶,其中需要利用UserService的方法
AuthGuard:路由攔截器,用于攔截到路由后通過AuthService來知道此用戶是否有權限訪問該路由,根據結果導航到不同路徑。
看到這里,你可能有些疑問,為什么我們不把UserService和AuthService合并呢?這是因為UserService是用于對用戶的操作的,不光認證流程需要用到它,我們未來要實現的一系列功能都要用到它,比如注冊用戶,后臺用戶管理,以及主頁要顯示用戶名稱等。
核心模塊
根據這個邏輯流程,我們來組織一下代碼。開始之前我們想把認證相關的代碼組織在一個新的模塊下,我們暫時叫它core
吧。在src\app
下新建一個core
目錄,然后在core
下面新建一個core.module.ts
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({imports: [CommonModule]
})
export class CoreModule {constructor (@Optional() @SkipSelf() parentModule: CoreModule) {if (parentModule) {throw new Error('CoreModule is already loaded. Import it in the AppModule only');}}
注意到這個模塊和其他模塊不太一樣,原因是我們希望只在應用啟動時導入它一次,而不會在其它地方導入它。在模塊的構造函數中我們會要求Angular把CoreModule注入自身,這看起來像一個危險的循環注入。不過,@SkipSelf
裝飾器意味著在當前注入器的所有祖先注入器中尋找CoreModule。如果該構造函數在我們所期望的AppModule中運行,就沒有任何祖先注入器能夠提供CoreModule的實例,于是注入器會放棄查找。默認情況下,當注入器找不到想找的提供商時,會拋出一個錯誤。 但@Optional
裝飾器表示找不到該服務也無所謂。 于是注入器會返回null,parentModule參數也就被賦成了空值,而構造函數沒有任何異常。
那么我們在什么時候會需要這樣一個模塊?比如在這個模塊中我們可能會要提供用戶服務(UserService),這樣的服務系統各個地方都需要,但我們不希望它被創建多次,希望它是一個單例。再比如某些只應用于AppComponent
模板的一次性組件,沒有必要共享它們,然而如果把它們留在根目錄,還是顯得太亂了。我們可以通過這種形式隱藏它們的實現細節。然后通過根模塊AppModule導入CoreModule來獲取其能力。
路由守衛
首先我們來看看Angular內建的路由守衛機制,在實際工作中我們常常會碰到下列需求:
該用戶可能無權導航到目標組件。 導航前需要用戶先登錄(認證)。
在顯示目標組件前,我們可能得先獲取某些數據。
在離開組件前,我們可能要先保存修改。
我們可能要詢問用戶:你是否要放棄本次更改,而不用保存它們?
我們可以往路由配置中添加守衛,來處理這些場景。守衛返回true
,導航過程會繼續;返回false
,導航過程會終止,且用戶會留在原地(守衛還可以告訴路由器導航到別處,這樣也取消當前的導航)。
路由器支持多種守衛:
用CanActivate來處理導航到某路由的情況。
用CanActivateChild處理導航到子路由的情況。
用CanDeactivate來處理從當前路由離開的情況。
用Resolve在路由激活之前獲取路由數據。
用CanLoad來處理異步導航到某特性模塊的情況。
在分層路由的每個級別上,我們都可以設置多個守衛。路由器會先按照從最深的子路由由下往上檢查的順序來檢查CanDeactivate
守護條件。然后它會按照從上到下的順序檢查CanActivate
守衛。如果任何守衛返回false
,其它尚未完成的守衛會被取消,這樣整個導航就被取消了。
本例中我們希望用戶未登錄前不能訪問todo,那么需要使用CanActivate
import { AuthGuardService } from '../core/auth-guard.service';
const routes: Routes = [{path: 'todo/:filter',canActivate: [AuthGuardService],component: TodoComponent}
];
當然光這么寫是沒有用的,下面我們來建立一個AuthGuardService
,命令行中鍵入ng g s core/auth-guard
(angular-cli對于Camel寫法的文件名是采用-
來分隔每個大寫的詞)。
import { Injectable, Inject } from '@angular/core';
import {CanActivate,Router,ActivatedRouteSnapshot,RouterStateSnapshot } from '@angular/router';@Injectable()
export class AuthGuardService implements CanActivate {constructor(private router: Router) { }canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {//取得用戶訪問的URLlet url: string = state.url;return this.checkLogin(url);}checkLogin(url: string): boolean {//如果用戶已經登錄就放行if (localStorage.getItem('userId') !== null) { return true; }//否則,存儲要訪問的URl到本地localStorage.setItem('redirectUrl', url);//然后導航到登陸頁面this.router.navigate(['/login']);//返回false,取消導航return false;}
}
觀察上面代碼,我們發現本地存儲的userId的存在與否決定了用戶是否已登錄的狀態,這當然是一個漏洞百出的實現,但我們暫且不去管它。現在我們要在登錄時把這個狀態值寫進去。我們新建一個登錄鑒權的AuthService
:ng g s core/auth
import { Injectable, Inject } from '@angular/core';
import { Http, Headers, Response } from '@angular/http';import 'rxjs/add/operator/toPromise';
import { Auth } from '../domain/entities';@Injectable()
export class AuthService {constructor(private http: Http, @Inject('user') private userService) { }loginWithCredentials(username: string, password: string): Promise<Auth> {return this.userService.findUser(username).then(user => {let auth = new Auth();localStorage.removeItem('userId');let redirectUrl = (localStorage.getItem('redirectUrl') === null)?'/': localStorage.getItem('redirectUrl');auth.redirectUrl = redirectUrl;if (null === user){auth.hasError = true;auth.errMsg = 'user not found';} else if (password === user.password) {auth.user = Object.assign({}, user);auth.hasError = false;localStorage.setItem('userId',user.id);} else {auth.hasError = true;auth.errMsg = 'password not match';}return auth;}).catch(this.handleError);}private handleError(error: any): Promise<any> {console.error('An error occurred', error); // for demo purposes onlyreturn Promise.reject(error.message || error);}
}
注意到我們返回了一個Auth對象,這是因為我們要知道幾件事:
用戶最初要導航的頁面URL
用戶對象
如果發生錯誤的話,是什么錯誤,我們需要反饋給用戶
這個Auth對象同樣在src\app\domain\entities.ts
中聲明
export class Auth {user: User;hasError: boolean;errMsg: string;redirectUrl: string;
}
當然我們還得實現UserService:ng g s user
import { Injectable } from '@angular/core';import { Http, Headers, Response } from '@angular/http';import 'rxjs/add/operator/toPromise';
import { User } from '../domain/entities';@Injectable()
export class UserService {private api_url = 'http://localhost:3000/users';constructor(private http: Http) { }findUser(username: string): Promise<User> {const url = `${this.api_url}/?username=${username}`;return this.http.get(url).toPromise().then(res => {let users = res.json() as User[];return (users.length>0)?users[0]:null;}).catch(this.handleError);}private handleError(error: any): Promise<any> {console.error('An error occurred', error); // for demo purposes onlyreturn Promise.reject(error.message || error);}
}
這段代碼比較簡單,就不細講了。下面我們改造一下src\app\login\login.component.html
,在原來用戶名的驗證信息下加入,用于顯示用戶不存在或者密碼不對的情況
<div *ngIf="usernameRef.errors?.required">this is required</div><div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div><!--add the code below--><div *ngIf="auth?.hasError">{{auth.errMsg}}</div>
當然我們還得改造src\app\login\login.component.ts
import { Component, OnInit, Inject } from '@angular/core';
import { Router, ActivatedRoute, Params } from '@angular/router';import { Auth } from '../domain/entities';@Component({selector: 'app-login',templateUrl: './login.component.html',styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {username = '';password = '';auth: Auth;constructor(@Inject('auth') private service, private router: Router) { }ngOnInit() {}onSubmit(formValue){this.service.loginWithCredentials(formValue.login.username, formValue.login.password).then(auth => {let redirectUrl = (auth.redirectUrl === null)? '/': auth.redirectUrl;if(!auth.hasError){this.router.navigate([redirectUrl]);localStorage.removeItem('redirectUrl');} else {this.auth = Object.assign({}, auth);}});}
}
然后我們別忘了在core模塊中聲明我們的服務src\app\core\core.module.ts
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService } from './auth.service';
import { UserService } from './user.service';
import { AuthGuardService } from './auth-guard.service';
@NgModule({imports: [CommonModule],providers: [{ provide: 'auth', useClass: AuthService },{ provide: 'user', useClass: UserService },AuthGuardService]
})
export class CoreModule {constructor (@Optional() @SkipSelf() parentModule: CoreModule) {if (parentModule) {throw new Error('CoreModule is already loaded. Import it in the AppModule only');}}
}
最后我們得改寫一下TodoService
,因為我們訪問的URL變了,要傳遞的數據也有些變化
//todo.service.ts代碼片段// POST /todosaddTodo(desc:string): Promise<Todo> {//“+”是一個簡易方法可以把string轉成numberconst userId:number = +localStorage.getItem('userId');let todo = {id: UUID.UUID(),desc: desc,completed: false,userId};return this.http.post(this.api_url, JSON.stringify(todo), {headers: this.headers}).toPromise().then(res => res.json() as Todo).catch(this.handleError);}// GET /todosgetTodos(): Promise<Todo[]>{const userId = +localStorage.getItem('userId');const url = `${this.api_url}/?userId=${userId}`;return this.http.get(url).toPromise().then(res => res.json() as Todo[]).catch(this.handleError);}// GET /todos?completed=true/falsefilterTodos(filter: string): Promise<Todo[]> {const userId:number = +localStorage.getItem('userId');const url = `${this.api_url}/?userId=${userId}`;switch(filter){case 'ACTIVE': return this.http.get(`${url}&completed=false`).toPromise().then(res => res.json() as Todo[]).catch(this.handleError);case 'COMPLETED': return this.http.get(`${url}&completed=true`).toPromise().then(res => res.json() as Todo[]).catch(this.handleError);default:return this.getTodos();}}
現在應該已經ok了,我們來看看效果:
用戶密碼不匹配時,顯示password not match
用戶不存在時,顯示user not found
直接在瀏覽器地址欄輸入http://localhost:4200/todo
,你會發現被重新導航到了login
。輸入正確的用戶名密碼后,我們被導航到了todo,現在每個用戶都可以創建屬于自己的待辦事項了。
路由模塊化
Angular團隊推薦把路由模塊化,這樣便于使業務邏輯和路由松耦合。雖然目前在我們的應用中感覺用處不大,但按官方推薦的方式還是和大家一起改造一下吧。刪掉原有的app.routes.ts
和todo.routes.ts
。添加app-routing.module.ts
:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';const routes: Routes = [{path: '',redirectTo: 'login',pathMatch: 'full'},{path: 'login',component: LoginComponent},{path: 'todo',redirectTo: 'todo/ALL'}
];@NgModule({imports: [RouterModule.forRoot(routes)],exports: [RouterModule]
})
export class AppRoutingModule {}
以及src\app\todo\todo-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { TodoComponent } from './todo.component';import { AuthGuardService } from '../core/auth-guard.service';const routes: Routes = [{path: 'todo/:filter',canActivate: [AuthGuardService],component: TodoComponent}
];@NgModule({imports: [ RouterModule.forChild(routes) ],exports: [ RouterModule ]
})
export class TodoRoutingModule { }
并分別在AppModule和TodoModule中引入路由模塊。
用VSCode進行調試
有讀者問如何用vscode進行debug,這章我們來介紹一下。首先需要安裝一個vscode插件,點擊左側最下面的圖標或者“在查看菜單中選擇命令面板,輸入install,選擇擴展:安裝擴展”,然后輸入“debugger for chrome”回車,點擊安裝即可。
然后點擊最左邊的倒數第二個按鈕
如果是第一次使用的話,齒輪圖標上會有個紅點,點擊選擇debugger for chrome
,vscode會幫你創建一個配置文件,這個文件位于\.vscode\launch.json
是debugger的配置文件,請改寫成下面的樣子。注意如果是MacOSX或者Linux,請把userDataDir
替換成對應的臨時目錄,另外把"webpack:///C:*":"C:/*"
替換成"webpack:///*": "/*"
,這句是因為angular-cli是采用webpack打包的,如果沒有使用angular-cli不需要添加這句。
{"version": "0.2.0","configurations": [{"name": "Launch Chrome against localhost, with sourcemaps","type": "chrome","request": "launch","url": "http://localhost:4200","sourceMaps": true,"runtimeArgs": ["--disable-session-crashed-bubble","--disable-infobars"],"diagnosticLogging": true,"webRoot": "${workspaceRoot}/src",//windows setup"userDataDir": "C:\\temp\\chromeDummyDir","sourceMapPathOverrides": {"webpack:///C:*":"C:/*"//use "webpack:///*": "/*" on Linux/OSX}},{"name": "Attach to Chrome, with sourcemaps","type": "chrome","request": "attach","port": 9222,"sourceMaps": true,"diagnosticLogging": true,"webRoot": "${workspaceRoot}/src","sourceMapPathOverrides": {"webpack:///C:*":"C:/*"}}]
}
現在你可以試著在源碼中設置一個斷點,點擊debug視圖中的debug按鈕,可以嘗試右鍵點擊變量把它放到監視中看看變量值或者逐步調試應用。
本章完整代碼見: https://github.com/wpcfan/awe...
第一節:Angular 2.0 從0到1 (一)
第二節:Angular 2.0 從0到1 (二)
第三節:Angular 2.0 從0到1 (三)
第四節:Angular 2.0 從0到1 (四)
第五節:Angular 2.0 從0到1 (五)