feat: Vike 骨架(pages feed+spa / renderer 双端入口 / AppShell 拆分 / serverClient)
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
This commit is contained in:
90
frontend/renderer/+onRenderClient.tsx
Normal file
90
frontend/renderer/+onRenderClient.tsx
Normal file
@@ -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<typeof createRoot> | null = null;
|
||||
|
||||
function waitForStylesheets(): Promise<void> {
|
||||
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
||||
if (links.length === 0) return Promise.resolve();
|
||||
return Promise.all(
|
||||
links.map(
|
||||
(node) =>
|
||||
new Promise<void>((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 = (
|
||||
<StrictMode>
|
||||
<Page />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
65
frontend/renderer/+onRenderHtml.tsx
Normal file
65
frontend/renderer/+onRenderHtml.tsx
Normal file
@@ -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<string> {
|
||||
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(<Page url={url} />));
|
||||
} catch (err) {
|
||||
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
|
||||
pageHtml = '';
|
||||
}
|
||||
}
|
||||
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="拾三一隅,自在交流" />
|
||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||
<script>${dangerouslySkipEscape(THEME_INIT_SCRIPT)}</script>
|
||||
<script>${dangerouslySkipEscape(ZOOM_BLOCK_SCRIPT)}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user