从头构建一个 react-router 克隆
React Router 是我几乎所有项目都会用到的一个包。不久前
,迈克尔·杰克逊发了一条推文。这让我很好奇,从头开始重新构建 React Router 到底有多难。
在深入探讨之前,我想先澄清一下,如果你的项目需要路由器,你应该直接使用 React Router。它功能更丰富,能处理更多边缘情况,而且测试非常完善。这纯粹是一次学习练习。
在这篇文章中,我们将构建一个基于较新的 v6 API 的简化版本的 react-router 。
React Router 的核心是一个名为“history”的包。这个包负责管理路由器的历史记录。在本文中,我们只关注如何为 Web 创建一个路由器,因此我们将把它直接嵌入到 React 组件中。我们首先需要一个根Router
组件和一个供其他组件使用的上下文。让我们从上下文开始。
我们的路由器将比 React Router 更加简化,因为我们将不再支持位置状态、哈希值以及 React Router 提供的其他情况。我们的路由器上下文将提供两个键:location 和 push:
- 位置只是当前路径的字符串。
- push 是一个可以调用来改变当前路径的函数。
这样我们就可以创建我们的基本路由器上下文。
const RouterContext = React.createContext({
location: "",
push: () => {},
});
如果没有渲染提供程序,此上下文将毫无用处。我们将在主Router
组件内部实现此功能。此组件的职责是提供当前路由的相关信息,并提供操作方法。我们将在 React 状态中存储当前位置路径。这样,当我们更新位置时,组件将重新渲染。我们还需要push
为上下文提供一个函数,该函数将简单地更新浏览器位置并更新我们的位置状态。最后,我们还监听窗口的“popstate”事件,以便在使用浏览器导航按钮时更新我们的位置。
function Router({ children }) {
const [location, setLocation] = React.useState(window.location.pathname);
const handlePush = useCallback(
(newLocation) => {
window.history.pushState({}, "", newLocation);
setLocation(newLocation);
},
[]
);
const handleHashChange = useCallback(() => {
setLocation(window.location.pathname);
}, []);
useEffect(() => {
window.addEventListener("popstate", handleHashChange);
return () => window.removeEventListener("popstate", handleHashChange);
}, [handleHashChange]);
const value = useMemo(() => {
return { location, push: handlePush }
}, [location, handlePush])
return (
<RouterContext.Provider value={value}>
{children}
</RouterContext.Provider>
);
}
为了测试我们的组件,我们需要一种方法来更新当前路由,以检查组件是否渲染正确。让我们Link
为此创建一个组件。我们的链接组件只需接受to
新路径作为参数,并push
在点击时从路由器上下文中调用我们的函数。
function Link({ to, children }) {
const { push } = React.useContext(RouterContext);
function handleClick(e) {
e.preventDefault();
push(to);
}
return (
<a href={to} onClick={handleClick}>
{children}
</a>
);
}
现在我们有了导航的方法,接下来我们需要一个方法来渲染一些路由!让我们创建一个Routes
andRoute
组件来处理这个问题。我们从Route
组件开始,因为它需要做的就是渲染我们传入的子元素。
function Route({ children }) {
return children;
}
接下来我们需要Routes
组件。我们需要遍历路由组件,找到一个与当前位置匹配的组件。我们还需要在路由上下文中渲染匹配的路由,以便路由子节点可以访问路径中匹配的任何参数。首先,我们需要创建匹配路由所需的函数。首先,我们需要一个函数,它接收路由的 path 属性,并将其转换为正则表达式,以便我们匹配当前位置。
function compilePath(path) {
const keys = [];
path = path.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return "([^\\/]+)";
});
const source = `^(${path})`;
const regex = new RegExp(source, "i");
return { regex, keys };
}
这还将为我们提供一个表示路径模式中任何参数的键数组。
compilePath("/posts/:id");
// => { regex: /^(/posts/([^\/]+))/i, keys: ["id"] }
接下来我们需要一个新函数,它将遍历每个子路由并使用该compilePath
函数测试它是否与当前位置匹配,同时提取任何匹配的参数。
function matchRoutes(children, location) {
const matches = [];
React.Children.forEach(children, (route) => {
const { regex, keys } = compilePath(route.props.path);
const match = location.match(regex);
if (match) {
const params = match.slice(2);
matches.push({
route: route.props.children,
params: keys.reduce((collection, param, index) => {
collection[param] = params[index];
return collection;
}, {}),
});
}
});
return matches[0];
}
最后,我们可以创建一个新的RouteContext
路由组件并将其组合起来。我们将提供的子组件传递给matchRoutes
函数,以查找匹配的路由,并将其渲染到路由上下文的提供程序中。
const RouteContext = React.createContext({
params: {},
});
function Routes({ children }) {
const { location } = useContext(RouterContext);
const match = useMemo(() => matchRoutes(children, location), [
children,
location,
]);
const value = useMemo(() => {
return { params: match.params }
}, [match])
// if no routes matched then render null
if (!match) return null;
return (
<RouteContext.Provider value={value}>
{match.route}
</RouteContext.Provider>
);
}
至此,我们实际上已经拥有了一个可以正常运行的路由器,然而,我们还缺少一个虽小却至关重要的部分。每个优秀的路由器都需要一种从 URL 中提取参数的方法。借助我们的钩子,RouteContext
我们可以轻松地创建一个useParams
钩子,供我们的路由提取这些参数。
function useParams() {
return useContext(RouteContext).params;
}
有了这些,我们就有了自己的 React Router 的基本工作版本!
function Products() {
return (
<>
<h4>Example Products</h4>
<ul>
<li>
<Link to="/products/1">Product One</Link>
</li>
<li>
<Link to="/products/2">Product Two</Link>
</li>
</ul>
</>
);
}
function Product() {
const { id } = useParams();
return (
<>
<h4>Viewing product {id}</h4>
<Link to="/">Back to all products</Link>
</>
);
}
function App() {
return (
<Router>
<Routes>
<Route path="/products/:id">
<Product />
</Route>
<Route path="/">
<Products />
</Route>
</Routes>
</Router>
);
}