mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
perf(web): split the reader and admin screens out of the first load
client.js was one 1.48 MB bundle (413 kB gzip) because the build assumed the Android shell loads from file://. It no longer does: WebHost serves the bundle from https://appassets.androidplatform.net/ via WebViewAssetLoader, the service worker precaches every emitted file, and the server serves any file under assets/ (caching hashed names forever). - The reader, media library/task/item admin pages and source editor pages load on first visit (app/router/lazy-pages.tsx); wrappers keep the page names, so the route tree and its tests read unchanged. - Chunks are content-hashed; the entry stays client.js and CSS stays one index.css, so no host-facing name changes and no unstyled flash. - A tab running an old client after a deploy gets "应用已更新 · 重新加载" instead of a dynamic-import error. - ui:review now opens a book and asserts the reader chunk loads and renders. client.js: 1478 kB -> 777 kB (gzip 413 -> 231 kB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
597369392e
commit
fbb7298bf0
5 files changed
+92
-22
No files matched your search
@@ -1,9 +1,22 @@
|
||||
import { isRouteErrorResponse, useRouteError, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../ui/toolkit.tsx';
|
||||
|
||||
/**
|
||||
* A lazily loaded screen (lazy-pages.tsx) whose chunk is gone: the server was updated
|
||||
* while this tab still ran the old client, which asks for the old chunk hashes. The
|
||||
* fix is a reload, so the screen says that instead of echoing the browser's message.
|
||||
*/
|
||||
const staleChunk = (error: unknown) => error instanceof Error
|
||||
&& /dynamically imported module|Importing a module script failed|error loading dynamically imported/i.test(error.message);
|
||||
|
||||
export function RouteErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const navigate = useNavigate();
|
||||
if (staleChunk(error)) return <div className="page-state" role="alert">
|
||||
<h2>应用已更新</h2>
|
||||
<p>重新加载后即可继续,书架和阅读进度不受影响。</p>
|
||||
<Button className="primary" onClick={() => window.location.reload()}>重新加载</Button>
|
||||
</div>;
|
||||
const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : '页面加载失败';
|
||||
return <div className="page-state" role="alert">
|
||||
<h2>页面暂时不可用</h2>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { lazy, Suspense, type ComponentType, type LazyExoticComponent } from 'react';
|
||||
import { FloatingNotice } from '../../ui/floating-notice.tsx';
|
||||
|
||||
/**
|
||||
* Screens that most sessions never open, loaded on first visit.
|
||||
*
|
||||
* The reader (EPUB/PDF/ZIP parsing, pagination, TTS) and the administrator screens
|
||||
* were half of a 1.5 MB first load for someone who only opens the shelf. Each
|
||||
* wrapper keeps the page's own name, so the route tree still reads — and is tested
|
||||
* — as `ReaderPage`, `MediaLibrariesPage`, … rather than as anonymous lazy objects.
|
||||
*/
|
||||
function deferred<M, K extends keyof M>(load: () => Promise<M>, name: K): LazyExoticComponent<ComponentType> {
|
||||
return lazy(() => load().then(module => ({ default: module[name] as ComponentType })));
|
||||
}
|
||||
|
||||
const fallback = <FloatingNotice message="正在加载…" busy />;
|
||||
|
||||
const reader = () => import('../../features/reader/pages/ReaderPage.tsx');
|
||||
const libraries = () => import('../../features/media/pages/MediaLibrariesPage.tsx');
|
||||
const libraryEditors = () => import('../../features/media/pages/MediaLibraryEditorPages.tsx');
|
||||
const tasks = () => import('../../features/media/pages/MediaTasksPage.tsx');
|
||||
const itemManagement = () => import('../../features/media/pages/MediaItemManagementPages.tsx');
|
||||
const sourceEditor = () => import('../../features/sources/pages/SourceEditorPage.tsx');
|
||||
const sourceCredentials = () => import('../../features/sources/pages/SourceCredentialsPage.tsx');
|
||||
const sourceExtension = () => import('../../features/sources/pages/SourceExtensionPage.tsx');
|
||||
|
||||
const Reader = deferred(reader, 'ReaderPage');
|
||||
const Libraries = deferred(libraries, 'MediaLibrariesPage');
|
||||
const LibraryCreate = deferred(libraries, 'MediaLibraryCreatePage');
|
||||
const LibraryEdit = deferred(libraryEditors, 'MediaLibraryEditPage');
|
||||
const LibraryPermissions = deferred(libraryEditors, 'MediaLibraryPermissionsPage');
|
||||
const Tasks = deferred(tasks, 'MediaTasksPage');
|
||||
const MetadataEdit = deferred(itemManagement, 'MediaMetadataEditPage');
|
||||
const Match = deferred(itemManagement, 'MediaMatchPage');
|
||||
const Chapters = deferred(itemManagement, 'MediaChaptersPage');
|
||||
const Edition = deferred(itemManagement, 'MediaEditionPage');
|
||||
const Structure = deferred(itemManagement, 'MediaStructurePage');
|
||||
const SourceEditor = deferred(sourceEditor, 'SourceEditorPage');
|
||||
const SourceCredentials = deferred(sourceCredentials, 'SourceCredentialsPage');
|
||||
const SourceExtension = deferred(sourceExtension, 'SourceExtensionPage');
|
||||
|
||||
export function ReaderPage() { return <Suspense fallback={fallback}><Reader /></Suspense>; }
|
||||
export function MediaLibrariesPage() { return <Suspense fallback={fallback}><Libraries /></Suspense>; }
|
||||
export function MediaLibraryCreatePage() { return <Suspense fallback={fallback}><LibraryCreate /></Suspense>; }
|
||||
export function MediaLibraryEditPage() { return <Suspense fallback={fallback}><LibraryEdit /></Suspense>; }
|
||||
export function MediaLibraryPermissionsPage() { return <Suspense fallback={fallback}><LibraryPermissions /></Suspense>; }
|
||||
export function MediaTasksPage() { return <Suspense fallback={fallback}><Tasks /></Suspense>; }
|
||||
export function MediaMetadataEditPage() { return <Suspense fallback={fallback}><MetadataEdit /></Suspense>; }
|
||||
export function MediaMatchPage() { return <Suspense fallback={fallback}><Match /></Suspense>; }
|
||||
export function MediaChaptersPage() { return <Suspense fallback={fallback}><Chapters /></Suspense>; }
|
||||
export function MediaEditionPage() { return <Suspense fallback={fallback}><Edition /></Suspense>; }
|
||||
export function MediaStructurePage() { return <Suspense fallback={fallback}><Structure /></Suspense>; }
|
||||
export function SourceEditorPage() { return <Suspense fallback={fallback}><SourceEditor /></Suspense>; }
|
||||
export function SourceCredentialsPage() { return <Suspense fallback={fallback}><SourceCredentials /></Suspense>; }
|
||||
export function SourceExtensionPage() { return <Suspense fallback={fallback}><SourceExtension /></Suspense>; }
|
||||
@@ -12,26 +12,18 @@ import { RouteErrorBoundary } from './error-boundary.tsx';
|
||||
import { ShelfPage } from '../../features/shelf/pages/ShelfPage.tsx';
|
||||
import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features/library/pages/LibraryPage.tsx';
|
||||
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
|
||||
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
|
||||
import { MinePage } from '../../features/account/pages/MinePage.tsx';
|
||||
import { HomePage } from '../../features/home/pages/HomePage.tsx';
|
||||
import { SearchPage } from '../../features/search/pages/SearchPage.tsx';
|
||||
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
|
||||
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
|
||||
import { MediaTasksPage } from '../../features/media/pages/MediaTasksPage.tsx';
|
||||
import { MediaFoldersPage } from '../../features/media/pages/MediaFoldersPage.tsx';
|
||||
import { MediaNarratorsPage } from '../../features/media/pages/MediaNarratorsPage.tsx';
|
||||
import {
|
||||
MediaMetadataEditPage,
|
||||
MediaMatchPage,
|
||||
MediaChaptersPage,
|
||||
MediaEditionPage,
|
||||
MediaStructurePage,
|
||||
} from '../../features/media/pages/MediaItemManagementPages.tsx';
|
||||
ReaderPage,
|
||||
MediaLibrariesPage, MediaLibraryCreatePage, MediaLibraryEditPage, MediaLibraryPermissionsPage, MediaTasksPage,
|
||||
MediaMetadataEditPage, MediaMatchPage, MediaChaptersPage, MediaEditionPage, MediaStructurePage,
|
||||
SourceEditorPage, SourceCredentialsPage, SourceExtensionPage,
|
||||
} from './lazy-pages.tsx';
|
||||
import { SourceCatalogPage } from '../../features/sources/pages/SourceCatalogPage.tsx';
|
||||
import { SourceEditorPage } from '../../features/sources/pages/SourceEditorPage.tsx';
|
||||
import { SourceCredentialsPage } from '../../features/sources/pages/SourceCredentialsPage.tsx';
|
||||
import { SourceExtensionPage } from '../../features/sources/pages/SourceExtensionPage.tsx';
|
||||
|
||||
export const routes = [{
|
||||
path: '/', element: <AppShell />, errorElement: <RouteErrorBoundary />, children: [
|
||||
|
||||
@@ -215,6 +215,12 @@ async function runReaderReview(browser, origin, checks, errors) {
|
||||
await page.goto(`${origin}/#/sources`);
|
||||
await waitHeading(page, '书源');
|
||||
await capture(page, 'sources', checks, errors);
|
||||
// The reader is a lazily loaded chunk (app/router/lazy-pages.tsx): opening a book
|
||||
// must fetch it and render the chapter, not stop at the Suspense fallback.
|
||||
const readerChunk = page.waitForResponse(response => /\/assets\/ReaderPage-[\w-]+\.js$/.test(response.url()));
|
||||
await page.goto(`${origin}/#/book/review-book`);
|
||||
assert.equal((await readerChunk).status(), 200);
|
||||
await page.locator('book-content').getByText('第 1 段', { exact: false }).first().waitFor();
|
||||
// Hash history is part of the router contract.
|
||||
await page.goto(`${origin}/#/shelf`);
|
||||
await page.goto(`${origin}/#/library`);
|
||||
|
||||
+13
-9
@@ -2,14 +2,17 @@ import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { pwa } from './tools/pwa/build.ts';
|
||||
|
||||
// The build output is consumed twice:
|
||||
// The build output is consumed three ways:
|
||||
// - served directly by the server for the browser/H5 build
|
||||
// - copied into the Android app's assets and loaded from file:// in a WebView
|
||||
// - precached by the service worker (tools/pwa/build.ts lists every emitted file)
|
||||
// - copied into the Android app's assets and served by WebViewAssetLoader from
|
||||
// https://appassets.androidplatform.net/ (see WebHost.kt), not from file://
|
||||
//
|
||||
// Because of the second consumer the build is a single self-contained bundle
|
||||
// with relative asset URLs (base: './') and no code-splitting: a WebView loading
|
||||
// from file:// cannot fetch sibling chunks over the network, and every extra
|
||||
// request through the file protocol is a chance to meet a platform quirk.
|
||||
// So relative asset URLs (base: './'), a fixed entry name (client.js) and one
|
||||
// stylesheet (index.css, so a lazily loaded screen never paints unstyled), but
|
||||
// rarely used screens (the reader, admin pages; app/router/lazy-pages.tsx) are
|
||||
// split into content-hashed chunks: every host fetches them like any sibling file,
|
||||
// and the server caches the hashed names forever.
|
||||
export default defineConfig({
|
||||
plugins: [react(), pwa()],
|
||||
base: './',
|
||||
@@ -18,12 +21,13 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
target: 'es2020',
|
||||
assetsInlineLimit: 0,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: 'assets/client.js',
|
||||
chunkFileNames: 'assets/[name].js',
|
||||
assetFileNames: 'assets/[name][extname]',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
// With cssCodeSplit off Vite names the one stylesheet style.css; keep index.css.
|
||||
assetFileNames: asset => asset.names?.[0] === 'style.css' ? 'assets/index.css' : 'assets/[name][extname]',
|
||||
},
|
||||
},
|
||||
modulePreload: { polyfill: false },
|
||||
|
||||
Reference in new issue
Block a user