用Angular2+Express快速搭建博客
1. 写在前面
昨天花了1天的时间把自己的博客从以前的Express换成了Angular2+Express,遂记录于此。博客Demo在这里,你也可以点击这里查看完整代码。
第一次使用Angular2,还是遇到了不少问题,比如
- ng-cli(1.0.0-rc.1)自动生成的项目直接跑起来报错;
- 采用前端路由,刷新页面出现404;
- 用webpack打包后端项目要注意什么;
- 使用Angular2时,如何为某个组件加script标签;
- ...
如果你也遇到了这些问题,或者你想了解一下Angular2开发的大体流程,可以接着往下看。
2. 前后端分离与SPA
先来谈谈传统的Web开发流程。在传统开发里,前端的工作可能是用HTML、CSS将页面“绘制”出来,然后用JS去处理页面里的逻辑。但由于页面中需要展示一些动态的来自数据库中的数据,所以“绘制”的内容不能在当时确实下来,于是用一些“变量”填充在HTML里,等有数据时,才用数据去替换对应的变量,得到最终的完整的页面。以上用“变量”填充HTML的过程,有可能也是由前端完成,但更多的时候其实是后端来完成的;用数据去替换变量的过程,即所谓的页面渲染一般也是在后端完成的,即所谓的后端渲染。还忘了说的一点是路由。传统意义下,页面的路由是由后端控制的,即我们每点击一个链接,跳转到哪个页面或者说接收到什么页面完全是由后端控制的。
以上是传统Web前后端搭配干活的方式,存在着一些问题。比如上面所说的用变量填充HTML的操作若交给后端去做,那么他必须先读懂前端的HTML逻辑,然后才能下手;就算把填充变量的活交给前端去做,但由于这些变量都来自后端,前端测试起来将非常困难;比如,由于填充HTML的操作是交给后端去做的,那么前端在做页面时可能是用一些写死的数据做的测试,最终将真实数据套用过来时,页面显示可能会有出入;再比如如果前端已经将页面交给后端去添加变量,若他再修改了页面,他必须告诉后端哪里做了修改,否则后端需要在修改后的页面里重新再添加一遍变量,这样之前的工作都白费了。
于是,有人提出增大前端的职责范围,把页面渲染交给前端去做,但还是在服务端完成,后端只负责提供数据API接口,完全不管页面的渲染,包括路由。而此意义下的前端,即需要编写页面的结构样式,还需要负责将数据套在里面渲染出最终的页面,需要数据时,通过HTTP或者其他手段调用后端提供的接口即可。这样分工下来,前后端的工作几乎没有重叠之处,他们唯一的交接点在于提供数据的API接口,而这个API接口可以保证是稳定的。这确实能够解决之前的开发效率问题,但增加了一层接口的调用,并对前端的要求会更高。而对前端人员而言,最熟悉的编程语言莫过于JS,于是多出的,调用后端接口,渲染页面的这一层很自然的就会采用Node.js来做。于是有了下面这图(盗用自淘宝UED博客,现在好像搜索不到了:-?):
再说说Angular的工作模式。Angular跟上图的工作方式很像,但只是说在前后端分工上是相似的。Angular把页面渲染的工作放在了浏览器端,(当然Angular也支持后端渲染,参见Angular Universal),因此没有Node这一层,如下图:
这种方式其实更像是C/S架构的软件:除了数据需要向后台获取,其余的工作,像是页面路由,页面渲染等,都是在”客户端“完成的,只不过这里的”客户端“运行在浏览器里。这即是所谓的SPA(单页面应用)。
3. Angular
前面说了一些题外话,下面正式介绍用Angular开发我们的博客前端,需要把Node.js和npm安装好,npm仓库最好使用国内的镜像。可以安装一个叫做nrm的库来非常方便的更改我们的npm源。
首先是工程骨架的搭建,直接采用Angular的构建工具@angular/cli
,先安装:
npm install -g @angular/cli
安装完成后就可以使用ng命令去生成我们的项目了:
ng new NiceBlog
生成的同时它会自动安装依赖包,完毕后,我们就可以进入NiceBlog目录,运行初始构建的项目了:
cd NiceBlog
npm start
注意:这里有坑!如果你使用的angular-cli版本是1.0.0-rc.1,生成的项目很可能跑不起来,至少我这里是这样。你需要将Angular的版本化由2.4.0换成2.4.9,然后重新安装依赖。
之后你便可以开发了。开发时,只要你修改代码,浏览器会自动刷新。
博客打算做成这个样子:
业务逻辑非常简单,就不再做解释了。按照Angular的开发思想,我们需要将一个应用切分为多(一)个模块,每个模块切分为多(一)个组件,组件依赖于服务,管道等。简单解释一下这些概念,模块是一系列组件,服务,管道等元素的集合,它通常按照业务功能进行划分;组件可以看成是一个页面里的小部件,比如一个导航条,一个菜单栏,一个Top10列表等;服务和后端开发里面的Service层相似,它为组件提供服务,比如一个ArticleService暴露出getArticles方法,为组件提供获取文章的服务,这样组件在需要文章数据时,依赖该服务即可,而不必考虑如何得到的这些数据;管道通常用来处理数据的输出格式。
由于这个应用够简单,我们不需要多余的模块,一个App模块作为启动模块,一个路由模块即可。然后App模块再按页面结构分为app、header、footer、summary、archive、detail、about组件。这些模块后可以用ng
命名自动生成,以生成header组件为例:
ng g component header
我们的工作中心围绕组件展开,其余的一切都是为组件服务的。一个基本组件由三个方面(文件)组成:
- 一个是组件的文档结构和各种事件的响应方法的指定,这个由HTML文件来控制,该文件通常起名为:
[组件名].component.html
; - 再一个是组件的样式,这个由css文件来控制,该文件通常起名为:
[组件名].component.css
; - 最后一个是组件的数据结构定义和对数据结构进行操作的方法,并且还需要在其中指定以上的两点,该文件官方推荐用TypeScript编写,通常起名为:
[组件名].component.ts
下面介绍各个组件的编写。
app组件
app组件是我们App模块的bootstrap组件(启动引导组件),这个ng在创建项目时就已经帮我们生成了。我们需要做的是在app组件里面布置好页面结构即可,这需要在该组件对应的HTML页面app.component.html
里写:
<blog-header></blog-header>
<main>
<router-outlet>
</router-outlet>
</main>
<blog-footer></blog-footer>
相信很容易看懂它的意思:顶部和底部是header和footer组件,它们是固定的,会出现在每个页面;夹在中间的main便签里面router-outlet
表示的是路由组件,到时候在路由模块里指定的是哪一个组件,它就会被那个组件代替。然后,你可以为main便签设置点样式,比如让它居中,这个在app组件对应的css文件app.component.css
设置即可。这样app组件就搞定了。
header组件
header组件即页面的导航栏,没有啥逻辑,因此也只需要编写其html和css即可:
header.component.html
<nav>
<div class="wrapper">
<img class="logo" src="../../../assets/img/logo.jpg"/>
<div class="items">
<a class="item" routerLink="/home" routerLinkActive="active">首·页</a>
<a class="item" routerLink="/archives" routerLinkActive="active">归·档</a>
<a class="item" routerLink="/about" routerLinkActive="active">关·于</a>
<a class="item" href="https://github.com/derker94" target="_blank">Github</a>
</div>
</div>
</nav>
代码也很简单,但要注意里面的a
标签的链接地址是写在routerLink
属性里的,而不是在传统的href
里。这个属性和routerLinkActive
是Angular定义的,照做就是。这样我们点击链接时,不会发出http请求,页面的路由是Angular完成的。
Route模块
下面定义route模块。可以使用ng g module app-routing
命令帮我们自动生成。在生成的模块定义文件app-routing.module.ts
里,需要交代路由链接与相应模板的关系,之前我们在app组件一节中就说过,这样<router-outlet></router-outlet>
,就会被相应的组件替换。具体代码如下:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import {SummaryComponent} from './components/summary/summary.component'
import {ArchiveComponent} from './components/archive/archive.component'
import {AboutComponent} from './components/about/about.component'
import {DetailComponent} from './components/detail/detail.component'
const routes: Routes = [
{path: 'home', component: SummaryComponent},
{path: 'archives', component: ArchiveComponent},
{path: 'about', component: AboutComponent},
{path: '', redirectTo: '/home', pathMatch: 'full'},
{path: 'articles/:id', component: DetailComponent},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: []
})
export class AppRoutingModule { }
路由编写好后,你就可以点击页面上的链接了,看看路由是不是生效了呢。
footer组件与about组件
这两个组件没什么好介绍的,都是些写死的数据。
summary组件与archive组件
根据以上路由规则,这个组件是我们在访问/home
时用到的组件。它是一个文章摘要的列表,就像下图一样:
看到列表,自然想到对应的数据结构,数组;而列表的每一项正对应文章(Article)数据结构。于是先定义Article数据结构:
article.ts
export class Article {
_id: string;
title: string;
word: number; // 字数
view: number; // 阅读数
comment: number; // 评论
comments: string[]; // 评论
labels: string[]; //标签
summary: string; // 摘要
html: string; // html 格式内容
date: Date;
}
然后,在Summary组件中,当然有一个文章数组的成员变量:
summary.component.ts
export class SummaryComponent implements OnInit {
articles: Article[];
constructor() {
}
}
于是在html中我们就可以”显示“该文章数组了:
summary.component.html
<div class="wrapper" infinite-scroll (scrolled)="onScroll()">
<section *ngFor="let article of articles">
<h2>
<a class="primary-link" [routerLink]="['/articles', article._id]">{{article.title}}</a>
<time class="float-right">{{article.date | smartDate}}</time>
</h2>
<p class="hint">字数 {{article.word}} 阅读 {{article.view}} 评论 {{article.comment || 0}} </p>
<p>{{article.summary}}...</p>
<p>
<span class="label" *ngFor="let label of article.labels">{{label}}</span>
</p>
</section>
</div>
其中用到了用来循环操作的ngFor
指令,具体语法请参考Angular2官方文档吧。
再回到summary.component.ts中,我们考虑如何获得这个文章数组呢,之前就说过通过服务来拿,我们注入一个ArticleService
(目前还没创建,先写着吧):
export class SummaryComponent implements OnInit {
articles: Article[];
constructor(private articleService: ArticleService) {// <======
}
}
然后再生命周期方法里调用该服务:
ngOnInit() {
this.articleService.getSummaries(0, this.limit).subscribe(res => {
this.articles = res.data;
this.total = res.total;
});
}
archive组件也是类似的,这里就不再介绍了。
ArticleService
下面编写Article服务类,好像也没啥好说的,就不贴代码了。Angular2在Http里面用到了RxJs,很值得学习。需要说明的一点是,在我们的代码里,是直接通过后端接口来获取数据的,要想前后端同步工作,必须先把http接口定义好。还需要说明的一点是,若前端在完成Service后想进行测试,而后端接口开发还没完成,或者前端在开发阶段时服务器是跑在本地的,这样调用接口存在跨域问题。解决上面问题的方法是使用Angular提供的in-memory-web-api模块。
其他问题
以上是用Angular编写前端的大致过程,相信你已经清楚了。还有一个我遇到的问题是:如何在一个组件中使用第三方的脚本呢?比如我要用Mathjax去处理我页面里的Tex公式,以前的做法是直接在html里面用script便签引入Mathjax库即可,但现在好像没地方可以让我们这么去做,在xxx.component.html
中去写吗?我试过,不行。最后Google到Stack Overflow里的一个答案,写一个服务来帮助我们加载,具体可以看我Github上的代码。
最后,代码写完后,我们可以使用npm run build
去build我们的代码,最后我们的代码会被打包成很少的几个文件。你会发现,这样打包出来的代码,有些文件会很大,有1M左右。可以开启aot进行优化,具体是把package.json
中的build对应的命令加上如下参数:
ng build --aot -prod
4. Express
后端采用Express开发,数据库使用的是MongoDB,采用这两者主要是开发的快。当然你也可以常用各种其他的语言技术,比如用JavaWeb来开发,或者用GO,Python,Ruby来开发等等。接口采用Restful风格,以json作为输出格式,相信这个很容易就能搞定,这里不多说。
想提一下的是,原本我准备把开发好的后端代码也用webpack打包一下,这样不仅能装x,最重要的是这么多文件被打包成一个文件,体积上小了不少,而且发布的时候特方便。但无奈装x归装x,在刚开始还能打包,但随着安装的库的增多,便开始报错,解决又需要花大力气,遂放弃。
最后说一下,前后端开发好后怎么结合在一起呢?这个具体实现要看你的后端选择的技术了。但是要保证:
- 前端build出的一堆文件的相对位置不要改变;
- 前端build出的
index.html
是首页面,在访问根url/
时,需要后端把这个index.html
响应给浏览器。 - 后端在收到无效的链接请求时,不要响应404,而是将请求转发到根url
/
上,或者还是将index.html
响应给浏览器,注意是请求转发,而不是重定向。
第3点是解决一些页面从首页点进来是ok的,但是刷新就报404的问题的关键。为什么这样能够解决呢?这是因为我们使用Angular后,点击链接时并不是像传统的那样发出一个http请求(还记得在header组件中,我们并没有为a标签指定href属性吗),而是由Angular处理了点击操作(前端路由),更新了页面(DOM),并更新了浏览器地址栏中的地址。我们刷新浏览器,相当于发出一个http请求去请求该页面,而后端压根就没有编写处理该请求的逻辑,自然会报404。解决的方法就是既然我们把路由交给了Angular去做,那么对于后端无法处理的请求同样转发到前端去,让前端去完成。
5. 小结
以上过程记录的并不详细,原因是如果你已经学过Angular了,那么你会觉得太啰嗦了;如果你还没学过Angular,建议你还是到官网去学习,那你已经讲解的非常详细了。以上只是记录整体结构和遇到的问题,希望能够为你带来帮助。
最后谈一谈使用Angular的感觉,一个字,太棒了!最大的感受是,它让不会组织代码的人都能把代码管理的井井有条。至于缺点嘛,尽管使用了aot,但build出来的文件还是感觉太大(500K左右),对于一个跑在1M小水管的博客应用来说,有点接受不了。但如果你开发一个稍微大型点的应用,相信这个缺陷应该不是问题了。