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/forward 是 go 方法的语法糖,可以往前或往后切换栈顶。还有一个回调事件,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 为这个元素注入 onClick 和 href 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 组件就开发完成了!
可以看到虽然代码很短,但实际上知识点并不少,我在写这个 Demo 的过程中学习和巩固了很多知识点,示例代码看这里 codesandbox。
本文中出现的所有代码均参考自开源组件 wouter github,是我在阅读源码之后自己尝试手写的阉割版本,仅可作为学习和了解 Router 原理使用。 wouter 本身代码也很简洁,支持服务端渲染,支持 preact,gzip 打包后体积仅 1.36KB,感兴趣的同学可以直接阅读其源码。