🥷 原来实现 Router Hook 组件只需要一百多行代码

2,363 阅读6分钟

Intro

在几乎每个 React 项目中,react-router 都必不可少。但你也知道,和 Vue 不同,react-router 并不是由 React 团队开发的。每次需要用到一些不熟悉的路由 API 都得去查 Router 的官网文档 reactrouter.com/ ,而不是 React 的官网。

react-router 本身的 API 也非常多,一下接收太多知识,导致很多新人都觉得 React 的上手门槛很高。但实际上,开发一个最最最基础的 react-router 用不了多少代码,这篇文章,我们将从头开始编写一个 React Router 实现。

Router 功能分解

监听 Location 变化

首先 Router 需要监听 Location 变化,并做出响应,(当然这些变化需要在单页应用内,而不是直接修改 location.href 的方式)。浏览器中提供了 History API,它提供了一些属性和方法,方便开发者访问当前会话的页面栈。

直接看 MDN 文档 中的 Demo。

window.onpopstate = function(event) {
  alert(`location: ${document.location}, state: ${JSON.stringify(event.state)}`)
}

history.pushState({page: 1}, "title 1", "?page=1")
history.pushState({page: 2}, "title 2", "?page=2")
history.replaceState({page: 3}, "title 3", "?page=3")
history.back() // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back() // alerts "location: http://example.com/example.html, state: null"
history.go(2)  // alerts "location: http://example.com/example.html?page=3, state: {"page":3}"

history 提供的方法都很简单,使用 pushState 可以往栈中压入新的路由、replaceState 方法可以替换栈顶的路由、back/forwardgo 方法的语法糖,可以往前或往后切换栈顶。还有一个回调事件,popstate,是不是在这个事件里面编写响应逻辑就可以了呢?

🐒 MonkeyPatch

很遗憾,popstate 仅在 back/forward/go 等动作完成时触发,pushState/replaceState 无法工作。

为了能响应这两个动作,我们可以打个 🐒 补丁,重写这两个方法,在他们调用完成之后往全局抛出事件。

(["pushState", "replaceState"] as const).forEach((type) => {
  const original = history[type];
  history[type] = function (...args) {
    const result = original.apply(this, args);
    const event = new Event(type.toLowerCase());
    (event as any).arguments = arguments;

    dispatchEvent(event);
    return result;
  };
});

结合上面的代码,我们可以封装一个 useLocation Hook,在每个路由事件(popstate/pushstate/replacestate)中判断当前 url 是否发生变化,如果修改了,那就更新 state,告诉 React 需要重新渲染组件了。

代码大概是下面这样:

const events = ["popstate", "pushstate", "replacestate"];

function currentPath(base: string) {
  const { pathname } = location;
  return pathname.startsWith(base) ? pathname.slice(base.length) : "/";
}

export function useLocation({ base = "" }) {
  const [{ path }, update] = useState({
    path: currentPath(base)
  });
  const lastPathRef = useRef(location.pathname);

  useEffect(() => {
    function checkForUpdate(e: any) {
      e.preventDefault();
      if (location.pathname !== lastPathRef.current) {
        lastPathRef.current = location.pathname;
        update({ path: lastPathRef.current });
      }
    }

    events.forEach((event) => addEventListener(event, checkForUpdate));
    return () =>
      events.forEach((event) => removeEventListener(event, checkForUpdate));
  }, [base]);

  const navigate = useCallback(
    (to: string, { replace = false } = {}) => {
      history[replace ? "replaceState" : "pushState"](null, "", base + to);
    },
    [base]
  );

  return [path, navigate] as const;
}

路径模式匹配和解析

在设计页面路由的时候,我们通常会使用 Restful 的风格,在 url 上支持动态参数,例如用户详情页面路由可以是 /users/:id。如何判断当前 url 是否和某个模式匹配并从中获取动态参数呢?

path-to-regexp

答案是:path-to-regexp,这个库每周有三千多万的下载量,它可以将路径转成正则表达式。用其提供的正则就可以判断路由是否匹配,以及通过路由捕获提取动态参数了。

用法很简单:

const keys = [];
const regexp = pathToRegexp("/users/:id", keys);

// regexp = /^/users(?:/([^/#?]+?))[/#?]?$/i
// keys = [{ name: 'id', prefix: '/', suffix: '', pattern: '[^\/#\?]+?', modifier: '' }]

自行实现

path-to-regexp 还支持很多特性,例如匹配描述符、参数加工等等,但如果想自己写一个基础能用的版本,其实也不难。

const escapeRx = (str) =>
  str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");

function parsePattern(pattern) {
  const regx = /:([0-9a-z]+)/i;
  let match = regx.exec(pattern);
  let keys = [];
  let result = "";
  while (match !== null) {
    keys.push(match[1]);
    result = `${result}${escapeRx(pattern.slice(0, match.index))}([^\\/]+?)`;
    pattern = pattern.slice(match.index + match[0].length);
    match = regx.exec(pattern);
  }
  result += escapeRx(pattern);
  return [keys, new RegExp(`^${result}(?:\\/)?$`, "i")];
}

上面的代码会尝试从 pattern 中提取满足 /:([0-9a-z]+)/i 正则的部分,并对其他字符进行正则转义,最终返回捕获结果的所有 key 和一个用于判断是否匹配的正则。

试运行一下:

parsePattern('/users/:id')

0: ['id']
1/^\/users\/([^\/]+?)(?:\/)?$/i

接下来进入常用的 Hook 和 组件开发。

Hook & 组件开发

<Router /> & useRouter

为了让这个基础的 Router 实现可以支持 base 和嵌套,我们可以使用 Context API,每个组件都是从 Fiber 树上找最近的 RouterContext 进行消费。

代码极其简单:

const RouterContext = createContext({
  base: ""
});

export function useRouter() {
  return useContext(RouterContext);
}

export function Router(props: {
  base?: string;
  children: any;
}) {
  const { children, base = "" } = props;
  const [state] = useState(() => ({
    base
  }));
  return <RouterContext.Provider value={state} children={children} />;
}

<Link /> & useLocation

接着需要封装一个 Link 组件,方便用户点击时调用 history API 而不是通过 href 的方式重新加载页面。

为了更方便路由跳转,可以基于前面的 useLocation 再封装一个 useNavigate hook。

function useNavigate(options: { href: string; replace?: boolean }) {
  const [, navigate] = useLocation();
  const optionRef = useRef(options);
  optionRef.current = options;
  return useCallback(() => {
    navigate(optionRef.current.href, optionRef.current);
  }, []);
}

Link 组件中就只需要在点击时调用 navigate 方法即可。

export function Link(props: {
  href: string;
  replace?: boolean;
  onClick?: (event?: any) => void;
  children: any;
}) {
  const navigate = useNavigate(props);
  const { base } = useRouter();
  const { href, onClick, children } = props;

  const handleClick = useCallback(
    (event) => {
      onClick?.(event);
      navigate();
    },
    [onClick]
  );

  const element = isValidElement(children)
    ? children
    : createElement("a", props);

  return cloneElement(element, {
    onClick: handleClick,
    href: base + href
  });
}

上面的代码会判断 children 是否为合法的 Element,如果不是的话,就手动创建一个 a 标签,并通过 cloneElement API 为这个元素注入 onClickhref props。

isValidElement 是 React 提供的顶级 API,可以用它来判断传入的 children 是否为正确的 ReactElement 对象。

expect(React.isValidElement(<div />)).toEqual(true);
expect(React.isValidElement(<Component />)).toEqual(true);

expect(React.isValidElement(null)).toEqual(false);
expect(React.isValidElement(true)).toEqual(false);
expect(React.isValidElement({})).toEqual(false);
expect(React.isValidElement('string')).toEqual(false);
expect(React.isValidElement(Component)).toEqual(false);
expect(React.isValidElement({type: 'div', props: {}})).toEqual(false);

从单元测试中可以看到,JSX 对象是 ValidElement(因为每个 JSX 都会被 babel 处理成 createElement 方法鸭),而基础数据类型或者 Component 都不是 ValidElement

如果只想对纯粹的鼠标左键点击做出响应,可以通过 event 上的属性 ctrlKey/metaKey/altKey/shiftKey 判断是否有其他鼠标功能键。

还有一个 event.button 属性,可以判断点击事件是否由鼠标左键发起(有些鼠标上有好几个按键)

if (
!!(
  event.ctrlKey ||
  event.metaKey ||
  event.altKey ||
  event.shiftKey ||
  event.button !== 0
)
) {
    return;
}

对了,如果希望外部可以中断跳转动作,可以在 onClick 执行完成之后,判断 event.defaultPrevented 属性,它会回一个布尔值,表明当前事件是否调用了 event.preventDefault() 方法。

if (!event.defaultPrevented) {
    navigate();
}

<Route />

每个路由也是组件,在这个组件的 pattern 和当前页面路由匹配的时候渲染,否则就不渲染。

export function Route({
  path: pattern,
  match,
  component
}) {
  const [path] = useLocation();
  const [matches, params] = match || matcher(pattern, path);

  if (!matches) {
    return null;
  }

  return createElement(component, {
    params
  });
}

命中路由时,还需要将路由中的动态参数通过 props 传递给组件实例。Route 的 match 属性是留给接下来的 Switch 组件用的。

<Switch />

通常我们的页面上同一级路由中,仅希望一个路由命中,而不是多个,这个功能可以通过 Switch 组件来实现。

export function Switch({ children }: { children: any }) {
  const [path] = useLocation();
  children = [].concat(children);
  for (const element of children) {
    if (isValidElement(element) && element.type === Route) {
      const match = matcher((element as any).props.path || "", path);
      if (!(element as any).props.path || match[0]) {
        return cloneElement(element as any, {
          match
        });
      }
    }
  }
  return null;
}

它会遍历所有 children,找到第一个匹配规则的 Route Element 返回;如果没有找到则什么也不渲染。

<Redirect />

最后我们还需要 Redirect 组件,当它被渲染时,需要进行页面跳转。

export function Redirect(props: Parameters<typeof useNavigate>[0]) {
  const navigate = useNavigate(props);

  useLayoutEffect(() => {
    navigate();
  }, []);

  return null;
}

Ending

🎉🎉🎉 把它们结合起来,一个最最最基本的 ReactRouter 组件就开发完成了!

屏幕录制2022-07-13 下午10.14.05.gif

可以看到虽然代码很短,但实际上知识点并不少,我在写这个 Demo 的过程中学习和巩固了很多知识点,示例代码看这里 codesandbox

本文中出现的所有代码均参考自开源组件 wouter github,是我在阅读源码之后自己尝试手写的阉割版本,仅可作为学习和了解 Router 原理使用。 wouter 本身代码也很简洁,支持服务端渲染,支持 preact,gzip 打包后体积仅 1.36KB,感兴趣的同学可以直接阅读其源码。