积梦前端的路由方案 ruled-router

#1

原文 https://segmentfault.com/a/1190000019143914

积梦(https://jimeng.io ) 是一个为制造业制作的一个平台.
积梦的前端基于 React 做开发的. 早期使用 React Router.
后来出现了一些 TypeScript 集成还有定制化的需求, 自己探索了一套方案.

使用 React Router 遇到的问题

React Router 本身是一个较为稳妥而且全面的方案, 早期我们使用了它.
后面随着积梦数据平台的页面的重构, 遇到了一些问题.
积梦的管理界面从顶层往内存在多个层级, 复杂的情况会出现五六层嵌套,

导航栏 -> 子导航 -> 标签页 -> 功能 -> 子页面

虽然一般的情况只是三四个层级, 但是页面的嵌套大量存在,
早期的我们办法是定义一个 basePath 变量用来表示外层路由,

<Route
  path={`${this.props.basePath}/:page`}
  render={(props) => {
    return this.renderSubPage(props.match.params.page);
  }}
/>

然后在内部跳转时, 也会使用 basePath 变量快速生成路径,

<Redirect to={`${this.props.basePath}/${EWorkflowPage.Step}`} />

这样手动传递偶尔会出错, 特别是当页面结构发生一些修改的时候.
经过一两次导航的重构, 我们在局部出现了一些代码, 无法正确跳转.
虽然靠着测试逐步修复了问题, 但是随着页面增多, 这个问题不能轻视.

我觉得这个问题是两部分,

一方面是 TypeScript 的类型检查没有帮助到的路由部分,
React Router 当中基本上通过字符串定义的路径, 这些不容易被类型检查.
特别是拼接的路由, 发生改变以后就难以准确追踪了.

另一方面, 我认为 React Router 的规则也限制了 JavaScript 代码的使用.
相对于 React Router 通过 Context 传递路由状态的方案, 更倾向于代码.
基于 switch/case 还有函数组成的控制流, 有更为灵活的应对的办法.

路由的解析 ruled-router

我同事和我都有一些使用基于路由配置生成路由的经验, 商量后我打算尝试.
我的想法是定义路由规则, 然后将路由解析称为对象, 然后通过代码进行控制.

比如这样一个路径:

/plants/152883204915/qualityManagement/measurementData/components/21712526851768321/processes/39125230470234114

进行拆解以后我认为就是几个层级:

/plants/152883204915
 /qualityManagement
  /measurementData/components/21712526851768321/processes/39125230470234114

跟 React Router 直接用标签做匹配的写法不同, 我认为路由应该先被解析,
该路由包含了页面的信息, 也包含了响应的参数, 实际上对应一个链表, 用对象表示是:

{
  "name": "plants", // <--- 第一层路由
  "matches": true,
  "restPath": null,
  "data": {
    "plantId": "152883204915"
  },
  "query": {}, 
  "next": {
    "name": "qualityManagement", // <--- 第二层路由
    "matches": true,
    "restPath": null,
    "data": {},
    "query": {},
    "next": {
      "name": "measurementData", // <--- 第三层路由
      "matches": true,
      "restPath": null,
      "data": {
        "componentId": "21712526851768321",
        "processId": "39125230470234114"
      },
      "query": {},
      "next": null
    }
  }
}

这是一个比较清晰的层级的结构, 很容易用 switch/case 判断渲染对应的子页面.

而解析这个路由所需要的规则, 也可以通过大致这样的代码定义出来.

let pageRules = [
  {
    path: "plants/:plantId",
    next: [
      {
        path: "qualityManagement",
        next: [
          {
            path: "measurementData/components/:componentId/processes/:processId"
          }
        ]
      }
    ]
  },
];

这样基于路由规则和解析函数, 路由定位的方案就变成了:

  • 从 URL 改变的事件获取到 location.hash 的字符串,
  • 用函数解析得到路由信息的 JSON 树,
  • 根据 JSON 逐级传递, 用 switch/case 跳转到对应的页面.

示例代码比如:

render() {
  const nextRoute = this.props.route.next;

  switch (nextRoute && nextRoute.name) {
    case RouteOutgoing.Records:
      return <Records route={nextRoute.next} plantId={plantId} />;
    case RouteOutgoing.Settings:
      return <Settings route={nextRoute} />;
  }

  return (
    <Redirect
      to={router.getPath(RouteOutgoing.Records, {
        plantId,
      })}
    />
  );
}

解析的代码在 ruled-router 可以找到, 使用 TypeScript 开发, 有基础的类型约束.

从代码看, 由于路由层级的显式处理, 会存在不少的 .next 需要手工维护, 对于维护有些啰嗦.
当然这个写法好的一面是路由信息随时可以打印和调试, 方便定位问题.

路由的跳转(code generator)

在 React Router 当中路由的跳转相对简单, 提供路径的字符串表示即可完成:

history.push('/a/b/${c}/d')

但是前面说了, 这样无法进行类型检测, 无法定位出现问题的路由位置.
我们尝试了几个方案, 用比较多的一个方案是给路由定义唯一的 ID 的枚举值, 然后查找枚举值跳转.
后来我从另一个思路开始尝试, 试着用不同的方案来搭配 TypeScript.

比如说这样的一套规则, 定义 3 个页面:

let routeRules = [
  { path: "home" },
  { path: "content" },
  { path: "else" },
  { path: "", name: "home" }
]

那么对应这个路由我就生成响应的代码, 这段代码, 就是 TypeScript 可以做类型检查的了,

export let genRouter = {
  home: {
    name: "home",
    raw: "home",
    path: () => `/home`,
    go: () => switchPath(`/home`),
  },
  content: {
    name: "content",
    raw: "content",
    path: () => `/content`,
    go: () => switchPath(`/content`),
  },
  else: {
    name: "else",
    raw: "else",
    path: () => `/else`,
    go: () => switchPath(`/else`),
  },
  _: {
    name: "home",
    raw: "",
    path: () => `/`,
    go: () => switchPath(`/`),
  },
};

其中 .go() 方法用于跳转, .path() 方法用于生成其他组件需要的字符串形态.
当然, 维护这样的一段代码, 成本并不低, 但是好在这样高度重复的代码是可以用代码生成的,
于是我们增加了 router-code-generator 这个脚本, 用于生成路由代码.

这样, 添加新路由的时候就需要,

  • 在 rules 当中添加路由规则,
  • 运行脚本生成路由的代码,
  • 在需要跳转的位置引用 genRouter 对象, 调用对应方法进行跳转.

实际业务当中的代码当然会复杂很多, 项目最终生成出来是两千多行的路由文件,

export let genRouter = {
  plants_: {
    name: "plants",
    raw: "plants/:plantId",
    path: (plantId: Id) => `/plants/${plantId}`,
    go: (plantId: Id) => switchPath(`/plants/${plantId}`),
    information: {
      name: "information",
      raw: "information",
      path: (plantId: Id) => `/plants/${plantId}/information`,
      go: (plantId: Id) => switchPath(`/plants/${plantId}/information`),
      products: {
        name: "material.finished",
        raw: "products",
        path: (plantId: Id) => `/plants/${plantId}/information/products`,
        go: (plantId: Id) => switchPath(`/plants/${plantId}/information/products`),

实际项目当中的脚本生成也是个需要处理的地方, 我们用 Webpack 将这部分代码打包运行,
性能上还好, 关掉类型检查的话几秒钟内可以完成, 具体看示例的代码:

类型检查的覆盖

前面的两部分, 覆盖了路由的解析, 还有路由的跳转, 完成了基本的路由的功能.

路由解析部分, 路由规则可以通过 JSON 结构定义, 基本能得到 TypeScript 的提示.
路由的解析结果, 是一棵大的 JSON 的树, 这中间有不少动态的部分, 需要开发时自己留意.
路由跳转的代码, 整个 Object 定义的结构可以被 TypeScript 解析, 基本上有完整的补全.

虽然并不完美, 但是很大程度利用了 TypeScript 的自动补全以及类型检查简化了书写.
当路由有增改时, 通过运行脚本还有执行类型检查, 比较容易定位到发生改变的部分.

该方案的不足

  • 路由劫持等功能

React Router 提供的功能显然远不止解析和跳转, 还有一些页面跳转相关的钩子, 甚至渐变等效果.
ruled-router 的方案没有去实现相关的功能.

  • 脚手架比较麻烦

从前面的描述也能看出来, 这一整套写法, 特别是后面跳转的写法, 引入了大量脚手架.
需要专门写一个 Webpack 配置来生成路由, 一般项目来说觉得非常繁琐了.

实际在项目当中, 由于我们有着较深的路由层级, 实际代码看上去又长又啰嗦:

genRouter.plants_.product.batch_.ooc.go(plantId, value);

genRouter.plants_.model.projects._status.go(this.props.plantId, record.id);

这代码是靠着 VS Code 提供的代码补全才能很快写出来… 也就是和 TypeScript 以及 VS Code 等工具绑定死了.

结尾

除了上面介绍的, 其他一些功能也在 ruled-router 方案里做了一些支持:

  • Query 参数. 可以被解析, 也可以在跳转代码当中被生成出来. 基本可用. 只是类型有缺失.
  • 性能优化的问题, 需要配合 shouldComponentUpdate 或者 useMemo 来优化, 就用到易于匹配的字符串形态.

特别是随着项目规模增加, 几百个大小页面的木有, 更多会需要类型检查工具来帮助我们做校验.
当然目前的方案在开发当中依然有着细节上的各种需要优化的地方, 要后续再想办法进一步优化.