From e71c77b5ca660930711ecc2c46725a5983bbfb52 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 9 Sep 2026 01:21:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Vike=20=E9=AA=A8=E6=9E=B6=EF=BC=88pages?= =?UTF-8?q?=20feed+spa=20/=20renderer=20=E5=8F=8C=E7=AB=AF=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20/=20AppShell=20=E6=8B=86=E5=88=86=20/=20serverClien?= =?UTF-8?q?t=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.config 接入 vike 插件并改产出 dist/client+dist/server;feed 白名单 route 匹配 /、/board/:id、/post/:id(.html);spa catch-all ssr:false;onRenderHtml renderToReadableStream 等 allReady 输出完整 HTML 并带主题/缩放脚本;onRenderClient 沿用样式表等待+冷启动预热+hydrate 回退;getHomeStoreState 作用域 + hydrating 变体入 RequestScope;删除 index.html 与 main.tsx --- frontend/index.html | 48 ---------- frontend/package.json | 6 +- frontend/pages/+config.ts | 6 ++ frontend/pages/feed/+Page.tsx | 10 ++ frontend/pages/feed/+route.ts | 11 +++ frontend/pages/spa/+Page.tsx | 8 ++ frontend/pages/spa/+route.ts | 10 ++ frontend/pages/spa/+ssr.ts | 4 + frontend/renderer/+onRenderClient.tsx | 90 ++++++++++++++++++ frontend/renderer/+onRenderHtml.tsx | 65 +++++++++++++ frontend/src/App.tsx | 131 +++++--------------------- frontend/src/AppRoutes.tsx | 85 +++++++++++++++++ frontend/src/AppShell.tsx | 24 +++++ frontend/src/api/serverClient.ts | 28 ++++++ frontend/src/main.tsx | 96 ------------------- frontend/src/ssr/feedPaths.ts | 26 +++++ frontend/src/ssr/scopeTypes.ts | 3 + frontend/src/utils/homeHydrate.ts | 6 +- frontend/tsconfig.json | 2 +- frontend/vite.config.ts | 35 +------ 20 files changed, 407 insertions(+), 287 deletions(-) delete mode 100644 frontend/index.html create mode 100644 frontend/pages/+config.ts create mode 100644 frontend/pages/feed/+Page.tsx create mode 100644 frontend/pages/feed/+route.ts create mode 100644 frontend/pages/spa/+Page.tsx create mode 100644 frontend/pages/spa/+route.ts create mode 100644 frontend/pages/spa/+ssr.ts create mode 100644 frontend/renderer/+onRenderClient.tsx create mode 100644 frontend/renderer/+onRenderHtml.tsx create mode 100644 frontend/src/AppRoutes.tsx create mode 100644 frontend/src/AppShell.tsx create mode 100644 frontend/src/api/serverClient.ts delete mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/ssr/feedPaths.ts diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 03fe577..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - 姜十三论坛 - 拾三一隅,自在交流 - - - - - -
- - - diff --git a/frontend/package.json b/frontend/package.json index 1cd56f3..1446d94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,9 +4,9 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" + "dev": "vike dev", + "build": "vike build", + "preview": "vike preview" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/frontend/pages/+config.ts b/frontend/pages/+config.ts new file mode 100644 index 0000000..82cacc8 --- /dev/null +++ b/frontend/pages/+config.ts @@ -0,0 +1,6 @@ +import type { Config } from 'vike/types'; + +export default { + // pageProps 透传给客户端(boot 数据经 +data 的 pageContext.data 自动序列化) + passToClient: ['pageProps'], +} satisfies Config; diff --git a/frontend/pages/feed/+Page.tsx b/frontend/pages/feed/+Page.tsx new file mode 100644 index 0000000..d5c2722 --- /dev/null +++ b/frontend/pages/feed/+Page.tsx @@ -0,0 +1,10 @@ +import App from '../../src/App'; +import type { PageContext } from 'vike/types'; + +export { Page }; + +/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */ +function Page(pageContext: PageContext) { + const { pathname, searchOriginal } = pageContext.urlParsed; + return ; +} diff --git a/frontend/pages/feed/+route.ts b/frontend/pages/feed/+route.ts new file mode 100644 index 0000000..a3eb715 --- /dev/null +++ b/frontend/pages/feed/+route.ts @@ -0,0 +1,11 @@ +import type { PageContext } from 'vike/types'; +import { matchFeedSsrPath } from '../../src/ssr/feedPaths'; + +export { route }; + +/** SSR 白名单:`/`、`/board/:id`、`/post/:id`(含 `.html` 伪静态) */ +function route(pageContext: PageContext) { + const match = matchFeedSsrPath(pageContext.urlPathname); + if (!match) return false; + return { routeParams: { kind: match.kind, id: match.id } }; +} diff --git a/frontend/pages/spa/+Page.tsx b/frontend/pages/spa/+Page.tsx new file mode 100644 index 0000000..ab5a33a --- /dev/null +++ b/frontend/pages/spa/+Page.tsx @@ -0,0 +1,8 @@ +import App from '../../src/App'; + +export { Page }; + +/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */ +function Page() { + return ; +} diff --git a/frontend/pages/spa/+route.ts b/frontend/pages/spa/+route.ts new file mode 100644 index 0000000..050cc51 --- /dev/null +++ b/frontend/pages/spa/+route.ts @@ -0,0 +1,10 @@ +import type { PageContext } from 'vike/types'; +import { matchFeedSsrPath } from '../../src/ssr/feedPaths'; + +export { route }; + +/** catch-all:SSR feed 白名单之外的一切 URL */ +function route(pageContext: PageContext) { + if (matchFeedSsrPath(pageContext.urlPathname)) return false; + return { routeParams: {} }; +} diff --git a/frontend/pages/spa/+ssr.ts b/frontend/pages/spa/+ssr.ts new file mode 100644 index 0000000..10fb94a --- /dev/null +++ b/frontend/pages/spa/+ssr.ts @@ -0,0 +1,4 @@ +export { ssr }; + +/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */ +const ssr = false; diff --git a/frontend/renderer/+onRenderClient.tsx b/frontend/renderer/+onRenderClient.tsx new file mode 100644 index 0000000..5155163 --- /dev/null +++ b/frontend/renderer/+onRenderClient.tsx @@ -0,0 +1,90 @@ +import { StrictMode } from 'react'; +import type { ComponentType } from 'react'; +import { hydrateRoot, createRoot } from 'react-dom/client'; +import type { PageContextClient } from 'vike/types'; +import { applyTheme, getStoredTheme } from '../src/utils/theme'; +import { beginHomeHydrate } from '../src/utils/homeHydrate'; +import { reloadForStaleChunk } from '../src/utils/chunkLoad'; +import { consumeHomeBoot } from '../src/utils/homeBoot'; +import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute'; + +export { onRenderClient }; + +type PageComponent = ComponentType<{ url?: string }>; + +let root: ReturnType | null = null; + +function waitForStylesheets(): Promise { + const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')); + if (links.length === 0) return Promise.resolve(); + return Promise.all( + links.map( + (node) => + new Promise((resolve) => { + const link = node as HTMLLinkElement; + try { + if (link.sheet) { + resolve(); + return; + } + } catch { + /* cross-origin */ + } + const finish = () => resolve(); + link.addEventListener('load', finish, { once: true }); + link.addEventListener('error', finish, { once: true }); + }), + ), + ).then(() => undefined); +} + +async function onRenderClient(pageContext: PageContextClient) { + applyTheme(getStoredTheme()); + consumeHomeBoot(); + + // Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404) + window.addEventListener('vite:preloadError', (event) => { + event.preventDefault(); + reloadForStaleChunk(); + }); + + const el = document.getElementById('root'); + if (!el) return; + + await waitForStylesheets(); + try { + if (document.fonts?.ready) await document.fonts.ready; + } catch { + /* ignore */ + } + + const path = `${window.location.pathname}${window.location.search}`; + if (isMainLayoutPath(window.location.pathname)) { + await ensureColdBootReady(path); + } + + // 有 SSR 正文 → hydrate 接管同一 DOM(首帧 hydrate 同构变体);SPA 壳 → 整树挂载 + const hydrating = pageContext.isHydration && el.innerHTML.trim() !== ''; + if (hydrating) beginHomeHydrate(); + + const Page = pageContext.Page as PageComponent; + const app = ( + + + + ); + + if (hydrating) { + try { + hydrateRoot(el, app); + } catch (err) { + console.warn('[j13] hydrateRoot 失败,回退 createRoot', err); + el.innerHTML = ''; + if (!root) root = createRoot(el); + root.render(app); + } + } else { + if (!root) root = createRoot(el); + root.render(app); + } +} diff --git a/frontend/renderer/+onRenderHtml.tsx b/frontend/renderer/+onRenderHtml.tsx new file mode 100644 index 0000000..da932d4 --- /dev/null +++ b/frontend/renderer/+onRenderHtml.tsx @@ -0,0 +1,65 @@ +import type { ComponentType, ReactElement } from 'react'; +import { renderToReadableStream } from 'react-dom/server'; +import { escapeInject, dangerouslySkipEscape } from 'vike/server'; +import type { PageContextServer } from 'vike/types'; +import App from '../src/App'; +import { createHomeStore } from '../src/store/homeStore'; +import { createRequestScope } from '../src/ssr/scopeTypes'; +import { runWithScope } from '../src/ssr/scope'; + +export { onRenderHtml }; + +type PageComponent = ComponentType<{ url?: string }>; + +/** 首帧主题脚本(localStorage j13-theme),与旧 index.html 保持一致 */ +const THEME_INIT_SCRIPT + = "(function(){var t=localStorage.getItem('j13-theme')||'light';document.documentElement.classList.toggle('dark',t==='dark');document.documentElement.style.colorScheme=t;})();"; + +/** 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */ +const ZOOM_BLOCK_SCRIPT + = "(function(){function inZoomAllowArea(target){return !!(target&&target.closest&&target.closest('.avatar-crop-stage, .image-lightbox'));}function blockGesture(e){if(inZoomAllowArea(e.target))return;if(e.cancelable)e.preventDefault();}function blockMultiTouch(e){if(e.touches&&e.touches.length>1){if(inZoomAllowArea(e.target))return;if(e.cancelable)e.preventDefault();}}document.addEventListener('gesturestart',blockGesture,{passive:false});document.addEventListener('gesturechange',blockGesture,{passive:false});document.addEventListener('gestureend',blockGesture,{passive:false});document.addEventListener('touchstart',blockMultiTouch,{passive:false});document.addEventListener('touchmove',blockMultiTouch,{passive:false});})();"; + +/** 等 lazy 页面全部渲染完成(allReady)后缓冲为完整 HTML,首字节语义与旧 Go 文档级 SSR 一致 */ +async function renderPageToString(element: ReactElement): Promise { + const stream = await renderToReadableStream(element, { + onError(err) { + console.error('[j13] SSR 渲染错误:', err); + }, + }); + await stream.allReady; + return new Response(stream).text(); +} + +async function onRenderHtml(pageContext: PageContextServer) { + const Page = pageContext.Page as PageComponent | undefined; + + let pageHtml = ''; + if (Page) { + // SSR feed 页:per-request 作用域 + hydrate 同构变体;渲染失败降级空壳(客户端自行接管),不 500 + const scope = createRequestScope(createHomeStore()); + scope.hydrating = true; + const { pathname, searchOriginal } = pageContext.urlParsed; + const url = pathname + (searchOriginal ?? ''); + try { + pageHtml = await runWithScope(scope, () => renderPageToString()); + } catch (err) { + console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err); + pageHtml = ''; + } + } + + return escapeInject` + + + + + + 姜十三论坛 - 拾三一隅,自在交流 + + + + +
${dangerouslySkipEscape(pageHtml)}
+ +`; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d94557..9ad6885 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,114 +1,29 @@ -import { Suspense } from 'react'; -import { - createBrowserRouter, - createRoutesFromElements, - RouterProvider, - Route, - Navigate, -} from 'react-router'; -import './styles/global.css'; -import { AuthProvider } from './hooks/useAuth'; -import { ThemeProvider } from './hooks/useTheme'; -import MainLayout from './layouts/MainLayout'; -import AdminLayout from './layouts/AdminLayout'; -import ErrorBoundary from './components/ErrorBoundary'; -import AppRouteError from './components/AppRouteError'; -import PageLoader from './components/PageLoader'; -import AuthPageFallback from './components/AuthPageFallback'; -import { Toaster } from './components/ui/sonner'; -import PullToRefresh from './components/PullToRefresh'; -import TopProgressBar from './components/TopProgressBar'; -import { lazyWithRetry } from './utils/lazyWithRetry'; +import { RouterProvider, createBrowserRouter, createMemoryRouter } from 'react-router'; +import AppShell from './AppShell'; +import { appRoutes } from './AppRoutes'; -const HomePage = lazyWithRetry(() => import('./pages/HomePage')); -const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage')); -const LoginPage = lazyWithRetry(() => import('./pages/LoginPage')); -const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage')); -const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')); -const ComposePage = lazyWithRetry(() => import('./pages/ComposePage')); -const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage')); -const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage')); -const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage')); -const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage')); -const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage')); -const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage')); -const LinksPage = lazyWithRetry(() => import('./pages/LinksPage')); -const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage')); -const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage')); -const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage')); -const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage')); -const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage')); -const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage')); -const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); -const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); -const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage')); -const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage')); -const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage')); -const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); -const SitePageView = lazyWithRetry(() => import('./pages/SitePageView')); -const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); -const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage')); -const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); +type AppRouter = ReturnType; -const router = createBrowserRouter( - createRoutesFromElements( - }> - }>} /> - }>} /> - }>} /> - } /> - }> - } /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - - }> - } /> - } /> - {/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - }>} /> - , - ), -); +/** 浏览器路由单例:整页加载创建一次,此后站内导航全部由 react-router 接管 */ +let browserRouter: AppRouter | null = null; -export default function App() { +function getAppRouter(url?: string): AppRouter { + if (import.meta.env.SSR) { + // 服务端:每请求新建 memory router(与浏览器同一棵路由树,保证 DOM 同构) + return createMemoryRouter(appRoutes, { initialEntries: [url ?? '/'] }); + } + browserRouter ??= createBrowserRouter(appRoutes); + return browserRouter; +} + +/** + * 应用根组件:Vike +Page 与客户端入口共用。 + * SSR 时传入 `url`(pathname + search),客户端忽略。 + */ +export default function App({ url }: { url?: string }) { return ( - - - - - - - - - - + + + ); } diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx new file mode 100644 index 0000000..f0060a7 --- /dev/null +++ b/frontend/src/AppRoutes.tsx @@ -0,0 +1,85 @@ +import { Suspense } from 'react'; +import { createRoutesFromElements, Route, Navigate } from 'react-router'; +import MainLayout from './layouts/MainLayout'; +import AdminLayout from './layouts/AdminLayout'; +import AppRouteError from './components/AppRouteError'; +import PageLoader from './components/PageLoader'; +import AuthPageFallback from './components/AuthPageFallback'; +import { lazyWithRetry } from './utils/lazyWithRetry'; + +const HomePage = lazyWithRetry(() => import('./pages/HomePage')); +const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage')); +const LoginPage = lazyWithRetry(() => import('./pages/LoginPage')); +const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage')); +const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')); +const ComposePage = lazyWithRetry(() => import('./pages/ComposePage')); +const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage')); +const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage')); +const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage')); +const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage')); +const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage')); +const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage')); +const LinksPage = lazyWithRetry(() => import('./pages/LinksPage')); +const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage')); +const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage')); +const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage')); +const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage')); +const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage')); +const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage')); +const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); +const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); +const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage')); +const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage')); +const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage')); +const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); +const SitePageView = lazyWithRetry(() => import('./pages/SitePageView')); +const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); +const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage')); +const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); + +/** 全量 react-router 路由树(Vike 页面壳与浏览器共用同一棵树) */ +export const appRoutes = createRoutesFromElements( + }> + }>} /> + }>} /> + }>} /> + } /> + }> + } /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + + }> + } /> + } /> + {/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }>} /> + , +); diff --git a/frontend/src/AppShell.tsx b/frontend/src/AppShell.tsx new file mode 100644 index 0000000..fd5bbe6 --- /dev/null +++ b/frontend/src/AppShell.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import './styles/global.css'; +import { AuthProvider } from './hooks/useAuth'; +import { ThemeProvider } from './hooks/useTheme'; +import ErrorBoundary from './components/ErrorBoundary'; +import { Toaster } from './components/ui/sonner'; +import PullToRefresh from './components/PullToRefresh'; +import TopProgressBar from './components/TopProgressBar'; + +/** 与路由无关的应用壳:Provider + 全局挂件(SSR 服务端 / 客户端共用) */ +export default function AppShell({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + + + + ); +} diff --git a/frontend/src/api/serverClient.ts b/frontend/src/api/serverClient.ts new file mode 100644 index 0000000..45b07c8 --- /dev/null +++ b/frontend/src/api/serverClient.ts @@ -0,0 +1,28 @@ +/** + * 服务端 API 客户端:Vike +data 在 Node 侧调用 Go API 用(仅 SSR 打包引用)。 + * + * - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001) + * - 转发浏览器 cookie 以透传登录态 + * - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染 + */ + +const GO_API_URL = process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`; + +export async function serverApi( + path: string, + opts?: { cookie?: string; timeoutMs?: number }, +): Promise { + try { + const res = await fetch(`${GO_API_URL}${path}`, { + headers: { + ...(opts?.cookie ? { cookie: opts.cookie } : {}), + }, + signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000), + cache: 'no-store', + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index 796c71e..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { hydrateRoot, createRoot } from 'react-dom/client'; -import { applyTheme, getStoredTheme } from './utils/theme'; -import App from './App'; -import { consumeHomeBoot } from './utils/homeBoot'; -import { beginHomeHydrate } from './utils/homeHydrate'; -import { reloadForStaleChunk } from './utils/chunkLoad'; -import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute'; - -applyTheme(getStoredTheme()); -consumeHomeBoot(); - -// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404) -window.addEventListener('vite:preloadError', (event) => { - event.preventDefault(); - reloadForStaleChunk(); -}); - -function hasSSRHome(): boolean { - return !!document.querySelector('#root .ssr-home'); -} - -function waitForStylesheets(): Promise { - const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')); - if (links.length === 0) return Promise.resolve(); - return Promise.all( - links.map( - (node) => - new Promise((resolve) => { - const link = node as HTMLLinkElement; - try { - if (link.sheet) { - resolve(); - return; - } - } catch { - /* cross-origin */ - } - const finish = () => resolve(); - link.addEventListener('load', finish, { once: true }); - link.addEventListener('error', finish, { once: true }); - }), - ), - ).then(() => undefined); -} - -function renderApp() { - return ( - - - - ); -} - -function mountCreateRoot(el: HTMLElement) { - createRoot(el).render(renderApp()); -} - -/** 有 SSR 正文:hydrate 接管同一 DOM;失败则回退 createRoot */ -function mountHydrateHome(el: HTMLElement) { - beginHomeHydrate(); - try { - hydrateRoot(el, renderApp()); - } catch (err) { - console.warn('[j13] hydrateRoot 失败,回退 createRoot', err); - el.innerHTML = ''; - mountCreateRoot(el); - } -} - -async function boot() { - const path = `${window.location.pathname}${window.location.search}`; - const ssr = hasSSRHome(); - - await waitForStylesheets(); - try { - if (document.fonts?.ready) await document.fonts.ready; - } catch { - /* ignore */ - } - - if (isMainLayoutPath(window.location.pathname)) { - await ensureColdBootReady(path); - } - - const root = document.getElementById('root'); - if (!root) return; - - if (ssr) { - mountHydrateHome(root); - } else { - mountCreateRoot(root); - } -} - -void boot(); diff --git a/frontend/src/ssr/feedPaths.ts b/frontend/src/ssr/feedPaths.ts new file mode 100644 index 0000000..3b18379 --- /dev/null +++ b/frontend/src/ssr/feedPaths.ts @@ -0,0 +1,26 @@ +/** + * SSR feed 页路由匹配(Vike +route.ts 用)。 + * + * 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id` + * (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。 + */ + +export type FeedSsrMatch = + | { kind: 'home'; id: '' } + | { kind: 'board'; id: string } + | { kind: 'post'; id: string }; + +const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [ + { re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) }, + { re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) }, + // 123 与 123.html(伪静态后缀由后台配置,两种形态都接住) + { re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) }, +]; + +export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null { + for (const { re, pick } of FEED_PATTERNS) { + const m = urlPathname.match(re); + if (m) return pick(m); + } + return null; +} diff --git a/frontend/src/ssr/scopeTypes.ts b/frontend/src/ssr/scopeTypes.ts index 42e754a..82c1ae1 100644 --- a/frontend/src/ssr/scopeTypes.ts +++ b/frontend/src/ssr/scopeTypes.ts @@ -25,6 +25,8 @@ export type RequestScope = { checkIns: Map; /** 主题(由 +data 从 cookie 解析) */ theme: 'light' | 'dark'; + /** 本请求渲染是否采用「hydrate 首帧同构变体」(feed SSR 页恒为 true) */ + hydrating: boolean; }; export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | 'dark' = 'light'): RequestScope { @@ -37,5 +39,6 @@ export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | ' pages: null, checkIns: new Map(), theme, + hydrating: false, }; } diff --git a/frontend/src/utils/homeHydrate.ts b/frontend/src/utils/homeHydrate.ts index 48cba39..c128441 100644 --- a/frontend/src/utils/homeHydrate.ts +++ b/frontend/src/utils/homeHydrate.ts @@ -1,5 +1,7 @@ /** 首页 SSR hydrate 首帧同构标志(仅 / 与板块首页) */ +import { getScope } from '../ssr/scope'; + declare global { interface Window { __J13_HYDRATING_HOME__?: boolean; @@ -30,8 +32,10 @@ export function endHomeHydrate() { } } -/** 首帧是否必须与 Go SSR DOM 同构 */ +/** 首帧是否必须与 SSR DOM 同构(SSR 渲染期读请求作用域,客户端读模块/窗口标志) */ export function isHomeHydrating(): boolean { + const scope = getScope(); + if (scope) return scope.hydrating === true; if (hydrating) return true; try { return !!window.__J13_HYDRATING_HOME__; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 7711e03..814e508 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -17,5 +17,5 @@ "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src", "pages", "renderer", "vite.config.ts"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6ae266f..0063672 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,40 +1,14 @@ import path from 'path'; -import { defineConfig, type Plugin } from 'vite'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import vike from 'vike/plugin'; // 与 Go 后端默认端口一致,可通过 VITE_API_PORT 覆盖 const apiPort = process.env.VITE_API_PORT || '3000'; const apiTarget = `http://localhost:${apiPort}`; -/** 把 stylesheet 挪到 head 靠前(先于 module script),接近 Gitea 外链 CSS 顺序 */ -function htmlStylesheetsFirst(): Plugin { - return { - name: 'html-stylesheets-first', - enforce: 'post', - transformIndexHtml(html) { - // 只处理真实标签,忽略注释里的示例文字 - const linkRe = /]*\/?>)[^>]*\brel=["']stylesheet["'][^>]*>\s*/gi; - // 更稳妥:逐个找 link 标签 - const styles: string[] = []; - const out = html.replace(/]*>/gi, (tag) => { - if (/\brel=["']stylesheet["']/i.test(tag)) { - styles.push(tag); - return ''; - } - return tag; - }); - if (!styles.length) return html; - const inject = `${styles.join('\n ')}\n `; - if (//i, ` ${inject}`); - }, - }; -} - export default defineConfig({ - plugins: [react(), htmlStylesheetsFirst()], + plugins: [react(), vike()], resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -42,7 +16,8 @@ export default defineConfig({ }, base: '/', build: { - outDir: '../embed_static/static/spa', + // Vike 双构建:产出 dist/client(静态资源)+ dist/server(SSR 渲染入口) + outDir: 'dist', emptyOutDir: true, rollupOptions: { output: {