TanStack Router 的設計目標是在 TypeScript 編譯器和運行時的限制下,盡可能實現型別安全。這意味著它不僅是用 TypeScript 編寫的,還會完全推斷提供的型別,並將其貫穿於整個路由體驗中。
最終,這代表開發者需要編寫的型別更少,並且隨著程式碼演進,能對其擁有更高的信心。
路由是分層的,其定義也是如此。如果你使用基於檔案的路由,大部分型別安全已經為你處理好了。
如果你直接使用 Route 類別,則需要注意如何透過 Route 的 getParentRoute 選項確保路由被正確賦予型別。這是因為子路由需要知道所有父路由的型別。若沒有這樣做,那些從上三層的 layout 和 pathless layout 路由解析出來的寶貴搜尋參數 (search params) 就會消失在 JS 的虛空中。
所以,別忘了將父路由傳遞給子路由!
const parentRoute = createRoute({
getParentRoute: () => parentRoute,
})
const parentRoute = createRoute({
getParentRoute: () => parentRoute,
})
為了讓路由器的型別能與頂層匯出如 Link、useNavigate、useParams 等一起運作,這些型別必須滲透 TypeScript 模組邊界並直接註冊到函式庫中。為此,我們在匯出的 Register 介面上使用宣告合併 (declaration merging)。
const router = createRouter({
// ...
})
declare module '@tanstack/solid-router' {
interface Register {
router: typeof router
}
}
const router = createRouter({
// ...
})
declare module '@tanstack/solid-router' {
interface Register {
router: typeof router
}
}
透過將路由器註冊到模組中,你現在可以使用匯出的鉤子、元件和工具,並享有路由器確切的型別。
元件上下文 (Component context) 是 React 和其他框架中用於向元件提供依賴項的強大工具。然而,如果該上下文在元件層級結構中移動時改變型別,TypeScript 將無法推斷這些變化。為了解決這個問題,基於上下文的鉤子和元件要求你提供提示,說明它們的使用方式和位置。
export const Route = createFileRoute('/posts')({
component: PostsComponent,
})
function PostsComponent() {
// 每個路由都有 TanStack Router 中大多數內建鉤子的型別安全版本
const params = Route.useParams()
const search = Route.useSearch()
// 有些鉤子需要來自*整個*路由器的上下文,而不僅是當前路由。為了實現型別安全,
// 我們必須傳遞 `from` 參數來告訴鉤子我們在路由層級結構中的相對位置。
const navigate = useNavigate({ from: Route.fullPath })
// ... 等等
}
export const Route = createFileRoute('/posts')({
component: PostsComponent,
})
function PostsComponent() {
// 每個路由都有 TanStack Router 中大多數內建鉤子的型別安全版本
const params = Route.useParams()
const search = Route.useSearch()
// 有些鉤子需要來自*整個*路由器的上下文,而不僅是當前路由。為了實現型別安全,
// 我們必須傳遞 `from` 參數來告訴鉤子我們在路由層級結構中的相對位置。
const navigate = useNavigate({ from: Route.fullPath })
// ... 等等
}
每個需要上下文提示的鉤子和元件都會有一個 from 參數,你可以在其中傳遞你正在渲染的路由的 ID 或路徑。
🧠 小技巧:如果你的元件是程式碼分割 (code-split) 的,可以使用 getRouteApi 函式 來避免傳遞 Route.fullPath 以取得型別安全的 useParams() 和 useSearch() 鉤子。
from 屬性是可選的,這意味著如果你不傳遞它,路由器會根據可用的型別給出最佳猜測。通常,這代表你會得到路由器中所有路由型別的聯集 (union)。
從技術上講,有可能傳遞一個滿足 TypeScript 的 from,但在運行時可能與你實際渲染的路徑不匹配。在這種情況下,每個支援 from 的鉤子和元件都會檢測你的預期是否與實際渲染的路由不匹配,並拋出運行時錯誤。
如果你正在渲染一個跨多個路由共享的元件,或者正在渲染一個不在路由內的元件,可以傳遞 strict: false 而不是 from 選項。這不僅會消除運行時錯誤,還會為你呼叫的鉤子提供寬鬆但準確的型別。一個很好的例子是從共享元件呼叫 useSearch:
function MyComponent() {
const search = useSearch({ strict: false })
}
function MyComponent() {
const search = useSearch({ strict: false })
}
在這種情況下,search 變數會被賦予路由器中所有路由可能的搜尋參數的聯集型別。
路由器上下文極其有用,因為它是最終的分層依賴注入 (dependency injection)。你可以向路由器及其渲染的每個路由提供上下文。隨著你建立這個上下文,TanStack Router 會將其與路由的層級結構合併,使每個路由都能存取其所有父路由的上下文。
createRootRouteWithContext 工廠函式會建立一個帶有實例化型別的新路由器,這會要求你向路由器履行相同的型別合約,並確保你的上下文在整個路由樹中正確賦予型別。
const rootRoute = createRootRouteWithContext<{ whateverYouWant: true }>()({
component: App,
})
const routeTree = rootRoute.addChildren([
// ... 所有子路由都能在上下文中存取 `whateverYouWant`
])
const router = createRouter({
routeTree,
context: {
// 現在必須傳遞這個
whateverYouWant: true,
},
})
const rootRoute = createRootRouteWithContext<{ whateverYouWant: true }>()({
component: App,
})
const routeTree = rootRoute.addChildren([
// ... 所有子路由都能在上下文中存取 `whateverYouWant`
])
const router = createRouter({
routeTree,
context: {
// 現在必須傳遞這個
whateverYouWant: true,
},
})
隨著應用程式規模擴大,TypeScript 檢查時間自然會增加。當應用程式規模擴大時,有幾點需要注意以保持 TS 檢查時間在可控範圍內。
客戶端資料快取 (client side data caches)(如 TanStack Query 等)的一個好模式是預取資料。例如,在 TanStack Query 中,你可能有一個路由在 loader 中呼叫 queryClient.ensureQueryData。
export const Route = createFileRoute('/posts/$postId/deep')({
loader: ({ context: { queryClient }, params: { postId } }) =>
queryClient.ensureQueryData(postQueryOptions(postId)),
component: PostDeepComponent,
})
function PostDeepComponent() {
const params = Route.useParams()
const data = useSuspenseQuery(postQueryOptions(params.postId))
return <></>
}
export const Route = createFileRoute('/posts/$postId/deep')({
loader: ({ context: { queryClient }, params: { postId } }) =>
queryClient.ensureQueryData(postQueryOptions(postId)),
component: PostDeepComponent,
})
function PostDeepComponent() {
const params = Route.useParams()
const data = useSuspenseQuery(postQueryOptions(params.postId))
return <></>
}
這看起來可能沒問題,對於小型路由樹,你可能不會注意到任何 TS 效能問題。然而在這種情況下,TS 必須推斷 loader 的返回型別,儘管它在你的路由中從未被使用。如果 loader 資料是一個複雜型別,並且有許多路由以這種方式預取,可能會降低編輯器效能。在這種情況下,改變很簡單,讓 TypeScript 推斷 Promise<void>。
export const Route = createFileRoute('/posts/$postId/deep')({
loader: async ({ context: { queryClient }, params: { postId } }) => {
await queryClient.ensureQueryData(postQueryOptions(postId))
},
component: PostDeepComponent,
})
function PostDeepComponent() {
const params = Route.useParams()
const data = useSuspenseQuery(postQueryOptions(params.postId))
return <></>
}
export const Route = createFileRoute('/posts/$postId/deep')({
loader: async ({ context: { queryClient }, params: { postId } }) => {
await queryClient.ensureQueryData(postQueryOptions(postId))
},
component: PostDeepComponent,
})
function PostDeepComponent() {
const params = Route.useParams()
const data = useSuspenseQuery(postQueryOptions(params.postId))
return <></>
}
這樣 loader 資料永遠不會被推斷,並將推斷移出路由樹,直到你第一次使用 useSuspenseQuery。
考慮以下 Link 的使用方式:
<Link to=".." search={{ page: 0 }} />
<Link to="." search={{ page: 0 }} />
<Link to=".." search={{ page: 0 }} />
<Link to="." search={{ page: 0 }} />
這些例子對 TS 效能不利。這是因為 search 解析為所有路由的所有 search 參數的聯集,TS 必須檢查你傳遞給 search 屬性的內容是否符合這個可能很大的聯集。隨著應用程式增長,這個檢查時間將隨著路由和搜尋參數的數量線性增加。我們已經盡力優化這種情況(TypeScript 通常會執行此工作一次並快取它),但對這個大型聯集的初始檢查仍然很耗時。這也適用於 params 和其他 API,如 useSearch、useParams、useNavigate 等。
相反,你應該嘗試透過 from 或 to 縮小到相關路由。
<Link from={Route.fullPath} to=".." search={{page: 0}} />
<Link from="/posts" to=".." search={{page: 0}} />
<Link from={Route.fullPath} to=".." search={{page: 0}} />
<Link from="/posts" to=".." search={{page: 0}} />
記住,你總是可以傳遞一個聯集給 to 或 from 來縮小你感興趣的路由範圍。
const from: '/posts/$postId/deep' | '/posts/' = '/posts/'
<Link from={from} to='..' />
const from: '/posts/$postId/deep' | '/posts/' = '/posts/'
<Link from={from} to='..' />
你也可以傳遞分支給 from,僅解析 search 或 params 來自該分支的任何子路由:
const from = '/posts'
<Link from={from} to='..' />
const from = '/posts'
<Link from={from} to='..' />
/posts 可能是一個分支,擁有許多共享相同 search 或 params 的子路由。
路由通常具有 params、search、loaders 或 context,甚至可以引用外部依賴項,這些對 TS 推斷來說也很耗時。對於這類應用程式,使用物件建立路由樹比元組 (tuples) 更高效。
createChildren 也可以接受物件。對於具有複雜路由和外部函式庫的大型路由樹,物件在 TS 型別檢查上比大型元組快得多。效能提升取決於你的專案、外部依賴項以及這些函式庫型別的寫法。
const routeTree = rootRoute.addChildren({
postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }),
indexRoute,
})
const routeTree = rootRoute.addChildren({
postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }),
indexRoute,
})
注意這種語法更冗長,但有更好的 TS 效能。在基於檔案的路由中,路由樹是自動生成的,因此冗長的路由樹不是問題。
你可能會想重複使用暴露的型別。例如,你可能會想這樣使用 LinkProps:
const props: LinkProps = {
to: '/posts/',
}
return (
<Link {...props}>
)
const props: LinkProps = {
to: '/posts/',
}
return (
<Link {...props}>
)
這對 TS 效能非常不利。問題在於 LinkProps 沒有型別參數,因此是一個非常大的型別。它包含 search,這是所有 search 參數的聯集;它包含 params,這是所有 params 的聯集。當將這個物件與 Link 合併時,會對這個巨大型別進行結構比較。
相反,你可以使用 as const satisfies 來推斷一個精確的型別,而不是直接使用 LinkProps 以避免巨大的檢查。
const props = {
to: '/posts/',
} as const satisfies LinkProps
return (
<Link {...props}>
)
const props = {
to: '/posts/',
} as const satisfies LinkProps
return (
<Link {...props}>
)
由於 props 不是 LinkProps 型別,因此這個檢查更便宜,因為型別更精確。你也可以透過縮小 LinkProps 進一步改善型別檢查。
const props = {
to: '/posts/',
} as const satisfies LinkProps<RegisteredRouter, string '/posts/'>
return (
<Link {...props}>
)
const props = {
to: '/posts/',
} as const satisfies LinkProps<RegisteredRouter, string '/posts/'>
return (
<Link {...props}>
)
這甚至更快,因為我們檢查的是縮小後的 LinkProps 型別。
你也可以使用這個方法將 LinkProps 縮小到特定型別,作為函式的屬性或參數使用。
export const myLinkProps = [
{
to: '/posts',
},
{
to: '/posts/$postId',
params: { postId: 'postId' },
},
] as const satisfies ReadonlyArray<LinkProps>
export type MyLinkProps = (typeof myLinkProps)[number]
const MyComponent = (props: { linkProps: MyLinkProps }) => {
return <Link {...props.linkProps} />
}
export const myLinkProps = [
{
to: '/posts',
},
{
to: '/posts/$postId',
params: { postId: 'postId' },
},
] as const satisfies ReadonlyArray<LinkProps>
export type MyLinkProps = (typeof myLinkProps)[number]
const MyComponent = (props: { linkProps: MyLinkProps }) => {
return <Link {...props.linkProps} />
}
這比在元件中直接使用 LinkProps 更快,因為 MyLinkProps 是一個更精確的型別。
另一個解決方案是不使用 LinkProps,而是提供反轉控制 (inversion of control) 來渲染一個縮小到特定路由的 Link 元件。渲染屬性 (render props) 是反轉控制給元件使用者的好方法。
export interface MyComponentProps {
readonly renderLink: () => React.ReactNode
}
const MyComponent = (props: MyComponentProps) => {
return <div>{props.renderLink()}</div>
}
const Page = () => {
return <MyComponent renderLink={() => <Link to="/absolute" />} />
}
export interface MyComponentProps {
readonly renderLink: () => React.ReactNode
}
const MyComponent = (props: MyComponentProps) => {
return <div>{props.renderLink()}</div>
}
const Page = () => {
return <MyComponent renderLink={() => <Link to="/absolute" />} />
}
這個特定例子非常快,因為我們已經將導航的控制反轉給元件的使用者。Link 被縮小到我們想要導航的確切路由。
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.