最近接触 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 模式:

_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);
        }
    }
}

页面(初始路由或者异步路由)加载完成后需要:

_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 这样的设计将路由也看作一种组件,可以分散在项目各个页面中。

参考资料