☰
HTML + CSS 3D 响应式个人作品集:原理、性能与移动端实践
2026/11/5 9:39:37 网站建设 项目流程

去年一年我断断续续看了几十个个人作品集站点,做得精致的不少,但真能把 3D 玩明白、又不牺牲加载速度和移动端体验的,比例低得可怜。大部分所谓“3D 作品集”,要么是套了个旋转立方体做装饰,要么整个页面卡成幻灯片,要么在手机上直接退化成一张张平铺的图片。HTML 实现响应式 3D 个人作品集网站这个需求看起来简单,实际上它同时压着三条线:视觉上的空间感、代码层面的渲染性能、以及不同屏幕尺寸下的布局稳定性。任何一条崩了,整个站点的观感就塌了。

这篇内容我想聊的就是这三条线怎么同时站住。核心是用原生 HTML + CSS 3D 变换配合少量 JavaScript,不引入重型 3D 引擎,就能做出有纵深、有视差、能在手机上顺滑运行的立体作品集。适合正在准备个人主页的设计师、前端求职者、独立开发者,也适合已经会写 CSS 但对perspective、preserve-3d这些参数始终一知半解、每次调都是靠感觉碰运气的朋友。下面我会把坐标系原理、参数计算、断点设计、完整代码和踩过的坑全摊开讲,尽量做到你看完能直接抄。

1. 项目整体设计与思路拆解

1.1 先想清楚作品集站点到底在展示什么

动手写第一行代码之前,有个问题必须先回答:作品集站点的第一诉求是什么。它跟营销落地页不一样,跟产品官网也不一样。访客通常只有几十秒耐心,他们来是为了在两三屏之内判断“这个人做东西的水平如何、审美在不在线、有没有跟自己业务匹配的案例”。所以整个页面的信息密度和视觉冲击力之间要做取舍。

我见过太多反面案例:首页先来一段 3D 粒子宇宙飞行动画,飞了八秒才看到第一张作品图,手机上还得左右滑动才能触发。这种设计在本地演示时很唬人,一旦上线,跳出率极高。真正有效的做法是把 3D 当成信息组织的工具而不是炫技的舞台——用空间的纵深关系表达作品的层级、用透视的变化引导视线、用轻微的视差让页面有呼吸感,而不是把所有东西都甩到用户脸上。

这也是我倾向于用 CSS 3D 变换而不是 WebGL 的根本原因:CSS 3D 的“上限”刚好卡在作品集需要的那条线上。它能做出卡片堆叠、倾斜画廊、深度分层、鼠标跟随的轻微旋转,这些足够撑起一个专业感很强的作品集;同时它天然继承 DOM 的可访问性、文本可选择、SEO 可抓取、响应式布局体系完整。而 WebGL 一旦用上,这些优势全都要用额外的工程量找补回来。

1.2 CSS 3D 与 WebGL 两条路线的真实取舍

技术选型这块我不喜欢讲“各有优劣”的和稀泥话,直接摆结论:以文字和图片为主的作品集,优先 CSS 3D;需要真实光照、模型、大量粒子的场景,才考虑 WebGL。

对比维度CSS 3D 变换WebGL(如 Three.js)
首屏体积几乎为 0,随 HTML/CSS 一起加载引擎本体通常 150KB 以上,压缩后仍不小
文本可访问性原生 DOM,可选中、可被搜索抓取需额外做 DOM 镜像层或降级方案
响应式适配直接吃媒体查询和容器宽度需要手动处理 canvas 尺寸与 DPR
交互实现成本改transform即可,调试直观需要场景、相机、射线拾取一整套
真实光照与材质无,只能靠渐变和阴影模拟完整支持
低端机表现中等,主要吃合成层性能差,GPU 弱时明显掉帧
适合的内容形态卡片、画廊、分层视差、倾斜面板3D 模型展示、粒子、复杂空间场景

这张表的关键不在“谁更强”,而在“谁的成本结构更匹配需求”。作品集的内容形态九成以上是“图片 + 标题 + 一段描述”,本质是二维信息,硬塞进三维引擎里,等于用卡车运一个茶杯——不是运不了,是每一环都在浪费。

还有一个常被忽略的点:CSS 3D 的降级路径极其干净。你把transform相关的几行 CSS 抽掉,页面立刻变成一个正常的二维瀑布流,内容一点不少。WebGL 做不到这一点,canvas 里渲染失败就是一片空白,还得额外维护一套降级 DOM。对于个人站点这种长期没人维护的项目,降级路径的优雅程度直接决定了它三年后还能不能看。

1.3 整体架构:一层视口、一层舞台、若干卡片

结构上我采用三层模型,这个模型借鉴了舞台剧的物理逻辑,理解起来很直观。

  • 视口层(viewport):负责定义观察者的位置,对应 CSS 里的perspective,相当于你站在台下第几排看舞台。这一层只设参数,不做变换。
  • 舞台层(stage):整体可以旋转、倾斜的对象,对应transform-style: preserve-3d。鼠标移动时轻微转动的就是它,相当于舞台本身在缓缓转动。
  • 卡片层(card):舞台上的每一件展品,各自有独立的位移和角度。它们可以被单独放大、翻转、向前推进,相当于台上每一块布景板。

分三层的意义在于职责隔离。如果所有变换都堆在一个元素上,你想调“整体旋转 5 度”就会连带影响每个卡片的姿态,改一个参数要重新校准全部。分层之后,舞台负责全局视角,卡片负责个体姿态,两边互不干扰,调参效率差别巨大。这个道理跟写代码时拆函数是一样的,只不过对象换成了 DOM 节点。

2. 3D 坐标系的底层参数,先把坐标系吃透

2.1 perspective 到底在改变什么

很多人对perspective的理解停留在“数值越小越夸张”。这个描述没错,但不够用,因为它没告诉你夸张的物理来源。真实世界里,近处的物体在视网膜上投影大,远处的投影小,这个比例关系由观察距离决定。CSS 的perspective就是那个观察距离,单位是像素。

假设你给一个 300px 宽的元素加上translateZ(150px),同时父容器设perspective: 600px。此时元素到观察者的距离从 600px 变成 450px,视觉放大倍率约等于 600 / 450 ≈ 1.33,元素看起来变成约 400px 宽。如果perspective设成 2000px,距离从 2000 变成 1850,倍率只有 1.08,几乎看不出变化。这个公式可以直接用来反推参数:

缩放倍率 = perspective / (perspective - translateZ)

记住这个式子,你在调“卡片向前浮出多少”的时候就不会再瞎试了。

.viewport { perspective: 1400px; perspective-origin: 50% 45%; overflow: hidden; } .card { transform: translateZ(80px); }

还有一个配套参数perspective-origin,它定义的是观察点的水平垂直位置,默认50% 50%也就是正中。把它调到50% 35%,视觉上相当于你从稍高处俯视舞台,卡片的上边缘会显得更“远”。这个参数在移动端特别有用——竖屏时人的视线落点偏上,把 origin 往上提一点,透视感会更自然,不会出现下边缘被压扁的怪异感。

提示:perspective必须写在父元素上才对子元素生效。写在自己身上无效,这是新手最常见的第一个错误。

2.2 preserve-3d 与那些悄悄压平你的属性

transform-style: preserve-3d的作用是让子元素保留三维空间关系。不加它,父元素的 3D 变换会被“压平”成一张二维图片再交给子元素,子元素之间的 Z 轴距离全部失效,整个场景瞬间变成一个平面。

这个属性本身不难,难的是它会被一些看起来毫不相干的属性破坏。实际项目里我踩过的有这几个:

  • overflow: hidden:在 3D 容器上使用会强制创建压平上下文。想做裁切就得把overflow提到更外层,且外层不能参与 3D 变换。
  • filter:任何滤镜(哪怕只是blur(0))都会触发渲染层重建并压平 3D。做卡片模糊效果时要在内部元素上加,不能加在preserve-3d的节点上。
  • opacity小于 1:部分浏览器同样会压平。淡入动画如果在 3D 容器上做,动画结束要记得复位。
  • will-change用错层级:加在 3D 容器上可能引发意外的层合成行为。
/* 正确:裁切在外层,3D 在内层 */ .viewport { overflow: hidden; } .stage { transform-style: preserve-3d; -webkit-transform-style: preserve-3d; }

注意:写完 3D 效果发现“完全没有立体感”,九成不是参数问题,是某个祖先元素上有overflow: hidden或filter。排查时一路往上翻 DOM,逐个临时禁用,很快就能定位。

2.3 变换顺序不是可交换的

transform里多个函数的排列顺序会直接改变最终结果,这是线性代数的基本性质。rotateX(20deg) translateZ(100px)和translateZ(100px) rotateX(20deg)得到的坐标完全不同:前者是先倾斜坐标系再沿新坐标系的 Z 轴前进,后者是先沿原 Z 轴前进再原地倾斜。

用生活化的说法:想象你手里端着一杯水。先往前走十米再转身九十度,和先转身九十度再往前走十米,最后站的位置完全不一样。CSS 变换就是这么回事。

对作品集的卡片布局,我总结出的顺序是:先定位(translate),再旋转(rotate),最后做局部偏移(translateZ)。这样写的好处是卡片在舞台上的“落点”稳定,旋转只影响姿态不影响位置,调布局时不容易打架。

.card { --i: 0; transform: translateX(calc(var(--i) * 40px)) translateY(calc(var(--i) * 20px)) rotateY(calc(var(--i) * 8deg)) translateZ(calc(var(--i) * -80px)); }

这段代码用了一个递增序号--i,让每张卡片依次右移、下移、旋转、后退,形成一条向后退去的立体扇面。实际调参的时候只要改这四个系数,就能在“紧密堆叠”和“散开排列”之间连续过渡,比一张张手写transform高效得多。

3. 响应式骨架构思:一套结构撑起三种屏幕

3.1 用 CSS 变量把断点和尺寸收成一个总开关

传统媒体查询的写法是一个断点一段样式,重复三遍之后,改一个间距要跳三处改。我更倾向的做法是把尺寸和间距全部变量化,媒体查询里只改变量值,具体规则只写一次。

:root { --card-w: clamp(240px, 30vw, 380px); --card-gap: clamp(16px, 3vw, 44px); --stage-depth: clamp(60px, 8vw, 120px); --tilt-max: 8deg; --persp: clamp(900px, 120vw, 1600px); } @media (max-width: 768px) { :root { --card-w: 82vw; --card-gap: 12px; --stage-depth: 40px; --tilt-max: 4deg; --persp: 900px; } }

这里clamp()是关键。它接受最小值、首选值、最大值三个参数,浏览器会取中间那个值并裁剪到区间内。clamp(240px, 30vw, 380px)的意思是:卡片宽度优先按视口宽度的 30% 走,但绝不低于 240px,也绝不超过 380px。一条规则覆盖了从手机到宽屏的全部情况,中间不需要任何断点。

这个写法的直接收益是中间尺寸不再有跳变。老式的@media (min-width: 768px)加@media (min-width: 1200px)方案,在 768px 和 767px 之间会突然变一次,平板横竖屏切换时能明显看到布局抖一下。clamp()是连续的,从 320px 到 2560px 一路平滑。

3.2 移动端该不该保留 3D

这个问题我纠结过挺久,最后的答案是:保留,但要换一种 3D。

桌面端的 3D 是“多卡片散开 + 鼠标视差”,三到五张卡片铺在视野里,靠鼠标位置驱动倾斜。这个模式搬到手机上立刻出问题:屏幕窄,卡片散开会互相遮挡;没有鼠标,视差失去输入源;性能也撑不住多个大图层同时合成。

移动端我改成了“纵向单列 + 单张卡片深度翻转”。同一时刻只有一张卡片处于主视觉位置,卡片入场时做一个约 15 度的 Y 轴翻转配合 Z 轴推进,滚动到下一张时上一张退出。这样既保留了空间感,又把同时参与 3D 合成的图层数量从五个降到一两个,性能压力骤降。

@media (max-width: 768px) { .stage { display: flex; flex-direction: column; gap: var(--card-gap); transform: none; /* 移动端取消整体倾斜,交给滚动驱动 */ } .card { transform: rotateY(0) translateZ(0) translateY(0); } .card.is-in { animation: card-rise 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; } @keyframes card-rise { from { opacity: 0; transform: perspective(900px) rotateY(14deg) translateZ(-80px); } to { opacity: 1; transform: perspective(900px) rotateY(0) translateZ(0); } } }

注意移动端我把perspective直接写进了transform函数里,而不是依赖父容器的perspective属性。原因是部分移动端内核对父级perspective的继承处理不一致,尤其在某些应用内置浏览器里,父级透视会直接失效导致动画变成平面缩放。写成perspective()函数是把这个参数绑定在元素自身,兼容性更稳。

3.3 字号与触控目标不能只靠 rem

作品集站点的文字层级通常很清晰:大标题、作品名、一句描述、标签。字号我给的是三档,全部用clamp()与视口挂钩:

.title { font-size: clamp(1.75rem, 5vw, 3.5rem); } .card-title { font-size: clamp(1rem, 2.2vw, 1.375rem); } .card-desc { font-size: clamp(0.8125rem, 1.5vw, 0.9375rem); }

另外有个细节很容易被忽略:倾斜和旋转的卡片上,文字的可读性会下降。旋转角度超过 12 度时,小字号文字会明显发虚,尤其在低 DPI 屏上。我的处理方式是给卡片内容区加一点反向补偿——卡片转rotateY(8deg),内容文字所在的内层容器转rotateY(-3deg),让文字保持在接近正对屏幕的角度。这一步纯属经验,没有任何规范要求,但实际观感差别很大。

触控目标方面,移动端所有可点击元素的最小尺寸我给到 44×44 CSS 像素。3D 卡片本身尺寸足够,但卡片上的“查看详情”按钮和底部分页点很容易做小,这两个位置要单独检查。

4. 逐段实现:从卡片堆叠到可交互的3D画廊

4.1 HTML 结构:语义化且方便变换

结构我尽量保持语义干净,不为了样式往里塞额外的 div。每张作品就是一个article,标题用h2(页面主标题用h1),图片用img并带loading="lazy"和明确的宽高属性,防止布局偏移。

<section class="works" aria-labelledby="works-title"> <h2 id="works-title" class="section-title">Selected Works</h2> <div class="viewport"> <div class="stage" id="stage"> <article class="card" style="--i: 0"> <a class="card-link" href="./work-01.html"> <div class="card-media"> <img src="./assets/w1.webp" alt="作品一预览图" width="800" height="600" loading="lazy"> </div> <div class="card-body"> <h3 class="card-title">项目名称</h3> <p class="card-desc">一句话说明做了什么、解决了什么。</p> <ul class="card-tags"><li>交互</li><li>前端</li></ul> </div> </a> </article> <!-- 其余卡片同理,只需递增 --i --> </div> </div> </section>

--i用内联样式写在元素上,是为了让 CSS 能读到序号做递增计算。这个技巧比给每张卡片写一串.card:nth-child(1) { ... }要优雅得多,加一张卡片只需要复制一段 HTML 改个数字。内联样式只承载这一个数值,不涉及视觉样式,所以不违背样式与结构分离的原则。

4.2 3D 卡片的核心样式

.viewport { perspective: var(--persp); perspective-origin: 50% 45%; overflow: hidden; padding-block: 6vh; } .stage { display: flex; justify-content: center; align-items: center; gap: var(--card-gap); transform-style: preserve-3d; transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); transition: transform 0.35s ease-out; will-change: transform; } .card { flex: 0 0 var(--card-w); border-radius: 18px; overflow: hidden; background: #16181d; box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.65); transform-style: preserve-3d; transform: rotateY(calc(var(--i) * 7deg)) translateZ(calc(var(--i) * -70px)); transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.5s ease; } .card:hover, .card:focus-within { transform: rotateY(0deg) translateZ(70px) scale(1.03); box-shadow: 0 40px 80px -20px rgba(0, 0, 0, 0.8); z-index: 10; }

几个细节值得单独说:

overflow: hidden加在.card上是为了裁切图片的圆角,但注意它会把这张卡片内部的 3D 压平。因为卡片内部本来就没有需要保留 3D 的子元素,所以这个副作用无害。如果是想让卡片内部也有立体层(比如图片浮在文字上方),就得把裁切换成clip-path或者给内部元素单独加圆角。

z-index: 10在preserve-3d上下文里其实是无效的——三维场景下的层叠顺序由 Z 轴的几何位置决定,不是由 z-index 决定。这里写它是为了照顾不支持 3D 的降级环境,在那种环境下它会按正常的层叠规则生效。看起来是冗余代码,其实是有意为之的兼容保险。

backface-visibility: hidden我只加在做翻转的卡片上。不加的话,卡片转到背面时图片会左右镜像显示,看起来像出了 bug。但也不能无脑全加,某些老版本内核对这个属性的处理会导致整张卡片消失,用之前记得实测目标环境。

4.3 鼠标视差与触摸拖拽的统一处理

视差的核心是让舞台跟着指针位置轻微转动。直接绑mousemove改transform会有一个典型问题:高频触发的样式写入会让浏览器在每一帧都重新计算,稍微复杂一点的页面就掉帧。我的做法是用requestAnimationFrame加缓动,把原始事件频率和渲染频率解耦。

const stage = document.getElementById('stage'); let rafId = null; let targetX = 0, targetY = 0; let curX = 0, curY = 0; const EASE = 0.08; function schedule() { if (rafId === null) rafId = requestAnimationFrame(tick); } function tick() { curX += (targetX - curX) * EASE; curY += (targetY - curY) * EASE; stage.style.setProperty('--rx', (curY * -6).toFixed(2) + 'deg'); stage.style.setProperty('--ry', (curX * 10).toFixed(2) + 'deg'); const settled = Math.abs(targetX - curX) < 0.001 && Math.abs(targetY - curY) < 0.001; if (settled) { rafId = null; } else { rafId = requestAnimationFrame(tick); } } function onPointer(e) { const p = e.touches ? e.touches[0] : e; const w = window.innerWidth; const h = window.innerHeight; targetX = (p.clientX / w - 0.5) * 2; targetY = (p.clientY / h - 0.5) * 2; schedule(); } window.addEventListener('pointermove', onPointer, { passive: true }); window.addEventListener('pointerleave', () => { targetX = 0; targetY = 0; schedule(); });

这里用pointermove而不是分别绑mousemove和touchmove,是因为 Pointer Events 天然统一了鼠标、触摸、触控笔三种输入,代码量少一半,行为也一致。加{ passive: true }是告诉浏览器这个监听器不会调用preventDefault,从而允许滚动不被阻塞,这对移动端的滚动流畅度影响很大。

缓动系数EASE = 0.08是我试出来比较舒服的值。调大到 0.2,跟手感强但会有抖动感;调到 0.03,非常丝滑但明显滞后,像隔着一层水。0.06 到 0.10 之间是比较通用的区间,具体看你的页面节奏。

还有个细节:settled判断。如果不做这个判断,requestAnimationFrame会永远循环下去,即使指针不动也在每帧写样式,白白耗电。加上这个判断后,动画收敛到接近目标值就自动停掉,下次指针移动再加schedule()重新启动。移动端这一点尤其重要,页面挂后台之前能省不少电。

4.4 入场动画与交错延迟

卡片一次性全部出现,立体感会打折扣。我给它加了一个交错入场:每张卡片根据--i计算延迟,依次从远处飞入。

.card { opacity: 0; animation: card-in 0.7s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; animation-delay: calc(var(--i) * 90ms); } @keyframes card-in { from { opacity: 0; transform: rotateY(calc(var(--i) * 7deg)) translateZ(calc(var(--i) * -70px - 220px)); } to { opacity: 1; transform: rotateY(calc(var(--i) * 7deg)) translateZ(calc(var(--i) * -70px)); } }

延迟用calc(var(--i) * 90ms),五张卡片总动画时长 0.7 + 0.36 = 1.06 秒。这个节奏我调过好几版:全部同时出现(0ms 间隔)显得廉价,间隔 200ms 以上又会让用户等得不耐烦。90ms 是个比较讨巧的值,既有递进感又不拖沓。

注意:交错动画的延迟别用在transition上,用animation-delay。如果用transition,你需要手动加类名触发,还要处理重复触发的问题,反而更麻烦。

5. 性能与兼容:那些文档里不写的坑

5.1 移动端 3D 失效的四类原因

线上出问题时,现象往往是“桌面好好的,手机上一片平”。我整理过排查顺序,按出现频率从高到低:

现象常见原因处理方式
完全无立体感祖先元素有overflow: hidden或filter逐级禁用排查,把裁切挪到最外层
卡片显示但无透视perspective写在了元素自身而非父级移到父容器,或用perspective()函数
动画闪烁/消失backface-visibility与老内核不兼容移除该属性,改用旋转角度控制
掉帧严重同时参与合成的图层过多减少可见卡片数,给非活动卡片加visibility: hidden

第四类最容易被忽视。3D 变换会让元素提升为独立的合成层,每个层都要占 GPU 纹理内存。五张 400×500 的卡片在 3 倍屏下,纹理内存占用大约是 400×500×3×3×4 字节×5 ≈ 72MB,这还只是卡片本身,不含阴影和模糊。中低端安卓机的 GPU 纹理预算普遍在 100MB 到 200MB 之间,一超标就开始掉帧,甚至直接白屏。

我的优化手段是:给不可见的卡片加visibility: hidden,让浏览器把它们的合成层回收掉。同时把卡片的阴影从box-shadow换成事先渲染好的渐变色块,因为大面积高斯模糊阴影在 3D 变换下会被反复重绘,开销比想象中大得多。

5.2 移动端的视口高度问题

100vh在移动浏览器上是个经典陷阱。地址栏收起和展开时,视口高度会变,但vh单位不会跟着变,导致页面底部被裁掉一截或者多出一块空白。

.hero { min-height: 100vh; min-height: 100dvh; /* 支持时覆盖,动态视口高度 */ }

dvh是动态视口高度,会随地址栏状态实时更新。写法上先写vh做兜底,再写dvh覆盖,不支持dvh的旧浏览器自动忽略第二行,支持的就用新的。这个技巧现在已经是标配了,但还有不少人在用100vh硬扛。

5.3 无障碍与降级:别把拒绝动效的用户挡在门外

有些用户会因为前庭功能敏感而关闭系统动效,操作系统层面提供了prefers-reduced-motion媒体查询。作品集网站如果无视这个设置,鼠标一移动整个页面晃,对这部分用户是很不友好的体验。

@media (prefers-reduced-motion: reduce) { .stage { transform: none !important; transition: none !important; } .card { animation: none !important; opacity: 1; transform: rotateY(0) translateZ(0); } * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }

这段规则把所有的 3D 倾斜和入场动画关掉,页面退化成干净的二维列表。功能一个不少,只是没了动效。这不是“高级优化”,是基本素养——你的视觉表达不应该建立在别人的不适之上。

另一层降级是@supports。虽然现代浏览器对transform-style: preserve-3d的支持已经很好了,但保险起见还是可以写:

@supports not (transform-style: preserve-3d) { .stage { transform: none; } .card { transform: none; opacity: 1; } }

6. 常见问题与排查技巧实录

6.1 高频问题速查表

问题现象排查方向解决方案
卡片重叠顺序错乱3D 上下文中z-index失效调整translateZ值,用几何位置排序
文字在斜面上发虚旋转角度过大 + 缺少文字补偿内容区反向旋转 3 到 5 度
滚动时整个页面抖动3D 元素参与滚动合成给滚动容器加overscroll-behavior: contain
首次加载闪烁图片未预留尺寸导致布局偏移给img写死width和height
动画结束后卡顿will-change未释放动画结束移除该属性,或用animationend清理
卡片在 Safari 上消失缺少-webkit-前缀补齐-webkit-transform-style等

6.2 几个我反复踩的具体坑

第一个坑:will-change滥用。这个东西的定位是“提前告诉浏览器我要变了,你准备好”,但很多人把它当成性能万能药,给所有卡片都加上。结果每个卡片都提前提升为合成层,内存占用翻倍,本来流畅的页面反而卡了。正确用法是只在即将发生动画的元素上加,动画结束后移除。我在实际项目里的处理是给舞台加,卡片不加,因为卡片的变换由 CSS 的:hover触发,浏览器自己会处理提升。

第二个坑:滚动容器的overflow连锁反应。页面主体嵌在一个overflow-y: auto的容器里,容器内部又要做 3D,这两者天然冲突。解法是把 3D 场景做成position: fixed的独立层,滚动只影响内容层,两层通过滚动进度做视觉联动。这个改动听起来大,实际上就是把 DOM 结构拆成两块,改完之后性能问题一次性解决。

第三个坑:WebP 图片在 3D 变换下的锯齿。卡片倾斜时,图片边缘会出现明显的锯齿或者模糊。原因是浏览器在合成时对旋转的纹理做了双线性插值,而图片本身的尺寸又不是整数倍缩放。我的做法是把图片的显示尺寸控制在原始尺寸的 0.5 倍到 1 倍之间,避免二次采样;同时给卡片加image-rendering: auto,让浏览器自己选插值算法。实测下来,图片按 2 倍尺寸导出、显示时缩到 0.5 倍,是清晰度和体积之间比较好的平衡点。

第四个坑:键盘用户的焦点可见性。用:focus-within给卡片做悬停效果的时候,记得同时给链接加清晰的outline。我见过不少站点用outline: none一刀切,鼠标用户没感觉,键盘用户彻底迷路。正确写法是outline: 2px solid currentColor; outline-offset: 4px;,视觉上克制但存在。

6.3 调试 3D 的三个小手法

浏览器开发者工具有个Rendering面板,里面可以打开“Paint flashing”和“Layer borders”。前者会把每次重绘的区域闪成绿色,你能直观看到哪些元素在疯狂重绘;后者会画出合成层边界,3D 元素会显示成黄色边框,一眼就能看出有没有意外提升的图层。

第二个手法是临时给preserve-3d的容器加一个艳丽的背景色。如果背景色出现在子元素上方,说明 3D 上下文被破坏了,子元素被压到了父元素的平面里。这个方法简单粗暴,但定位问题极快。

第三个手法是把所有transform值写成可控的 CSS 变量,然后在控制台里直接改。比如document.documentElement.style.setProperty('--persp', '800px'),实时看到透视变化,比改代码刷新快得多。


最后分享一个我自己的习惯:每次调完 3D 参数,我会把浏览器窗口缩到 360px 宽,再用设备模拟器切到低端安卓机型跑一遍。绝大多数问题都是在这个尺寸下暴露出来的,桌面端再怎么调都觉得没问题。3D 这个东西很吃实际设备的表现,参数在开发机上漂亮,不代表用户在通勤路上的手机上看着舒服。把最窄的屏幕当主场景来设计,往上放大反而容易,反过来就难了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询