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