你应该考虑放弃 react-router 的数据路由模式,改而使用更加适合国内版本的封装版本(包含完整可 CV 的模版)
React 的路由库
React
只有核心是官方的,Nextjs
可以算半个官方的。这会造成什么问题呢?
那就是使用上的撕裂感,和各种相关库的野蛮生长。好处是有了更多的选择,坏处是实现上可能存在冗余,以及会对很多编码能力不强的同学造成很大的困扰
能叫上名的 React
路由是有好几个的,但我们最熟悉,国内用的最普遍的是 react-router
,它现在整合了 remixjs
的路由包,对外的名称叫 react-router-dom
,相当于三个库的结合体,也是本篇文章讲解的对象
数据路由
数据路由被叫做 data-router
,核心功能都是从 remix-router
里引进来,它带来了许多的新特性,例如懒加载,最快的数据加载 loader 机制
,form-action
的交互模式等等
这里我们比较喜欢的是,如下开箱即用的功能
- 懒加载
- 返回路由匹配项 ------ 通过 hooks 函数直接获取到进入的是哪些路由,对于鉴权之类的功能,很有帮助
- Loader 机制 ------ 可以让进入的路由项并发的执行一个函数,通用用于数据加载的提速
- Promise 的组件化使用
- 围绕路由项周边的辅助功能,比如路由进入异常可以给个友好提示
这还只是个别功能,其他强大的功能也不少。看似美好的表面藏着的,却是国内项目很难用到有甩不掉的包袱,即它提供的新功能我们可能只能用到一小部分,加上也许会用到的撑破天应该都不足一半,如果只是盲目的按照官方推荐方式使用,那就得把另一半用不到的内容也带上
为了那一点点 easy 的功能,却会使得你的应用体积暴增
显然这是不划算的,我们只需要小小的封装也可以做到类似事情,本篇文章都会给你讲到
至于为什么我们用不上官方还推呢?也许国外的大佬们更喜欢吧
打包数据对比
图片太多我这里只把我测试的,数据、方式、结果,简单的贴出来,感兴趣的可以自己尝试
测试步骤为
- 使用 vite 创建一个 ts-react 应用
- 安装
react-router-dom@^6.22.2
,这是测试时能安装到的最新版本 - 创建 3 个子路由,代码在下边
- 同样的功能分别用,数据路由和非数据路由的方式实现
- Dev 测试切换无报错,然后打包对比体积
子路由代码
tsx
//类似于这样的代码,写三个,作为三个页面
export const Home = () => {
return <div>
<h1>home</h1>
</div>
}
数据路由
tsx
// 文件路径 src/DataRouter.tsx
import { createBrowserRouter } from "react-router-dom"
import { Home } from "./pages/Home"
import { Page1 } from "./pages/Page1"
import { Page2 } from "./pages/Page2"
import { App } from "./App"
// App 组件是全局的根组件
export const DataRouter = createBrowserRouter([
{
path: "/",
element: (
<App>
<Home />
</App>
)
},
{
path: "page1",
element: (
<App>
<Page1 />
</App>
)
},
{
path: "page2",
loader: () => 1,
element: (
<App>
<Page2 />
</App>
)
}
])
// App.tsx
import { FC, ReactNode } from "react"
import { Link } from "react-router-dom"
export const App: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div>
<div className="nav" style={{ display: "flex", gap: "1rem" }}>
<Link to="/">to home</Link>
<Link to="/page1">to page1</Link>
<Link to="/page2">to page2</Link>
</div>
<hr />
{children}
</div>
)
}
// 入口文件 main.ts
import React from "react"
import ReactDOM from "react-dom/client"
import { DataRouter } from "./DataRouter.tsx"
import { RouterProvider } from "react-router-dom"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={DataRouter} />
</React.StrictMode>
)
一般写法,非数据路由
tsx
//App.tsx 和上边一样
//main.tsx
import ReactDOM from "react-dom/client"
import { App } from "./App.tsx"
import { BrowserRouter, useRoutes } from "react-router-dom"
import { Home } from "./pages/Home.tsx"
import { Page1 } from "./pages/Page1.tsx"
import { Page2 } from "./pages/Page2.tsx"
import React from "react"
const AppRoutes = () => {
return useRoutes([
{
path: "/",
element: <Home />
},
{
path: "/page1",
element: <Page1 />
},
{
path: "/page2",
element: <Page2 />
}
])
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App>
<AppRoutes />
</App>
</BrowserRouter>
</React.StrictMode>
)
打包结对对比
会发现只是单纯写了个路由,什么都没干,体积膨胀了 36.13kb
如果继续使用相关的 hooks
体积还会继续膨胀
演示中 App
组件是用来做全局包裹的组件,这里可以给一些公共的样式,依赖注入一些数据等,几乎是必备的,会发现数据路由下用起来很麻烦
如果你可以接受那就可以关闭这篇文章啦~
ts
//数据路由
dist/index.html 0.40 kB │ gzip: 0.27 kB
dist/assets/index-98d03929.js 162.97 kB │ gzip: 53.17 kB
✓ built in 961ms
//一般写法
dist/index.html 0.40 kB │ gzip: 0.27 kB
dist/assets/index-2708b45c.js 199.10 kB │ gzip: 64.82 kB
✓ built in 991ms
整理下路由需要哪些功能
这里拿 vue-router
作对比,它足够的成熟,适用范围足够的广,这是全球开发者共同印证的事实
直接说结论,为了满足我们日常所需以下功能几乎是必备的
- 懒加载
- 生命周期/路由守卫
- 提供对外做动画的出口
- 组件内得能获取到路由的信息,越多越好
对于不适用数据路由的 react-router
来说
1,2 不提供,3,4 支持,但 4 刻意做的很有限(和 vue-router 比起来提供的信息少很多很多)
我们的目标也很明确,把 1,2 自己手动实现即可
至于其他的都是些锦上添花的功能,比如把 Promise
组件化之类的
懒加载
惰性加载分为两步
- 惰性路由的代码单独打包,这是打包工具都具备的基本能力,只要用
import()
引入就会生效 - 返回的结果是
Promise
,框架得能解析,或者能根据开发者自定义的返回,正常运行
React. Lazy
是官方提供的,用它做个简单就够了
tsx
const LazyLoad = (loader: () => Promise<any>) => {
Const Element = lazy (loader)
Return () => (
<Suspense>
<Element />
</Suspense>
)
}
使用也很简单
tsx
LazyLoad (() => import ("./pages/Home"))
生命周期 / 路由守卫
生命周期想要实现的完整会非常的复杂,结合我们经常使用的场景来说,只有全局进入
是刚需,即当进入页面时,只有当符合要求了才能正常的加载显示页面,否则就给重定向到别的地方
路由也是由一个个 React
组件组成的,所以符合一般组件的加载顺序,即从上到下,一层一层顺序创建,我们想做个卡点其实相当于我们需要给每个路由创建一个父组件,动态的判断是否要显示真正的路由组件
这里有个技巧,就是我们没有必要给每个路由都创建个父组件,只需要给每条路由的那个祖先组件创建一个就行了,例如
tsx
<Route name="祖先1" element={<Comp />}>
<Route name="爷爷">...</Route>
</Route>
<Route name="祖先2" element={<Comp />}>
<Route name="爷爷">...</Route>
</Route>
这是伪代码,里边每个最外层的就是组件组件,然后它可以嵌套,感觉会像链子一样
给祖先组件创建组件,是可以使用 useLocation
钩子的,有了它就能知道进来的 patname
路由路径,即便是深层次的子路由路径变化,也是能触发的。所以给祖先组件套一层,就能监控到所有的路由变化
我们可以简单的封装出以下结构
tsx
Export interface GuardRouteProps {
Element: FC<any>
}
export const GuardRoute: FC<GuardRouteProps> = ({ Element }) => {
Const { pathname } = useLocation ()
const [RouteComp, setRouteComp] = useState<FC>(() => null)
Const nav = useNavigate ()
UseEffect (() => {
If (RouteComp === Element) return
// todo... 路由守卫的校验逻辑
//通过 setRouteComp (Element)
//不通过 nav ("/login")
}, [])
return <Element />
}
获取路由信息
这样做有个问题
我们不知道哪个路由,是否应该用什么策略来干些不同的事,比如页面 A 和页面 B 的校验逻辑不同,只知道一个路由路径我们无法做差异化配置
这里就可以做个 map
备用,对于路由的定义我们写出以下代码
tsx
Type GRouteObject = RouteObject & {
meta?: Record<string, any>
}
Export const routes: GRouteObject[] = [
{
Path: "/",
element: <GuardRoute Element={LazyLoad(() => import ("./pages/Home"))} />
},
{
Path: "/page 1",
element: <GuardRoute Element={LazyLoad(() => import ("./pages/Page 1"))} />
},
{
Path: "/page 2",
element: <GuardRoute Element={LazyLoad(() => import ("./pages/Page 2"))} />,
}
]
Const mapRoutesToMap = (
Routes: GRouteObject[],
map: Map<string, GRouteObject>
) => {
Routes.ForEach (route => {
Map.Set (route. Path!, route)
If (route. Children) {
MapRoutesToMap (route. Children, map)
}
})
Return map
}
Export const routesMap = mapRoutesToMap (routes, new Map ())
吐槽:react-router-dom
的 ts
类型声明连个扩展都不给,还得自己定义,人家 Vue
直接给了个 meta
给用户使用
之所以吐槽是因为,我们开发者是不知道将来的某个版本中,我们使用的某个字段是否会被库作者使用,反之 vue-router
明确给了个 meta
字段就代表它只会被用于用户使用,就不会产生可能的意外冲突
这里我们扩展出一个 meta
字段用户写一些差异化的配置
制作这个 routesMap
并不会拉胯性能,它会递归的把嵌套路由拉成一级
它能帮助封装的路由守卫获取差异化配置,对于后台管理系统的左侧菜单也会有帮助
tsx
Export interface GuardRouteProps {
Element: FC<any>
}
export const GuardRoute: FC<GuardRouteProps> = ({ Element }) => {
Const { pathname } = useLocation ()
const [RouteComp, setRouteComp] = useState<FC>(() => null)
Const nav = useNavigate ()
//react-router-dom 会帮我们把 query/hash/多余的符号去掉,只要不是动态参数是一定能够匹配到的
Const { meta = {} } = routesMap.Get (pathname)
UseEffect (() => {
If (RouteComp === Element) return
// todo... 路由守卫的校验逻辑
//通过 setRouteComp (Element)
//不通过 nav ("/login")
})
return <Element />
}
完整代码
到此一个很普通的模版就做好了,我们规整下就可以 cv 直接用了
tsx
//router. Tsx
Import { FC, Suspense, lazy, useEffect, useState } from "react"
Import {
RouteObject,
UseLocation,
UseNavigate,
UseRoutes
} from "react-router-dom"
Export interface GuardRouteProps {
Element: FC<any>
}
export const GuardRoute: FC<GuardRouteProps> = ({ Element }) => {
Const { pathname } = useLocation ()
const [RouteComp, setRouteComp] = useState<FC>(() => null)
Const nav = useNavigate ()
//react-router-dom 会帮我们把 query/hash/多余的符号去掉,只要不是动态参数是一定能够匹配到的
Const { meta = {} } = routesMap.Get (pathname)
UseEffect (() => {
If (RouteComp === Element) return
// todo... 路由守卫的校验逻辑
//通过 setRouteComp (Element)
//不通过 nav ("/login")
})
return <Element />
}
const LazyLoad = (loader: () => Promise<any>) => {
Const Element = lazy (loader)
Return () => (
<Suspense>
<Element />
</Suspense>
)
}
Type GRouteObject = RouteObject & {
meta?: Record<string, any>
}
Export const routes: GRouteObject[] = [
{
Path: "/",
element: <GuardRoute Element={LazyLoad(() => import ("./pages/Home"))} />
},
{
Path: "/page 1",
element: <GuardRoute Element={LazyLoad(() => import ("./pages/Page 1"))} />
},
{
Path: "/page 2",
element: <GuardRoute Element={LazyLoad(() => import ("./pages/Page 2"))} />
}
]
Export const useRouterRoutes = () => routes
Const mapRoutesToMap = (
Routes: GRouteObject[],
map: Map<string, GRouteObject>
) => {
Routes.ForEach (route => {
Map.Set (route. Path!, route)
If (route. Children) {
MapRoutesToMap (route. Children, map)
}
})
Return map
}
Export const routesMap = mapRoutesToMap (routes, new Map ())
Export const useRoutesMap = () => routesMap
Export const AppRoutes = () => {
Return useRoutes (routes)
}
// main. Tsx
Import ReactDOM from "react-dom/client"
Import { App } from "./App. Tsx"
Import { BrowserRouter } from "react-router-dom"
Import { AppRoutes } from "./router. Tsx"
ReactDOM.CreateRoot (document.GetElementById ("root")!). Render (
<BrowserRouter>
<App>
<AppRoutes />
</App>
</BrowserRouter>
)
其他功能 ------ 动态参数,SSR,Loader
动态参数目前实现不支持,尽量用 query
参数代替即可,如果需要可以做以下扩展
tsx
export const GuardRoute: FC<GuardRouteProps> = ({ Element }) => {
Const { pathname } = useLocation ()
const [RouteComp, setRouteComp] = useState<FC>(() => null)
Const nav = useNavigate ()
//react-router-dom 会帮我们把 query/hash/多余的符号去掉,只要不是动态参数是一定能够匹配到的
Let route: GRouteObject | undefined = routesMap.Get (pathname)
If (! Route) {
Const path = Array.From (routesMap.Keys ()). Find (path => !!UseMatch (path))
If (! Path) {
// 说明没有一个兜底的,自己看着办
Throw "说明没有一个兜底的,自己看着办"
}
Route = routesMap.Get (path)
}
UseEffect (() => {
If (RouteComp === Element) return
// todo... 路由守卫的校验逻辑
//通过 setRouteComp (Element)
//不通过 nav ("/login")
})
return <Element />
}
useMatch
会拿我们自己的路径,经过编译再和实际的路由路径对比,它可以匹配动态路径参数
如果你能理解并且能靠自己手写出来这个结构,那么你可以尝试看看我的另一篇文章,尝试自己实现个 loader
机制
这里只是个半成品代码,因为存在匹配顺序的问题,觉得复杂的话就用 query 代替所有动态路径即可,这是百分百可以轻松做到的
标题是:如何设计并实现一个所谓"全网最快的路由数据加载 loader" ------ vue/react
总结
本文给出的模版直接 CV
就足够日常所需了,扩展起来也容易
因为自己的封装中没有用到数据路由相关的 api,经过打包工具 treeshaking
后就不会携带这些死代码,最后给出完整的打包对比
bash
/*
Dist/index. Html 0.40 kB │ gzip: 0.27 kB
Dist/assets/index-98 d 03929. Js 162.97 kB │ gzip: 53.17 kB
✓ built in 961 ms
Dist/index. Html 0.40 kB │ gzip: 0.27 kB
Dist/assets/index-2708 b 45 c. Js 199.10 kB │ gzip: 64.82 kB
✓ built in 991 ms
Dist/index. Html 0.40 kB │ gzip: 0.27 kB
Dist/assets/Error-0 cc 7 b 8 e 1. Js 0.11 kB │ gzip: 0.12 kB
Dist/assets/Page 1-3 e 256 cac. Js 0.12 kB │ gzip: 0.12 kB
Dist/assets/Page 2-319 ed 62 d. Js 0.14 kB │ gzip: 0.14 kB
Dist/assets/Home-e 8 de 57 d 5. Js 0.16 kB │ gzip: 0.15 kB
Dist/assets/index-8 de 41 b 2 e. Js 164.70 kB │ gzip: 53.93 kB
✓ built in 1.00 s
*/
最下边是经过自己封装后打包的体积,体积碰撞的范围非常有限,和使用了数据路由后的代码对比还是相当明显的
如果细化的话,我们封装的代码其实连 1kb
都没,ts
代码不会打包进去,膨胀的代码主要来源于项目代码+ react router
相关钩子的代码