Remix 路由模块提供了一种声明式的方式向页面头部注入链接:每个路由文件可以导出一个 links 变量或函数,返回一组用于生成 <link> 元素的描述对象。在 JavaScript 项目中,这种写法非常直接,但在 TypeScript 严格模式下,一旦链接对象包含内置类型未声明的属性,编辑器就会在 rel、href 等字段旁边给出红色波浪线。出现这类提示并不代表运行时会出错,而是类型边界没有和业务约定对齐。理解 Remix 如何描述这些头部链接,是修复问题的第一步。

Remix 对路由模块的 links 导出有专用类型签名:LinksFunction。该类型要求返回值是 LinkDescriptor[],而 LinkDescriptor 又由 HtmlLinkDescriptor 和 PageLinkDescriptor 组成。前者对应普通 HTML <link> 标签上的属性,后者用于路由级预取、分页等 Remix 内部能力。很多类型冲突都发生在只关注 LinkDescriptor 这个宽泛联合类型,却没有深入查看 HtmlLinkDescriptor 实际包含哪些字段。
头部链接的类型来源:links 导出与 LinkDescriptor
在 Remix 中,路由模块并不是只能导出组件和 loader。为了让某个路由在页面 <head> 区域添加样式表、预加载资源或设置 canonical,可以导出一个名为 links 的函数。Remix 会在服务端渲染时调用这个函数,并把返回的链接对象转换成真正的 <link> 元素。下面是一个最小示例。
import type { LinksFunction } from "@remix-run/node";
import globalStyles from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStyles },
{
rel: "preload",
href: "/fonts/main.woff2",
as: "font",
type: "font/woff2",
crossOrigin: "anonymous",
},
];
这里的 LinksFunction 来自 @remix-run/node,它本质上是一个函数签名,返回值类型为 LinkDescriptor[]。LinkDescriptor 是 Remix 为链接描述定义的联合类型,联合成员包括 HtmlLinkDescriptor 和 PageLinkDescriptor。HtmlLinkDescriptor 负责表达普通 HTML <link> 标签能够接收的各种属性,例如 rel、href、media、type、crossOrigin 等。之所以要额外封装一层 LinkDescriptor,是因为 Remix 还需要在路由模块之间传递一些特殊链接信息,例如页面预取或者分页资源,这些并不完全等同于标准 HTML 属性。
从使用角度看,绝大多数开发者只需要关心 HtmlLinkDescriptor。但正是这个类型在 Remix 不同版本之间存在差异,并且它与 React 自己的 LinkHTMLAttributes 有关。某些在浏览器端已经有效的新属性,可能因为 React 类型声明或者 Remix 类型导出版本不一致,而没有被及时收录。此时如果仍然按照原生 HTML 的直觉去写对象字面量,就会触发 TypeScript 的严格对象字面量检查。
类型报错的典型现场与定位思路
假设你需要在样式表链接上添加一个自定义的 data-track 属性,用来在前端监控脚本里标识这个链接。从 HTML 标准看,data-* 属性是完全合法的,于是你很自然地写下这样的代码:
export const links: LinksFunction = () => [
{
rel: "stylesheet",
href: "/styles/theme.css",
"data-track": "global-nav",
},
];
运行 TypeScript 检查后,编译器会提示类似这样的错误:对象字面量只能指定已知属性,并且 data-track 不存在于类型 HtmlLinkDescriptor 中。这是因为 Remix 的内置类型没有为任意 data-* 属性预留索引签名。它只列出了一组明确的链接属性,远超这些属性的键名都会被拒绝。
遇到这种错误时,最好不要直接使用 as any 或者 as unknown as HtmlLinkDescriptor。这类断言虽然能让编译通过,但会把整个对象的类型检查关闭,之后即使把 rel 拼成错误字符串,或者把 href 误写成 hre,TypeScript 也不会再提醒。更稳的做法是先定位类型定义:在编辑器中按住 Ctrl 或 Command 点击 LinksFunction 或 LinkDescriptor,进入 Remix 包中的类型文件,查看 HtmlLinkDescriptor 到底声明了哪些字段。根据查看结果,可以判断这个属性是 Remix 版本遗漏,还是本就不应该放在路由链接描述符中。
还有一个容易混淆的点:links 函数既可以返回 HtmlLinkDescriptor[],也可能返回包含 PageLinkDescriptor 的数组。如果某些对象使用 rel: "prefetch" 这类值,它可能被推入 PageLinkDescriptor 分支,而这个分支的字段约束与普通 HTML 链接不同。因此当报错信息指向 LinkDescriptor 联合类型时,需要先看清 TypeScript 把当前对象识别成了哪一个成员,再决定如何扩展。
扩展头部链接类型的三种方案
对于确实需要自定义数据属性或补充较新链接属性的项目,可以从三个方向入手。如果项目只是少量使用 data-*,可以保留 Remix 的原生类型,只在局部定义一个新的描述符类型,然后通过辅助函数包装返回值。下面是一个利用模板字面量类型扩展 HtmlLinkDescriptor 的例子。
import type {
HtmlLinkDescriptor,
PageLinkDescriptor,
LinksFunction,
} from "@remix-run/node";
type DataAttribute = `data-${string}`;
type AppHtmlLinkDescriptor = HtmlLinkDescriptor & {
[K in DataAttribute]?: string;
};
export function createLink(
descriptor: AppHtmlLinkDescriptor | PageLinkDescriptor
) {
return descriptor;
}
export const links: LinksFunction = () => [
createLink({
rel: "stylesheet",
href: "/styles/theme.css",
"data-track": "global-nav",
}),
];
这个方案的优点是改动范围小,不依赖 Remix 内部类型是否以 interface 形式暴露。DataAttribute 使用模板字面量类型 data-${string},因此 data-track、data-variant、data-section 等键名都会被识别为可选字符串属性。由于 AppHtmlLinkDescriptor 是 HtmlLinkDescriptor 的交叉类型,它仍然兼容 LinkDescriptor,所以 links 函数可以直接返回,不需要任何 as 断言。
第二种方案是当确认 HtmlLinkDescriptor 为 interface 时,使用 TypeScript 的声明合并直接给它增加字段。通常需要找到声明该类型的模块名,例如 @remix-run/server-runtime,然后在一个全局类型声明文件中写入:
declare module "@remix-run/server-runtime" {
interface HtmlLinkDescriptor {
"data-track"?: string;
"data-variant"?: string;
}
}
声明合并会让所有 HtmlLinkDescriptor 都获得这两个可选属性,因此路由文件里不需要再包一层 helper。不过这种方案的局限也比较明显:一旦 Remix 后续版本把 HtmlLinkDescriptor 改成 type 别名,或者调整了类型归属模块,这段声明就会失效,有时还会产生难以定位的冲突。所以如果项目追求稳定升级,建议优先使用第一种局部扩展方案。
第三种方案是把所有链接描述收敛到应用自己的工具文件中,建立一套只有业务需要的链接类型。例如在 app/lib/links.ts 中统一导出 defineLinks 和 AppLinkDescriptor,路由模块不再直接依赖 Remix 的 HtmlLinkDescriptor。这样做适合大型项目,尤其是当你希望限制团队只能使用 rel 为 stylesheet、preload、canonical 的固定集合,而不是随意书写任意字符串时。
用应用级封装统一头部链接约定
把头部链接类型封装到独立文件之后,路由模块的代码会变得更加清晰。你可以在 app/lib/links.ts 中集中定义允许出现的 rel 联合类型,并保留 Remix 原生属性,同时加入项目级的数据属性。示例封装如下:
import type {
HtmlLinkDescriptor,
PageLinkDescriptor,
} from "@remix-run/node";
type DataAttribute = `data-${string}`;
export type AppLinkDescriptor =
| (HtmlLinkDescriptor & { [K in DataAttribute]?: string })
| PageLinkDescriptor;
export function defineLinks(
descriptors: AppLinkDescriptor[]
) {
return descriptors;
}
在路由文件中引入这个工具后,links 的返回值依然可以满足 LinksFunction 的类型要求,但你在编写链接对象时就能获得 data-* 属性的补全提示。例如:
import type { LinksFunction } from "@remix-run/node";
import { defineLinks } from "~/lib/links";
export const links: LinksFunction = () =>
defineLinks([
{
rel: "preload",
href: "/images/banner.webp",
as: "image",
"data-lazy": "false",
},
{
rel: "canonical",
href: "https://ipipp.com/landing",
},
]);
这段代码避免了对象字面量被直接赋值给 LinkDescriptor[] 时触发的过严检查,因为 defineLinks 的参数类型已经明确包含了自定义数据属性。与此同时,如果某一天你需要再为所有链接增加一个 data-version 字段,只需要修改 DataAttribute 模板字面量,或者在 defineLinks 的参数类型中追加一个可选成员,所有路由文件都会自动获得新规则的约束。
需要特别说明的是,类型扩展解决的是编译期约束,并不能改变 Remix 最终输出 HTML 时的实际行为。对于普通 data-* 属性,Remix 会原样输出到 <link> 标签上,因此这种扩展在运行时是安全的。但如果你扩展的是 Remix 内部才使用的特殊字段,则需要确认服务端渲染逻辑是否真的会读取它,否则可能只会获得一个通过编译但没有任何运行效果的对象。
总体上,Remix 为路由模块提供的链接类型并不复杂,只是边界比原生 HTML 略窄。遇到类型报错时,先确认对象被识别为 HtmlLinkDescriptor 还是 PageLinkDescriptor,再决定是否通过声明合并或应用级 helper 扩展。这样既能保留 TypeScript 的自动补全与编译期校验,也能让团队在维护大量路由链接时保持一致的约定。
TypeScriptRemix Route头部链接类型修改时间:2026-09-18 01:10:38