Vike react-full 示例深度解析:手动集成 React 实现客户端路由、数据获取与 HTML 流式渲染
【免费下载链接】vike(Replaces Next.js/Nuxt) 🔨 Build mission-critical applications with stability and development freedom.项目地址: https://gitcode.com/GitHub_Trending/vi/vike
Vike 官方仓库中的examples/react-full是一个“全功能”示例:它不依赖vike-react扩展包,而是手动将 React 集成进 Vike 的页面渲染管线,完整演示了客户端路由、同构数据获取、预渲染、Route Function、错误页、激活链接、HTML 流式渲染与页面切换加载动画等十余项能力。读完本文,你将掌握如何在 Vike 中自行编写onRenderHtml/onRenderClient钩子完成 React 集成,并通过该示例的源码理解每个特性背后的 Vike 配置与钩子调用机制。
示例定位与运行方式
examples/react-full/README.md对该示例的定位是:手动集成 React 并展示尽可能多特性的示例(Example of manually integrating React that showcases many features)。README 同时给出两条重要提示:
- 创建新的 Vike 应用时,官方推荐使用 Bati 脚手架而不是直接复制本示例。因为本示例使用自定义 React 集成,而非通常更推荐的
vike-react包; - 如果只需要了解最小集成的样子,可以改看更简单的
examples/react-minimal/。
因此本示例的价值在于“学习 Vike 的核心机制”,而非“直接拿来当项目模板”。运行方式在 README 中已给出:
git clone git@github.com:vikejs/vike cd vike/examples/react-full/ npm install npm run dev从 package.json 可以看到三个脚本:dev执行vike dev,build执行vike build,preview执行vike build && vike preview。核心依赖包括vike(当前锁定 0.4.262)、vite、react/react-dom、@vitejs/plugin-react-swc(React 编译插件)、@mdx-js/rollup(Markdown/MDX 支持)以及react-streaming(React 流式渲染辅助库)。
vite.config.ts 只有 8 行,注册了三个 Vite 插件,体现了“Vike 插件 + MDX 插件 + React 插件”的最小插件组合:
import react from '@vitejs/plugin-react-swc' import mdx from '@mdx-js/rollup' import vike from 'vike/plugin' import type { UserConfig } from 'vite' export default { plugins: [vike(), mdx(), react()], } satisfies UserConfig其中vike()插件负责把pages/目录下的文件约定(+Page、+data、+route等)编译成路由与渲染配置,这也是“文件系统路由 + 文件约定钩子”能够生效的基础。
特性总览与文件结构
README 中列出的特性清单,与仓库中的文件一一对应:
| 特性 | 对应源码文件 |
|---|---|
Client Routing +navigate() | renderer/+config.ts(clientRouting: true) |
| 数据获取(服务端 + 同构) | pages/star-wars/index/+data.ts、renderer/+config.ts(dataIsomorph自定义设置) |
预渲染 +onBeforePrerenderStart() | pages/hello/+onBeforePrerenderStart.ts |
| Route Function | pages/hello/+route.ts |
| TypeScript | 全项目.ts/.tsx,另有 renderer/PageContext.ts 类型声明 |
| Markdown | pages/markdown/+Page.mdx |
+client.ts | pages/markdown/+client.ts |
| Error Page | pages/_error/+Page.tsx |
| Active Links | renderer/Link.tsx |
任意组件访问pageContext | renderer/usePageContext.tsx |
| HTML 流式渲染 | renderer/+onRenderHtml.tsx |
| 页面切换加载动画 | renderer/+onPageTransitionStart.ts、renderer/+onPageTransitionEnd.ts |
页面组织上,pages/目录采用 Vike 的文件系统路由约定:index/、hello/、markdown/、star-wars/@id/(动态段)、_error/(错误页)等子目录各包含若干+xxx约定文件。renderer/目录则存放与页面无关的全局渲染代码:Layout.tsx、Link.tsx、+onRenderHtml.tsx、+onRenderClient.tsx、PageContext.ts等。
全局配置:prerender、clientRouting 与自定义设置
renderer/+config.ts 是整个示例的行为中枢,其中每一项都值得展开:
export const config = { prerender: true, // 构建时预渲染所有页面 passToClient: ['someAsyncProps'], // 白名单:允许 pageContext 字段传给客户端 clientRouting: true, // 启用客户端路由(SPA 式跳转) hydrationCanBeAborted: true, // 允许 hydration 中途被新导航打断 meta: { /* 定义 title 与 dataIsomorph 两个自定义设置 */ }, hooksTimeout: { data: { error: 30 * 1000, warning: 10 * 1000 } }, } satisfies Config几个关键点:
prerender: true+clientRouting: true组合。构建时所有页面被静态渲染为 HTML(SSG),运行时客户端导航不再请求服务器(SPA 式跳转)。README 特性列表中的“Pre-rendering”与“Client Routing”正是由这两个配置驱动的。dataIsomorph是一个自定义 Vike 设置,这是 Vikemeta机制的演示。源码中它声明env: { config: true },并通过effect校验dataIsomorph必须为布尔值;当某页面把它设为true时,effect返回{ meta: { data: { env: { server: true, client: true } } } },即覆盖 Vike 默认行为(data()只在服务端执行),让data()也在浏览器端执行。README 特性列表中的“isomorphic fetching”就靠它实现——客户端导航时数据直接从浏览器请求,完全绕开 Node.js/Edge 服务器。hooksTimeout.data为data()钩子设置超时:10 秒打印警告、30 秒抛出错误,用于暴露过慢的数据获取。- 文件底部通过
declare global { namespace Vike { interface Config { title?: string } } }为自定义设置title补充 TypeScript 类型,这是 Vike 自定义设置与 TS 联动的标准做法。
手动集成 React:onRenderHtml 与 onRenderClient
Vike 本身是渲染无关(renderer-agnostic)的框架,vike-react只是把下面的两个钩子封装成了开箱即用的实现。本示例手动编写它们,恰好展示了集成的本质。
服务端renderer/+onRenderHtml.tsx:
const onRenderHtml = async (pageContext: PageContextServer) => { const { Page } = pageContext const stream = await renderToStream( <Layout pageContext={pageContext}> <Page /> </Layout>, { disable: true }, // 本示例实际关闭了流式;仅为演示 Vike 可承载 react-streaming ) const title = getPageTitle(pageContext) const documentHtml = escapeInject`<!DOCTYPE html> <html> <head><title>${title}</title></head> <body><div id="root">${stream}</div></body> </html>` return { documentHtml, pageContext: async () => ({ someAsyncProps: 42 }), } }两个细节值得注意:
- HTML 骨架完全由自己拼接,
escapeInject是 Vike 提供的防注入模板函数;<Page />组件由 Vike 根据路由解析后注入pageContext。 - 返回值中的
pageContext: async () => ({...})是在 HTML 流结束(stream-end)之后才发送到客户端的异步数据。配合+config.ts中的passToClient: ['someAsyncProps'],这个字段才会被序列化并传给浏览器端的pageContext。源码注释说明:引入react-streaming仅仅是为了演示 Vike 对“预渲染应用 + react-streaming”组合的兼容性,本示例实际通过{ disable: true }关闭了真正的流式输出。 - 标题由 renderer/getPageTitle.ts 计算,优先级为:
data()动态返回的title→ 静态自定义设置config.title→ 兜底'Vike Demo'。
客户端renderer/+onRenderClient.tsx 区分两种进入页面的路径:
const onRenderClient = async (pageContext: PageContextClient) => { const { Page } = pageContext const page = ( <Layout pageContext={pageContext}><Page /></Layout> ) const container = document.getElementById('root')! if (pageContext.isHydration) { root = ReactDOM.hydrateRoot(container, page) // 首次加载:水合服务端 HTML } else { if (!root) root = ReactDOM.createRoot(container) root.render(page) // 客户端导航:直接客户端渲染 } document.title = getPageTitle(pageContext) }pageContext.isHydration是判断依据:初始页面加载时服务端已产出 HTML,需要hydrateRoot水合;而客户端路由跳转(clientRouting: true后由 Vike 客户端运行时发起)没有现成 HTML,走createRoot+render。root变量被闭包缓存,保证多次客户端导航复用同一个 ReactDOM root,这正是客户端路由不刷新整页的关键。
数据获取:服务端 data() 与同构 data()
服务端获取以 pages/star-wars/index/+data.ts 为代表:
export { data } export type Data = Awaited<ReturnType<typeof data>> async function data() { await sleep(700) // Simulate slow network const movies = await getStarWarsMovies() return { movies: filterMoviesData(movies), // 只传必要字段,减小网络传输量 title: getTitle(movies), // 页面 <title> } }注释中明确了两点工程考量:data的返回值会被传给客户端,因此应当裁剪掉用不到的字段;Data类型通过Awaited<ReturnType<typeof data>>自动推导,使组件里读取data时获得完整类型。
同构获取则由 pages/star-wars/@id/+dataIsomorph.ts 配合前述meta配置实现:该页面把自定义设置dataIsomorph声明为true,于是+data.tsx在服务端和浏览器端都会执行。效果是:SSR 首屏时数据来自服务端;而此后用户在客户端导航到/star-wars/@id时,数据直接由浏览器发起请求,服务器完全不参与。
页面组件读取数据使用 renderer/useData.tsx,它只是从pageContext.data中取出值并做泛型断言,一行核心代码:
function useData<Data>() { const { data } = usePageContext() return data as Data }Route Function 与路由保护(guard)
pages/hello/+route.ts 演示 Route Function——一种用函数完全接管路由解析的能力:
const route = (pageContext: PageContextServer | PageContextClient) => { if (pageContext.urlPathname === '/hello' || pageContext.urlPathname === '/hello/') { const name = 'anonymous' return { routeParams: { name } } } return resolveRoute('/hello/@name', pageContext.urlPathname) }它把/hello(含尾斜杠)映射到name = 'anonymous',其余路径交给resolveRoute按/hello/@name模板解析。由于该函数同时接收PageContextServer | PageContextClient,路由解析在服务端与客户端保持同构,避免两端路由结果不一致。
pages/hello/+guard.ts 演示guard()钩子做页面保护:
const guard = async (pageContext: PageContextServer) => { if (pageContext.urlPathname === '/hello/forbidden') { await sleep(2 * 1000) // Unlike Route Functions, guard() can be async throw render(401, 'This page is forbidden.') } }访问/hello/forbidden时抛出render(401, 'This page is forbidden.'),最终由错误页接管展示。值得注意的是源码注释:guard()与 Route Function 的一个区别是它可以是async函数,适合做需要异步校验(如鉴权)的逻辑。
预渲染与 onBeforePrerenderStart
+config.ts开启prerender: true后,默认只预渲染能被路由确定解析的页面;对于/hello这类含动态默认值的页面,pages/hello/+onBeforePrerenderStart.ts 显式列出要生成的 URL 列表:
const onBeforePrerenderStart = async () => { return ['/hello', ...names.map((name) => `/hello/${name}`)] }其中names来自 pages/hello/names.ts(['evan', 'rom', 'alice', 'jon', 'eli']),于是构建时会额外预渲染 5 个/hello/<name>静态页。pages/star-wars/index/+onBeforePrerenderStart.ts 中有同样的用法。
pageContext 的类型化与 React Context 注入
Vike 的pageContext是一个可被项目类型系统增强的对象。renderer/PageContext.ts 通过全局声明扩展了它:
declare global { namespace Vike { interface PageContext { Page: Page // () => React.ReactElement,由渲染钩子消费 data?: { title?: string } config: { title?: string } abortReason?: string someAsyncProps?: number // 与 +onRenderHtml 中 stream-end 数据对应 } } }“任意 React 组件都能访问pageContext”的能力则通过 renderer/usePageContext.tsx 实现——一个标准的 React Context 包装:
const Context = React.createContext<PageContext>(undefined as any) function PageContextProvider({ pageContext, children }) { return <Context.Provider value={pageContext}>{children}</Context.Provider> } function usePageContext() { return useContext(Context) }renderer/Layout.tsx 在渲染树最外层(React.StrictMode内)包裹PageContextProvider并传入pageContext,因此任何子组件调用usePageContext()/useData()都能拿到当前页面的上下文。这是手动集成 React 时把“框架级数据”下沉到组件树的关键一步。
激活链接、错误页与 Markdown 页面
Active Links:renderer/Link.tsx 用pageContext.urlPathname计算当前导航是否处于激活态:
const isActive = href === '/' ? urlPathname === href : urlPathname.startsWith(href) const className = [props.className, isActive && 'is-active'].filter(Boolean).join(' ') return <a {...props} className={className} />根路径要求精确匹配,其余前缀匹配;样式由 renderer/css/links.css 中的.is-active类控制。由于基于urlPathname,激活状态在每次客户端导航后随新pageContext自动更新,无需额外状态管理。
Error Page:pages/_error/是 Vike 的错误页约定目录。pages/_error/+Page.tsx 从pageContext读取is404与abortReason(guard()抛出的render(401, 'This page is forbidden.')会把消息写进abortReason),并居中展示,未提供abortReason时按 404/500 给出默认文案。
Markdown:pages/markdown/+Page.mdx 是一个 MDX 文件作为页面组件的直接示例——vike()+@mdx-js/rollup两个插件配合后,.mdx文件可以直接导出Page组件,无需任何额外胶水代码。
客户端钩子:+client.ts 与页面生命周期
pages/markdown/+client.ts 演示+client.ts约定文件的用途——它只在浏览器端加载执行,且仅当导航到该页面时触发:
console.log(`Hello from +client.ts with viewport height ${window.document.documentElement.clientHeight}`)这类钩子适合放埋点、仅客户端的初始化逻辑(如读取window、设置定时器),且不会进入服务端产物。
页面切换动画由一对生命周期钩子驱动:
- renderer/+onPageTransitionStart.ts:客户端导航开始时给
<body>加上page-is-transitioning类名,配合 renderer/css/page-transition-loading-animation.css 中的样式展示加载动画(加载图标资源见 renderer/css/page-transition-loading-animation/loading.svg); - renderer/+onPageTransitionEnd.ts:新页面渲染完成时移除该状态,结束动画。
另外 renderer/+onHydrationEnd.ts 在首次水合完成后打印“Hydration finished; page is now interactive.”,可用于在水合真正完成后再触发依赖完整 DOM 的客户端逻辑。
小结:从 react-full 示例能学到什么
examples/react-full用约 40 个源文件完整走了一遍“不依赖 vike-react、手动集成 React”的全部环节,其结构与实现可以直接作为参照清单:
- 插件最小集:
vike()+@mdx-js/rollup+@vitejs/plugin-react-swc(见 vite.config.ts); - 渲染管线:
onRenderHtml拼 HTML(escapeInject防注入)+onRenderClient按isHydration分支水合/客户端渲染; - 数据流:
data()服务端获取、dataIsomorph同构获取、passToClient+ stream-end 异步数据三种通道(见 renderer/+config.ts); - 路由层:文件系统路由 + Route Function +
guard()保护 +onBeforePrerenderStart预渲染 URL 列表; - 组件层:React Context 注入
pageContext、useData/usePageContext取数、基于urlPathname的激活链接; - 生命周期:
+client.ts、onPageTransitionStart/End、onHydrationEnd各管一段客户端行为。
再次强调 README 的提醒:生产项目初始化建议用官方脚手架而非复制本示例;本示例的定位是让你看清vike-react这类扩展包在底层到底替你做了什么。想进一步缩小认知范围时,可以对照更精简的 examples/react-minimal/README.md。
【免费下载链接】vike(Replaces Next.js/Nuxt) 🔨 Build mission-critical applications with stability and development freedom.项目地址: https://gitcode.com/GitHub_Trending/vi/vike
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考