Angular(Angular 2+ )是一套现代的 WEB 开发框架,它采用模块化开发,提供一套完整的开发支持,使开发者能更专注于业务逻辑,提高生产效率。
CMS(内容管理系统),提供对内容的增、删、改、查等功能。
本文介绍如何用 Angular 搭建一个 CMS 系统,文章重点关注流程,更多技术细节请参考 官方文档。
目标
实现简易用户管理功能,查看在线例子。
- 编辑页:支持新建用户,支持修改用户信息
- 列表页:展示用户数据,支持分页查询,支持删除用户
搭建环境
确保设备已安装 node , 且满足 node 8.x 和 npm 5.x 以上的版本。
安装 Angular CLI 。它包含一套命令行指令,可以帮助开发者快速创建项目、添加文件、以及执行项目运行、打包等任务。
npm install -g @angular/cli
创建Angular项目
使用 Angular CLI 提供的ng new
命令创建一个新项目。Angular CLI 会在当前目录创建一个指定命名的新项目,创建过程中会自动安装项目所需依赖,如果在公司内网这一步需要配合代理进行。运行下列命令创建并启动一个 CMS 项目。
ng new cms
cd cms
ng serve --open
使用--open
,在编译完成后会自动打开浏览器并访问 http://localhost:4200/,可以看到一个 Angular 项目启动了。其他比较常用的是参数有,
--port 指定端口号
--proxy-config 代理配置文件
--host fe.cms.webdev.com /*在需要读取cookie的情况下会有用*/
搭建页面骨架
模块与组件
Angular 采用模块化的开发方式。
模块是一组功能的集合。模块把若干组件、服务等聚合在一起,它们共享同一个编译上下文环境。页面的每一个小部分都可以看作是一个组件。
组件包含组件类和组件模版。模版负责组件的展示,可以使用 Angular 的模版语法对 html 进行修改。组件类实现组件的逻辑部分,可以通过注入服务去实现一些数据交互逻辑。
Angular CLI 初始化项目中有唯一的一个模块—— AppModule 模块。它是一个根模块,页面从这里启动,它下面可以包含子模块和组件。为了演示方便,在项目中不再新建模块,只通过组件去实现不同页面的展示。
新建两个组件:list 负责数据管理,edit 负责表单编辑。除此之外,还需要一个 nav-side 组件作为页面导航,负责 list、edit 的切换。用 ng g 命令创建这三个组件。下面几个命令是等价的。
ng generate component nav-side
ng g component edit
ng g c list
试试将它们添加到页面中,在模版中创建它们。
在页面上可以看到,这三个组件都被创建了。但我们需要在不同情况下分别展示 list 和 edit 组件,可以通过引入路由模块来实现。
路由
Angular 的
Router
模块提供了对路由对支持。在 Angular 中使用路由至少要做如下两个配置:
1、定义路由。Angular 路由(Route)是一个包含 path 和 component 属性对对象数组。path 用来匹配URL路径,component 则告诉 Router 在当前路径下应该创建哪个组件。
2、添加路由出口。在页面上添加元素,当路由到的某个组件时,会在当前位置展示组件的视图。
定义页面需要的路由。Edit 路由上定义了一个id参数,通过它可以把用户ID传给组件。
import { RouterModule, Routes } from '@angular/router';
const appRoutes: Routes = [
{ path: 'list', component: ListComponent },
{ path: 'edit/:id', component: EditComponent },
{ path: 'edit', redirectTo: 'edit/create', pathMatch: 'full'},
{ path: '', redirectTo: '/list', pathMatch: 'full'} // 默认定向到list
];
@NgModule({
imports: [
RouterModule.forRoot(appRoutes),
// other imports here
],
...
})
export class AppModule { }
在模版中定义路由出口,之前的 edit 和 list 模块被路由出口代替。当路由匹配 edit 或 list 时,它们会在router-outlet
的位置被创建。
在 nav-side 中使用路由跳转。绑定routerLink
属性,下面使用两种方式,后一种方式支持传入更多参数。此外还绑定了routerLinkActive
属性,它支持传入CSS类,当当前路由被激活时CSS类就会被添加。
现在我们会看到页面效果如图。点击侧边栏,可在列表页和编辑页之间来回切换。
至此,页面骨架搭建完成。
列表页实现
简单梳理列表页需要实现的内容。
- 功能拆分:数据展示、查询、删除
- 页面划分:表格、分页、搜索框
数据定义
在开始页面实现之前,需要做一些准备工作,首先需要设计列表页的数据。
Angular项目中默认使用TypeScript开发,在TS中我们可以通过Interface实现数据类型的定义。
定义Interface的好处在于可以规范数据类型,编辑器及代码编译阶段都会对数据类型做检查,可以减少由于类型而导致的问题的产生,明确的类型定义也便于后期维护。
新建一个data.interface.ts
文件,并定义用户、列表、分页、列表搜索参数的数据格式。
export interface IUser {
id?: number;
nick: string;
sex: 'male'|'female';
}
export interface IList {
data: IUser[];
pager: IPager
}
export interface IPager {
currPage: number;
totalPage: number;
}
export interface ISearchParams {
page?: number;
keyword?: string;
}
数据模拟
在一些场景下,为了模拟数据请求,前端需要实现mock接口的功能。Angular提供了In-memory-web-api进行数据模拟。
我们可以创建项目中需要的一组数据,然后通过 REST API 请求获取数据。我们可以按照真实接口的样式去实现请求方法,在真正的接口准备好之后,只需要移除in-memory-data
,就可以实现真实与模拟请求的无缝替换。
下面我们定义需要的数据。
import { InMemoryDbService } from 'angular-in-memory-web-api';
export class InMemoryDataService implements InMemoryDbService {
createDb() {
const users = [
{ id: 12, nick: 'Narco', sex: 'male' },
{ id: 13, nick: 'Bombasto', sex: 'male' }
...
];
return {users};
}
}
数据请求
HttpClient
Angular中实现HTTP请求需要引入
HttpClientModule
。HttpClient
提供了一组 API 用来实现 HTTP 请求,并返回一个 Observable 类型的对象,可以对返回数据做流式处理,如错误拦截、数据转化等。
新建data.service.ts
,用来实现数据请求。
在获取数据列表的请求中,我们使用map
操作符对数据进行处理,获取需要的对应分页下的数据。
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { IList, IUser, ISearchParams } from './data.interface';
@Injectable({
providedIn: 'root',
})
export class DataService {
private url = 'api/users';
constructor(private http: HttpClient) {}
getList(params: ISearchParams): Observable {
let currPage = params.page, totalPage: number, limit = 6;
return this.http.get(this.url, {
params: new HttpParams().set('nick', params.keyword)
}).pipe(
map((data: IUser[]) => {
return { // 模拟分页
data: data.slice((currPage-1)*limit, (currPage)*limit),
pager: {
currPage: currPage,
totalPage: Math.ceil(data.length / limit)
}
}
}))
}
getUser(id: number): Observable {
return this.http.get(`${this.url}/${id}`)
}
deleteUser(id: number): Observable {
return this.http.delete(`${this.url}/${id}`)
}
addUser(data: IUser): Observable {
return this.http.post(this.url, data)
}
updateUser(data: IUser): Observable {
return this.http.put(this.url, data)
}
}
在AppModule中引入发送数据请求需要的HttpClientModule
和本地数据获取需要的HttpClientInMemoryWebApiModule
。
import { HttpClientModule } from '@angular/common/http';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
@NgModule({
imports: [
HttpClientModule,
HttpClientInMemoryWebApiModule.forRoot(
InMemoryDataService, { dataEncapsulation: false }
)
// other imports here
],
...
})
export class AppModule { }
组件实现
下一步,需要在 list 组件内调用 DataService 获取列表数据并展示。这里使用到了 Angular 生命周期钩子——ngOnInit
,在组件 Init 之后执行页面逻辑。
接下来会使用到 Observale 和 RXJS 操作符,相关知识点参考 Angular Observable, RXJS
由于 DataService 返回一个包含列表数组及分页信息的 Observable 类型的数据,我们需要将这两部分数据分离并展示。下面代码中,通过一系列流的操作,我们把分页数据提取给了 pager 对象,列表数组使用一个 Observable 类型的对象表示—— listData$。
将 listData$ 绑定到模版上,通过async
[pipe](https://angular.io/guide/pipes)可以实现 Observable 的订阅。Observable 在被订阅后,每次更新 Observer 都会受到新数据,即页面上的数据都会刷新。由于 updateList$ 是BehaviorSubject
类型,只需要调用next
方法即可实现数据的刷新。
export class ListComponent implements OnInit {
pager: IPager = { currPage: 1, totalPage: 1 } as IPager;
listData$: Observable;
updateList$: BehaviorSubject = new BehaviorSubject(1);
constructor(private service: DataService) { }
ngOnInit() {
this.listData$ = this.updateList$
.pipe(
switchMap((page: number) => {
// 获取列表数据
return this.service.getList(Object.assign({
page: page
}, this.searchForm.form.getRawValue())).pipe(
catchError(() => of({ data: [], pager: { currPage: 1, totalPage: 1 } })))
}),
tap((list: IList) => { this.pager = list.pager }),
map((list: IList) => list.data)
)
}
//删除用户
deleteUser(id: number) {
this.service.deleteUser(id).subscribe(() => {
//刷新列表
this.updateList$.next(this.pager.currPage);
})
}
}
ID
昵称
性别
操作
{{data.id}}
{{data.nick}}
{{data.sex === 'male'? '男': '女'}}
编辑
删除
组件间数据交互
分页组件
实现一个简单的分页组件,展示当前页码和总页数,并提供一个输入框可以填写需要跳转到的页面。
新建一个 pagination 组件。组件接收 IPager 类型的参数,并展示 pager 内容。当跳转按钮被点击时,向外发出 pageChange 事件,并把需要跳转到的页码给出。父组件( ListComponent )需要在模版中给 pagination 组件传入 pager 属性的值,并监听 pageChange 事件。这里使用了 Angular 的@Input
、@Output
定义了组件的输入输出属性。
对于回车跳转的方式,可以直接监听 Input 上的 keyup 事件,也可以通过 RXJS 的fromEvent
监听 keyup 事件,当监听到回车时调用页面跳转方法。
export class PaginationComponent implements OnInit {
targetPage: number;
@Input() pager: IPager;
@Output() pageChange: EventEmitter = new EventEmitter();
ngOnInit() {
fromEvent(document.getElementById('input'), 'keyup')
.pipe(filter((event: KeyboardEvent) => event.key === 'Enter'))
.subscribe(() => { this.onPageChange(); })
}
onPageChange() {
this.pageChange.emit(+this.targetPage);
this.targetPage = null;
}
}
跳转
{{pager.currPage}} / {{pager.totalPage}}
onPageChange(page: number) {
this.updateList$.next(page);
}
搜索组件
对于搜索组件,它需要将搜索表单内容与列表页共享,这里通过@ViewChild
的方式共享数据,它提供了父组件获取子组件实例的方法,通过组件实例可以获取到组件内的属性。
新建 searh-form 组件,使用 Reactive-Form 的模式构建一个搜索表单。
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
...
export class SearchFormComponent implements OnInit {
form: FormGroup;
@Output() search: EventEmitter = new EventEmitter();
constructor(private fb: FormBuilder) { }
ngOnInit() {
this.form = this.fb.group({keyword: ['']});
}
onSubmit() {
this.search.emit();
}
}
@ViewChild(SearchFormComponent) searchForm: SearchFormComponent;
ngOnInit() {
this.listData$ = this.updateList$
.pipe(
switchMap((page: number) => {
return this.service.getList(Object.assign({
page: page
}, this.searchForm.form.getRawValue())).pipe(
catchError(() => of({ data: [], pager: { currPage: 1, totalPage: 1 } })))
}),
tap((list: IList) => { this.pager = list.pager }),
map((list: IList) => list.data)
)
}
onSearchDataChange() {
this.updateList$.next(1);
}
至此,我们实现了用户的展示、查询、删除操作,列表页完成。
编辑页实现
简单梳理编辑页需要实现的内容。
- 功能拆分:数据新增、修改
- 页面划分:标题、表单
标题
在编辑页需要根据用户ID区分是否新建用户。在路由配置中我们已经配置了编辑页最后一个参数为ID,并设置对于新建用户(没有用户ID)的情况下路由统一跳转到 create。因此我们需要在页面中获取路由ID参数,根据是否 create 判断是否为新建用户,并保存用户ID。
这里采用了监听路由参数的方式来获取路由参数,在页面URL发生改变时,用户ID会及时更新。
userId: string;
construct(
...
private route: ActiveRoute
) {
this.route.paramMap.subscribe((params: ParamMap) => {
this.userId = +params.get('id') || null;
})
}
{{!userId? '新建用户': ('编辑用户 - ')}}{{userId}}
表单
新建
同样的,我们引入 Reactive-Form 模块,通过数据模型来渲染表单。这里我们加入了表单校验配置,设置 nick 和 sex 都必填,校验结果可以通过invalid
方法获取。并且在校验失败时,将提交按钮置灰。
表单数据的提交就是请求 DataService 的 addUser 方法,可以在提交成功后通过路由方法跳转到列表页。
ngOnInit() {
this.userForm = this.fb.group({
nick: [null, Validators.required],
sex: [null, Validators.required]
})
}
onSubmit() {
this.dataservice.addUser(this.userForm.getRawValue()).subscribe(() => {
this.router.navigate(['/list']);
})
}
修改
在用户ID存在时,需要获取用户信息进行展示。DataService 已经实现了数据获取方法,在拿到用户信息后,可以通过patchValue
对 userForm 的数据进行修改。
最后我们修改一下 submit 方法,让它能兼容新建和保存两种模式。
construct(
...
private route: ActiveRoute
) {
this.route.paramMap.subscribe((params: ParamMap) => {
this.userId = +params.get('id') || null;
this.userId && this.getFormData();
})
}
private getFormData() {
this.dataservice.getUser(this.userId).subscribe((data) => {
this.userForm.patchValue({nick: data.nick, sex: data.sex});
})
}
onSubmit() {
let submitType = this.userId? 'updateUser': 'addUser';
let formData = this.userForm.getRawValue();
this.userId && (formData.id = this.userId);
this.dataservice[submitType](formData).subscribe(() => {
this.router.navigate(['/list']);
})
}
项目打包及部署
如果需要把项目打包并部署到服务器上,只需要运行ng build
命令即可完成打包,可以配置--prod
参数以选择 AOT 的方式打包。打包后的文件会被保存在angular.json
中配置的outputPath
路径下。
文件的引用路径可以查看打包后的 index.html,并且可以在 angular.json 中修改配置路径。
最后
整套流程下来,我们构建了一个简单但是完整的 CMS 系统,涉及了 Angular 中大部分基础知识点。后续可参考官方文档,增强系统功能,运用更多 Angular 特性。