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 顶栏
This commit is contained in:
@@ -3,9 +3,10 @@ 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 { installZoomGuard } from '../src/utils/zoomGuard';
|
||||
import { beginHomeHydrate } from '../src/utils/homeHydrate';
|
||||
import { reloadForStaleChunk } from '../src/utils/chunkLoad';
|
||||
import { seedFeedData, type FeedData } from '../src/ssr/feedData';
|
||||
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
|
||||
import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
|
||||
|
||||
export { onRenderClient };
|
||||
@@ -40,6 +41,7 @@ function waitForStylesheets(): Promise<void> {
|
||||
|
||||
async function onRenderClient(pageContext: PageContextClient) {
|
||||
applyTheme(getStoredTheme());
|
||||
installZoomGuard();
|
||||
// hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
|
||||
if (pageContext.isHydration) {
|
||||
seedFeedData((pageContext as { data?: FeedData }).data);
|
||||
@@ -54,6 +56,16 @@ async function onRenderClient(pageContext: PageContextClient) {
|
||||
const el = document.getElementById('root');
|
||||
if (!el) return;
|
||||
|
||||
// 有 SSR 正文 → hydrate 接管同一 DOM(首帧数据已由 +data seed,无需冷启动预热);
|
||||
// SPA 壳 / SSR 降级空壳 → createRoot 挂载,挂载前先冷启动预热,避免空白首帧
|
||||
const hydrating = pageContext.isHydration && el.innerHTML.trim() !== '';
|
||||
|
||||
// 仅非 hydrate 路径需要冷启动:SSR 页 seed 已灌入缓存,冷启动只会重复请求并阻塞 hydrate
|
||||
if (!hydrating && isMainLayoutPath(window.location.pathname)) {
|
||||
const path = `${window.location.pathname}${window.location.search}`;
|
||||
await ensureColdBootReady(path);
|
||||
}
|
||||
|
||||
await waitForStylesheets();
|
||||
try {
|
||||
if (document.fonts?.ready) await document.fonts.ready;
|
||||
@@ -61,13 +73,6 @@ async function onRenderClient(pageContext: PageContextClient) {
|
||||
/* 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;
|
||||
|
||||
@@ -5,9 +5,11 @@ 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/feedData';
|
||||
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();
|
||||
@@ -16,14 +18,6 @@ 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, {
|
||||
@@ -47,10 +41,13 @@ async function onRenderHtml(pageContext: PageContextServer) {
|
||||
const { pathname, searchOriginal } = pageContext.urlParsed;
|
||||
const url = pathname + (searchOriginal ?? '');
|
||||
try {
|
||||
pageHtml = await runWithScope(scope, () => {
|
||||
pageHtml = await runWithScope(scope, async () => {
|
||||
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
|
||||
seedFeedData(data);
|
||||
return renderPageToString(<Page url={url} />);
|
||||
// 官方 SSR 路径:await 首导航后 StaticRouterProvider 同步渲染(避免 RouterProvider suspend
|
||||
// 导致内容落入 <div hidden>,禁 JS 爬虫不可见)
|
||||
const app = await createSsrApp(url);
|
||||
return renderPageToString(app);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
|
||||
@@ -60,15 +57,19 @@ async function onRenderHtml(pageContext: PageContextServer) {
|
||||
|
||||
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">
|
||||
<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)}
|
||||
<script>${dangerouslySkipEscape(THEME_INIT_SCRIPT)}</script>
|
||||
<script>${dangerouslySkipEscape(ZOOM_BLOCK_SCRIPT)}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
|
||||
|
||||
Reference in New Issue
Block a user