不到 100 行代码,实现 React Router 核心逻辑
The following article is from 前端精读评论 Author 黄子毅
造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,Router 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。
精读
Router 快速实现了 React Router 3 个核心 API:Router
、navigate
、Link
,下面列出基本用法,配合理解源码实现会更方便:
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,即 Router
套 Router
后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。
下面该实现 navigate
Link
了,他俩做的事情都是跳转,有如下区别:
API 调用方式不同, navigate
是调用式函数,而Link
是一个内置navigate
能力的a
标签。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
就很简单了,有几个考虑点:
返回一个正常的 <a>
标签。因为正常 <a>
点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的navigate
(源码里没做这个抽象,笔者稍微优化了下)。但按住 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>
标签默认行为,又能在点击时优化为单页跳转,里面对 preventDefault
与 metaKey
的判断值得学习。
总结
从这个小轮子中可以学习到一下几个经验:
造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 pushState
无法触发popstate
那段,直接把popstate
代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发popstate
行为,而非只是更新渲染组件这个动作,万一以后再有监听popstate
的地方,你的触发逻辑就能很自然的应用到那儿。尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 Link
的实现是基于<a>
标签拓展的,如果采用自定义<span>
标签,不仅要补齐样式上的差异,还要自己实现ctrl
后打开新 tab 的行为,甚至<a>
默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事半功倍,甚至无法实现。
讨论地址是:精读《react-snippets - Router 源码》· Issue #418 · dt-fe/weekly