Web 前端如何实现一个 UI Router 库


我们在这里将要聊的东西称为 UI Router,为了和服务 API 的 Router 区分开,仅是指用于管理和驱动页面变化的 Router。

例如前端常见的 react-router-dom,Angular 的 ui-router,Android 常见的各种路由库,Flutter 的 go_router

本质上 UI Router 就是为了实现用户在多个界面流转的状态管理,我们把 Router 简单定义为一个地址对应一个界面,当用户来到了某个地址,我们就给它呈现对应的界面。

控制用户流转的各个动作也是 UI Router 在处理,例如返回上一个界面,用一个新的界面替换到当前界面等,它会在背后维护着一个界面集合。

客户端的 UI Router 实现要更加复杂,后续有机会再聊聊。

基础实现

Web 本身就已经有了一个「链接跳转」的模型,可以实现跳转/前进/后退等操作,并且根据 url 变化来加载页面,整体是可以基于两个栈来实现的,可以参考 Design custom Browser History

所以说 Web 默认的通过 URL 在多个页面的流转,浏览器已经是实现了,而现在较常见的单页面应用,则可以通过 Hashchange 或者 History API 来实现,这也是 Web 前端中所指的路由库所做的事情。

我们可以先来看看在浏览器的单页面 UI Router 的基础实现是怎么样的,其关键在于两部分:

  1. 监听地址变化
  2. 路由状态管理

我们定义一个路由的基本配置是这样的:

interface Route {
  path: string; // 匹配的路径,例如 /about,/user/:id 等
  handler: () => void; // 匹配到对应路径时的处理器
}

核心部分

正如上述提到的,我们要先能够监听地址的变化,在浏览器中一般有两种模式:

在浏览器中,地址栏是很重要的一个交互部分,它直接影响了页面展示内容,而不仅有页面自身的交互动作。

  • 使用 hashchange 事件监听 URL 中 # 后边那部分,即 hash 的变化,将 # 后边部分作为路由路径来处理
  • 使用 History API,可以监听 popstate 事件,将整个 location.pathname 那部分作为路由路径来处理

当可以监听地址变化后,就需要通过地址匹配对应的路由配置,来执行相应的 handler。路由匹配的一个实现思路就是将你声明的路由配置中的 path 转为正则表达式来进行匹配,有兴趣的可以参考 path-to-regexp repo

然后就是路由状态管理了,API 提供基础的状态访问以及控制,例如最基本的有下边这些:

interface Router {
  init: (routes: Route[]) => void; // 初始化,即用于传入路由配置

  navigate: (path: string) => void; // 用于跳转到新的路由页面
  back: () => void; // 返回
  forward: () => void; // 前进

  currentRoute: Route; // 当前匹配的路由
  currentParams: any; // 解析出来的参数
}

那么一个 Web 的 UI Router 库的基本雏形就差不多完成了。

更多应用需求

在实际的应用中,基于以上的路由雏形,还有一些问题或者需求是需要考虑的:

  • 默认路由,也就是匹配不到时用到,通常会是 404 页面
  • 路由重定向
  • 路由嵌套
  • 路由冲突

默认路由

默认路由的实现比较简单,也就是当没有任何路由被匹配到时有一个默认的动作,也可以延伸一下,转变为路由匹配过程中异常时的处理:

// 原本的路由初始化时扩展一个 errorHandler
interface Router {
  init: (routes: Route[], errorHandler: () => void);
}

// 捕获匹配过程中的异常
try {
  // 假设 match 是路由匹配的实现方法
  const route = match(url); // ...
  if (!route) {
    throw new Error()
  }
} catch (e) {
  //
  Router.errorHandler();
}

路由重定向

路由重定向的实现也比较简单,在路由配置中新增一个字段 redirect,它可以是一个路由 path,也可以是一个方法,当匹配到对应的 path 时,如果有 redirect 字段就不执行 handler 了,而是变为匹配 redirect 指定的 path,或者是执行 redirect 方法即可:

interface Route {
  redirect: string | () => void;
}

const route = match(url);
if (route.redirect) {
  // ...
}

路由嵌套

路由嵌套的实现稍微麻烦一点,首先在路由配置中新增 children 字段,匹配到路由后(路由需要解析出 url 匹配剩余部分),判断是否有 children 配置,有的话执行下层的匹配,递归下去即可。

这里要留意的是父层级的 handler 和子层级的 handler 都要执行,这样方便嵌套路由中去实现父层级 layout 元素的渲染,子层级 outlet 或这 slot 的渲染。

interface Route {
  children: Route[];
}

// tryMatch 作为递归方法
function tryMatch(url, routes) {
  const { route, rest } = match(url);
  if (route.handler) {
    route.hanlder();
  }
  if (route.children && rest) {
    tryMatch(rest, route.children);
  }
}

路由冲突

关于路由冲突的,根据实际需求可以制定实现的策略,当出现相同的 path 声明时,可以抛出异常,可以打印警告,也可以默认处理掉,例如 react-router 的就是后声明的覆盖前边的。这是一种边缘场景,实现时考虑好对应的处理方式即可。

结合 React / Vue

结合不同的 UI 库,路由库表现出来的 API 会完全不一样,这里拿比较流行的 React 和 Vue 举例子,看看怎么把上述提到的路由核心融入到 UI 库的实际应用中,主要就是在 handler 中去处理 UI 的变更。

React

路由渲染对应组件

在 React 中,路由的声明会转变为组件式的,例如:

interface Route {
  path: string;
  component: ReactNode;
}

但是我们核心部分的 handler 依旧需要,只是为了方便实现,我们可以让 Router 提供一个 onChange 之类的方法来监听 url 变化,然后提供一个最外层的组件,通过 onChange 获取对应的 route 来渲染不同的 component,然后使用 React 的 Provider/Context 将路由的相关信息跨多个组件层级传递下去。

import { createContext } from "react";

// 创建 React 需要的 Context 来传递路由信息
export const RouterContext = createContext({ route: null });
function createRouter(routes: Routes[]) {
  // ...
  return router;
}

function RouterProvider({ router: Router }) {
  const [route, setRoute] = useState(null);

  useEffect(() => {
    router.onChange((route) => {
      setRoute(route);
    });
  }, []);

  return (
    <RouterContext.Provider value={{ route }}>
      {route?.component}
    </RouterContext.Provider>
  );
}

支持路由嵌套的 Outlet

在这个基础上,如果要实现路由嵌套的话,我们需要 Outlet 组件可以获取到对应层级路由配置的 component 来使用,那么 router.onChange 方法匹配到的结果就不是 route,而是一个数组,按照从上到下的层级,把每一个路由列出来。

我们添加一个层级计数的 Context 来传递当前的组件所在的路由层级,Outlet 渲染时根据层级从 route[] 中取对应路由。

// 层级计数的 Context
export const OutletContext = createContext(0);

function RouterProvider({ router: Router }) {
  // ...
  // 当前是 0 层级,但是下一个 Outlet 要用的层级是 1
  return (
    <RouterContext.Provider value={routes}>
      <OutletContext.Provider value={1}>
        {routes[0]?.component}
      </OutletContext.Provider>
    </RouterContext>
  )
}

function Outlet() {
  const routes = useContext(RouterContext);
  const index = useContext(OutletContext);

  // 传递层级变化,每次都是 +1
  return (
    <OutletContext.Provider value={index + 1}>
      {routes[index]?.component}
    </OutletContext.Provider>
  );
}

另外的一些 React 语法糖性质的 API,例如用 JSX 声明路由,指定 ErrorComponent 等,就不再详细展开了,有兴趣的可以思考下如何实现。

Vue

Vue 插件

结合 Vue 比较好的方式应该是实现一个 Vue 插件,该插件会全局注入我们创建的 router 来访问路由相关状态,并且注册一个 router-view 组件,该组件在内部监听由 Router 提供的 onChange 方法来渲染对应的路由内容。

// 插件 install 的时候把我们创建的 router 注册进去
const routerKey = "routerKey";

function createRouter(routes: Route[]) {
  // ...
  return {
    router,
    install(app) {
      // 兼容 options API
      app.config.globalProperties.$router = router;
      app.provide(routerKey, router);
    },
  };
}

// 提供一个 hook 在 setup 时获取 router
function useRouter() {
  return inject(routerKey);
}
<!-- router-view 组件实现的基础逻辑 -->
<script setup>
import { ref } from "vue";

const router = useRouter();
const route = ref(router.currentRoute);

// 监听 url 变化渲染对应的组件
// template 中我们使用 <component :is> 标签来实现组件的变化
router.onChange((route) => {
  route.value = route;
});
</script>

<template>
  <component :is="route.component"></component>
</template>

支持路由嵌套的 router-view

类似 React 的实现,我们可以利用 vue 的 provide / inject API,在 router-view 中传递一个层级来获取对应层级的路由内容,同样地也是需要 Router.onChange 方法是返回多层级的路由信息:

<script setup>
import { ref, provide, inject } from "vue";

const viewDepthKey = "viewDepth";
const router = useRouter();
const route = ref(router.currentRoute);
const viewDepth = inject(viewDepthKey, 0);
provide(viewDepthKey, viewDepth + 1);

// 使用 viewDepth 获取对应的路由
router.onChange((routes) => {
  route.value = routes[viewDepth];
});
</script>

React Router 为什么复杂

这里顺带探索一下我常用的 react-router 的一些特性,来聊聊为什么看它的文档以及源码,会感觉非常复杂,不像我们上述提及的路由实现那么简单。

首先,从源码上看,它做了多层抽象,一方面可以将基础 API 稳定沉淀下来后方便维护,一方面也便于应用层面的扩展,例如支持 react-native:

  • router,是基础的路由配置,创建和状态管理,也就是我们前边提到的基础实现的部分
  • react-router,是基于 react 的实现版本,提供基于 react 基础 API 的核心功能
  • react-router-dom,是在 react-router 的基础上,结合 web 提供的功能特性,例如 createBrowserRouter 这种 API

我们单独看 react-router-dom 版本的话,它为了支持更多的场景和特性,做了不少工作:

  • 为了支持 SSR 和其他非浏览器的运行环境,它提供了诸如 createStaticHandlercreateMemoryRouter 之类的 API
  • 为了页面性能和体验考虑,提供了 loader 的 API 来支持路由切换时的数据请求和 UI 同步
  • 为了开发者的体验,提供了不少简单易用的 hooks,例如 useParamsuseNavigate

如果可以仔细查看 react-router 官方 tutorial 文档,那么你可以了解它一些复杂设计要解决的应用场景。因为它支持的功能特性比较多,覆盖的应用场景比较全,也导致了他的 API 和实现有一定的复杂度,虽然可能一时半会我们用不到某些特性,但某一天当你需要了,你也会感谢它已经支持了。