以下文章来源于前端精读评论 ,作者黄子毅
阿里数据中台前端团队分享前端界的好文精读——帮你筛选靠谱的内容。
造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,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
要做哪些事情?
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
了,他俩做的事情都是跳转,有如下区别:
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
的判断值得学习。
从这个小轮子中可以学习到一下几个经验:
pushState
无法触发 popstate
那段,直接把 popstate
代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 popstate
行为,而非只是更新渲染组件这个动作,万一以后再有监听 popstate
的地方,你的触发逻辑就能很自然的应用到那儿。Link
的实现是基于 <a>
标签拓展的,如果采用自定义 <span>
标签,不仅要补齐样式上的差异,还要自己实现 ctrl
后打开新 tab 的行为,甚至 <a>
默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事半功倍,甚至无法实现。讨论地址是:精读《react-snippets - Router 源码》· Issue #418 · dt-fe/weekly