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小水管的博客應用來說,有點接受不了。但如果你開發一個稍微大型點的應用,相信這個缺陷應該不是問題了。