Angular 從 0 到 1 (五)史上最簡單的 Angular 教程

接灰的電子產品發表於2016-12-26

第一節:初識Angular-CLI
第二節:登入元件的構建
第三節:建立一個待辦事項應用
第四節:進化!模組化你的應用
第五節:多使用者版本的待辦事項應用
第六節:使用第三方樣式庫及模組優化用
第七節:給元件帶來活力
Rx--隱藏在Angular 2.x中利劍
Redux你的Angular 2應用
第八節:查缺補漏大合集(上)
第九節:查缺補漏大合集(下)

第五節:多使用者版本的待辦事項應用

第四節我們完成的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;
}複製程式碼

驗證使用者賬戶的流程

我們來梳理一下使用者驗證的流程

  1. 儲存要訪問的URL
  2. 根據本地的已登入標識判斷是否此使用者已經登入,如果已登入就直接放行
  3. 如果未登入導航到登入頁面 使用者填寫使用者名稱和密碼進行登入
  4. 系統根據使用者名稱查詢使用者表中是否存在此使用者,如果不存在此使用者,返回錯誤
  5. 如果存在對比填寫的密碼和儲存的密碼是否一致,如果不一致,返回錯誤
  6. 如果一致,儲存此使用者的已登入標識到本地
  7. 導航到原本要訪問的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 {
    //取得使用者訪問的URL
    let 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的存在與否決定了使用者是否已登入的狀態,這當然是一個漏洞百出的實現,但我們暫且不去管它。現在我們要在登入時把這個狀態值寫進去。我們新建一個登入鑑權的AuthServiceng 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 only
    return 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 only
    return 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 /todos
  addTodo(desc:string): Promise<Todo> {
    //“+”是一個簡易方法可以把string轉成number
    const 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 /todos
  getTodos(): 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/false
  filterTodos(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

Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
image_1b23h2m601puv1q9664c52c1jem9.png-7.2kB

使用者不存在時,顯示user not found
Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
image_1b23h3l811dn4g9h16qu1jm11htbm.png-5.6kB

直接在瀏覽器位址列輸入http://localhost:4200/todo,你會發現被重新導航到了login。輸入正確的使用者名稱密碼後,我們被導航到了todo,現在每個使用者都可以建立屬於自己的待辦事項了。
Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
image_1b23hdv51l621elh1uucsri32213.png-51.1kB

路由模組化

Angular團隊推薦把路由模組化,這樣便於使業務邏輯和路由鬆耦合。雖然目前在我們的應用中感覺用處不大,但按官方推薦的方式還是和大家一起改造一下吧。刪掉原有的app.routes.tstodo.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”回車,點選安裝即可。

Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
image_1b23hjd3rble1nb11u7i19qgjqb1g.png-170.5kB

然後點選最左邊的倒數第二個按鈕
Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
image_1b23htavu19i412obd751h8kusj1t.png-72.5kB

如果是第一次使用的話,齒輪圖示上會有個紅點,點選選擇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按鈕,可以嘗試右鍵點選變數把它放到監視中看看變數值或者逐步除錯應用。

Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
image_1b23igfkdhn71ug71cng3in94t2a.png-400.1kB

本章完整程式碼見: github.com/wpcfan/awes…

紙書出版了,比網上內容豐富充實了,歡迎大家訂購!
京東連結:item.m.jd.com/product/120…

Angular 從 0 到 1 (五)史上最簡單的 Angular 教程
Angular從零到一

第一節: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 (五)
第六節:Angular 2.0 從0到1 (六)
第七節:Angular 2.0 從0到1 (七)
第八節:Angular 2.0 從0到1 (八)

相關文章