跳到主要内容

客户端路由

evjs 以 src/pages 作为客户端路由的唯一事实来源。应用页面写在 页面文件中;框架会发现这些文件,并按配置生成一个框架托管的 SPA, 或生成多个不带路由器的 MPA 页面。evjs 不会写入临时 runtime 路由文件;SPA 模式只会生成类似 src/evjs-route-types.d.ts 的类型声明,让 TypeScript 从页面树推导导航 path。

目录结构

src/
├── api/*.server.ts # 可选 server functions
├── layout/
│ └── index.tsx # 可选 SPA 根布局
└── pages/
├── index.tsx # /
├── about.tsx # /about
├── users/$userId.tsx # /users/$userId
└── posts/index.tsx # /posts

动态路由段使用 $param 文件名。[id].tsx[...slug].tsx 这类 bracket 段会被拒绝,避免目录约定出现多套写法。catch-all 和可选段暂不属于 页面路由约定,因此 $...slug.tsx$slug?.tsx$.tsx 也会被拒绝。 动态参数名必须是 $ 后面的 JavaScript 标识符,例如 $userId.tsx$team_id.tsx,但 $__proto__.tsx$constructor.tsx$prototype.tsx 这类保留对象属性名会被拒绝。$_splat.tsx 也会被拒绝,因为 wildcard 路由会把 * 暴露为 _splat。静态路由段必须小写,并且只能使用 URL-safe 字符:小写字母、 数字、._-~;如果文件需要映射到自定义或大小写敏感 path,请使用显式 pages 配置。同一个 route path 也不能重复动态参数名,例如 teams/$teamId/users/$teamId.tsx 会被拒绝。 只有参数名不同的同级动态路由也会被拒绝:users/$id.tsxusers/$userId.tsx 都匹配 /users/:param,请保留一个统一参数名,或使用显式 pages 配置。

(marketing)/about.tsx 这类 route group 段不受支持;如果它应该成为 URL, 请使用 marketing/about.tsx 这样的真实路径段。如果文件需要映射到不符合目录 形状的 URL,请使用显式 pages 配置。

路由发现会把 .tsx.jsx.ts.js 文件视为可能的页面模块。 声明文件(.d.ts)、测试文件(*.test.**.spec.*)、隐藏 dot 文件/目录、 Storybook 文件(*.story.**.stories.*)、*.client.* 客户端专用模块、 *.server.* 服务端专用模块,以及没有这些源码扩展名的文件会被忽略。

路由段以 _ 开头的文件或目录只作为 src/pages 内部私有模块。它们可以使用源码扩展名, 但不会被发现为 URL 路由。可以用它们放页面局部组件、helper 或暂不暴露为 URL 的草稿页面。

SPA 和 MPA 模式使用相同的确定性路由顺序:/ 最先,父路由排在子路由之前, 同级静态路由排在动态路由之前。例如 src/pages/users/settings.tsx 会排在 src/pages/users/$id.tsx 之前。graph 和 build plan 使用的 resolved route list 也按同样规则归一化;重复的 path、动态 URL shape 或 route id 也会在这里被拒绝。 routing.routes 不是公开的 defineConfig() 字段;应用应使用 src/pages 发现或显式 pages 配置。运行时路由匹配也会按 specificity 选择结果,因此精确/静态路由会优先于 动态或 wildcard 路由,即使外部 manifest 尚未排序。

生成的 route id 必须唯一。evjs 会从 URL path 派生 route id,并把分隔符和标点 归一化为下划线,因此 src/pages/admin/panel.tsxsrc/pages/admin_panel.tsx 会被同时拒绝,因为它们都会生成 admin_panel。 server-rendered route-derived page id 也使用同样规则。生成的 id 冲突时,请重命名其中一个 route 文件,或改用显式 pages 配置并提供唯一 page id。

每个被发现的路由文件都必须默认导出 React 组件。如果 src/pages 下的模块不是页面, 请放进 _ 前缀文件/目录、对客户端专用代码使用 *.client.* 命名、对服务端专用代码使用 *.server.* 命名,或移到 src/pages 外部。语法错误和默认导出错误会在路由发现阶段、 bundler 运行前报告。

当项目存在 src/pages,且项目没有声明显式的 apppagesremote 配置时,SPA 路由会自动启用。也可以显式配置:

// ev.config.ts
import { defineConfig } from "@evjs/ev";

export default defineConfig({
routing: {
mode: "spa",
dir: "./src/pages",
mount: "#app",
},
});

MPA 使用相同的页面文件,只需要切换输出模式:

// ev.config.ts
import { defineConfig } from "@evjs/ev";

export default defineConfig({
routing: {
mode: "mpa",
},
});

MPA 模式下,每个发现到的 CSR 页面都会生成独立 HTML 文档和客户端 entry。 导出 render = "ssg" 的文件路由会输出独立 static HTML document,并获得用于 static generation 的 server renderer;默认不创建 browser page entry。MPA 不会引入 客户端路由器配置。

页面

每个页面模块默认导出 React 组件。页面逻辑需要当前 route 参数、search 参数或 loader data 时,使用 page hooks;生成的路由胶水由框架托管。

// src/pages/users/$userId.tsx
import { usePageParams, useQuery } from "@evjs/client";
import { getUser } from "../../api/users.server";

export default function UserPage() {
const { userId } = usePageParams();
const { data: user } = useQuery(getUser, userId);
if (!user) return null;
return <h1>{user.name}</h1>;
}

SPA 和 MPA 模式都使用 page hooks 读取路由数据。这样页面模块不需要引入框架 wrapper 类型,也不需要额外写 props 注解。evjs 不会把 paramssearchloaderData 作为页面组件 props 传入。文件路由从 $param 段派生参数; 底层显式 manifest route 也可以使用 :param 段,wildcard * 段会暴露为 _splat。空参数名、保留对象属性名、显式 :_splat 参数和重复参数名在这里同样会被拒绝。 一个 route path 最多只能包含一个 wildcard 段,因为 hooks 只会暴露一个 _splat 值。 同一套 hooks 会暴露这些参数名。

SPA 模式下,页面模块可以导出与页面逻辑相关的页面生命周期,例如 loaderbeforeLoadvalidateSearchpendingComponenterrorComponentnotFoundComponent。evjs 会把这些导出挂到框架托管的 route 上。MPA 模式不处理 这些生命周期,页面按普通 React 组件和数据逻辑编写。

// src/pages/search.tsx
import { usePageSearch } from "@evjs/client";

export const validateSearch = (search: Record<string, unknown>) => ({
q: typeof search.q === "string" ? search.q : "",
});

export default function SearchPage() {
const search = usePageSearch();
const q = typeof search.q === "string" ? search.q : "";
return <h1>Search: {q}</h1>;
}

布局

SPA 模式下,根布局是可选文件。它放在路由目录旁边:默认 src/pages 使用 src/layout/index.tsx,自定义 routing.dirsrc/app/pages 时使用 src/app/layout/index.tsx。默认导出会以 children 包裹当前页面,因此用户代码不需要引入 路由 outlet 组件。

如果迁移应用的共享外框在其他位置,可以通过 routing.layout: "./src/shell/AppLayout.tsx" 显式指定。layout/index.jsxlayout/index.js 这类非 TSX 布局模块也应这样配置;自动发现约定仍然只认 layout/index.tsx。设置 routing.layout: false 可以让 SPA 不消费任何框架根布局。

布局约定只用于 SPA,且路由目录旁边只有一个根目录入口:必须使用精确路径 layout/index.tsxlayout.tsxlayout.jsxlayout.ts 和非 TSX 的 layout/index.* 都不是别名。MPA 模式不接受也不消费框架 layout 文件;需要公共视觉包裹时, 在各页面里导入普通组件即可。如果只是文档外壳相同,可以复用 HTML 模板。

路由目录只放页面路由。不要在其中任何位置放名为 layout 的文件或目录;evjs 会把它报告为目录约定错误, 而不是把它转换成页面路由。这个保留段是精确且大小写敏感的,但 Layout.tsx 这类大写文件名仍会因为 discovered route 的小写静态段规则被拒绝。嵌套视觉包裹应作为普通组件由需要的页面导入。 即使 routing.layout 显式指向其他模块,或通过 routing.layout: false / MPA 模式关闭 layout discovery,这条规则也仍然成立。

// src/layout/index.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<main>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
{children}
</main>
);
}

导航

页面内可以使用普通 <a>,也可以使用 @evjs/clientLink。导航 helper 使用同一套文件路径约定来描述 path 和 params。

默认 src/pages 路由目录下,ev devev build 会在 SPA 模式下写入 src/evjs-route-types.d.ts。自定义 routing.dir 时,会在该路由目录的父级写入同名文件。 这个文件只用于增强 @evjs/clientLinkuseLinkPropsredirect 等 helper 使用的底层 @evjs/client route register;应用代码不需要导入它, 也不需要手写 framework router bootstrap。

生成文件会从 @evjs/client/internal/route-types 导入类型 helper。 这是生成专用的 internal subpath;不要在应用源码中导入这个 internal helper。

该声明文件会保留每个路由的字面量 ID 和 path,用于导航类型推导。内部生成的 TypeScript 标识符会自动去重,因此 admin-paneladmin_panel 这类合法 route id 不会生成非法或重复的声明。

确保生成的声明文件在 tsconfig.jsoninclude 范围内。默认 include: ["src"] 适用于 src/pages,也适用于 src/app/pages 这类 src 下的自定义目录。如果页面路由放在 src 外部,需要把该路由目录的父级也加入 include

import { Link } from "@evjs/client";

export default function HomePage() {
return (
<Link to="/users/$userId" params={{ userId: "1" }}>
Open user
</Link>
);
}

渲染元信息

页面模块仍然负责声明自身渲染元信息:

export const render = "ssr";
export const hydrate = "load";
export const prerender = { partial: true } as const;

export default function CampaignPage() {
return <main>Campaign</main>;
}

构建图会从页面模块读取这些元信息,并关联到发现到的文件路由。renderhydrate 必须是字符串字面量,prerender 必须是 true,或包含 partialdeliveryrevalidate 的对象字面量;prerender.revalidate 必须是 false 或表示秒数的正整数;rsc 必须是布尔字面量。Full prerendering(prerender = true 或非 partial prerender 对象)必须声明 render = "ssg"render = "ssr"; partial prerendering 必须声明 render = "ssr"

只有 RSC 页面才使用 export const rsc = true,且这类页面也必须声明 render = "ssr",并省略 hydrate 或声明 hydrate = "none"。RSC 页面暂不能同时使用 partial prerendering;请为一个 route 选择一种渲染模型,或拆分成多个 route。rsc = false 没有效果,并会产生 warning;除非要用 true 启用 RSC,否则请删除它。每个 metadata 名称只能导出一次;重复的 renderhydrateprerenderrsc 导出会被拒绝, 而不是按源码顺序取最后一个值。