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

nightire · 2015年09月09日 · 最后由 shangmengjie 回复于 2019年09月10日 · 13320 次阅读
本帖已被管理员设置为精华贴

引子

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 设计:

  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 并没有为这个特殊的 ApplicationRoute 做一个明确的定义(但是简要描述了其特点),不过在其他类似的路由系统里我们可以找到等价物——比如来自 ui.router(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 的理解日渐深入之后你会发现所有的嵌套路由(包括顶层路由)都是抽象路由,因为它们都会隐式的创建对应的 IndexRoute 作为该路径的顶节点,访问它们就等于访问它们的 IndexRoute。我认为 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
})

我在考虑...

如果把这么系统的内容放入 wiki 是不是会让更多人受益呢?

坐等管理员出手

确实漂亮的解法,赞!

👍 不错,我之前也遇到过这个问题,不过因为我涉及的 route 不多,就一个特殊情况,所以在进入这个 route 把showNavbar值设为 true,然后在添加一个离开这个 route 的方法,把showNavbar在设为 false。

@nightire 求荐 ES6 的好书

#4 楼 @boyishwei ES6 多数东西都很简单,我建议你一边学一边把这个过了:http://es6katas.org/

这就够了,真正难的也就是 Generator 及其所代表的异步编程范式了,这个是要靠理解能力的。我曾经看过很多文章和视频,然而唯一一部印象深刻很容易让我理解的是这个:https://www.youtube.com/watch?v=lil4YCCXRYc,也包括 Async Function。

@nightire U'r amazing, 我就知道 at 你一下肯定有好东西。用 Ember,学跟用的过程中,总觉得就像用 Rails,Ruby 却还没吃透那种感觉,不踏实。谢谢推荐! @sandy_xu 也谢谢推荐!

文中说的应该是 HTTP 无状态而不是 HTML 无状态吧,HTML 本身只是一门标记语言,不存在什么状态不状态的问题吧

#8 楼 @kosl90 严格来说,你是对的。不过 HTML 是用来“呈现”出 HTTP 状态的结构语言(比如说用户登录与否会改变 HTML 等等),而前端的开发归根结底就是根据 HTTP 的状态来操作 HTML(确切地说,DOM),所以我们已经习惯了直接说“HTML 的状态”。以前后端渲染是由后端“记住”当前的状态,然后渲染出 HTML 交给浏览器,当变成前端渲染的时候,保存和管理状态的职责就转移到了前端,所以说路由才是 SPA 最核心的部分,也是 SPA 框架区别于传统前端开发的最典型特征。

从这个角度来说,SPA 框架管理 HTTP 的状态这件事情从“状态表征”这个角度来说(State Representation)主要体现在两点,一是 URLs,二就是视图(写代码的时候对应的是模版,渲染的时候就是 HTML 了)。

所以这就是个习惯性说法。

我之前也遇到过同样的问题,其实还有一种更简洁的办法: 可以在 ApplicationController 里面设置一个绑定 currentPath 的变量,用它来判断不同 route 应该显示的 layout. 举个例子:

// app/controllers/application.js
export default Ember.Controller.extend({
  layout: function(){
    if(this.get('currentPath') === 'sessions.new'){
      return 'layout_a';
    }else{  //可以扩展 layout  方法实现更复杂的layout逻辑
      return 'layout_b';
    };
  }.property('currentPath')
})
//app/templates/application.hbs
{{partial layout}}

//app/templates/layout_a.hbs
<main>
    {{outlet}}
</main>

//app/templates/layout_b.hbs
<header>...</header>
<main>
    {{outlet}}
</main>

#10 楼 @EricZhu 对的,我其实在文中提到过以前会这么处理,不过你有没有注意我现在给出的解决方案(不止这一篇里的)里几乎不会出现 Controller?那是因为随着 Routable Components 的即将出现,Controller 将成为昨日黄花(在 Ember 的世界里),当然了,现在的版本有时候还是必须要用 Controller 的,不过我现在使用 Ember 的一个原则就是能不依赖 Controller 的就不写 Controller(当然是合理的范畴内)

这就是我本文给出的方案的缘由,不是说 Controller 里的 layout 钩子不好用,只是为了着眼于未来罢了。

@nightire 想知道像在 emberjs 中是如何针对不同页面渲染不同样式文件的,还是统一好所有的 css 命名空间,最终就是生成一个 css 的?但有时不同入口页面可能样式会有冲突。。

#13 楼 @nouh 首先呢,CSS 样式真心不需要拆分,很少会有应用写的 CSS 文件庞大到无法容忍单一文件的加载,有多少人能把针对项目的样式代码写得和 Bootstrap 一样大的?然而 Bootstrap 的样式最终也就一个文件而已,合并上项目样式真心没多少。拆开了,HTTP 请求数反而上去了,得失之间还得平衡。

再者入口页面的样式冲突——怎么会?除非一个应用里用了同名的样式但样式的定义却不同。这只能说是你样式命名没有做好,和 Ember 没啥关系,换成别的不也一样?

一定要拆也不是不行,办法有很多,举例来说:

  1. 最终生成统一样式文件的,处于复用并扩展的目的需要重写个别样式,可以在区分入口的路由处为路由模版添加作为命名空间的 class,以此来重写。
  2. 按路由分割样式文件的,路由激活时动态修改 HEAD 里引用的文件。注意,Ember CLI 默认是把样式打包成一个文件的,即使你按照路由分开写也是如此,但是 Ember CLI 是可以配置的,你可以随意定义最终输出的样式文件,包括数量和路径等等,详情自行查阅文档。
  3. 按组件分割样式的,这可能是未来的一种趋势,样式独立于组件之内,不互相干扰。这是一个很美妙的方法,不过真正的解决方案尚未成熟,很多人都在探索。你可以试试这个插件:https://www.npmjs.com/package/ember-component-css

@nightire 实际动手的时候有个问题就是想在 dom ready 时候做些事情,在 ember 中不知道在什么地方来处理比较好,google 了下发现以前的方式好像都是在 view 层用 didInsertElement 这个钩子来弄的,但现在 ember 里已经要废弃 view 了,所以比较好的实践是怎么样地呢

#16 楼 @nouh Component 也有 didInsertElement 呀。

@nightire

曾经 Ember 有 {{partial}} 这样的 helper 来做模版片段复用,但是第一,以后没有 {{partial}} 了,二来用 {{partial}} 做布局是错误的选择。

为啥是错误的选择呢?

#18 楼 @vkill 这个你拿 partial 和 component 对比一下就知道了,以前 ember 有 partial view render 三个 helper 来做片段复用,以后就只有一个 component 了。布局的事情,即便大体上来说是全局共享的,但有时也要根据路由的不同而有相应的变化,partial 仅仅是 HTML 片段复用,完全不存在作用域隔离、生命周期的机制,所以以后也不会有了。

routeable 组件,指这个组件在不同的路由下,表现不一样?这个文章的例子里,觉得 think in ember 的解决办法,比基于事件的看着更优雅啊。这篇文章,并没让我感受到为啥 route 比 controller 更重要,不过现在 controller+route+template 是有些松散。

关于 routable component 的细节,可以看一下这个 RFC:https://github.com/ef4/rfcs/blob/routeable-components/active/0000-routeable-components.md

另外我并没有说 route 比 controller 重要,这篇文章也没打算说明这一点。路由与控制器是两个层级的概念,谈不上谁比谁更重要。不过就如你所说目前松散的结构的确可以变得紧凑一些,这种变化其实来自于 component 的完善。最终 component 就等于过去的 controller + view & template,而 routable component 是可以用来转递初始 state 的 component(非常粗略的说),这样一来 states(一般就是 data)通过路由获取之后,不需要 controller 来“保持”它们,而是直接交给 routable component,有此开始逐层渲染下面的 component 最终形成完整的应用。一旦路由变更,自然就有另一个 routable component 接手,逐层渲染……大概是这样子的,毕竟还没有正式 landing 的 feature,所以我说的可能会有不准确的地方。不过归根结底 controller 可以不是必须的。


顺便多说几句(但是因为 routable component 没有 landing,所以仅供闲聊参考):

去掉 controller 这一层会有很多好处,也能让整个 Ember 变得更容易理解,举例来说——

以前,路由自己会调用 render 方法,你也可以改写它。render 做的事情比较多,就像这样:

this.render('post', {
  view: 'special-post',
  controller: 'other-controller' ,
  model: myPost,
  into: 'application', 
  outlet: 'sidebar',
})

所以,一个路由(对应着 app 的一种状态)至少得需要 controller w/ model + view & template w/ outlet(s) 才算是完整的一块。这当然说得通也合乎情理,但是这种分散的结构也带来很多问题,比如说对初学者不友好,比如说相互通信和依赖很复杂等等。

早先出炉的 basic component 把 view & template 取代了(部分 controller 的职责也包括在内),后面准备出炉的 routable component 以及 aync component(专为数据交互设计)等于是要把 route 的一部分 hooks 和 controller 合并掉。还拿 render 为例,以后 route 调用 render 的签名就成了:

this.render('post', attributes)

或者是

this.render('post', attributes, { into: 'application', outlet: 'sidebar' })

'post' 就是一个 routable component,其是就是 PostRoute 和 对应的 component,attributes 是传递给 routable component 的初始“状态”,一般来说就是以前的 model hook。一旦把 attributes 传给了 component,接下来就是 parent component & children components 们的事儿了,不需要 controller,data persistence and communication 有 store,IOC 有 services,完了。真没 controller 什么事儿了。

intooutlet 还有必要存在,但是现在不需要单独的 application-template(它就是最顶层的 routable component 的一部分),outlet(s) 自然也直接写进 component 里面了。

如此一来,以前的 beforeModel afterModel setupController 还有 this.controllerFor 等等方法都不再需要,整个 route 变得清爽明确,对初学者会友好的多。这些细节在我前面提到的 RFC 里都有具体的实现思想描述,我这里算是粗略的概述一下。

#21 楼 @nightire 的确很优雅的方法。但我的场景更复杂一些,不仅仅是 on/off 的问题,而是需要在不同 route 下渲染不同的 sidebar,所以我把你的思路扩充了下,结合{{component helper,通过在不同的 route 下定义需要渲染的 sidebar 来达到目的。然后问题来了:

  1. 如果 / 下 有 /admin/users,分别渲染admin-sidebarusers-sidebar,然而后两个 routes 下各有一大堆子 routes,怎样才能让这些子 routes 自然继承对应的 admin-sidebar/users-sidebar 而不必再每一个 route 重复定义呢?

  2. 我把不同的 sidebars 都用 components 实现,如何给不同的 components 传递不同的参数呢?

#22 楼 @ugoa

  1. 首先我会考虑把两个 sidebar 变成一个,然后根据传参不同来渲染不同的 sidebar,当然这也要看实际情况复杂到何种地步;

  2. 子路由继承很简单啊,sidebar 和父路由的 {{outlet}} 是同级的,子路由(的模板)都是从 {{outlet}} 进去的,所以自然继承了父路由上定义的 sidebar,不用重复。渲染路由的定义只需要在父路由声明一次就好了。

然而这只是个思路问题,具体问题还是要具体分析的。不过不要忘了你还有别的武器可以选择,比如说 Mixin。

#23 楼 @nightire 第二点的确有种恍然大悟的感觉,当时怎么都没想到测试一下先,现在架构已经改了。

但第一点描述的就是我想避免的,主要是不想在模板里写很多的 if else,(没办法,层级略多,不同 route 里 sidebar 的功能各异,给 sidebar-components 传参也略啰嗦,有的 sidebar 还绑定了 query params,耦合度比较高,想抽象出来有点费脑筋。

所以现在采取的办法比较笨,每一级模板才用最少化原则,尽量在模板里避免 if else,route 需要什么就渲染什么,虽然多一点重复代码,但好在够直白,将来重构起来也很容易(按 ember 的发展方向重构肯定是不可避免的),并且也用到了 block components,在里面把 UI layout 定义好,需要什么就往里面填什么。竟然代码也看得过去,边走边改吧。谢谢指点。

#23 楼 @nightire 再问一个题外话,现在我的项目已经出具规模了,找文件代码有点不容易,在考虑转到 pods 结构。有几个疑虑:

  1. 现在转到 pods 的条件成熟吗?
  2. 使用 pods 过程中你体会到的好处多还是缺点多?
  3. 有哪些 killer benefits,或有哪些大到让你想放弃的缺陷?
  4. 如果现在重构,将来 pods 成主流了后需要做的升级工作会小很多吗? 叨扰了。

#25 楼 @ugoa

关于 Pods 的话题前两天有一个帖子里我刚回答过(可是我找不着了,脑残 ing),Robert Jackson 前几天有一个 presentation 在油管上你找来看看。大体意思就是现在的 Pods 也还不完美,还有一些大家特别渴求的场景无法实现,比如说嵌套的 Components。

就我个人而言,用不用 Pods 都无妨,对于 Ember 来说无非就是不一样的 Resolving 规则而已,你理解了这个规则就很容易理解不同的方式如何组织文件。

用 Pods 的最大好处对于我来说就是不同类型的文件(hbs/js/css)都在同一个目录下,好找——但也不是绝对的。基本上选择哪种方式最直接的影响就是找文件,这个怎么说呢?我觉得根源在于你对所用的编辑器够不够熟悉,还有你找文件的方法够不够多。

比方说我是用 Vim 的,不管用不用 Pods 找文件这种事都是小菜一碟,我有两个环境,一个是 MacVim,一个是 Windows 下的 gvim,前者我用 NERDTree + CtrlP,后者用 Unite,再配合 Sessions 那是想怎么找就怎么找,直接搜索代码用 the_silver_searcher(Ack 的增强版本),我自己是没觉得这有什么难度或痛点可言。

同事们有用 WebStorm 的,倒是跟我提过文件不好找的事情,那我就用 WebStorm 一段时间,默认的快捷查找(双击 Shift 哪个)也不错啊,就是有点慢。后来我又找着一个 Plugin(那里面的 Ember Plugin 就那一个),找文件也挺强的,就是有 Bug 还在逐渐完善中。另外我发现他们都不太会用 WebStorm 或者说 Jetbrains 家的 IDE,它家的 IDE 是有一个 Task Context 功能,做一个功能可以新建一个 Context,所有相关的文件及其变更都可以随着这个 Context 走,你切换 Context 就好像切换 Vim 的 Session 一样,这些工具提供的便利如果你学不会去用那也没办法,再牛的 Pods 也救不了你。

至于以后,Pods 还会有一个大的调整,现在就在进行中,Github 上有几个 PR 都和此有关的。但这个改动不会很快(比方说下一个小版本)就完成,主要是因为这个调整要随着 Routable Components 和 Glimmer Components 走的,后两者的最终实现会影响 Pods 的实际设计。

所以对于你的问题,怎么说呢……我个人是无可无不可的,无非就是怎么组织文件呗,还是看你们小伙伴们的意见吧,大家都认可就行了。

#26 楼 @nightire 其实我还没那么水啦,哈哈。之前我也用 vim 好多年的,只是为做 ember 开始用 atom,上手快,装卸插件无脑流,但工具本身比较新,限制较多,尤其文件切换这块,功能也没 vim 全。 你这么讲我就差不多有底了,准备暂时先不改了,团队就我一个人负责前端开发,同时还要做 server API,觉得还是不折腾好了。 多谢分享。

#27 楼 @ugoa 你还别说,最近我换到 Windows 平台,Vim 还有很多坑没填好,所以也在用 Atom 试水,Windows 环境下干活还需要适应一阵子呢。

年前的这一段时间里打算录录视频神马的,老是写长篇太累了。

给你介绍一个视频:https://vimeo.com/148529508

里面主要是讲一些关于 Components 的最佳实践的,之前我挖了个关于 Components 的坑暂时停了,因为有一些东西我找不到答案,最近也在和这个视频的作者探讨一些这方面的事情。这个小妞很牛哦,华人,在 Dockyard 工作,Ember 专家啊。

#28 楼 @nightire 嗯,我有在 medium 上看到她的 post,立马就关注了,好像还是你推荐的。

关于 pod,我一直在使用 pod,并没有出现 component 不能嵌套的情况啊。个人感觉使用 pod 组织(限于 component)确实能让找文件方便很多。

我在做开源框架的二次开发 用的就是 emberjs 其中就是登录页面和首页那都要经过 application 默认的页面 所以造成样式上的问题 现在看了你这个有了思路 但是试了下 没有解决掉我的问题 我想问下 setupController()必须要与 model 有联系吗 我写在路由的扩展项里面并没有解决我的问题??

我现在要做的就是和你这个一模一样 把默认 application 里面的公共头部在应该显示的地方显示 在不需要的地方隐藏掉

需要 登录 后方可回复, 如果你还没有账号请 注册新账号