SPA(單頁面應用)的核心是什麼?javascript
自該類型應用誕生以來我最多思考的問題就是這個。如今前端 SPA 框架滿天飛,許多不是框架的也被稱做框架,究竟有什麼表明性的層(layer)能讓一個系統稱得上是框架?html
個人答案是路由,而路由的本質就是一個狀態管理器。沒有路由機制的系統不能稱之爲框架,而路由機制作得很差的框架也算不上好框架(但能夠算是好的工具集合,好比 Angular——詳見我在 Ruby China 上曾經吐過的槽)。前端
爲何這麼說呢?咱們都知道 HTML 是無狀態的(stateless),作一堆 HTML 頁面拼在一塊兒那不叫「應用」,頂多稱之爲「內容系統」;在之前,HTML 網站上的狀態管理是由後端的 Session 加前端的 Cookies 協做完成的,到了 SPA 的時代 Session 不是必須的了(儘管傳統的 Session 機制也是可用的),UI 上的狀態轉移到了前端由 JavaScript 徹底管控(因爲 SPA 先後分離的特色),因此前端工程師擔負起了更多的業務邏輯職責,相應的整個技術鏈上也必須有一個可靠的環節來幫助他們作狀態管理這件事情。html5
在前端框架的發展過程當中路由的誕生是水到渠成的(基於一些新技術的成熟,好比 HTML5 的History API 等等),可是應用開發工程師對於路由的理解和重視卻還遠遠不夠。若是說傳統的前端開發是以頁面爲中心來入手的話,那麼現代的 SPA 應用開發就是以狀態爲中心來着手設計和開發的。java
Ember 就是一款很是重視路由組件的 SPA 框架,本文藉由一個實現 UI 佈局的例子來談談 UI 編程與路由的關係,儘管這只是涉及到路由特性的一部分卻也足夠說明一些問題了。但願這個例子能讓更多前端工程師認識和理解路由的重要性,從而更好的設計與實現 SPA 應用的各類功能場景。git
多數應用都有以下所述的 UI 設計:github
多數視圖在一個通用的佈局內呈現,好比典型的 Header + Main 的佈局編程
個別視圖須要一個特定的佈局,好比登陸和註冊頁面不須要 Header 等等後端
對於這些場景來講,那些重複的 HTML 結構(如 Header 和 Footer)確定須要某種方式的抽象使得它們能夠複用或者指定渲染仍是不渲染。後端渲染技術使用了一些機制(如 helpers 等) 來幫助開發者在視圖層實現這些邏輯,等到返回給瀏覽器的時候已是完整的 HTML 了(固然也有 Turbolinks 這樣融合了部分前端路由特性的新技術,本文不作進一步描述),這顯然是不適合前端應用的場景的,由於對於 SPA 應用來講用戶更換 URLs 時須要在瀏覽器端即時拼裝最終的完整視圖,並不存在「預先渲染好的頁面一塊兒交付過來」這麼一說。咱們須要先思考一下高層設計,看看有什麼機制能夠利用的。瀏覽器
路由是怎麼管理狀態的?複雜的話題簡單說:
In Ember.js, each of the possible states in your application is represented by a URL.
在 Ember.js 中,應用的每個可能的狀態都是經過 URL 體現的。
這是官方文檔裏所總結的,我來試着舉例表述一下:
假設當前有以下路由定義:
let Router = Ember.Router.extend() Router.map(function() { this.route('dashboard', { path: '/dashboard' }) this.route('signin', { path: '/signin' }) })
因而,當用戶——
進入 /dashboard
URL 的時候,對應的 dashboard
路由開始接管應用的當前狀態
進入 /signin
URL 的時候,對應的 signin
路由開始接管應用的當前狀態
但更重要的是:全部的路由都有一個共有的頂級路由——application
路由,其重要性主要體如今:
它是惟一一個靠譜的能夠用來管理全局範圍狀態的路由
它爲全部子路由的視圖渲染提供了模板的入口(outlet)
接着問題來了:若是說狀態經過 URL 來體現,那麼 UI 佈局的不一樣如何體現呢?好比:
進入 /dashboard
URL 的時候,咱們須要 Header + Main 的佈局
進入 /signin
URL 的時候,咱們不須要 Header
不管何種情形,application
路由在其中的做用……?
由於每個路由都會渲染本身的模版,咱們能夠作一個最簡單的嘗試:
{{!app/pods/application/template.hbs}} {{outlet}}
{{!app/pods/dashboard/template.hbs}} <header>...</header> <main> ... {{outlet}} </main>
{{!app/pods/signin/template.hbs}} <main> ... {{outlet}} </main>
雖然這麼作能夠奏效,然而問題也是顯而易見的:若是出現多個和 dashboard
同樣的佈局結構,咱們將不得很少次重複 <header></header>
;曾經 Ember 有 {{partial}}
這樣的 helper 來作模版片斷複用,可是第一,之後沒有 {{partial}}
了,二來用 {{partial}}
作佈局是錯誤的選擇。
若是咱們能夠把問題場景簡化爲只有一種可能,例如「全部的視圖都用 Header + Main 的佈局」,那麼解決方案能夠簡化爲:
{{!app/pods/application/template.hbs}} <header>...</header> <main> {{outlet}} </main> <footer>...</footer>
{{!app/pods/dashboard/template.hbs}} ... {{outlet}}
{{!app/pods/signin/template.hbs}} ... {{outlet}}
那麼再次恢復原來的場景要求,問題變成了:「進入 /signin
以後,如何隱藏 application
模版裏的 <header></header>
?
隱藏模版裏的片斷,最簡單的方法能夠這麼作:
{{!app/pods/application/template.hbs}} {{#if showNavbar}} <header>...</header> {{/if}} <main> {{outlet}} </main>
咱們知道模版內可訪問的變量能夠經過控制器來設置,但此時我不打算建立 ApplicationController
,由於路由裏有一個 setupController
的鉤子方法能幫咱們設置控制器的(更重要的緣由是很快 Routable Components 將取代如今的 route + controller + template 的分層體系,因此從如今開始最好儘量少的依賴 controller),試試看:
// app/pods/application/route.js export default Ember.Route.extend({ setupController(controller) { this._super(...arguments) controller.set('showNavbar', true) }), })
如今全部的狀態都會顯示 header 部分了,那怎麼讓 /signin
不顯示呢?或許這樣……?
// app/pods/signin/route.js export default Ember.Route.extend({ setupController() { this._super(...arguments) this.controllerFor('application').set('showNavbar', false) }), })
如下是測試結果(這裏建議先寫 Acceptance Test,省時間且不易錯漏),在每次刷新頁面後:
從... | 到... | 結果 |
---|---|---|
/ |
/dashboard |
成功 |
/dashboard |
/ |
成功 |
/ |
/signin |
成功 |
/signin |
/ |
失敗 |
/dashboard |
/signin |
成功 |
/signin |
/dashboard |
失敗 |
/signin |
/dashboard |
失敗 |
/dashboard |
/signin |
失敗 |
咱們在測試中增長了 /dashboard
的訪問,可是咱們並無定義位於 DashboardRoute
裏的 setupController
鉤子,這是由於咱們指望 /dashboard
可以繼承 /
的狀態,不然全部的路由都要設置相似的 setupController
會把人累死,然而測試結果可能會讓初學者以爲摸不着頭腦,咱們試着分析一下好了:
/
和 /dashboard
都須要 showNavbar === true
,因此正反均可以;
當自 /signin
刷新頁面的時候,先執行了 ApplicationRoute
而後纔是 SigninRoute
,等到進入 /
的時候,setupController
不會再次執行的;
同上;
同上。
這裏最明顯的問題就是 ApplicationRoute#setupController
這個鉤子方法是不可靠的,你只能保證它的第一次運行,一旦變成了在路由之間來回跳轉就無效了。
實際上,
setupController
的做用是將model
鉤子返回的結果綁定在對應的控制器上的,你能夠擴展這個邏輯但也僅限於數據層面的設置。只有當調用了route#render()
且返回了與以前不一樣的 model 時setupController
纔會再次被調用。
因而問題又變成了:有哪個鉤子方法能保證在路由發生變化的時候均可用?
這是一個很是重要但又很無趣的主題,我不想在這裏重複那些能夠經過閱讀文檔和親測就能夠得出的答案,不過我能夠給出一份測試路由生命週期的完整代碼片斷:
https://gist.github.com/nightire/f766850fd225a9ec4aa2
把它們放進你的路由當中而後仔細觀察吧。順便給你一些經驗之談:
這個測試不要錯過 ApplicationRoute
,由於它是最特殊的一個
其餘的路由至少要同時測試兩個,好比 IndexRoute
和 TestRoute
不要只測試頁面刷新後的生命週期,還要嘗試各類路由之間的相互過渡
測試完以後,你就會對整個路由系統有一個很是全面的瞭解了,這些體驗會帶給你一個重要的技能,便是在未來你能夠很容易的決斷出實現一個功能應該從哪裏入手。對於咱們這個例子來講,比較重要的結論以下:
ApplicationRoute
是全部路由的共同先祖,當你第一次進入應用程序——不管是從 /
進入仍是從 /some/complicated/state
進入——ApplicationRoute
都是第一個實例化的路由,而且它 activated
就不會 deactivated
了(除非你手動刷新瀏覽器)。所以咱們能夠把 ApplicationRoute
做爲一個特殊的永遠激活的路由
若是你有應用邏輯依存於 ApplicationRoute#setupController
,那麼第一次進入就是惟一靠譜的機會——你不能期望這個鉤子會在路由來回切換的時候觸發
可是其餘路由上的 #setupController
鉤子是會在每次過渡進來的時候從新執行的
基於以上分析,咱們能夠調整咱們的代碼了:
// app/pods/application/route.js export default Ember.Route.extend()
// app/pods/index/route.js and app/pods/dashboard/route.js export default Ember.Route.extend({ setupController() { this._super(...arguments) this.controllerFor('application').set('showNavbar', true) }, })
// app/pods/signin/route.js export default Ember.Route.extend({ setupController() { this._super(...arguments) this.controllerFor('application').set('showNavbar', false) }, })
咱們把 ApplicationRoute#setupController
裏的邏輯轉移到了 IndexRoute#setupController
裏去,就是由於當你訪問 /
的時候,ApplicationRoute#setupController
只會觸發一次(第一次刷新的時候),而 IndexRoute#setupController
則能夠保證每次都觸發。如今,咱們設想的場景能夠實現了。
這個設定一開始看起來很是古怪,不少初學者都在這裏被搞暈掉:「爲何要有 IndexRoute
?爲何不直接用 ApplicationRoute
?」
當咱們剛開始接觸前端的路由機制時,咱們很容易把 ApplicationRoute
和 /
關聯起來,可實際上真正和 /
關聯的是 IndexRoute
。若是你沒有自行建立 IndexRoute
,Ember 會幫你建立一個,但無論怎樣 IndexRoute
都是必不可少的。
那麼 ApplicationRoute
到底扮演着一個什麼樣的角色呢?
先記住這個結論:在路由系統中,路由樹中任何一個當前激活的路徑都會至少包括兩個路由節點,而且其中一個必然是 ApplicationRoute
,這也正是 ApplicationRoute
永遠處於 activated
而永遠不會 deactivate
的緣由所在。
舉幾個例子:
當訪問 '/' 時,路由樹中當前激活的路徑爲:application => index
當訪問 '/users/new' 時,路由樹中當前激活的路徑爲:application => users => new
當訪問 '/posts/1/comments/1' 時,路由樹中當前激活的路徑爲:application => post => index => comment => index
,也多是:application => posts => show => comments => show
——取決於你的路由規則的寫法
等等……
Ember 並無爲這個特殊的 | 41b8a0714e572ed059c0e52d0e3c676c91 | 作一個明確的定義(可是| 41b8a0714e572ed059c0e52d0e3c676c92 |),不過在其餘相似的路由系統裏咱們能夠找到等價物——好比來自 | 41b8a0714e572ed059c0e52d0e3c676c93 |(Angular 生態圈裏最優秀的路由系統)裏的抽象路由(Abstract Route)。
Ember 的 ApplicationRoute
和 ui.router 的抽象路由很是類似,它們的共性包括:
都可以擁有子路由
自身都不能被直接激活(不能位於路由樹中當前激活路徑的頂點)
不能直接過渡,也就是 transition to;Ember 裏會等價於過渡到 IndexRoute
,ui.router 則會拋出異常
都有對應的模版、控制器、數據入口、生命週期鉤子等等
當其下的任意子路由被激活,做爲父節點的抽象路由都會被激活
固然,它們也有不一樣,好比說:你能夠在 ui.router 的路由樹中任意定義抽象路由,不受數量和節點深度的限制,只要保證抽象路由不會位於某條路徑的頂點就是了;而 Ember Router 只有一個抽象路由(並且並無明確的定義語法,只是行爲相似——典型的鴨子類型設計嘛)且只能是 ApplicationRoute
,你能夠手動建立別的路由來模擬,可是 Ember Router 不會阻止你過渡到這些路由,不像 ui.router 會拋出異常(這一點很容易讓初學者碰壁)
實際上當你對 Ember Router 的理解日漸深刻以後你會發現全部的嵌套路由(包括頂層路由)都是抽象路由,由於它們都會隱式的建立對應的 | 41b8a0714e572ed059c0e52d0e3c676c98 | 做爲該路徑的頂節點,訪問它們就等於訪問它們的 | 41b8a0714e572ed059c0e52d0e3c676c99 |。我認爲 Ember Router 的這個設計與 ui.router 相比有利有弊:
利:設計精巧簡單,能夠避免大量的 boilerplate 代碼,路由的定義相對清晰簡潔
弊:對於初學者來講,因爲不存在抽象路由的概念,很難深入理解父子節點,特別是隱式 IndexRoute
的存在價值
不,還差一些。試想:當咱們須要不少路由來組織應用程序的結構時,相似的 #setupController
豈不是要重複定義不少次?如何抽象這一邏輯讓其變得易於複用和維護?
在開發 Angular 應用的時候,相似場景的路由定義通常是這樣的:
+----> layoutOne(with header) +----> childrenRoutes(like dashboard, etc.) | | application(root) -| | | +----> layoutTwo(without header) +----> childrenRoutes(like signin, etc.)
咱們用 Ember Router 也能夠模擬這樣的路由定義,實現一樣的結果,代碼相似:
// app/router.js let Router = Ember.Router.extend({ location: config.locationType, }) Router.map(function() { // provide layout w/ <header></header> this.route('layoutOne', { path: '/' }, function() { this.route('dashboard', { resetNamespace: true }) // ... }) // provide layout w/o <header></header> this.route('layoutTwo', { path: '/' }, function() { this.route('signin', { resetNamespace: true }) // ... }) })
可是我的很是不喜歡也不推崇這麼作,緣由是:
這樣的路由定義寫多了會很噁心
爲了不相似 /layoutOne/dashboard
這樣的 URLs,不得不重複設定 path: '/'
來覆蓋
ui.router 解決此問題依靠的是 url pattern inheritence,因爲每個路由的定義都必須指明 url 屬性,因此也就習慣了
爲了不相似 layoutTwo.signin
這樣的路由名字,不得不重複設定 resetNamespace: true
ui.router 解決此問題依靠的是路由定義裏的 parent 屬性,因此子路由是能夠分開定義的,不用嵌套也就無需 resetNamespace
對比兩家的路由定義語法,各有優缺點吧,可是 Ember Router 向來都是以簡明扼要著稱的,真心不喜歡爲了這個小小需求而把路由定義寫得一塌糊塗
另外這樣的路由設計還會致使 application
這個模版變成一個廢物,除了 {{outlet}}
它啥也作不成,生成的 DOM Tree 裏平白多一個標籤看的人直噁心~
既然問題的本質是 #setupController
鉤子須要重複定義,那麼有沒有 Ember 風格辦法來解決這一問題呢?
首先咱們來考量一下 Mixin,你能夠這麼作:
// app/mixins/show-navbar.js export default Ember.Mixin.create({ setupController() { this._super(...arguments) this.controllerFor('application').set('showNavbar', true) }, }) // app/mixins/hide-navbar.js export default Ember.Mixin.create({ setupController() { this._super(...arguments) this.controllerFor('application').set('showNavbar', false) }, })
// app/pods/index/route.js and app/pods/dashboard/route.js import ShowNavbarMixin from '../../mixins/show-navbar' export default Ember.Route.extend(ShowNavbarMixin, { // ... }) // app/pods/signin/route.js import HideNavbarMixin from '../../mixins/hide-navbar' export default Ember.Route.extend(HideNavbarMixin, { // ... })
這麼作倒也不是不行,可是——明顯很蠢嘛——這和抽取兩個方法而後處處調用沒有什麼本質的區別,看起來咱們須要的是某種程度上的繼承與重寫纔對:
// somewhere in app/app.js Ember.Route.reopen({ // show navbar by default, can be overwriten when define a specific route withLayout: true, setupController() { this._super(...arguments) this.controllerFor('application').set( 'showNavbar', this.get('withLayout') ) }, })
// app/pods/index/route.js and app/pods/dashboard/route.js // Do nothing if showNavbar: true is expected // app/pods/signin/route.js export default Ember.Route.extend({ withLayout: false, })
這樣就好了,不須要額外的路由體系設計,就用 Ember 的對象系統便足夠完美。本文所描述的這個例子其實很是簡單,我相信略有 Ember 經驗的開發者都能作出來,可是個人重點不在於這個例子,而在於對路由系統的一些闡述和理解。這個例子來源自真實的工做,爲了給同事解釋清楚最初的方案爲何不行着實費了我好大功夫,因而我把整個梳理過程記錄下來,但願對初學者——特別是對 SPA 的核心還沒有了解的初學者能有所助益吧。
這個問題其實還有多種解法,基於事件響應的解法我就在現實裏演示了兩種,不過相比於上面的最終方案,它們仍是略微糙了些。在這裏我寫其中一種比較少見的,裏面涉及到一些 Ember 的內部機制,權當是一個借鑑吧,思路我就很少解釋了。
// app/mixins/hide-navbar.js export default Ember.Mixin.create({ hideNavbar: function() { this.set('showNavbar', false) }.on('init'), })
// app/router.js let Router = Ember.Router.extend({ location: config.locationType, didTransition() { this._super(...arguments) let currentRoute = this.get('container') .lookup(`route:${this.get('currentRouteName')}`) this.get('container').lookup('controller:application').set( 'showNavbar', _.isUndefined(currentRoute.get('showNavbar')) ) } })
// app/pods/signin/route.js import HideNavbarMixin from '../../mixins/hide-navbar' export default Ember.Route.extend(HideNavbarMixin, { // only use this mixin when you need to hide the Header })
原文首發於 Ruby China 社區,轉載請註明。