[Tips on Ember 2] UI 布局与应用状态的关系处理

引子

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

  1. 多数视图在一个通用的布局内呈现,好比典型的 Header + Main 的布局编程

  2. 个别视图须要一个特定的布局,好比登陆和注册页面不须要 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' })
})

因而,当用户——

  1. 进入 /dashboard URL 的时候,对应的 dashboard 路由开始接管应用的当前状态

  2. 进入 /signin URL 的时候,对应的 signin 路由开始接管应用的当前状态

  3. 但更重要的是:全部的路由都有一个共有的顶级路由——application 路由,其重要性主要体如今:

    1. 它是惟一一个靠谱的能够用来管理全局范围状态的路由

    2. 它为全部子路由的视图渲染提供了模板的入口(outlet)

接着问题来了:若是说状态经过 URL 来体现,那么 UI 布局的不一样如何体现呢?好比:

  1. 进入 /dashboard URL 的时候,咱们须要 Header + Main 的布局

  2. 进入 /signin URL 的时候,咱们不须要 Header

  3. 不管何种情形,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 会把人累死,然而测试结果可能会让初学者以为摸不着头脑,咱们试着分析一下好了:

  1. //dashboard 都须要 showNavbar === true,因此正反均可以;

  2. 当自 /signin 刷新页面的时候,先执行了 ApplicationRoute 而后才是 SigninRoute,等到进入 / 的时候,setupController 不会再次执行的;

  3. 同上;

  4. 同上。

问题分析

这里最明显的问题就是 ApplicationRoute#setupController 这个钩子方法是不可靠的,你只能保证它的第一次运行,一旦变成了在路由之间来回跳转就无效了。

实际上,setupController 的做用是将 model 钩子返回的结果绑定在对应的控制器上的,你能够扩展这个逻辑但也仅限于数据层面的设置。只有当调用了 route#render() 且返回了与以前不一样的 model 时 setupController 才会再次被调用。

因而问题又变成了:有哪个钩子方法能保证在路由发生变化的时候均可用?

路由的生命周期

这是一个很是重要但又很无趣的主题,我不想在这里重复那些能够经过阅读文档和亲测就能够得出的答案,不过我能够给出一份测试路由生命周期的完整代码片断:

https://gist.github.com/nightire/f766850fd225a9ec4aa2

把它们放进你的路由当中而后仔细观察吧。顺便给你一些经验之谈:

  1. 这个测试不要错过 ApplicationRoute,由于它是最特殊的一个

  2. 其余的路由至少要同时测试两个,好比 IndexRouteTestRoute

  3. 不要只测试页面刷新后的生命周期,还要尝试各类路由之间的相互过渡

测试完以后,你就会对整个路由系统有一个很是全面的了解了,这些体验会带给你一个重要的技能,便是在未来你能够很容易的决断出实现一个功能应该从哪里入手。对于咱们这个例子来讲,比较重要的结论以下:

  1. ApplicationRoute 是全部路由的共同先祖,当你第一次进入应用程序——不管是从 / 进入仍是从 /some/complicated/state 进入——ApplicationRoute 都是第一个实例化的路由,而且它 activated 就不会 deactivated 了(除非你手动刷新浏览器)。所以咱们能够把 ApplicationRoute 做为一个特殊的永远激活的路由

  2. 若是你有应用逻辑依存于 ApplicationRoute#setupController,那么第一次进入就是惟一靠谱的机会——你不能期望这个钩子会在路由来回切换的时候触发

  3. 可是其余路由上的 #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 的缘由所在。

举几个例子:

  1. 当访问 '/' 时,路由树中当前激活的路径为:application => index

  2. 当访问 '/users/new' 时,路由树中当前激活的路径为:application => users => new

  3. 当访问 '/posts/1/comments/1' 时,路由树中当前激活的路径为:application => post => index => comment => index,也多是:application => posts => show => comments => show ——取决于你的路由规则的写法

  4. 等等……

Ember 并无为这个特殊的 | 41b8a0714e572ed059c0e52d0e3c676c91 | 作一个明确的定义(可是| 41b8a0714e572ed059c0e52d0e3c676c92 |),不过在其余相似的路由系统里咱们能够找到等价物——好比来自 | 41b8a0714e572ed059c0e52d0e3c676c93 |(Angular 生态圈里最优秀的路由系统)里的抽象路由(Abstract Route)

Ember 的 ApplicationRoute 和 ui.router 的抽象路由很是类似,它们的共性包括:

  1. 都可以拥有子路由

  2. 自身都不能被直接激活(不能位于路由树中当前激活路径的顶点)

  3. 不能直接过渡,也就是 transition to;Ember 里会等价于过渡到 IndexRoute,ui.router 则会抛出异常

  4. 都有对应的模版、控制器、数据入口、生命周期钩子等等

  5. 当其下的任意子路由被激活,做为父节点的抽象路由都会被激活

固然,它们也有不一样,好比说:你能够在 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 })
        // ...
    })
})

可是我的很是不喜欢也不推崇这么作,缘由是:

  1. 这样的路由定义写多了会很恶心

  2. 为了不相似 /layoutOne/dashboard 这样的 URLs,不得不重复设定 path: '/' 来覆盖

    • ui.router 解决此问题依靠的是 url pattern inheritence,因为每个路由的定义都必须指明 url 属性,因此也就习惯了

  3. 为了不相似 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 社区,转载请注明。

相关文章
相关标签/搜索