Hash模式与History模式

1,178 阅读6分钟

分享前端技术架构与工程的学习笔记

目前实现前端路由的途径有两种:

  1. Hash模式:使用URL的hash标识作为路径标记,通过监听hashchange事件实现回调逻辑。
  2. History模式:使用URL的path作为路径标记,建筑History API及其相关事件实现跳转和回调逻辑。

Hash模式

一个完整的URL包括协议、用户、域名、端口、路径、查询参数和hash标识,比如从http://www.foo.com/user?name=bar#message可以获取如下表的信息

协议 用户名 域名 端口号 路径 查询参数 hash标识
http www.foo.com 80 /user name=bar message

当用户、域名、端口、路径、查询参数放生变化时,浏览器将其判定为新的URL,进而以新的URL发起请求,然而hash标识变化不会触发次行为。所以hash模式的有点之一是不需要服务器支持,是前端完全自主的路由机制。相对依赖于HTML5规范新增History API的History路由,Hash在浏览器兼容方面表现更优。

API设计

一个路由必须包含最基础的两个因子:路径(path)和回调函数(action),为了便于调试和定位要额外加上唯一的name属性。并且增加动态路由的支持,此时路由的配置API雏形如下:

new Router([{
    path: '/',
    name: 'home',
    action() {}
},{
    // name和sex为动态参数
    path: 'user/:name/:sex',
    name: 'user',
    action() {}
}])

这样就完成了一个简单路由的配置,但不足以应对一些复杂的场景,还需要进一步抽象。Vue-router是很好的借鉴案例,每个路由都是一个类似组件的对象,有自己的行为和声明周期。仿照Vue-router的设计,为路由定义声明周期:

  • beforeEach和afterEach是全局钩子函数,分别在进入每个路由之前和之后执行。
  • 路由内部的beforeEnter在action之前执行,并且是阻塞式,根据返回值分发后续逻辑,返回true继续跳转,false终止跳转。
  • 路由有两种行为:leave和update,分别触发beforeLeave和beforeUpdate,两者均为阻塞式。
  • 路由的afterEnter在action之后执行,afterUpdate在路由更行成功之后执行。

结合以上生命周期的设计便可确定配置API的文正形态,可分为两方面:一是全局钩子,二是每个路由的参数和钩子函数。

new Router({
    routes: [{
        path: '/',
        name: 'home',
        beforeEnter() {},
        action() {},
        afterEnter(){},
        beforeLeave() {},
        beforeUpdate() {},
        afterUpdate() {}
    }],
    // 全局钩子函数
    beforeEach() {},
    afterEach() {}
})

类结构设计

为了便于路基拆分,我们设计两个类:负责外层路由统筹的Router和负责单个路由的Route。Router类唯一个所有Route的列表,监听hashchange事件跳转对应的路由;对外暴露go和back两个API进行路由的跳转和回退。每个用户配置的路由均对应一个Route实例,每个路由维护本身的参数和状态等信息,同时对外暴露match和parse两个API,均接受URL的完整路径作为参数,match返回是否匹配自身路由,parse解析路径中的参数信息并执行回调。TS定义两者的接口类型:

interface RouterInterface {
    go(path: string): void;
    back(): void;
}

class Router implements RouterInterface {}

interface RouteInterface {
    name: string;
    beforeEnter: Function | null;
    afterEnter: Function | null;
    beforeUpdate: Function | null;
    afterUpdate: Function | null;
    beforeLeave: Function | null;
    // 动态参数
    params: {
        [k: string]: string | undefined;
    }
    // 完整路径
    fullpath: string;
    match(fullpath: string): boolean;
    // 解析URL的完整路径并执行回调
    parse(fullpath: string): void;
}
class Route implements RouterInterface {}

Router初始化之后启动hashchange事件监听,为了支持刷新还需要立即判断当前URL与用户配置路由是否匹配:

private _start() {
    window.addEventListener('hashchange', (ev: HashChangeEvent) => {
        this._onHashChange(ev.newURL)
    })
    this._restore()
}

// 恢复当前URL对应的路由
private _restore() {
    this._onHashChange(window.location.href)
}

_onHashChange方法的逻辑是先获取hash值,然后循环调用每个Route的match方法进行匹配。对于动态路由处理先用一个简单的正则表达式从配置中参数path中解析出动态参数的名称,并将所有参数汇总为一个路由匹配正则表达式:

export class Route implements RouteInterface {
    static paramPattern = /\/\:\b(\w+)\b/g
    constructor(info) {
        this._path = info.path
        let tmpPath = this._path
        let match = Route.paramPattern.exec(this._path)
        while(macth) {
            const param = match[1]
            this._requiredParams.push(param)
            this.params[param] = undefined
            tmpPath.replace(`/:${param}`, '/{\\w+}')
            match = Route.paramPattern.exec(this._path)
        }
        Route.paramPattern.lastIndex = 0
        this._validator = new RegExp(`^${tmpPath.replace(/\//g,'\\/')}$`)
    }
}

配置的动态路由/user/:name/:sex经过以上逻辑处理之后可提取出两个参数name和sex,路由匹配正则_validator为/^\user/(\w+)/(\w+)$/.Route.match功能便是用 _validator测试入参是否匹配。

阻塞shi路由钩子函数

阻塞式钩子函数最简单的实现方式是根据函数返回值是true哈市false进行逻辑分发,但需要明确各个钩子函数的触发条件。beforeEach在进入每个路由之前执行,那么触发它的条件必须是当前路由的beforeLeave返回true;而触发beforeLeave触发条件是目标枯井与当前路由并非相同。对于动态路由/user/:name/:sex而言,改变name和sex相当于更新而非离开当前路由,触发beforeUpdate而不是beforeLeave。调用Router.go(targetPath)进行跳转的流程图如路由流程图所示,流程中每个阻塞式钩子函数的返回值均会决定到底是跳转还是中断(具体方式参考源码)

History模式

History路由在前后端整体架构上不同于Hash路由最显著的特点是需要服务器支持,History路由支持刷新的条件是服务端将所有路由的请求rewrite(注意不是redicet)到跟路由,然后前端浏览器环境下进行子路由恢复,在功能实现上,History路由和Hash路由的主要区别在于路由间的跳转和监听方式:

  • 跳转新路径是由history.pushState API,回退和前进是由history.back和history.go API。
  • 通过监听popstate事件处理路由回退

popstate事件只有在调用history.back和hietory.go时才触发,而不会被pushState触发。如果不进行特殊处理,仅使用原生的hsitory API只能监听到路由的前进和后退,而监听不到新路由的跳转。所以我们必须在跳转新路由时触发并监听某种事件,可以借助CustomEvent API实现popstate语义相反的“pushstate”事件,这是一个自定义事件的函数并将其命名为pushstate,这样就可以被window监听到。

// 创建pushstate事件
function createPushstateEvent(state:KV<string>): PushShtateEvent{
    const ev = new CustomEvent('pushstate')
    ev['state'] = state
    return <PushStateEvent>ev
}
// 监听pushstate事件
window.addEventListener('pushstate', (ev: PushShtateEvent) => {
    this._onRouteChange(window.location.pathname,ev.state.name)
})

跳转新路径的逻辑为调用history.pushState API的同时调用createPushStateEvent函数创建一个pushstate事件,并且以window对象为target触发,其中state对象的属性name为目标路由的名称,借助此属性可以简化目标路径的匹配逻辑。

private _pushState(state: KV<string>,path: string) {
    this._history.pushtate(state, '', path)
    window.dispatchEvent(createPushstateEvent(state))
}
this._pushState({
    name: targetRoute.name
}, path)

History路由的有点在于能够被爬虫程序抓取到路径,然而仅路由被抓取还远不足以支撑SEO,因为页面的本质仍然是CSR的SPA,若想达到更好的SEO效果则必须采用SSR。Node.js的出现改变了Web技术的传统格局,不仅可以取代PHP、Java等作为SSR的承载者,而且为JavaScript同构编程提供了强有力的技术支撑。

Node.js中间层与同构编程

....待续