普通视图
彻底搞懂 CSS 定位:relative 与 absolute 的奥秘
在前端开发中,CSS 定位 (position) 是布局的基石之一。relative 和 absolute 是最常用也最容易混淆的两种定位方式。掌握它们的区别和用法,对于构建复杂、精确的页面布局至关重要。今天,我们就来深入剖析这两个定位属性的工作原理和应用场景。
1. 一切的起点:static (默认定位)
在讨论 relative 和 absolute 之前,我们先明确一个概念:static。
-
定义:
static是元素的默认定位方式。 -
特点:
- 元素按照正常的文档流(Normal Flow)从上到下、从左到右依次排列。
-
top,right,bottom,left这些偏移属性对static定位的元素无效。 - 元素会出现在它在 HTML 源码中的自然位置。
理解 static 是理解其他定位方式的基础,因为它代表了“无特殊定位”的状态。
2. 相对定位:position: relative
-
作用: 元素仍然占据着它在正常文档流中的原始位置。
- 你可以使用
top,right,bottom,left属性来相对于它原本的位置进行偏移。 - 偏移之后,原来的空间仍然保留,不会被其他元素占据。这可能导致元素重叠。
- 你可以使用
-
关键点:
-
参照物: 元素相对于它原本在文档流中的位置进行移动。
-
脱离文档流? : 没有。它依然占据着原始空间,周围的元素会认为它还在那里。
-
用途:
-
微调元素位置:当需要对某个元素进行轻微的位置调整,但又不想影响页面其他元素的整体布局时,
relative非常有用。 -
最重要的用途:为绝对定位的子元素提供定位基准。当一个元素设置了
position: relative,它就成为了其内部设置了position: absolute的子元素的定位参考点 (Containing Block) 。
-
微调元素位置:当需要对某个元素进行轻微的位置调整,但又不想影响页面其他元素的整体布局时,
-
-
示例代码:
<!DOCTYPE html>
<html>
<head>
<style>
.relative-box {
position: relative;
top: 20px; /* 相对于原始位置向下移动 20px */
left: 30px; /* 相对于原始位置向右移动 30px */
background-color: lightblue;
width: 200px;
height: 100px;
}
.text-after {
background-color: lightgreen;
}
</style>
</head>
<body>
<div class="relative-box">我是一个相对定位的元素</div>
<p class="text-after">这段文字会出现在 .relative-box 原本应该在的地方,即使它已经移动了。</p>
</body>
</html>
在这个例子中,蓝色的 div 移动了,但它原来的空间(灰色虚线框示意)依然存在,绿色文字紧随其后。
3. 绝对定位:position: absolute
-
作用:
- 元素完全脱离正常文档流。
- 它不再占据文档流中的空间,周围的元素会忽略它的存在,好像它不存在一样。
- 你可以使用
top,right,bottom,left属性来相对于其最近的已定位祖先元素(即position属性为relative,absolute,fixed, 或sticky的祖先元素)进行定位。 - 如果其所有祖先元素都没有设置定位(即都是
static),那么它将**相对于初始包含块(通常是视口 viewport)**进行定位。
-
关键点:
-
参照物: 相对于最近的已定位祖先元素(
position不是static的祖先)。如果没有这样的祖先,则相对于视口。 -
脱离文档流? : 是的。它不再占用文档流中的空间。
-
用途:
- 实现精确的位置控制:常用于创建弹窗、提示框、工具提示、覆盖层、图标定位、侧边栏等需要精确定位在某个位置的元素。
- 创建不依赖于文档流顺序的布局。
-
-
示例代码:
<!DOCTYPE html>
<html>
<head>
<style>
.parent-container {
position: relative; /* 关键:为子元素提供定位基准 */
width: 300px;
height: 200px;
background-color: lightblue;
margin-top: 50px; /* 方便观察 */
}
.absolute-child {
position: absolute;
top: 10px; /* 相对于 .parent-container 的顶部 */
left: 10px; /* 相对于 .parent-container 的左侧 */
background-color: red;
width: 50px;
height: 50px;
}
</style>
</head>
<body>
<div class="parent-container">
父容器
<div class="absolute-child">绝对定位子元素</div>
</div>
</body>
</html>
在这个例子中,红色的 div 是绝对定位的,并且它的父元素(蓝色的 div)是相对定位的。因此,红色 div 的 top: 10px 和 left: 10px 是相对于蓝色 div 的左上角计算的。同时,红色 div 不会占用蓝色 div 内部文档流的空间。
4. relative 与 absolute 的关系
relative 和 absolute 经常配合使用。relative 最重要的作用之一就是为 absolute 子元素创建一个定位上下文。
- 当父元素设置了
position: relative(或absolute,fixed),它就成为了子元素position: absolute的参考坐标系。 - 子元素的
top,right,bottom,left将不再是相对于浏览器窗口,而是相对于这个设置了定位的父元素。
5. 对比总结
| 特性 |
position: static (默认) |
position: relative (相对定位) |
position: absolute (绝对定位) |
|---|---|---|---|
| 是否脱离文档流 | 否 | 否 | 是 |
| 是否占据空间 | 是 | 是 (移动后原位置仍保留) | 否 |
top/right/bottom/left 是否有效 |
否 | 是 (相对于原始位置偏移) | 是 (相对于定位祖先元素或视口) |
| 定位参考点 | 无 (遵循文档流) | 元素自身的原始位置 | 最近的已定位祖先元素 (或视口) |
| 主要用途 | 正常布局 | 微调位置、为绝对定位子元素提供参考 | 精确控制位置、创建悬浮元素 |
结语
理解 relative 和 absolute 的核心区别在于是否脱离文档流以及定位的参考点。relative 是“我动了,但我原来的地方还在”,常用于为 absolute 子元素划定一个活动范围。absolute 是“我自由了,我不占地方了,我想在哪就在哪(相对于我的定位祖先)”。掌握好这对组合,你就能更灵活地掌控页面元素的布局了。
2025 年 CSS 年度调查报告亮点速览
近日,「State of CSS 2025」年度调查报告公布。
这份报告收集了全球数万名开发者的真实使用经验和反馈,堪称是 Web 开发领域的“年度风向标”。
本篇我们盘点下这份报告的亮点部分。
1. 使用率最高的功能是 :has()
在调查的所有功能中,**:has()**是使用率最高也是最受欢迎的功能。
想必大家已经很熟悉了,它是一个功能非常强大的伪类,可以实现类似“父选择器”和“前面兄弟选择器”的功能。
举个简单的例子,下面的 CSS 代码表示如果 <a> 元素里面有 <img> 元素,则这个 <a> 元素就会匹配。
:has(img) {
display: block;
}
我们可以使用这个选择器轻松区分是文字链接还是图像链接,并设置不同的 CSS 样式。
2. 使用率第二高的功能是 aspect-ratio
这个 CSS 属性允许你定义元素盒子的宽高比。
这意味着即使父容器或视口大小发生变化,浏览器也会调整元素的尺寸以保持指定的宽高比。
比如我们将一张图片设置为 3/2 宽高比:
img {
aspect-ratio: 3/2;
}
3. 使用率最低的是 sibling-count 和 sibling-index
记得以前实现列表项交错动画时,要手动给每个元素设置不同的延迟吗?
现在,用 sibling-index() 一行代码就能搞定!
li {
transition: opacity 0.3s ease;
transition-delay: calc((sibling-index() - 1) * 100ms);
}
这个函数会自动获取元素在兄弟节点中的位置(从 1 开始计数),通过简单的计算就能实现流畅的交错动画效果。
如果再搭配 @starting-style,连入场动画都能轻松搞定:
li {
transition: opacity 0.3s ease;
transition-delay: calc((sibling-index() - 1) * 100ms);
@starting-style {
opacity: 0;
}
}
![]()
之所以使用率最低,可以理解,因为浏览器支持还比较新。
4. 受欢迎程度第二高的功能是 Subgrid
Subgrid 表示子网格,它并不是一个 CSS 属性,而是 grid-template-columns 和 grid-template-rows 属性支持的关键字,其使用的场景需要外面已经有个 Grid 布局。
什么时候会用到 Subgrid 呢?
举个例子,这是一个布局效果:
![]()
你会发现,标题字数不一样,内容字数不一样,导致底部很难对齐。
然而我们想要的效果是这样的:
![]()
此时就可以用到 Subgrid,使用示例如下:
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
}
.item {
grid-row: 1 / 4;
display: grid;
grid-template-rows: subgrid;
}
5. 认知度增长最高的是 light-dark()
不知道你是否实现过网站的浅色和深色主题:
:root {
/* 默认浅色主题 */
--text-heading: #000;
--text-body: #212121;
--surface: #efefef;
@media (prefers-color-scheme: dark) {
/* 暗色主题 - 第一遍 */
--text-heading: #fff;
--text-body: #efefef;
--surface: #212121;
}
}
.dark-theme {
/* 暗色主题 - 又写一遍! */
--text-heading: #fff;
--text-body: #efefef;
--surface: #212121;
}
同样的颜色写两遍,一个给媒体查询(自动切换),一个给切换按钮。
改一次要改两个地方,烦死了!
现在使用 light-dark() 轻松实现!
:root {
/* 跟随系统偏好 */
color-scheme: light dark;
/* 一次定义,自动切换 */
--text-heading: light-dark(#000, #fff);
--text-body: light-dark(#212121, #efefef);
--surface: light-dark(#efefef, #212121);
}
就这么简单!系统是浅色就用第一个,暗色就用第二个。
6. 评论最多的功能是 line-clamp,多是负面评价
CSS 属性 line-clamp 用于将容器的内容限制为指定的行数,也就是我们常实现的内容多时显示省略号的效果。
举个例子:
p {
width: 300px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
效果如下:
![]()
之所以被大家吐槽,多是因为技术局限性问题,比如:
- 能限制行数,无法精确控制高度
- 浏览器兼容性还不够好
- 与动态内容配合困难:当文本内容长度不确定时,难以准确控制显示效果
当我们实际使用 line-clamp 的时候,还要配合一系列属性比如 display、-webkit-box-orient、overflow、text-overflow,这种组合方案既复杂又不够语义化。
7. 结论
CSS 这些年无疑在快速的发展中,而人们对 CSS 的满意度也在持续攀升。
引用报告中的一句话:
“如果说 2025 年的主题是稳定不可能之事,那么 2026 年或许是实现期待已久的梦想之年。”
对于热爱 CSS 的人来说,现在正是尝试、学习并参与塑造未来发展方向的最佳时机。
我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。
欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。
新的一年,如果你想快速改变自己,欢迎加入我的知识星球:“冴羽·前端大佬成长之路”,10 年工作总结、100+ 篇精华主题、70W 字原创内容,带你升级认知、重构生活、建立知识管理系统、通关面试、引领职场。用一年时间,实现十倍成长,一鸣惊人。
CSS-HTML Form 表单交互深度指南
前言
虽然现代前端框架(Vue/React)已经极大简化了表单处理,但理解原生 Form 表单 的事件流、控件属性和 API,依然是处理复杂业务逻辑(如埋点、自定义验证、无刷新提交)的基础。
一、 Form 表单的核心机制
<form> 是所有输入控件的容器。它不仅负责数据的收集,还管理着数据的提交 (Submit) 与 重置 (Reset) 周期。
1. 关键属性
-
action:数据提交的目标 URL。 -
method:HTTP 请求方式(GET拼接到 URL,POST放入请求体)。
2. 提交与拦截
当表单内存在 type="submit" 的按钮时,点击会触发 submit 事件。
const form = document.querySelector("#myForm");
form.addEventListener("submit", (event) => {
// 1. 阻止浏览器默认的跳转刷新行为
event.preventDefault();
// 2. 自定义验证逻辑
if (inputValue === "") {
alert("内容不能为空");
return;
}
// 3. 执行异步提交(如使用 Fetch/Axios)
console.log("表单已提交");
});
3. 重置行为
form.reset() 不仅仅是清空。它会将所有字段恢复为页面加载时的初始值(例如 <input value="default"> 会恢复为 "default" 而非空)。
二、 输入控件的“通用武器库”
无论 input、select 还是 textarea,它们都共享以下核心属性和方法:
1. 公共属性与方法
-
disabled:禁用控件,数据不会被提交。 -
readOnly:只读,数据会随表单提交。 -
form:只读属性,返回当前控件所属的表单对象引用。 -
focus()/blur():手动控制焦点的获取与失去。
2. 三大核心事件
| 事件 | 触发时机 |
|---|---|
focus |
控件获得焦点时。 |
blur |
控件失去焦点时(常用于输入后的实时验证)。 |
change |
内容改变且失去焦点时触发(注意:与 input 事件实时触发不同)。 |
三、 文本输入:Input vs Textarea
1. 单行文本框 <input type="text">
-
placeholder:提示文本。 -
maxlength:硬性限制用户输入的字符长度。
2. 多行文本框 <textarea>
-
rows/cols:控制显示的行数和宽度。 -
wrap换行控制:-
soft(默认):提交时不带换行符。 -
hard:提交的数据中包含换行符(必须配合cols使用)。
-
四、 选择框:Select 与 Option
<select> 元素在 JavaScript 中拥有更丰富的集合操作。
1. Select 关键操作
-
multiple:是否允许多选(按住 Ctrl/Command 键)。 -
options:返回包含所有<option>元素的 HTMLCollection。 -
remove(index):移除指定索引的选项。
2. Option 选项详情
每一个 Option 对象都有:
-
index:在下拉列表中的位置。 -
selected:布尔值,通过设置为true可实现代码控制选中。 -
text:用户看到的文字。 -
value:提交给后端的值。
五、 面试模拟题
Q1:submit 事件和按钮的 click 事件有什么区别?
参考回答:
submit 事件是绑定在 form 元素上的。如果用户在输入框中按“回车键”,或者点击了 type="submit" 的按钮,都会触发 form 的 submit 事件。相比点击事件,监听 submit 能更全面地捕获提交动作。
Q2:如何通过原生 JS 获取表单内的所有数据?
参考回答:
现代浏览器推荐使用 FormData 对象:
const formData = new FormData(formElement);
// 获取特定字段
const username = formData.get('username');
// 转化为对象
const data = Object.fromEntries(formData.entries());
Q3:disabled 和 readOnly 在表单提交时有什么区别?
参考回答:
- 设置了
disabled的控件,其值在表单提交时会被忽略,且用户无法交互。 - 设置了
readOnly的控件,用户无法修改值,但其值在提交时会被包含在表单数据中。
掌握 CSS 布局基石:行内、块级、行内块元素深度解析
前言
在 CSS 世界中,每个元素都有一个默认的 display 属性。理解这些元素的显示模式,是解决“为什么我的宽高设置无效?”、“为什么两个 div 不在一行?”等问题的关键。
一、 三大元素显示模式对比
1. 块级元素 (Block Elements)
块级元素就像是积木,默认从上往下堆叠。
-
特点:
- 独占一行:默认占满父容器 100% 宽度。
-
属性全开:支持设置
width、height、margin和padding。 -
嵌套规则:可以包含行内元素和其他块级元素(注意:
p和h1~h6比较特殊,建议不要包裹块级元素)。
-
代表标签:
div,p,h1~h6,ul,ol,li,header,footer,section等。
2. 行内元素 (Inline Elements)
行内元素就像是文本,随内容流动。
-
特点:
- 并排显示:相邻元素在同一行内排列,直到排不下才换行。
-
宽高无效:设置
width和height不起作用,宽度由内容撑开。 -
间距局限:水平方向的
margin和padding有效;垂直方向无效(不占据空间,但可能背景会溢出)。
-
代表标签:
span,a,strong,em,i,label。
3. 行内块元素 (Inline-Block)
结合了前两者的优点,既能并排显示,又能设置宽高。
-
特点:
- 并排排列:不独占一行。
-
属性支持:支持设置
width、height、margin和padding。
-
代表标签:
img,input,button,textarea,select。注:这些元素在 CSS 规范中被称为“可替换元素”,它们天生具有行内块的特性。
二、 inline-block 的“间隙之谜”
1. 产生原因
当你给子元素设置 display: inline-block 时,HTML 代码中标签之间的空格或换行符会被浏览器解析为一个约 4px 的空白字符。
2. 解决方案
-
方法 A:父元素设置
font-size: 0(最常用).parent { font-size: 0; } .child { display: inline-block; font-size: 14px; } /* 子元素需手动恢复字号 */ -
方法 B:标签首尾相接(代码极丑,不推荐)
<div class="child">A</div><div class="child">B</div> -
方法 C:改用 Flex 布局(现代开发首选)
.parent { display: flex; } /* 彻底告别间隙问题 */
三、 空元素 (Void Elements)
空元素是指没有子节点且没有结束标签的元素,它们通常通过属性来承载内容。
-
常见标签:
<br>,<hr>,<img>,<input>,<link>,<meta>。
四、 面试模拟题
Q1:如何让行内元素(如 span)支持宽高?
参考回答:
- 修改
display属性为block或inline-block。 - 设置
float(浮动后的元素会自动变为块级表现)。 - 设置
position: absolute或fixed。
Q2:img 标签是行内元素还是块级元素?为什么它可以设置宽高?
参考回答: img 在表现上属于行内元素(不换行),但它是一个可替换元素(Replaced element) 。可替换元素的内容不受 CSS 控制,其外观由标签属性决定。浏览器在渲染这类元素时,会赋予它们类似 inline-block 的特性,因此可以设置宽高。
Q3:display: none 和 visibility: hidden 有什么区别?
参考回答:
-
display: none:脱离文档流,不占据空间,会引起回流(Reflow)。 -
visibility: hidden:隐藏内容,但保留占据的物理空间,不会引起回流,仅引起重绘(Repaint)。
Tailwind CSS + lucide-react:手搓一个能打的产品级登录页
登录页这玩意儿,表面看就是两个输入框加按钮,但写过的人都知道——它简直是前端工程的“照妖镜”。组件抽象、状态管理、响应式、加载态、可访问性,全在这方寸之间。今天就把我踩过的坑、验证过的最佳实践,完整复盘一遍。
技术选型:为什么不是“全家桶”而是“三剑客”?
Vite:别用 CRA 了,真的
2024 年还用 CRA 新建项目,就像今天还在用 jQuery 写新需求——不是不行,只是没必要。Vite 的秒级启动、按需热更新、对 React 的丝滑支持,用了就回不去。
npm init vite@latest login-demo -- --template react
Tailwind CSS:原子化 CSS 的“真香”现场
刚开始我也抵触过——“这不就是 inline style 吗?”用了三个月后发现,Tailwind 的精髓在于用约束换自由:
- 不再纠结
.login-input--error还是.login__input-error - 样式紧耦合组件,重构时删组件即删样式,不留垃圾
- 设计系统(间距、色板、圆角)被工具类强制约束,UI 一致性自然来
lucide-react:图标库的“现代化”答案
放弃 iconfont 吧,字体图标在 Retina 屏下糊、在 SSR 场景下闪、在暗黑模式里要单独维护反色。lucide-react 的 SVG 组件化方案:
- Tree-shaking,只打包用到的图标,体积 < 10KB
- 可传
size,className,strokeWidth,和普通组件无异 - 和 Tailwind 的
text-*,fill-*类无缝配合
import { Lock, Mail, Eye, EyeOff, Loader2 } from 'lucide-react';
登录页的业务复杂度:UI 只是冰山一角
真正落地的登录页至少包含:
-
受控组件:杜绝
document.getElementById,数据单一源 - 表单校验:实时反馈、错误聚合、防抖提交
- 加载态:按钮禁用、节流、异步反馈
-
密码显隐:无障碍支持(
aria-label) - 响应式:移动端优先的触控体验
- 自动填充:处理浏览器自动填充的黄色背景
- 安全:防止 XSS、CSRF Token 透传
React 状态设计:别写“面条代码”
1. 状态聚合:一个对象管所有
新手容易写成这样:
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
维护过老代码的都知道,新增一个字段要改三行,提交时要拼半天对象。正确姿势:
const [form, setForm] = useState({ email: '', password: '', rememberMe: false });
const [errors, setErrors] = useState({});
const [ui, setUi] = useState({ loading: false, showPassword: false });
三层状态分离:数据层(form)、校验层(errors)、UI 层(ui)。各司其职,后续维护一目了然。
2. 表单处理的“万能钥匙”
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
// 实时清错
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
关键点:
- 利用
name属性做映射,扩展新字段零成本 - 输入即清错,用户体验细节
- 支持
text,password,checkbox,select等所有表单元素
3. 提交逻辑的“防御性编程”
const handleSubmit = async (e) => {
e.preventDefault();
const nextErrors = validate(form);
if (Object.keys(nextErrors).length) {
setErrors(nextErrors);
return;
}
setUi(prev => ({ ...prev, loading: true }));
try {
await onLogin(form); // 业务注入
} catch (err) {
setErrors({ form: err.message });
} finally {
setUi(prev => ({ ...prev, loading: false }));
}
};
记住:loading 态必须在 finally 里关闭 ,无论成功失败,用户都要有反馈。
Tailwind 的工程化细节:不是堆砌,是设计
1. 容器:响应式的“黄金分割”
<div className="w-full max-w-md mx-auto px-6 py-8 md:px-10 md:py-12">
-
w-full max-w-md:移动端 100%,PC 端最大 448px -
mx-auto:居中,无需额外写margin: 0 auto -
px-6 md:px-10:断点平滑过渡,避免“跳变”
2. 表单间距:space-y 的魔法
<form className="space-y-6" onSubmit={handleSubmit}>
<div>...</div>
<div>...</div>
<button>...</button>
</form>
space-y-6 自动给每个子元素加 margin-top,除了第一个。等价于:
.form > * + * {
margin-top: 1.5rem;
}
但语义更清晰,且避免了 first:mt-0 这类修补。
3. 输入框:状态即 class
<input
className={clsx(
"w-full rounded-lg border px-10 py-3 text-base transition",
"border-slate-300 bg-white placeholder:text-slate-400",
"focus:border-indigo-600 focus:ring-2 focus:ring-indigo-600/20 focus:outline-none",
errors.email && "border-red-500 ring-2 ring-red-500/20"
)}
/>
- 利用
clsx或classNames做条件合并 - 聚焦态、错误态、默认态全用 class 表达
-
focus:outline-none移除默认蓝框,用 ring 替代,更可控
4. 图标定位:group 的联动
<div className="relative group">
<Mail className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-600" />
<input className="pl-10 ..." />
</div>
-
group-focus-within:父级聚焦,图标变色 -
pointer-events-none:让点击穿透到 input - 无需手写
:focus-within选择器
lucide-react:图标是组件,不是字体
密码显隐:带无障碍支持的完整实现
<button
type="button"
onClick={() => setUi(prev => ({ ...prev, showPassword: !ui.showPassword }))}
aria-label={ui.showPassword ? '隐藏密码' : '显示密码'}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-slate-100 transition"
>
{ui.showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
要点:
-
aria-label读屏软件可读 -
type="button"防止触发提交 -
hover:bg-slate-100增加触控反馈
加载态:图标即动画
{ui.loading && <Loader2 className="mr-2 inline-block animate-spin" />}
animate-spin 是 Tailwind 内置动画,配合 lucide 的 Loader2 图标,无需额外写 CSS 动画。
响应式与暗黑模式:一次写好,到处适用
Mobile First 的触控优化
jsx
Copy
<input className="min-h-[44px] ..." /> {/* iOS 最小触控高度 */}
<button className="min-h-[44px] active:scale-95 ..."> {/* 按下反馈 */}
暗黑模式:Tailwind 的 dark 前缀
<div className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</div>
只需在 tailwind.config.js 里启用 darkMode: 'class',然后在顶层加 dark 类即可。
效果图
![]()
最后
登录页是前端工程师的“基本功”,也是“面试常考题”。把状态设计、Tailwind 原子类、可访问性、加载态这些细节处理好,后续复杂业务才能游刃有余。
记住:代码是写给下一个维护你的人看的,包括三个月后的自己。
前端样式工程化三剑客:CSS Modules、Scoped CSS 与 CSS-in-JS 深度实战
前言:为什么我们需要“工程化”样式?
在早期的前端开发中,CSS 是全局的。我们写一个 .button { color: red },它会立刻影响页面上所有的按钮。这在小型项目中或许可行,但在大型应用、多人协作或开源组件库开发中,这无异于“灾难”——样式冲突、优先级战争(Specificity Wars)层出不穷。
为了解决这个问题,现代前端框架提出了三种主流的解决方案:
- CSS Modules (React 生态主流方案)
- Scoped CSS (Vue 生态经典方案)
- CSS-in-JS (Stylus Components 为代表的动态方案)
本文将通过三个实战 Demo (css-demo, vue-css-demo, styled-component-demo),带你深入理解这三种方案的原理、差异及面试考点。
第一部分:CSS Modules - 基于文件的模块化
场景描述:
在 css-demo 项目中,我们不再直接使用全局的 CSS,而是利用 Webpack 等构建工具,将 CSS 文件编译成 JavaScript 对象。
1.1 核心原理
CSS Modules 并不是一门新的语言,而是一种编译时的解决方案。
-
编译机制:构建工具(如 Webpack)会将
.module.css文件编译成一个 JS 对象。 -
局部作用域:默认情况下,CSS Modules 中的类名是局部的。构建工具会将类名(如
.button)编译成唯一的哈希值(如_src-components-Button-module__button__23_a0)。 -
导入方式:在组件中,我们通过
import styles from './Button.module.css'导入这个对象,然后通过styles.button动态绑定类名。
1.2 代码实战解析
在我们的 Demo 中,定义了一个按钮组件:
// Button.jsx
import styles from './Button.module.css';
export default function Button() {
return (
<button className={styles.button}>My Button</button>
);
}
对应的样式文件:
/* Button.module.css */
.button {
background-color: blue;
color: white;
padding: 10px 20px;
}
发生了什么?
- 构建工具读取
Button.module.css。 - 将
.button转换为类似_button_hash123的唯一类名。 - 生成一个对象:
{ button: '_button_hash123' }。 - JSX 渲染时,
className变成了唯一的哈希值,实现了样式隔离。
1.3 答疑解惑与面试宝典
Q1:CSS Modules 是如何解决样式冲突的?
-
答: 核心在于哈希化(Hashing) 。它利用构建工具,在编译阶段将局部类名映射为全局唯一的哈希类名。由于哈希值的唯一性,不同组件即使定义了同名的
.button,最终生成的 CSS 类名也是不同的,从而从根本上杜绝了冲突。
Q2:CSS Modules 和普通的 CSS import 有什么区别?
-
答:
-
普通 CSS:
import './style.css'只是引入了样式,类名依然是全局的。 -
CSS Modules:
import styles from './style.module.css'将样式变成了 JS 对象,你必须通过对象的属性来引用类名,从而强制实现了作用域隔离。
-
普通 CSS:
Q3:如何在 CSS Modules 中使用全局样式?
-
答: 虽然不推荐,但有时确实需要。可以通过
:global伪类来声明::global(.global-class) { color: red; }这样
global-class就不会被哈希化,保持全局生效。
第二部分:Vue Scoped CSS - 属性选择器的魔法
场景描述:
在 vue-css-demo 项目中,我们使用 Vue 单文件组件(SFC)的经典写法,通过 <style scoped> 实现样式隔离。
2.1 核心原理
Vue 的 scoped 属性实现原理与 CSS Modules 截然不同,它采用的是属性选择器方案。
-
编译机制:Vue Loader 会为组件中的每个 DOM 元素生成一个唯一的属性(例如
data-v-f3f3eg9)。 -
样式重写:同时,它会将
<style scoped>中的选择器(如.txt)重写为属性选择器(如.txt[data-v-f3f3eg9])。 - 作用域限制:由于只有当前组件的 DOM 元素拥有该属性,样式自然只能作用于当前组件。
2.2 代码实战解析
在 Vue 的 Demo 中,我们有两个层级:App.vue 和 HelloWorld.vue。
<!-- App.vue -->
<template>
<div>
<h1 class="txt">Hello world in App</h1>
<HelloWorld />
</div>
</template>
<style scoped>
.txt {
color: red;
}
</style>
<!-- HelloWorld.vue -->
<template>
<div>
<h1 class="txt">你好,世界!!!</h1>
</div>
</template>
<style scoped>
.txt {
color: blue;
}
</style>
发生了什么?
- 编译后,
App.vue中的<h1>标签被加上了data-v-abc123属性。 -
App.vue的 CSS 变成了.txt[data-v-abc123] { color: red }。 - 编译后,
HelloWorld.vue中的<h1>标签被加上了data-v-xyz456属性。 -
HelloWorld.vue的 CSS 变成了.txt[data-v-xyz456] { color: blue }。 -
结果:父子组件的
.txt类名互不干扰,各自生效。
2.3 答疑解惑与面试宝典
Q1:Vue Scoped 的性能怎么样?
- 答: 性能通常很好,但也有局限。它只生成一次属性,且利用了浏览器原生的属性选择器能力。但是,如果组件层级很深,属性选择器的权重会增加。此外,它无法穿透子组件(即父组件的 scoped 样式无法直接修改子组件的样式),这是它的设计初衷,也是需要注意的点。
Q2:如何修改子组件的样式?(深度选择器)
-
答: 当需要修改第三方组件库(如 Element Plus)的样式时,scoped 会失效。Vue 提供了深度选择器:
-
Vue 2:使用
>>>或/deep/。 -
Vue 3:使用
:deep()。
/* Vue 3 写法 */ .parent-class :deep(.child-class) { color: red; } -
Vue 2:使用
Q3:scoped 会导致样式权重增加吗?
-
答: 会。 因为它变成了属性选择器,例如
.txt变成了.txt[data-v-123],其权重高于普通的类选择器。如果在全局样式中写了.txt { color: blue },而在 scoped 中写了.txt { color: red },scoped 的样式会因为权重更高而覆盖全局样式。
第三部分:Stylus Components - CSS-in-JS 的动态艺术
场景描述:
在 styled-component-demo 项目中,我们将 CSS 直接写在 JavaScript 文件中,通过模板字符串创建组件。
3.1 核心原理
CSS-in-JS 是一种运行时的解决方案(虽然也支持 SSR 和编译时优化)。
- 组件即样式:它将样式直接绑定到组件上。你不是在组件中引用类名,而是直接创建一个“带样式的组件”。
- 动态性:样式可以像 JS 变量一样使用,支持传参(Props)。这使得主题切换、动态样式变得非常简单。
- 唯一性:生成的类名也是唯一的(通常基于组件名和哈希),确保不污染全局。
3.2 代码实战解析
// App.jsx
import styled from 'styled-components';
// 创建一个带样式的 Button 组件
const Button = styled.button`
background: ${props => props.primary ? 'blue' : 'white'};
color: ${props => props.primary ? 'white' : 'blue'};
padding: 8px 16px;
`;
function App() {
return (
<>
<Button>默认按钮</Button>
<Button primary>主要按钮</Button>
</>
);
}
发生了什么?
-
组件定义:
styled.button是一个函数,它接收模板字符串作为参数,返回一个 React 组件。 -
动态插值:在模板字符串中,我们可以使用 JavaScript 逻辑(如三元表达式)来根据
props动态生成 CSS。 -
渲染:当
<Button primary>渲染时,库会根据逻辑生成对应的 CSS 规则(如background: blue),注入到<head>中,并将生成的唯一类名应用到 DOM 上。
3.3 答疑解惑与面试宝典
Q1:CSS-in-JS 的优缺点是什么?
-
答:
- 优点:极致的动态能力(基于 Props 的样式)、天然的组件隔离、支持主题(Theme)、解决了全局污染问题。
- 缺点:运行时性能开销(需要 JS 计算生成 CSS)、CSS 文件体积无法单独缓存(随 JS 打包)、调试时类名可读性差(全是哈希)、学习成本较高。
Q2:CSS-in-JS 和 CSS Modules 哪个更好?
-
答: 没有绝对的好坏,取决于场景。
- CSS Modules:适合对性能要求极高、样式逻辑简单的项目,或者团队习惯传统的 CSS 写法。
- CSS-in-JS:适合组件库开发、需要高度动态样式(如主题切换)、或者团队追求极致的组件封装性。
Q3:面试官问“你怎么看待把 CSS 写在 JS 里?”
-
答: 这是一个经典的“分离关注点”争论。
- 传统观点认为 HTML/CSS/JS 应该分离。
- 现代组件化观点认为,组件才是关注点。一个 Button 组件的逻辑、结构和样式是紧密耦合的,放在一起更利于维护和复用。CSS-in-JS 正是这种理念的体现。
第四部分:三剑客终极对比与选型建议
为了让你更直观地理解,我整理了以下对比表:
| 特性 | CSS Modules | Vue Scoped | CSS-in-JS (Stylus Components) |
|---|---|---|---|
| 作用域机制 | 哈希类名 (编译时) | 属性选择器 (编译时) | 哈希类名 (运行时/编译时) |
| 动态性 | 弱 (需配合 classnames 库) | 中 (需配合动态 class 绑定) | 强 (直接使用 JS 逻辑) |
| 学习成本 | 低 (仍是 CSS) | 低 (Vue 特性) | 中 (需学习新 API) |
| 调试难度 | 低 (类名清晰) | 低 | 中 (类名哈希化) |
| 适用场景 | 大型 React 应用 | Vue 2/3 项目 | 组件库、高动态 UI |
选型建议:
-
如果你在用 Vue:首选
scoped,简单高效。如果项目非常复杂,可以考虑 CSS Modules 或 CSS-in-JS。 -
如果你在用 React:
- 如果追求性能和工程化规范,选 CSS Modules。
- 如果追求极致的组件封装和动态主题,选 CSS-in-JS (如 Stylus Components 或 Emotion)。
- 如果是新项目,也可以考虑 Tailwind CSS 等 Utility-First 方案。
结语:样式工程化的未来
从全局 CSS 到现在的模块化、组件化,前端样式的发展始终围绕着**“隔离”与“复用”**这两个核心矛盾。
CSS Modules 和 Vue Scoped 通过编译时手段解决了隔离问题,而 CSS-in-JS 则通过运行时手段赋予了样式以逻辑能力。
无论你选择哪一种方案,理解其背后的原理(哈希化、属性选择器、动态注入)都是至关重要的。希望这篇博客能帮助你在 css-demo、vue-css-demo 和 styled-component-demo 三个项目中游刃有余,并在面试中脱颖而出。
最后的思考题:
- 如果让你设计一个组件库(如 Ant Design),你会选择哪种方案?为什么?(提示:考虑主题定制和样式隔离的平衡)
附录:常见面试题汇总
-
Vue scoped 的原理是什么?
- 答:通过属性选择器。给组件元素加唯一属性,给样式加属性选择器。
-
React 中如何实现 CSS Modules?
- 答:文件名加
.module.css,导入为对象,通过对象属性绑定 className。
- 答:文件名加
-
CSS-in-JS 的性能瓶颈在哪里?
- 答:运行时计算样式、注入 CSSOM 的操作(虽然现代库做了很多优化,如缓存)。
-
如何解决 CSS Modules 中的长类名问题?
- 答:通常不需要解决,构建工具会压缩。如果在 DevTools 中调试,可以配置 Webpack 的
localIdentName来生成可读的开发类名。
- 答:通常不需要解决,构建工具会压缩。如果在 DevTools 中调试,可以配置 Webpack 的
-
Shadow DOM 和上述方案有什么区别?
- 答:Shadow DOM 是浏览器原生的样式隔离方案,隔离性最强(完全独立的 DOM 树),但兼容性和集成成本较高。上述方案都是基于现有 DOM 的模拟隔离。
用纯 CSS3 打造《星球大战》片头字幕动画|前端特效实战
🌌 用纯 CSS3 打造《星球大战》片头字幕动画|前端特效实战
无需 JavaScript,仅用 HTML + CSS3 关键帧动画,复刻电影级 3D 字幕效果
大家好!今天带大家用 纯 CSS3 实现一个经典又酷炫的前端动画—— 《星球大战》开场字幕。这个效果利用了 perspective、transform: translateZ() 和 @keyframes 等 CSS3 特性,完美模拟了文字从屏幕前方飞向远方的 3D 视觉效果。
整个项目零 JS、零依赖,是学习 CSS 动画和 3D 变换的绝佳案例!
🎬 效果预览(文字描述)
- 黑色宇宙背景(
bg.jpg) - “STAR WARS” 标题从远处飞入,逐渐放大后消失
- 副标题 “The Force Awakens” 同样飞入飞出
- 主文案(如 “A long time ago...”)以倾斜角度从底部向上滚动,最终消失在远方
- 所有文字带有金属光泽渐变,增强科幻感
🧱 一、HTML 结构:语义化 + 精简
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Star Wars Intro - Pure CSS3</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="starwars">
<img src="./star.svg" alt="STAR" class="star" />
<img src="./wars.svg" alt="WARS" class="wars" />
<div class="byline">
<span>A</span><span> </span>
<span>L</span><span>O</span><span>N</span><span>G</span>
<!-- ... 每个字符用 span 包裹 ... -->
</div>
</div>
</body>
</html>
✅ 设计思路:
- 使用
<img>加载 “STAR” 和 “WARS” 的 SVG 图标(更易控制缩放与动画) - 主文案每个字符用
<span>包裹 → 便于逐字控制动画 - 容器
.starwars作为 3D 舞台
💡 为什么用 13 个
<span>?
因为要对每一个文字单独做 3D 旋转和透明度动画,行内元素必须转为inline-block才支持transform。
🎨 二、CSS 核心:3D 舞台 + 关键帧动画
1. 初始化:重置样式 + 全屏背景
/* 引入 Meyer Reset */
html, body { margin: 0; padding: 0; }
body {
height: 10 h;
background: #000 url(./bg.jpg);
}
2. 创建 3D 舞台(关键!)
.starwars {
perspective: 800px; /* 模拟人眼到屏幕的距离 */
transform-style: preserve-3d; /* 保持子元素 3D 变换 */
width: 34em;
height: 17em;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
🔍
perspective: 800px是实现“纵深感”的核心!值越小,3D 效果越强。
3. 文字定位
.star, .wars, .byline {
position: absolute;
}
.star { top: -0.75em; }
.wars { bottom: -0.5em; }
.byline {
top: 45%;
text-align: center;
letter-spacing: 0.4em;
font-size: 1.6em;
/* 金属渐变文字 */
background: linear-gradient(90deg, #fff 0%, #000 50%, #fff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* 让渐变生效 */
}
4. 行内元素支持 3D 旋转
.byline span {
display: inline-block; /* 必须!否则 rotateY 无效 */
}
🌀 三、动画实现:@keyframes 关键帧
1. STAR/WARS 飞入飞出
.star {
animation: star 10s ease-out infinite;
}
@keyframes star {
0% {
opacity: 0;
transform: scale(1.5) translateY(-0.75em);
}
20% { opacity: 1; }
90% { opacity: 1; transform: scale(1); }
100% {
opacity: 0;
transform: translateZ(-1000em); /* 飞向远方 */
}
}
✨
translateZ(-1000em)是让元素“远离屏幕”的关键!
2. 主文案滚动 + 逐字旋转
.byline {
animation: move-byline 10s linear infinite;
}
.byline span {
animation: spin-letters 10s linear infinite;
}
@keyframes move-byline {
0% { transform: translateZ(5em); } /* 从近处开始 */
100% { transform: translateZ(0); } /* 向远处移动 */
}
@keyframes spin-letters {
0%, 10% {
opacity: 0;
transform: rotateY(90deg); /* 初始“侧躺” */
}
30% { opacity: 1; }
70%, 86% {
opacity: 1;
transform: rotateY(0deg); /* 正面朝向 */
}
95%, 100% { opacity: 0; }
}
💡
rotateY(90deg)让文字像“钢管舞”一样从侧面转正,增强动感!
🛠️ 四、技术亮点总结
| 技术点 | 作用 |
|---|---|
perspective |
创建 3D 视觉空间 |
transform-style: preserve-3d |
保留子元素 3D 变换 |
translateZ() |
控制元素在 Z 轴(纵深)位置 |
@keyframes |
定义复杂动画流程 |
display: inline-block |
使行内元素支持 3D 变换 |
background-clip: text |
实现文字渐变填充 |
📦 五、项目结构
star-wars-css/
├── index.html
├── style.css
├── readme.md
├── bg.jpg # 宇宙背景图
├── star.svg # "STAR" Logo
└── wars.svg # "WARS" Logo
✅ 所有资源本地化,开箱即用!
🚀 六、如何运行?
- 下载全部文件
- 在浏览器中打开
index.html - 享受你的星球大战时刻!
💬 结语
通过这个项目,我们不仅复刻了一个经典电影特效,更深入理解了:
- CSS 3D 变换的核心原理
- 如何用
translateZ模拟纵深运动 - 关键帧动画的精细控制
- 行内元素的动画限制与突破
CSS 不只是样式,更是动画引擎!
🔗 所用图片
![]()
![]()
![]()
CSS Container Queries:实现响应式设计的新思路
CSS Container Queries:实现响应式设计的新思路
作为一名前端开发者,我相信你一定对媒体查询(Media Queries)不陌生。多年来,我们一直依赖 @media 规则来创建响应式设计,根据屏幕尺寸调整样式。但随着组件化开发的普及和设计复杂性的增加,我们逐渐发现了媒体查询的局限性。今天,我想和大家分享一个激动人心的新特性——CSS Container Queries,它正在改变我们思考和实现响应式设计的方式。
媒体查询的困境
在深入了解 Container Queries 之前,让我们先回顾一下传统媒体查询的限制。
想象这样一个场景:你正在开发一个卡片组件,这个组件可能会出现在页面的不同位置——有时占据整个宽度,有时只占据侧边栏的一小部分。使用传统的媒体查询,我们只能基于整个视口的尺寸来调整样式:
.card {
padding: 1rem;
background: white;
border-radius: 8px;
}
.card h2 {
font-size: 1.2rem;
}
@media (min-width: 768px) {
.card h2 {
font-size: 1.5rem;
}
}
这种方法的问题在于,即使卡片本身很小(比如在侧边栏中),但如果视口宽度超过了768px,标题仍然会使用较大的字体,这可能导致布局问题。
Container Queries 的革命性思路
Container Queries 的出现解决了这个根本问题。它允许我们基于容器的尺寸而不是视口的尺寸来应用样式。这意味着组件可以根据自己的实际可用空间来调整外观,真正实现了组件级别的响应式设计。
基本语法和使用
要使用 Container Queries,首先需要定义一个容器:
.card-container {
container-type: inline-size;
/* 或者使用简写 */
container: card-container / inline-size;
}
然后就可以使用 @container 规则了:
.card {
padding: 1rem;
background: white;
border-radius: 8px;
}
.card h2 {
font-size: 1.2rem;
}
@container (min-width: 400px) {
.card h2 {
font-size: 1.5rem;
}
.card {
padding: 2rem;
}
}
@container (min-width: 600px) {
.card {
display: flex;
align-items: center;
}
.card h2 {
font-size: 1.8rem;
}
}
实际应用案例
让我通过一个完整的例子来展示 Container Queries 的强大之处。假设我们要创建一个产品卡片组件,它需要在不同的容器中表现出不同的布局:
<div class="main-content">
<div class="product-card">
<img src="product.jpg" alt="产品图片">
<div class="product-info">
<h3>产品标题</h3>
<p>产品描述文本...</p>
<div class="product-price">¥199</div>
<button>立即购买</button>
</div>
</div>
</div>
<aside class="sidebar">
<div class="product-card">
<!-- 相同的HTML结构 -->
</div>
</aside>
CSS实现:
/* 定义容器 */
.main-content,
.sidebar {
container-type: inline-size;
}
/* 基础样式 */
.product-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-info {
padding: 1rem;
}
.product-info h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.product-price {
font-size: 1.2rem;
font-weight: bold;
color: #e74c3c;
margin: 0.5rem 0;
}
/* 中等尺寸容器 */
@container (min-width: 320px) {
.product-card {
display: flex;
}
.product-card img {
width: 150px;
height: 120px;
flex-shrink: 0;
}
.product-info h3 {
font-size: 1.2rem;
}
}
/* 大尺寸容器 */
@container (min-width: 500px) {
.product-card img {
width: 200px;
height: 150px;
}
.product-info {
padding: 1.5rem;
}
.product-info h3 {
font-size: 1.4rem;
}
.product-price {
font-size: 1.4rem;
}
}
容器类型详解
Container Queries 支持几种不同的容器类型:
1. inline-size
这是最常用的类型,监听容器的内联尺寸(通常是宽度):
.container {
container-type: inline-size;
}
2. size
监听容器的所有尺寸(宽度和高度):
.container {
container-type: size;
}
@container (min-width: 400px) and (min-height: 300px) {
/* 样式规则 */
}
3. normal
默认值,不创建容器查询上下文。
命名容器查询
为了更好地组织代码,我们可以给容器命名:
.sidebar {
container: sidebar-container / inline-size;
}
.main-content {
container: main-container / inline-size;
}
@container sidebar-container (max-width: 300px) {
.product-card {
/* 侧边栏特定样式 */
}
}
@container main-container (min-width: 800px) {
.product-card {
/* 主内容区特定样式 */
}
}
与CSS Grid/Flexbox的完美结合
Container Queries 与现代布局技术结合使用时威力更大:
.grid-container {
container-type: inline-size;
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.grid-item {
container-type: inline-size;
}
@container (min-width: 300px) {
.grid-item .content {
display: flex;
flex-direction: column;
gap: 1rem;
}
}
@container (min-width: 400px) {
.grid-item .content {
flex-direction: row;
align-items: center;
}
}
性能考量和最佳实践
在使用 Container Queries 时,有几个重要的性能和使用原则:
1. 避免循环依赖
确保容器的尺寸不依赖于其内容的查询结果:
/* 避免这样做 */
.container {
container-type: inline-size;
width: fit-content; /* 可能导致循环依赖 */
}
2. 合理使用容器类型
只有在真正需要时才设置 container-type: size,因为它的性能开销比 inline-size 更大。
3. 渐进增强
为不支持 Container Queries 的浏览器提供回退方案:
/* 回退样式 */
.card {
padding: 1rem;
}
.card h2 {
font-size: 1.2rem;
}
/* 支持 Container Queries 时的增强 */
@supports (container-type: inline-size) {
.card-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
padding: 2rem;
}
.card h2 {
font-size: 1.5rem;
}
}
}
浏览器兼容性和Polyfill
截至2024年,Container Queries 已经在现代浏览器中得到了良好支持:
- Chrome 105+
- Firefox 110+
- Safari 16+
对于需要支持旧版浏览器的项目,可以考虑使用 polyfill 或采用渐进增强的策略。
实际项目中的应用场景
1. 组件库开发
在开发可复用组件时,Container Queries 让组件真正做到了自适应:
.button-group {
container-type: inline-size;
display: flex;
gap: 0.5rem;
}
@container (max-width: 200px) {
.button-group {
flex-direction: column;
}
}
2. 复杂布局系统
在复杂的后台管理系统中,不同区域的组件可以根据实际空间灵活调整:
.dashboard-widget {
container-type: inline-size;
}
@container (min-width: 300px) {
.chart-widget {
/* 显示完整图表 */
}
}
@container (max-width: 299px) {
.chart-widget {
/* 显示简化版本 */
}
}
CSS Container Queries 代表了响应式设计思维的重大转变。从关注全局视口到关注局部容器,这种变化让我们能够创建更加灵活、可复用的组件。虽然它还是一个相对较新的特性,但我相信随着浏览器支持的完善和开发者认知的提升,Container Queries 将成为现代前端开发的重要工具。
作为前端开发者,我建议大家开始在新项目中尝试使用 Container Queries,特别是在组件化开发中。它不仅能解决传统媒体查询的局限性,更能让我们的代码更加模块化和可维护。
响应式设计的未来已经到来,你准备好拥抱这个变化了吗?
电商都在用的 Sticky Sidebar,原来是这样实现的!
在电商、内容类网站中,“粘性侧边栏” 是非常常见的交互设计 —— 滚动页面时,侧边栏(如商品规格、筛选条件)始终保持可视,能显著提升用户体验。但实现过程中,我们常会遇到布局冲突、动态内容导致 sticky 失效等问题。本文将从基础原理到进阶适配,拆解一个 “智能粘性侧边栏” 的实现思路。
最近在浏览海外电商平台时,注意到一个高频出现的交互细节:产品详情页的侧边栏会“粘性固定”。无论左侧是图片轮播区,还是右侧是商品信息/购买按钮区,只要其中一侧内容较短,它就会在用户滚动页面时自动“吸顶”,始终保持在可视区域内。
![]()
还有一些官网介绍页也有这种效果
![]()
这种 Sticky Sidebar(粘性侧边栏) 效果极大提升了用户体验——用户无需反复滚动回顶部就能看到关键信息或操作按钮。
作为前端,必须学习借鉴一下。今天就一起深入理解下 position: sticky 的工作原理,并手写一个响应式 Sticky Sidebar 的 HTML Demo。
一、position: sticky 基础:粘住,但不 “越界”
position: sticky 是 CSS 中非常实用的定位属性,它兼具relative和fixed的特性:
- 当用户滚动页面、该元素尚未到达指定的粘附阈值(如
top: 20px)时,它表现为relative定位,随文档流正常布局; - 一旦滚动使其达到阈值(元素顶部距离视口顶部为 20px),它就会“粘住”在视口的指定位置(顶部20px处),表现得像
fixed定位; - 但这种“固定”仅在其父容器的边界内有效——当父容器完全滚出视口后,该元素也会随之离开,不再固定。
✅ 总结一句话:
sticky元素在滚动到阈值前表现如relative,之后表现如fixed,但始终被限制在父容器内。
核心粘性样式定义如下:
.sticky-sidebar_sticky {
position: sticky;
top: 20px; /* 滚动到距离视口顶部20px时触发粘性 */
z-index: 10;
}
⚠️ 注意:
sticky定位必须配合至少一个top、right、bottom或left值才能生效。
二、Sticky 拟人化比喻:方形的女孩与视口顶端的男孩
光看定义太抽象。我自己强行想了个类比来加深记忆:
![]()
想象有一个 被拍扁成方形的女孩,她只能在家(父容器)里,从小被父母“金屋藏娇”——她永远不能离开这个房间(即不能脱离父元素的边界)。
在女孩家上空,视口顶部(top: 0)挂着一个 被拧成一条线的男孩,处在浏览器视口的上边缘。女孩头朝向男孩。
-
当页面刚开始向下滚动时(视口向下移动),男孩逐渐靠近女孩。
-
一旦女孩的头碰到男孩所在的位置(
top: 0),男孩立马“粘住”了她,带着她在房间内继续“移动”——此时女孩表现为fixed定位,粘在视口顶部。 -
男孩带着女孩继续在家里“移动”,但注意!她依然不能走出房间。如果男孩飘出女孩家(父容器滚动出视口),她也停留在房间内,男孩女孩暂时分离了。
-
当页面向上回滚时,男孩接触到女孩头部时,男孩又会“粘住”她,直到把她带回她最初的那个位置——也就是她在房间里的原始坐标。这时她又变回
relative定位。
三、实战:手写一个 Sticky Sidebar Demo
我参考主流实现方式,写了一个简洁的 HTML 示例。(可以复制保存到本地看效果)以下是完整代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sticky Sidebar with Bottom Alignment</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f9f9f9;
padding: 40px 20px;
line-height: 1.6;
color: #333;
}
.sticky-sidebar {
display: block;
width: 100%;
}
.sticky-sidebar__container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: flex-start; /* 👈 关键!避免子项被 stretch */
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
background: white;
border-radius: 16px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
}
/* 加深 .sticky-sidebar__content 的阴影 */
.sticky-sidebar__content {
background-color: #ffffff;
padding: 24px;
border-radius: 12px;
/* 增加阴影的垂直偏移、模糊半径、扩散半径和颜色,使边缘更明显 */
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
/* 👇 核心:sticky 行为 */
.sticky-sidebar__sticky {
position: sticky;
top: 20px;
z-index: 10;
}
.image-placeholder {
width: 150px;
height: 150px;
background-color: #e0e0e0;
color: #000000;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
font-size: 14px;
text-align: center;
padding: 10px;
}
@media screen and (max-width: 989px) {
.sticky-sidebar__container {
grid-template-columns: 1fr;
gap: 24px;
}
.sticky-sidebar__sticky {
position: static !important;
}
}
/* 推荐商品区域 */
.recommended-products {
max-width: 1200px;
margin: 80px auto 0;
padding: 0 20px;
}
.recommended-products h2 {
text-align: center;
margin-bottom: 32px;
font-size: 28px;
color: #111;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px;
}
.product-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-4px);
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
background: #eee;
}
.product-card .info {
padding: 16px;
}
.product-card .info h3 {
font-size: 18px;
margin-bottom: 8px;
}
.product-card .info .price {
color: #e53935;
font-weight: bold;
}
/* 滚动提示 */
.scroll-hint {
text-align: center;
margin-top: 40px;
color: #888;
font-style: italic;
}
</style>
</head>
<body>
<!-- 主 Sticky 区域 -->
<sticky-sidebar class="sticky-sidebar" data-sticky-offset="20">
<div class="sticky-sidebar__container">
<!-- 左侧:短内容 -->
<div class="sticky-sidebar__left" data-sidebar-side="left">
<div class="sticky-sidebar__content">
<h2>🏃♂️ Product Media</h2>
<p>This is the product image/video gallery area.</p>
<div
style="
background: #eee;
height: 300px;
margin-top: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
"
>
[Product Image]
</div>
<p style="margin-top: 16px; font-size: 14px; color: #666">
(Short content — will stick while scrolling)
</p>
<!-- 动态内容:可展开的图片库 -->
<details
style="
margin-top: 20px;
padding: 12px;
background: #f0f0f0;
border-radius: 8px;
cursor: pointer;
"
>
<summary style="font-weight: bold; user-select: none">
🖼️ More Images (Click to expand)
</summary>
<div
style="
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
"
>
<div class="image-placeholder">[image 1]</div>
<div class="image-placeholder">[image 2]</div>
<div class="image-placeholder">[image 3]</div>
<div class="image-placeholder">[image 4]</div>
</div>
</details>
</div>
</div>
<!-- 右侧:超长内容 -->
<div class="sticky-sidebar__right" data-sidebar-side="right">
<div class="sticky-sidebar__content">
<h2>🛒 Variants & Add to Cart</h2>
<p>Select your size, color, and add to cart below.</p>
<div style="margin: 20px 0">
<label><strong>Size:</strong></label>
<select
style="
width: 100%;
padding: 10px;
margin-top: 6px;
border: 1px solid #ddd;
border-radius: 6px;
"
>
<option>US 7</option>
<option>US 8</option>
<option>US 9</option>
<option>US 10</option>
<option>US 11</option>
<option>US 12</option>
</select>
</div>
<button
style="
background: #1a73e8;
color: white;
border: none;
padding: 14px 24px;
font-size: 18px;
border-radius: 8px;
width: 100%;
margin: 20px 0;
"
>
Add to Cart
</button>
<hr style="margin: 30px 0; border: 0; border-top: 1px solid #eee" />
<h3>📝 Product Description</h3>
<p>
This premium running shoe features lightweight mesh, responsive foam,
and durable outsole.
</p>
<!-- 动态内容:可展开的详细介绍 -->
<details
style="
margin-top: 30px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
cursor: pointer;
"
>
<summary style="font-weight: bold; font-size: 16px; user-select: none">
📖 Detailed Features & Benefits
</summary>
<div style="margin-top: 16px; line-height: 1.8; color: #555">
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Advanced Cushioning Technology
</h4>
<p>
Our premium running shoes feature cutting-edge cushioning
technology that provides exceptional comfort and support. The
multi-layer foam construction absorbs impact while maintaining
responsiveness, allowing you to run longer with less fatigue.
</p>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Breathable Mesh Upper
</h4>
<p>
The engineered mesh upper ensures maximum breathability, keeping
your feet cool and dry during intense workouts. The strategic
ventilation zones allow air to flow freely, preventing moisture
buildup and odor formation even during extended running
sessions.
</p>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Durable Outsole
</h4>
<p>
The reinforced rubber outsole is designed to withstand rigorous
use on various terrains. With our proprietary grip pattern,
you'll experience superior traction on both wet and dry
surfaces, ensuring safety and confidence with every stride.
</p>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Lightweight Design
</h4>
<p>
Weighing just 7.2 ounces per shoe, our design minimizes energy
expenditure while maintaining structural integrity. The
lightweight construction allows for faster acceleration and
smoother transitions, making it ideal for both casual joggers
and competitive runners.
</p>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Sustainability
</h4>
<p>
We're committed to environmental responsibility. Our shoes are
crafted using 30% recycled materials, reducing waste without
compromising performance. The eco-friendly manufacturing process
minimizes water usage and carbon emissions, making this a
responsible choice for conscious consumers.
</p>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Fit & Comfort
</h4>
<p>
Designed with an ergonomic fit, these shoes conform to your
foot's natural shape. The padded collar and tongue provide
additional comfort, while the secure lacing system ensures a
snug fit that reduces slippage and blisters during extended
wear.
</p>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Performance Metrics
</h4>
<p>Testing by professional athletes has shown:</p>
<ul style="margin-left: 20px; margin-top: 8px">
<li>15% improvement in running efficiency</li>
<li>25% reduction in impact-related fatigue</li>
<li>40% increase in comfort rating vs. competitors</li>
<li>99% durability over 300+ miles of running</li>
</ul>
<h4 style="margin-top: 16px; margin-bottom: 8px; color: #333">
Care Instructions
</h4>
<p>
To maintain optimal performance, hand wash with mild soap and
cool water. Air dry naturally away from direct heat sources.
Regular cleaning helps preserve the breathable mesh and extends
the lifespan of your shoes.
</p>
</div>
</details>
<!-- 另一个可展开的动态内容 -->
<details
style="
margin-top: 20px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
cursor: pointer;
"
>
<summary style="font-weight: bold; font-size: 16px; user-select: none">
⭐ Customer Reviews
</summary>
<div style="margin-top: 16px">
<div
style="
margin-bottom: 16px;
padding: 12px;
background: white;
border-left: 4px solid #ffc107;
border-radius: 4px;
"
>
<p style="font-weight: bold; margin-bottom: 4px">
John D. ⭐⭐⭐⭐⭐
</p>
<p>
"Best shoes I've ever owned! The comfort is incredible, and
they last forever. Highly recommend for anyone serious about
running."
</p>
</div>
<div
style="
margin-bottom: 16px;
padding: 12px;
background: white;
border-left: 4px solid #ffc107;
border-radius: 4px;
"
>
<p style="font-weight: bold; margin-bottom: 4px">
Sarah M. ⭐⭐⭐⭐⭐
</p>
<p>
"I've tried many brands, but these are my favorite. The
support and cushioning are perfect. My feet feel amazing
after long runs."
</p>
</div>
<div
style="
margin-bottom: 16px;
padding: 12px;
background: white;
border-left: 4px solid #ffc107;
border-radius: 4px;
"
>
<p style="font-weight: bold; margin-bottom: 4px">
Mike T. ⭐⭐⭐⭐
</p>
<p>
"Great shoes! True to size, very comfortable. Only minor
issue with sizing guide, but overall fantastic product."
</p>
</div>
<div
style="
margin-bottom: 16px;
padding: 12px;
background: white;
border-left: 4px solid #ffc107;
border-radius: 4px;
"
>
<p style="font-weight: bold; margin-bottom: 4px">
Emma L. ⭐⭐⭐⭐⭐
</p>
<p>
"Perfect fit, amazing comfort level. These shoes transformed
my running experience. Will definitely buy again!"
</p>
</div>
</div>
</details>
</div>
</div>
</div>
</sticky-sidebar>
<!-- 👇 新增:推荐商品区域(让页面更长,并展示 sticky 自然结束) -->
<div class="recommended-products">
<h2>You May Also Like</h2>
<div class="product-grid">
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Trail+Shoe"
alt="Trail Shoe"
/>
<div class="info">
<h3>Trail Running Shoe</h3>
<div class="price">$119.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Gym+Shoe"
alt="Gym Shoe"
/>
<div class="info">
<h3>Gym Training Shoe</h3>
<div class="price">$99.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Running+Socks"
alt="Socks"
/>
<div class="info">
<h3>Performance Socks (3-Pack)</h3>
<div class="price">$19.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Water+Bottle"
alt="Bottle"
/>
<div class="info">
<h3>Insulated Water Bottle</h3>
<div class="price">$29.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Trail+Shoe"
alt="Trail Shoe"
/>
<div class="info">
<h3>Trail Running Shoe</h3>
<div class="price">$119.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Gym+Shoe"
alt="Gym Shoe"
/>
<div class="info">
<h3>Gym Training Shoe</h3>
<div class="price">$99.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Running+Socks"
alt="Socks"
/>
<div class="info">
<h3>Performance Socks (3-Pack)</h3>
<div class="price">$19.99</div>
</div>
</div>
<div class="product-card">
<img
src="https://via.placeholder.com/300x200/e0e0e0/000000?text=Water+Bottle"
alt="Bottle"
/>
<div class="info">
<h3>Insulated Water Bottle</h3>
<div class="price">$29.99</div>
</div>
</div>
</div>
</div>
<div class="scroll-hint">
✅ 尝试以下操作来观察 ResizeObserver 的实时效果:<br />
1️⃣ 展开左侧 "More Images" → 左侧变高<br />
2️⃣ 展开右侧 "Detailed Features & Benefits" → 右侧变高<br />
3️⃣ 观察 sticky 策略是否动态调整(哪一侧保持固定)<br />
4️⃣ 滚动到底部,观察 sticky 在容器结束时自然结束(不会穿透父容器)
</div>
<script>
(function () {
class StickySidebar extends HTMLElement {
constructor() {
super();
this.leftSide = null;
this.rightSide = null;
this.stickyOffset = 20;
this.resizeObserver = null;
this.isDesktop = window.innerWidth > 989;
}
connectedCallback() {
this.leftSide = this.querySelector('[data-sidebar-side="left"]');
this.rightSide = this.querySelector('[data-sidebar-side="right"]');
this.stickyOffset = parseInt(this.dataset.stickyOffset) || 20;
this.setupStickyBehavior();
this.setupResizeObserver();
window.addEventListener('resize', () => {
const wasDesktop = this.isDesktop;
this.isDesktop = window.innerWidth > 989;
if (wasDesktop !== this.isDesktop) {
this.setupStickyBehavior();
}
});
}
setupResizeObserver() {
if (!window.ResizeObserver) return;
this.resizeObserver = new ResizeObserver(() => {
if (this.isDesktop) {
setTimeout(() => this.setupStickyBehavior(), 50);
}
});
this.resizeObserver.observe(this.leftSide);
this.resizeObserver.observe(this.rightSide);
}
setupStickyBehavior() {
if (!this.isDesktop) {
this.leftSide.classList.remove('sticky-sidebar__sticky');
this.rightSide.classList.remove('sticky-sidebar__sticky');
return;
}
const leftHeight = this.leftSide.offsetHeight;
const rightHeight = this.rightSide.offsetHeight;
this.leftSide.classList.remove('sticky-sidebar__sticky');
this.rightSide.classList.remove('sticky-sidebar__sticky');
if (leftHeight < rightHeight) {
this.leftSide.classList.add('sticky-sidebar__sticky');
this.leftSide.style.top = this.stickyOffset + 'px';
} else if (rightHeight < leftHeight) {
this.rightSide.classList.add('sticky-sidebar__sticky');
this.rightSide.style.top = this.stickyOffset + 'px';
}
}
}
customElements.define('sticky-sidebar', StickySidebar);
})();
</script>
</body>
</html>
1. 两列布局:Grid 实现 + flex/grid 布局的关键坑点
示例中采用 CSS Grid 实现两列布局,核心容器样式:
.sticky-sidebar_container {
display: grid;
grid-template-columns: 1fr 1fr; /*两列等分 */
gap: 40px;
align-items: flex-start; /* 重中之重 */
max-width: 1200px;
margin: 0 auto;
}
确保主内容区和侧边栏水平并排,且有合理间距。
为什么必须加 align-items: flex-start?
如果省略align-items: flex-start,会发生两个问题:
- 子元素被强制拉伸,即使内容本身很短,也会和另一列(长内容列)等高;
- sticky元素的 “父容器高度” 被撑满,粘性效果失去意义(元素本身已经占满父容器,已经没有在父容器的滚动空间了,滚动时不会触发 fixed)。
补充:如果用 Flex 实现两列布局,同样需要注意
/*Flex布局示例 */
.flex-container {
display: flex;
gap: 40px;
align-items: flex-start; /* 必须加,否则sticky失效 */
}
.flex-container .col {
flex: 1;
}
2. 进阶:ResizeObserver 监测动态高度,让sticky“智能切换”
示例中侧边栏包含可展开的details组件(如“更多图片”“详细特性”),展开/收起时列的高度会动态变化。如果仅靠初始高度判断哪一列加sticky,交互体验会割裂——因此需要ResizeObserver监测高度变化,动态调整粘性元素。
ResizeObserver 是什么?
ResizeObserver是浏览器原生API,用于监测元素的尺寸(宽/高)变化,触发回调函数。相比传统的window.resize(仅监测窗口变化),它能精准感知元素自身的尺寸变化,是处理动态内容的利器。
示例中的实现逻辑
- 初始化监测:连接DOM后,监听左右两列的尺寸变化
setupResizeObserver() {
if (!window.ResizeObserver) return;
this.resizeObserver = new ResizeObserver(() => {
if (this.isDesktop) {
// 延迟执行,确保DOM尺寸已更新
setTimeout(() => this.setupStickyBehavior(), 50);
}
});
// 监听左右两列的尺寸变化
this.resizeObserver.observe(this.leftSide);
this.resizeObserver.observe(this.rightSide);
}
- 动态调整粘性规则:对比两列高度,仅给“较短的列”添加sticky类
setupStickyBehavior() {
if (!this.isDesktop) {
// 移动端取消sticky,回归静态布局
this.leftSide.classList.remove("sticky-sidebar__sticky");
this.rightSide.classList.remove("sticky-sidebar__sticky");
return;
}
// 获取当前两列的实际高度
const leftHeight = this.leftSide.offsetHeight;
const rightHeight = this.rightSide.offsetHeight;
// 先清空所有sticky类
this.leftSide.classList.remove("sticky-sidebar__sticky");
this.rightSide.classList.remove("sticky-sidebar__sticky");
// 智能判断:短的一列添加sticky
if (leftHeight < rightHeight) {
this.leftSide.classList.add("sticky-sidebar__sticky");
this.leftSide.style.top = this.stickyOffset + "px";
} else if (rightHeight < leftHeight) {
this.rightSide.classList.add("sticky-sidebar__sticky");
this.rightSide.style.top = this.stickyOffset + "px";
}
}
- 响应式兼容:窗口尺寸变化时,同步更新布局逻辑
window.addEventListener('resize', () => {
const wasDesktop = this.isDesktop;
this.isDesktop = window.innerWidth > 989;
// 仅当从桌面端/移动端切换时,重新设置sticky
if (wasDesktop !== this.isDesktop) {
this.setupStickyBehavior();
}
});
3. 细节优化,完善交互体验
-
移动端降级:屏幕宽度<989px时,强制取消sticky(
position: static !important),避免小屏上的布局错乱; -
z-index 层级:给sticky元素加
z-index: 10,防止被其他内容遮挡;
4. <sticky-sidebar> Web Component 元素
<sticky-sidebar>是一个基于 Web Component 技术实现的自定义元素。Web Component 是浏览器原生支持的标准组件技术,相比 React、Vue 等框架组件具有跨框架兼容的优势,可以在不同技术栈之间直接复用。
其中,connectedCallback 方法相当于 React 中的 useEffect 钩子(组件挂载时执行)。
你可以根据具体的业务需求进一步扩展功能(例如自定义 sticky 触发阈值、适配多列布局等)。
总结
实现一个“健壮的粘性侧边栏”,需要兼顾三层:
-
基础层:掌握
position: sticky的特性和边界; -
布局层:Grid/Flex布局中,务必设置
align-items: flex-start,避免子元素拉伸导致sticky失效; -
动态层:用
ResizeObserver监测元素高度变化,让sticky策略随内容动态调整。
这套思路不仅适用于电商商品页,也可迁移到博客侧边栏、后台管理系统等场景。核心是理解“sticky的生效条件”和“布局对定位的影响”,再结合原生API解决动态内容的适配问题。
position: sticky 是一个优雅而强大的 CSS 特性,它用极简的代码解决了复杂的滚动交互问题。理解其“相对+固定+受限”的三重特性,是用好它的关键。
注意事项 & 性能建议
-
父容器需有滚动上下文
position: sticky是否可见,取决于父容器是否有足够内容使其在滚动中“经过”视口。如果整个页面不可滚动,或父容器内无其他内容,sticky 行为将无法被触发——并非失效,而是缺乏滚动场景。 -
警惕
overflow限制 sticky 范围
避免在 sticky 元素与 body 之间意外插入 overflow: hidden/scroll/auto 的祖先元素,否则 sticky 的粘附范围会被限制在该容器内,可能不符合预期。 -
移动端兼容性良好
现代浏览器(包括 iOS Safari 和 Android Chrome)均完整支持position: sticky,可安全用于生产环境。 -
避免过度使用
虽然sticky性能开销较小,但大量或嵌套使用可能引发布局抖动,尤其在低端设备上。保持简洁,只在必要处使用。
📚 参考资料
学习优秀作品,是提升技术的最佳路径。本文作为自己的学习笔记,也希望这篇解析对你有所帮助