普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月7日首页

记一次主题闪烁问题

2026年4月7日 15:11

为站点添加亮暗模式切换组件,却在黑暗模式下,遇到主题闪烁的问题,如图:

blinking.gif

主题初始化

添加切换组件之前,已经做好了亮暗模式的获取,即通过 window.matchMedia('(prefers-color-scheme: dark)') 获取信息,由于使用了 tailwindcss , 可控制 document 节点的 'dark' 类名切换页面亮暗模式。

在初始化站点亮暗模式之前,还注册了对 document 节点 class 变化的监听,根据有无 'dark' 类名,将亮暗模式信息持久化储存。

代码如下:

const getThemePreference = () => {
  if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
    return localStorage.getItem('theme');
  }
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
};
const isDark = getThemePreference() === 'dark';

if (typeof localStorage !== 'undefined') {
  const observer = new MutationObserver(() => {
    const isDark = document.documentElement.classList.contains('dark');
    localStorage.setItem('theme', isDark ? 'dark' : 'light');
  });
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['class'],
  });
}

document.documentElement.classList[isDark ? 'add' : 'remove']('dark');

主题闪烁

在浏览器暗黑模式下,进入页面,页面已经初始化为暗黑模式。但 ModeToggle 组件的渲染引发了主题闪烁。

组件代码如下:

import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark';

const ModeToggle = () => {
  const [theme, setTheme] = useState<Theme>('light');

  useEffect(() => {
    const isDark = document.documentElement.classList.contains('dark');
    setTheme(isDark ? 'dark' : 'light');
  }, []);

  useEffect(() => {
    const docClassList = document.documentElement.classList;
    if (theme === 'dark' && !docClassList.contains('dark')) {
      docClassList.add('dark');
    } else if (theme === 'light' && docClassList.contains('dark')) {
      docClassList.remove('dark');
    }
  }, [theme]);

  const handleClick = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <Button size="icon" onClick={handleClick}>
      {theme === 'light' ? <Moon /> : <Sun />}
    </Button>
  );
};

export default ModeToggle;

分析一下执行流程。

组件将 theme 初始化为 'light'

初次渲染,依次执行组件的两个 useEffect

首先,是依赖项为空数组的 useEffect,此时,页面已经为暗黑模式,即 document 节点的 class 已经包含了 'dark' ,所以会执行 setTheme('dark')

接着依赖项为 themeuseEffect, 会执行 docClassList.remove('dark') , 将页面置为日间模式。

接着执行第二次渲染(由第一次渲染的 setTheme('dark') 触发),触发依赖项为 themeuseEffect

此时,theme'dark' , document 节点也没有了 'dark' 类,所以将执行 docClassList.add('dark') , 将之前变为日间模式的页面重置为暗黑模式。那个日间模式的持续时间非常短暂,所以就有了动图上看到的闪烁。

很明显,问题就在依赖项为 themeuseEffect 里面将页面置为日间模式的代码。

修复

于是我不再将 theme 初始化为 'light' ,而是给它一个 null 值,让依赖值为空的那个 useEffect 根据 document 的类名来决定设置 theme'light' 还是 'dark'

//...

type Theme = 'light' | 'dark' | null;

const ModeToggle = () => {
  const [theme, setTheme] = useState<Theme>(null);
  // ...
};

这样一来,主题闪烁消失了,暗黑模式下,组件的跳变也不见了,如图:

blinking-fix.gif

组件跳变问题

但是,又产生了新的问题,如下图,在日间模式下,刷新页面,右侧的 ModeToggle 组件会有一个跳变。

toggle-jump.gif

组件代码如下:

type Theme = 'light' | 'dark' | null;

const ModeToggle = () => {
  const [theme, setTheme] = useState<Theme>(null);

  useEffect(() => {
    const isDark = document.documentElement.classList.contains('dark');
    setTheme(isDark ? 'dark' : 'light');
  }, []);

  useEffect(() => {
    const docClassList = document.documentElement.classList;
    if (theme === 'dark' && !docClassList.contains('dark')) {
      docClassList.add('dark');
    } else if (theme === 'light' && docClassList.contains('dark')) {
      docClassList.remove('dark');
    }
  }, [theme]);

  const handleClick = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <Button size="icon" onClick={handleClick}>
      {theme === 'light' ? <Moon /> : <Sun />}
    </Button>
  );
};

日间模式下的渲染流程如下:

第一次渲染, theme 初始值为 null

依次执行两个 useEffect 。依赖项为空数组的 useEffect 执行 setTheme('light') , 这将触发第二次渲染。由于 theme 值为 null ,依赖项为 themeuseEffect 不会对主题产生影响。

在组件返回的 JSX 部分,可看到 theme === 'light' ? <Moon /> : <Sun /> ,由于 themenull , 此时将渲染 Sun 图标,而不是预期的 Moon 图标。问题就在这里。

第二次渲染, theme 值为 'light'

执行依赖项为 themeuseEffect , document 节点并没有 'dark' 类名,页面保持日间主题状态。

在组件返回的 JSX 部分,此时渲染了正确的 Moon 图标。

两次渲染了不同的图标,所以会有跳变。

修复

那么再添加逻辑判断修复吗?可行是可行。不过既然基于 tailwindcss 的 'dark' 类名控制亮暗模式,何不也通过它来控制图标渲染?更准确来说,是通过 CSS 的变形,来确定如何渲染图标。代码如下:

const ModeToggle = () => {
  // ...

  return (
    <Button onClick={handleClick}>
      <Sun className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <Moon className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
    </Button>
  );
};

可以看到,为两个图标添加了一些类,来控制它们的样式。

scale 相关:通过缩放,来控制图标的”显隐“。在日间模式下, Sun 图标缩小为 0%,不可见; Moon 图标大小为 100%,即初始大小。夜间模式同理。

absolute : 让 Sun 图标脱离文档流,由 Moon 撑起宽高,使得两个图标只占一个图标的空间。由于没有给 absoulute 元素设置位置偏移量,所以它的位置参照原本的 static 定位。假如不给 Sun 设置 absolute, 就会产生两个图标大小的空间,如图:

double-space.png

rotate 相关:在主题切换时,为图标提供旋转动画,优化体验。

修复效果如下:

日间模式:

light-fix.gif

夜间模式:

dark-fix.gif

昨天以前首页

实现一个水纹涟漪轮播图

作者 JYeontu
2026年4月5日 18:13

说在前面

办公室下午 4 点,产品同学把电脑转过来:

“这个轮播图能不能高级一点?现在一切图就像 PPT 翻页”

我:“要多高级?”

产品:“像水面一样扩散开那种,最好再有点电影感”

大家平时做轮播图,常见方案基本是 opacity 淡入淡出、translateX 滑动、或者 3D 翻转。够用是够用,但“惊喜感”比较有限。

效果展示

实现思路

整体结构

HTML :一个 canvas + 左右按钮 + 底部指示器。

<div class="carousel" id="carousel">
  <canvas id="canvas"></canvas>
</div>
<button class="nav-btn prev" id="prevBtn"></button>
<button class="nav-btn next" id="nextBtn"></button>
<div class="indicators" id="indicators"></div>
  • 轮播“画面”只交给 Canvas 负责。
  • 所有控件都是常规 DOM,交互实现更简单。
  • 视觉重活在 JS 里做,样式层只负责外观。

cover 裁剪 + DPR 适配

很多同学写 Canvas 容易糊,问题常出在高清屏适配。

function resizeCanvas() {
  const rect = canvas.parentElement.getBoundingClientRect();
  canvas.width = rect.width * devicePixelRatio;
  canvas.height = rect.height * devicePixelRatio;
  ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}

这段代码的作用:

  1. 物理像素devicePixelRatio 放大,保证清晰度。
  2. 再用 setTransform 把坐标系映射回 CSS 像素,避免你后面算坐标时崩溃。

另外它还实现了 cover 裁剪(类似 CSS background-size: cover),保证不同尺寸图片都能完整铺满画布,不留黑边。

“谁该出现”判定

const relDist = waveFront - dist;

if (relDist > waveWidth * waveCount) {
  useNewImage = true;
} else if (relDist > 0) {
  const wavePhase = (relDist / waveWidth) * Math.PI * 2;
  const amplitude = waveAmplitude * Math.sin(wavePhase)
    * Math.exp(-relDist / (waveWidth * waveCount) * 2);

  const angle = Math.atan2(dy, dx);
  srcX = x + Math.cos(angle) * amplitude;
  srcY = y + Math.sin(angle) * amplitude;

  useNewImage = relDist > waveWidth;
}

拆开理解:

  1. dist 是当前像素到波纹中心的距离。
  2. waveFront 是当前时刻波前推进到哪里。
  3. relDist = waveFront - dist 可以理解成“波前相对这个像素的位置关系”。
  4. 根据 relDist 分三段:
    • 波已过去:显示新图。
    • 波正在影响:做径向位移(扭曲),并按圈层逐步切换到新图。
    • 波还没到:继续显示旧图。

这就是为什么你会看到“像水波推着画面前进”的感觉,而不是生硬切换。

为什么要用离屏 Canvas?

这里用了 fromCanvastoCanvasoutputCanvas 三个离屏画布

const fromData = fromCtx.getImageData(0, 0, pw, ph);
const toData = toCtx.getImageData(0, 0, pw, ph);
const outputData = outputCtx.createImageData(pw, ph);

作用有三点:

  1. 避免每一帧重复去主画布读像素(成本高)。
  2. 输出先在离屏里 putImageData,最后一次性 drawImage 到主画布,流程更稳。
  3. 绕开 putImageData 不受 transform 影响的问题,DPR 场景更可控。

性能小技巧

像素级循环最怕卡顿,这里用了一个很实用的策略:按块采样。

const step = 2;
for (let y = 0; y < ph; y += step) {
  for (let x = 0; x < pw; x += step) {
    // 计算一次后,填充 step x step 区域
  }
}

这相当于“每 2x2 像素算一次”,能明显减轻运算量。因为波纹本身是连续变化的,人眼很难察觉这点采样损失,性能却能换来很大提升。

交互

这个文件里交互是完整闭环:

  1. goTo(index) 统一处理切换入口,防抖靠 isTransitioning
  2. next/prev 处理循环索引。
  3. setInterval(next, 4500) 自动播放。
  4. 鼠标悬停暂停、离开恢复。
  5. 键盘左右箭头也能切。
  6. visibilitychange 时页面隐藏就停播,避免后台空转。

源码地址

gitee

gitee.com/zheng_yongt…

github

github.com/yongtaozhen…


🌟 觉得有帮助的可以点个 star~

🖊 有什么问题或错误可以指出,欢迎 pr~

📬 有什么想要实现的功能或想法可以联系我~


公众号推广

关注公众号 前端也能这么有趣 ,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

告别过度工程:菜鸟前端亲证,浏览器早已帮你搞定这 9 件事

作者 悟空瞎说
2026年4月5日 10:09

作为一名拥有 14 年前端开发经验的菜鸟,我亲历了前端行业从刀耕火种的 jQuery 时代,到框架百花齐放的工程化时代,再到如今原生 API 日趋完善的现代化时代。在漫长的开发生涯中,我见过太多团队陷入过度工程化的陷阱:为了实现一个简单功能,引入数十 KB 的第三方库;手写大量冗余 JS 代码,解决浏览器早已原生支持的问题;盲目追求自定义实现,忽略平台原生能力的稳定性与兼容性。

这篇文章,我将结合 14 年踩坑、重构、性能优化的实战经验,拆解 9 个前端高频场景 —— 这些需求你每天都可能遇到,而浏览器原生 API/CSS 特性早已给出完美解,帮你告别冗余代码、减少依赖、提升性能与可维护性。全文无抄袭,全部基于实战经验重构,带你回归前端本质,用好浏览器这座 “宝藏库”。

一、非关键任务延迟执行:requestIdleCallback,告别 setTimeout 黑科技

刚入行时,我们处理非关键任务(如用户行为埋点、日志上报、次要资源预加载),几乎都用setTimeout(fn, 0)这种黑科技。原理是利用浏览器事件循环,把任务塞进宏队列末尾,尽量不阻塞主线程,但这种方式完全不受浏览器调度控制—— 页面渲染繁忙时,它照样执行,导致卡顿、交互延迟,尤其在移动端老机型上问题频发。

后来我做电商网站,商品列表页同时渲染上百个组件,还要上报滚动、点击埋点,用setTimeout导致页面滑动掉帧,LCP(最大内容绘制)指标严重超标。直到发现requestIdleCallback这个原生 API,才彻底解决问题。

requestIdleCallback的核心逻辑是:只在浏览器空闲时执行指定任务,完全贴合浏览器渲染周期,不会阻塞关键渲染路径、用户交互(点击、输入、滚动)。它会监听浏览器主线程状态,当主线程空闲(无重排重绘、无用户操作)时,才触发回调,完美适配非紧急、非阻塞的任务。

14 年经验实战用法

javascript

运行

// 非关键埋点:用户滚动行为统计
function trackUserScrollBehavior() {
  const scrollInfo = {
    scrollTop: document.documentElement.scrollTop,
    scrollHeight: document.documentElement.scrollHeight,
    timestamp: Date.now()
  };
  // 异步上报,不阻塞主线程
  navigator.sendBeacon('/api/track/scroll', JSON.stringify(scrollInfo));
}

// 优雅降级:兼容不支持的浏览器(如旧版Safari)
if ('requestIdleCallback' in window) {
  // 空闲时执行,支持超时配置(确保任务最终会执行)
  requestIdleCallback(trackUserScrollBehavior, { timeout: 2000 });
} else {
  // 降级方案,仍优先不阻塞
  setTimeout(trackUserScrollBehavior, 30);
}

老兵关键提醒

  1. 适用场景:数据埋点、日志上报、非核心资源预加载、后台计算、图片离线生成等非紧急任务;绝对不要用于动画、交互响应等关键任务。
  2. 兼容性:现代浏览器全覆盖,Safari 15.4 + 支持,旧版需降级。
  3. 性能收益:我曾用它优化电商首页,埋点逻辑不再阻塞渲染,页面滑动帧率从 35fps 提升至 60fps,LCP 缩短 200ms,这就是原生能力的力量。

二、父级元素聚焦样式::focus-within,干掉冗余 JS 聚焦监听

早年做表单开发,想实现 “输入框聚焦时,父级容器高亮边框”,标准解法是:给输入框绑定focusblur事件,通过 JS 动态添加 / 移除父级样式。代码量大、容易漏绑事件、表单字段多了还会出现样式不同步 bug,维护成本极高。

直到 CSS :focus-within伪类出现,我才意识到:十几行 JS 能解决的事,一行 CSS 就搞定。这个伪类的作用是:当子元素处于聚焦状态时,选中父级元素,无需任何 JS 逻辑,纯 CSS 实现,无 bug、无性能损耗。

14 年经验实战用法

css

/* 基础表单容器样式 */
.form-item {
  border: 1px solid #e5e7eb;
  padding: 12px 16px;
  border-radius: 8px;
  transition: border-color 0.2s ease;
  margin-bottom: 16px;
}

/* 子元素聚焦时,父级容器样式变化 */
.form-item:focus-within {
  border-color: #3b82f6;
  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}

/* 输入框样式,去除默认聚焦轮廓 */
.form-item input {
  border: none;
  outline: none;
  width: 100%;
  font-size: 14px;
}

html

预览

<div class="form-item">
  <input type="text" placeholder="请输入用户名" />
</div>
<div class="form-item">
  <input type="password" placeholder="请输入密码" />
</div>

老兵关键提醒

  1. 兼容性全平台完美支持,IE 除外(如今前端基本放弃 IE),无需降级。
  2. 扩展场景:不仅适用于输入框,还适用于下拉框、按钮、富文本编辑器等所有可聚焦元素,复杂表单、搜索框、登录页都能通用。
  3. 维护优势:纯 CSS 样式与行为分离,后期修改样式无需改动 JS,大幅降低维护成本,这是工程化开发的核心原则。

三、网络状态监听:navigator.onLine,PWA 离线体验原生实现

早年做 PWA(渐进式 Web 应用)时,离线状态处理是一大难题。为了监听用户断网、联网,很多团队引入第三方网络检测库,或手写轮询请求接口判断网络状态,不仅增加包体积,还会产生无效请求,耗电、耗流量,检测精度还低。

其实浏览器原生提供了navigator.onLine属性,配合online/offline事件,就能精准监听网络状态变化,无需任何第三方依赖,轻量、精准、高效。

14 年经验实战用法

javascript

运行

// 初始网络状态判断
const initNetworkStatus = () => {
  if (!navigator.onLine) {
    showOfflineTip();
    // 离线数据缓存(IndexedDB/localStorage)
    cacheOfflineData();
  }
};

// 显示离线提示
function showOfflineTip() {
  const tip = document.createElement('div');
  tip.className = 'offline-tip';
  tip.textContent = '网络连接断开,请检查网络设置';
  document.body.appendChild(tip);
  setTimeout(() => tip.remove(), 3000);
}

// 监听离线事件
window.addEventListener('offline', () => {
  showOfflineTip();
  // 离线逻辑:暂停请求、缓存用户输入
  pauseAsyncRequest();
});

// 监听联网事件
window.addEventListener('online', () => {
  const tip = document.createElement('div');
  tip.className = 'online-tip';
  tip.textContent = '网络已恢复,正在同步数据';
  document.body.appendChild(tip);
  setTimeout(() => tip.remove(), 3000);
  // 联网逻辑:重新请求、同步离线缓存数据
  syncOfflineData();
});

// 初始化
initNetworkStatus();

老兵关键提醒

  1. 核心误区navigator.onLinetrue≠后端服务可用,仅代表设备有网络连接,需结合接口异常处理(try/catch、axios 拦截器)使用。
  2. 实战场景:PWA 应用、表单离线编辑、弱网环境优化、数据自动同步,都是高频使用场景。
  3. 兼容性:所有现代浏览器全覆盖,移动端、桌面端均稳定支持,是 PWA 开发必备原生 API。

四、流畅动画实现:requestAnimationFrame,告别 setInterval 卡顿

早年做前端动画,几乎都用setInterval固定时间间隔修改 DOM 样式,比如setInterval(() => { el.style.left = x + 'px' }, 16),看似模拟 60fps 帧率,实则问题极大:setInterval 与浏览器渲染周期不同步,容易出现丢帧、卡顿、闪烁,尤其在页面繁忙时,动画效果惨不忍睹。

requestAnimationFrame是浏览器专为动画设计的原生 API,与浏览器重绘周期完全同步,浏览器会在每次重绘前执行回调,确保动画流畅,且页面隐藏时自动暂停,节省性能。这是我 14 年开发中,优化动画性能的首选方案。

14 年经验实战用法

javascript

运行

// 获取动画元素
const box = document.querySelector('.animate-box');
let offset = 0;

// 动画执行函数
function animateBox(timestamp) {
  // 计算位移,使用transform替代left,避免重排
  offset = (offset + 2) % 300;
  box.style.transform = `translateX(${offset}px)`;
  // 循环执行动画
  requestAnimationFrame(animateBox);
}

// 启动动画
requestAnimationFrame(animateBox);

css

.animate-box {
  width: 50px;
  height: 50px;
  background: #3b82f6;
  border-radius: 8px;
  /* 开启硬件加速 */
  will-change: transform;
}

老兵关键提醒

  1. 性能核心必须配合 transform/opacity 使用,这两个属性不会触发浏览器重排,动画性能极致优化。
  2. 优势:页面隐藏时自动暂停,减少 CPU / 内存消耗;无需计算时间间隔,浏览器自动适配帧率。
  3. 兼容性全浏览器支持,从 IE10 到现代浏览器,无任何兼容问题,是前端动画标准方案。

五、组件自适应:容器查询(Container Queries),终结视口媒体查询局限

早年做响应式开发,只能用@media媒体查询,基于整个视口宽度调整样式。但实际开发中,我们常需要基于组件自身容器宽度调整样式 —— 比如卡片组件在侧边栏窄容器、首页宽容器中展示不同布局,媒体查询完全无法实现,只能手写 JS 监听容器尺寸,或写多套样式强行适配,代码冗余、维护困难。

如今 CSS 容器查询彻底解决这个问题,让组件真正实现自适应,不依赖视口,只看自身容器,是组件化开发的革命性特性。作为常年开发组件库的老兵,我认为这是 CSS 近几年最实用的更新。

14 年经验实战用法

css

/* 定义容器:开启行内尺寸查询 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 卡片基础样式 */
.card {
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* 容器宽度≥400px时,修改卡片布局 */
@container card (min-width: 400px) {
  .card {
    flex-direction: row;
    align-items: center;
  }
}

/* 容器宽度≥600px时,进一步优化 */
@container card (min-width: 600px) {
  .card {
    padding: 24px;
    gap: 20px;
  }
}

html

预览

<!-- 窄容器:卡片垂直布局 -->
<div class="card-container" style="width: 300px;">
  <div class="card">
    <img src="cover.jpg" alt="封面" />
    <div class="card-content">内容</div>
  </div>
</div>

<!-- 宽容器:卡片水平布局 -->
<div class="card-container" style="width: 500px;">
  <div class="card">
    <img src="cover.jpg" alt="封面" />
    <div class="card-content">内容</div>
  </div>
</div>

老兵关键提醒

  1. 兼容性:现代浏览器(Chrome 105+、Firefox 110+、Safari 16+)全覆盖,旧版浏览器可通过降级样式适配。
  2. 组件化价值:让组件真正可移植、自包含,不依赖页面环境,是设计系统、组件库开发必备特性。
  3. 最佳实践:优先使用inline-size(行内尺寸),适配水平响应式场景,这是最常用的配置。

六、安全随机 ID:crypto.getRandomValues,远离 Math.random 冲突风险

早年开发中,生成临时 ID、会话标识、订单后缀,几乎都用Math.random().toString(36).slice(2)这种简易方式。但Math.random伪随机数,熵值低,存在重复风险,尤其在高并发、大批量生成 ID 时,冲突概率极高,线上曾出现过用户 ID 重复、购物车数据错乱的严重 bug。

浏览器原生crypto.getRandomValues提供加密级安全随机数,熵值高、无规律、重复概率极低,是生成安全随机 ID 的标准方案,比Math.random可靠百倍。

14 年经验实战用法

javascript

运行

/**
 * 生成安全随机ID
 * @param {number} length 字节长度,默认8字节
 * @returns {string} 十六进制随机字符串
 */
function generateSecureId(length = 8) {
  // 创建无符号字节数组
  const bytes = new Uint8Array(length);
  // 获取加密级安全随机数
  crypto.getRandomValues(bytes);
  // 转换为十六进制字符串
  return Array.from(bytes)
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');
}

// 生成用户临时ID
const tempUserId = generateSecureId();
console.log('安全临时ID:', tempUserId);

// 生成会话标识
const sessionId = generateSecureId(16);
console.log('安全会话ID:', sessionId);

老兵关键提醒

  1. 进阶方案:若需要标准 UUID,直接用crypto.randomUUID(),一行代码生成 UUID v4,兼容性极佳,是现代开发首选。
  2. 适用场景:用户临时 ID、会话标识、订单号、缓存键、加密盐值等禁止重复的场景。
  3. 兼容性:所有现代浏览器全覆盖,移动端、桌面端、WebWorker 中均稳定支持。

七、原生模态框:标签,干掉第三方模态框库冗余依赖

早年开发模态框(弹窗),必须引入第三方库(如 Bootstrap Modal、Element UI Dialog),或手写 JS 实现:遮罩层、显示隐藏、焦点管理、点击遮罩关闭、ESC 关闭、无障碍支持…… 代码量巨大,还容易出现焦点错乱、遮罩层穿透、移动端适配问题。

HTML5 原生<dialog>标签彻底解决这个问题,自带遮罩、焦点管理、无障碍支持,几行代码就能实现标准模态框,无需任何第三方依赖,体积轻量、功能完善。

14 年经验实战用法

html

预览

<!-- 原生模态框 -->
<dialog id="confirm-dialog">
  <div class="dialog-content">
    <h3>确认操作</h3>
    <p>确定要提交表单吗?</p>
    <div class="dialog-footer">
      <button onclick="document.getElementById('confirm-dialog').close()">取消</button>
      <button onclick="handleSubmit()">确认提交</button>
    </div>
  </div>
</dialog>

<!-- 触发按钮 -->
<button onclick="document.getElementById('confirm-dialog').showModal()">打开确认弹窗</button>

css

/* 模态框基础样式 */
#confirm-dialog {
  border: none;
  border-radius: 8px;
  padding: 24px;
  width: 90%;
  max-width: 400px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}

/* 遮罩层样式 */
#confirm-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 20px;
}

老兵关键提醒

  1. 核心方法showModal()打开模态框(带遮罩)、close()关闭、returnValue获取返回值,完全满足日常需求。
  2. 无障碍优势:原生支持焦点管理、屏幕阅读器朗读,符合 WCAG 无障碍标准,这是手写模态框很难实现的。
  3. 兼容性:现代浏览器全覆盖,Safari 15.4 + 支持,旧版可通过简单 polyfill 兼容。

八、语音输入:Web Speech API,无需 AI 库实现语音识别

现在很多产品需要语音输入功能,很多团队第一反应是引入transformers.js、百度语音 SDK 等第三方库,增加包体积、依赖外部服务、配置复杂。其实Chromium 内核浏览器(Chrome/Edge)原生支持语音识别 API,简单几行代码就能实现语音转文字,适合内部系统、演示项目、轻量语音场景。

14 年经验实战用法

javascript

运行

// 兼容webkit前缀
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;

if (SpeechRecognition) {
  // 创建语音识别实例
  const recognition = new SpeechRecognition();
  // 设置语言
  recognition.lang = 'zh-CN';
  // 连续识别
  recognition.continuous = false;
  // 临时结果返回
  recognition.interimResults = false;

  // 识别成功回调
  recognition.onresult = (e) => {
    const text = e.results[0][0].transcript;
    console.log('识别结果:', text);
    // 填充到输入框
    document.getElementById('voice-input').value = text;
  };

  // 识别错误回调
  recognition.onerror = (e) => {
    console.error('语音识别错误:', e.error);
    alert('语音识别失败,请重试');
  };

  // 绑定按钮事件
  window.startVoiceInput = () => {
    recognition.start();
  };
} else {
  alert('当前浏览器不支持语音输入,请使用Chrome/Edge浏览器');
}

html

预览

<input type="text" id="voice-input" placeholder="点击按钮语音输入" />
<button onclick="startVoiceInput()">🎤 语音输入</button>

老兵关键提醒

  1. 兼容性:仅 Chromium 内核浏览器支持,Safari/Firefox 暂不支持,生产环境需做好降级提示。
  2. 适用场景:内部管理系统、演示项目、轻量表单输入,不适合强依赖语音功能的核心业务。
  3. 优势零依赖、零成本、无需服务端,纯前端实现,快速满足轻量需求。

九、CSS 特性检测:@supports,优雅适配新特性,避免样式崩溃

前端开发中,我们经常使用 CSS 新特性(如backdrop-filtercontainer-typegap),但旧版浏览器不支持,会导致样式错乱、页面崩溃。早年只能通过 JS 检测浏览器版本,动态添加样式,逻辑复杂、维护困难。

CSS @supports规则完美解决这个问题,纯 CSS 检测浏览器是否支持指定特性,支持则应用新样式,不支持则回退到基础样式,优雅适配新旧浏览器,这是我做跨端兼容的必备技巧。

14 年经验实战用法

css

/* 基础样式,所有浏览器都支持 */
.glass-card {
  background: #ffffff;
  padding: 24px;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

/* 检测支持backdrop-filter时,应用毛玻璃效果 */
@supports (backdrop-filter: blur(10px)) {
  .glass-card {
    background: rgba(255, 255, 255, 0.6);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255, 255, 255, 0.2);
  }
}

/* 组合条件检测 */
@supports (display: grid) and (container-type: inline-size) {
  .responsive-card {
    display: grid;
    gap: 16px;
  }
}

老兵关键提醒

  1. 语法灵活:支持单特性检测、组合检测(and/or/not),覆盖几乎所有适配场景。
  2. 兼容性:现代浏览器全覆盖,IE 不支持,但 IE 会直接忽略@supports规则,应用基础样式,无兼容性风险。
  3. 实战价值:使用 CSS 新特性时,必须配合 @supports,确保旧版浏览器样式不崩溃,这是跨端兼容的标准实践。

十、14 年前端老兵的核心感悟:别让过度工程化,掩盖原生的力量

写完这 9 个场景,我想分享 14 年开发的核心感悟:前端开发的本质,是用最少的成本、最优的性能,解决用户需求,而不是盲目堆砌技术、引入依赖、手写冗余代码。

浏览器经过数十年迭代,早已不是当年的 “简陋画布”,而是一座蕴藏无数原生能力的宝藏库。我们过度工程化的根源,往往是对原生 API/CSS 特性不熟悉,习惯用旧经验解决新问题,忽略了平台本身的能力。

老兵给前端开发者的 3 条建议

  1. 定期盘点原生能力:每年花时间学习浏览器新特性、新 API,很多第三方库的功能,原生早已实现。
  2. 引入依赖前先问自己:这个功能,浏览器原生能实现吗?能,就优先用原生,减少依赖、降低风险。
  3. 回归本质,拒绝炫技:好的代码不是越复杂越好,而是简单、稳定、易维护,原生方案永远是首选。

库和框架是工具,不是必需品。当你真正吃透浏览器原生能力,会发现:很多你曾经头疼的问题,浏览器早已帮你完美解决。放下过度工程化的执念,用好原生这座宝藏,你的前端开发之路会更轻松、更高效。

VTJ.PRO实践:接入TailwindCSS

2026年4月3日 12:26

VTJ.PRO没有内置TailwindCSS,可以通过增加依赖的方式支持 TailwindCSS。

增加 TailwindCSS 依赖

打开设计器的依赖管理面板,点击 + 添加 TailwindCSS 依赖。

填写以下信息:

  • 包名:tailwindcss
  • 版本:4.2.2
  • 导出名称:tailwindcss
  • 资源文件:https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4?.js

image.png

测试验证

在设计器页面管理面板,新建一个页面,打开AI助手,输入提示词:

在当前页面使用 tailwindcss 做一个演示 tailwindcss 用法的页面

image.png

页面生成效果

6a1391fa-b071-4307-b8a4-b21f27c5e4cc.png

经过验证,TailwindCSS 已经成功接入到VTJ.PRO

CSS Position 定位:从入门到精通

2026年4月3日 09:56

引言:为什么 Position 如此重要?

想象一下,你正在布置一个房间(你的网页)。家具(HTML 元素)默认会按照购买顺序依次摆放——这就是文档流。但有时候,你想让台灯悬浮在角落,让时钟始终挂在墙上,或者让某个装饰在滚动时"粘"在特定位置。

这时,你就需要 CSS 的 position 属性——它就像是给元素发放的"特权通行证",让它们突破常规布局的限制!


一、先搞懂:什么是文档流?

在深入 position 之前,必须理解这个核心概念:

📋 文档流(Document Flow)
├── 块级元素:从上到下垂直排列
├── 行内元素:从左到右水平排列
└── 元素默认按 HTML 代码顺序依次摆放

关键点position 的本质就是控制元素是否脱离文档流以及如何定位


二、Position 的五种取值详解

1️⃣ static - 默认定位(无定位)

.element {
  position: static; /* 默认值,通常可以省略 */
}
特性 说明
文档流 ✅ 不脱离
top/left/right/bottom ❌ 不生效
z-index ❌ 不生效
使用场景 默认状态,无需特殊定位时
<!-- 示例 -->
<div class="box">我是 static,按正常顺序排列</div>

2️⃣ relative - 相对定位

.element {
  position: relative;
  top: 20px;    /* 向下偏移 20px */
  left: 30px;   /* 向右偏移 30px */
}
特性 说明
文档流 ✅ 不脱离(原位置保留)
参照物 自身原始位置
z-index ✅ 生效
使用场景 微调位置、作为 absolute 的参照父元素
<style>
  .container {
    position: relative; /* 重要!作为子元素 absolute 的参照 */
    width: 300px;
    height: 200px;
    background: #e0e0e0;
  }
  .child {
    position: relative;
    top: 10px;
    left: 10px;
    background: #4CAF50;
    color: white;
  }
</style>

<div class="container">
  <div class="child">相对定位 - 相对于自己原位置移动</div>
</div>

💡 经典用法relative + absolute 组合,父元素设 relative,子元素设 absolute 实现局部定位


3️⃣ absolute - 绝对定位

.element {
  position: absolute;
  top: 0;
  right: 0;
}
特性 说明
文档流 ❌ 脱离(不占原位置)
参照物 最近的非 static 定位祖先元素
无参照时 相对于初始包含块(通常是 viewport)
z-index ✅ 生效
使用场景 弹窗、角标、悬浮按钮、下拉菜单
<style>
  .card {
    position: relative; /* 关键:建立定位上下文 */
    width: 250px;
    height: 150px;
    background: #fff;
    border: 1px solid #ddd;
  }
  .badge {
    position: absolute;
    top: -10px;
    right: -10px;
    background: #f44336;
    color: white;
    padding: 5px 10px;
    border-radius: 50%;
  }
</style>

<div class="card">
  <span class="badge">NEW</span>
  <p>卡片内容...</p>
</div>

⚠️ 常见坑点:absolute 元素找不到定位祖先时,会相对于页面定位,导致布局错乱!


4️⃣ fixed - 固定定位

.element {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
}
特性 说明
文档流 ❌ 脱离
参照物 浏览器视口(viewport)
滚动行为 固定不动
z-index ✅ 生效
使用场景 导航栏、回到顶部按钮、广告横幅
<style>
  .navbar {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 60px;
    background: #333;
    color: white;
    z-index: 1000; /* 确保在最上层 */
  }
  
  .back-to-top {
    position: fixed;
    bottom: 30px;
    right: 30px;
    width: 50px;
    height: 50px;
    background: #2196F3;
    color: white;
    border-radius: 50%;
  }
</style>

<nav class="navbar">固定导航栏 - 滚动时始终在顶部</nav>
<button class="back-to-top"></button>

🔍 注意:在移动端需要注意视口高度和键盘弹出时的影响


5️⃣ sticky - 粘性定位(⭐ 现代布局利器)

.element {
  position: sticky;
  top: 0; /* 必须指定阈值! */
}
特性 说明
文档流 ✅ 不脱离(阈值前)
行为 relative + fixed 的结合体
阈值 必须设置 top/bottom/left/right 之一
使用场景 表格表头、侧边导航、吸顶效果
<style>
  .sticky-header {
    position: sticky;
    top: 0; /* 滚动到距离顶部 0px 时固定 */
    background: #1976D2;
    color: white;
    padding: 15px;
    z-index: 100;
  }
  
  .content {
    height: 2000px; /* 制造滚动空间 */
    padding: 20px;
  }
</style>

<header class="sticky-header">
  📌 粘性标题 - 滚动时吸顶
</header>
<div class="content">
  <p>向下滚动查看效果...</p>
</div>

⚠️ sticky 不生效的常见原因

  1. 父元素有 overflow: hidden/auto/scroll
  2. 没有设置 top/left/right/bottom 阈值
  3. 父元素高度不足以产生滚动

三、五种定位对比速查表

属性值 脱离文档流 参照物 z-index 典型场景
static - 默认布局
relative 自身原位置 微调、定位上下文
absolute 最近非 static 祖先 弹窗、角标
fixed 视口 导航栏、悬浮按钮
sticky ⚠️ 条件脱离 滚动容器 吸顶、表格表头

四、实战案例合集

案例 1:商品卡片角标

.product-card {
  position: relative;
}
.sale-badge {
  position: absolute;
  top: 10px;
  left: 10px;
  background: #ff5722;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
}

案例 2:模态框居中

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0,0,0,0.5);
}
.modal-content {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 30px;
  border-radius: 8px;
}

案例 3:侧边吸顶导航

.sidebar {
  position: sticky;
  top: 80px; /* 距离顶部 80px 开始固定 */
  height: calc(100vh - 80px);
  overflow-y: auto;
}

五、常见坑点与解决方案

问题 原因 解决方案
absolute 乱跑 找不到定位祖先 给父元素加 position: relative
sticky 不生效 父元素 overflow 限制 检查并调整父元素 overflow
fixed 被遮挡 z-index 层级问题 提高 z-index 值
定位偏移不准 未考虑 border/padding 使用 box-sizing: border-box

六、最佳实践建议

  1. 建立定位上下文:使用 relative 父容器包裹 absolute 子元素
  2. 层级管理:统一规划 z-index,避免层级混乱
  3. 移动端适配:fixed 元素注意安全区域和键盘影响
  4. 性能优化:避免过多 fixed/sticky 元素影响滚动性能
  5. 语义化优先:能不用 position 解决的布局,优先用 Flexbox/Grid

结语

position 是 CSS 布局的基石之一,掌握它能让你的页面从"规整"变得"灵动"。记住核心要点:

  • 🎯 static - 默认,不定位
  • 🎯 relative - 相对自己,保留位置
  • 🎯 absolute - 相对祖先,脱离文档流
  • 🎯 fixed - 相对视口,滚动不动
  • 🎯 sticky - 滚动到阈值后固定

最好的学习方式就是动手实践!打开你的代码编辑器,创建几个测试页面,亲自体验每种定位的效果变化。

CSS 起步文档

作者 小霍同学
2026年4月3日 09:11

CSS 入门

什么是 CSS

CSS(层叠样式表,Cascading Style Sheets)用于描述 HTML 或 XML 文档的样式。通过选择器和声明组成的规则控制网页元素的呈现。

CSS 语法结构

选择器 {
  属性名: 属性值;
}

CSS 引入方式

方式 写法示例 使用场景
行内样式 <div style="color: red;"> 单个元素临时样式
内部样式表 <style> div { color: red; } </style> 单页面内样式
外部样式表 <link rel="stylesheet" href="style.css"> 多页面复用,推荐

CSS 注释

/* 这是注释 */

基础选择器

选择器 作用 特点 使用频率 示例
标签选择器 选中所有同名标签 无法差异化 较高 div {}
类选择器 选中指定 class 灵活,可多类名 最高 .nav {}
id 选择器 选中唯一 id 唯一性,配合 JS 较少 #logo {}
通配符选择器 选中所有元素 权重最低,影响范围大 特殊场景 * {}

类选择器示例

<div class="box red">内容</div>

一个标签可以有多个类名,用空格分隔:class="box red"(公共样式 + 独立样式)。

通配符常用于清除内外边距

* {
  margin: 0;
  padding: 0;
}

字体与文本属性

字体属性

body {
  font-family: "Microsoft YaHei", Arial, sans-serif;  /* 字体系列 */
  font-size: 16px;                                     /* 字体大小 */
  font-weight: 700;                                    /* 粗细 100~900 */
  font-style: italic;                                  /* 斜体 */
}

复合属性(顺序固定):

div {
  font: italic 700 16px/1.5 "Microsoft YaHei";
}

必须同时保留 font-sizefont-family,否则无效。

文本属性

p {
  color: #ff0000;           /* 颜色(十六进制最常用) */
  text-align: center;       /* 水平对齐:left/center/right */
  text-decoration: none;    /* 下划线、删除线等,a 标签常用 none */
  text-indent: 2em;         /* 首行缩进,em 相对当前文字大小 */
  line-height: 26px;        /* 行高,单行文字垂直居中可设 line-height = 容器 height */
}

复合选择器与伪类

选择器 示例 说明
后代选择器 ul li {} 空格,选中所有后代
子选择器 div > p {} >,只选直接子元素
并集选择器 div, .nav {} 逗号,同时选中多个
链接伪类 a:hover {} LVHA 顺序::link :visited :hover :active
:focus 伪类 input:focus {} 获得焦点的元素

常用链接写法

a {
  color: #333;
  text-decoration: none;
}
a:hover {
  color: #c81623;
}

元素显示模式

类型 特点 常见标签
块级元素 独占一行,可设宽高,宽默认100% div, p, h1~h6, ul
行内元素 一行多个,不能设宽高,宽高由内容撑开 span, a, em, strong
行内块元素 一行多个,可设宽高,有间隙 img, input, td

显示模式转换

display: block;        /* 转块级 */
display: inline;       /* 转行内 */
display: inline-block; /* 转行内块 */

注意:行内元素的上下 margin 无效,上下 padding 视觉上有效但不会增加元素实际占据的高度(仍由 line-height 决定)。尽量只给行内元素设置左右内外边距。

背景属性

div {
  background-color: #f0f0f0;
  background-image: url(images/bg.jpg);
  background-repeat: no-repeat;   /* 不平铺,可选 repeat-x/repeat-y */
  background-position: center top; /* 方位词或像素,如 10px 20px */
  background-attachment: fixed;    /* 背景固定 */
}

复合写法(推荐顺序:color image repeat attachment position):

background: #fff url(bg.png) no-repeat fixed center;

背景半透明(CSS3,内容不透明):

background: rgba(0, 0, 0, 0.3);  /* 最后一个数 0~1,可写 .3 */

CSS 三大特性

  1. 层叠性:相同选择器设置相同样式,后写的覆盖先写的(就近原则)。

  2. 继承性:子元素继承父元素的某些样式(colorfont-sizeline-height 等)。

    • line-height 无单位写法(如 1.5)子元素会按自己的 font-size 重新计算;带单位(如 1.5em)则会先计算父元素行高再继承,容易产生意外,推荐无单位写法。
  3. 优先级

选择器 权重(四位数)
继承或 * 0,0,0,0
标签选择器 / 伪元素 0,0,0,1
类选择器 / 伪类 / 属性选择器 0,0,1,0
id 选择器 0,1,0,0
行内样式 style="" 1,0,0,0
!important 无穷大
  • 权重叠加不进位,如 0,0,1,1
  • 复合选择器权重相加。

盒子模型

组成

content(内容) + padding(内边距) + border(边框) + margin(外边距)

边框 border

border: 1px solid red;
border-top: 2px dashed blue;
border-collapse: collapse;  /* 表格细线边框 */

内边距 padding

值的个数 含义
padding: 5px 上下左右均为 5px
padding: 5px 10px 上下 5px,左右 10px
padding: 5px 10px 20px 上 5px,左右 10px,下 20px
padding: 5px 10px 20px 30px 上右下左(顺时针)

如果盒子已有宽高,padding/border 会撑大盒子。解决方案:

  1. 手动减去撑大的尺寸。
  2. 使用 box-sizing: border-box;(推荐)。

外边距 margin

语法同 padding

块级盒子水平居中margin: 0 auto;(需设置宽度)。

外边距合并(常见坑点)

  • 相邻块元素垂直外边距合并:两个块上下排列,margin-bottommargin-top 会合并,取较大值。
  • 嵌套块元素垂直外边距塌陷:父元素有上外边距,子元素也有上外边距,父元素会塌陷较大值。

解决方法(任选一种):

  • 给父元素加 borderpadding
  • 给父元素加 overflow: hidden
  • 给父元素加 display: flow-root(现代方法,不产生副作用)。
  • 使用 flexgrid 布局(推荐)。

清除内外边距(通配符初始化)

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;  /* 可选,全局切换盒子模型 */
}

圆角边框与阴影

圆角 border-radius

div {
  border-radius: 10px;           /* 四个角一致 */
  border-radius: 10px 20px 30px 40px; /* 左上 右上 右下 左下 */
  border-radius: 50%;            /* 圆形(宽高相等)或椭圆(宽高不等) */
}

盒子阴影 box-shadow

box-shadow: h-shadow v-shadow blur spread color inset;

示例:

div:hover {
  box-shadow: 10px 10px 10px -4px rgba(0,0,0,0.3);
}
  • 阴影不占空间,不影响布局。

文字阴影 text-shadow

text-shadow: 2px 2px 2px gray;

浮动与清除浮动

浮动基本用法

传统布局方法,新项目推荐使用 Flex 或 Grid。

float: left;  /* 或 right, none */
  • 浮动元素脱离标准流,不再占位。
  • 多个浮动元素在一行排列(父容器宽度不够则换行)。
  • 任何元素浮动后都会具有行内块特性(可设宽高,一行多个)。

布局准则

  • 纵向排列用标准流,横向排列用浮动。

清除浮动(解决父元素高度塌陷)

原因:子元素浮动后,父元素高度为 0,影响后续布局。

方法1:额外标签法

在浮动子元素末尾添加空块级标签:<div style="clear: both;"></div>(不推荐,增加冗余标签)。

方法2:父级添加 overflow: hidden

.father {
  overflow: hidden;
}

方法3:伪元素清除浮动(推荐)

.clearfix::after {
  content: "";
  display: block;
  height: 0;
  clear: both;
  visibility: hidden;
}

方法4:双伪元素(现代)

.clearfix::before,
.clearfix::after {
  content: "";
  display: table;
}
.clearfix::after {
  clear: both;
}

现代更简单的方式:父元素设置 display: flow-root;(无副作用,兼容性良好)。

定位

定位模式 + 边偏移

position 说明 是否脱标
static 默认,无偏移
relative 相对自身原位置移动 否(占位)
absolute 相对最近有定位的父级
fixed 相对浏览器窗口
sticky 滚动到阈值后固定 混合

边偏移top, bottom, left, right(像素或百分比)。

子绝父相

子元素 absolute,父元素 relative(父占位,子自由移动)。

绝对定位盒子居中

.box {
  position: absolute;
  left: 50%;
  margin-left: -自身宽度的一半px;
}

堆叠顺序 z-index

  • 仅定位元素(position 不为 static)有效。
  • 数值越大越靠上,默认 auto(0)。

元素的隐藏与显示

方法 是否占位 适用场景
display: none; 不占位 常用,搭配 JS 做特效
visibility: hidden; 占位 需要保留空间时
overflow: hidden; 不占位 隐藏溢出部分,也可做清除浮动
opacity: 0; 占位 透明,仍可响应事件(配合 pointer-events

精灵图与字体图标

精灵图(Sprites)

将多个小图标合成一张图,通过 background-position 移动显示。

步骤

  1. 测量小图标大小和偏移(通常负值)。
  2. 设置背景图、不重复、定位。
.icon {
  width: 20px;
  height: 20px;
  background-image: url(sprites.png);
  background-repeat: no-repeat;
  background-position: -40px -20px;
}

字体图标(iconfont)

本质是字体,可像文字一样改变颜色、大小。

通用引入模板

@font-face {
  font-family: 'icomoon';
  src: url('fonts/icomoon.woff2') format('woff2'),
       url('fonts/icomoon.woff') format('woff'),
       url('fonts/icomoon.ttf') format('truetype');
  font-weight: normal;
  font-style: normal;
}
.icon {
  font-family: 'icomoon';
  font-size: 16px;
}

推荐使用

iconfont.cnwww.iconfont.cn/

icomoon.ioicomoon.io/

界面样式与实用技巧

鼠标样式 cursor

cursor: pointer;     /* 小手 */
cursor: move;        /* 移动十字 */
cursor: not-allowed; /* 禁止 */

去掉表单轮廓线

input, textarea {
  outline: none;
}
textarea {
  resize: none;      /* 禁止拖拽改变大小 */
}

图片与文字垂直对齐 vertical-align

  • 默认 baseline(基线对齐),导致图片底部有空隙。
  • 解决方法:给图片设置 vertical-align: middle / top / bottom,或 display: block
img {
  vertical-align: middle;
}

单行文本溢出省略

.ellipsis {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

多行文本溢出省略(WebKit)

.multiline-ellipsis {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

CSS 画三角形

.triangle {
  width: 0;
  height: 0;
  border: 50px solid transparent;
  border-top-color: red;
}

CSS3 新增选择器

属性选择器

选择符 说明 权重
E[att] 有 att 属性的 E 0,0,1,0
E[att="val"] att 属性值等于 val 同上
E[att^="val"] att 属性值以 val 开头 同上
E[att$="val"] att 属性值以 val 结尾 同上
E[att*="val"] att 属性值包含 val 同上

结构伪类选择器

选择符 说明 示例
E:first-child 父元素中第一个孩子且为 E li:first-child
E:last-child 父元素中最后一个孩子且为 E
E:nth-child(n) 父元素中第 n 个孩子且为 E(n 可为数字、odd、even、公式)
E:first-of-type 父元素中第一个该类型的 E
E:nth-of-type(n) 父元素中第 n 个该类型的 E

区别nth-child 先找第 n 个孩子,再检查是否匹配 E;nth-of-type 先过滤出所有 E,再取第 n 个。 例如:div span:nth-child(1),如果第一个子元素不是 <span>,则选不中。

示例

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>
li:nth-child(2) { color: red; }   /* 选中第二个 li */
li:nth-of-type(2) { color: blue; } /* 同样选中第二个 li,但如果中间有其他类型,结果可能不同 */

伪元素选择器

选择符 说明 权重
::before 在元素内容最前面插入内容 0,0,0,1
::after 在元素内容最后面插入内容 同上
div::before {
  content: "前缀";
  display: inline-block;  /* 默认是行内元素,可改块级 */
}

必须设置 content 属性,否则伪元素不生效。

CSS3 盒子模型与其他特性

box-sizing

box-sizing: content-box;  /* 默认,宽高只包含 content */
box-sizing: border-box;   /* 宽高包含 padding 和 border(推荐) */

滤镜 filter

img {
  filter: blur(5px);   /* 模糊,单位 px */
}

calc() 函数

width: calc(100% - 80px);  /* 运算符两侧必须有空格 */

CSS3 过渡与动画

过渡 transition

div {
  width: 100px;
  transition: all 0.5s ease 0s;  /* 属性 时长 运动曲线 延迟 */
}
div:hover {
  width: 200px;
}

运动曲线:ease(默认)、linearease-inease-outsteps()(逐帧)。

动画 animation

@keyframes move {
  0% { transform: translateX(0); }
  100% { transform: translateX(100px); }
}
div {
  animation: move 2s linear infinite alternate;
}

常用属性

属性 描述
animation-name 关键帧名称
animation-duration 持续时间
animation-timing-function 速度曲线(含 steps()
animation-iteration-count 播放次数(infinite 无限)
animation-direction 是否反向(alternate
animation-fill-mode 结束状态(forwards 保持)
animation-play-state 暂停(paused

性能优化:对动画属性使用 will-change: transform; 可提升流畅度。


CSS3 2D/3D 转换

2D 转换 transform

transform: translate(50px, 50px);   /* 位移,百分比相对自身 */
transform: rotate(45deg);           /* 旋转,正值顺时针 */
transform: scale(1.5);              /* 缩放,1 为原大小 */
transform-origin: left top;         /* 变换中心点 */

3D 转换

.father {
  perspective: 500px;               /* 透视,给父元素 */
  transform-style: preserve-3d;     /* 子元素开启 3D 空间 */
}
.child {
  transform: translateZ(100px) rotateY(45deg);
}
  • 左手准则:拇指指向轴正向,四指弯曲方向即为旋转正方向。

移动端 Web 开发基础

视口 viewport

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
属性 说明
width 布局视口宽度,常用 device-width
initial-scale 初始缩放比,通常 1.0
maximum-scale 最大缩放比
user-scalable 是否允许用户缩放(yes/no,但部分浏览器已忽略,建议用 maximum-scale 限制)

二倍图与响应式图像

  • 物理像素比:1 CSS 像素在不同屏幕上对应不同物理像素。
  • 解决高清屏模糊:准备 @2x 图,CSS 尺寸缩小一半。
/* 背景图方式 */
background-image: url(icon@2x.png);
background-size: 20px 20px;

/* 或使用 image-set */
background-image: -webkit-image-set(url(icon.png) 1x, url(icon@2x.png) 2x);

/* 或使用 img 的 srcset */
<img src="icon.png" srcset="icon@2x.png 2x" alt="">

移动端 CSS 初始化

推荐使用

normalize.cssnecolas.github.io/normalize.c…

常见重置样式

/* 去除移动端点击高亮 */
-webkit-tap-highlight-color: transparent;
/* 移除默认外观(如按钮圆角) */
-webkit-appearance: none;
/* 禁止长按菜单 */
img, a {
  -webkit-touch-callout: none;
}

现代布局:Flex 与 Grid

Flex 布局(一维布局)

.container {
  display: flex;           /* 或 inline-flex */
  flex-direction: row;     /* 主轴方向:row | column */
  justify-content: center; /* 主轴对齐:flex-start | center | space-between | space-around */
  align-items: center;     /* 交叉轴对齐:stretch | center | flex-start | flex-end */
  flex-wrap: wrap;         /* 换行 */
}
.item {
  flex: 1;                 /* 子项占据剩余空间比例 */
  order: 2;                /* 排序,数值越小越靠前 */
}

推荐:使用 Flex 替代浮动做一维布局(水平或垂直排列)。

Grid 布局(二维布局)

.container {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;  /* 三列等分 */
  grid-template-rows: auto 200px;
  gap: 20px;                            /* 行列间距 */
}
.item {
  grid-column: span 2;   /* 跨越两列 */
}

适用场景:复杂网格布局,如卡片墙、相册、仪表盘。

响应式设计与媒体查询

基本语法

@media (min-width: 768px) and (max-width: 1024px) {
  body {
    font-size: 14px;
  }
}

常用断点(参考)

  • 手机:max-width: 767px
  • 平板:768px - 1024px
  • 桌面:min-width: 1025px

结合 rem 的适配方案

  1. 媒体查询 + rem:动态改变根元素 font-size
  2. flexible.js(阿里):将屏幕分为 10 等份,1rem = 屏幕宽度/10。
<script src="https://cdn.jsdelivr.net/npm/flexible.js"></script>

vw / vh 适配

  • 1vw = 视口宽度的 1%1vh = 视口高度的 1%
  • 与百分比的区别:百分比相对父容器,vw/vh 相对视口。
.box {
  width: 50vw;   /* 屏幕宽度一半 */
  height: 30vh;  /* 屏幕高度 30% */
}

CSS 自定义属性(变量)

定义与使用

:root {
  --primary-color: #3498db;
  --spacing: 1rem;
}
button {
  background-color: var(--primary-color);
  padding: var(--spacing);
}

动态修改(JS)

document.documentElement.style.setProperty('--primary-color', '#e74c3c');

优势:主题切换、代码复用、易于维护。


性能与兼容性

浏览器私有前缀

前缀 浏览器
-webkit- Chrome, Safari
-moz- Firefox
-ms- IE
-o- Opera

使用建议:借助 Autoprefixer 工具自动添加,手写时只写标准属性。

性能提示

  • 避免使用通配符 * 选择器(影响渲染性能)。
  • 动画中使用 transformopacity(不触发重排)。
  • 对频繁动画元素使用 will-change: transform;
  • 减少 CSS 嵌套层级(选择器最长不超过 4 层)。

常见坑点与最佳实践

坑点 解决方案
外边距塌陷 父元素加 overflow: hiddendisplay: flow-root
图片底部空白间隙 vertical-align: middledisplay: block
浮动父元素高度塌陷 使用 clearfixdisplay: flow-root
行内元素上下边距无效 改用 display: inline-block 或块级
nth-child 与预期不符 确认是否选错孩子类型,改用 nth-of-type
绝对定位盒子没有参照物 给父元素加 position: relative
过渡/动画未生效 检查属性是否可动画(如 display: none 不能过渡)
移动端点击高亮 设置 -webkit-tap-highlight-color: transparent
1px 边框在高清屏变粗 使用 transform: scale(0.5)border-image

CSS 毛玻璃效果完全指南:从入门到避坑

作者 阿虎儿
2026年3月31日 17:41

CSS 毛玻璃效果完全指南:从入门到避坑

image.png

Glassmorphism(毛玻璃/磨砂玻璃)是近年来流行的 UI 设计风格,核心是通过模糊背景营造出半透明玻璃质感。本文总结了实现方式、关键参数调节,以及在实际项目中遇到的各类"不生效"问题及解决方案。


一、核心 CSS 写法

最简洁的毛玻璃效果只需 6 行 CSS:

.glass-card {
  background: rgba(255, 255, 255, 0.15); /* 白色半透明背景 */
  backdrop-filter: blur(12px);           /* 磨砂模糊 */
  -webkit-backdrop-filter: blur(12px);   /* Safari 兼容 */
  border: 1px solid rgba(255, 255, 255, 0.3); /* 半透明边框增强玻璃感 */
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);  /* 轻微阴影 */
  border-radius: 16px;
}

参数说明

属性 推荐值 作用
background 透明度 0.1 ~ 0.3 越小越透明,越大越白
blur() 半径 2px ~ 20px 越大越模糊,实际项目中 2~6px 已足够
border 透明度 0.2 ~ 0.4 模拟玻璃边缘的反光感

⚠️ 重要经验blur 值并非越大越好。在实际项目中(尤其是背景图内容复杂时),blur(2px) 往往比 blur(12px) 更自然,过大的值会让界面显得"糊",而非"透"。


二、最简 HTML 示例

毛玻璃效果必须有"后面的内容"才能显现,一个彩色背景 + 一张卡片是最经典的演示结构:

<div class="scene">
  <div class="glass-card">
    <h2>磨砂玻璃效果</h2>
    <p>backdrop-filter: blur(2px)</p>
  </div>
</div>
/* 外层:提供彩色背景,让磨砂有东西可以模糊 */
.scene {
  background: linear-gradient(135deg, #667eea, #f093fb, #4facfe);
  display: flex;
  align-items: center;
  justify-content: center;
  height: 300px;
}

/* 内层:真正的毛玻璃卡片 */
.glass-card {
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  border: 1px solid rgba(255, 255, 255, 0.3);
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
  border-radius: 16px;
  padding: 32px 40px;
}

三、常见"不生效"问题与解决方案

❌ 问题一:纯色背景上看不出效果

原因backdrop-filter 模糊的是元素后面的内容。如果背景是纯色,模糊前后没有区别,自然看不出效果。

解决方案:确保毛玻璃元素后面有丰富的内容——渐变色、图片、其他 UI 元素都可以。


❌ 问题二:父元素有背景图,效果穿透失败

这是实际项目中最常见的坑。结构如下时:

.main {
  background: url('bg.png') no-repeat;
}

.content-card {
  backdrop-filter: blur(12px); /* 无效! */
}

原因backdrop-filter 模糊的是元素所在渲染层下方的图层,而父元素的 background 不构成独立图层,导致无法穿透。

解决方案:用伪元素 ::before 将背景图单独放在一个真实的渲染层:

.main {
  position: relative; /* 必须 */
}

/* 用伪元素承载背景图,形成独立渲染层 */
.main::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url('bg.png') no-repeat right -130px;
  z-index: 0;
  pointer-events: none;
}

/* 卡片层级必须高于伪元素 */
.content-card {
  position: relative;
  z-index: 1; /* 必须 */

  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 16px;
}

❌ 问题三:祖先元素存在 transform / filter / will-change

原因:这三个 CSS 属性会创建新的层叠上下文(Stacking Context) ,将 backdrop-filter 的作用域限制在该上下文内部,导致无法模糊到更底层的内容。

排查方法:检查毛玻璃元素的所有祖先,找到设置了以下属性的元素:

/* 这些属性都会阻断 backdrop-filter */
transform: translateX(...);
filter: brightness(...);
will-change: transform;

解决方案:移除不必要的 transform/filter/will-change,或调整 DOM 结构,将毛玻璃元素移出受影响的层叠上下文。


❌ 问题四:blur 值设置过大,效果反而失真

这是一个容易被忽视的细节。blur(12px) 在 demo 中很漂亮,但在实际项目背景中(尤其是图片背景)可能导致:

  • 背景完全糊掉,看不出任何纹理
  • Chrome 下渲染出现白边或色块
  • 性能下降明显

解决方案:从小值开始测试,blur(2px)blur(6px) 通常是实际项目中更合适的范围。

/* 推荐:小值更真实 */
backdrop-filter: blur(2px);

/* 慎用:大值适合背景简单的 demo */
backdrop-filter: blur(12px);

❌ 问题五:元素设置了 overflow: hidden

原因overflow: hidden 在某些浏览器版本下会与 backdrop-filter 产生冲突,导致模糊效果被裁切或失效。

解决方案:检查元素本身或父元素是否设置了 overflow: hidden,改为 overflow: visible 或用其他方式实现裁切需求。


四、浏览器兼容性

浏览器 支持情况
Chrome 76+ ✅ 原生支持
Firefox 103+ ✅ 原生支持
Safari ✅ 需加 -webkit- 前缀
Edge 79+ ✅ 原生支持
IE ❌ 不支持

兼容写法(始终同时写两行):

backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);

五、完整实战模板

以下是一个适用于 Vue/React 项目的完整模板,涵盖了上述所有注意事项:

<!-- 结构 -->
<div class="page-wrapper">
  <div class="bg-layer"></div>  <!-- 独立背景层 -->
  <div class="glass-card">
    <slot />
  </div>
</div>
.page-wrapper {
  position: relative;
  min-height: 100vh;
}

/* 独立背景层,确保 backdrop-filter 可以穿透 */
.bg-layer {
  position: absolute;
  inset: 0;
  background: url('bg.png') no-repeat center / cover;
  z-index: 0;
}

/* 毛玻璃卡片 */
.glass-card {
  position: relative;
  z-index: 1;

  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(2px);           /* 小值更真实 */
  -webkit-backdrop-filter: blur(2px);
  border: 1px solid rgba(255, 255, 255, 0.3);
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
  border-radius: 16px;
  padding: 24px;
  overflow: visible;  /* 避免与 backdrop-filter 冲突 */
}

六、总结

场景 解决方案
纯色背景看不出效果 换为渐变或图片背景
父元素背景图穿透失败 ::before 伪元素单独承载背景图
祖先有 transform/filter 调整 DOM 结构或移除干扰属性
blur 值太大显示异常 降低至 2~6px,从小值开始调试
overflow: hidden 冲突 改为 overflow: visible
Safari 不显示 添加 -webkit-backdrop-filter 前缀

毛玻璃效果看起来简单,实际落地时坑点不少。核心原则只有一条:backdrop-filter 模糊的是元素后面的独立渲染层,任何阻断渲染层的因素都会让效果失效。 理解这一点,问题便迎刃而解。

分享一个THREE.JS中无限滚动的技巧

作者 苏武难飞
2026年3月31日 08:33

分享一个THREE.JS中无限滚动的技巧

最近在学习three.js发现了一个无限滚动的效果感觉还挺好看的故此来分享一下

本篇代码使用了@react-three/fiber

01.gif

1. 如何布局

首先我们准备好我们需要的图片资源,然后直接加载到页面中心

function ImageTube() {

    const imageUrls = useMemo(() => [
        "/tube/im1.jpg",
        "/tube/im3.jpg",
        "/tube/im2.jpg",
        "/tube/im4.jpg",
        "/tube/im5.jpg",
        "/tube/im6.jpg",
        "/tube/im7.jpg",
        "/tube/im8.jpg",
        "/tube/im9.jpg",
    ], []);

    const textures = useTexture(imageUrls);


    return Array.from({length: imageUrls.length}).map((_, index) => {

        return (
            <mesh key={index}>
                <planeGeometry args={[1, 1]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })
}

20260328172459

PlaneGeometry 参数定义表

参数索引 参数名 类型 默认值 功能描述
args[0] width float 1.0 平面宽度:沿 X 轴的长度。
args[1] height float 1.0 平面高度:沿 Y 轴的长度。
args[2] widthSegments int 1 水平分段:沿宽度方向将平面切分成多少个面片。
args[3] heightSegments int 1 垂直分段:沿高度方向将平面切分成多少个面片。

1.1 上下分开排列布局!

目前我们已经把我们所有的图片信息都加载到画面中了,下一步我们让这些图片依次从上到下排列!

核心是利用meshposition属性来改变定位,举个例子🌰

return Array.from({length: imageUrls.length}).map((_, index) => {

        const tileW = 0.8;
        const tileH = 1.0;

        // 定义对角线的边界
        const startX = -width / 2 + tileW / 2;     // 屏幕左侧
        const startY = height / 2 - tileH / 2;    // 屏幕上方
        const endX = width / 2 - tileW / 2;      // 屏幕右侧
        const endY = -height / 2 + tileH / 2;   // 屏幕下方

        const count = imageUrls.length;
        const t = index / (count - 1);

        const x = startX + (endX - startX) * t;
        const y = startY + (endY - startY) * t;
        const z = 0;


        return (
            <mesh key={index} position={[x,y,z]}>
                <planeGeometry args={[tileW, tileH]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })

20260328174354

可以很直观的看到我们已经完成了一个对角线排列的图片布局!

1.2 水平球形排列

经过上面的练习我们已经知道了可以通过position来排列,那么接下来我们把图片按照效果图那样排列一下!主要是用到了球坐标公式

{x=radius×cos(θ)z=radius×sin(θ)y=height\begin{cases} x = \text{radius} \times \cos(\theta) \\ z = \text{radius} \times \sin(\theta) \\ y = \text{height} \end{cases}
变量 名称 含义 作用
theta (θ\theta) 弧度 (Radians) 物体在圆周上的角度位置。 决定物体在圆圈的“几点钟方向”。
radius (rr) 半径 物体距离圆心的距离。 决定圆阵的大小(圆柱体的粗细)。
x X 坐标 水平轴位置。 决定物体的左右分布。
z Z 坐标 深度轴位置。 决定物体的远近分布(产生 3D 深度感)。
 return Array.from({length: imageUrls.length}).map((_, index) => {

        const count = imageUrls.length;
        // const y = (index - (count - 1) / 2) * ySpacing;

        const radius = 4;
        const theta = (index / count - 1) * Math.PI * 2;
        const x = Math.cos(theta) * radius;
        const z = Math.sin(theta) * radius;


        return (
            <mesh key={index} position={[x, 0, z]}>
                <planeGeometry args={[tileW, tileH]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })

02.gif

1.3 引入Group上下排列

上面我们已经成功的实现了一个水平球形排列,接下来我们要把这九张图重复上下排列!这时候就有一个问题了,目前我们的xz的计算很简单就是代入公式直接求答案,如果要上下排列那么就不再是一个单纯的圆形而是一个圆柱体形的排列,复杂度上了一个档次所以我介绍一个新的组件Group!也就是组的概念,在这个组下的所有元素都受到这个Group的影响!也就是position属性也是先根据Group再计算自身的


    const radius = 4;
    const tileW = 0.8;
    const tileH = 1.0;
    const ySpacing = 2.7;

    const rows = 5;
    const cols = 12;

    const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number;  }> = [];
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const y = (rowIndex - (rows - 1) / 2) * ySpacing;
            const baseRow = rowIndex % rows;
            out.push({rowIndex, y, baseRow});
        }
        return out;
    }, [rows, ySpacing]);


return (
        <group>
            {rowPositions.map(({rowIndex, y, baseRow}) => (
                <group key={rowIndex} position={[0, y, 0]}>

                    {Array.from({length: cols}).map((_, col) => {
                        const theta = (col / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
                    })}

                </group>
            ))}
        </group>
    )

20260330143425

成功完成五行十二列的布局!

2. 自滚动

目前的布局我们基本上实现了,接下来添加一个自旋转效果,说到自动的视图变化那肯定就要用到useFrame

useFrame 是 R3F 中最重要的 Hook,它允许你在每一帧(通常是每秒 60 次)执行代码。它是实现 动画物理模拟实时交互 的核心入口。

    useFrame((_state, dt) => {
        ...
        ...
        👆每一帧自动运行
    });

举个例子!我们直接在useFrame中控制grouprotation.y


 const rowGroupRefs = useRef<Array<Object3D | null>>([]);

 useFrame((_state, dt) => {
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            // const baseRow = rowIndex % rows;
            rowObj.rotation.y  += 0.0065;
        }
 })

{rowPositions.map(({rowIndex, y, baseRow}) => (
                <group
                    ...
                    ...
                    ref={(obj) => {
                        rowGroupRefs.current[rowIndex] = obj;
  }}>
...
...
...  

03

这样已经实现了一个基本的自滚动效果!

2.1 交错排列

现在的滚动效果可以发现每一行每一列都是对齐的有点过去生硬了,我们加一个偏移量


 const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number; rowOffset: number }> = [];
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            ...
            ...
            + const rowOffset = baseRow % 2 === 0 ? 0 : 0.5;
            out.push({rowIndex, y, baseRow, rowOffset});
        }
        return out;
}, [rows, ySpacing]);


{Array.from({length: cols}).map((_, col) => {
                        const theta = ((col + rowOffset) / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
})}

04.gif

2.2 和鼠标滚轮结合

现在基本的自转已经实现了,但是我们的目标是和鼠标滚轮结合还要有一个上下的滚动效果!

首先我们来实现一个上下滚动的效果,根据我们 1.3 学到的经验我们应该在这些外层再使用一个Group,并且控制这个Groupposition.y


  const rowGroupRefs = useRef<Array<Object3D | null>>([]);
  + const groupRef = useRef<Object3D>(null);
  + const scrollCurrent = useRef(0);


    useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            // const baseRow = rowIndex % rows;
            rowObj.rotation.y += 0.0065;
        }


    })


  <group ref={groupRef}>
            {rowPositions.map(({rowIndex, y, baseRow,rowOffset}) => (
                <group>
                    ...
                    ...
                <group>    
   </group>

scrollTargetRef为外部组件传递进来

function App() {

    const tubeScrollTarget = useRef(0);

    const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
        tubeScrollTarget.current += event.deltaY * 0.002;
    }, []);

    return (
        <div className="sceneRoot" onWheel={onWheel}>

            <Canvas
                camera={{position: [0, 0, 6.5], fov: 50}}
                onCreated={({camera}) => {
                    camera.lookAt(0, 0, 0);
                }}
            >

                {/*<OrbitControls enableDamping dampingFactor={0.05}/>*/}
                <ambientLight intensity={0.5}/>
                <directionalLight position={[5, 5, 5]} intensity={1}/>

                <Environment preset="studio" blur={10.5}/>
                <GridPlane targetCenterUv={targetCenterUv}/>
               
                <ImageTube2 scrollTargetRef={tubeScrollTarget}/>

            </Canvas>
        </div>
    )
}

05.gif

okkk,现在和鼠标滚轮事件结合可以上下滚动我们的列表了,但是现在还有点呆板的是我们绕y轴旋转的列表还没有和滚轮事件结合,我们的目标是在鼠标滚动的时候我们的旋转列表应该进行一个加速滚动!

这个时候我们需要用到另一个公式

📈 动态旋转位移叠加公式

该公式负责在 useFrame 中更新全局旋转角度,结合了自动巡航与用户交互。

  1. 公式定义 Total_Angle_Increment = (Base_Auto_Speed + User_Input_Velocity) * Delta_Time

  2. 参数拆解

  • baseSpeed: 静态常量,控制背景自动旋转的快慢。
  • spinVelocityRef: 动态变量,承载用户交互产生的动能(惯性)。
  • scaledDt: 时间缩放因子,确保跨设备速度一致性。
  1. 运行逻辑
  2. 每一帧读取当前的“自转力”和“手动推力”。
  3. 将两者求和,得到当前的总瞬时速度
  4. 根据这一帧经过的时间计算出应该转过的弧度增量
  5. 累加到 angle.current,从而驱动所有 meshrotation.y

首先我们得速率spinVelocityRef依然要和鼠标滚轮事件绑定,同时区分一下鼠标滚动得方向tubeNaturalDir

    const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
        tubeScrollTarget.current += event.deltaY * 0.002;
      + tubeSpinVelocity.current += event.deltaY * 0.004;

        if (event.deltaY < 0) tubeNaturalDir.current = -1;
        else if (event.deltaY > 0) tubeNaturalDir.current = 1;
    }, []);


 <ImageTube
    scrollTargetRef={tubeScrollTarget}
  + spinVelocityRef={tubeSpinVelocity}
    naturalDirRef={tubeNaturalDir}
    ...
    ... />

然后在我们得ImageTube中接收这个速率并且做一个递增处理


 useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        ...
        ...

    })

接下来我们算一个旋转速度比例系数


 useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

      + rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            0.12;
      // 根据方向不同产生不同方向得力
      + const scaledDt = dt * rotationSpeedScale.current;
        ...
        ...

    })

有了速率和速度比例系数之后我们就可以套进公式算出来我们应该滚动得距离了


useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

06

3. 无限滚动!

拖拖拉拉才进入本章得主题,那就是实现一个无限滚动得效果,如果大家之前用css做过类似得效果得话其实是能了解到所谓得无限滚动都是障眼法,是通过瞬时改变值来实现得,也就是把滚动了一定距离得值直接改到起始位置!

在我们这个上下滚动得例子中很明显我们得滚动距离最大是rows * ySpacing / 2 ,同样由于我们支持上下自由滚动所以我们最起码需要

const repeatCount = 3;
const totalRows = rows * repeatCount;

okok来实际看下代码,之前所有关于rows得地方都需要改变成totalRows

function ImageTube({
                        baseSpeedRef,
                        tubeAngleRef,
                        naturalDirRef,
                        scrollTargetRef,
                        spinVelocityRef,
                        rotationSpeedScaleLerpRef,
                        rotationSpeedScaleTargetRef,
                    }: {
    baseSpeedRef: React.MutableRefObject<number>;
    tubeAngleRef: React.MutableRefObject<number>;
    naturalDirRef: React.MutableRefObject<number>;
    scrollTargetRef: React.MutableRefObject<number>;
    spinVelocityRef: React.MutableRefObject<number>;
    rotationSpeedScaleTargetRef: React.MutableRefObject<number>;
    rotationSpeedScaleLerpRef: React.MutableRefObject<number>;
}) {

    const groupRef = useRef<Object3D>(null);
    const rowGroupRefs = useRef<Array<Object3D | null>>([]);
    const scrollCurrent = useRef(0);
    const rotationSpeedScale = useRef(1);
    const angle = useRef(0);

    const imageUrls = useMemo(() => [
        "/tube/im1.jpg",
        "/tube/im3.jpg",
        "/tube/im2.jpg",
        "/tube/im4.jpg",
        "/tube/im5.jpg",
        "/tube/im6.jpg",
        "/tube/im7.jpg",
        "/tube/im8.jpg",
        "/tube/im9.jpg",
    ], []);


    const textures = useTexture(imageUrls);

    const radius = 4;
    const tileW = 0.8;
    const tileH = 1.0;
    const ySpacing = 2.7;

    const rows = 5;
    const cols = 12;

    const loopHeight = rows * ySpacing;
    const repeatCount = 3;
    const totalRows = rows * repeatCount;

    const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number; rowOffset: number }> = [];
        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const y = (rowIndex - (totalRows - 1) / 2) * ySpacing;
            const baseRow = rowIndex % rows;
            const rowOffset = baseRow % 2 === 0 ? 0 : 0.5;
            out.push({rowIndex, y, baseRow, rowOffset});
        }
        return out;
    }, [totalRows, ySpacing]);


    useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

    return (
        <group ref={groupRef}>
            {rowPositions.map(({rowIndex, y, baseRow, rowOffset}) => (
                <group
                    key={rowIndex}
                    position={[0, y, 0]}
                    ref={(obj) => {
                        rowGroupRefs.current[rowIndex] = obj;
                    }}>

                    {Array.from({length: cols}).map((_, col) => {
                        const theta = ((col + rowOffset) / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
                    })}

                </group>
            ))}
        </group>
    )

}

然后我们依然在useFrame中处理我们得瞬时逻辑!


useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;
        // 当超过最大滚动距离得时候,我们瞬间改变滚动得高度
        if (scrollCurrent.current > loopHeight / 2) {
            scrollCurrent.current -= loopHeight;
            scrollTargetRef.current -= loopHeight;
        } else if (scrollCurrent.current < -loopHeight / 2) {
            scrollCurrent.current += loopHeight;
            scrollTargetRef.current += loopHeight;
        }

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

07.gif

4. 结语

为什么总觉得成功只差一步!

参考资料

Reactive Depth: Building a Scroll-Driven 3D Image Tube with React Three Fiber

CSS 技巧:CSS 中选择 html 元素的各种奇技淫巧

2026年3月30日 12:19

在 CSS 中选中 <html> 元素,这件事看起来再基础不过。大多数情况下,我们只需要写下 html {} 或者 :root {},问题就已经解决了,而且这也是最推荐、最常见的做法。

但如果稍微换个角度去想,除了这些“标准答案”,有没有其他方式也能选中 <html>?答案是——有,而且还不少。

当然,这些写法在实际项目中几乎没有使用价值,甚至可以说有点“多此一举”。不过,它们有一个很有意思的意义——可以帮助我们更深入地理解 CSS 选择器的工作原理。当你开始思考这些问题时,比如 :scope 在什么情况下等价于根元素、& 在非嵌套环境下到底代表谁、:has() 是否可以“反向”匹配父级,甚至能不能选中“没有父元素”的节点,你会发现 CSS 的灵活性远比想象中更高。

所以,这篇内容并不是在教你最佳实践,而更像是一场轻松的探索。我们会用各种“非常规”的方式去选中 <html>,看看 CSS 选择器的边界到底在哪里——以及,它到底能被玩到多离谱。

html:root

刚才提到过,通常我们会使用经典且熟知的 html {}:root{} 来选中 <html> 元素:

html {
    background-color: lightblue;
}

/* 或者 */
:root {
    background-color: lightblue;
}

在大多数情况下,html:root 的效果是一样的,但它们本质上属于两种不同类型的选择器。它们之间的差异主要体现在语义、适用范围和优先级上。

从本质来看,html 是一个元素选择器,它的作用非常直接,就是选中页面中的 <html> 标签本身;而 :root 则是一个伪类选择器,它匹配的是“文档的根元素”。在 HTML 文档中,这个根元素恰好就是 <html> 元素,因此两者在这里表现一致。

不过,这种一致只是“刚好如此”。从语义角度来说,html 表达的是一个具体的标签,而 :root 表达的是一种结构上的位置——也就是最顶层的那个元素。这种差别在其他类型的文档中就会变得明显:

  • HTML 文档::root 匹配 <html>

  • SVG 文档::root 匹配 <svg>

  • RSS 文档::root 匹配 <rss>

  • Atom 文档::root 匹配 <feed>

  • MathML 文档::root 匹配 <math>

  • 其他 XML 文档::root 匹配最外层元素,比如 <note>

:root 的实际意义是什么呢?一个很关键的点在于它的优先级。作为伪类选择器,:root 的权重是 0-1-0,高于元素选择器 html0-0-1。这意味着在样式冲突时,使用 :root 定义的规则更容易生效,从而减少被其他样式覆盖的可能性。

&:scope

接下来,我们来看一些你可能不太熟知的方法。我们可以先从最短、也是最“奇怪”的选择器开始——嵌套选择器 & 。它只有一个字符,但在特定情况下却可以直接选中 <html>

& {
    background-color: lightblue;
}

接下来是 :scope 选择器:

:scope {
    background-color: lightblue;
}

这两个写法之所以都能“指向” <html> ,其实依赖的是它们的回退行为。当 & 没有出现在嵌套规则中时,它不会再“拼接父选择器”,而是退化为指向当前作用域的根;而在没有显示式定义作用域(例如没有使用 @scope)的情况下,:scope 也会表示文档的根节点。于是,在普通的 HTML 文档中,它们最终都会指向 <html>

不过,从设计初衷来看,:scope& 的用途其实完全不同。:scope 用来表示“当前作用域的根元素”,而这个“根”在使用 @scope 时是可以被重新定义的;只有在默认情况下,它才等同于 <html> 。而 & 则主要用于 CSS 嵌套,用来引用当前选择器本身,从而实现更直观的嵌套写法。

例如:

element:hover {
    /* 写法一 */
}

element {
    &:hover {
        /* 等价于上面(注意 &) */
    }
}

如果省略 &,语义就会发生变化:

element {
    :hover {
        /* 实际变成 element :hover(注意空格) */
    }
}

甚至还可以写出更“绕”的形式:

element {
    :hover & {
        /* 表示 :hover element */
    }
}

但一旦 & 脱离了嵌套环境,它就不再参与选择器拼接,而只是简单地指向作用域根。在没有 @scope 的情况下,这个根就是 <html>——这也是它成为一个“隐藏选择器”的原因之一。

温馨提示:如果你对 CSS 的嵌套与作用域机制感兴趣,尤其是 &@scope 的用法,可以进一步阅读《CSS 的嵌套和作用域:&@scope》,会有更深入的理解。

:has(head):has(body)

我们还可以借助 :has() 这个“反向选择器”来选中 <html> 。例如:

:has(head) {
    background-color: lightblue;
}

/* 或者 */
:has(body) {
    background-color: lightblue;
}

之所以可行,是因为在规范上,<html> 元素只应该包含 <head><body> 这两个直接子元素(有点像那种“非黑即白”的设定)。如果你在 <html> 里写入其他标签,那属于无效 HTML,虽然浏览器通常会“帮你收拾残局”,把这些元素自动移动到 <head><body> 中。

更关键的一点是,在标准结构中,没有其他元素可以包含 <head><body> 。因此,当我们写 :has(head):has(body) 时,理论上只会匹配到 <html> 元素本身(除非你刻意写出错误的嵌套结构,但那显然不是正常用法)。

这种方式实用吗?其实并不太实现。但它很好地展示了 :has() 的能力,同时也顺带帮你复习了一下什么才是“合法的 HTML 结构”。

温馨提示:如今,:has() 选择器为 CSS 带来了前所未有的能力,它让我们可以完成许多过去必须依赖 JavaScript 才能实现的效果。如果你对这些更进阶的用法感兴趣,那么下面这几节课的内容非常值得花时间深入了解。

:not(* *)

除了前面那些方法,我们还可以利用一个很有意思的事实: <html> 是页面中唯一没有父元素的节点。基于这一点,可以写出一个略显“花哨”的选择器:

:not(* *) {
    background-color: lightblue;
}

这里的 * * 表示“所有被其他元素包含的元素”,而 :not(* *) 就是把这些元素全部排除掉。最终剩下的,正是那个不被任何元素包含的 <html>。顺便一提,* 被称为“通配选择器”,可以匹配任意元素。

你也可以在中间加入子代组合符 >

:not(* > *) {
    background-color: lightblue;
}

当然,围绕这些思路,我们还可以继续组合出更多“奇技淫巧”的写法,例如:

:is(&) {}
:where(&) {}
&& {}
&&&& {} /* 没错,& 可以无限叠加 */
:has(> body)
:has(> head)
:has(body, head)
/* 等等... */

这些写法有实际价值吗?大多数情况下并没有。但作为一次探索 CSS 选择器能力边界的练习,它们既有趣,也能帮助你更深入地理解选择器背后的机制。

小结

到这里,我们用各种“非常规”的方式,把 <html> 元素从头到尾“折腾”了一遍。从最常见的 html:root,到利用回退行为的 :scope&,再到借助结构关系的 :has(),甚至是通过“排除一切”的 :not(),你会发现:选中 <html> 的方法,远比想象中要多

但更重要的并不是这些写法本身,而是它们背后所体现的规则——选择器的匹配逻辑、作用域的概念、优先级的影响,以及 CSS 在不同上下文中的行为方式。这些才是真正值得理解的部分。

当然,在实际项目中,我们依然应该优先使用简单、清晰、可维护的写法,比如 html:root。那些“奇技淫巧”更多是一种探索和练习,它们的价值在于帮助你建立更扎实的底层认知,而不是直接拿来用在生产环境中。

如果说这篇内容有什么收获,那大概就是:CSS 远不只是“写样式”这么简单,它本身也是一门可以被不断挖掘和玩出花样的语言

❌
❌