這幾年里跌捆,單頁(yè)面應(yīng)用的框架令人應(yīng)接不暇薄湿,各種新的概念也層出不窮叫倍。從過(guò)去的 jQuery Mobie偷卧、Backbone 到今天的 Angular 2、React吆倦、Vue 2听诸,除了版本號(hào)不同,他們還有很多的相同之處逼庞。
剛開始寫商業(yè)代碼的時(shí)候蛇更,我使用的是 jQuery瞻赶。使用 jQuery 來(lái)實(shí)現(xiàn)功能很容易赛糟,找到一個(gè)相應(yīng)的 jQuery 插件,再編寫相應(yīng)的功能即可砸逊。對(duì)于單頁(yè)面應(yīng)用亦是如此璧南,尋找一個(gè)相輔助的插件就可以了,如 jQuery Mobile师逸。
盡管在今天看來(lái)司倚,jQuery Mobile 已經(jīng)不適合于今天的多數(shù)場(chǎng)景了。這個(gè)主要原因是篓像,當(dāng)時(shí)的用戶對(duì)于移動(dòng) Web 應(yīng)用的理解和今天是不同的动知。他們覺(jué)得移動(dòng) Web 應(yīng)用就是針對(duì)移動(dòng)設(shè)備而訂制的,移動(dòng)設(shè)備的 UI员辩、更快的加載速度等等盒粮。而在今天,多數(shù)的移動(dòng) Web 應(yīng)用奠滑,幾乎都是單頁(yè)面應(yīng)用了丹皱。
過(guò)去,即使我們想創(chuàng)建一個(gè)單頁(yè)面應(yīng)用宋税,可能也沒(méi)有一個(gè)合適的方案摊崭。而在今天,可選擇的方案就多了(PS:參見(jiàn)《第四章:學(xué)習(xí)前端只需要三個(gè)月【框架篇】》)杰赛。每個(gè)人在不同類型的項(xiàng)目上呢簸,也會(huì)有不同的方案,沒(méi)有一個(gè)框架能解決所有的問(wèn)題
- 對(duì)于工作來(lái)說(shuō)乏屯,我更希望的是一個(gè)完整的解決方案根时。
- 對(duì)于編程體驗(yàn)來(lái)說(shuō),我喜歡一點(diǎn)點(diǎn)的去創(chuàng)造一些輪子瓶珊。
當(dāng)我們會(huì)用的框架越多的時(shí)候啸箫, 所花費(fèi)的時(shí)間抉擇也就越多。而單頁(yè)面應(yīng)用的都有一些相同的元素伞芹,對(duì)于這些基本元素的理解忘苛,可以讓我們更快的適合其他框架蝉娜。
單頁(yè)面應(yīng)用的演進(jìn)
我接觸到單頁(yè)面應(yīng)用的時(shí)候,它看起來(lái)就像是將所有的內(nèi)容放在一個(gè)頁(yè)面上么扎唾。只需要在一個(gè) HTML 寫好所需要的各個(gè)模板召川,并在不同的頁(yè)面上 data-role 表明這是個(gè)頁(yè)面(基于 jQuery Mobile)——每個(gè)定義的頁(yè)面都和今天的移動(dòng)應(yīng)用的模式相似,有 header胸遇、content荧呐、footer 三件套。再用 id 來(lái)定義好相應(yīng)的路由纸镊。
<div data-role="page" id="foo">
...
</div>
這樣我們就在一個(gè) HTML 里返回了所有的頁(yè)面了倍阐。隨后,只需要在在入口處的 href 里逗威,寫好相應(yīng)的 ID 即可峰搪。
<a href="#foo">跳轉(zhuǎn)到foo</a>
當(dāng)我們點(diǎn)擊相應(yīng)的鏈接時(shí),就會(huì)切換到 HTML 中相應(yīng)的 ID凯旭。這種簡(jiǎn)單的單頁(yè)面應(yīng)用基本上就是一個(gè)離線應(yīng)用了概耻,只適合于簡(jiǎn)單的場(chǎng)景,可是它帶有單頁(yè)面應(yīng)用的基本特性罐呼。而復(fù)雜的應(yīng)用鞠柄,則需要從服務(wù)器獲取數(shù)據(jù)。然而早期受限于移動(dòng)瀏覽器性能的影響嫉柴,只能從服務(wù)器獲取相應(yīng)的 HTML厌杜,并替換當(dāng)前的頁(yè)面。
在這樣的應(yīng)用中差凹,我們可以看到單頁(yè)面應(yīng)用的基本元素: 頁(yè)面路由期奔,通過(guò)某種方式,如 URL hash 來(lái)說(shuō)明表明當(dāng)前所在的頁(yè)面危尿,并擁有從一個(gè)頁(yè)面跳轉(zhuǎn)到另外一個(gè)頁(yè)面的入口呐萌。
當(dāng)移動(dòng)設(shè)備的性能越來(lái)越好時(shí),開發(fā)者們開始在瀏覽器里渲染頁(yè)面:
- 使用 jQuery 來(lái)做頁(yè)面交互
- 使用 jQuery Ajax 來(lái)從服務(wù)端獲取數(shù)據(jù)
- 使用 Backbone 來(lái)負(fù)責(zé)路由及 Model
- 使用 Mustache 作為模板引擎來(lái)渲染頁(yè)面
- 使用 Require.js 來(lái)管理不同的模板
- 使用 LocalStorage 來(lái)存儲(chǔ)用戶的數(shù)據(jù)
通過(guò)結(jié)合這一系列的工具谊娇,我們終于可以實(shí)現(xiàn)一個(gè)復(fù)雜的單頁(yè)面應(yīng)用肺孤。而這些,也就是今天我們看到的單頁(yè)面應(yīng)用的基本元素济欢。我們可以在 Angular 應(yīng)用赠堵、React 應(yīng)用、Vue.js 應(yīng)用 看到這些基本要素的影子法褥,如:Vue Router茫叭、React Router、Angular 2 RouterModule 都是負(fù)責(zé)路由(頁(yè)面跳轉(zhuǎn)及模塊關(guān)系)的半等。在 Vue 和 React 里揍愁,它們都是由輔助模塊來(lái)實(shí)現(xiàn)的呐萨。因?yàn)?React 只是層 UI 層,而 Vue.js 也是用于構(gòu)建用戶界面的框架莽囤。
路由:頁(yè)面跳轉(zhuǎn)與模塊關(guān)系
要說(shuō)起路由谬擦,那可是有很長(zhǎng)的故事。當(dāng)我們?cè)跒g覽器上輸入網(wǎng)址的時(shí)候朽缎,我們就已經(jīng)開始了各種路由的旅途了惨远。
- 瀏覽器會(huì)檢查有沒(méi)有相應(yīng)的域名緩存,沒(méi)有的話就會(huì)一層層的去向 DNS服務(wù)器 尋向话肖,最后返回對(duì)應(yīng)的服務(wù)器的 IP 地址北秽。
- 接著,我們請(qǐng)求的網(wǎng)站將會(huì)將由對(duì)應(yīng) IP 的 HTTP 服務(wù)器處理狼牺,HTTP 服務(wù)器會(huì)根據(jù)請(qǐng)求來(lái)交給對(duì)應(yīng)的應(yīng)用容器來(lái)處理羡儿。
- 隨后,我們的應(yīng)用將根據(jù)用戶請(qǐng)求的路徑是钥,將請(qǐng)求交給相應(yīng)的函數(shù)來(lái)處理。最后缅叠,返回相應(yīng)的 HTML 和資源文化
當(dāng)我們做后臺(tái)應(yīng)用的時(shí)候悄泥,我們只需要關(guān)心上述過(guò)程中的最后一步。即肤粱,將對(duì)應(yīng)的路由交給對(duì)應(yīng)的函數(shù)來(lái)處理弹囚。這一點(diǎn),在不同的后臺(tái)框架的表現(xiàn)形式都是相似的领曼。
如 Python 語(yǔ)言里的 Web 開發(fā)框架 Django 的 URLConf鸥鹉,使用正規(guī)表達(dá)式來(lái)表正
url(r'^articles/2003/$', views.special_case_2003),
而在 Laravel 里,則是通過(guò)參數(shù)的形式來(lái)呈現(xiàn)
Route::get('posts/{post}/comments/{comment}', function ($postId, $commentId) {
//
});
雖然表現(xiàn)形式有一些差別庶骄,但是總體來(lái)說(shuō)也是差不多的毁渗。而對(duì)于前端應(yīng)用來(lái)說(shuō),也是如此单刁,將對(duì)應(yīng)的 URL 的邏輯交由對(duì)應(yīng)的函數(shù)來(lái)處理灸异。
React Router 使用了類似形式來(lái)處理路由,代碼如下所示:
<Route path="blog" component={BlogList} />
<Route path="blog/:id" component={BlogDetail} />
當(dāng)頁(yè)面跳轉(zhuǎn)到 blog 的時(shí)候羔飞,會(huì)將控制權(quán)將給 BlogList 組件來(lái)處理肺樟。
當(dāng)頁(yè)面跳轉(zhuǎn)到 blog/fasfasf-asdfsafd 的時(shí)候,將匹配到這二個(gè)路由逻淌,并交給 BlogDetail 組件 來(lái)處理么伯。而路由中的 id 值,也將作為參數(shù) BlogDetail 組件來(lái)處理卡儒。
相似的田柔,而 Angular 2 的形式則是:
{ path: 'blog', component: BlogListComponent },
{ path: 'blog/:id', component: BlogDetailComponent },
相似的誓篱,這里的 BlogDetailComponent 是一個(gè)組件,path 中的 id 值將會(huì)傳遞給 BlogDetailComponent 組件凯楔。
從上面來(lái)看窜骄,盡管表現(xiàn)形式上有所差異,但是其行為是一致的:使用規(guī)則引擎來(lái)處理路由與函數(shù)的關(guān)系摆屯。稍有不同的是邻遏,后臺(tái)的路由完全交由服務(wù)器端來(lái)控制,而前端的請(qǐng)求則都是在本地改變其狀態(tài)虐骑。
并且同時(shí)在不同的前端框架上准验,他們?cè)谛袨樯线€有一些區(qū)別。這取決于我們是否需要后臺(tái)渲染廷没,即刷新當(dāng)前頁(yè)面時(shí)的表現(xiàn)形式糊饱。
- 使用 Hash (#)或者 Hash Bang (#!) 的形式。即 # 開頭的參數(shù)形式颠黎,諸如 ued.party/#/blog另锋。當(dāng)我們?cè)L問(wèn) blog/12 時(shí),URL 的就會(huì)變成 ued.party/#/blog/12
- 使用新的 HTML 5 的 history API狭归。用戶看到的 URL 和正常的 URL 是一樣的夭坪。當(dāng)用戶點(diǎn)擊某個(gè)鏈接進(jìn)入到新的頁(yè)面時(shí),會(huì)通過(guò) history 的 pushState 來(lái)填入新的地址过椎。當(dāng)我們?cè)L問(wèn) blog/12 時(shí)室梅,URL 的就會(huì)變成 ued.party/blog/12。當(dāng)用戶刷新頁(yè)面的時(shí)候疚宇,請(qǐng)通過(guò)新的 URL 來(lái)向服務(wù)器請(qǐng)求內(nèi)容亡鼠。
幸運(yùn)的是,大部分的最新 Router 組件都會(huì)判斷是否支持 history API敷待,再來(lái)決定先用哪一個(gè)方案间涵。
數(shù)據(jù):獲取與鑒權(quán)
實(shí)現(xiàn)路由的時(shí)候,只是將對(duì)應(yīng)的控制權(quán)交給控制器(或稱組件)來(lái)處理讼撒。而作為一個(gè)單頁(yè)面應(yīng)用的控制器浑厚,當(dāng)執(zhí)行到相應(yīng)的控制器的時(shí)候,就可以根據(jù)對(duì)應(yīng)的 blog/12 來(lái)獲取到用戶想要的 ID 是 12根盒。這個(gè)時(shí)候钳幅,控制器將需要在頁(yè)面上設(shè)置一個(gè) loading 的狀態(tài),然后發(fā)送一個(gè)請(qǐng)求到后臺(tái)服務(wù)器炎滞。
對(duì)于數(shù)據(jù)獲取來(lái)說(shuō)敢艰,我們可以通過(guò)封裝過(guò) XMLHttpRequest 的 Ajax 來(lái)獲取數(shù)據(jù),也可以通過(guò)新的册赛、支持 Promise 的 Fetch API 來(lái)獲取數(shù)據(jù)钠导,等等震嫉。Fetch API 與經(jīng)過(guò) Promise 封裝的 Ajax 并沒(méi)有太大的區(qū)別,我們?nèi)匀皇菍戭愃朴诘男问剑?/p>
fetch(url).then(response => response.json())
.then(data => console.log(data))
.catch(e => console.log("Oops, error", e))
對(duì)于復(fù)雜一點(diǎn)的數(shù)據(jù)交互來(lái)說(shuō)牡属,我們可以通過(guò) RxJS 來(lái)解決類似的問(wèn)題票堵。整個(gè)過(guò)程中,比較復(fù)雜的地方是對(duì)數(shù)據(jù)的鑒權(quán)與模型(Model)的處理逮栅。
模型麻煩的地方在于:轉(zhuǎn)變成想要的形式悴势。后臺(tái)返回的值是可變的,它有可能不返回措伐,有可能是 null特纤,又或者是與我們要顯示的值不一樣——想要展示的是 54%,而后臺(tái)返回的是 0.54侥加。與此同時(shí)捧存,我們可能還需要對(duì)數(shù)值進(jìn)行簡(jiǎn)單的計(jì)算,顯示一個(gè)范圍担败、區(qū)間昔穴,又或者是不同的兩種展示。
同時(shí)在必要的時(shí)候氢架,我們還需要將這些值存儲(chǔ)在本地傻咖,或者內(nèi)存里。當(dāng)我們重新進(jìn)入這個(gè)頁(yè)面的時(shí)候岖研,我們?cè)偃プx取這些值。
一旦談?wù)摰綌?shù)據(jù)的時(shí)候警检,不可避免的我們就需要關(guān)心安全因素孙援。對(duì)于普通的 Web 應(yīng)用來(lái)說(shuō),我們可以做兩件事來(lái)保證數(shù)據(jù)的安全:
- 采用 HTTPS:在傳輸?shù)倪^(guò)程中保證數(shù)據(jù)是加密的扇雕。
- 鑒權(quán):確保指定的用戶只能可以訪問(wèn)指定的數(shù)據(jù)拓售。
目前,流行的前端鑒權(quán)方式是 Token 的形式镶奉,可以是普通的定制 Token础淤,也可以是 JSON Web Token。獲取 Token 的形式哨苛,則是通過(guò) Basic 認(rèn)證——將用戶輸入的用戶名和密碼鸽凶,經(jīng)過(guò) BASE64 加密發(fā)送給服務(wù)器。服務(wù)器解密后驗(yàn)證是否是正常的用戶名和密碼建峭,再返回一個(gè)帶有時(shí)期期限的 Token 給前端玻侥。
隨后,當(dāng)用戶去獲取需要權(quán)限的數(shù)據(jù)時(shí)亿蒸,需要在 Header 里鑒定這個(gè) Token 是否有限凑兰,再返回相應(yīng)的數(shù)據(jù)掌桩。如果 Token 已經(jīng)過(guò)期了,則返回 401 或者類似的標(biāo)志姑食,客戶端就在這個(gè)時(shí)候清除 Token波岛,并讓用戶重新登錄。
數(shù)據(jù)展示:模板引擎
現(xiàn)在音半,我們已經(jīng)獲取到這些數(shù)據(jù)了则拷,下一步所需要做的就是顯示這些數(shù)據(jù)。與其他內(nèi)容相比祟剔,顯示數(shù)據(jù)就是一件簡(jiǎn)單的事隔躲,無(wú)非就是:
- 依據(jù)條件來(lái)顯示、隱藏某些數(shù)據(jù)
- 在模板中對(duì)數(shù)據(jù)進(jìn)行遍歷顯示
- 在模板中執(zhí)行方法來(lái)獲取相應(yīng)的值物延,可以是函數(shù)宣旱,也可以是過(guò)濾器。
- 依據(jù)不同的數(shù)值來(lái)動(dòng)態(tài)獲取樣式
- 等等
不同的框架會(huì)存在一些差異叛薯。并且現(xiàn)代的前端框架都可以支持單向或者雙向的數(shù)據(jù)綁定浑吟。當(dāng)相應(yīng)的數(shù)據(jù)發(fā)生變化時(shí),它就可以自動(dòng)地顯示在 UI 上耗溜。
最后组力,在相應(yīng)需要處理的 UI 上,綁上相應(yīng)的事件來(lái)處理抖拴。
只是在數(shù)據(jù)顯示的時(shí)候燎字,又會(huì)涉及到另外一個(gè)問(wèn)題,即組件化阿宅。對(duì)于一些需要重用的元素候衍,我們會(huì)將其抽取為一個(gè)通用的組件,以便于我們可以復(fù)用它們洒放。
<my-sizer [(size)]="fontSizePx"></my-sizer>
并且在這些組件里蛉鹿,也會(huì)涉及到相應(yīng)的參數(shù)變化即狀態(tài)改變。
交互:事件與狀態(tài)管理
完成一步步的渲染之后往湿,我們還需要做的事情是:交互妖异。交互分為兩部分:用戶交互、組件間的交互——共享狀態(tài)领追。
組件交互:狀態(tài)管理
用戶從 A 頁(yè)面跳轉(zhuǎn)到 B 頁(yè)面的時(shí)候他膳,為了解耦組件間的關(guān)系,我們不會(huì)使用組件的參數(shù)來(lái)傳入值蔓腐。而是將這些值存儲(chǔ)在內(nèi)存里矩乐,在適當(dāng)?shù)臅r(shí)候調(diào)出這些值。當(dāng)我們處理用戶是否登錄的時(shí)候,我們需要一個(gè) isLogined 的方法來(lái)獲取用戶的狀態(tài)散罕;在用戶登錄的時(shí)候分歇,我們還需要一個(gè) setLogin 的方法;用戶登出的時(shí)候欧漱,我們還需要更新一下用戶的登錄狀態(tài)职抡。
在沒(méi)有 Redux 之前,我都會(huì)寫一個(gè) service 來(lái)管理應(yīng)用的狀態(tài)误甚。在這個(gè)模塊里寫上些 setter缚甩、getter 方法來(lái)存儲(chǔ)狀態(tài)的值,并根據(jù)業(yè)務(wù)功能寫上一些來(lái)操作這個(gè)值窑邦。然而擅威,使用 service 時(shí),我們很難跟蹤到狀態(tài)的變化情況冈钦,還需要做一些額外的代碼來(lái)特別處理郊丛。
有時(shí)候也會(huì)犯懶一下,直接寫一個(gè)全局變量瞧筛。這個(gè)時(shí)候維護(hù)起代碼來(lái)就是一場(chǎng)噩夢(mèng)厉熟,需要全局搜索相應(yīng)的變量。如果是調(diào)用某個(gè)特定的 Service 就比較容易找到調(diào)用的地方较幌。
用戶交互:事件
事實(shí)上揍瑟,對(duì)于用戶交互來(lái)說(shuō)也只是改變狀態(tài)的值,即對(duì)狀態(tài)進(jìn)行操作乍炉。
舉一個(gè)例子绢片,當(dāng)用戶點(diǎn)擊登錄的時(shí)候,發(fā)送數(shù)據(jù)到后臺(tái)岛琼,由后臺(tái)返回這個(gè)值杉畜。由控制器一一的去修改這些狀態(tài),最后確認(rèn)這個(gè)用戶登錄衷恭,并發(fā)一個(gè)用戶已經(jīng)登錄的廣播,又或者修改全局的用戶值纯续。