Files
jiang13-forum/frontend/renderer/+onRenderHtml.tsx
freefire 2392768868 feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复
- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined:
  抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫
- 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到
  各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记
- 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false,
  useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出,
  head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块)
- 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、
  探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image
- Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、
  自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生
  历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView +
  scroll-margin-top 适配 sticky 顶栏
2026-09-09 20:53:28 +08:00

79 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { createHomeStore } from '../src/store/homeStore';
import { createRequestScope } from '../src/ssr/scopeTypes';
import { runWithScope } from '../src/ssr/scope';
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
import { renderHeadTags } from '../src/ssr/head';
import { installServerDom } from '../src/ssr/serverDom';
import { readThemeFromCookie } from '../src/utils/theme';
import { createSsrApp } from '../src/App';
// 服务端 DOM polyfill:内容处理依赖 DOMParser/document,须在渲染前安装
installServerDom();
export { onRenderHtml };
type PageComponent = ComponentType<{ url?: string }>;
/** 等 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;
const data = (pageContext as { data?: FeedData }).data;
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, async () => {
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
seedFeedData(data);
// 官方 SSR 路径:await 首导航后 StaticRouterProvider 同步渲染(避免 RouterProvider suspend
// 导致内容落入 <div hidden>,禁 JS 爬虫不可见)
const app = await createSsrApp(url);
return renderPageToString(app);
});
} catch (err) {
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
pageHtml = '';
}
}
const headTags = renderHeadTags(data?.head);
// 主题从请求 Cookie 读取,首帧直接渲染到 <html> 上(深色用户无白屏闪烁,无需内联脚本)
// headersOriginal 经 Vike 归一化为 pageContext.headers(键全小写)
const cookieHeader = (pageContext as { headers?: Record<string, string> | null }).headers?.cookie;
const dark = readThemeFromCookie(cookieHeader) === 'dark';
const htmlAttrs = dark ? ' class="dark" style="color-scheme:dark"' : '';
// 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
return escapeInject`<!DOCTYPE html>
<html lang="zh-CN"${dangerouslySkipEscape(htmlAttrs)}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${dangerouslySkipEscape(headTags)}
</head>
<body>
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
</body>
</html>`;
}