DEFAULTKAVY

Illustrator, Web Developer and VTuber

我再一次重写了 Amateras Router

Published on 8/16/25 · Updated on 8/18/25

在 Amateras Router 第一版发布之前,我其实不知道写过多少版不同的 Router 了。当然,这些历史版本都有各自的问题,而我每一次重写也都抱着想一口气解决这些问题的初衷去做,但很显然每次结局都是再重写一遍。

我整理了过去几个版本所遇到的问题:

一、为了实现类型安全,结构受到限制

利用TS判断路径字符串内有哪些参数,并且子路由也要能获取父路由的类型资讯。这让代码结构的设计变得局限,因为要将类型数据在路由之间传递,我只能选择用函数链(Method Chain)作为路由构建的方式。

二、页面代码分离

在早期版本中,能让不同页面的逻辑代码分别写在独立的文件,并在入口文件将所有独立路由导入到路由器设置中。这是非常基础的需求,但那时我并没有像现在如此关注代码体积的问题,所以并没有实现按需加载分离页面代码文件的功能。

这个问题在 Amateras Router 第一版得到了解决,依赖 ES6 的 import() 语法。

三、路径设置的功能不完善

在我的几个项目中,经常会出现多个路径导向一个页面的需求。这听上去像是不难解决的问题,但其背后又会衍生出几个麻烦。

比如,如果一个页面同时有 /user/:id/profile 两条路径导向,第一条路径可以是导向任何用户的个人页面,而第二条则是导向当前登录的用户的个人页面。那么我会希望,在路由设置的代码结构中,能够对这些路径分布一目了然,并且类型安全也能得到保证。难点在于,/profile 缺失了 id 的参数资讯,这里需要类型报错提醒开发者,并提供另一种补上参数数据的方式。

还有其他路径设置的需求,这些都让路由器的设计变得麻烦了起来。

先思考

路由器是我重写过最多次的工具,我总是设计出不够好用的路由器。这些路由器虽然都能完成我那些个人项目中的任务,但无论是结构还是逻辑始终都不够优雅。

所以这一次我在结构设计上花了比以往更多的时间和精力去思考,我认为先想出一个写起来方便好用的路由结构,可能比功能实现的代码本身来得更重要些。毕竟重写了那么多次的路由器,路由逻辑本身倒是没有什么天翻地覆的改变。

一个能够同时满足上面所有需求的路由器结构,这真的是让我不停的怀疑人生。我一开始还在纠结要不要在第一版的基础上做修改,但很快我便认识到改变结构这件事只能是推倒重来。不过好在我苦思冥想终究是找到了解决方案,仅用了一天就完成了第二版路由器。

插曲:关于类型

如果要问我 TypeScript 到底为开发者带来了什么?那我会说它带来的类型安全就是面向开发者的用户界面,一个好的类型设计本身就是工具的使用说明书。应该输入什么值?有什么可以调用的函数?函数有哪些不同的函数重载(Overload)?只要你的类型写得好,就能让开发者在编写的过程中减少很多学习成本。

接触 TypeScript 五年了,我对于类型的理解也算是融会贯通。在掌握这门语言之后,不得不感叹这个语言究竟有多么强大,造福了多少开发者们。

TypeScript 几乎是我这个库的基础,没了它这个库的意义就少了一半。

差异

在原先第一版中,分离页面代码的方式如下:

// ./pageA.ts
export default $('route', '/pageA/:id', page => page.params.id)

// ./pageB.ts
export default $('route', '/pageB/:id', page => page.params.id)

// ./index.ts
$('router')
.route('/pageA/:id', () => import('./pageA.ts'))
.route('/pageB', () => 'Page B', route => route
	.route('/:id', () => import('./pageB.ts'))
)
.listen()

在这一版中,我为了让被分离的部分也能正确得到该路径中的参数,使用了这种两边都要填写完整路径的方式。并且如果两边路径不相等,就会出现类型错误提示。

然而这个模式一看就有问题,第一是为了分离文件,必须把相同的路径重写两次。虽然我「贴心」的提供了路径检查的类型保护,尽可能让开发者不会出错,但这种方式完全谈不上优雅。所以在第一版路由器写到途中的时候,我就有预感要重写一次了。

第二则是无法实现不同路径指向同一页面的功能。因为两边的路径必须确保一致,还有路径参数这个类型数据需要传递,实现这功能的难度更高了。正好手头上有个项目需要这个功能,因此才决定将这个版本彻底舍弃重做。

解决一:麻烦

在重写之后,实现相同结果的代码如下:

// ./pageA.ts
export default $.route<['id']>(page => page.params.id)

// ./pageB.ts
export default $.route<['id']>(page => page.params.id)

// ./index.ts
$('router')
.route('/pageA/:id', () => import('./pageA.ts'))
.route('/pageB', () => 'Page B', route => route
	.route('/:id', () => import('./pageB.ts'))
)
.listen()

你会发现其实 ./index.ts 文件的代码并没有任何修改,仅仅是被分离的文件有了不同的写法了。处理路由的 APIs 看上几乎没什么变化,背后的逻辑却是天差地别。在被分离的文件中,我们不再需要重复写出完整的路径了,取而代之的是我们可以用 Type Generic 把需要的参数列出来。

试想在撰写一个页面文件时,其实我们没必要严格定义这个页面必须是什么路径才能访问,如果能让这个页面需要的参数被满足,那么理论上任何路径都能被设为访问路径。因此重写后甚至能做到这样的事:

// ./postPage.ts
export default $.route<['userId?', 'postId']>(page => {
	return page
	.content([
		`Post ID: ${page.params.postId}`,
		`User ID: ${page.params.userId ?? 'No User ID'}`
	])
})

// .index.ts
$('router')
.route('/users/:userId/posts/:postId', () => import('./postPage.ts'))
.route('/posts/', page => 'Posts', route => route
	.route('/:postId', () => import('./postPage.ts'))
)

以上的路径设置方式都是正确的,因为页面所需要的参数已经被满足。你甚至能设置可选的参数名,只需要像 userId? 这样添加一个 ? 在名字最后一位即可。那么在路径检查时,这个可选参数便能够被忽略不计。

这样就能解决了重复写出完整路径的麻烦,不需要一次次去同步路径的变更,也能让页面代码的复用性提高。但是,这并没有解决我手头上的项目遇到的问题:为页面多添加一个指定路径,并且该页面应该被视为同一个页面组件复用。

解决二:别名

在我的路由器结构定义中,每一个 Route 对象都是单独的页面构建器。

拿上面代码的例子来解释,虽然我定义了两个路由都指向了同一个页面构建代码,但实际上每个路由的页面缓存是完全分开的!这意味着,路径 /users/1/posts/100 和路径 /posts/100 使用的页面组件对象并不是同一个,而是重复构建多一次的结果,尽管它们渲染出来看上去是一样的。

而我痛恨臃肿和浪费。

因此,我需要想出一个方案,让单独的路由能够拥有多个路径指向的方案!这部分不多说,直接上代码:

// 为了简化,我就不以分离文件的形式展示了
const postPage: PageBuilder = $.route<['userId?', 'postId']>(page => page)

$('router')
.route('/user/:userId/posts/:postId', postPage, route => route
	.alias('/path/to/post', { postId: '100' }) // pass
	.alias('/profile/posts/:postId', () => ({ userId: CLIENT_ID })) // pass
	.alias('/post100') // type error: postId is required
)
.route('/', homePage, route => route
	.alias('/path/to/home')
)

处理多路径的逻辑并非难事,但是要构思出类型保护的完整方案真的是非常烧脑。在这段代码中能看到 .alias() 这个陌生函数,这便是添加别名路径的方式了。当我们添加页面构建器 PageBuilder.route() 函数中,无论是主路径还是别名路径都将会依据该页面构建器的参数要求进行检查。所有路径都必须满足页面构建器 Type Generic 中定义的参数名,不在路径中表示的参数,可以透过追加参数数据来完成参数要求。

别名机制还有更强大的能力,子路由页面同样能经由别名路径来访问:

$('router')
.route('/user/:userId', userPage, route => route
	.alias('/profile', { userId: CLIENT_ID })
	.route('/posts', postsPage)
)
// 手动解析路径
.resolve('/user/1/posts') // OK
.resolve('/profile/posts') // OK

不仅增加了功能,而且达到的效果远超预期。这就是重新设计结构带来的惊喜,好的结构设计就是能让功能开发变得事半功倍。

解决三:路径组

在一些大型的项目中,随着页面的增多会导致多个路径拥有相同的父级前缀。这时候如果有一个路径分组的概念可能会更好,因此我在开发初期便考虑了这个功能。

$('router')
.route('/', homePage)
.group('/events', route => route
	.alias('/e')
	.route('/', eventListPage)
	.route('/:id', eventPage)
)

从代码来看也是非常清楚明了的路径结构,.group() 函数同样会创建一个新的 Route 但不会拥有构建器,它仅仅只是作为一个节点。意外的是,这个设计并没有让解析路径的逻辑变得复杂多少,反而像是本该就这样设计一般,解决过程非常顺利。

总结

在第一版的时候我将 Router 视为 Route 的继承,并以 Route 作为节点的概念去连接父子级的路径。但由于 Router 是需要置入 DOM 树当中的,因此我错误的将 Route 也作为一个 $HTMLElement 的组件来设计。实际上 Route 完全没有作为 DOM 的必要性,它根本不会需要被内置。

这一次我将 RouterRoute 彻底断开继承,但有一部分函数(比如 .route().group())都会出现在两者的属性中。在前后版本的对比例子中,你会看到我们不再使用 $('route') 的方式创建 Route 了,而是使用 $.route() 函数作为创建入口。因为在我的定义中, $() 函数是和构建 DOM 相关的函数。

这一点是和我以往写的路由器库完全不同的形式,而它的核心逻辑竟美得不可思议。

在实现了更多新功能的同时,这个新版库的代码体积居然比第一版更小了。

有时候代码真的很奇妙呢。