静态优先:一个「本地优先」产品官网的技术架构与工程实践
本文基于 Omni Suite 官网(omnisuite.top)的真实重构过程撰写。文中所有数据均来自实际构建与线上实测,而非理论推演。
引言:官网是产品的一部分
对于工具类产品,官网常被当作「营销页面」——一个用来放截图、写广告语的地方。但如果你做的是本地优先(local-first)产品,官网的定位需要重新思考:它本身就是产品哲学的一次演示。
一个主张「数据不出设备」的扩展套件,如果官网塞满了第三方追踪脚本、广告 SDK、行为埋点,那无论文案写得多真诚,都是自相矛盾。
Omni Suite 是一个由六款 Manifest V3 浏览器扩展组成的套件,全部遵循同一条铁律:所有计算与模型推理都在用户设备端完成,零数据上云。这个约束不只作用于扩展本身,也作用于官网的技术选型。
于是官网的技术目标变得清晰:
- 静态优先 —— 能预渲染的绝不运行时计算
- 零第三方依赖 —— 没有分析脚本、没有广告 SDK、没有外部字体 CDN
- 构建期注入 —— 环境变量在
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 导航清晰度的保障
两个跳转入口:
- Header 常驻「文档中心」按钮
- 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 |
浙公网安备 33010602011771号