这次主页重构看起来只是把首页做得更“酷”一点:3D 星轨背景、轨道式导航、玻璃态面板、PWA 缓存、音乐播放器、状态卡片。真正做完以后才发现,视觉只是表层,难点其实在几个很具体的工程问题上:滚动到底应该归谁处理,触控板的细碎 delta 怎么变成可控的导航,弹层打开后为什么还会带着底层页面一起动,以及一个 Three.js 背景该不该压进首屏包。
这篇记录不写效果展示,主要复盘几个踩坑点。
1. 3D 首页最容易犯的错:把滚动当成普通滚动
传统页面的滚动是连续的,用户滚多少,页面就滚多少。3D 场景式首页不一样。它的“滚动”往往不是文档流滚动,而是把输入转换成一个场景进度,比如:
sceneProgress = 0 // 首页
sceneProgress = 1 // 状态舱
sceneProgress = 2 // 第一个链接节点这个模型的好处是清晰:摄像机、DOM 卡片、导航点都可以围绕同一个 progress 做插值。问题也很明显:如果把触控板每一次微小的 wheel delta 都直接加到 progress 上,页面会变得非常难控。
鼠标滚轮一次通常是明确的一步,而触控板是一串很密的细碎事件。把它们等价处理,就会出现两个问题:
- 用户只是轻轻滑了一下,镜头却慢慢漂出去;
- 用户想停在当前节点,惯性滚动又把页面推到下一屏。
所以这类首页更适合“阈值触发”,而不是“连续跟随”。
我的处理方式是把滚轮增量累积起来,超过阈值后只推进一屏,并加一个短暂锁定时间:
const WHEEL_THRESHOLD = 90
const STEP_LOCK_MS = 430
wheelDelta += e.deltaY
if (Math.abs(wheelDelta) >= WHEEL_THRESHOLD) {
stepScene(wheelDelta > 0 ? 1 : -1)
wheelDelta = 0
}这看起来没有连续插值那么“顺滑”,但使用体验反而更稳定。首页导航的本质不是阅读长文,而是在几个节点之间移动。对节点式页面来说,“可预测”比“每个像素都响应”更重要。
2. 弹层必须拥有自己的滚动边界
这次最典型的 bug 是:首页三个快捷按钮点开档案弹层后,在弹层里下滑,触发的却是首页场景切换。
根因不是 CSS,而是事件归属。弹层里的 wheel / touchmove 没有被拦截,事件继续冒泡到了绑定在 window 上的场景滚动控制器。于是用户以为自己在滚弹层,实际是在推进首页镜头。
这种交互非常破坏信任,因为用户的手势目标和页面响应不一致。
修复思路很直接:弹层打开后,滚动事件应该止步于弹层;弹层内部可滚区域用自己的 overflow-y: auto,并通过 overscroll-behavior: contain 阻止滚动链外溢。
<div class="box-overlay" @wheel.stop @touchmove.stop>
<div class="box" @wheel.stop @touchmove.stop>
<div class="content">
...
</div>
</div>
</div>.content {
overflow-y: auto;
overscroll-behavior: contain;
}还有一个细节:场景滚动控制器本身也应该知道当前是否有全局浮层打开。即使某个浮层漏了 .stop,底层也应该能拒绝处理:
if (store.boxOpenState || store.setOpenState || store.backgroundShow) return前端里很多“玄学滚动问题”,本质上都是事件所有权没有划清楚。
3. PWA 不是加上插件就完事
主页用了 PWA 缓存,构建阶段会生成 sw.js 和 registerSW.js。这类功能在线上很好用,但也容易带来两个实际问题。
第一个是兼容性。不是所有访问环境都支持 Service Worker,也不是所有环境都允许注册 Service Worker。如果代码直接写:
navigator.serviceWorker.addEventListener('controllerchange', handler)在不支持的环境里,入口脚本会直接抛错。对于单页应用来说,入口脚本抛错就可能是白屏。更稳的写法是:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', handler)
}第二个是缓存更新。部署后用户看到旧资源,不一定是 nginx 没更新,也可能是旧 Service Worker 还在接管页面。线上排查时要同时看:
index.html是否引用了新 hash 的资源;- 新 hash 的 JS/CSS 是否能 200;
- 浏览器里的 Service Worker 是否已经更新;
- 是否需要强刷或等待自动更新。
PWA 的问题不难,但它会把“部署成功”和“用户实际看到新版本”拆成两件事。
4. Three.js 不应该默认进首屏入口包
3D 背景是这次视觉效果的核心,但 Three.js 体积不小。如果它和 Vue 入口、Element Plus、播放器一起打进主包,首屏会被拖慢。
这类资源更适合异步加载。页面先加载基本结构,3D 背景组件再动态 import:
const AmbientField = defineAsyncComponent(() => import('@/components/AmbientField.vue'))更进一步,组件内部的 Three.js 也可以动态加载:
let THREE
onMounted(async () => {
THREE = await import('three')
buildScene()
})这样拆完以后,入口 JS 从接近 900KB 降到了 150KB 左右。Three.js 仍然是一个大包,但它变成了“需要 3D 背景时再加载”的独立异步资源,而不是所有用户进入页面都必须先下载。
这里的核心不是追求构建报告里的数字好看,而是让资源加载顺序符合用户实际路径:先可见、再精致;先可用、再动效。
5. 不要让视觉设计压过操作模型
这次主页的视觉目标是“轨道、星图、信号舱”,但交互目标其实很朴素:
- 用户能稳定停在某个场景;
- 用户点开弹层后,注意力留在弹层里;
- 用户使用触控板时,不需要小心翼翼地控制手指;
- 页面更新后,不会因为 SW 或缓存让人误判部署失败;
- 首屏不因为 3D 效果变得迟钝。
一个页面“高级”不在于元素有多少,而在于输入和反馈之间是否可预期。尤其是个人主页这种入口型页面,用户不会花很多时间学习你的交互规则。第一次滚动不舒服,第一次点开弹层不受控,页面就已经输了。
6. 这次调整后的几个结论
这次重构后,我给自己留下几个比较实用的规则:
- 节点式场景不要直接消费触控板连续 delta,应该做阈值和步进。
- 弹层、抽屉、菜单这类覆盖层必须拥有滚动边界。
- 全局事件监听要能卸载,也要能根据应用状态拒绝处理。
- PWA 代码必须做能力判断,缓存更新要纳入部署验证。
- Three.js、播放器、设置面板这类重模块应尽量异步加载。
- 构建警告不能只靠提高阈值掩盖,先确认大包是否已经离开首屏路径。
最后还是那句话:交互不是动效,交互是控制感。
一个 3D 首页可以很炫,但如果滚动不听话,它就只是一个会动的麻烦。
京公网安备11010802044340号