perf(frontend-ssr): 修文档页移动端 500,LH 拉升至 95/86/93,主题色改 #299764

一揽子水合修复 + 性能优化 + 品牌色更换:

- 修 P0:/doc/** 撤预渲染改回 SSR——预渲染 HTML 恒为宽屏,真机 UA 下
  客户端窄屏判定与之失配导致整页 500。窄屏判定收口到
  composables/useShellNarrow.ts(服务端按 sec-ch-ua-mobile/UA 判、客户端
  沿用序列化值、爬虫强制宽屏),移动端 /doc 恢复 200 且水合成本大降
- 图片治理:markdown 渲染注入 loading=lazy + decoding=async + 真实宽高;
  尺寸清单 config/doc-image-dims.json 由 nuxt.config 构建期扫 public/ 生成
  (CF Workers 无 fs,只能构建期算),unsized-images fail→pass
- 源站压缩:compressPublicAssets(142 个 .gz/.br)+ server/plugins/
  compress-html.ts(SSR HTML 按 Accept-Encoding 回 br/gzip,首页
  35KB→7.5KB),消除 Lantern 无压缩高估
- a11y:/dns 的 el-select 补 aria-label(92→97)
- NuxtLink 预取改 interaction(省 idle 期观察者任务,ssl TBT 370→120ms)
- 主题色全局 #3EAF7C → #299764(17 文件 38 处,白底对比 2.75→3.68:1)

LH 实证(mobile):/ 67→95、/dns 61→86、/ssl 57→93;桌面 / 100、/dns 99、
/ssl 98。零新增 fail;unsized-images、unused-css/js、interactive、
speed-index 等一批 fail→pass。剩余 fail:bf-cache(LH 内部错误)、
color-contrast(品牌绿 3.68:1,需 4.5:1,知情保留)、TBT(页面 chunk
求值+水合地板,再降需组件级懒水合,UX 有损暂缓)。
This commit is contained in:
nomdn committed 2026-10-04 15:49:16 +08:00
1 parent 924b9bcc36
commit 243060a32a
26 files changed
+254 -52

No files matched your search

+17 -3
View File
@@ -4,7 +4,21 @@ import { useDark, useToggle } from '@vueuse/core';
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
import { config } from '../config/index';
const isNarrow = ref(false);
// ==================== 窄屏判定(水合前就必须定下来) ====================
// 为什么不能等到 onMounted 才算:SSR 不知道视口宽度,只能按宽屏输出 ——
// 于是整条桌面版顶栏菜单(10 个条目 + 分隔线 + 3 个下拉)会被下发给手机;
// 水合时 isNarrow 还是 false,手机就先把这一整套水合一遍
// (实测 375×812:header-menu 区域 268 次 DOM 变更、70 次事件绑定),
// 紧接着 onMounted 把它改成窄屏、再整棵销毁重建 —— 这一整轮纯属白干。
// 所以改成「服务端按请求头判定、客户端直接沿用服务端序列化下来的结论」,
// 两边输入一致 ⇒ 结论一致 ⇒ 水合期不产生任何分歧,桌面上仍是原来的形态。
// 权威值依旧由 onMounted 的 matchMedia 覆盖,管「桌面窗口被拖窄」这类请求头判不出的情形。
//
// 注意:**不要**在客户端重新嗅探 navigator.userAgent。SSR 页两边大概率巧合一致,
// 但预渲染页没有请求头(服务端恒判宽屏),客户端一嗅探就必然分叉 ——
// 实测那会让整个 /doc/** 在真手机上水合失配、Nuxt 直接渲染 500 页。
// 判定逻辑与文档见 app/composables/useShellNarrow.ts。
const isNarrow = useShellNarrow()
let mediaQueryList: MediaQueryList | null = null;
const drawer = ref(false);
@@ -345,7 +359,7 @@ onMounted(() => {
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
/* 暗色模式下根元素的文字色与页面底色。
@@ -354,7 +368,7 @@ html.dark {
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
/* Drawer 内部链接占满一行。
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
@@ -0,0 +1,34 @@
/**
* 顶栏 / 抽屉「宽屏还是窄屏」的唯一判定入口(服务端算一次,客户端沿用)。
*
* 为什么客户端不自己嗅探 navigator.userAgent:
* SSR 出的 HTML 是跟着【服务端】的判定走的。客户端若各算各的,只要两边结论不一致,
* Vue 水合就会整棵树失配 —— 实测在预渲染的 /doc/** 上是直接抛
* `Cannot read properties of null (reading 'nodeType')`,然后被 Nuxt 渲染成 500 页
* (真手机打开文档站必然复现:预渲染时没有请求头 ⇒ 服务端恒判宽屏)。
* 把服务端的判定序列化进 payload 当客户端的初值,首帧 vdom 与 HTML 必然一致;
* 顺带还免疫「CDN 把桌面版缓存回给手机」——那种情况下客户端也会乖乖按 HTML 走宽屏。
*
* 权威值仍由 app.vue 的 onMounted 用 matchMedia 覆盖,管「桌面窗口被拖窄」这类
* 请求头判不出的情形。窄屏判定是全局共享状态(useState),文档页据此决定要不要
* 渲染侧栏,所以两边读的必须是同一个 ref。
*/
const CRAWLER_RE =
/bot|crawler|spider|slurp|bingbot|baiduspider|yandex|duckduckbot|facebookexternalhit|preview/i
export function uaIsNarrow(ua: string, chMobile?: string) {
if (!ua && !chMobile) return false // 两个都拿不到(含预渲染):按宽屏,与改造前一致
if (CRAWLER_RE.test(ua)) return false // 抓取端一律宽屏,否则顶栏导航链接会从 HTML 里消失
if (chMobile) return chMobile === '?1' // Chromium 的 Client Hint,最准
return /Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile|Mobile/i.test(ua)
}
export function useShellNarrow() {
// 只有服务端拿得到请求头;客户端分支只为类型与「init 被意外调用」兜底。
const headers: Record<string, string | undefined> = import.meta.server
? useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
: {}
return useState<boolean>('ipw-shell-narrow', () =>
uaIsNarrow(headers['user-agent'] || '', headers['sec-ch-ua-mobile']),
)
}
+2 -2
View File
@@ -9,10 +9,10 @@ html.dark .markdown-body {
/* GitHub 暗色主题的核心颜色 */
--color-fg-default: #e6edf3;
--color-fg-muted: #8b949e;
--color-fg-accent: #3EAF7C;
--color-fg-accent: #299764;
--color-bg-default: #242424;
--color-bg-muted: #080707;
--color-bg-accent: #3EAF7C;
--color-bg-accent: #299764;
--color-border-default: #242424;
--color-border-muted: #242424;
+2 -2
View File
@@ -261,10 +261,10 @@ function getStatusClass(status: string): string {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+5 -4
View File
@@ -201,7 +201,8 @@ const doc = page.value;
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
<el-option
v-for="item in recordTypes"
:key="item.value"
@@ -297,7 +298,7 @@ const doc = page.value;
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
@@ -305,11 +306,11 @@ const doc = page.value;
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+2 -2
View File
@@ -284,10 +284,10 @@ async function getUserIP() {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+5 -1
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
// 详见 app/pages/doc/index.vue 的同名注释。
const isNarrow = useShellNarrow()
const route = useRoute();
const path = computed(() => route.path)
@@ -18,7 +22,7 @@ useHead({
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
+8 -1
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
const isNarrow = useShellNarrow()
const { data: page } = await useAsyncData('doc-index', () =>
$fetch('/api/markdown/doc/index')
)
@@ -18,7 +25,7 @@ useHead({
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
+1 -1
View File
@@ -209,6 +209,6 @@ onMounted(() => {
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
</style>
+2 -2
View File
@@ -272,10 +272,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+2 -2
View File
@@ -287,11 +287,11 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+2 -2
View File
@@ -305,10 +305,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+2 -2
View File
@@ -261,7 +261,7 @@ onMounted(async () => {
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
@@ -444,7 +444,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
+1 -1
View File
@@ -286,7 +286,7 @@ html.dark .loading-overlay p {
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
+2 -2
View File
@@ -272,10 +272,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+1 -1
View File
@@ -389,7 +389,7 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+2 -2
View File
@@ -271,11 +271,11 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+3 -3
View File
@@ -281,7 +281,7 @@ function getStatusClass(status: string): string {
@import "../style.css";
@import "../../assets/css/tool-common.css";
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
@@ -319,10 +319,10 @@ function getStatusClass(status: string): string {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+7 -7
View File
@@ -109,14 +109,14 @@ blockquote p {
margin: 0.5em;
}
blockquote a{
color: #3EAF7C;
color: #299764;
font-size: 1em;
/* 正文里的链接仅靠颜色区分不够(绿 #3EAF7C 与周围正文的对比度远低于 3:1),
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
text-decoration: underline
}
a {
color: #3EAF7C;
color: #299764;
font-size: 1.3em;
text-decoration: none
@@ -132,7 +132,7 @@ footer .one-line {
footer .one-line a {
font-size: 1em;
text-align: center;
color: #3EAF7C;
color: #299764;
text-decoration: none
}
footer {
@@ -207,7 +207,7 @@ footer {
}
blockquote a{
color: #3EAF7C;
color: #299764;
font-size: 1em;
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
@@ -272,7 +272,7 @@ h4>.header-anchor,
h5>.header-anchor,
h6>.header-anchor {
opacity: 1;
color: #3EAF7C;
color: #299764;
}
h1:hover>.header-anchor,
@@ -285,5 +285,5 @@ h6:hover>.header-anchor {
}
.header-anchor::before {
content: "#";
color: #3EAF7C;
color: #299764;
}
+1 -1
View File
@@ -226,7 +226,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
+1
View File
@@ -0,0 +1 @@
{"/doc/16129597411766.jpg":{"width":3592,"height":1328},"/doc/16129602811940.jpg":{"width":3466,"height":882},"/doc/16129604712653.jpg":{"width":3516,"height":1538},"/doc/16333892496060.jpg":{"width":3582,"height":1194},"/doc/16333892838706.jpg":{"width":3570,"height":1372},"/doc/16333893415522.jpg":{"width":3572,"height":1362},"/doc/16333896690329.jpg":{"width":3574,"height":1254},"/doc/16333896988636.jpg":{"width":3574,"height":1370},"/doc/16333903308731.jpg":{"width":3582,"height":1474},"/doc/16333903920413.jpg":{"width":3582,"height":1404},"/doc/16333904352646.jpg":{"width":3576,"height":1076},"/doc/16333906824294.jpg":{"width":3580,"height":920},"/doc/16333908635792.jpg":{"width":3582,"height":1978},"/doc/16333909449752.jpg":{"width":3580,"height":752},"/doc/16333909824098.jpg":{"width":3582,"height":936},"/doc/16333930536112.jpg":{"width":3580,"height":1128},"/doc/16355616705145.jpg":{"width":1636,"height":958},"/doc/16355617569448.jpg":{"width":1634,"height":1256},"/doc/16355621400735.jpg":{"width":1632,"height":1582},"/doc/16355623081368.jpg":{"width":1672,"height":1090},"/doc/16355624511052.jpg":{"width":1098,"height":622},"/doc/16355625447934.jpg":{"width":2124,"height":490},"/doc/16355626382824.jpg":{"width":1858,"height":696},"/doc/16355626574718.jpg":{"width":1340,"height":604},"/doc/16355698585824.jpg":{"width":1496,"height":834},"/doc/16355852767627.jpg":{"width":650,"height":553},"/doc/16355909063872.jpg":{"width":2004,"height":1660},"/doc/addCustomRAM-1.png":{"width":3354,"height":1262},"/doc/dns_srv_record.png":{"width":2468,"height":558},"/doc/http2-chrome.png":{"width":3418,"height":1222},"/doc/ipv6_domain_resolve (1).png":{"width":1820,"height":306},"/doc/ipv6_domain_resolve.png":{"width":1820,"height":306},"/doc/ipv6_webcheck.png":{"width":1008,"height":925},"/doc/istio-gateway-ipv6-https.png":{"width":1596,"height":1330},"/doc/istio-gateway-ipv6.png":{"width":3576,"height":1342},"/doc/istio-ingress-ipv6.png":{"width":3012,"height":1278},"/doc/k8s-ipv6-ingress.png":{"width":2974,"height":1500},"/doc/ram.png":{"width":3830,"height":1850},"/doc/ram_add_authority.png":{"width":3832,"height":1228},"/doc/securityGroupEnableIPv6.png":{"width":2922,"height":548},"/doc/tls_01_client_hello.png":{"width":3316,"height":1902},"/doc/tls_02_server_hello_change_cipher_spec_encrypted_extensions.png":{"width":3306,"height":1904},"/doc/tls_03_certificate_verify_finished.png":{"width":3306,"height":1882},"/doc/tls_04_change_cipher_spec_finished.png":{"width":3316,"height":1898},"/doc/tls_tcp_01_syn.png":{"width":3318,"height":1902},"/doc/tls_tcp_02_syn_ack.png":{"width":3312,"height":1864},"/doc/tls_tcp_03_ack.png":{"width":3316,"height":1902},"/doc/v6connect.png":{"width":564,"height":552},"/doc/v6test.png":{"width":1595,"height":872},"/doc/view_ipv6_adress_url.png":{"width":2126,"height":366},"/doc/wireshark_http2.png":{"width":3306,"height":1892},"/doc/wireshark_http2_1.png":{"width":3314,"height":1914},"/doc/wireshark_ipv6.png":{"width":3518,"height":2182},"/doc/wireshark_ipv6_tcp_ack.png":{"width":3528,"height":598},"/doc/wireshark_tls_preferences.png":{"width":1360,"height":1098},"/hyw.png":{"width":804,"height":802},"/hyw.webp":{"width":804,"height":802},"/jingya.jpg":{"width":300,"height":274},"/备案图标.png":{"width":36,"height":40}}
+65 -11
View File
@@ -1,5 +1,41 @@
import {config} from "./config/index";
import { docConfig } from "./config/doc";
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join, relative } from "node:path";
import { imageSize } from "image-size";
// ==================== 内容图片尺寸清单(构建期生成) ====================
// markdown 里的图片(如 /doc/xxx.jpg)需要真实 width/height 才能消除 CLS
// 和 Lighthouse 的 unsized-images。生产跑在 CF Workers 上,**没有 fs**,
// 运行时读不了 public/ —— 所以在 nuxt.config 求值时(每次 build/dev 启动
// 都会执行)扫一遍 public/ 下的位图,把尺寸写成 JSON 清单,让
// utils/markdown.ts 作为构建期常量 import 进去。新增图片后重新构建即生效。
function buildImageDimManifest(): void {
const IMG_RE = /\.(jpe?g|png|webp|gif|avif)$/i;
const dims: Record<string, { width: number; height: number }> = {};
const walk = (dir: string) => {
if (!existsSync(dir)) return;
for (const name of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, name.name);
if (name.isDirectory()) {
walk(p);
} else if (IMG_RE.test(name.name)) {
try {
const r = imageSize(new Uint8Array(readFileSync(p)));
if (r.width && r.height) {
dims["/" + relative("public", p).replace(/\\/g, "/")] = {
width: r.width,
height: r.height,
};
}
} catch { /* 损坏文件跳过,渲染时不写宽高 */ }
}
}
};
walk("public");
writeFileSync("config/doc-image-dims.json", JSON.stringify(dims, null, 0));
}
buildImageDimManifest();
// https://nuxt.com/docs/api/configuration/nuxt-config
const extractDomains = (obj: any): string[] => {
// 将对象转为 JSON 字符串,用正则匹配所有 https:// 开头的域名部分
@@ -27,6 +63,17 @@ const middlewareOrigin = config.Middleware?.[0]
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
experimental: {
defaults: {
nuxtLink: {
// NuxtLink 默认「链接进入视口就预取目标页 JS」——顶栏 15 个菜单项会让
// 首屏把其它工具页的 chunk(含 87KB 的校验库等重依赖)全拉下来,
// 白吃带宽还挤占首屏关键请求。改为「悬停/聚焦才预取」:真正要去
// 哪个页面时再拉那一页,首屏只加载当前页需要的代码。
prefetchOn: { interaction: true, visibility: false },
},
},
},
modules: [
"nitro-cloudflare-dev",
'@element-plus/nuxt',
@@ -122,10 +169,13 @@ export default defineNuxtConfig({
},
routeRules: {
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
'/doc/**': { prerender: true },
// /doc/** 曾经是 SSG(prerender),已撤回改为 SSR,原因见下方 nitro.prerender 处的注释。
},
nitro: {
// 构建期给 public/ 静态资源生成 .gz/.br 预压缩副本,按 Accept-Encoding 直接回;
// SSR HTML 的压缩在 server/plugins/compress-html.ts。两者合起来让本地直连的
// 传输体积与线上(EdgeOne 会压)一致,Lighthouse 的 Lantern 模拟才不会虚高。
compressPublicAssets: true,
publicAssets: [
{
dir: 'public',
@@ -137,17 +187,21 @@ export default defineNuxtConfig({
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
}
},
prerender: {
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
},
// 这里曾用 prerender.routes 把整份 docConfig 的文档页在构建期预渲染成静态 HTML。
// 已撤回,改回 SSR —— 因为预渲染与「按请求头决定渲染形态」根本冲突:
// 构建期没有请求头,窄屏判定恒为宽屏,于是预渲染产物里永远躺着
// 「15 条桌面顶栏菜单 + 被 CSS 隐藏的 40 项文档侧栏」;真手机打开时客户端判定为窄屏,
// 首帧 vdom 与 HTML 对不上,Vue 水合直接抛
// `Cannot read properties of null (reading 'nodeType')`,整页变成 500。
// 顺带收益:/doc 现在也走运行时安全头(预渲染页拿不到 CSP,见下方 security.ssg)。
// 代价只是源站多跑一层 SSR,前面有 EdgeOne 缓存兜着。
},
security: {
ssg: {
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
nitroHeaders: false,
},
headers: {
+1
View File
@@ -26,6 +26,7 @@
"element-plus": "^2.14.3",
"github-markdown-css": "^5.9.0",
"github-slugger": "^2.0.0",
"image-size": "^2.0.4",
"is-ip": "^5.0.1",
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.1",
+10
View File
@@ -50,6 +50,9 @@ importers:
github-slugger:
specifier: ^2.0.0
version: 2.0.0
image-size:
specifier: ^2.0.4
version: 2.0.4
is-ip:
specifier: ^5.0.1
version: 5.0.1
@@ -2905,6 +2908,11 @@ packages:
image-meta@0.2.2:
resolution: {integrity: sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==}
image-size@2.0.4:
resolution: {integrity: sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==}
engines: {node: '>=18'}
hasBin: true
immutable@5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
@@ -8229,6 +8237,8 @@ snapshots:
image-meta@0.2.2: {}
image-size@2.0.4: {}
immutable@5.1.9: {}
impound@1.1.6(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.62.4)(vite@8.2.0(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.49.0)(yaml@2.9.0)):
@@ -0,0 +1,42 @@
import { brotliCompressSync, gzipSync } from 'node:zlib'
import type { NitroApp } from 'nitro/types'
/**
* SSR HTML 压缩。
*
* 为什么要有它:nitro 不压 SSR 响应体(静态资源另有 compressPublicAssets 预压缩),
* 本地直连 / Lighthouse 测到的 HTML 就是裸传的 —— Lantern 会按无压缩体积去模拟
* 低速网络,把 FCP/LCP/SpeedIndex 放大好几倍(实测 836ms 模拟成 3.8s)。
* 生产链路前面有 EdgeOne 兜底,但源站自己会压是标准做法,也让本地测量贴近线上。
*
* 只压 SSR 渲染出的 text/html:body 是字符串、体积 > 1KB、对方 Accept-Encoding 支持才压,
* 按客户端优先级选 br(brotli,文本比 gzip 再小 ~15%)否则 gzip。
*/
export default defineNitroPlugin((nitroApp: NitroApp) => {
nitroApp.hooks.hook('render:response', (response, { event }) => {
const headers = (response.headers ??= {})
const type = String(
headers['content-type'] ?? event.node.res.getHeader('content-type') ?? '',
)
if (!type.includes('text/html')) return
const body = response.body
if (typeof body !== 'string' || body.length < 1024) return
if (headers['content-encoding']) return // 已有编码(异常路径),不叠加
const accept = String(event.node.req.headers['accept-encoding'] ?? '')
let encoding: 'br' | 'gzip'
if (/\bbr\b/.test(accept)) {
response.body = brotliCompressSync(Buffer.from(body))
encoding = 'br'
} else if (/\bgzip\b/.test(accept)) {
response.body = gzipSync(Buffer.from(body))
encoding = 'gzip'
} else {
return
}
headers['content-encoding'] = encoding
// 体积变了,content-length 必须跟着改,否则连接会被截断/挂起
headers['content-length'] = String((response.body as Buffer).length)
})
})
+34
View File
@@ -3,12 +3,27 @@ import GithubSlugger from 'github-slugger'
import { getSingletonHighlighter } from './shiki.bundle'
import { fromHighlighter } from '@shikijs/markdown-it/core'
import MarkdownIt from 'markdown-it'
// 构建期由 nuxt.config 的 buildImageDimManifest() 扫描 public/ 生成(生产在
// CF Workers 上没有 fs,尺寸只能在构建期算好烧进 bundle)。
// 新增图片后重新构建即生效;查不到的 src 不写宽高,只加 lazy。
import imageDimsJson from '../config/doc-image-dims.json'
const slugger = new GithubSlugger()
let md: MarkdownIt | null = null
let initPromise: Promise<void> | null = null
// ==================== 内容图片治理(Lighthouse:unsized-images / CLS / LCP) ====================
// markdown 里的图片(如 /doc/xxx.jpg)默认裸 <img>:无宽高 → 加载时撑开布局(CLS);
// 且全部 eager 加载 → 移动端模拟 LCP 被最大的一张 389KB 图拖到 10.8s。
// 治法:渲染时统一注入 loading=lazy / decoding=async + 真实 width/height
// (浏览器据此预留纵横比空间,加载前后零位移)。
type Dim = { width: number; height: number }
const dimCache = new Map<string, Dim | null>(
Object.entries(imageDimsJson as Record<string, Dim>).map(([k, v]) => [k, v]),
)
async function initMarkdown() {
const highlighter = await getSingletonHighlighter()
await Promise.all([
@@ -37,6 +52,25 @@ async function initMarkdown() {
})
})
md.use(fromHighlighter(highlighter, { theme: 'vitesse-dark' }))
// 内容图片:lazy + 异步解码 + 真实宽高(尺寸由 renderMarkdown 前置预热进 dimCache)
const defaultImage = md.renderer.rules.image
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx]
const src = token.attrGet('src') || ''
if (src) {
token.attrSet('loading', 'lazy')
token.attrSet('decoding', 'async')
const dim = dimCache.get(src)
if (dim) {
token.attrSet('width', String(dim.width))
token.attrSet('height', String(dim.height))
}
}
return defaultImage
? defaultImage(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
}
// Start initialization at module load time