← 返回
未分类

PageSpeed Insights SEO 优化

Optimize React/Vite SPAs for SEO and PageSpeed performance. Covers HTML shell, JSON-LD structured data, font optimization, Cloudflare Pages headers, image sitemaps, Core Web Vitals, Cumulative Layout Shift (CLS), and Lighthouse accessibility fixes.
Optimize React/Vite SPAs for SEO and PageSpeed performance. Covers HTML shell, JSON-LD structured data, font optimization, Cloudflare Pages headers, image sitemaps, Core Web Vitals, Cumulative Layout Shift (CLS), and Lighthouse accessibility fixes.
Klesen
未分类 community v1.0.1 2 版本 100000 Key: 无需
★ 0
Stars
📥 12
下载
💾 0
安装
2
版本
#latest

概述

SPA SEO & Performance Optimization

Trigger

User asks to SEO/PageSpeed optimize a React/Vite SPA, or a PageSpeed report reveals issues.

Class-level approach

1. Audit the current state

  • Fetch the live site's response headers (curl -sI) — check x-robots-tag, CSP, caching
  • Check _headers, _redirects, robots.txt, sitemap.xml in the project
  • Identify font weight using @font-face analysis in CSS
  • Check for Google/Baidu verification tags

2. Fix critical issues first (SEO scoring)

IssueFix
------------
x-robots-tag: noindexAdd X-Robots-Tag: index, follow to _headers for /*.html and /
Missing hreflangAdd for all language variants + x-default
No Baidu verificationGet code from ziyuan.baidu.com, add to HTML
Missing OG tagsAdd og:site_name, og:image:alt, og:locale

3. Font optimization (@fontsource projects)

Case A: Latin-only fonts (Inter, JetBrains Mono, Noto Sans/Serif Latin)

Before: Inter 400/500/600/700 + Noto Serif Latin 500/600 + Noto Serif CJK 500/600 + JetBrains Mono 400/500
After:  Inter 400/500/600/700 + Noto Serif CJK 500/600 ONLY
Savings: ~78KB + 2-4 fewer font requests

Steps:

  1. Remove Noto Serif SC Latin subsets — CJK range already covers Latin chars
  2. Remove JetBrains Mono @font-face — replace with "SF Mono", "Consolas", monospace in CSS classes
  3. Replace .font-mono { font-family: "JetBrains Mono"... } with system monospace
  4. Replace .label-text font-family similarly
  5. Change font-display: swap to font-display: optional for Latin fonts — prevents font-swap CLS (body-wide ~0.586) on all text.

Case B: CJK/Chinese fonts (⚠️ #1 perf killer for Chinese sites)

CJK font packages are ~1.5MB per font weight. Two weights = 3MB.

Fix: Remove entirely. System CJK serif fonts (Songti SC on Mac, SimSun on Windows) ship on every OS. Update font-family to use system fonts only, rebuild, verify no woff2 in dist.

Alternative (subset): Google Fonts API with text= parameter limits to needed characters (5-15KB instead of 1.5MB).

3b. Vendor chunk splitting (Vite build optimization)

If PageSpeed reports "Reduce unused JavaScript" for the vendor bundle, split React chunks:

rollupOptions: {
  output: {
    manualChunks(id) {
      if (id.includes('node_modules/react-dom')) return 'vendor-dom';
      if (id.includes('node_modules/react')) return 'vendor-react';
      if (id.includes('node_modules/')) return 'vendor-misc';
    },
  },
},

Before: one vendor.js (59KB, 25KB flagged as unused)

After: vendor-react (8KB) + vendor-dom (180KB) + vendor-misc (7KB)

Pitfall: react-dom's size is not unused code — it's the hydration runtime. The PSI warning for vendor chunks is a false positive for SPAs.

3c. React → Preact swap (nuclear JS reduction)

When "Reduce unused JavaScript" persists even after chunk splitting (react-dom still 180KB, 22-25KB unused), swap React for Preact via aliases. Zero code changes needed — Preact is API-compatible.

// vite.config.ts — add to resolve.alias
resolve: {
  alias: {
    'react': 'preact/compat',
    'react-dom': 'preact/compat',
    'react-dom/client': 'preact/compat',
    'react/jsx-runtime': 'preact/jsx-runtime',
  },
},

After the alias, update manualChunks since there's no separate react-dom chunk anymore:

manualChunks(id) {
  if (id.includes('node_modules/@emailjs')) return 'vendor-email';
  if (id.includes('node_modules/')) return 'vendor-misc';
},

Results from real project (55KB gzip total JS → 12.8KB gzip main + 7.8KB misc):

ChunkBefore (React)After (Preact)
--------------------------------------
vendor-dom180KB (56KB gzip)vanishes entirely 🗑️
vendor-react8KB (3KB gzip)vanishes entirely 🗑️
Index (main)34KB (13KB gzip)33KB (12.8KB gzip) — includes Preact runtime

Total gzip JS: 75KB → 22KB. Lighthouse "unused JS" warning goes from 22.5KB to ~0.

Installation: npm install preact

Pitfall: Preact does NOT include SyntheticEvent (React's cross-browser event wrapper). If code accesses e.nativeEvent or relies on event pooling, it may break. For standard form submissions and click handlers, Preact works identically.

3d. Eliminate third-party SDKs in favor of raw fetch()

When Lighthouse or PageSpeed flags an external vendor chunk (e.g., @emailjs/browser at 3.5KB), check if the library's REST API is simple enough to call directly.

EmailJS example — replace the SDK with raw fetch():

- import emailjs from "@emailjs/browser";
- await emailjs.send(SERVICE_ID, TEMPLATE_ID, params, { publicKey: KEY });
+ const res = await fetch("https://api.emailjs.com/api/v1.0/email/send", {
+   method: "POST",
+   headers: { "Content-Type": "application/json" },
+   body: JSON.stringify({
+     service_id: SERVICE_ID,
+     template_id: TEMPLATE_ID,
+     user_id: PUBLIC_KEY,
+     template_params: { name, email, ... },
+   }),
+ });
+ if (!res.ok) throw new Error(`EmailJS responded with ${res.status}`);

Savings: ~3.5KB removed (SDK + dependency tree), 31 modules vs 51 modules transformed.

⚠️ CSP update required: Add the API endpoint to connect-src in _headers:

- connect-src 'self'
+ connect-src 'self' https://api.emailjs.com

Rule of thumb: Before adding any npm SDK, check if the service exposes a simple REST API. If yes, raw fetch() + key exposed in browser contexts anyway — no benefit from the SDK wrapper.

3e. Switch minifier: esbuild → terser

esbuild is the Vite default (faster builds) but terser does better dead-code elimination. Switch when:

  • "Reduce unused JavaScript" persists after chunk splitting and Preact swap
  • drop_console is desired (strips all console.log from production bundles)
  • Even marginal KB savings matter (vendor-dom shrank ~2KB gzip)

Config:

build: {
  minify: 'terser',          // replaces 'esbuild'
  terserOptions: {
    compress: {
      drop_console: true,    // strips console.log/debug/error
      drop_debugger: true,
      passes: 2,             // multiple optimization passes
    },
    format: {                // ⚠️ use 'format' NOT 'output' (deprecated in terser 6)
      comments: false,
    },
  },
},

Installation: npm install -D terser

Results: vendor-dom 180.29KB (56.27KB gzip) → 178.55KB (55.73KB gzip). Modest but meaningful, and the console stripping prevents accidental debug log leaks.

4. HTML shell optimization (source index.html)

<!-- Resource Hints: preconnect to third-party origins -->
<link rel="dns-prefetch" href="//static.cloudflareinsights.com" />
<link rel="preconnect" href="https://static.cloudflareinsights.com" />

<!-- Preload hero image (LCP element) -->
<link rel="preload" href="./images/hero-showcase.webp" as="image" fetchpriority="high" />

Preconnect rules: 3-4 max. Always pair dns-prefetch + preconnect. Check CSP first — preconnecting to a blocked origin wastes the connection.

Common origins: static.cloudflareinsights.com, Google Fonts/Analytics (if CSP allows), CDN font origins.

5. Post-build plugin regex trap (expanded)

Vite post-build plugins (like html-transform) that inject/replace content via regex silently fail when the source HTML format changes (e.g., comment style vs ).

Fix pattern: Match the ACTUAL comment text in source index.html, not a stylized delimiter:

// BROKEN: plugin regex looks for delimiters the source doesn't use
html.replace(/<!-- ===== Critical CSS ===== -->[\s\S]*?<\/style>/, replacement)

// FIXED: match the real comment text in source index.html
html.replace(/<!-- Inline critical CSS to prevent CLS -->[\s\S]*?<\/style>/, replacement)

Verification: Always inspect dist/index.html after build — grep for the injected classes:

grep -c 'font-display' dist/index.html       # > 0 = critical CSS expanded
grep -c 'preconnect' dist/index.html          # > 0 = hints survived
grep -c 'media="print"' dist/index.html       # > 0 = async CSS loading
ls dist/assets/*.woff2                         # font files exist

5a. Preload main JS entry in post-build plugin

In the same html-transform plugin, parse the built HTML to find the main JS entry (index-*.js) and add it as an explicit before the script tag. This lets the browser discover and download the main JS entry even sooner:

// Inside html-transform plugin's closeBundle handler
const mainJsMatch = html.match(/<script type="module" crossorigin src="(\.\/assets\/index-[^"]+\.js)">/);
if (mainJsMatch) {
  const mainJsUrl = mainJsMatch[1];
  if (!html.includes(`href="${mainJsUrl}"`)) {
    html = html.replace(
      mainJsMatch[0],
      `<link rel="modulepreload" href="${mainJsUrl}" />\n    ${mainJsMatch[0]}`,
    );
  }
}

This adds critical-path discovery without changing the execution order. Verify:

grep -c 'modulepreload.*index-' dist/index.html  # > 0 = JS preload active

Workflow Preference for This User

Verify current state before applying optimizations. This user explicitly asks to re-read the latest report and compare with current source/dist state before making any changes. Do not jump to fixes — first audit what's actually different between report claims and local files. Use execute_code or parallel read_file + terminal calls to generate a side-by-side comparison and present findings before writing code.

Lighthouse Report → Source Code Optimization Pipeline

When PageSpeed/Lighthouse returns a list of suggestions, do NOT apply them blindly — the report may be stale or based on cached content.

Step 1: Verify report accuracy against actual files

Image dimensions: Lighthouse reports HTML attribute values, not actual file dimensions. Cross-check:

# macOS (sips)
sips -g pixelWidth -g pixelHeight public/images/*.webp | grep -E "(pixelWidth|pixelHeight|\.webp)"
# ImageMagick
identify public/images/*.webp

Common finding: Images were already resized to display size but HTML width/height attributes reference old larger values.

Fix: Update React component attributes to match actual file dimensions:

// Before: stale attributes (wrong aspect-ratio → CLS)
width="1344" height="768"
// After: matches actual file on disk
width="1200" height="686"

Step 2: Apply image-specific fixes

See references/image-resize-workflow.md. Key addition: After resizing images, ALWAYS sync the width/height attributes in React components (Hero.tsx, Industries.tsx, Cases.tsx are common sites).

Step 2a: Re-compress images for further savings (after resizing)

After verifying dimensions are correct (Step 1), re-encode ALL resized images at quality 75 for additional byte savings beyond dimension-only reduction:

from PIL import Image
import os, glob

images_dir = 'public/images'
for f in sorted(glob.glob(f'{images_dir}/*.webp')):
    name = os.path.basename(f)
    if 'qr' in name: continue  # skip tiny files
    before = os.path.getsize(f)
    img = Image.open(f)
    img.save(f, 'WEBP', quality=75, method=6)
    after = os.path.getsize(f)
    print(f'{name}: {before/1024:.1f} KiB -> {after/1024:.1f} KiB')

⚠️ Re-compression trap: Re-encoding an already-optimized WebP at the same or higher quality can INCREASE file size (double-compression artifacts). Always use quality ≤ original. Test 3-5 images first before batch-running. Skip images that grow (revert with git checkout).

Typical results from this session:

Image typeBefore resizeAfter resize+compressTotal savings
-------------------------------------------------------------
hero-showcase (1344x768)28 KiB9.8 KiB (800×457, q75)-65%
case images (1184x635)49-82 KiB5.5-16 KiB (480×350, q75)-70-87%
industry images (1184×864)23-74 KiB7-21 KiB (600×438, originals)-70-90%

Step 3: Apply structural fixes

  • Add preconnect hints (section 4 above)
  • Split vendor chunks if "Reduce unused JavaScript" warning is significant (section 3b)
  • Verify critical CSS expansion survived post-build plugin (section 5)

Step 4: Rebuild and verify

# CLEAN rebuild — ensures no stale images/files in dist/
rm -rf dist && npm run build

# Verify everything survived the post-build plugin
grep -c 'font-display' dist/index.html
grep -c 'preconnect' dist/index.html
grep -c 'modulepreload.*index-' dist/index.html   # JS entry preload (if added)

# Verify fresh images were copied from public/
sips -g pixelWidth -g pixelHeight dist/images/hero-showcase.webp 2>/dev/null | grep pixel
ls -lh dist/images/*.webp | head -5

JSON-LD structured data

Always include these schema types when applicable:

  1. Organization — @id, logo, sameAs, knowsAbout, areaServed (array of Country objects, not array of strings)
  2. Service — @id, inLanguage, provider refs Organization @id
  3. WebSite — @id, publisher refs Organization @id
  4. BreadcrumbList — @id, first item should use real URL not #hero
  5. ItemList — for case studies/portfolio, include datePublished
  6. FAQPage — rich snippet potential, target 6-8 questions

Noscript fallback for crawlers

Place AFTER tag (NOT in ):

<noscript>
  <h1>Site Name — Description</h1>
  <p>Summary paragraph...</p>
  <h2>Services</h2>
  <ul><li>Service 1</li></ul>
  <p>Contact: email@example.com</p>
</noscript>

⚠️ Vite's HTML parser rejects block elements inside

Sitemap with image extensions

Add xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" to . Each image needs block with and .

Cloudflare Pages specifics

  • _headers must include X-Robots-Tag: index, follow — Cloudflare may add noindex by default on Pages
  • CSP script-src 'self' blocks Google Analytics/GTM — update if needed
  • SPA fallback in _redirects: /* /index.html 200

Browser Console Verification (when PSI API is unavailable)

// Get ALL resource transfer sizes
performance.getEntriesByType('resource').map(r => ({
  name: r.name.split('/').pop(),
  size: r.transferSize,
  type: r.initiatorType,
  duration: r.duration.toFixed(0) + 'ms'
}))
// Get total page transfer
performance.getEntriesByType('resource').reduce((s, r) => s + (r.transferSize || 0), 0)
// Filter for fonts specifically
performance.getEntriesByType('resource').filter(r => r.name.includes('woff2'))
  .map(r => ({ name: r.name.split('/').pop(), size: r.transferSize }))

PSI API Quota Pitfall

PSI free tier has a daily query limit (~25K/day). On HTTP 429, use curl-based offline analysis:

curl -sI https://example.com                                     # headers, caching
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}\n" url      # TTFB
curl -s url | grep -E '(title|meta|script type="application/ld|link rel=")'  # SEO meta
curl -sI url | grep -i cf-cache-status                            # CDN status

Rule: One PSI API call per session — for final verification only.


SPA-Specific CLS Prevention

React SPAs suffer CLS from three distinct root causes. Fix all three to get CLS below 0.1.

Root Cause 1: Hydration Gap (the app-shell trap)

The traditional fix is a

that mirrors the React header. This is wrong. The app-shell's content (SVG icon with multiple , elements, brand text, inline styles) never exactly matches the React-rendered
— any structural or dimensional difference causes a measurable CLS during hydration.

Fix: Remove the app-shell entirely. No shell = nothing to swap.

- <div id="root">
-   <div id="app-shell" style="position:fixed;...">svg...</div>
- </div>
+ <div id="root" style="padding-top:72px;min-height:100vh;"></div>

How this eliminates CLS:

  1. #root has padding-top: 72px — content area starts 72px from the top
  2. No app-shell exists to be replaced — nothing swaps during hydration
  3. React adds the fixed
    on top of the padding area
  4. Zero DOM swap = zero layout shift

Update critical CSS — remove the #app-shell rule:

 #root { min-height: 100vh; padding-top: 72px; }
-#app-shell { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: 72px; }

Result: CLS drops from >1.0 to <0.05 on desktop Lighthouse. Bonus: index.html shrinks by ~1.7KB (no more embedded SVG).

Root Cause 2: Font-Swap CLS (Latin fonts)

font-display: swap on Latin fonts (Inter, 24KB each) causes body-wide CLS (~0.586).

Fix: Use font-display: optional instead of swap for preloaded Latin fonts. Preloaded fonts still arrive in time on fast connections; on slow connections the fallback stays. Zero CLS either way.

LCP Hero Text Font Fallback

Even with font-display: optional, the hero title (often the LCP element) may use a serif display font (.font-display class) that the browser hasn't loaded yet. Without a system-serif fallback in critical CSS, the title text renders invisibly — no CLS, but delayed LCP measurement.

Fix: Add a system-serif fallback to BOTH the source index.html's inline