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:
1 parent
924b9bcc36
commit
243060a32a
26 files changed
+254
-52
No files matched your search
@@ -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']),
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -209,6 +209,6 @@ onMounted(() => {
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -389,7 +389,7 @@ onMounted(() => {
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--el-color-primary: #3EAF7C;
|
||||
--el-color-primary: #299764;
|
||||
}
|
||||
.el-icon{
|
||||
font-size: 1.3em;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -226,7 +226,7 @@ html.dark pre code {
|
||||
|
||||
.badge-item h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #3EAF7C;
|
||||
color: #299764;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+10
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user