最近接触 Polymer 项目,其中的路由使用方式和之前的一些框架很不一样。阅读了官方关于路由的设计文档之后,发现其背后的设计思想十分值得思考。
集中式的路由设计
熟悉 Vue 的同学一定知道,使用配套的 vue-router 时,我们通常会提供一份全局性的路由配置,例如官网中的例子:
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }
const routes = [
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar }
]
const router = new VueRouter({
routes
})
其实不光是前端路由,在 express、RoR 等框架中,路由也通常被设计成初始化的一部分,在程序运行之前就已经确定:
app.get('/', handleIndex)
app.get('/invoices', handleInvoices)
app.get('/invoices/:id', handleInvoice)
app.get('/invoices/:id/edit', handleInvoiceEdit)
app.listen()
在 React Router v4 之前,也采用的这种路由组织方式,也称作静态路由。
职责分离
vue-router 还提供了一些相关的便捷特性,比如切换页面时的滚动行为:
const router = new VueRouter({
routes: [...],
scrollBehavior (to, from, savedPosition) {
// return 期望滚动到哪个的位置
}
})
虽然对于开发者来说,这些开箱即用的特性十分方便,但是在“职责分离”的原则下,很多不该路由考虑的功能被集成了进来。这是与 Polymer 的设计原则相悖的,更倾向于把页面切换,数据预加载这些功能交给其他组件完成。
无独有偶, React Router v4 也采用了这种动态路由的设计思想。
app-route 组件
先来看看 Polymer 中 app-route 组件的用法。
如果当前路由路径匹配了 pattern
,就通过双向绑定将当前 route
内容(从哪来的后面再讲)映射成 data
对象。
例如这里 data.tabName
就对应路由路径中 /tabs
后的子路径:
<app-route route="{{route}}" pattern="/tabs/:tabName" data="{{data}}">
</app-route>
<paper-tabs selected='{{data.tabName}}' attr-for-selected='key'>
<paper-tab key='foo'>Foo</paper-tab>
<paper-tab key='bar'>Bar</paper-tab>
<paper-tab key='baz'>Baz!</paper-tab>
</paper-tabs>
<neon-animated-pages selected='{{data.tabName}}'
attr-for-selected='key'
entry-animation='slide-from-left-animation'
exit-animation='slide-right-animation'>
<neon-animatable key='foo'>Foo Page Here</neon-animatable>
<neon-animatable key='bar'>Bar Page Goes Here</neon-animatable>
<neon-animatable key='baz'>Baz Page, the Best One of the Three</neon-animatable>
</neon-animated-pages>
其他组件就可以自由使用 data.tabName
了。例如负责页面切换动画的 <neon-animated-pages>
组件。可以看出 <app-route>
组件负责的功能是很少的,甚至不关心 route
是哪来的,很好地体现了职责分离思想。
那么将当前页面 URL 映射成 route 对象的工作是谁做的呢?
app-location 组件
<app-location>
负责将浏览器地址栏 URL 映射成 route 对象,供 <app-route>
使用:
<app-location route="{{route}}"></app-location>
另外,路由模式的选择(history/hash)也由这个组件负责。
使用 use-hash-as-path
可以切换成 hash 模式。
解决嵌套路由
项目中的路由不可能永远是简单的,面对复杂路由结构例如嵌套路由情况,这里借用 vue-router 中的一张图:
/user/foo/profile /user/foo/posts
+------------------+ +-----------------+
| User | | User |
| +--------------+ | | +-------------+ |
| | Profile | | +------------> | | Posts | |
| | | | | | | |
| +--------------+ | | +-------------+ |
+------------------+ +-----------------+
在 vue-router 的集中式配置路由中使用 children
表示这种嵌套关系:
const router = new VueRouter({
routes: [
{
path: '/user/:id', component: User,
children: [
{
path: 'profile',
component: UserProfile
},
{
path: 'posts',
component: UserPosts
}
而在 polymer 中,可以使用 tail
获取 pattern
截断后的部分,也就是子路径路由对象。
使用双向绑定将这个对象暴露给其他 <app-route>
组件继续使用,这样就实现了嵌套效果。
<app-route route="{{route}}" pattern="/tabs" tail="{{tabsRoute}}"></app-route>
<tabs-page route="{{tabsRoute}}"></tabs-page>
相比所有组件都依赖一个统一的全局性的路有对象,从中获取自己想要的部分。这样做的好处是显而易见的,每个组件只需要关心整条路由路径上和自己真正相关的部分,比如 <tabs-page>
组件完全不需要关心 /tabs
之前的部分。
实际项目中的用法
最后让我们以官方 Demo Shop 为例,看看实际使用效果如何。
首先是已经介绍过的两个组件用法,现在可以通过 routeData.page
访问到路由路径:
<app-location route="{{route}}"></app-location>
<app-route
route="{{route}}"
pattern="/:page"
data="{{routeData}}"
tail="{{subroute}}"></app-route>
接着,我们注册了针对 route.page
的监听器,一旦发生页面切换,这个处理函数就会被触发:
static get observers() { return [
'_routePageChanged(routeData.page)'
]}
在监听到路由路径变化时,需要记录下当前的页面名称,便于其他展示类组件使用。 同时,在这里还可以做一些重要的工作,例如保存当前的滚动距离以便回退时恢复,还有关闭掉打开的侧边栏等等。
_routePageChanged(page) {
if (this.page === 'list') {
this._listScrollTop = window.pageYOffset;
}
// 保存页面名称
this.page = page || 'home';
this.drawerOpened = false;
}
现在 page
发生了改变,注册的钩子需要触发:
static get properties() { return {
page: {
type: String,
reflectToAttribute: true,
observer: '_pageChanged'
},
PRPL 模式的应用
之前介绍过 Polymer 中的 PRPL 模式:
- Render 渲染初始路由(home)。注意这里已经使用 HTML imports 引入了首页:
<link rel="import" href="shop-home.html">
。 - Lazy-load 使用 importHref() 异步加载其他路由。
_pageChanged(page, oldPage) {
if (page != null) {
if (page == 'home') {
// 渲染初始路由
this._pageLoaded(Boolean(oldPage));
} else {
// 延迟加载其余路由
let cb = this._pageLoaded.bind(this, Boolean(oldPage));
Polymer.importHref(
this.resolveUrl('shop-' + page + '.html'),
cb, cb, true);
}
}
}
页面(初始路由或者异步路由)加载完成后需要:
- 继续加载其他异步资源
- 重新设置下 UI,例如顶部导航条等等。
_pageLoaded(shouldResetLayout) {
this._ensureLazyLoaded();
if (shouldResetLayout) {
Polymer.Async.timeOut.run(() => {
this.$.header.resetLayout();
}, 1);
}
}
Polymer.RenderStatus.afterNextRender 可以将一些非关键操作加入队列,不影响首屏渲染性能。我们将异步加载其他资源的操作放在这里,加载完成后,注册 Service Worker。
_ensureLazyLoaded() {
if (!this.loadComplete) {
Polymer.RenderStatus.afterNextRender(this, () => {
Polymer.importHref(this.resolveUrl('lazy-resources.html'), () => {
// 注册 service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js', {scope: '/'});
}
this._notifyNetworkStatus();
this.loadComplete = true;
});
});
}
}
总结
其实这两种路由的设计思路很难说谁更好,集中式易于开发者上手,更符合过往的编程经验。而从职责分离的设计原则出发,Polymer 或者 ReactRouter 这样的设计将路由也看作一种组件,可以分散在项目各个页面中。