摘要:如果说传统的前端开发是以页面为中心来入手的话,那么现代的应用开发就是以状态为中心来着手设计和开发的。初步分析路由是怎么管理状态的复杂的话题简单说在中,应用的每一个可能的状态都是通过体现的。
引子
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}}... ... {{outlet}}
{{!app/pods/signin/template.hbs}}... {{outlet}}
虽然这么做可以奏效,然而问题也是显而易见的:如果出现多个和 dashboard 一样的布局结构,我们将不得不多次重复
如果我们可以把问题场景简化为只有一种可能,例如“所有的视图都用 Header + Main 的布局”,那么解决方案可以简化为:
{{!app/pods/application/template.hbs}}... {{outlet}}
{{!app/pods/dashboard/template.hbs}} ... {{outlet}}
{{!app/pods/signin/template.hbs}} ... {{outlet}}
那么再次恢复原来的场景要求,问题变成了:“进入 /signin 之后,如何隐藏 application 模版里的
隐藏模版里的片段,最简单的方法可以这么做:
{{!app/pods/application/template.hbs}} {{#if showNavbar}}... {{/if}}{{outlet}}
我们知道模版内可访问的变量可以通过控制器来设置,但此时我不打算创建 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/this.route("layoutOne", { path: "/" }, function() { this.route("dashboard", { resetNamespace: true }) // ... }) // provide layout w/o 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 社区,转载请注明。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/85962.html
摘要:今天这篇主要讲讲里关于样式开发的一些前期准备工作,主要是和。总的来说就不要再用了,又大又笨而且连亲爹都准备放弃它了,未来将是小快灵组件协同工作的大趋势,就是可以用来替代的组件库。 今天这篇主要讲讲 Ember CLI 里关于样式开发的一些前期准备工作,主要是 Sass 和 Bootstrap。 Ember Addons 是寻找各种组件的绝佳场所,下文将要介绍的一些都可以在这里找到,没事...
摘要:因为组件的存在范围被限制在以内,这就是这种机制目前存在的意义所在。组件都是可以传递参数或外部作用域的,利用此机制进行判断来执行可选行为,这是对用户友好的举措。 这一篇还是一个简单的例子所引发的思考。 你看,如今的框架和库,无论规模大小功能多少,它们在本质上都朝着组件化的思路快速演进着。Angular 有 directives,Angular 2应该也还是这个叫法;Ember 从 Vie...
摘要:好,你用就用吧,各种问题自己也不会看文档问谷歌,成天怨声载道的不得不吐槽一下现在的年轻人。为什么使用有关和的纠结历史可以去谷歌一下,此处不再啰嗦最根本的原因就是对的支持更好,更新和维护也更勤快。 Tips on Ember 2 对我来说是没什么计划性的写作,我只是把它当做是每天工作的总结日志,一个很重要的目的是为团队做一些技术事务的整理,以帮助一些新人快速成长起来。如果有些内容不能满足...
摘要:警告版本是很不稳定的,并不推荐使用于要上线的应用。如果你要尝试新的特性,要么是新建一个测试用的,要么是你的应用离正式上线还早并且你和你的团队折腾得起。在此功能正式发布之后应该是不需要这段补丁代码的,目前来说也不会影响使用。 Ruby China 的朋友大概都知道我很喜欢 Ember,然而我用 Ember 的经历其实远比不上 Angular 那么丰富(Ember 业余爱好,Angular...
摘要:正文在年,框架的选择并不少。特别的,通过思考这些框架分别如何处理状态变化是很有用的。本文探索以下的数据绑定,的脏检查的虚拟以及它与不可变数据结构之间的联系。当状态产生变化时,只有真正需要更新的部分才会发生改变。 译者言 近几年可谓是 JavaScript 的大爆炸纪元,各种框架类库层出不穷,它们给前端带来一个又一个的新思想。从以前我们用的 jQuery 直接操作 DOM,到 Backb...
阅读 2185·2021-11-18 10:02
阅读 3288·2021-11-11 16:55
阅读 2693·2021-09-14 18:02
阅读 2426·2021-09-04 16:41
阅读 2054·2021-09-04 16:40
阅读 1164·2019-08-30 15:56
阅读 2212·2019-08-30 15:54
阅读 3160·2019-08-30 14:15