静态优先:一个「本地优先」产品官网的技术架构与工程实践

本文基于 Omni Suite 官网(omnisuite.top)的真实重构过程撰写。文中所有数据均来自实际构建与线上实测,而非理论推演。


引言:官网是产品的一部分

对于工具类产品,官网常被当作「营销页面」——一个用来放截图、写广告语的地方。但如果你做的是本地优先(local-first)产品,官网的定位需要重新思考:它本身就是产品哲学的一次演示。

一个主张「数据不出设备」的扩展套件,如果官网塞满了第三方追踪脚本、广告 SDK、行为埋点,那无论文案写得多真诚,都是自相矛盾。

Omni Suite 是一个由六款 Manifest V3 浏览器扩展组成的套件,全部遵循同一条铁律:所有计算与模型推理都在用户设备端完成,零数据上云。这个约束不只作用于扩展本身,也作用于官网的技术选型。

于是官网的技术目标变得清晰:

  1. 静态优先 —— 能预渲染的绝不运行时计算
  2. 零第三方依赖 —— 没有分析脚本、没有广告 SDK、没有外部字体 CDN
  3. 构建期注入 —— 环境变量在 next build 时固化进产物

下面按十一个工程维度展开:技术选型、设计系统、动效、部署、缓存、国际化、排版细节、信息架构、构建期注入、数据一致性,以及贯穿其中的验证方法。每个维度都附实测数据。


一、为什么选择静态优先

1.1 决策依据

官网的内容更新频率是周级——新增一款扩展、更新一个版本号。用 SSR 或 ISR 带来的实时性收益几乎为零,却要承担运维成本。

next build 后每个路由都是完整的静态 HTML:

Route (app)
┌ ○ /            ├ ○ /_not-found    ├ ○ /community
├ ○ /discussions ├ ○ /docs
├ ○ /robots.txt  └ ○ /sitemap.xml

○  (Static)  prerendered as static content

七个路由全部静态预渲染,Nginx 直接反代到 Node 服务,无数据库、无 Redis、无消息队列。整个服务栈只有三层:

Cloudflare (CDN) → Nginx (反代) → PM2 托管 Next.js (端口 8100)

127.0.0.1:8100

1.2 关闭图片优化的取舍

// next.config.ts
const nextConfig: NextConfig = {
  reactStrictMode: true,
  images: { unoptimized: true },
};

这是刻意而非疏忽。Next.js 的 <Image> 优化会在服务端生成多尺寸副本并缓存。对官网而言成本大于收益:

  • 截图是产品界面,压缩伪影在深色背景上尤其明显;而截图的核心价值恰恰在于展示真实界面
  • 引入了运行时图片处理开销,与「静态优先」冲突
  • 需要额外的缓存目录管理

1.3 静态优先的代价

必须诚实说明这个选择的局限:任何需要用户态数据的功能都无法实现。评论系统不能走服务端渲染,只能客户端挂载(见第十节的 Giscus 方案)。这不是 bug,是架构选择的必然结果。


二、技术选型的版本组合

{
  "next": "16.3.4",
  "react": "19.2.8",
  "framer-motion": "^12.43.0",
  "lenis": "^1.3.26",
  "tailwindcss": "^4",
  "typescript": "^5"
}

三个值得说明的点:

2.1 Next.js 16 + Turbopack

构建默认走 Turbopack,实测编译耗时 11.3 秒(含 TypeScript 类型检查)

Turbopack 的缓存目录(.next/cache)在跨机器同步时容易损坏,因此部署脚本在构建前强制清理 rm -rf .next/cache .next/turbopack node_modules/.cache。这不是迷信——2026-10-08 的一次部署中,残留缓存导致构建失败,清理后恢复正常。

代价:清缓存意味着每次部署都是全量编译,无法享受增量构建。11.3 秒的耗时在可接受范围内,所以这个取舍成立;如果项目再大一个量级,这里需要重新评估。

2.2 React 19 + reactStrictMode: true

严格模式开启后开发期会双调用 effect,用来暴露副作用问题。代价是开发期某些动画会触发两次。对静态站点而言收益大于成本。

2.3 Tailwind CSS 4 的 @theme 块

Tailwind 4 最大的变化是配置从 JS 文件移到 CSS 的 @theme 块。这对设计系统有实质影响——见下一节。


三、设计系统:令牌化的收益与陷阱

3.1 令牌定义

@theme {
  --color-kyma-lime:  #00f2fe;   /* 唯一强调色 */
  --color-kyma-cream: #f4f4e8;   /* 主文字色 */
  --color-kyma-black: #050505;   /* 背景 */
  --color-kyma-olive: #0c1822;   /* 卡片底色 */

  --font-display: "TASA Orbiter", sans-serif;  /* 标题与大字号 */
  --font-mono: "Roboto Mono", monospace;       /* 数据与标签 */
  --font-body: "Inter", sans-serif;            /* 正文 */
}

(实际文件里还有 kyma-gray 与 kyma-gray-light 两级灰,用于正文与弱化的说明文字,此处省略。)

关键决策是强调色只有一个(kyma-lime)。这是刻意的克制。深色背景下,单一的青色能形成明确的视觉焦点,多色强调会导致注意力分散。产品界面里的数据指标、链接、焦点态都走这一个色。

3.2 语义令牌与原始令牌解耦

组件里只写语义类名(bg-kyma-card、text-kyma-gray-light),不写原始色值。这层解耦在有换肤需求时价值巨大——改 @theme 一处,全站生效。

3.3 一个真实陷阱:.t-watermark 的硬编码覆盖

官网有一个巨大的背景水印「OMNI」。它最初定义在 CSS 类里:

.t-watermark {
  font-size: clamp(48px, 14vw, 360px);
  line-height: 0.85;
  color: #1a1e16;
  opacity: 0.45;
}

问题在于 CSS 类优先级高于 Tailwind 工具类。当我在组件里写 text-[#1c2119] 想调整颜色时,它不生效。修复方式是把硬编码值从 CSS 里移走,把控制权交还组件:

.t-watermark {
  font-family: var(--font-display);
  font-weight: 800;
  letter-spacing: -0.06em;
  text-transform: uppercase;
  /* 颜色与尺寸交给调用方的 Tailwind 类 */
}

教训:Tailwind 与自定义 CSS 混用时,硬编码在 CSS 类里的属性会静默覆盖工具类,且没有任何报错。

3.4 字体的可变字重陷阱

fonts.css 里 TASA Orbiter 声明了 600/700/800 三档,但三者指向同一个 woff2 文件——这是可变字体(variable font)。@font-face 用离散的 font-weight 值声明可变字体,浏览器只在这三档使用该文件。

这意味着 900(font-black)会回落到 800。 如果要加粗到最粗,只能用 800。


四、动效:性能与体验的边界

4.1 单一缓动曲线

整个站点只用一个缓动曲线:

// src/lib/easings.ts
export const EASE_EXPO_OUT: Bezier = [0.16, 1, 0.3, 1];

指数衰减——前段响应快,尾段平滑收敛。每个组件如果各用一条曲线,页面会显得像多个来源拼凑而成。一致的曲线是「像一套系统」的底层原因。

4.2 入场动画的参数

export const fadeUp = {
  hidden: { opacity: 0, y: 26 },
  show: { opacity: 1, y: 0, transition: { duration: 0.9, ease: EASE_EXPO_OUT } },
};

export const viewportOnce = {
  once: true,                    // 只触发一次
  amount: 0.2,                   // 元素 20% 进入视口即触发
  margin: '0px 0px -80px 0px',   // 判定边界上移 80px
};

margin: -80px 的设计意图:元素在完全可见之前就开始动效。用户感知到的是「元素在我看向它时已经在动」,而不是「元素突然出现然后才开始动」。

4.3 平滑滚动的移动端豁免

const lenis = new Lenis({
  duration: 1.2,
  easing: LENIS_EASING,
  orientation: 'vertical',
  smoothWheel: true,
  syncTouch: false,          // 关键:触摸设备不劫持原生滚动
  touchMultiplier: 1.6,
});

syncTouch: false 是必要妥协。在 iOS 上劫持原生触摸滚动会让滚动手感明显劣化。桌面端要惯性,移动端要原生。

4.4 无障碍不是可选项

const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) return;   // 完全不初始化 Lenis,回归原生滚动
@media (prefers-reduced-motion: reduce) {
  button, .btn-action, .dpad-btn, .tool-btn {
    transition: none !important;
  }
  button:active, .btn-action:active {
    transform: none !important;
  }
}

前庭功能障碍用户会因大面积视差运动产生眩晕。这不是边缘情况——动画敏感人群的比例远高于直觉。上述规则在 Lenis 侧(JS 层)与 CSS 层双重拦截。


五、部署 pipeline:从 34 分钟到 32 秒

这是本次重构中收益最大的一块。

5.1 三种方案的实测对比

方案 传输量 耗时 结果
本地产物 + scp 52 MB 34 分钟 网络慢时极慢
tar 打包 + scp 33 MB 卡住 11 分钟 无进展,已弃用
rsync 增量 + 服务器构建 ~536 KB 32–55 秒 ✅ 当前方案

5.2 为什么前两种都失败

第一种的问题很直观:.next 产物 52MB,跨网 scp 极慢。

第二种的问题更有意思:tar 打包时排除了 node_modules 和 .next,但 public/ 有 48MB(其中 downloads/ 36MB 是安装包 zip)——tar 每次都打全量,压缩后仍有 33MB。scp 上传后 ssh heredoc 部分没有执行,服务器上没有任何构建进程,src 目录时间戳仍是旧的。卡了 11 分钟无任何进展。

5.3 rsync 方案的关键

# 源码仅 536KB;public 48MB 但无变化时 rsync 自动跳过
rsync -az --delete src/    oracle:/var/www/omni-website/src/
rsync -az public/          oracle:/var/www/omni-website/public/
rsync -az package.json pnpm-lock.yaml next.config.ts tsconfig.json \
        postcss.config.mjs next-env.d.ts oracle:/var/www/omni-website/

# 服务器构建(跳过 npm install,依赖未变时)
ssh oracle 'cd /var/www/omni-website && rm -rf .next/cache && \
            npx next build && pm2 restart omni-website'

核心洞察:日常改动只发生在 src/(536KB),而 public/ 的 48MB 几乎不变。rsync 的增量能力自动区分这两者——这是 tar 打包做不到的。

第二个关键洞察:npm install 在服务器上耗时可达 10 分钟。依赖未变时跳过它,是 32 秒与 11 分钟的差别。脚本默认跳过,需要时显式加 --install。

5.4 一个致命坑:备份目录不能放项目内

这是本次事故的根因,值得单独说明。

部署脚本最初把备份放在项目目录内:cp -r $D/src $D/src.bak-$TS,产物形如 /var/www/omni-website/src.bak-20261008-120648。

而 tsconfig.json 的配置是:

{
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

include 的 **/*.tsx 会匹配到备份目录里的所有文件。 exclude 只排除了 node_modules。

于是下一次构建时,类型检查扫描到备份里的旧 app/page.tsx——它还在传 onOpenExtension prop,而该 prop 已在重构中从 Hero 组件移除:

src.bak-20261008-120648/app/page.tsx(75,15): error TS2322:
  Type '{ onOpenExtension: (id: string) => void; }' is not
  assignable to type 'IntrinsicAttributes'.
  Property 'onOpenExtension' does not exist on type 'IntrinsicAttributes'.
Failed to type check.

更糟的是:脚本先 rm -rf .next/cache 再 next build。构建一失败,就没有可回退的产物——服务直接中断约 20 分钟。

两道防线修复:

① tsconfig 显式排除

"exclude": [
  "node_modules",
  "src.bak-*",
  "**/src.bak-*",
  "*.bak-*",
  ".next/cache"
]

② 备份移到项目目录之外

BK=/home/ubuntu/omni-backups
mkdir -p $BK
cp -r $D/src $BK/src-$TS

教训:exclude: ["node_modules"] 是危险的默认。TS 的 include 用 **/* 通配时,任何新增的临时目录、备份目录都会进入编译范围。

第二条教训:构建型部署应先构建到临时目录,成功后再原子切换。先删旧产物再构建,一旦失败就没有退路。(这一条截至本文写成时仍未实施,见 11.3 节。)


六、缓存策略:CDN 与源站的博弈

6.1 问题

Next.js SSR 输出自带 Cache-Control: s-maxage=31536000(一年)。而官网 HTML 每次部署后内容都变,Cloudflare 依据该头缓存一年且不会自动失效——导致新版本上线后用户仍看到旧页面。

6.2 修复:源站强制不缓存 HTML

在 Nginx 层覆盖上游响应头:

location / {
    proxy_pass http://127.0.0.1:8100;
    proxy_hide_header Cache-Control;
    proxy_hide_header Age;
    add_header Cache-Control "no-cache, no-store, must-revalidate, max-age=0" always;
    add_header X-Cache-Policy "html-no-store" always;
}

关键点:proxy_hide_header Cache-Control 必须先隐藏上游头,add_header 才能生效。否则 Nginx 会同时输出两个 Cache-Control,浏览器取最宽松的那个——规则等于没写。

同时对带内容 hash 的静态资源保持长缓存:

location /_next/static/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location /fonts/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

这是「HTML 不缓存 + 静态资源长缓存」的组合:HTML 每次都回源(内容变了立即生效),带内容 hash 的 JS/CSS 缓存一年(文件名变了才重新拉取)。两者的分界线是文件名里有没有 hash——这是 Next.js 已经替你做好的区分,Nginx 只需要顺着它写规则。

6.3 验证方法的教训

出事时公网返回 200,我据此以为服务还在,实际源站早已 000。是用户报告 502 才发现的。

必须同时探测两端:

SRC=$(ssh oracle "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8100/")
PUB=$(curl -s -o /dev/null -w '%{http_code}' "https://omnisuite.top/?_=$(date +%s)")

公网探测还要加随机参数绕过 CDN 缓存。


七、中文化工程:装饰性英文的判断标准

官网原本有大量英文文案,需要区分两类:

7.1 保留(品牌名与技术专名)

保留项 类别
OMNI® SUITE / OMNI 品牌 logo
Chrome / Edge / Firefox 浏览器名
OMNIPIC / RESUMERADAR 产品名,全大写是既定视觉风格
MANIFEST V3 / WEB CRYPTO AES-128 技术标准
OmniPic Studio / ResumeRadar ATS 产品全名
GitHub / Python / Go 平台与语言名

判断标准:是否为专有名词。 品牌名、技术标准、平台名翻译后反而降低可读性。

7.2 替换(装饰性英文)

原文 改为 位置
[DOCUMENTATION CENTER] [文档中心] 全屏菜单
LOCAL EXTENSION SUITE · ZERO DATA LEAK. 本地扩展套件 · 零数据外泄 全屏菜单
[NAVIGATION · 核心技术手册] [导航 · 核心技术手册] 全屏菜单
OMNI SUITE MATRIX · 2026 OMNI SUITE 矩阵 · 2026 全屏菜单
DOCS 文档 卡片左栏
100% LOCAL PRIVACY 100% 本地隐私 卡片左栏(竖排)
OMNI DOCS MANUAL OMNI 文档手册 卡片底部
OFFICIAL 官方 卡片底部
LOCAL OFFLINE 本地离线 卡片底部
CLOUD LEAK 零外泄 卡片底部
EXTENSION MATRIX 扩展矩阵 首页章节标签
INSTALLATION & QUICKSTART 安装与快速上手 首页章节标签
COMMUNITY & DISCUSSIONS 社区与讨论 首页章节标签
PATHWAY A / PATHWAY B 路径 A / 路径 B 文档页
DUAL-PACKAGE STATION 双包联动服务站 文档页
GITHUB(按钮) 开源仓库 文档页
OmniSense AI OmniSense 本地 AI 文档页
PRO TIPS 专家避坑建议 文档页

判断标准:是否可用中文等价表达且不影响理解。 装饰性标签、动作文案、状态描述都属于这一类。

一个反直觉的发现:越是大字号、越是「视觉元素化」的英文,越容易被漏掉。 正文里的英文一眼可见,但像 DOCS、OFFICIAL 这种被当作图形符号使用的短标签,代码里被当成「不是文案」,于是被系统性忽略。最终清单里约三成属于这一类。

7.3 一个只有中文化后才会暴露的问题

卡片左栏有一行竖排文字,原代码:

// 修复前:残留的 rotate 让中文竖排倒过来
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
// 修复后
style={{ writingMode: "vertical-rl" }}

vertical-rl 让字符从上到下排列;rotate(180deg) 是西文竖排的惯例——书脊式,从下往上读。但中文竖排本来就是从上往下,不需要旋转。这个 rotate 是英文版 100% LOCAL PRIVACY 时代留下的,翻译成中文后它就成了 bug。

更一般的结论:国际化不是「替换字符串」,而是「替换排版假设」。语言变了,与之绑定的书写方向、断行规则、字重需求、字形高度都会变。只改文案不改排版,bug 会滞后暴露。


八、加粗引发的字形裁切

这是一个只有加粗后才会出现的细节问题,很有代表性。

8.1 现象

菜单大标题加粗到 800 后,下方"漏出"文字残影。

8.2 根因:textShadow 影子副本机制

自定义组件 RollingText 实现 hover 滚动动画:

<div className="inline-flex overflow-hidden" style={{ lineHeight: numLineHeight }}>
  {/* 在下方一个行高处渲染影子副本 */}
  <span style={{ textShadow: `0 ${numLineHeight}em 0 ${color}` }}>
    {chars.map(...)}
  </span>
</div>

字符上移 -${numLineHeight}em 时,影子副本正好补位,形成无缝滚动。影子靠外层 overflow-hidden 裁掉。

只要容器高度 < 影子起点(= lineHeight),裁剪就正常。

8.3 实测数据

字号 56px 下的三次调整:

lineHeight 容器 clientH 内容 scrollH 结果
0.92(51.52px) 52px 59px ❌ 溢出 7px
1.1(61.6px) 62px 64px ❌ 仍差 2px
1.2(67.2px) 67px 67px ✅ 无裁剪

字形实际高度约 64px = 1.14em——中文全角字形在 800 字重下超出 em box。

8.4 一个无效方案

我一度想只调 textShadow 偏移量让副本离得更远。行不通——影子偏移必须等于动画位移(都基于 numLineHeight),否则 hover 时副本无法无缝补位,会出现跳变。只能增大 lineHeight。

8.5 线上实测验证

文档: { weight: "800", clientH: 67, scrollH: 67, clipped: false }
讨论: { weight: "800", clientH: 67, scrollH: 67, clipped: false }

scrollHeight === clientHeight 即为无裁剪。


九、信息架构精简的量化结果

本次重构把首页从「信息展示」改为「入口导航」:

指标 重构前 重构后 变化
首页渲染文字 5871 字符 1928 字符 -67%
index.html 体积 236,771 字节 68,562 字节 -71%
首页 section 数 5 3 -2
文档组织 3 章散在首页 4 章集中于 /docs +2 节

9.1 做了什么

首页保留:Hero(价值主张)→ 扩展矩阵(六款卡片)→ 安装指引(精简)→ Footer

迁移到 /docs:原本占据首页的「独立官网展示区」(467 字)和「本地自建站详解」(555 字)——这两块本质是文档内容,放在首页违反了「一眼抓住重点」。

首页只留简要介绍 + 指向 /docs 的链接。 详细的功能说明、使用文档、技术参数全部进入独立的文档页。

9.2 导航清晰度的保障

两个跳转入口:

  1. Header 常驻「文档中心」按钮
  2. Footer 快速导航区列出全部文档章节

风格一致性靠设计令牌保证——首页与文档页共用同一套 @theme,不需要额外维护。


十、构建期注入:环境变量的反直觉行为

10.1 一个静默失败的组件

官网的社区讨论用 Giscus(基于 GitHub Discussions,纯客户端,无自建后端、无数据库、无追踪)。它的配置走 NEXT_PUBLIC_GISCUS_REPO 一类的环境变量。

关键点在于:NEXT_PUBLIC_ 前缀的变量在 next build 时就被内联进产物,而不是运行时读取。我在本地改好了配置、本地预览一切正常,但服务器上是另一份产物——服务器的 .env.local 一旦缺失或不一致,构建出来的 JS 里 repoId 就是 undefined。

最麻烦的是它的失败形态:不报错,只是组件不渲染。 构建成功、页面 200、控制台干净,唯一的现象是讨论区那一块是空的。这种失败无法通过健康检查发现,只能靠肉眼去看那个区域到底有没有内容。

现在的做法是部署前置校验——比对本地与服务器环境变量文件的 MD5:

local=$(md5 -q .env.local); remote=$(ssh oracle "md5sum /var/www/omni-website/.env.local | cut -d' ' -f1")
[ "$local" = "$remote" ] || { echo "env 不一致,中止部署"; exit 1; }

一致值:e70ee1266e1b804ec08f786e4a593155。

更一般的结论:静态站点没有「运行时配置」这个概念。所有客户端可见的配置都是构建期常量。想要部署后可调,就必须走服务端接口,而那会引入运行时依赖——与静态优先的取舍直接冲突。这里选择了接受构建期常量,用前置校验来兜底。

10.2 grep 构建产物为什么会给出错误结论

部署完想验证「首页数据条到底显示什么」,我最初的做法是在构建产物里 grep 字符串,结果反复「找不到」。

原因不是没渲染,而是 React 把数字与标签渲染成了多个相邻文本节点。模板里写的 {storeCount} 款已上架,落到 HTML 里是若干被注释节点分隔开的片段。整串匹配必然落空。

更根本的问题是:grep 产物验证的是「你写下的东西」,不是「用户看到的东西」。 中间隔着 JSX 编译、样式层叠、字体回退、布局计算、客户端水合——任何一环都可能改变最终呈现。这次遇到的字体裁切、水印不可见、竖排倒置,全都是「源码正确但渲染错误」的案例,grep 对此完全无能。

10.3 改成读渲染后的 DOM

现在的验证方式是接 Chrome DevTools Protocol,在真实渲染的页面上取 innerText 与 getComputedStyle。第八节那组 { weight: "800", clientH: 67, scrollH: 67, clipped: false } 就是这么来的——clientH 与 scrollH 相等即无裁切,这是一个能从 DOM 直接读出、且与用户视觉体验严格一一对应的判据。

同类手段也用于验证布局:读 getBoundingClientRect() 比对水印底边与 section 底边是否重合(实测两者均为 792px),比「看起来差不多」可靠得多。

顺带一提,这套流程里还需要写 sessionStorage 跳过首屏 intro 动画,否则每次截图都要等它播完。

方法论层面的收获:验证手段必须比被验证的东西更接近用户。源码 < 构建产物 < 渲染 DOM < 用户眼睛。越往右越慢、越贵,但也越可信。在「样式类 bug」这个子集上,跳过渲染层直接查源码,基本等于没验证。


十一、跨项目数据一致性:营销数字为什么会漂移

11.1 四处版本号错误

OmniGame 上线做数据核对时,发现了四处与真实产物不一致的数字:

位置 原值 实际 依据
OmniGame 版本 v1.1.0 v1.2.0 manifest.json
OmniGame 游戏数 144 款 160 款 games/ 目录实际计数
OmniPic 版本 v3.2.0 v3.2.1 已发布构建产物
ResumeRadar 版本 v2.4.1 v0.1.7 本地 manifest.json

ResumeRadar 那个尤其典型:v2.4.1 是从没存在过的版本号,像是某次编辑时顺手写的。而它自己的独立站点上又写着 v0.2.2,与本地 manifest 的 0.1.7 再次不一致——同一个产品,三个地方三个版本号。

11.2 单一数据源的边界

项目里 src/data/extensions.ts 是产品数据的单一数据源,版本号、状态、商店链接都从它读。理论上不该出现漂移。

但它的约束力有明确边界:只能约束引用它的地方。 硬编码在 JSX 里的字符串,不进这个体系。而恰恰是这些硬编码处出了全部四个 bug——它们通常在「补充说明」「卡片副标题」「状态行」这类看起来不像数据的位置。

这提示了一个实践:数据文件的价值不在于集中,而在于让硬编码显得刺眼。 当所有正经数据都从一处读取时,散落在组件里的字面量在代码审查时就会明显不协调。前提是团队有审查习惯,或者有 lint 规则兜底——本项目两者都还缺。

11.3 代码修不了的问题

仍有一处无法用代码修复:public/screenshots/omnipic-site.png 这张截图,图片内容本身含有英文(BUILD 3.2.1、MANIFEST V3、INI HTTP MODIFIER)。

这是产品界面截图,字是界面上真实渲染出来的。要改只能重新截图。它暴露的是单一数据源的物理边界——图片是二进制,不进编译期检查,也不可能被任何 lint 规则覆盖。 任何「全站中文化」的验收清单,如果只检查代码,都会漏掉这一类。

同类情况还有部署流程:当前脚本仍是「先清空 .next 再构建」。构建失败期间,磁盘上没有可用产物,PM2 重启即 502。更稳妥的做法是构建到临时目录、成功后再原子切换——这个改造已经明确,但尚未实施。

把未解决的问题写进文档,比假装它们不存在要好。 一次重构的价值不只在于改好了什么,也在于把边界画清楚:哪些归代码管,哪些归流程管,哪些只能靠人工。


结语:技术选择的自洽性

回顾这次重构,最有价值的不是某个具体技巧,而是技术选择与产品主张的自洽:

  • 主张「数据不出设备」→ 官网零第三方脚本、零外部字体 CDN
  • 主张「本地优先」→ 官网静态优先、构建期注入、无服务端用户态
  • 主张「可审计」→ 部署流程每个环节都有实测数据,失败路径有明确回滚命令

一个本地优先产品的官网,本身就应该是一个静态的、无追踪的、可离线阅读的页面。这不是营销话术,而是可以直接用浏览器开发者工具验证的事实——打开 Network 面板,刷新页面,看不到任何指向非本机域名的请求。


附录:关键实测数据汇总

项目 数值
Next.js 版本 16.3.4(Turbopack)
React 版本 19.2.8
构建耗时 11.3s(含类型检查)
部署耗时 32–55s(rsync 增量 + 服务器构建)
路由数 7 个,全部静态预渲染
首页文字量 1928 字符
index.html 68,562 字节
源码目录 536 KB
静态资源目录 48 MB(其中 downloads 36 MB)
大标题字重 800(TASA Orbiter 可变字体最粗可用值)
大标题行高 1.2(字形 1.14em,留 0.06 余量)
缓动曲线 [0.16, 1, 0.3, 1]
入场动画 0.9s,视口 20% 触发,边界上移 80px
强调色 单一 #00f2fe
posted @ 2026-10-09 10:20  SuperEden  阅读(2)  评论(0)    收藏  举报