Angular
Skill MARUCIE/openclaw-foundry/web/public/packs/spellbook-frontend-engineer/skills/angular
The curated AI Agent skill marketplace — 37K+ vetted skills, S/A/B/C ratings, deploy anywhere
npx -y skills add MARUCIE/openclaw-foundry --skill angularAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when building or refactoring Angular applications — choosing between signals, RxJS, and NgRx for state, configuring routing with guards and lazy loading, optimizing change detection, or writing TestBed component tests.
SKILL.md
20.6 KB, as published. Nobody here has run it
是什么
Angular 是 Google 出品的企业级前端框架,自带依赖注入(Dependency Injection)、路由、表单、HTTP 一整套能力。 用它的效果是:大型团队协作时约定多过自由发挥,新人接手老项目的学习成本被显著压低。
怎么用
- 先按业务领域划分模块(Module)与路由,让代码结构对应组织结构。
- 用依赖注入管理服务与状态,让组件保持轻薄而业务逻辑可被独立测试。
- 通过响应式表单(Reactive Forms)处理复杂校验,让表单逻辑显式而不藏在模板里。
- 用 RxJS 编排异步流,让数据流与副作用都有统一的描述方式。
- 通过 Angular CLI 与 Schematics 做工程化约束,让规范由工具而不是口头传达。
架构图
flowchart LR
路由模块 --> 组件
组件 --> 服务注入
服务注入 --> 状态管理
状态管理 --> 响应式流
响应式流 --> 视图渲染
Angular Patterns
Modern Angular (v17+) conventions using standalone components, signals, and typed forms.
When to Activate
- Creating or refactoring Angular components, services, or directives
- Choosing between signals, RxJS, or NgRx for state
- Setting up routing with guards, resolvers, or lazy loading
- Building reactive or template-driven forms with validation
- Writing Angular tests (TestBed, jasmine, or jest)
- Configuring HttpClient with interceptors or typed responses
- Optimizing change detection with
OnPush
Project Structure
src/
├── app/
│ ├── core/ # Singleton services, interceptors, guards (provided in root)
│ │ ├── services/
│ │ ├── interceptors/
│ │ └── guards/
│ ├── shared/ # Reusable standalone components, pipes, directives
│ │ ├── components/
│ │ ├── pipes/
│ │ └── directives/
│ ├── features/ # Feature modules — each is a route subtree
│ │ ├── users/
│ │ │ ├── user-list/
│ │ │ ├── user-detail/
│ │ │ └── users.routes.ts
│ │ └── orders/
│ ├── app.component.ts
│ ├── app.config.ts # bootstrapApplication providers
│ └── app.routes.ts
Standalone Components (Angular 17+)
No NgModule required. Default for all new components.
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { UserService } from '../core/services/user.service';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, RouterLink], // import what you use
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (user of users(); track user.id) {
<li>
<a [routerLink]="['/users', user.id]">{{ user.name }}</a>
</li>
}
@empty { <li>No users found.</li> }
</ul>
`,
})
export class UserListComponent {
private userService = inject(UserService); // inject() instead of constructor DI
users = this.userService.users; // signal from service
}
New Control Flow (Angular 17+)
Prefer @if, @for, @switch over *ngIf, *ngFor:
@if (isLoading()) {
<app-spinner />
} @else if (error()) {
<app-error [message]="error()" />
} @else {
<app-data-table [rows]="data()" />
}
@for (item of items(); track item.id) {
<app-item [item]="item" />
} @empty {
<p>Nothing here.</p>
}
@switch (status()) {
@case ('active') { <span class="green">Active</span> }
@case ('inactive') { <span class="red">Inactive</span> }
@default { <span>Unknown</span> }
}
Signals
Signals replace BehaviorSubject for synchronous local and service state.
import { signal, computed, effect } from '@angular/core';
// Writable signal
const count = signal(0);
count.set(5);
count.update(n => n + 1);
count(); // read — always call as a function
// Computed — automatically tracks dependencies
const doubled = computed(() => count() * 2);
// Effect — runs when dependencies change (use sparingly)
effect(() => {
console.log('count changed:', count());
});
Signals in Services
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
// Private writable, public readonly
private _users = signal<User[]>([]);
readonly users = this._users.asReadonly();
readonly activeUsers = computed(() =>
this._users().filter(u => u.active)
);
load() {
this.http.get<User[]>('/api/users').subscribe(users => {
this._users.set(users);
});
}
add(user: User) {
this._users.update(list => [...list, user]);
}
}
toSignal / toObservable — bridging signals ↔ RxJS
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// Convert Observable to Signal (use in components instead of async pipe)
readonly users = toSignal(this.http.get<User[]>('/api/users'), {
initialValue: [] as User[],
});
// Convert Signal to Observable (pass to RxJS operators)
const count$ = toObservable(this.count);
count$.pipe(debounceTime(300)).subscribe(...);
Dependency Injection
// Provided in root (singleton for the whole app)
@Injectable({ providedIn: 'root' })
export class AuthService { ... }
// Provided in a specific component subtree (new instance per component)
@Component({
providers: [LocalCartService], // scoped to this component and its children
})
// inject() function — preferred over constructor injection
export class MyComponent {
private auth = inject(AuthService);
private router = inject(Router);
}
// Injection tokens for non-class values
export const API_URL = new InjectionToken<string>('API_URL');
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' },
provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes),
],
};
// Usage
private apiUrl = inject(API_URL);
HttpClient
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
getUsers(): Observable<User[]> {
return this.http.get<User[]>(`${this.apiUrl}/users`);
}
createUser(data: CreateUserDto): Observable<User> {
return this.http.post<User>(`${this.apiUrl}/users`, data);
}
updateUser(id: string, data: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.apiUrl}/users/${id}`, data);
}
deleteUser(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/users/${id}`);
}
}
Functional Interceptors (Angular 15+)
// core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.getToken();
if (!token) return next(req);
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
}));
};
// core/interceptors/error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) inject(AuthService).logout();
if (error.status === 0) console.error('Network error');
return throwError(() => error);
}),
);
};
// app.config.ts
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
Routing
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
// Lazy-loaded feature route
{
path: 'users',
loadChildren: () => import('./features/users/users.routes').then(m => m.USERS_ROUTES),
},
// Protected route
{
path: 'admin',
canActivate: [authGuard],
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
{ path: '**', component: NotFoundComponent },
];
// features/users/users.routes.ts
export const USERS_ROUTES: Routes = [
{ path: '', component: UserListComponent },
{ path: ':id', component: UserDetailComponent,
resolve: { user: userResolver } },
];
Guards and Resolvers
// Functional guard (Angular 14+)
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) return true;
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
// Functional resolver
export const userResolver: ResolveFn<User> = (route) => {
return inject(UserService).getUser(route.paramMap.get('id')!);
};
// Component reads resolved data
export class UserDetailComponent {
private route = inject(ActivatedRoute);
user = toSignal(this.route.data.pipe(map(d => d['user'] as User)));
}
Forms
Reactive Forms (preferred for complex forms)
import { FormBuilder, Validators, AbstractControl } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" />
@if (email.invalid && email.touched) {
<span>{{ emailError() }}</span>
}
<input type="password" formControlName="password" />
<button type="submit" [disabled]="form.invalid">Save</button>
</form>
`,
})
export class UserFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
get email() { return this.form.controls.email; }
emailError = computed(() => {
if (this.email.hasError('required')) return 'Email is required';
if (this.email.hasError('email')) return 'Invalid email format';
return '';
});
submit() {
if (this.form.invalid) return;
console.log(this.form.getRawValue());
}
}
Custom Validator
function noSpaces(control: AbstractControl) {
return control.value?.includes(' ')
? { noSpaces: 'Username cannot contain spaces' }
: null;
}
// Async validator (e.g. check username availability)
function uniqueUsername(userService: UserService): AsyncValidatorFn {
return (control) =>
userService.checkUsername(control.value).pipe(
map(taken => taken ? { usernameTaken: true } : null),
catchError(() => of(null)),
);
}
Template-Driven Forms (simple forms only)
@Component({
imports: [FormsModule],
template: `
<form #f="ngForm" (ngSubmit)="submit(f)">
<input name="name" ngModel required #nameField="ngModel" />
@if (nameField.invalid && nameField.touched) {
<span>Name is required</span>
}
<button type="submit" [disabled]="f.invalid">Save</button>
</form>
`,
})
export class SimpleFormComponent {
submit(form: NgForm) {
if (form.valid) console.log(form.value);
}
}
RxJS in Angular
Common operators used in Angular services and components:
// switchMap — cancel previous, use latest (search, route params)
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.search(query)), // cancels in-flight on new keystroke
).subscribe(results => this._results.set(results));
// combineLatest — react when any input changes
combineLatest([this.userId$, this.filter$]).pipe(
switchMap(([id, filter]) => this.loadOrders(id, filter)),
).subscribe(orders => this._orders.set(orders));
// takeUntilDestroyed — auto-unsubscribe when component destroys (Angular 16+)
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
this.data$.pipe(takeUntilDestroyed()).subscribe(...);
// shareReplay — multicast HTTP call so multiple subscribers don't cause multiple requests
readonly users$ = this.http.get<User[]>('/api/users').pipe(
shareReplay(1),
);
Change Detection
OnPush skips re-render unless: an @Input() reference changes, a signal read in the template changes, an Observable piped with async emits, or markForCheck() is called.
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>{{ title }}</p>
<p>{{ count() }}</p> <!-- signal: auto-tracked -->
<p>{{ data$ | async }}</p> <!-- observable: tracked by async pipe -->
`,
})
export class MyComponent {
@Input() title = ''; // change detection on reference change
count = signal(0); // change detection on signal change
data$ = this.service.data$; // change detection on async pipe emit
}
Always use OnPush for new components. Combine with signals or async pipe — avoid manual markForCheck().
Pipes
// Custom pure pipe (re-runs only when input changes)
@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 100, trail = '…'): string {
return value.length > limit ? value.slice(0, limit) + trail : value;
}
}
// Usage in template
{{ longText | truncate:50 }}
{{ longText | truncate:50:'...' }}
// Impure pipe (re-runs on every change detection cycle) — use sparingly
@Pipe({ name: 'filter', standalone: true, pure: false })
Built-in pipes: date, currency, number, percent, json, async, keyvalue, titlecase, uppercase, lowercase, slice.
Custom Directives
// Attribute directive — adds behaviour to host element
@Directive({ selector: '[appHighlight]', standalone: true })
export class HighlightDirective {
@Input('appHighlight') color = 'yellow';
private el = inject(ElementRef);
private renderer = inject(Renderer2);
@HostListener('mouseenter') onEnter() {
this.renderer.setStyle(this.el.nativeElement, 'background', this.color);
}
@HostListener('mouseleave') onLeave() {
this.renderer.removeStyle(this.el.nativeElement, 'background');
}
}
// Usage
<p appHighlight="lightblue">Hover me</p>
NgRx (when signals aren't enough)
Use NgRx when: multiple features share state, you need time-travel debugging, or complex side effects via Effects.
// state/user.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
export const UserActions = createActionGroup({
source: 'Users',
events: {
'Load Users': emptyProps(),
'Load Users Success': props<{ users: User[] }>(),
'Load Users Failure': props<{ error: string }>(),
},
});
// state/user.reducer.ts
const initialState: UserState = { users: [], loading: false, error: null };
export const userReducer = createReducer(
initialState,
on(UserActions.loadUsers, state => ({ ...state, loading: true })),
on(UserActions.loadUsersSuccess, (state, { users }) => ({ ...state, users, loading: false })),
on(UserActions.loadUsersFailure, (state, { error }) => ({ ...state, error, loading: false })),
);
// state/user.effects.ts
export const loadUsersEffect = createEffect(
(actions$ = inject(Actions), userService = inject(UserService)) =>
actions$.pipe(
ofType(UserActions.loadUsers),
switchMap(() =>
userService.getUsers().pipe(
map(users => UserActions.loadUsersSuccess({ users })),
catchError(e => of(UserActions.loadUsersFailure({ error: e.message }))),
),
),
),
{ functional: true },
);
// Component
store.dispatch(UserActions.loadUsers());
users = toSignal(store.select(selectAllUsers));
Testing
Component Tests (TestBed)
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
describe('UserListComponent', () => {
let fixture: ComponentFixture<UserListComponent>;
let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
userService = jasmine.createSpyObj('UserService', ['getUsers']);
userService.getUsers.and.returnValue(of([{ id: '1', name: 'Alice' }]));
await TestBed.configureTestingModule({
imports: [UserListComponent], // standalone: import directly
providers: [{ provide: UserService, useValue: userService }],
}).compileComponents();
fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
});
it('renders a user', () => {
const item = fixture.debugElement.query(By.css('li'));
expect(item.nativeElement.textContent).toContain('Alice');
});
it('calls service on init', () => {
expect(userService.getUsers).toHaveBeenCalled();
});
});
Service Tests
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [UserService, provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // ensure no unexpected requests
it('fetches users', () => {
service.getUsers().subscribe(users => {
expect(users.length).toBe(1);
expect(users[0].name).toBe('Alice');
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush([{ id: '1', name: 'Alice' }]);
});
});
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Memory leaks from subscriptions | Use takeUntilDestroyed() or async pipe |
ExpressionChangedAfterItHasBeenCheckedError | Don't mutate state during change detection; use OnPush + signals |
Injecting in constructor when using inject() | Pick one — don't mix constructor params and inject() in the same class |
*ngFor without trackBy on large lists | Add track item.id (new syntax) or trackBy function |
| Impure pipe causing performance issues | Switch to pure pipe + signal/async pipe combination |
Importing BrowserModule in feature modules | Only import in app.config.ts; use CommonModule or standalone imports in features |
| Forgetting to unsubscribe from route params | Use toSignal(this.route.paramMap) or takeUntilDestroyed() |
Red Flags
ChangeDetectionStrategy.Defaulton new components — Default CD runs on every browser event across the entire tree; all new components should useOnPushand rely on signals or theasyncpipe- Subscribing in a component without unsubscribing — a bare
subscribe()withouttakeUntilDestroyed(), theasyncpipe, or anngOnDestroyunsubscribe creates a memory leak anytype in templates — Angular'sstrictTemplatesflag catches type mismatches at compile time;anysilences the check and defers the error to runtime- Service provided in a component's
providersarray — a component-provided service creates a new instance per component; for singleton behavior, provide at the module or root level - Mixing
[(ngModel)]and reactive forms in the same component — template-driven and reactive form approaches manage state separately and conflict when mixed; pick one approach per form - Direct DOM manipulation bypassing Angular's abstractions — accessing the DOM directly breaks SSR and bypasses change detection; use
Renderer2, the CDK, or signals - Forgetting
markForCheck()after async updates inOnPushcomponents — signals and theasyncpipe trigger CD automatically; manually pushed non-signal updates inOnPushrequire explicitmarkForCheck()
Checklist
- All new components are standalone with
ChangeDetectionStrategy.OnPush -
inject()used instead of constructor injection - Signals used for local and service state (not
BehaviorSubjectfor new code) -
toSignal()used in templates instead ofasyncpipe where possible - All subscriptions cleaned up with
takeUntilDestroyed()orasyncpipe - Reactive forms used for complex forms; typed with
FormBuilder - Lazy loading applied to all feature routes
- Functional guards and resolvers (not class-based)
- Functional interceptors (not class-based)
- HttpClient responses typed with generics (
http.get<User[]>(...)) - Tests use
jasmine.createSpyObjfor service dependencies, not real services