2022-09-23前沿技术00
请注意,本文编写于 580 天前,最后修改于 580 天前,其中某些信息可能已经过时。

造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,Router 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。

精读

Router 快速实现了 React Router 3 个核心 API:RouternavigateLink,下面列出基本用法,配合理解源码实现会更方便:

const App = () => (
  <Router
    routes={[
      { path: '/home', component: <Home /> },
      { path: '/articles', component: <Articles /> }
    ]}
  />
)

const Home = () => (
  <div>
    home, <Link href="/articles">go articles</Link>,
    <span onClick={() => navigate('/details')}>or jump to details</span>
  </div>
)

首先看 Router 的实现,在看代码之前,思考下 Router 要做哪些事情?

  • 接收 routes 参数,根据当前 url 地址判断渲染哪个组件。
  • 当 url 地址变化时(无论是用户触发还是自己的 navigate Link 触发),渲染新 url 对应的组件。

所以 Router 是一个路由渲染分配器与 url 监听器:

export default function Router ({ routes }) {
  // 存储当前 url path,方便其变化时引发自身重渲染,以返回新的 url 对应的组件
  const [currentPath, setCurrentPath] = useState(window.location.pathname);

  useEffect(() => {
    const onLocationChange = () => {
      // 将 url path 更新到当前数据流中,触发自身重渲染
      setCurrentPath(window.location.pathname);
    }

    // 监听 popstate 事件,该事件由用户点击浏览器前进/后退时触发
    window.addEventListener('popstate', onLocationChange);

    return () => window.removeEventListener('popstate', onLocationChange)
  }, [])

  // 找到匹配当前 url 路径的组件并渲染
  return routes.find(({ path, component }) => path === currentPath)?.component
}

最后一段代码看似每次都执行 find 有一定性能损耗,但其实根据 Router 一般在最根节点的特性,该函数很少因父组件重渲染而触发渲染,所以性能不用太担心。

但如果考虑做一个完整的 React Router 组件库,考虑了更复杂的嵌套 API,即 RouterRouter 后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。

下面该实现 navigate Link 了,他俩做的事情都是跳转,有如下区别:

  1. API 调用方式不同,navigate 是调用式函数,而 Link 是一个内置 navigate 能力的 a 标签。
  2. Link 其实还有一种按住 ctrl 后打开新 tab 的跳转模式,该模式由浏览器对 a 标签默认行为完成。

所以 Link 更复杂一些,我们先实现 navigate,再实现 Link 时就可以复用它了。

既然 Router 已经监听 popstate 事件,我们显然想到的是触发 url 变化后,让 popstate 捕获,自动触发后续跳转逻辑。但可惜的是,我们要做的 React Router 需要实现单页跳转逻辑,而单页跳转的 API history.pushState 并不会触发 popstate,为了让实现更优雅,我们可以在 pushState 后手动触发 popstate 事件,如源码所示:

export function navigate (href) {
  // 用 pushState 直接刷新 url,而不触发真正的浏览器跳转
  window.history.pushState({}, "", href);

  // 手动触发一次 popstate,让 Route 组件监听并触发 onLocationChange
  const navEvent = new PopStateEvent('popstate');
  window.dispatchEvent(navEvent);
}

接下来实现 Link 就很简单了,有几个考虑点:

  1. 返回一个正常的 <a> 标签。
  2. 因为正常 <a> 点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的 navigate(源码里没做这个抽象,笔者稍微优化了下)。
  3. 但按住 ctrl 时又要打开新 tab,此时用默认 <a> 标签行为就行,所以此时不要阻止默认行为,也不要继续执行 navigate,因为这个 url 变化不会作用于当前 tab。
export function Link ({ className, href, children }) {
  const onClick = (event) => {
    // mac 的 meta or windows 的 ctrl 都会打开新 tab
    // 所以此时不做定制处理,直接 return 用原生行为即可
    if (event.metaKey || event.ctrlKey) {
      return;
    }

    // 否则禁用原生跳转
    event.preventDefault();

    // 做一次单页跳转
    navigate(href)
  };

  return (
    <a className={className} href={href} onClick={onClick}>
      {children}
    </a>
  );
};

这样的设计,既能兼顾 <a> 标签默认行为,又能在点击时优化为单页跳转,里面对 preventDefaultmetaKey 的判断值得学习。

总结

从这个小轮子中可以学习到一下几个经验:

  • 造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。
  • 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。
  • 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 pushState 无法触发 popstate 那段,直接把 popstate 代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 popstate 行为,而非只是更新渲染组件这个动作,万一以后再有监听 popstate 的地方,你的触发逻辑就能很自然的应用到那儿。
  • 尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 Link 的实现是基于 <a> 标签拓展的,如果采用自定义 <span> 标签,不仅要补齐样式上的差异,还要自己实现 ctrl 后打开新 tab 的行为,甚至 <a> 默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事倍功半,甚至无法实现。

本文作者:前端小毛

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!