paint-brush
Cách viết Bộ định tuyến React Typesafe của riêng bạn trong 500 dòngtừ tác giả@olegwock
395 lượt đọc
395 lượt đọc

Cách viết Bộ định tuyến React Typesafe của riêng bạn trong 500 dòng

từ tác giả OlegWock39m2024/03/11
Read on Terminal Reader

dài quá đọc không nổi

Vì vậy, hãy tham gia cùng tôi trong bài đăng này, nơi chúng ta sẽ xây dựng bộ định tuyến an toàn kiểu của riêng mình từ đầu để mở hộp đen đó và hiểu hoạt động bên trong của nó. Bài viết này giả định rằng bạn đã biết React và cảm thấy thoải mái với TypeScript.
featured image - Cách viết Bộ định tuyến React Typesafe của riêng bạn trong 500 dòng
OlegWock HackerNoon profile picture
0-item

Khi tôi học React khoảng 6 năm trước, bộ định tuyến phản ứng là một trong những thư viện bên thứ ba đầu tiên tôi chọn. Ý tôi là, điều này rất hợp lý: định tuyến là một trong những khía cạnh quan trọng của Ứng dụng Trang Đơn hiện đại. Tôi đã nhận nó cho các cấp.


Đối với tôi, bộ định tuyến phản ứng là một chiếc hộp ma thuật, tôi không biết nó hoạt động bên trong như thế nào. Vì vậy, sau một thời gian, tôi tự nhiên hiểu ra cách hoạt động của định tuyến nói chung, điều đó hơi buồn. Không còn phép thuật nữa à :(


Nhưng tôi rất vui vì đã bỏ lại khái niệm "hộp đen ma thuật" đó. Tôi nghĩ đó là một ý tưởng thực sự có hại. Hiểu rằng mọi công nghệ đều được tạo ra bởi các kỹ sư, giống như bạn và tôi, truyền cảm hứng cho tôi rất nhiều và tôi hy vọng điều đó cũng có tác dụng tương tự với bạn.


Vì vậy, hãy tham gia cùng tôi trong bài đăng này, nơi chúng ta sẽ xây dựng bộ định tuyến an toàn kiểu của riêng mình từ đầu để mở hộp đen đó và hiểu hoạt động bên trong của nó. Bài viết này giả định rằng bạn đã biết React và cảm thấy thoải mái với TypeScript.

Tính năng và hạn chế

Hãy để tôi phác thảo những tính năng nào của bộ định tuyến của chúng tôi sẽ được đề cập trong bài viết này.


Tính năng nổi bật của chúng tôi sẽ là an toàn về kiểu chữ. Điều này có nghĩa là bạn sẽ cần xác định trước các tuyến đường của mình và TypeScript sẽ kiểm tra xem bạn không chuyển một số điều vô nghĩa thay vì URL hay cố gắng lấy các tham số bị thiếu trong tuyến đường hiện tại. Điều này sẽ yêu cầu một số loại hình thể dục dụng cụ, nhưng đừng lo lắng, tôi sẽ hướng dẫn bạn thực hiện.


Ngoài ra, bộ định tuyến của chúng tôi sẽ hỗ trợ mọi thứ bạn mong đợi từ một bộ định tuyến thông thường: điều hướng đến URL, khớp tuyến đường, phân tích thông số tuyến đường, điều hướng lùi/tiến và chặn điều hướng.


Bây giờ, về những hạn chế. Bộ định tuyến của chúng tôi sẽ chỉ hoạt động trong trình duyệt (xin lỗi React Native!) và nó sẽ không hỗ trợ SRR. Hỗ trợ SSR có lẽ tương đối dễ dàng, nhưng bài đăng này đã rất lớn rồi nên tôi sẽ không đề cập đến nó.

Thuật ngữ

Bây giờ chúng ta đã có tầm nhìn về những gì chúng ta muốn thực hiện, chúng ta cần nói về cấu trúc và thuật ngữ. Sẽ có khá nhiều khái niệm tương tự nhau nên việc xác định trước chúng là rất quan trọng.


Sẽ có hai loại tuyến đường trong thư viện của chúng tôi: tuyến thô và tuyến được phân tích cú pháp. Tuyến đường thô chỉ là một chuỗi trông giống như /user/:id/info hoặc /login ; đó là một mẫu cho các URL. Tuyến đường thô có thể chứa các tham số, là các phần bắt đầu bằng dấu hai chấm, như :id .


Định dạng này dễ sử dụng đối với các nhà phát triển nhưng lại không dễ sử dụng đối với một chương trình; chúng tôi sẽ chuyển đổi các tuyến đường đó sang định dạng thân thiện với máy hơn và gọi đó là tuyến đường được phân tích cú pháp .


Nhưng người dùng không mở tuyến đường, họ mở URL. Và URL là mã định danh cho tài nguyên (trang bên trong ứng dụng trong trường hợp của chúng tôi); nó có thể trông giống như http://example.app/user/42/info?anonymous=1#bio . Bộ định tuyến của chúng tôi chủ yếu quan tâm đến phần thứ hai của URL ( /user/42/info?anonymous=1#bio ), mà chúng tôi sẽ gọi path .


Đường dẫn bao gồm tên đường dẫn ( /user/42/info ), tham số tìm kiếm ( ?anonymous=1 ) và hàm băm ( #bio ). Đối tượng lưu trữ các thành phần đó dưới dạng các trường riêng biệt sẽ được gọi là location .

Tổng quan cấp cao về API

Khi xây dựng thư viện React, tôi muốn thực hiện theo ba bước. Trước hết, API bắt buộc. Các chức năng của nó có thể được gọi từ bất kỳ nơi nào và chúng thường không bị ràng buộc với React. Trong trường hợp bộ định tuyến, đó sẽ là các chức năng như navigate , getLocation hoặc subscribe .


Sau đó, dựa trên các hàm đó, các hook như useLocation hoặc useCurrentRoute được tạo ra. Và sau đó các hàm và hook đó được sử dụng làm cơ sở để xây dựng các thành phần. Theo kinh nghiệm của tôi, điều này hoạt động rất tốt và cho phép tạo một thư viện linh hoạt và có thể mở rộng dễ dàng.


API của bộ định tuyến bắt đầu bằng hàm defineRoutes . Người dùng phải chuyển bản đồ của tất cả các tuyến đường thô cho hàm, chức năng này sẽ phân tích các tuyến đường và trả về một ánh xạ có cùng hình dạng. Tất cả các API dành cho nhà phát triển, như tạo URL hoặc khớp tuyến đường, sẽ chấp nhận các tuyến được phân tích cú pháp chứ không phải các tuyến thô.


 const routes = defineRoutes({ login: '/login', user: { me: '/user/me', byId: '/user/:id/info', } });


Bước tiếp theo là chuyển các tuyến được phân tích cú pháp tới hàm createRouter . Đây là phần cốt lõi của bộ định tuyến của chúng tôi. Hàm này sẽ tạo ra tất cả các hàm, hook và các thành phần. Điều này có vẻ bất thường, nhưng cấu trúc như vậy cho phép chúng ta điều chỉnh các loại đối số và đạo cụ được chấp nhận cho phù hợp với một tập hợp các tuyến đường cụ thể được xác định trong routes , đảm bảo an toàn về kiểu (và cải thiện DX).


 const { Link, Route, useCurrentRoute, navigate, /* etc... */ } = createRouter(routes);


createRouter sẽ trả về các hàm có thể được sử dụng ở bất kỳ đâu trong ứng dụng của bạn (API bắt buộc), các hook cho phép các thành phần của bạn phản ứng với những thay đổi về vị trí và ba thành phần: Link , RouteNotFound . Điều này sẽ đủ để đáp ứng phần lớn các trường hợp sử dụng và bạn có thể xây dựng các thành phần của riêng mình dựa trên các API đó.

Lập trình cấp loại để giải trí và kiếm lợi nhuận

Chúng tôi bắt đầu bằng cách giải quyết phần an toàn về kiểu chữ trong quảng cáo chiêu hàng của mình. Như tôi đã đề cập trước đây, với bộ định tuyến an toàn kiểu, TypeScript sẽ cảnh báo trước cho bạn về tình huống như thế này:

 <Link href="/logim" />


Hoặc như thế này:

 const { userld } = useRoute(routes.user.byId);


Và nếu bạn không thể thấy ngay những điều này có vấn đề gì, bạn chắc chắn cần một bộ định tuyến an toàn :)

Hệ thống kiểu chữ trong TypeScript rất mạnh mẽ. Ý tôi là, bạn có thể tạo một công cụ cờ vua , một trò chơi phiêu lưu hoặc thậm chí là cơ sở dữ liệu SQL bằng cách sử dụng lập trình cấp loại.


Bạn đã quen thuộc với 'lập trình mức giá trị' trong đó bạn thao tác các giá trị, ví dụ: nối hai chuỗi:

 function concat(a, b) { return a + b; } concat('Hello, ', 'World!'); // 'Hello, World!'


Nhưng bạn cũng có thể làm điều đó với các loại!

 type Concat<A extends string, B extends string> = `${A}${B}`; type X = Concat<'Hello, ', 'World!'>; // ^? type X = "Hello, World!"


Có, nó không mạnh bằng các chức năng thông thường của bạn và trông khác. Nhưng nó cho phép bạn làm một số thứ khá hay và hữu ích.


Chúng tôi sẽ sử dụng lập trình cấp loại để trích xuất các tham số từ các tuyến thô và xây dựng các loại mới có thể kiểm tra xem nhà phát triển có cố gắng chuyển các giá trị không chính xác cho Link hoặc cho hàm đang xây dựng URL hay không.


Lập trình cấp loại với TS có thể nhanh chóng trở thành một mớ hỗn độn không thể đọc được. May mắn thay cho chúng tôi, đã có nhiều dự án che giấu tất cả sự phức tạp này và cho phép chúng tôi viết mã rõ ràng như thế này:


 export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;


Khá gọn gàng phải không? Nhân tiện, đó là tất cả mã bạn cần để phân tích các tham số từ tuyến đường thô. Trong dự án này, chúng tôi sẽ sử dụng thư viện hotscript - nó sẽ giúp chúng tôi giảm độ phức tạp và số lượng mã cấp loại.


Nhưng điều đó không bắt buộc: nếu bạn cảm thấy thích phiêu lưu, bạn có thể thử tự mình thực hiện tất cả các loại này. Bạn có thể tìm thấy một số nguồn cảm hứng trong bộ định tuyến Chicane , bộ định tuyến này triển khai các tính năng tương tự mà không cần sử dụng thư viện loại của bên thứ ba.


Nếu bạn định làm theo, tôi khuyên bạn nên tạo một dự án React mới bằng cách sử dụng bộ khởi động yêu thích của bạn (tôi sử dụng Vite ) và bắt đầu viết mã ở đó. Bằng cách này, bạn sẽ có thể kiểm tra bộ định tuyến của mình ngay lập tức.


Xin lưu ý rằng các khung như Next.js cung cấp định tuyến riêng có thể can thiệp vào dự án này và thay vào đó hãy sử dụng React 'vanilla'. Nếu gặp khó khăn, bạn có thể tìm thấy mã hoàn chỉnh tại đây .


Bắt đầu bằng cách cài đặt các gói của bên thứ ba: hotscript cho các tiện ích cấp loại và biểu thức chính quy để phân tích các tham số từ URL/tuyến thô.


 npm install hotscript regexparam


Loại gạch xây dựng đầu tiên của chúng tôi là tuyến đường thô. Tuyến đường thô phải bắt đầu bằng / ; bạn sẽ mã hóa nó như thế nào trong TS? Như thế này:

 export type RawRoute = `/${string}`;


Dễ dàng phải không? Nhưng defineRoutes không chấp nhận một tuyến đường thô duy nhất, nó chấp nhận ánh xạ, có thể lồng nhau, vì vậy hãy mã hóa tuyến đường đó. Bạn có thể bị cám dỗ để viết một cái gì đó như thế này:

 export type RawRoutesMap = { [key: string]: RawRoute | RawRoutesMap };


Điều này sẽ hiệu quả. Tuy nhiên, loại này có thể sâu vô hạn và TS sẽ gặp khó khăn khi tính toán nó trong một số trường hợp. Để làm cho cuộc sống của TS dễ dàng hơn, chúng tôi sẽ giới hạn mức độ lồng nhau được phép. 20 cấp độ lồng nhau sẽ là đủ cho tất cả các ứng dụng và TS có thể xử lý việc đó một cách dễ dàng.


Nhưng làm thế nào để bạn giới hạn độ sâu của các kiểu đệ quy? Tôi đã học được thủ thuật này từ câu trả lời SO này; đây là phiên bản được sửa đổi theo yêu cầu của chúng tôi:

 export type RecursiveMap<T, MaxDepth extends number> = { [key: string]: RecursiveMap_<T, MaxDepth, []>; }; type RecursiveMap_<T, MaxDepth extends number, Stack extends unknown[]> = MaxDepth extends Stack["length"] ? T : T | { [key: string]: RecursiveMap_<T, MaxDepth, [1, ...Stack]> };


Đây là loại phức tạp đầu tiên của chúng tôi, vì vậy hãy để tôi giải thích nó. Ở đây chúng tôi có hai loại: RecursiveMap hoạt động như một điểm vào và gọi RecursiveMap_ truyền cho nó một tham số tuple bổ sung. Bộ dữ liệu này được sử dụng để theo dõi độ sâu của ánh xạ, với mỗi lệnh gọi, chúng tôi sẽ thêm một phần tử vào mảng này.


Và chúng ta tiếp tục gọi nó cho đến khi độ dài của bộ dữ liệu này bằng MaxDepth . Trong TS, khi extends được sử dụng với các giá trị cụ thể , còn được gọi là hằng số (ví dụ: cụ thể là 42 , không phải number ), điều đó có nghĩa là 'bằng'.


Và vì cả MaxDepthStack["length"] đều là những số cụ thể nên mã này có thể được đọc là MaxDepth === Stack["length"] . Bạn sẽ thấy công trình này được sử dụng rất nhiều.


Tại sao nên sử dụng tuple thay vì chỉ cộng số? Chà, không dễ để cộng hai số trong TypeScript! Có cả một thư viện cho việc đó và Hotscript cũng có thể thêm số, nhưng nó yêu cầu nhiều mã (ngay cả khi bạn không nhìn thấy), điều này có thể làm chậm máy chủ TS và trình soạn thảo mã của bạn nếu sử dụng quá mức.


Vì vậy, nguyên tắc nhỏ của tôi là tránh các loại phức tạp càng nhiều càng tốt.


Với loại tiện ích này, chúng ta có thể xác định ánh xạ của mình đơn giản như:

 export type RawRoutesMap = RecursiveMap<RawRoute, 20>;


Đó là tất cả đối với các loại tuyến đường thô. Tiếp theo trong hàng đợi là tuyến đường được phân tích cú pháp. Tuyến được phân tích cú pháp chỉ là một đối tượng JavaScript với một vài trường bổ sung và một hàm; đây là cách nó trông như thế nào:

 export type ParsedRoute<R extends RawRoute> = { keys: RouteParam<R>[]; build(...params: PathConstructorParams<R>): Path<R>; raw: R; ambiguousness: number, pattern: RegExp; };


Hãy bắt đầu giải nén cái này từ trường keys . Nó chỉ đơn giản là một mảng các tham số cần thiết cho tuyến đường này. Đây là cách nó được thực hiện:

 import { Pipe, Strings, Tuples } from "hotscript"; export type RouteParam< Route extends RawRoute, > = Pipe< Route, [ Strings.Split<"/">, Tuples.Filter<Strings.StartsWith<":">>, Tuples.Map<Strings.TrimLeft<":">>, Tuples.ToUnion ] >;


Trong Hotscript, có hai cách để gọi một hàm: Call hoặc Pipe . Call rất hữu ích khi bạn cần gọi một hàm duy nhất, nhưng trong trường hợp của chúng tôi, chúng tôi có 4 hàm trong số đó! Pipe chấp nhận đầu vào và chuyển nó vào chức năng đầu tiên của bộ dữ liệu được cung cấp.


Giá trị trả về được chuyển làm đầu vào cho hàm thứ hai, v.v. Trong trường hợp của chúng tôi, ví dụ: nếu chúng tôi có tuyến đường thô /user/:userId/posts/:postId , nó sẽ được chuyển đổi như thế này:

 export type Beep = Pipe< "/user/:userId/posts/:postId", [ Strings.Split<"/">, // ["user", ":userId", "posts", ":postId"] Tuples.Filter<Strings.StartsWith<":">>, // [":userId", ":postId"] Tuples.Map<Strings.TrimLeft<":">>, // ["userId", "postId"] Tuples.ToUnion // "userId" | "postId" ] >;


Nhìn thấy? Đây là sự kỳ diệu của lập trình cấp loại! Bây giờ, hãy giải quyết chức năng build đó. Nó chấp nhận các tham số tuyến đường (như userIdpostId ) và các tham số/băm tìm kiếm tùy chọn và kết hợp chúng thành một đường dẫn. Hãy xem cách triển khai PathConstructorParams :

 // Allows us to also accept number and // any other type which can be converted into string export type StringLike = { toString: () => string }; export type SearchAndHashPathConstructorParams = { hash?: string, search?: string | { [key: string]: string, } }; export type RouteParamsMap< Route extends RawRoute, Val extends string | StringLike = string, > = { [key in RouteParam<Route>]: Val }; export type PathConstructorParams<R extends RawRoute> = | [RouteParamsMap<R, StringLike>] | [RouteParamsMap<R, StringLike>, SearchAndHashPathConstructorParams];


Các tham số của hàm được định nghĩa là một mảng (sau này được ... lan truyền trong định nghĩa của

build ), trong đó phần tử đầu tiên là RouteParamsMap và phần tử thứ hai là SearchAndHashPathConstructorParams tùy chọn. Còn việc trả lại giá trị của build thì sao? Chúng tôi đã thiết lập đường dẫn của nó, nhưng bạn mô tả nó bằng TypeScript như thế nào?


Chà, cái này khá giống với RouteParam , nhưng yêu cầu thể dục dụng cụ nhiều hơn một chút!

 import { Fn } from "hotscript"; interface ReplaceParam extends Fn { return: this["arg0"] extends `:${string}` ? string : this["arg0"]; } // Leading slashes will be removed by Split<"/">, so we need to // add them back after our manipulations type Pathname< Route extends RawRoute, > = `/${Pipe< Route, [ Strings.Split<"/">, Tuples.Map<ReplaceParam>, Tuples.Join<"/"> ] >}${Route extends `${string}/` ? '/' : ''}`; export type Path< Route extends RawRoute, > = Pathname<Route> | `${Pathname<Route>}?${string}` | `${Pathname<Route>}#${string}`;


Những gì chúng tôi làm ở đây là chia tuyến đường của mình thành các phân đoạn, ánh xạ lên từng phân đoạn và gọi hàm tùy chỉnh của chúng tôi ReplaceParam trên mỗi phân đoạn. Nó kiểm tra xem phân đoạn hiện tại có phải là một tham số hay không và thay thế nó bằng string hoặc trả về phân đoạn nguyên trạng. ' ReplaceParam ' thay thế có thể trông hơi lạ, nhưng đó là cách bạn xác định các hàm tùy chỉnh bằng Hotscript.


Chúng tôi tuyên bố rõ ràng rằng đường dẫn chỉ bao gồm đường dẫn, đường dẫn theo sau là dấu chấm hỏi (điều này bao gồm các URL có thông số tìm kiếm và hàm băm) hoặc ký hiệu băm (điều này bao gồm các URL không có thông số tìm kiếm nhưng có hàm băm).


Chúng tôi cũng sẽ cần một loại để mô tả tuyến đường phù hợp, tức là tuyến đường được phân tích cú pháp với các tham số được lấy từ URL:

 // Interface (and not type) because we need to use `this` export interface RouteWithParams<R extends RawRoute> { route: ParsedRoute<R>, params: RouteParamsMap<R>, // TS can't properly infer type of route object with simple // check like currentRoute.route === routes.user.byId, so we // need our custom type guard matches: <T extends RawRoute>(route: ParsedRoute<T>) => this is RouteWithParams<T>, }


Loại cuối cùng là ParsedRoutesMap ; nó tương tự như RawRoutesMap , nhưng dành cho các tuyến đường được phân tích cú pháp.

 // This accepts RawRoutesMap and transforms it into // mapping of parsed routes of same shape export type ParsedRoutesMap<RM extends RawRoutesMap> = { [Key in keyof RM]: RM[Key] extends RawRoute ? ParsedRoute<RM[Key]> : RM[Key] extends RawRoutesMap ? ParsedRoutesMap<RM[Key]> : never; };


Và trên lưu ý đó, chúng tôi kết thúc với các loại. Sẽ còn một số tính năng nữa nhưng chúng đơn giản hơn và chúng tôi sẽ đề cập đến chúng khi chúng tôi triển khai. Nếu lập trình cấp độ loại là thứ bạn muốn thử nhiều hơn, bạn có thể xem Bản mô tả cấp độ loại để tìm hiểu thêm và cố gắng giải quyết các thách thức về loại (họ cũng có một danh sách tài nguyên tốt).

Trình phân tích cú pháp tuyến đường

Cuối cùng, chúng ta quay lại mã hóa mức giá trị thông thường. Hãy bắt đầu thực hiện bằng cách triển khai defineRoutes .

 export const typedKeys = <const T extends {}> (obj: T) => { return Object.keys(obj) as Array<keyof T>; }; export const defineRoutes = <const T extends RawRoutesMap>(routesMap: T): ParsedRoutesMap<T> => { const entries = typedKeys(routesMap).map((key) => { const entry = routesMap[key]; if (typeof entry === 'string') { return [key, parseRoute(entry)] as const; } else { // Nested map return [key, defineRoutes(entry)] as const; } }); return Object.fromEntries(entries); };


Không có gì phức tạp ở đây; Hãy cùng tìm hiểu sâu hơn về hàm parseRoute .

 import { parse, inject, type RouteParams as RegexRouteParams } from "regexparam"; export class InvalidRoute extends Error { }; export class InvalidRouteParams extends Error { }; const parseRoute = <const R extends RawRoute>(route: R): ParsedRoute<R> => { if (!route.startsWith('/')) { throw new InvalidRoute('route should start with slash (/)') } const { keys, pattern } = parse(route); const hasRequiredParams = keys.length > 0; const parsedRoute: ParsedRoute<R> = { build(...args) { const params = ( hasRequiredParams ? args[0] : undefined ) as RouteParamsMap<R, StringLike> | undefined; const searchAndHash = ( hasRequiredParams ? args[1] : args[0] ) as SearchAndHashPathConstructorParams | undefined; if (hasRequiredParams) { if (!params) { throw new InvalidRouteParams( `Parameters for route ${route} weren't provided` ); } const missingKeys = keys.filter(k => !(k in params)); if (missingKeys.length) { throw new InvalidRouteParams( `Missing parameters for route ${route}: ${missingKeys.join(', ')}` ); } } else if (args.length > 1) { throw new InvalidRouteParams( `Route ${route} doesn't accept any parameters, received ${args[0]}` ); } let path = hasRequiredParams ? inject(route, params as RegexRouteParams<R>) : route; if (searchAndHash && searchAndHash.search) { if (typeof searchAndHash.search === 'string') { path += searchAndHash.search.startsWith('?') ? searchAndHash.search : '?' + searchAndHash.search; } else { path += '?' + new URLSearchParams(searchAndHash.search).toString(); } } if (searchAndHash && searchAndHash.hash) { path += searchAndHash.hash.startsWith('#') ? searchAndHash.hash : '#' + searchAndHash.hash; } return path as Path<R>; }, raw: route, keys: keys as RouteParam<R>[] || [], ambiguousness: keys.length, pattern: pattern, }; return parsedRoute; };


parseRoute cũng rất đơn giản, mặc dù dài hơn đáng kể. Để phân tích lộ trình và trích xuất các tham số, chúng tôi sử dụng thư viện regexparam . Nó cho phép chúng ta lấy một mảng các tham số cần thiết cho tuyến đường và tạo ra một biểu thức chính quy mà sau này chúng ta sẽ sử dụng để khớp URL với tuyến đường.


Chúng tôi lưu trữ thông tin này cùng với tuyến thô ban đầu được sử dụng để xây dựng đối tượng này và mức độ mơ hồ (chỉ là một số tham số trong tuyến).

Trình bao bọc lịch sử

Mỗi bộ định tuyến phải lưu trữ trạng thái của nó ở đâu đó. Trong trường hợp ứng dụng trong trình duyệt, điều đó thực sự có tới 4 tùy chọn: trong bộ nhớ (ở biến trạng thái bên trong thành phần gốc hoặc trong một biến bên ngoài cây thành phần), API lịch sử hoặc phần băm của URL.


Định tuyến trong bộ nhớ có thể là lựa chọn của bạn nếu bạn không muốn hiển thị cho người dùng thấy bạn có các tuyến đường, chẳng hạn như nếu bạn đang mã hóa một trò chơi trong React. Việc lưu tuyến đường dưới dạng hàm băm có thể hữu ích khi ứng dụng React của bạn chỉ có một trang trong một ứng dụng lớn hơn và bạn không thể thay đổi URL theo cách mình muốn.


Nhưng trong hầu hết các trường hợp, sử dụng History API sẽ là lựa chọn tốt nhất. Nó tương thích với SSR (các tùy chọn khác thì không), tuân theo các kiểu hành vi mà người dùng đã quen và trông sạch sẽ nhất. Trong dự án này, chúng tôi cũng sẽ sử dụng nó. Tuy nhiên, nó có một lỗ hổng đáng chú ý: nó hầu như không thể sử dụng được nếu không có các trình bao bọc bổ sung.


Với History AP, bạn có thể đăng ký sự kiện popstate và trình duyệt sẽ cho bạn biết khi URL thay đổi. Nhưng chỉ khi thay đổi được người dùng thực hiện, chẳng hạn như nhấp vào nút quay lại. Nếu thay đổi URL được bắt đầu từ mã, bạn cần phải tự mình theo dõi nó.


Hầu hết các bộ định tuyến mà tôi nghiên cứu đều sử dụng trình bao bọc riêng: gói NPM lịch sử sử dụng bộ định tuyến phản ứng và bộ định tuyến, bộ định tuyến TanStack có cách triển khai riêng và wouter không có trình bao bọc đầy đủ nhưng vẫn phải có lịch sử bản vá khỉ .


Vì vậy, hãy triển khai trình bao bọc của riêng chúng ta.

 export type HistoryLocation = Pick<Location, | 'origin' | 'href' | 'hash' | 'search' | 'pathname' >; export type NavigationBlocker = (isSoftNavigation: boolean) => boolean; export const createHistory = () => { const winHistory = window.history; const winLocation = window.location; const getLocation = (): HistoryLocation => { return { origin: winLocation.origin, href: winLocation.href, pathname: winLocation.pathname, search: winLocation.search, hash: winLocation.hash, }; }; /* Some magic code */ return /* something... */; };


Có hai loại chúng tôi sẽ sử dụng, HistoryLocationNavigationBlocker . Đầu tiên, là phiên bản giới hạn một chút của loại Location tích hợp (đó là loại window.location ) và phiên bản thứ hai sẽ được đề cập khi chúng tôi chuyển sang tính năng chặn điều hướng. Tất cả các mã tiếp theo từ chương này sẽ nằm trong hàm createHistory .


Hãy bắt đầu với việc triển khai đăng ký các thay đổi trong lịch sử. Chúng ta sẽ sử dụng các hàm kiểu React để đăng ký trong dự án này: bạn gọi subscribe thông qua một cuộc gọi lại và nó trả về một hàm khác mà bạn cần gọi khi muốn hủy đăng ký.

 const subscribers: Set<VoidFunction> = new Set(); const onChange = () => { subscribers.forEach(fn => { try { fn(); } catch (err) { console.error('Error while handling location update', err); } }) }; const subscribe = (listener: VoidFunction) => { subscribers.add(listener); return () => { subscribers.delete(listener); }; };


Bước tiếp theo là phản ứng với những thay đổi về vị trí, bao gồm cả những thay đổi được thực hiện theo chương trình. Bạn sẽ làm điều này như thế nào? Tất nhiên là với bản vá khỉ . Điều đó có thể trông hơi bẩn (và thực tế là như vậy), nhưng thật không may, chúng tôi không có lựa chọn nào tốt hơn.

 const origPushState = winHistory.pushState.bind(winHistory); const origReplaceState = winHistory.replaceState.bind(winHistory); winHistory.pushState = (data, unused, url) => { // tryNavigate will be covered later tryNavigate(() => { origPushState(data, unused, url); onChange(); }); }; winHistory.replaceState = (data, unused, url) => { tryNavigate(() => { origReplaceState(data, unused, url); onChange(); }); }; // This event is emmited when user initiates navigation // or when calling history.go, history.back and history.forward window.addEventListener('popstate', onChange);


Và phần còn thiếu cuối cùng trong quá trình triển khai lịch sử của chúng tôi là chặn điều hướng: một tính năng cho phép bạn chặn yêu cầu điều hướng và hủy nó có điều kiện. Một ví dụ điển hình về việc chặn điều hướng sẽ ngăn người dùng mất tiến trình của họ trong một biểu mẫu.

 let blockers: NavigationBlocker[] = []; const beforeUnloadHandler = (event: Event) => { const blocked = blockers.some(blocker => blocker(false)); if (blocked) { event.preventDefault(); // @ts-ignore For older browsers event.returnValue = ''; return ''; } }; const tryNavigate = (cb: VoidFunction) => { const blocked = blockers.some(blocker => blocker(true)); if (blocked) return; cb(); }; const addBlocker = (blocker: NavigationBlocker) => { blockers.push(blocker); if (blockers.length === 1) { addEventListener('beforeunload', beforeUnloadHandler, { capture: true }); } return () => { blockers = blockers.filter(b => b !== blocker); if (blockers.length === 0) { removeEventListener('beforeunload', beforeUnloadHandler, { capture: true }); } } };


Trong quá trình triển khai của chúng tôi, trình chặn là một hàm trả về một boolean cho biết liệu chúng tôi có cần chặn điều hướng này hay không. Liên quan đến việc chặn điều hướng, có hai loại điều hướng và chúng ta cần xử lý chúng theo cách khác nhau.


Một mặt, có sự điều hướng mềm - khi người dùng điều hướng từ một trang trong ứng dụng của chúng tôi đến một trang khác trong ứng dụng của chúng tôi. Chúng tôi hoàn toàn kiểm soát nó và do đó có thể chặn nó, hiển thị bất kỳ giao diện người dùng tùy chỉnh nào (để xác nhận ý định của người dùng) hoặc thực hiện các hành động sau khi chặn điều hướng.


Mặt khác, có điều hướng cứng - khi người dùng điều hướng đến một trang web khác hoặc đóng tab hoàn toàn. Trình duyệt không thể cho phép JavaScript quyết định xem có nên thực hiện thao tác điều hướng này hay không vì điều này sẽ gây ra mối lo ngại về bảo mật. Nhưng trình duyệt cho phép JavaScript cho biết liệu chúng tôi có muốn hiển thị hộp thoại xác nhận bổ sung cho người dùng hay không.


Khi chặn điều hướng mềm, bạn có thể muốn hiển thị giao diện người dùng bổ sung (ví dụ: hộp thoại xác nhận tùy chỉnh), nhưng trong trường hợp điều hướng cứng, điều đó thực sự không có ý nghĩa vì người dùng sẽ chỉ nhìn thấy nó nếu họ quyết định ở lại trang và , tại thời điểm đó, nó vô dụng và khó hiểu.


Khi lịch sử của chúng tôi gọi hàm chặn điều hướng, nó sẽ cung cấp một boolean, cho biết liệu chúng tôi có đang thực hiện điều hướng mềm hay không.


Và với tất cả những điều đó, chúng ta chỉ cần trả về đối tượng lịch sử của mình:

 return { subscribe, getLocation, push: winHistory.pushState, replace: winHistory.replaceState, go: (distance: number) => tryNavigate(() => winHistory.go.call(winHistory, distance)), back: () => tryNavigate(() => winHistory.back.call(winHistory)), forward: () => tryNavigate(() => winHistory.forward.call(winHistory)), addBlocker, };

Bước 1: API bắt buộc

Cuối cùng chúng ta cũng ở đây. API bắt buộc sẽ là cơ sở cho tất cả các hook và thành phần tiếp theo, đồng thời sẽ cho phép nhà phát triển xây dựng các hook tùy chỉnh để đáp ứng nhu cầu của họ. Trước hết, chúng ta cần chuyển bản đồ tuyến đường của mình thành một mảng phẳng. Bằng cách này, việc lặp qua tất cả các tuyến đường sẽ dễ dàng hơn rất nhiều, điều này sẽ rất hữu ích khi chúng ta bắt đầu làm việc ở phần khớp tuyến đường.


Chúng ta cần cả hai loại tiện ích (sẽ chuyển ParsedRoutesMap thành liên kết của ParsedRoute ) và hàm (sẽ chuyển routesMap thành một mảng các tuyến đường được phân tích cú pháp). Hãy bắt đầu với loại:

 export type Values<T extends {}> = T[keyof T]; type FlattenRouteMap<T> = T extends ParsedRoute<any> | RawRoute ? T : T extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap ? AllRoutesFromMap<T> : never; export type AllRoutesFromMap< RM extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap > = FlattenRouteMap<Values<RM>>;


Có vẻ không cần thiết khi chia nó thành hai loại, nhưng có một lý do rất quan trọng cho việc này: nếu bạn triển khai nó như một loại duy nhất gọi chính nó, TS sẽ phàn nàn rằng loại đó quá sâu và có thể là vô hạn. Vì vậy, chúng tôi giải quyết vấn đề này bằng cách chia nó thành hai loại gọi nhau.


Đối với hàm cấp giá trị, chúng ta cũng cần một trình bảo vệ kiểu để kiểm tra xem giá trị được truyền có phải là tuyến được phân tích cú pháp hay không.

 export const isParsedRoute = <T extends `/${string}` = `/${string}`>( route: any ): route is ParsedRoute<T> => { return !!route && typeof route === 'object' && typeof route.raw === 'string' && typeof route.build === 'function'; } export const getAllRoutes = <T extends RawRoutesMap>( routesMap: ParsedRoutesMap<T> ): ParsedRoute<AllRoutesFromMap<T>>[] => { type PossibleRawRoute = AllRoutesFromMap<T>; return typedKeys(routesMap).flatMap((k) => { const val = routesMap[k]; if (isParsedRoute<PossibleRawRoute>(val)) { return [val] as const; } // At this point we know that val isn't ParsedRoute, so it has to be map of routes // but TS can't infer that, so we help him a little by casting val to correct type return getAllRoutes(val as ParsedRoutesMap<T>); }); };


Bây giờ, hãy bắt đầu triển khai bộ định tuyến của chúng tôi. Giống như lịch sử, trong chương này và hai chương tiếp theo, tất cả mã sẽ đi vào hàm createRouter , trừ khi có quy định khác.

 import { useSyncExternalStore, ComponentType, useMemo, MouseEventHandler, ComponentProps, useEffect } from 'react'; export const createRouter = <T extends RawRoutesMap>(routesMap: ParsedRoutesMap<T>) => { // Type for any possible route from passed routesMap type RouteType = AllRoutesFromMap<T>; // Similar to above, but for matched routes, ie includes URL parameters type BindedRouteWithParams = RouteWithParams<RouteType>; // Some of our functions will accept route filter, // which can be single parsed route, array or object type RouteFilter<T extends RouteType> = | ParsedRoute<T> | ParsedRoute<T>[] | Record<string, ParsedRoute<T>>; const history = createHistory(); const routes = getAllRoutes(routesMap); };


Trước hết, hãy hướng dẫn bộ định tuyến của chúng tôi khớp vị trí hiện tại với một trong các tuyến đường đã biết. Một số tiện ích có thể đi vào phạm vi toàn cầu hoặc các tệp riêng biệt, không nằm trong hàm createRouter :

 export const filterOutFalsy = <T>(obj: T[]): Exclude<T, undefined>[] => { return obj.filter(Boolean) as Exclude<T, undefined>[]; }; export class RouteMatchingConflict extends Error { }; // This will be used later export class RouteMismatch extends Error { };


Và mã này đi vào hàm createRouter .

 const extractRouteParams = <T extends RawRoute>( pathname: string, parsedRoute: ParsedRoute<T> ) => { const match = parsedRoute.pattern.exec(pathname); if (!match) return undefined; // Extract all route parameters from match array // and construct object from them return Object.fromEntries(parsedRoute.keys.map((key, index) => { return [key, match[index + 1]]; })) as RouteParamsMap<T>; }; const findMatchingRoute = ( location: HistoryLocation ): BindedRouteWithParams | undefined => { const matchingRoutes = filterOutFalsy(routes.map(route => { const params = extractRouteParams<RawRoute>( location.pathname, route ); if (!params) return undefined; return { route, params, matches<T extends RawRoute>(r: ParsedRoute<T>) { return route === r; }, }; })); if (matchingRoutes.length === 0) return undefined; if (matchingRoutes.length === 1) return matchingRoutes[0]; // At this point we have multiple matching routes :/ // Gotta decide which one we prefer let lowestAmbiguousnessLevel = Infinity; let lowestAmbiguousnessMatches: BindedRouteWithParams[] = []; matchingRoutes.forEach((match) => { if (match.route.ambiguousness === lowestAmbiguousnessLevel) { lowestAmbiguousnessMatches.push(match); } else if (match.route.ambiguousness < lowestAmbiguousnessLevel) { lowestAmbiguousnessLevel = match.route.ambiguousness; lowestAmbiguousnessMatches = [match]; } }); if (lowestAmbiguousnessMatches.length !== 1) { throw new RouteMatchingConflict( `Multiple routes with same ambiguousness level matched pathname ${location.pathname}: ${lowestAmbiguousnessMatches.map(m => m.route.raw).join(', ')}` ); } return lowestAmbiguousnessMatches[0]; }; let currentRoute = findMatchingRoute(history.getLocation()); // This function will be later returned from createRouter function const getCurrentRoute = () => currentRoute;


Ở đây chúng tôi xem xét tất cả các tuyến đường đã biết và cố gắng khớp từng tuyến đường với vị trí hiện tại. Nếu biểu thức chính quy của tuyến đường khớp với URL — chúng tôi nhận thông số tuyến đường từ URL, nếu không, chúng tôi sẽ nhận được null . Đối với mỗi tuyến phù hợp, chúng tôi tạo một đối tượng RouteWithParams và lưu nó vào một mảng. Bây giờ, nếu chúng ta có 0 hoặc 1 tuyến đường phù hợp thì mọi thứ đều đơn giản.


Tuy nhiên, nếu có nhiều tuyến đường khớp với vị trí hiện tại, chúng tôi phải quyết định tuyến đường nào có mức độ ưu tiên cao hơn. Để giải quyết vấn đề này, chúng tôi sử dụng trường ambiguousness . Như bạn có thể nhớ, tuyến đường này có một số tham số và tuyến đường có ambiguousness thấp nhất sẽ được ưu tiên.


Ví dụ: nếu chúng tôi có hai tuyến đường /app/dashboard/app/:section , vị trí http://example.com/app/dashboard sẽ khớp với cả hai tuyến đường. Nhưng khá rõ ràng rằng URL này phải tương ứng với tuyến đường /app/dashboard chứ không phải /app/:section .


Tuy nhiên, thuật toán này không có khả năng chống đạn. Ví dụ: cả hai tuyến đường /app/:user/settings/:section/app/dashboard/:section/:region đều sẽ khớp với URL http://example.com/app/dashboard/settings/asia . Và vì chúng có cùng mức độ mơ hồ nên bộ định tuyến của chúng tôi sẽ không thể quyết định cái nào sẽ được ưu tiên.


Bây giờ, chúng ta cần dán mã này lại với nhau để phản ứng với những thay đổi về vị trí và cập nhật biến currentRoute ;

 const areRoutesEqual = <A extends RawRoute, B extends RawRoute>( a: RouteWithParams<A> | undefined, b: RouteWithParams<B> | undefined ): boolean => { if (!a && !b) return true; // Both are undefined if ((!a && b) || (a && !b)) return false; // Only one is undefined if (!a!.matches(b!.route)) return false; // Different routes // Same routes, but maybe parameters are different? const allParamsMatch = a.route.keys.every(key => a.params[key] === b!.params[key]); return allParamsMatch; }; history.subscribe(() => { const newRoute = findMatchingRoute(history.getLocation()); if (!areRoutesEqual(newRoute, currentRoute)) { currentRoute = newRoute; notifyRouteChange(); // Will be covered later } });


Giờ đây, bộ định tuyến của chúng tôi sẽ phản ứng với những thay đổi về vị trí và người dùng luôn có thể nhận được tuyến đường hiện tại, vâng! Nhưng nó sẽ không hữu ích lắm nếu không có khả năng đăng ký các thay đổi về tuyến đường, vì vậy hãy thêm nó vào. Cách tiếp cận này rất giống với cách chúng tôi đã sử dụng trong trình bao bọc lịch sử.

 const subscribers: Set<VoidFunction> = new Set(); const subscribe = (cb: VoidFunction) => { subscribers.add(cb); return () => void subscribers.delete(cb); }; const notifyRouteChange = () => { subscribers.forEach(cb => { try { cb(); } catch (err) { console.error('Error in route change subscriber', err); } }); };


Để thực hiện điều hướng, chúng tôi sẽ hiển thị các hàm navigatenavigateUnsafe , là một trình bao bọc đơn giản xung quanh history.pushhistory.replace :

 // This function accepts any string path (no type-safety) const navigateUnsafe = ( path: string, { action = 'push' }: { action?: 'push' | 'replace' } = {} ) => { history[action]({}, '', path) }; // And this function accepts only paths that correspond to one of routes const navigate = ( path: Path<RouteType>, options: { action?: 'push' | 'replace' } = {} ) => { navigateUnsafe(path, options); };


Chà, bây giờ đó là một bộ định tuyến thực sự! Rất đơn giản nhưng vẫn làm việc. Chúng tôi vẫn còn một số hook và thành phần để triển khai, nhưng từ đây mọi việc trở nên dễ dàng hơn nhiều.

Bước 2: Móc

Đối với hook, chúng ta có thể bắt đầu với những cái đơn giản trả về vị trí hiện tại và tuyến đường hiện tại. Bản thân chúng khá dễ dàng, nhưng useSyncExternalStore biến chúng thành một lớp lót. Cách chúng tôi thiết kế API bắt buộc trước đó đã cho phép chúng tôi giảm đáng kể mã cho các hook này.

 const useLocation = () => { return useSyncExternalStore(history.subscribe, history.getLocation); }; const useCurrentRoute = () => { return useSyncExternalStore(subscribe, getCurrentRoute); };


Khi mã hóa các thành phần lẽ ra chỉ được hiển thị trên một tuyến/bộ tuyến cụ thể, bạn có thể sử dụng useCurrentRoute để lấy tuyến đường hiện tại, kiểm tra xem nó có khớp với tiêu chí hay không, sau đó sử dụng các tham số của nó (hoặc đưa ra lỗi).


Nhưng đây là một nhiệm vụ phổ biến đến mức sẽ là một tội ác nếu bắt người dùng của chúng tôi viết cái hook của riêng họ cho việc đó - bộ định tuyến của chúng tôi phải cung cấp chức năng này ngay lập tức.

 function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: true): RouteWithParams<T>; function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict: false): RouteWithParams<T> | undefined; function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: boolean): RouteWithParams<T> | undefined { const currentRoute = useCurrentRoute(); const normalizedFilter = Array.isArray(filter) ? filter : isParsedRoute(filter) ? [filter] : Object.values(filter); const isMatching = !!currentRoute && normalizedFilter.some(route => currentRoute.matches(route)); if (isMatching) return currentRoute as RouteWithParams<T>; else { if (strict === false) return undefined; throw new RouteMismatch( `Current route doesn't match provided filter(s)` ); } }


Móc này có hai phiên bản: nghiêm ngặt và thoải mái. Nếu người dùng chuyển true làm tham số thứ hai (hoặc không chuyển bất cứ thứ gì, vì true là giá trị mặc định), hook này sẽ báo lỗi nếu tuyến hiện tại không khớp với một trong các bộ lọc được cung cấp.


Bằng cách này, bạn có thể chắc chắn rằng hook sẽ trả về tuyến đường phù hợp hoặc hoàn toàn không quay lại. Nếu tham số thứ hai là sai, thay vì đưa ra một ngoại lệ, hook sẽ chỉ trả về không xác định nếu tuyến đường hiện tại không khớp với các bộ lọc.


Để mô tả hành vi này với TypeScript, chúng tôi sử dụng một tính năng gọi là nạp chồng hàm . Điều này cho phép chúng ta xác định nhiều định nghĩa hàm với các loại khác nhau và TypeScript sẽ tự động chọn một định nghĩa để sử dụng khi người dùng gọi một hàm như vậy.


Ngoài các tham số đường dẫn, một số dữ liệu có thể được chuyển trong các tham số tìm kiếm, vì vậy hãy thêm một hook để phân tích chúng từ chuỗi thành ánh xạ. Đối với điều này, chúng tôi sẽ sử dụng API trình duyệt tích hợp URLSearchParams .

 const useSearchParams = () => { const location = useLocation(); return useMemo(() => { return Object.fromEntries( (new URLSearchParams(location.search)).entries() ); }, [location.search]); };


Và hook cuối cùng trong phần này là useNavigationBlocker cũng khá đơn giản: nó sẽ chỉ chấp nhận lệnh gọi lại và gói các lệnh gọi tới history.addBlocker thành một hiệu ứng, để gắn lại trình chặn nếu nó thay đổi.

 const useNavigationBlocker = (cb: NavigationBlocker) => { useEffect(() => { return history.addBlocker(cb); }, [cb]); };


Bây giờ chúng ta hãy chuyển sang phần thành phần!

Bước 3: Thành phần

Thành phần đầu tiên bạn nghĩ đến khi đề cập đến thư viện định tuyến là gì? Tôi cá đó là Route hoặc ít nhất là thứ gì đó tương tự. Như bạn đã thấy trước đây, hook của chúng tôi rất đơn giản nhờ API bắt buộc được thiết kế tốt để thực hiện tất cả các công việc nặng nhọc.


Điều tương tự cũng xảy ra với các thành phần; người dùng có thể dễ dàng triển khai chúng chỉ bằng một vài dòng mã. Nhưng hiện tại chúng tôi là một thư viện định tuyến nghiêm túc, hãy đưa pin vào hộp :)

 type RouteProps = { component: ComponentType, match: RouteFilter<RouteType> }; const Route = ({ component: Component, match }: RouteProps) => { const matchedRoute = useRoute(match, false); if (!matchedRoute) return null; return (<Component />); };


Chà, điều đó thật dễ dàng! Bạn muốn đoán cách chúng tôi triển khai thành phần NotFound ? :)

 type NotFoundProps = { component: ComponentType }; const NotFound = ({ component: Component }: NotFoundProps) => { const currentRoute = useCurrentRoute(); if (currentRoute) return null; return (<Component />); };


Và thành phần cuối cùng cần thiết cho bộ định tuyến của chúng ta là Link , thành phần này phức tạp hơn một chút. Bạn không thể chỉ sử dụng <a href="/app/dashboard" /> vì nó sẽ luôn bắt đầu điều hướng cứng và không cung cấp bất kỳ loại an toàn nào. Vì vậy, hãy giải quyết những vấn đề sau:

 type LinkProps = Omit<ComponentProps<"a">, 'href'> & ( // Our link accepts either type-strict href // or relaxed unsafeHref { href: Path<RouteType>, unsafeHref?: undefined } | { href?: undefined, unsafeHref: string } ) & { action?: 'push' | 'replace' }; const Link = ({ action = 'push', onClick, href, unsafeHref, ...props }: LinkProps) => { const hrefToUse = (href ?? unsafeHref)!; const targetsCurrentTab = props.target !== '_blank'; const localOnClick: MouseEventHandler<HTMLAnchorElement> = (event) => { if (onClick) { onClick(event); if (event.isDefaultPrevented()) { // User-defined click handler cacnelled navigation, we should exit too return; } } const inNewTab = !targetsCurrentTab || event.ctrlKey || event.shiftKey || event.metaKey || event.button === 1; if (!isExternal && !inNewTab) { event.preventDefault(); navigateUnsafe(hrefToUse, { action }); } }; const isExternal = useMemo(() => { if (!hrefToUse) return false; return new URL(hrefToUse, window.location.href).origin !== location.origin; }, [hrefToUse]); return <a {...props} href={hrefToUse} onClick={localOnClick} /> };


Tương tự như chức năng navigate , thành phần Link sẽ kiểm tra kiểu URL mà bạn chuyển tới nó nhưng cũng cho phép bạn cung cấp một URL chuỗi tùy ý (dưới dạng lối thoát hoặc cho các liên kết bên ngoài). Để ghi đè hành vi của <a> , chúng tôi đính kèm trình nghe onClick của riêng mình, bên trong đó chúng tôi sẽ cần gọi onClick ban đầu (được chuyển đến thành phần Link của chúng tôi).


Sau đó, chúng tôi kiểm tra xem nhà phát triển đã hủy điều hướng chưa (nếu có thì chúng tôi nên bỏ qua sự kiện này). Nếu tất cả đều ổn, chúng tôi sẽ kiểm tra xem liên kết có ở bên ngoài hay không và liệu liên kết đó có được mở trong tab hiện tại hay không. Và chỉ khi đó chúng ta mới có thể hủy tính năng điều hướng cứng được tích hợp sẵn và thay vào đó, hãy gọi hàm navigateUnsafe của riêng mình.


Và bây giờ, chúng ta chỉ cần trả về tất cả các hàm, hook và thành phần của mình (cùng với một số hàm được xuất lại trực tiếp từ lịch sử) từ hàm createRouter .

 return { // Directly re-exported from history go: history.go, back: history.back, forward: history.forward, addBlocker: history.addBlocker, getLocation: history.getLocation, subscribeToLocation: history.subscribe, // Imperative API subscribe, getCurrentRoute, navigate, navigateUnsafe, // Hooks useLocation, useCurrentRoute, useRoute, useSearchParams, useNavigationBlocker, // Components Link, Route, NotFound, };


Và với điều đó, bộ định tuyến của chúng tôi đã hoàn thành. Bây giờ, chúng ta có thể xây dựng ứng dụng nhỏ xíu của mình để giới thiệu bộ định tuyến nhỏ xíu mà chúng ta vừa tạo! Nhân tiện, bạn có thể tìm thấy mã hoàn chỉnh cho bộ định tuyến này (bao gồm mã cho một ứng dụng mẫu) tại đây .

Ghép các mảnh ghép lại với nhau

Vì vậy, làm thế nào để tất cả các mã này liên kết với nhau? Khá gọn gàng, nếu tôi tự nói như vậy! Hãy tưởng tượng bạn đang tạo một ứng dụng ghi chú. Trước tiên, bạn sẽ bắt đầu bằng cách xác định các tuyến đường và tạo bộ định tuyến như thế này:

 export const routes = defineRoutes({ // Yep, this will be very minimal app root: '/', newNote: '/new', note: '/note/:noteId', }); const router = createRouter(routes); // Export functions and component you will be using export const { navigate, Link, Route, NotFound, useRoute, useNavigationBlocker } = router;


Sau đó, bạn liên kết các tuyến đường bạn đã xác định với các thành phần trang:

 function App() { return ( <div className="app"> <div className="links"> {/* This is how you build URL for Link */} <Link href={routes.root.build({})}>View all notes</Link> <Link href={routes.newNote.build({})}>Create new</Link> </div> <Route match={routes.root} component={NotesListPage} /> <Route match={routes.newNote} component={NewNotePage} /> <Route match={routes.note} component={NoteDetailsPage} /> <NotFound component={NotFoundPage} /> </div> ) }


Khi ở NoteDetailsPage , bạn cần lấy ID ghi chú từ URL, vì vậy bạn sử dụng hook useRoute :

 export const NoteDetailsPage = () => { const { getNote } = useNotes(); const { params } = useRoute(routes.note); const note = getNote(params.noteId); return note ? (<> <h1>{note.title}</h1> <div>{note.text}</div> </>) : (<h1>Not found</h1>); };


Và khi tạo ghi chú mới, có thể bạn sẽ muốn xác nhận ý định của người dùng nếu họ điều hướng đi mà không lưu ghi chú:

 export const NewNotePage = () => { const saveNote = () => { isSubmittingNoteRef.current = true; const note = createNote(title, text); // And this is programmatic redirect navigate(routes.note.build({ noteId: note.id })); isSubmittingNoteRef.current = false; }; const [title, setTitle] = useState(''); const [text, setText] = useState(''); const { createNote } = useNotes(); const isSubmittingNoteRef = useRef(false); useNavigationBlocker((isSoftNavigation) => { const dirty = title !== '' || text !== ''; if (!dirty || isSubmittingNoteRef.current) return false; if (isSoftNavigation) { const confirmation = confirm('Do you want to leave?'); return !confirmation; } else { return true; } }); return <> <h1>New note</h1> <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} /> <textarea placeholder="Text" value={text} onChange={e => setText(e.target.value)} /> <button onClick={saveNote}>Save</button> </>; };

Những cải tiến có thể có

Mặc dù bộ định tuyến của chúng tôi thực sự có định tuyến nhưng nó không phù hợp với các giải pháp sẵn sàng sản xuất như bộ định tuyến TanStack, bộ định tuyến Reac hoặc bộ định tuyến Next.js. Ý tôi là nó chỉ có ~500 dòng mã, không nhiều. Nhưng chính xác thì cái gì còn thiếu?


Trước hết, Kết xuất phía máy chủ. Ngày nay, không phải tất cả các ứng dụng đều có thể cần SSR nhưng tất cả các thư viện định tuyến đều phải hỗ trợ nó. Việc thêm hiển thị phía máy chủ vào một chuỗi (không phát trực tuyến SSR!) sẽ liên quan đến việc tạo một history khác sẽ lưu trữ vị trí hiện tại trong bộ nhớ (vì không có API lịch sử trên máy chủ) và cắm lịch sử đó vào hàm createRouter .


Tôi không biết việc triển khai SSR phát trực tuyến sẽ khó đến mức nào, nhưng tôi cho rằng nó sẽ được kết nối chặt chẽ với sự hỗ trợ của Suspense.


Thứ hai, bộ định tuyến này không tích hợp tốt với khả năng hiển thị đồng thời. Chủ yếu là do chúng tôi sử dụng useSyncExternalStore vì nó không tương thích với các chuyển đổi không chặn . Nó hoạt động theo cách này để tránh bị rách: tình huống trong đó một phần của giao diện người dùng được hiển thị với một giá trị cửa hàng cụ thể nhưng phần còn lại của giao diện người dùng được hiển thị bằng một giá trị khác.


Và do đó, bộ định tuyến không tích hợp tốt với Suspense, vì đối với mỗi bản cập nhật vị trí bị tạm dừng, một bản cập nhật dự phòng sẽ được hiển thị. Nhân tiện, tôi đã đề cập đến tính đồng thời trong React trong bài viết này và trong bài viết này , tôi nói về Hồi hộp, tìm nạp dữ liệu và use hook.


Nhưng ngay cả với những nhược điểm này, tôi hy vọng bạn thấy bài viết này thú vị và tự xây dựng bộ định tuyến của riêng mình :)