Angular 學習教材(由淺入深)

學習路徑總覽

第1-2週:基礎概念 → 第3-4週:元件與模組 → 第5-6週:進階功能
→ 第7-8週:狀態管理 → 第9-10週:效能優化 → 第11-12週:實戰專案

第一週:環境設定與基礎

Day 1-2:認識 Angular

Angular 是什麼: - Google 開發的前端框架 - 使用 TypeScript - 適合大型企業級應用 - 完整的解決方案(路由、表單、HTTP、測試)

與其他框架比較

框架 特點 適用場景
Angular 完整、嚴謹、企業級 大型專案、企業內部系統
React 靈活、社群大 各種規模專案
Vue 簡單、易學 小型專案、快速開發

Day 3-4:環境安裝

# 安裝 Node.js(LTS 版本)
# 下載:https://nodejs.org/

# 安裝 Angular CLI
npm install -g @angular/cli

# 建立第一個專案
ng new my-first-app
cd my-first-app
ng serve

Angular CLI 常用指令

指令 功能
ng new 建立新專案
ng generate 建立元件/服務/模組
ng serve 啟動開發伺服器
ng build 建構生產版本
ng test 執行單元測試
ng e2e 執行端對端測試

Day 5-7:專案結構

my-first-app/
├── src/
│   ├── app/
│   │   ├── app.component.ts      # 根元件
│   │   ├── app.component.html    # 根元件模板
│   │   ├── app.module.ts         # 根模組
│   │   └── app-routing.module.ts # 路由設定
│   ├── assets/                   # 靜態資源
│   ├── environments/             # 環境變數
│   ├── index.html                # 主要 HTML
│   ├── main.ts                   # 入口點
│   └── styles.css                # 全域樣式
├── angular.json                  # Angular 設定
├── package.json                  # 依賴套件
└── tsconfig.json                 # TypeScript 設定

本週作業: - [ ] 安裝 Angular CLI - [ ] 建立第一個專案 - [ ] 修改 app.component.html,顯示「Hello Angular!」 - [ ] 了解專案結構


第二週:TypeScript 基礎

Day 1-2:基本語法

// 變數宣告
let name: string = "John";
let age: number = 30;
let isActive: boolean = true;

// 陣列
let numbers: number[] = [1, 2, 3];
let names: Array<string> = ["Alice", "Bob"];

// 物件
interface User {
  name: string;
  age: number;
  email?: string; // 可選屬性
}

const user: User = {
  name: "John",
  age: 30
};

Day 3-4:函式與型別

// 函式
function add(a: number, b: number): number {
  return a + b;
}

// 箭頭函式
const multiply = (a: number, b: number): number => a * b;

// 可選參數
function greet(name: string, greeting?: string): string {
  return `${greeting || "Hello"}, ${name}!`;
}

// 聯合型別
function format(input: string | number): string {
  if (typeof input === "number") {
    return input.toFixed(2);
  }
  return input.toUpperCase();
}

Day 5-7:類別與介面

// 類別
class Animal {
  constructor(public name: string) {}

  speak(): string {
    return `${this.name} makes a sound`;
  }
}

// 繼承
class Dog extends Animal {
  speak(): string {
    return `${this.name} barks`;
  }
}

// 介面
interface Printable {
  print(): void;
}

// 實作介面
class Document implements Printable {
  constructor(public title: string) {}

  print(): void {
    console.log(`Printing ${this.title}`);
  }
}

本週作業: - [ ] 完成 TypeScript 官方教學 - [ ] 練習宣告變數、函式、類別 - [ ] 建立一個簡單的 Animal 類別層級


第三週:元件基礎

Day 1-2:第一個元件

# 建立元件
ng generate component hello
# 或簡寫
ng g c hello
// hello.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-hello',
  templateUrl: './hello.component.html',
  styleUrls: ['./hello.component.css']
})
export class HelloComponent {
  message: string = "Hello from Angular!";

  changeMessage(): void {
    this.message = "Message changed!";
  }
}
<!-- hello.component.html -->
<div class="hello">
  <h2>{{ message }}</h2>
  <button (click)="changeMessage()">Change Message</button>
</div>

Day 3-4:資料綁定

// 四種綁定方式
@Component({
  selector: 'app-binding-demo',
  template: `
    <!-- 1. 插值 -->
    <h1>{{ title }}</h1>

    <!-- 2. 屬性綁定 -->
    <img [src]="imageUrl">

    <!-- 3. 事件綁定 -->
    <button (click)="onClick()">Click Me</button>

    <!-- 4. 雙向綁定 -->
    <input [(ngModel)]="name">
    <p>Hello, {{ name }}!</p>
  `
})
export class BindingDemoComponent {
  title = "Binding Demo";
  imageUrl = "assets/logo.png";
  name = "";

  onClick(): void {
    console.log("Button clicked!");
  }
}

Day 5-7:父子元件通訊

// 父元件
@Component({
  selector: 'app-parent',
  template: `
    <app-child
      [message]="parentMessage"
      (onMessageChange)="handleMessageChange($event)">
    </app-child>
  `
})
export class ParentComponent {
  parentMessage = "Hello from parent";

  handleMessageChange(newMessage: string): void {
    this.parentMessage = newMessage;
  }
}

// 子元件
@Component({
  selector: 'app-child',
  template: `
    <p>{{ message }}</p>
    <button (click)="sendMessage()">Send to Parent</button>
  `
})
export class ChildComponent {
  @Input() message: string = "";
  @Output() onMessageChange = new EventEmitter<string>();

  sendMessage(): void {
    this.onMessageChange.emit("Hello from child!");
  }
}

本週作業: - [ ] 建立第一個元件 - [ ] 練習四種資料綁定 - [ ] 建立父子元件通訊


第四週:模組與路由

Day 1-2:模組系統

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HelloComponent } from './hello/hello.component';

@NgModule({
  declarations: [
    AppComponent,
    HelloComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
# 建立模組
ng generate module admin
ng generate module shared

# 建立帶路由的模組
ng generate module admin --routing

Day 3-4:路由設定

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
import { NotFoundComponent } from './not-found/not-found.component';

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'about', component: AboutComponent },
  { path: '**', component: NotFoundComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
<!-- app.component.html -->
<nav>
  <a routerLink="/">Home</a>
  <a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>

Day 5-7:路由進階

// 帶參數的路由
const routes: Routes = [
  { path: 'user/:id', component: UserComponent },
  { path: 'product/:id', component: ProductComponent }
];

// 在元件中取得參數
@Component({ ... })
export class UserComponent {
  userId: string = "";

  constructor(private route: ActivatedRoute) {
    this.route.params.subscribe(params => {
      this.userId = params['id'];
    });
  }
}

// 路由守衛
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private router: Router) {}

  canActivate(): boolean {
    const isLoggedIn = /* check auth */;
    if (isLoggedIn) {
      return true;
    }
    this.router.navigate(['/login']);
    return false;
  }
}

本週作業: - [ ] 建立多個模組 - [ ] 設定路由(首頁、關於、404) - [ ] 實作帶參數的路由


第五週:表單處理

Day 1-2:Template-driven 表單

// app.module.ts
import { FormsModule } from '@angular/forms';

@NgModule({
  imports: [
    FormsModule
  ]
})
<!-- 表單範例 -->
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
  <div>
    <label>Email:</label>
    <input type="email"
           name="email"
           ngModel
           required
           email>
  </div>
  <div>
    <label>Password:</label>
    <input type="password"
           name="password"
           ngModel
           required
           minlength="6">
  </div>
  <button type="submit"
          [disabled]="loginForm.invalid">
    Login
  </button>
</form>

Day 3-4:Reactive 表單

// app.module.ts
import { ReactiveFormsModule } from '@angular/forms';

// 表單元件
@Component({ ... })
export class RegisterComponent {
  registerForm = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
    confirmPassword: ['']
  }, { validators: this.passwordMatchValidator });

  constructor(private fb: FormBuilder) {}

  passwordMatchValidator(form: FormGroup) {
    const password = form.get('password')?.value;
    const confirmPassword = form.get('confirmPassword')?.value;
    return password === confirmPassword ? null : { passwordMismatch: true };
  }

  onSubmit(): void {
    if (this.registerForm.valid) {
      console.log(this.registerForm.value);
    }
  }
}
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
  <div>
    <label>Name:</label>
    <input formControlName="name">
    <div *ngIf="registerForm.get('name')?.invalid && registerForm.get('name')?.touched">
      Name is required (min 2 characters)
    </div>
  </div>

  <div>
    <label>Email:</label>
    <input formControlName="email">
    <div *ngIf="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
      Valid email is required
    </div>
  </div>

  <button type="submit" [disabled]="registerForm.invalid">
    Register
  </button>
</form>

Day 5-7:表單驗證與自訂驗證

// 自訂驗證器
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const forbidden = nameRe.test(control.value);
    return forbidden ? { forbiddenName: { value: control.value } } : null;
  };
}

// 使用
this.registerForm = this.fb.group({
  username: ['', [Validators.required, forbiddenNameValidator(/admin/i)]]
});

本週作業: - [ ] 建立登入表單(Template-driven) - [ ] 建立註冊表單(Reactive) - [ ] 實作自訂驗證器


第六週:HTTP 與服務

Day 1-2:HttpClient 基礎

// app.module.ts
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  imports: [
    HttpClientModule
  ]
})
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private apiUrl = 'https://api.example.com/users';

  constructor(private http: HttpClient) {}

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.apiUrl)
      .pipe(
        retry(1),
        catchError(this.handleError)
      );
  }

  createUser(user: User): Observable<User> {
    const httpOptions = {
      headers: new HttpHeaders({
        'Content-Type': 'application/json'
      })
    };
    return this.http.post<User>(this.apiUrl, user, httpOptions);
  }

  private handleError(error: any) {
    console.error('An error occurred:', error);
    return throwError(() => new Error('Something bad happened'));
  }
}

Day 3-4:Observable 與 RxJS

import { Observable, of, from, interval } from 'rxjs';
import { map, filter, switchMap, mergeMap, debounceTime } from 'rxjs/operators';

// 建立 Observable
const numbers$ = of(1, 2, 3, 4, 5);

// 使用 operators
numbers$.pipe(
  filter(n => n % 2 === 0),
  map(n => n * 10)
).subscribe(result => console.log(result));

// 從事件建立
const searchInput = document.getElementById('search');
fromEvent(searchInput, 'input').pipe(
  debounceTime(300),
  map((e: any) => e.target.value),
  switchMap(query => this.searchService.search(query))
).subscribe(results => {
  this.results = results;
});

Day 5-7:錯誤處理與攔截器

// http.interceptor.ts
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = localStorage.getItem('token');

    if (token) {
      req = req.clone({
        setHeaders: {
          Authorization: `Bearer ${token}`
        }
      });
    }

    return next.handle(req);
  }
}

// 全域錯誤處理
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  handleError(error: any): void {
    console.error('Global error:', error);
    // 顯示錯誤訊息給使用者
  }
}

本週作業: - [ ] 建立 UserService - [ ] 練習 RxJS operators - [ ] 實作 JWT 攔截器


第七週:元件進階

Day 1-2:生命週期

@Component({
  selector: 'app-lifecycle',
  template: `<p>{{ message }}</p>`
})
export class LifecycleComponent implements OnInit, OnDestroy {
  message: string = "";

  constructor() {
    console.log('1. Constructor');
  }

  ngOnInit(): void {
    console.log('2. ngOnInit');
    this.message = "Component initialized";
  }

  ngOnDestroy(): void {
    console.log('6. ngOnDestroy');
    // 清理資源
  }

  // 其他生命週期鉤子
  ngOnChanges(changes: SimpleChanges): void {
    console.log('3. ngOnChanges');
  }

  ngAfterContentInit(): void {
    console.log('4. ngAfterContentInit');
  }

  ngAfterViewInit(): void {
    console.log('5. ngAfterViewInit');
  }
}

Day 3-4:Content Projection 與 ViewChild

// Content Projection
@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <ng-content></ng-content>
    </div>
  `
})
export class CardComponent {}

// 使用
<app-card>
  <h2>Title</h2>
  <p>Content here</p>
</app-card>

// ViewChild
@Component({
  selector: 'app-parent',
  template: `
    <input #nameInput>
    <button (click)="focusInput()">Focus</button>
  `
})
export class ParentComponent {
  @ViewChild('nameInput') nameInput!: ElementRef;

  focusInput(): void {
    this.nameInput.nativeElement.focus();
  }
}

Day 5-7:動態元件

// 動態元件
@Component({
  selector: 'app-dynamic',
  template: `
    <ng-container *ngComponentOutlet="currentComponent"></ng-container>
  `
})
export class DynamicComponent {
  currentComponent = AlertComponent;
}

// 使用 ViewContainerRef
@Component({
  selector: 'app-container',
  template: `<div #container></div>`
})
export class ContainerComponent implements AfterViewInit {
  @ViewChild('container', { read: ViewContainerRef })
  container!: ViewContainerRef;

  ngAfterViewInit(): void {
    const componentRef = this.container.createComponent(AlertComponent);
    componentRef.instance.message = "Dynamic component!";
  }
}

本週作業: - [ ] 練習生命週期鉤子 - [ ] 建立可重用的 Card 元件 - [ ] 實作動態元件載入


第八週:狀態管理

Day 1-2:服務與 Dependency Injection

// 服務
@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private isLoggedIn = false;

  login(username: string, password: string): boolean {
    // 登入邏輯
    this.isLoggedIn = true;
    return true;
  }

  logout(): void {
    this.isLoggedIn = false;
  }

  isAuthenticated(): boolean {
    return this.isLoggedIn;
  }
}

// 使用
@Component({ ... })
export class DashboardComponent {
  constructor(private authService: AuthService) {}

  get isLoggedIn(): boolean {
    return this.authService.isAuthenticated();
  }
}

Day 3-4:簡單狀態管理

// state.service.ts
@Injectable({
  providedIn: 'root'
})
export class StateService {
  private state = {
    user: null as User | null,
    todos: [] as Todo[],
    loading: false
  };

  private state$ = new BehaviorSubject(this.state);

  getState(): Observable<typeof this.state> {
    return this.state$.asObservable();
  }

  setUser(user: User | null): void {
    this.state = { ...this.state, user };
    this.state$.next(this.state);
  }

  addTodo(todo: Todo): void {
    this.state = {
      ...this.state,
      todos: [...this.state.todos, todo]
    };
    this.state$.next(this.state);
  }
}

Day 5-7:NgRx 基礎

// 安裝
// npm install @ngrx/store @ngrx/effects

// actions
export const addTodo = createAction(
  '[Todo] Add Todo',
  props<{ todo: Todo }>()
);

export const removeTodo = createAction(
  '[Todo] Remove Todo',
  props<{ id: string }>()
);

// reducer
export const todoReducer = createReducer(
  initialState,
  on(addTodo, (state, { todo }) => ({
    ...state,
    todos: [...state.todos, todo]
  })),
  on(removeTodo, (state, { id }) => ({
    ...state,
    todos: state.todos.filter(t => t.id !== id)
  }))
);

// selector
export const selectTodos = createSelector(
  (state: AppState) => state.todos,
  todos => todos
);

// 在元件中使用
@Component({ ... })
export class TodoComponent {
  todos$ = this.store.select(selectTodos);

  constructor(private store: Store) {}

  addTodo(todo: Todo): void {
    this.store.dispatch(addTodo({ todo }));
  }
}

本週作業: - [ ] 建立 AuthService - [ ] 實作簡單狀態管理 - [ ] 安裝並使用 NgRx


第九週:效能優化

Day 1-2:Change Detection

// OnPush 策略
@Component({
  selector: 'app-optimized',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ count }}`
})
export class OptimizedComponent {
  count = 0;

  constructor(private cdr: ChangeDetectorRef) {}

  increment(): void {
    this.count++;
    this.cdr.markForCheck();
  }
}

// 使用 trackBy
@Component({
  selector: 'app-list',
  template: `
    <div *ngFor="let item of items; trackBy: trackById">
      {{ item.name }}
    </div>
  `
})
export class ListComponent {
  items: Item[] = [];

  trackById(index: number, item: Item): string {
    return item.id;
  }
}

Day 3-4:懶載入

// 路由懶載入
const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module')
      .then(m => m.AdminModule)
  },
  {
    path: 'user',
    loadChildren: () => import('./user/user.module')
      .then(m => m.UserModule)
  }
];

// 預載入策略
const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module')
      .then(m => m.AdminModule),
    preload: true
  }
];

Day 5-7:記憶體管理

// 避免記憶體洩漏
@Component({ ... })
export class SmartComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit(): void {
    this.dataService.getData()
      .pipe(takeUntil(this.destroy$))
      .subscribe(data => {
        this.data = data;
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// 使用 async pipe
@Component({
  template: `
    <div *ngIf="data$ | async as data">
      {{ data.name }}
    </div>
  `
})
export class AsyncComponent {
  data$ = this.dataService.getData();
}

本週作業: - [ ] 使用 OnPush 策略優化元件 - [ ] 實作路由懶載入 - [ ] 清理訂閱避免記憶體洩漏


第十週:測試

Day 1-2:單元測試

// hello.component.spec.ts
describe('HelloComponent', () => {
  let component: HelloComponent;
  let fixture: ComponentFixture<HelloComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HelloComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(HelloComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should display message', () => {
    expect(component.message).toBe('Hello from Angular!');
  });

  it('should change message', () => {
    component.changeMessage();
    expect(component.message).toBe('Message changed!');
  });
});

Day 3-4:服務測試

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should get users', () => {
    const mockUsers = [
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' }
    ];

    service.getUsers().subscribe(users => {
      expect(users.length).toBe(2);
      expect(users).toEqual(mockUsers);
    });

    const req = httpMock.expectOne('https://api.example.com/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });
});

Day 5-7:端對端測試

// 安裝:npm install cypress --save-dev

// e2e/spec.ts
describe('Todo App', () => {
  it('should add a todo', () => {
    cy.visit('/');
    cy.get('input[data-testid="todo-input"]').type('New Todo');
    cy.get('button[data-testid="add-button"]').click();
    cy.get('.todo-item').should('contain', 'New Todo');
  });

  it('should complete a todo', () => {
    cy.visit('/');
    cy.get('.todo-item').first().find('input[type="checkbox"]').check();
    cy.get('.todo-item').first().should('have.class', 'completed');
  });
});

本週作業: - [ ] 撰寫元件單元測試 - [ ] 撰寫服務測試 - [ ] 建立第一個 E2E 測試


第十一週:實戰專案

Day 1-2:專案規劃

專案:任務管理系統

功能需求:
- 用戶註冊/登入
- 建立/編輯/刪除任務
- 任務分類與標籤
- 任務狀態(待辦/進行中/已完成)
- 搜尋與篩選

技術架構:
- Angular 19
- Angular Material
- NgRx(狀態管理)
- HttpClient + REST API

Day 3-7:實作核心功能

Day 3: 專案架構、路由、模組
Day 4: 用戶認證模組
Day 5: 任務 CRUD 功能
Day 6: 分類與標籤
Day 7: 搜尋與篩選

第十二週:部署與維護

Day 1-3:建構與部署

# 建構生產版本
ng build --configuration production

# 部署到 GitHub Pages
ng add angular-cli-ghpages
ng deploy

# 部署到 Firebase
ng add @angular/fire
ng deploy

Day 4-7:CI/CD

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist/my-app

學習資源

官方資源

推薦課程

社群