很多人在拿到一张视频社区首页的设计稿时,第一反应是打开编辑器,从顶栏开始一行行往下码。结果写到中午,页面已经有三屏长了,回头改一个间距,整块布局塌掉;想把右侧榜单挪到窄屏底部,发现 DOM 结构根本没给这个可能性。我做过好几轮这类静态页面的仿写,最后得出的结论有点反直觉:这类首页的难点从来不是"像不像",而是"改得动不动"。HTML5 + CSS3 写首页,考验的不是你会不会用 flex,而是你有没有在写第一个<div>之前,就把页面当成一组可以独立呼吸的模块来规划。
这篇内容讲的是一个视频社区首页的完整静态还原思路,涉及语义化骨架、固定顶栏、纯 CSS 轮播、卡片流网格、主题变量、响应式断点与交付前的自查清单。它适合已经会写基础 HTML/CSS、但独立做大页面时总觉得"哪里不对劲"的读者,也适合想系统梳理现代 CSS 布局能力的人。全文不依赖任何框架,也不写脚本,所有交互视觉全部落在 HTML5 语义标签和 CSS3 能力之内。
1. 先拆出页面的"呼吸单元",再决定写哪一行标签
我见过的返工最多的写法,是打开页面就建一个<div class="container">,然后所有东西一层层往里塞。写到后面你会得到一个深度七八层、类名互相覆盖、改一处动全身的结构。真正省时间的做法是先把首页摊开,按"视觉上能独立存在、功能上能独立替换"的标准切块,每一块就是一个呼吸单元。
一个典型的视频社区首页,横着切是这几层:顶栏(Logo、搜索、投稿入口、头像)、分区导航条(一排图标加文字)、首屏推荐位(轮播 + 右侧小的推荐卡组)、分区内容块(每个分区一行标题加若干卡片)、主推荐流(多列卡片瀑布)、侧边栏(榜单、公告、话题)、页脚。竖着切则是每个块内部的卡片模板。
先做这步拆分的好处很实在:你会立刻发现"卡片"这个东西在页面上出现了至少四种形态——视频卡、直播卡、专栏卡、榜单条目——它们的骨架高度相似,只有底部信息行不同。这意味着 HTML 只需要写一份模板,内部用不同的修饰类区分,CSS 也可以共用大部分规则。反过来,如果你一边写一边发现,四个卡片的 HTML 结构各不相同,那后面必然要写四套 CSS。
1.1 一张区域对照表,把标签和布局方式一起定下来
我习惯在动手前先填一张表,把所有区域、对应的语义标签、预期的布局手段、是否吸顶一次性定死。这张表不写完不开始写代码。
| 区域 | 语义标签 | 布局方式 | 是否吸顶 | 备注 |
|---|---|---|---|---|
| 顶栏 | <header> | Flex,三段分布 | 是 | 内含<nav>与搜索表单 |
| 分区导航 | <nav> | Flex + 横向滚动 | 是(桌面端) | 窄屏改为可滑动 |
| 首屏推荐 | <section> | Grid 两列(轮播 + 推荐位) | 否 | 轮播自带容器 |
| 分区内容块 | <section>包裹多个<article> | Grid 自动填充 | 否 | 每块一个标题行 |
| 主推荐流 | <main> | Grid 多列 | 否 | 卡片最密集区域 |
| 侧边栏 | <aside> | Flex 纵向 | 可选吸顶 | 榜单、公告 |
| 页脚 | <footer> | Flex 多列 | 否 | 链接分组 |
为什么强调标签选型而不是全用<div>?两个实际收益。第一是调试效率:DevTools 里一排<section>和<aside>,你在元素面板里扫一眼就知道自己在哪个区域,比看十个<div class="box">快得多。第二是当页面被键盘用户或读屏软件访问时,地标(landmark)能让他们直接跳到主内容,跳过顶栏和导航——这个成本你写标签的时候顺手就付了,事后补要重写结构。
1.2 类名先定命名空间,避免后期"加个前缀"的灾难
类名我建议一开始就带区域前缀,比如hd-(header)、nav-、card-、side-、ft-。这不是为了好看,是为了防止后期出现.title这种全局类。你在顶栏写了个.title,在卡片里又写了个.title,两个规则打架的时候,你只能靠!important或者加深选择器救场,而这个坑会越挖越深。
我的做法是:区域前缀 + 元素名,状态用is-前缀单独一层,比如card-video、card-video__meta、is-active、is-loading。状态类和结构类分开的好处是,改状态的时候你只需要看is-开头的那些规则,不会误伤布局。
还有一点值得提前说:容器宽度不要靠每个区块自己算。我会在最外层定义一个布局容器,最大宽度用一个变量控制,左右内边距用clamp()让它随视口伸缩。所有区域共用这一个容器,页面左右边缘才能对齐成一条直线。这条线一旦歪了,整页看起来就是"廉价"的。
2. HTML 骨架落地:容器、卡片模板与那些该顺手加的属性
骨架代码我按区域拆成几段来写。先看最外层结构和顶栏。
<body> <a class="skip-link" href="#main">跳到主内容</a> <header class="hd"> <div class="layout hd__inner"> <a class="hd__logo" href="/" aria-label="返回首页">站点标识</a> <form class="hd__search" role="search" action="#"> <label class="sr-only" for="q">搜索内容</label> <input id="q" name="q" type="search" placeholder="搜索视频、专栏、用户" autocomplete="off"> <button type="submit" class="hd__search-btn">搜索</button> </form> <nav class="hd__actions" aria-label="用户操作"> <a class="hd__avatar" href="#"><img src="avatar.jpg" alt="个人头像" width="36" height="36"></a> </nav> </div> </header> <nav class="nav" aria-label="内容分区"> <ul class="layout nav__list"> <li><a class="nav__item is-active" href="#">推荐</a></li> <li><a class="nav__item" href="#">动画</a></li> <li><a class="nav__item" href="#">影视</a></li> <li><a class="nav__item" href="#">知识</a></li> </ul> </nav> <main id="main" class="layout"> <!-- 首屏、分区块、推荐流 --> </main> <aside class="side" aria-label="榜单与公告"><!-- ... --></aside> <footer class="ft"><!-- ... --></footer> </body>注意搜索框。很多人图省事直接写<input placeholder="搜索">外面套个 div,视觉上完全一样,但屏幕阅读器读到这里只会念"编辑框",用户不知道搜什么。加一个<label>用视觉隐藏类藏起来,再配role="search",成本几秒钟,收益是这块区域变成了一个可被快速定位的搜索地标。
提示:
<input type="search">在部分浏览器里会带一个自带的清除按钮,样式和你的设计对不上。要么接受它,要么用::-webkit-search-cancel-button单独处理,别用type="text"绕过去——那会丢掉语义。
2.1 卡片模板:一份结构,喂给四种不同形态
卡片是整个页面复用率最高的单元。我的写法是把结构写到最"满",用到哪部分就隐藏哪部分,而不是为每种卡片单独写一套。
<article class="card card--video"> <a class="card__link" href="#"> <div class="card__cover"> <img src="cover.jpg" alt="" width="320" height="200" loading="lazy" decoding="async"> <span class="card__badge">12:34</span> </div> <h3 class="card__title">一个足够长到需要两行才能放下并且还要截断的标题示例</h3> <p class="card__meta"> <span class="card__author">某位创作者</span> <span class="card__stat">2.3 万播放</span> </p> </a> </article>这份结构的关键在于img的width和height属性。它们不是用来定死尺寸的——CSS 里的width: 100%会覆盖显示尺寸——而是给浏览器一个宽高比,让它在图片下载完成之前就预留好正确的高度。没有这两个属性,图片加载完成的一瞬间整个卡片会往下顶,你正在读的标题会突然跳走。这就是布局抖动最常见的一种来源,一个属性就能解决。
封面的alt我写成空字符串,这是有意的。卡片整体是一个链接,链接的可访问名称来自标题文字,封面再念一遍是重复信息。装饰性图片给空alt是标准做法,不是偷懒。
loading="lazy"放在首屏之外的卡片上,decoding="async"让图片解码不阻塞主线程。这两个属性在长列表页面上能明显降低首屏的负载。
2.2 三种"不变形"的封面处理,选哪个取决于版式
封面图永远是静态页面的痛点:源图比例乱七八糟,卡片格子却要求统一。我实测过三种思路,各有适用场景。
| 方案 | 写法 | 优点 | 代价 |
|---|---|---|---|
容器定高 +object-fit: cover | 卡片内.card__cover{height:180px}+ 图片object-fit:cover | 最简单,格子绝对齐 | 窄图两侧内容被裁掉 |
aspect-ratio+object-fit: cover | 容器aspect-ratio:16/10 | 高度随宽度自适应,不断层 | 宽高比极端时裁切较多 |
图片作为背景 +background-size: cover | background-image | 裁切可控,可加渐变遮罩 | 失去loading与语义,不建议用于内容图 |
第三种现在我不推荐用在内容图上,因为一旦图片走背景,懒加载和替代文本就都没了。内容图老老实实用<img>,装饰性的渐变遮罩再挂到伪元素上。
2.3 写 HTML 时就该顺手补上的属性
有几样东西,事后补比重写还麻烦,我习惯在写标签的当下就一并写上:顶栏 logo 链接的aria-label(因为 logo 往往是一张图,没有文字);所有纯图标的按钮补aria-label;导航列表用<ul>包一层,因为阅读器会播报"列表,共 4 项",用户立刻知道有多少个入口;跳转链接.skip-link放在<body>第一个子元素,它默认藏起来,获得焦点时出现——这个对键盘用户是刚需,成本却极低。
3. 固定顶栏的层级战争:sticky、层叠上下文与毛玻璃的代价
顶栏吸顶看起来是最简单的一件事,position: sticky; top: 0一行就够了。但我在实际项目里被它坑过的次数,比任何一个布局属性都多。
最常见的失败是"写了 sticky 但页面完全没反应"。原因基本集中在三个地方:父元素有overflow: hidden或overflow: auto,sticky 元素会以这个父元素为滚动容器,父元素本身不滚动,所以永远不"粘";sticky 元素的高度等于父元素高度,没有可滚动的余量;或者祖先链上某个元素的transform、filter、will-change创建了新的包含块,改变了定位参照。排查的时候不要盯着 sticky 那一行看,往上一层层查overflow和transform更快。
顶栏和导航条同时吸顶,还需要算偏移量。顶栏高度如果是56px,导航的top就得是56px,否则两个会叠在一起。我一般把顶栏高度写成一个 CSS 变量,导航的top直接引用它,改一处全局生效,不用两处同步。
3.1 一个能直接用的层级表
z-index失效的根本原因,永远是想比较的两个元素不在同一个层叠上下文里。所以别追着数字调,先把上下文理清楚。我给页面定了一张固定的层级表,所有组件按表取值,不临时发挥。
| 层级 | 用途 | 典型取值 |
|---|---|---|
| 基础层 | 普通内容流 | auto |
| 悬浮层 | 卡片 hover 抬升 | 2 |
| 吸顶导航 | 顶栏、分区条 | 100 |
| 下拉浮层 | 搜索联想、菜单 | 200 |
| 遮罩弹层 | 弹窗背景与内容 | 1000 |
数值之间留出足够空隙,是为了中间插需求时不用把一整排数字全改一遍。这一点吃过亏的人都懂——一旦开始用101、102、103这种连续编号,你迟早会遇到需要插一个"介于 102 和 103 之间"的东西。
3.2 毛玻璃好看,但它是有代价的
顶栏做半透明加背景模糊,效果确实高级。写法是backdrop-filter: blur(12px)配一个半透明背景色。但这个东西有三个隐性成本,不提前知道会很难受。
第一,它会让元素创建层叠上下文,也就是你在它内部设置的z-index不再和外部的元素直接比较。第二,模糊是逐帧重算的,顶栏面积越大、模糊半径越大,滚动时的开销越高,低端设备上会出现明显掉帧。第三,如果背后没有足够的内容或者背景色接近,模糊效果几乎看不出来,白花性能。
我的处理是:只在桌面宽屏开启毛玻璃,窄屏用不透明背景色直接盖住;模糊半径控制在10px上下,不要贪心;给顶栏加will-change: backdrop-filter要慎重,它会长期占用一层合成层,不如不加。
还有个小细节——滚动之后顶栏该有个阴影,表示"下面还有内容"。纯 CSS 现在可以用滚动驱动的动画来做,但兼容性还不齐。我的做法是给一个很轻的默认阴影,或者干脆用border-bottom: 1px solid配半透明色,视觉上够用,还不用脚本参与。
4. 轮播不做脚本:CSS 动画与 scroll-snap 两条路
首屏轮播是这类页面里唯一"看起来必须用脚本"的部分。但纯 CSS 确实能做出两种可用形态,选哪种取决于你要的交互语义。
第一条路是@keyframes驱动的自动轮播。所有幻灯片横向排成一行,外层容器overflow: hidden,内部轨道用一个位移关键帧循环平移。参数要算准:假设有 4 张图,每张图片占容器 100% 宽,那么位移的终点是-400%(相对于轨道自身宽度,如果轨道被撑成 400% 的话)。为了避免从最后一张跳回第一张时的"倒带",普遍做法是复制第一张放到末尾,动画跑到复制张时瞬间归零。
.carousel__track { display: flex; width: 400%; animation: slide 16s infinite; } .carousel__slide { width: 25%; } @keyframes slide { 0%, 20% { transform: translateX(0); } 25%, 45% { transform: translateX(-25%); } 50%, 70% { transform: translateX(-50%); } 75%, 95% { transform: translateX(-75%); } 100% { transform: translateX(-100%); } }这里的百分比是按"轨道宽度的百分比"算的,因为translateX的百分比参照的是元素自身宽度。轨道宽 400%,移动 25% 就是移动一个屏幕宽。时间段留出 20% 的停留、5% 的过渡,节奏才不显得仓促。总时长 16 秒、4 张图,每张停留 3.2 秒——这个节奏是我调过几轮之后觉得比较舒服的,再快用户看不完标题,再慢会觉得页面卡住了。
4.1 scroll-snap:更接近真实操作习惯的那条路
自动轮播有个先天问题:用户看到第三张想停下来看,它会自己滑走。所以现在很多产品改成"手动滑动 + 吸附",而这件事 CSS 原生就能做。
.carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; scrollbar-width: none; } .carousel::-webkit-scrollbar { display: none; } .carousel__slide { flex: 0 0 100%; scroll-snap-align: start; }scroll-snap-type: x mandatory让滑动结束后自动吸附到最近的整张,手势一停就对齐,不会停在两张图中间。flex: 0 0 100%保证每张刚好占满。scrollbar-width: none藏掉横向滚动条,视觉上就像原生的轮播组件。这套写法的好处是交互完全交给浏览器,惯性、回弹、触屏手势全都不用管。
两条路线的取舍我列一下:
| 维度 | keyframes 自动轮播 | scroll-snap 手动滑动 |
|---|---|---|
| 是否需要脚本 | 完全不需要 | 完全不需要 |
| 用户可控性 | 差(只能悬停暂停) | 好(想停就停) |
| 触屏体验 | 一般 | 接近原生 |
| 指示点同步 | 难(纯 CSS 需 radio 方案) | 难(同样需要额外结构) |
| 首屏视觉冲击 | 强 | 中 |
我现在的默认选择是后者。首页轮播的价值在于"让用户看到有多个推荐",而不是"强迫用户看完四个推荐",手滑更符合这个目标。
4.2 别忘了一件事:让它可以被暂停
自动播放的动画,对某些用户来说是干扰,甚至会引起不适。CSS 里有一个媒体查询专门处理这件事:prefers-reduced-motion。当系统设置为减少动效时,把动画关掉。
@media (prefers-reduced-motion: reduce) { .carousel__track { animation: none; } * { scroll-behavior: auto !important; } }另外,鼠标悬停时暂停是个几乎零成本的体验优化:.carousel:hover .carousel__track { animation-play-state: paused; }。键盘用户聚焦到轮播内部时也应该暂停,用:focus-within同样能实现。这两行代码能挡掉"用户正在看,图自己跑了"的差评。
注意:纯 CSS 轮播做不了"点击指示点跳到第 N 张"这种精确控制。如果产品明确要求指示点可点,老老实实上脚本,别用 radio +
:checked硬撑——那条路的 HTML 结构会变得非常别扭,维护成本远高于收益。
5. 卡片流:Grid 定骨架、Flex 管内容,aspect-ratio 收拾残局
主推荐流是页面上卡片最密集的地方,也是布局最容易崩的地方。我的分工很明确:外层网格用 Grid,卡片内部用 Flex。Grid 负责"有几列、列多宽、间隔多少",Flex 负责"封面在上、标题在中、信息行在下"。
外层的写法:
.feed { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 20px 16px; }这一行几乎解决了整个响应式布局问题。minmax(240px, 1fr)的意思是:每列最小 240px,剩余空间平均分。auto-fill让浏览器自己算能放几列——宽屏 6 列,窄屏 2 列,中间平滑过渡。你不需要写任何媒体查询来改列数。
gap的两个值分别是行间距和列间距。行距比列距略大一点,是因为卡片下方的文字需要视觉呼吸空间,行与行贴太近会显得拥挤。这个细节很多仿写都忽略了,但它直接影响"精致感"。
5.1 auto-fill 和 auto-fit 差在哪,什么时候用哪个
这两个值名字像,行为差很多。auto-fill会保留空轨道,即使没有内容也占着位置;auto-fit会把空轨道塌缩掉,让已有内容撑满整行。
具体表现:当容器里只有 2 个卡片,但宽度够放 6 列时,auto-fill会让这 2 个卡片各占一列的宽度,右边留 4 个空格;auto-fit会让这 2 个卡片各撑到 3 列宽。前者看起来"空",后者看起来"卡片被拉大了"。
我的选择是:内容数量不确定的长列表用auto-fill,因为卡片宽度应该保持一致,用户滚动时不会觉得格子忽大忽小;固定数量的展示区(比如"本周精选 4 个")用auto-fit,让它们均匀铺满。
5.2 卡片内部:一行 flex 解决信息对齐
封面下面的信息行,最常见的需求是"作者名靠左、播放量靠右,中间自动撑开"。这件事一行就够:
.card__meta { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--c-text-2); } .card__author { margin-right: auto; }margin-right: auto比justify-content: space-between更稳,因为它在元素超过两个的时候依然保持"只有最后一个靠右",不会把所有空隙摊平。这个技巧我在几乎所有列表行里都在用。
封面的圆角我用border-radius: 8px配overflow: hidden,但注意——如果卡片 hover 时有transform: translateY(-4px),overflow: hidden会裁掉阴影。解决方式是把阴影从卡片移到封面以外的容器,或者干脆只做位移不做阴影。我倾向于位移加一层很轻的阴影,并且把overflow: hidden只放在封面容器上,不放在卡片上。
5.3 两行截断:老办法依然是最省事的
标题必须截断,否则卡片高度参差不齐,网格会变得很难看。多行截断目前最实用的还是-webkit-line-clamp那一套:
.card__title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }虽然带前缀,但主流浏览器都支持,实际项目里照用不误。要注意的是它需要配合固定的行高,否则两行的实际高度会随字号浮动。我给标题定line-height: 1.4,字号用clamp()弹性缩放,卡片高度就稳定了。
如果标题只有一行,卡片底部会空出一截——这个"空"其实是好事,它让同一行的卡片底部信息行对齐在同一条水平线上。为了让信息行始终贴底,卡片本身可以设成 flex 纵向,标题区flex: 1。
6. 视觉一致性的来源:变量、间距刻度与状态覆盖
一个页面看起来"专业"还是"拼凑",八成取决于一致性,而不是单个元素好不好看。一致性的落地手段就一个:能抽成变量的绝不写字面值。
我的变量清单大致分四组:颜色、间距、圆角与阴影、字号与行高。
:root { --c-brand: #fb7299; --c-bg: #ffffff; --c-bg-2: #f6f7f8; --c-text-1: #18191c; --c-text-2: #61666d; --c-line: #e3e5e7; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --r-sm: 4px; --r-md: 8px; --r-lg: 12px; --fs-sm: 12px; --fs-md: 14px; --fs-lg: clamp(16px, 1.2vw + 12px, 20px); }间距用 4 的倍数做刻度,是为了让所有间距都能互相整除。你会发现页面上一旦出现13px、17px、22px这种数字,整体就开始"散",因为人眼对不齐的东西非常敏感。定死刻度之后,你只会在4/8/12/16/24/32里挑,页面自然就整齐了。
字号用clamp()而不是写死,是让标题在不同视口下平滑缩放,避免在某个宽度上突然跳一档。
6.1 主题切换:在变量层覆写,而不是重写全部规则
暗色主题的正确做法是在根上换一批变量值,所有引用变量的规则自动跟随。
[data-theme="dark"] { --c-bg: #17181a; --c-bg-2: #1f2124; --c-text-1: #e3e5e7; --c-text-2: #9499a0; --c-line: #2f3134; }前提是你前面写规则的时候全部走了变量。如果哪里有硬编码的#fff,切到暗色就会出现一块刺眼的白。我在写完浅色版之后会做一件事:全局搜一遍颜色字面值,把漏网的补进变量里。这个动作大概五分钟,能省掉后面反复"这里怎么还是白的"的排查。
也可以顺带支持系统偏好:@media (prefers-color-scheme: dark)里做同样的变量覆写,用户不手动切换时跟随系统。
6.2 hover 状态写不好,页面就会"抖"
新手最容易写出来的 hover 是这样的:padding变大、margin变小、border从无到有。这三样都会改变盒模型尺寸,触发周围元素重排,鼠标一移上去整行都在动。
正确的做法是只用transform和颜色做反馈。位移用translateY(-2px),缩放用scale,颜色变化用color和background-color。这些属性中,transform和opacity由合成层处理,不触发重排重绘,性能最好。
.card { transition: transform .18s ease, box-shadow .18s ease; } .card:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgb(0 0 0 / .08); }transition的时间我一般取0.15s到0.2s之间。低于0.1s几乎感觉不到,高于0.3s会觉得拖沓。而且千万别写transition: all,它会把你不想过渡的属性一起过渡,包括布局属性,是性能问题的常见来源。
焦点状态同样重要。:focus-visible让键盘聚焦时才显示外框,鼠标点击不显示。但外框不能直接outline: none了事,那等于把键盘用户的眼睛蒙上。我通常换成outline: 2px solid var(--c-brand); outline-offset: 2px;,既好看又保留可访问性。
7. 断点不是越多越好:让 Grid 自己适配,媒体查询只兜底
我刚做这类页面时,会写五六个断点:320、480、768、1024、1280、1440。写完发现维护起来极其痛苦,因为每个断点里都要重复调整相同的几处。
后来我改了个思路:能用流式解决的,绝不用断点。列数交给auto-fill,字号交给clamp(),间距交给clamp(),容器留白交给clamp()。真正需要媒体查询的地方,其实只剩下"结构发生质变"的那几处。
比如侧边栏。宽屏时它在右侧,是一个独立的<aside>;窄屏时它不该继续待在那儿,而应该移到主内容下面。这种情况光靠 Grid 自动换列做不到,因为 DOM 顺序是主内容在前、侧栏在后,而且在 HTML 里它本来就在<main>外面。
我的处理是:把主内容区和侧栏放进同一个 Grid 容器,宽屏用grid-template-columns: 1fr 320px,窄屏改成1fr单列,侧栏自然掉到下面。
.page { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); } @media (min-width: 1024px) { .page { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; } }minmax(0, 1fr)里的0是关键。Grid 的1fr默认最小宽度是auto,如果里面有长标题或者很宽的图片,列会被撑破。写成minmax(0, 1fr)才允许收缩。这个坑我在做带长标题的卡片流时踩过,表现为"页面出现了横向滚动条,但怎么看都找不到哪个元素超宽"。
align-items: start是为了让侧栏不被拉伸到和主内容一样高,否则侧栏的背景色会一直延伸到底部,看起来很怪。
7.1 触屏设备上的 :hover 陷阱
移动端浏览器在点击时会给元素一个"粘住的 hover 状态"——点完卡片,它的 hover 样式一直不消失,直到你点别的地方。解决方式是给 hover 样式加条件:
@media (hover: hover) and (pointer: fine) { .card:hover { transform: translateY(-2px); } }这样只有真正有指针、能悬停的设备才应用 hover 效果。触屏设备的反馈改用:active,按下时给一个轻微缩放,手感更接近原生应用。
7.2 小屏下的信息取舍
窄屏不是把桌面版缩小就完事。我做过对比:在 375px 宽度下,一个卡片如果保留"作者 + 播放量 + 弹幕数 + 时长 + 发布时间",文字会挤成一行糊。这时候要主动砍信息。
我的取舍顺序是:保留封面和标题(用户靠这两样决定点不点),保留播放量(这是最强的社会证明),砍掉发布时间和次要统计。卡片改成横向布局——左边一小块封面,右边标题加一行信息,一屏能放更多条,滑动效率更高。
@media (max-width: 640px) { .card--video .card__link { display: grid; grid-template-columns: 140px 1fr; gap: var(--sp-3); align-items: start; } .card__cover { aspect-ratio: 16 / 10; } }这里能看到aspect-ratio的价值:横向布局里封面变窄了,高度自动按比例跟着缩,不用另写一套尺寸。这就是为什么我前面说它"收拾残局"——同一个组件在不同版式下复用,靠的就是比例而不是固定像素。
8. 交付前的自查:布局抖动、渲染开销与可访问性
页面写完能看,和页面写完能交付,中间隔着一轮自查。我给自己列了一份固定清单,每次交付前过一遍,能挡掉大部分"上线后才发现"的问题。
8.1 三类最常见的布局抖动
第一类是图片没有尺寸。前面说过,width和height属性必须写,配合 CSS 里的height: auto,浏览器就能按比例预留空间。
第二类是字体加载导致的文字重排。自定义字体下载完成前用的是回退字体,两者字宽不同,整段文字会重排。缓解办法是font-display: swap加一个和主字体度量接近的回退字体栈。静态页面如果对字体不苛求,直接用系统字体栈是最省事的。
第三类是动态插入内容。纯静态页面这个问题不突出,但如果卡片是模板渲染的,一定要注意容器先占位再填内容,否则内容一到,下面全部下推。骨架屏就是干这个的:先用占位块把高度撑住,数据到了再替换。
8.2 渲染开销:别让长列表拖垮滚动
首页动辄上百张卡片,每一张都带圆角、阴影、悬停过渡,滚动起来压力不小。几个成本低、收益明显的优化:
- 首屏之外的区块加
content-visibility: auto,浏览器会跳过屏幕外内容的渲染,配合contain-intrinsic-size给它一个估算高度,滚动条不会乱跳。 - 阴影尽量用一层,别叠三四层
box-shadow,每一层都要参与绘制。 - 避免在大面积元素上用
filter和backdrop-filter,前面说过代价。 - 图片统一
loading="lazy"加decoding="async",首屏那一两张可以设fetchpriority="high"。
8.3 一份可以直接照着勾的清单
| 检查项 | 判断标准 | 常见问题 |
|---|---|---|
| 图片尺寸 | 所有<img>是否都有width/height | 加载后页面跳动 |
| 语义标签 | 是否有header/nav/main/aside/footer | 全是 div,无法快速定位 |
| 焦点可见 | Tab 走一遍,焦点框是否可见 | outline: none没补替代样式 |
| 对比度 | 次要文字颜色与背景对比是否够 | 浅灰配白底,读不清 |
| 横向滚动 | 窄屏下是否出现横向滚动条 | Grid 列被长内容撑破 |
| 缩略图裁切 | 极端比例的图是否严重变形或裁掉主体 | 只用object-fit: fill |
| 主题切换 | 切到暗色是否有残留白块 | 硬编码颜色值 |
| 触屏 hover | 手机上点击后样式是否粘住 | 未使用@media (hover: hover) |
| 动效偏好 | 系统开启减少动效后动画是否停止 | 未处理prefers-reduced-motion |
这份清单我最看重的是"焦点可见"和"对比度"两项,因为它们最容易被跳过,也最直接影响真实用户。你可以自己试一次:拔掉鼠标,只用 Tab 键从页面顶部走到页脚,走不通就说明还有活干。
最后说一个我自己踩过的坑。有次我把轮播的轨道宽度写成400%,幻灯片写width: 25%,本地看着完美。换到一台更宽的显示器上,因为外层容器有max-width,轨道的百分比参照变了,最后一张露出半个。根因是百分比宽度依赖父级,而父级宽度又被max-width限制住了。后来我改成幻灯片用flex: 0 0 100%,位移用translateX(-100%)这类基于自身宽度的值,就没再出过问题。百分比基准这件事,写的时候多问一句"这个百分比是相对谁的",能省掉很多跨设备调试的时间。
写这类首页最大的收获,其实不是把某个页面还原得多像,而是你会慢慢形成一套自己的判断:什么时候该用 Grid,什么时候 Flex 更省事,哪些属性会在某个尺寸下突然失效。这些判断没法从文档里抄,只能靠一版一版改出来。