|
| 1 | +造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,[Router](https://github.com/ashok-khanna/react-snippets/blob/main/Router.js) 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。 |
| 2 | + |
| 3 | +## 精读 |
| 4 | + |
| 5 | +[Router](https://github.com/ashok-khanna/react-snippets/blob/main/Router.js) 快速实现了 React Router 3 个核心 API:`Router`、`navigate`、`Link`,下面列出基本用法,配合理解源码实现会更方便: |
| 6 | + |
| 7 | +```tsx |
| 8 | +const App = () => ( |
| 9 | + <Router |
| 10 | + routes={[ |
| 11 | + { path: '/home', component: <Home /> }, |
| 12 | + { path: '/articles', component: <Articles /> } |
| 13 | + ]} |
| 14 | + /> |
| 15 | +) |
| 16 | + |
| 17 | +const Home = () => ( |
| 18 | + <div> |
| 19 | + home, <Link href="/articles">go articles</Link>, |
| 20 | + <span onClick={() => navigate('/details')}>or jump to details</span> |
| 21 | + </div> |
| 22 | +) |
| 23 | +``` |
| 24 | + |
| 25 | +首先看 `Router` 的实现,在看代码之前,思考下 `Router` 要做哪些事情? |
| 26 | + |
| 27 | +- 接收 routes 参数,根据当前 url 地址判断渲染哪个组件。 |
| 28 | +- 当 url 地址变化时(无论是用户触发还是自己的 `navigate` `Link` 触发),渲染新 url 对应的组件。 |
| 29 | + |
| 30 | +所以 `Router` 是一个路由渲染分配器与 url 监听器: |
| 31 | + |
| 32 | +```tsx |
| 33 | +export default function Router ({ routes }) { |
| 34 | + // 存储当前 url path,方便其变化时引发自身重渲染,以返回新的 url 对应的组件 |
| 35 | + const [currentPath, setCurrentPath] = useState(window.location.pathname); |
| 36 | + |
| 37 | + useEffect(() => { |
| 38 | + const onLocationChange = () => { |
| 39 | + // 将 url path 更新到当前数据流中,触发自身重渲染 |
| 40 | + setCurrentPath(window.location.pathname); |
| 41 | + } |
| 42 | + |
| 43 | + // 监听 popstate 事件,该事件由用户点击浏览器前进/后退时触发 |
| 44 | + window.addEventListener('popstate', onLocationChange); |
| 45 | + |
| 46 | + return () => window.removeEventListener('popstate', onLocationChange) |
| 47 | + }, []) |
| 48 | + |
| 49 | + // 找到匹配当前 url 路径的组件并渲染 |
| 50 | + return routes.find(({ path, component }) => path === currentPath)?.component |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +最后一段代码看似每次都执行 `find` 有一定性能损耗,但其实根据 `Router` 一般在最根节点的特性,该函数很少因父组件重渲染而触发渲染,所以性能不用太担心。 |
| 55 | + |
| 56 | +但如果考虑做一个完整的 React Router 组件库,考虑了更复杂的嵌套 API,即 `Router` 套 `Router` 后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。 |
| 57 | + |
| 58 | +下面该实现 `navigate` `Link` 了,他俩做的事情都是跳转,有如下区别: |
| 59 | + |
| 60 | +1. API 调用方式不同,`navigate` 是调用式函数,而 `Link` 是一个内置 `navigate` 能力的 `a` 标签。 |
| 61 | +2. `Link` 其实还有一种按住 `ctrl` 后打开新 tab 的跳转模式,该模式由浏览器对 `a` 标签默认行为完成。 |
| 62 | + |
| 63 | +所以 `Link` 更复杂一些,我们先实现 `navigate`,再实现 `Link` 时就可以复用它了。 |
| 64 | + |
| 65 | +既然 `Router` 已经监听 `popstate` 事件,我们显然想到的是触发 url 变化后,让 `popstate` 捕获,自动触发后续跳转逻辑。但可惜的是,我们要做的 React Router 需要实现单页跳转逻辑,而单页跳转的 API `history.pushState` 并不会触发 `popstate`,为了让实现更优雅,我们可以在 `pushState` 后手动触发 `popstate` 事件,如源码所示: |
| 66 | + |
| 67 | +```tsx |
| 68 | +export function navigate (href) { |
| 69 | + // 用 pushState 直接刷新 url,而不触发真正的浏览器跳转 |
| 70 | + window.history.pushState({}, "", href); |
| 71 | + |
| 72 | + // 手动触发一次 popstate,让 Route 组件监听并触发 onLocationChange |
| 73 | + const navEvent = new PopStateEvent('popstate'); |
| 74 | + window.dispatchEvent(navEvent); |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +接下来实现 `Link` 就很简单了,有几个考虑点: |
| 79 | + |
| 80 | +1. 返回一个正常的 `<a>` 标签。 |
| 81 | +2. 因为正常 `<a>` 点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的 `navigate`(源码里没做这个抽象,笔者稍微优化了下)。 |
| 82 | +3. 但按住 `ctrl` 时又要打开新 tab,此时用默认 `<a>` 标签行为就行,所以此时不要阻止默认行为,也不要继续执行 `navigate`,因为这个 url 变化不会作用于当前 tab。 |
| 83 | + |
| 84 | +```tsx |
| 85 | +export function Link ({ className, href, children }) { |
| 86 | + const onClick = (event) => { |
| 87 | + // mac 的 meta or windows 的 ctrl 都会打开新 tab |
| 88 | + // 所以此时不做定制处理,直接 return 用原生行为即可 |
| 89 | + if (event.metaKey || event.ctrlKey) { |
| 90 | + return; |
| 91 | + } |
| 92 | + |
| 93 | + // 否则禁用原生跳转 |
| 94 | + event.preventDefault(); |
| 95 | + |
| 96 | + // 做一次单页跳转 |
| 97 | + navigate(href) |
| 98 | + }; |
| 99 | + |
| 100 | + return ( |
| 101 | + <a className={className} href={href} onClick={onClick}> |
| 102 | + {children} |
| 103 | + </a> |
| 104 | + ); |
| 105 | +}; |
| 106 | +``` |
| 107 | + |
| 108 | +这样的设计,既能兼顾 `<a>` 标签默认行为,又能在点击时优化为单页跳转,里面对 `preventDefault` 与 `metaKey` 的判断值得学习。 |
| 109 | + |
| 110 | +## 总结 |
| 111 | + |
| 112 | +从这个小轮子中可以学习到一下几个经验: |
| 113 | + |
| 114 | +- 造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。 |
| 115 | +- 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。 |
| 116 | +- 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 `pushState` 无法触发 `popstate` 那段,直接把 `popstate` 代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 `popstate` 行为,而非只是更新渲染组件这个动作,万一以后再有监听 `popstate` 的地方,你的触发逻辑就能很自然的应用到那儿。 |
| 117 | +- 尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 `Link` 的实现是基于 `<a>` 标签拓展的,如果采用自定义 `<span>` 标签,不仅要补齐样式上的差异,还要自己实现 `ctrl` 后打开新 tab 的行为,甚至 `<a>` 默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事半功倍,甚至无法实现。 |
| 118 | + |
| 119 | +> 讨论地址是:[精读《react-snippets - Router 源码》· Issue #418 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/418) |
| 120 | +
|
| 121 | +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** |
| 122 | + |
| 123 | +> 关注 **前端精读微信公众号** |
| 124 | +
|
| 125 | +<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> |
| 126 | + |
| 127 | +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |
| 128 | +
|
| 129 | + |
0 commit comments