分类: 技术

使用 History API 升级 SPA 路由方案

1. 背景

组内现在使用组内自己开发的移动端 SPA 引擎 Cyra,版本为 1.2.x。框架很轻,使用 hash 做路由,然后统一管理各个 view(视图)的状态以及 view 之间跳转。

开发过程中,遇到很多坑都和使用 hash 做路由有关。当然,问题并不在 hash 本身,而是和客户端以及后端 RD 配合中遇到的问题。比如:

  1. 验签问题,客户端没有将 hash 计算进去;
  2. 客户端会在 WebView 中访问的 URL 上拼接一些参数,作为客户端和前端之间通信的一种手段。由于拼接算法有问题,导致部分参数拼接到了 hash 后面,使得 Cyra 多个 view 之间通过 URL 传递的参数格式被破坏;
  3. iOS 版 APP 的 WebView 中通过 jsbridge 来修改 title,但是 hash 的修改无法修改。

综上,考虑升级框架的路由实现方案,使用 History API 代替 hash,并配合 Session Storage 做多页降级,以此来解决上述问题。

2. 方案

为兼容 Cyra 1.x 开发的项目,此次升级仅修改路由的内部实现方式,而不修改框架对外提供的原有 API。所以修改主要集中在路由变换时的 path 管理和数据传递。

2.1 URL 格式
2.1.1 已知问题

因客户端在 URL 后拼接参数格式不规范,有时会导致 Cyra 取参错误,例如使用 WebView 访问 URL:

http://host/index.html#view1/name=tom,age=1

时,客户端会在 URL 后拼上部分参数,致使实际访问的 URL 变为:

http://host/index.html#view1/name=tom,age=1&utm=xxx&...

此时 Cyra 取参,age 的值变为了 1&utm,出现错误。

2.1.2 解决方案

为避免该问题,将页面间传递的强数据写在 path 而非 query 中,并以/结尾分隔,格式为:

http://host/${view_name}/${data}/

其中:

  1. view_name 对应每个 view;
  2. data 为 view 之间传递的强数据。
2.2 单页

对于支持 History API 的系统,仍像当前 Cyra 一样实现为单页引擎。

  1. 使用 pushState/replaceState 方法配合 onpopstate 事件代替 hash 来做路由管理。
2.3 多页

对于不支持 History API 的系统,每个 path 对应一个页面。

  1. 使用 Session Storage 做页面间弱数据传递。

3. CHANGELOG

  1. Page 中不用再写 id() 方法。
  2. Action 相关 API 变化:
    1. Page 中 defineActions 方法定义可选
    2. page.performAction (action: Action, data?: any) => page.perform (toPath: string, data?: any)
    3. prepareForAction (next: Function, action: Action, destinationPagePerform: Function) => prepareForPerform (next: Function, toPath: string, destinationPagePerform: Function)
  3. 每个 Page 只需 export 类而不用 export 类的实例。(引自 v1.3)
  4. 添加 beforeLeaving 接口(1.3.0 已提供),用来模拟拦截点击返回按钮。(引自 v1.3)
  5. routes 在 Cyra.initApp 中传入:Cyra.initApp({…, routes: {detail: detail, …},…})
  6. 实现 History API 路由方式。
  7. 完成多页降级,主要体现在弱数据处理。
  8. 增加数据存储接口:Storage.setAppStorage(key: string, data: any) 和 Storage.getAppStorage(key: string),数据存储在 sessionStorage 中。
  9. 本地开发过程中,需要在 mock 中添加 select(match(‘/page’), proxy.url(‘/page.html’)) 和 select(match(‘/page/*’), proxy.url(‘/page.html’)),完成域名本地映射。
  10. Cyra.initApp 方法添加配置参数:
    • appRoot 表示项目在服务器上的虚拟路径地址,如一元夺宝为:/resource/oneyuan/
    • mode 表示使用的路由方式(history/hash/multipage),缺省表示默认使用 History API,如浏览器不支持则使用多页降级。若指定该配置参数,则使用对应路由方式。
  11. 在线上机器的 nginx 增加如下配置:History API 路由方案 Nginx 配置

4. 已解决问题

  1. 对于单页实现,刷新页面时会因找不到对应 HTML 文件导致 404。可通过在 nginx 中配置 location,使每个 SPA 包含的所有 view 对应 path 都指向该 SPA 对应 HTML 等资源。
  2. 对于多页降级实现,访问每个 view 都需要重复请求相同 HTML 等资源。考虑使用浏览器 cache-control 中的 ETag/If-None-Match 配合资源打戳来控制缓存策略,避免重复请求,但需要 server 支持,后续优化。
  3. 引入全局对象 application 来存储不同页面的公共信息,放在 Session Storage 中。
  4. 兼容性问题:
    • History API:iOS 无问题,Android 3/4.0/4.1 对应的部分机型实现有 bug(比如 pushState 修改了 state 但却没有改变 URL),或直接没有实现。
    • Session Storage:iOS/Android 普遍实现。
  5. 团购 APP 的 WebView 有 bug,使用 History API 无法显示关闭按钮,其他 APP 以及安卓版均无此问题。
  6. 是否改变 performAction 执行流程,在路由改变后再处理 view 跳转前逻辑以及 view 的生命周期函数。这样,用户点击返回按钮的操作也可以执行 performAction 方法,如果想要取消跳转,只要 history.back() 即可,便于统一管理。但是这样对于多页应用,还是需要按照当前流程处理,待定。
  7. 是否需要将 Page 的 id 和 path 两个概念合二为一,因为每个 path 对应一个 view/Page,每个 Page 对应唯一的 id,所以 id 和 path 都可以唯一确认一个 Page,没有必要维护两个概念。而且 id 这个概念只是供 Cyra 内部作为检索 route/page 等功能使用,不应该作为接口暴露给用户,用户不应该介入到 Cyra 内部实现。
  8. 是否需要去掉 Action 中的 fromPageID 这个概念,因为用户点击“返回”(或在浏览器中点击“前进”)按钮,本质都是执行了一个 Action,但是这部分操作没有在 Action 中记录,所以感觉没有必要统计这部分信息。进一步,是否可以简化 Action,执行 performAction 是否只需要传入目标 view 的 path 即可,而不需要再经过 Action 做一步转换(如果 Action 中只保存目标 view 的 path)。
  9. 对于 History API 方式,或者指定访问链接必须有 view path,或者在 HTML 部分添加特定识别标志,否则无法获取正确的 view path。
  10. pushState 会将 query 重写,导致客户端拼接的参数丢失,部分接口可能无法获取 query 中客户端传递的参数。
  11. 使用 Action 绘出页面跳转图是否有必要,对比 native 开发,由于一个 native APP 是一个封闭的环境,页面间的跳转都限制在 APP 的视图集合内。但是对于 webAPP,入口不封闭,导致会从非本 APP 视图集合内的页面跳转到当前 APP 内的某个视图。来源的不确定性,直接导致了环境的不确定性,所以跳转视图的必要性需要讨论。
  12. context 数据是否需要放在 sessionStorage 中。之前讨论中有说到要把 context 数据(如 currentAction/currentRoute/currentPage 等)存储在 sessionStorage 中,但是 sessionStorage 所存储键值对中的值只能保存 string 类型数据,所以不适合做结构化对象数据存储。仍然将 context 数据存储在 router 中。
  13. 因为上一条中所述的 sessionStorage 特性,存取弱数据时对数据分别做 JSON.stringifyJSON.parse 操作。
  14. isBack 判断逻辑太复杂,对于以下每种情况还要分单页和多页两种情况,所以暂时取消。
    • A => B => C
    • A1 => A2 => A3
    • A => B => A
  15. 现在取消一个页面跳转需要在两处写逻辑,一处是在 prepareForPerform 中直接阻止更新 path 来拦截跳转,另一处是在 beforeLeaving 中执行 cancelBack,用来模拟拦截点击返回按钮离开页面。
  16. sessionStorage 的有效作用于在同一个窗口的同一个域下。
  17. 应考虑方法让更多问题在编译阶段暴露出来,比如 performAction 中的目标位置。

注:转载注明出处并联系作者,本文链接:https://nodefe.com/spa-engine-upgrade-history-api/

发表评论

评论

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax

  1. demo不足,已懵逼…想直接问:
    1、这个方案可维护性怎么样?
    2、这个方案方便移植么?或者说这个方案只适用于该Cyra项目的问题解决?

    • 可维护性是指?现在是我和另一个同事在维护。
      方便移植,我在 2.0 里已经将所有和路由相关的操作全都拆分到 Router 类中,所有有关 URL 处理的实现都放在一个统一的地方,并且每种 URL 路由方式对应一个类。只是现在 2.0 版本还有几个概念还没确定是否保留,所以还没有发布到 NPM。
      你是鹏飞么。。?