引子
SPA(单页面应用)的核心是什么?
自该类型应用诞生以来我最多思考的问题就是这个。现在前端 SPA 框架满天飞,许多不是框架的也被称作框架,究竟有什么代表性的层(layer)能让一个系统称得上是框架?
我的答案是路由,而路由的本质就是一个状态管理器。没有路由机制的系统不能称之为框架,而路由机制做得不好的框架也算不上好框架(但可以算是好的工具集合,比如 Angular——详见我在 Ruby China 上曾经吐过的槽)。
为什么这么说呢?我们都知道 HTML 是无状态的(stateless),做一堆 HTML 页面拼在一起那不叫“应用”,顶多称之为“内容系统”;在以前,HTML 网站上的状态管理是由后端的 Session 加前端的 Cookies 协作完成的,到了 SPA 的时代 Session 不是必须的了(尽管传统的 Session 机制也是可用的),UI 上的状态转移到了前端由 JavaScript 完全管控(由于 SPA 前后分离的特点),所以前端工程师担负起了更多的业务逻辑职责,相应的整个技术链上也必须有一个可靠的环节来帮助他们做状态管理这件事情。
在前端框架的发展过程中路由的诞生是水到渠成的(基于一些新技术的成熟,比如 HTML5 的History API 等等),但是应用开发工程师对于路由的理解和重视却还远远不够。如果说传统的前端开发是以页面为中心来入手的话,那么现代的 SPA 应用开发就是以状态为中心来着手设计和开发的。
Ember 就是一款非常重视路由组件的 SPA 框架,本文借由一个实现 UI 布局的例子来谈谈 UI 编程与路由的关系,尽管这只是涉及到路由特性的一部分却也足够说明一些问题了。希望这个例子能让更多前端工程师认识和理解路由的重要性,从而更好的设计与实现 SPA 应用的各种功能场景。
场景描述
多数应用都有如下所述的 UI 设计:
多数视图在一个通用的布局内呈现,比如典型的 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
岂不是要重复定义很多次?如何抽象这一逻辑让其变得易于复用和维护?
Thinking in Angular way(w/ ui.router)
在开发 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 里平白多一个标签看的人直恶心~
Thinking in Ember way
既然问题的本质是 #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 社区,转载请注明。