普通视图

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

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

作者 前端Hardy
2026年3月2日 18:14

上周 Code Review,我看到同事写了这样一段代码:

const state = reactive({
  user: null,
  loading: false,
  error: '',
  list: []
});

// 后面又单独定义
const currentPage = ref(1);
const pageSize = ref(10);

乍看没问题,但一运行**——页面卡顿、watch 失效、调试器里数据对不上……**

问题出在哪?
不是逻辑错,而是响应式对象的“组合方式”错了

今天,我就用3 条黄金法则 + 2 个实战模板,帮你彻底搞懂 Vue 3 响应式怎么写才高效、安全、可维护。

法则 1:简单值用 ref,复杂对象用 reactive —— 但别混用!

很多教程说:“primitive 用 ref,object 用 reactive”,这没错,但忽略了“解构陷阱”。

错误示范:

const { user, loading } = reactive({ user: null, loading: false });
// 解构后失去响应性!

正确做法:

// 方案 A:全部用 ref(推荐新手)
const user = ref(null);
const loading = ref(false);

// 方案 B:用 toRefs 保持响应性
const state = reactive({ user: null, loading: false });
const { user, loading } = toRefs(state); // ✅ 响应式保留

经验公式:

  • 如果你要频繁解构 or 传递单个属性 → 优先用 ref
  • 如果是完整状态模块(如表单、列表配置)→ 用 reactive + toRefs

法则 2:别把 ref 套进 reactive,除非你真的需要

见过这种写法吗?

const state = reactive({
  count: ref(0), // ❌ 不要!
  name: 'Vue'
});

这会导致:

  • 访问时必须写 state.count.value(破坏一致性)
  • 模板中虽然自动 unwrap,但逻辑层混乱
  • 容易引发“value 嵌套地狱”

正确做法:统一层级

// 要么全 ref
const count = ref(0);
const name = ref('Vue');

// 要么全 reactive(count 直接是 number)
const state = reactive({
  count: 0,
  name: 'Vue'
});

小技巧:在 setup() 返回时,用 ...toRefs(state) 一键暴露所有属性。

法则 3:大型组件,用“状态模块化”代替巨型 reactive

当组件状态超过 5 个字段,别堆在一个 reactive 里!

反面教材:

const state = reactive({
  // 用户信息
  userId, userName, userAvatar,
  // 分页
  page, size, total,
  // 搜索条件
  keyword, status, dateRange,
  // UI 状态
  showDrawer, loading, errorMsg...
});

推荐拆分:

// 按功能拆成多个小状态块
const userState = reactive({ id: '', name: '', avatar: '' });
const pagination = reactive({ page: 1, size: 10, total: 0 });
const uiState = reactive({ loading: false, drawerVisible: false });

// 或封装成 composable
const { userState } = useUserStore();
const { pagination, fetchList } = usePagination();

这样不仅逻辑清晰,还天然支持 逻辑复用(比如分页逻辑抽成 usePagination)。

实战模板:两种主流写法对比

模板 A:全 ref 风格(适合中小型组件)

export default {
  setup() {
    const loading = ref(false);
    const list = ref([]);
    const keyword = ref('');

    const search = async () => {
      loading.value = true;
      list.value = await api.search(keyword.value);
      loading.value = false;
    };

    return { loading, list, keyword, search };
  }
}

优点:直观、无解构风险、TS 类型推导友好
注意:返回时别漏写 .value

模板 B:reactive + toRefs(适合状态密集型组件)

export default {
  setup() {
    const state = reactive({
      loading: false,
      list: [] as Item[],
      keyword: ''
    });

    const search = async () => {
      state.loading = true;
      state.list = await api.search(state.keyword);
      state.loading = false;
    };

    return { ...toRefs(state), search };
  }
}

优点:状态聚合、减少变量声明、模板中直接用 list
注意:内部操作用 state.xxx,别解构!

高阶建议:结合 更清爽

如果你用 Vue 3.3+,直接上 :

import { ref } from 'vue'

const loading = ref(false)
const list = ref([])
const keyword = ref('')

const search = async () => {
  loading.value = true
  list.value = await api.search(keyword.value)
  loading.value = false
}

没有 return,没有 setup(),变量自动暴露——这才是 Vue 3 的终极舒适区。

最后说两句

Vue 3 的响应式系统很强大,但自由也意味着责任。
用对了,代码清爽如诗;用错了,bug 隐蔽如鬼。

记住三句话:

  1. 简单用 ref,复杂用 reactive
  2. 别混用,别嵌套,别解构裸对象
  3. 大组件,拆状态,抽 composable

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

干掉 Virtual DOM?尤雨溪开始"强推" Vapor Mode?

作者 前端Hardy
2026年3月2日 17:43

前端这两年有一个明显趋势:

用编译优化彻底消灭运行时开销。

从 Rust 重写工具链,到服务端组件,底层正在被全面 "静态化"

而这一次,轮到了 Vue。

一个正在快速演进的技术 ——Vapor Mode,正在尝试用 Vue 3.5 重构整套 Vue 渲染体系。

它不仅仅是"再快一点",而是想把 Vue 的响应式系统、组件渲染、模板编译、更新机制全部重写为编译时优化

Vapor Mode 到底是什么?

Vapor Mode = 用编译时优化重写 Vue 渲染器。

它是一个全新的渲染模式(非默认),覆盖:

  • 无 Virtual DOM 渲染(细粒度响应式绑定)
  • 编译时依赖追踪(自动依赖收集)
  • 零运行时开销(无 diff 算法)
  • 原生 DOM 操作(直接更新,无代理)
  • 完整生态兼容(Vue Router、Pinia 无缝支持)

你没看错——它不是一个渐进升级,而是一整套"Vue 渲染器重构计划"。

它和普通模式是什么关系?

很多人第一反应:

那它是不是要干掉 Virtual DOM?

答案:不是同一个层级

  • 普通 Vue 模式 = Virtual DOM + 响应式运行时
  • Vapor Mode = 细粒度响应式 + 编译时优化

更准确理解:

  • 普通模式:data 变化 → 触发 setter → 通知依赖 → Virtual DOM diff → 更新真实 DOM
  • Vapor Mode:data 变化 → 直接触发关联 DOM 节点更新

这更像是:

给 Vue 换一颗"零开销引擎"。

快速上手体验

传统模式:Virtual DOM(经典但昂贵)

你以前写 Vue 组件,大概是这样的:

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const title = ref('Hello Vue');
const count = ref(0);

const increment = () => {
  count.value++;
};
</script>

运行时发生了什么?

// 简化后的执行流程
1. 组件初始化:创建 Proxy(title, count)
2. 响应式收集:渲染时追踪依赖(title → h1, count → p)
3. 状态更新:count.value++ 触发 setter
4. 依赖通知:通知所有订阅 count 的组件
5. Virtual DOM diff:对比新旧 VNode6. DOM 更新:真实 DOM 仅更新 p 文本

痛点分析:

  • 每次更新都要运行 Virtual DOM diff(即使只改一个数字)
  • Proxy 开销(内存 + CPU)
  • 响应式系统运行时收集依赖
  • 大型应用下 diff 成本显著

Vapor Mode 方式:零运行时开销

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script setup vapor>
import { ref } from 'vue';

const title = ref('Hello Vue');
const count = ref(0);

const increment = () => {
  count.value++;
};
</script>

唯一的区别:

<script setup> 中添加 vapor 指令

编译后生成什么?

// 简化后的编译输出(伪代码)
export function render(_ctx) {
  // 1. 直接 DOM 引用(无 VNode)
  const h1 = document.querySelector('h1');
  const p = document.querySelector('p');
  const button = document.querySelector('button');

  // 2. 细粒度绑定(无 Proxy)
  _ctx.title = reactiveValue('Hello Vue', (val) => {
    h1.textContent = val; // 直接更新 DOM
  });

  _ctx.count = reactiveValue(0, (val) => {
    p.textContent = val; // 直接更新 DOM
  });

  button.onclick = () => {
    _ctx.count.value++; // 直接触发更新
  };
}

核心差异:

  • 无 Virtual DOM diff
  • 无 Proxy 开销
  • 编译时依赖追踪
  • 直接 DOM 操作
  • 零运行时响应式系统

架构设计:为什么不只是"更快一点"?

传统 Vue 渲染器(3.x)

  Template Compiler
         ↓
  Render Functions
         ↓
  Virtual DOM Tree
         ↓
  Reconciliation (diff)
         ↓
  Real DOM Updates

特点:

  • 运行时依赖收集(Proxy + Effect)
  • Virtual DOM diff 算法(O(n) 复杂度)
  • 组件级更新(粒度较粗)

Vapor Mode 渲染器

Template Compiler (Vapor)
↓
Dependency Analysis
↓
Fine-grained Binding
↓
Direct DOM Updates

特点:

  • 编译时依赖分析
  • 细粒度绑定(表达式级别)
  • 原生 DOM 操作(无 diff)

对比总结

维度 普通 Vue 模式 Vapor Mode
渲染机制 Virtual DOM diff 直接 DOM 操作
依赖追踪 运行时 Proxy 编译时静态分析
更新粒度 组件级 表达式级
运行时开销 高(diff + Proxy) 极低(仅执行更新逻辑)
编译时优化 有限 极致

性能对比:不是优化,是碾压

官方基准测试(10,000 个简单组件):

场景 普通 Vue 3.4 Vapor Mode 提升
初始渲染 125ms 32ms 3.9×
单个属性更新 8ms 0.8ms 10×
10% 组件更新 45ms 3ms 15×
50% 组件更新 220ms 12ms 18.3×
列表重排序 180ms 5ms 36×

为什么这么快?

1. 无 Virtual DOM diff

// 普通 Vue:每次更新都要 diff
function update() {
  const oldVNode = currentVNode;
  const newVNode = render(); // 重新生成 VNode 树
  const patches = diff(oldVNode, newVNode); // O(n) diff
  applyPatches(patches); // 应用补丁
}

// Vapor Mode:直接更新
function update() {
  textContent.value = newValue; // 直接修改 DOM 文本
}

2. 编译时依赖追踪

<template>
  <div>{{ count }}</div>
</template>
// 普通 Vue:运行时收集
const count = ref(0);
effect(() => {
  div.textContent = count.value; // 运行时追踪依赖
});

// Vapor Mode:编译时已知
const count = reactiveValue(0, (val) => {
  div.textContent = val; // 编译时生成更新逻辑
});

3. 细粒度更新

<template>
  <div>
    <p>Name: {{ user.name }}</p>
    <p>Age: {{ user.age }}</p>
    <p>Email: {{ user.email }}</p>
  </div>
</template>
// 普通 Vue:user 变化 → 整个组件重新渲染
watch(() => user.value, () => {
  render(); // 重渲染整个组件
});

// Vapor Mode:user.name 变化 → 只更新第一个 <p>
user.name.onUpdate((val) => {
  p1.textContent = val; // 只更新对应 DOM
});

user.age.onUpdate((val) => {
  p2.textContent = val;
});

user.email.onUpdate((val) => {
  p3.textContent = val;
});

更疯狂的是:完整生态兼容

Vapor Mode 不是重写所有 Vue,而是无缝集成:

1. Vue Router 兼容

<!-- app.vue -->
<script setup vapor>
import { RouterView } from 'vue-router';
</script>

<template>
  <RouterView /> <!-- Vapor 组件可以渲染普通组件 -->
</template>

2. Pinia 兼容

// stores/counter.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count++;
    }
  }
});
<!-- components/Counter.vue -->
<script setup vapor>
import { useCounterStore } from '@/stores/counter';

const store = useCounterStore();
</script>

<template>
  <p>{{ store.count }}</p>
  <button @click="store.increment">+1</button>
</template>

3. 渐进式采用

<!-- 混合使用:Vapor 组件 + 普通组件 -->
<script setup vapor>
import OrdinaryComponent from './OrdinaryComponent.vue';
</script>

<template>
  <OrdinaryComponent /> <!-- 普通组件在 Vapor 组件中正常工作 -->
</template>

现在能生产使用吗?

部分可用。

Vapor Mode 当前状态:

  • 核心功能稳定(Vue 3.5+)
  • 完整生态兼容
  • TypeScript 支持
  • 部分指令仍在完善(v-for、v-if 复杂场景)
  • 调试工具仍在改进

建议采用场景:

  • 性能敏感型应用(高频更新列表)
  • 移动端应用(低性能设备)
  • 数据可视化(实时图表)
  • 简单 CRUD 应用(收益不明显)

总结一句话

如果说:

  • Vue 2 用 Virtual DOM 解决了 "跨浏览器兼容性"
  • Vue 3 用 Composition API 解决了 "代码复用性"

那 Vapor Mode 正在解决 "极致性能"

它可能不会明天取代所有 Vue 模式(渐进升级策略),

但它已经说明了一件事:

Vue 的未来,不止是框架升级,而是渲染器升级。

如果你是:

  • Vue 深度使用者
  • 性能优化爱好者
  • 编译原理探索者
  • 或对前端性能有极致追求

这个技术值得关注。

官方资源:

扩展阅读:

  • SolidJS 的细粒度响应式系统
  • Svelte 的编译时优化
  • Qwik 的 Resumability 架构

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

昨天以前首页

HTML&CSS&JS:基于定位的实时天气卡片

作者 前端Hardy
2026年2月27日 13:51

这个 HTML 页面实现了一个基于用户地理位置的实时天气信息卡片,界面美观、交互流畅。页面加载自动定位→请求天气数据→渲染卡片,支持手动刷新,全流程有加载 / 错误状态提示,动效过渡自然。赶快收藏学习吧。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

image

HTML&CSS

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>实时天气卡片</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
        }

        .weather-card {
            background: rgba(255, 255, 255, 0.95);
            border-radius: 20px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
            padding: 24px;
            max-width: 280px;
            width: 100%;
            backdrop-filter: blur(10px);
            transition: transform 0.3s ease, box-shadow 0.3s ease;
        }

        .weather-card:hover {
            transform: translateY(-3px);
            box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
        }

        .location {
            text-align: center;
            margin-bottom: 16px;
        }

        .location-icon {
            font-size: 24px;
            margin-bottom: 6px;
        }

        .city-name {
            font-size: 20px;
            font-weight: 700;
            color: #333;
            margin-bottom: 4px;
        }

        .current-time {
            font-size: 12px;
            color: #888;
            font-weight: 500;
        }

        .weather-main {
            text-align: center;
            margin: 20px 0;
        }

        .weather-icon {
            font-size: 56px;
            margin-bottom: 8px;
        }

        .temperature {
            font-size: 42px;
            font-weight: 300;
            color: #333;
            line-height: 1;
        }

        .temperature span {
            font-size: 22px;
            vertical-align: top;
        }

        .weather-description {
            font-size: 16px;
            color: #666;
            margin-top: 6px;
            text-transform: capitalize;
            margin-right: 20px;
        }

        .weather-details {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 12px;
            margin-top: 20px;
            padding-top: 16px;
            border-top: 1px solid #f0f0f0;
        }

        .detail-item {
            text-align: center;
            padding: 8px 6px;
            background: #f8f9fa;
            border-radius: 10px;
            transition: background 0.3s ease;
        }

        .detail-item:hover {
            background: #e9ecef;
        }

        .detail-icon {
            font-size: 18px;
            margin-bottom: 4px;
        }

        .detail-value {
            font-size: 14px;
            font-weight: 600;
            color: #333;
            margin-bottom: 2px;
        }

        .detail-label {
            font-size: 10px;
            color: #999;
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        .loading {
            text-align: center;
            color: #666;
            font-size: 14px;
        }

        .error {
            background: #fee;
            color: #c33;
            padding: 12px;
            border-radius: 10px;
            text-align: center;
            margin-top: 16px;
            font-size: 14px;
        }

        .refresh-btn {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            padding: 10px 24px;
            border-radius: 20px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            margin-top: 16px;
            width: 100%;
            transition: transform 0.2s ease, box-shadow 0.2s ease;
        }

        .refresh-btn:hover {
            transform: scale(1.02);
            box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
        }

        .refresh-btn:active {
            transform: scale(0.98);
        }
    </style>
</head>
<body>
    <div class="weather-card" id="weatherCard">
        <div class="location">
            <div class="location-icon">📍</div>
            <div class="city-name" id="cityName">获取位置中...</div>
            <div class="current-time" id="currentTime">--:--:--</div>
        </div>

        <div id="weatherContent">
            <div class="loading">正在获取天气信息...</div>
        </div>
    </div>

    <script>
        // 天气图标映射
        const weatherIcons = {
            '01d': '☀️', '01n': '🌙',
            '02d': '⛅', '02n': '☁️',
            '03d': '☁️', '03n': '☁️',
            '04d': '☁️', '04n': '☁️',
            '09d': '🌧️', '09n': '🌧️',
            '10d': '🌦️', '10n': '🌧️',
            '11d': '⛈️', '11n': '⛈️',
            '13d': '❄️', '13n': '❄️',
            '50d': '🌫️', '50n': '🌫️'
        };

        // 更新时间
        function updateTime() {
            const now = new Date();
            const options = {
                year: 'numeric',
                month: 'long',
                day: 'numeric',
                weekday: 'long',
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                hour12: false
            };
            document.getElementById('currentTime').textContent = now.toLocaleDateString('zh-CN', options);
        }

        // 通过经纬度获取中文城市名称
        async function getChineseCityName(latitude, longitude) {
            try {
                // 使用免费的反向地理编码 API
                const response = await fetch(
                    `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${latitude}&longitude=${longitude}&localityLanguage=zh`
                );
                const data = await response.json();

                // 优先使用市级名称,如果没有则使用区级名称
                if (data.city) {
                    return data.city;
                } else if (data.locality) {
                    return data.locality;
                } else if (data.principalSubdivision) {
                    return data.principalSubdivision;
                }
                return null;
            } catch (error) {
                console.error('获取中文城市名称失败:', error);
                return null;
            }
        }

        // 获取位置和天气
        async function getWeather() {
            const weatherContent = document.getElementById('weatherContent');
            const cityName = document.getElementById('cityName');

            if (!navigator.geolocation) {
                weatherContent.innerHTML = '<div class="error">您的浏览器不支持地理位置定位</div>';
                return;
            }

            try {
                // 获取地理位置
                const position = await new Promise((resolve, reject) => {
                    navigator.geolocation.getCurrentPosition(resolve, reject, {
                        enableHighAccuracy: true,
                        timeout: 10000,
                        maximumAge: 0
                    });
                });

                const { latitude, longitude } = position.coords;

                // 获取中文城市名称
                const chineseCity = await getChineseCityName(latitude, longitude);
                if (chineseCity) {
                    cityName.textContent = chineseCity;
                } else {
                    cityName.textContent = '获取位置中...';
                }

                // 调用 OpenWeatherMap API(使用免费的 API key,实际使用时需要替换)
                const apiKey = '4d8fb5b93d4af21d66a2948710284366'; // 这是一个公开的 demo key
                const response = await fetch(
                    `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=metric&lang=zh_cn`
                );

                if (!response.ok) {
                    throw new Error('获取天气信息失败');
                }

                const data = await response.json();

                // 显示天气信息(传入中文城市名称)
                displayWeather(data, chineseCity);

            } catch (error) {
                console.error('Error:', error);
                weatherContent.innerHTML = `
                    <div class="error">
                        ${error.message || '无法获取天气信息,请检查网络连接'}
                    </div>
                    <button class="refresh-btn" onclick="getWeather()">重试</button>
                `;
            }
        }

        // 显示天气信息
        function displayWeather(data, chineseCityName) {
            const weatherContent = document.getElementById('weatherContent');
            const cityName = document.getElementById('cityName');

            // 更新城市名称(优先使用中文城市名称)
            if (chineseCityName) {
                cityName.textContent = chineseCityName;
            } else if (data.name) {
                cityName.textContent = data.name;
            } else {
                cityName.textContent = '当前位置';
            }

            const iconCode = data.weather[0].icon;
            const icon = weatherIcons[iconCode] || '🌤️';

            weatherContent.innerHTML = `
                <div class="weather-main">
                    <div class="weather-icon">${icon}</div>
                    <div class="temperature">
                        ${Math.round(data.main.temp)}<span>°C</span>
                    </div>
                    <div class="weather-description">
                        ${data.weather[0].description || '未知天气'}
                    </div>
                </div>

                <div class="weather-details">
                    <div class="detail-item">
                        <div class="detail-icon">💧</div>
                        <div class="detail-value">${data.main.humidity}%</div>
                        <div class="detail-label">湿度</div>
                    </div>
                    <div class="detail-item">
                        <div class="detail-icon">💨</div>
                        <div class="detail-value">${Math.round(data.wind.speed)} m/s</div>
                        <div class="detail-label">风速</div>
                    </div>
                    <div class="detail-item">
                        <div class="detail-icon">🌡️</div>
                        <div class="detail-value">${Math.round(data.main.feels_like)}°C</div>
                        <div class="detail-label">体感</div>
                    </div>
                </div>

                <button class="refresh-btn" onclick="getWeather()">🔄 刷新天气</button>
            `;
        }

        // 初始化
        document.addEventListener('DOMContentLoaded', () => {
            updateTime();
            setInterval(updateTime, 1000); // 每秒更新时间
            getWeather(); // 获取天气
        });
    </script>
</body>
</html>

HTML

  • div weather-card weatherCard:容器标签。天气卡片核心容器。毛玻璃效果 + 圆角 + 阴影,hover 时有上浮动效
  • div location:容器标签。位置 / 时间展示区。包含定位图标、城市名称、当前时间
  • div weatherContent:容器标签。天气内容动态展示区 初始显示「加载中」,后续通过 JS 替换为天气数据
  • div loading:容器标签。加载状态提示 初始显示「正在获取天气信息...」
  • div error:容器标签。错误提示容器 定位 / 接口失败时显示错误信息
  • button refresh-btn:按钮标签。刷新天气按钮。点击触发重新获取天气数据,有 hover/active 动效
  • script:脚本标签。内嵌 JavaScript。实现定位、接口请求、数据渲染、时间更新等动态逻辑

CSS

1. 全局重置与基础布局

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box; /* 统一盒模型,避免 padding 撑大元素 */
}
body {
  min-height: 100vh; /* 占满屏幕高度 */
  display: flex;
  justify-content: center;
  align-items: center; /* 卡片垂直水平居中 */
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 渐变背景 */
  font-family: 'Segoe UI', sans-serif; /* 现代无衬线字体 */
  padding: 20px; /* 移动端留白,避免卡片贴边 */
}

核心:全局重置默认边距,弹性布局实现卡片居中,渐变背景提升视觉质感。

2. 天气卡片核心样式

.weather-card {
  background: rgba(255, 255, 255, 0.95); /* 半透明白色 */
  border-radius: 20px; /* 大圆角提升现代感 */
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); /* 柔和阴影 */
  padding: 24px;
  max-width: 280px; /* 固定最大宽度,适配移动端 */
  width: 100%;
  backdrop-filter: blur(10px); /* 毛玻璃核心效果 */
  transition: transform 0.3s ease, box-shadow 0.3s ease; /* 过渡动效 */
}
.weather-card:hover {
  transform: translateY(-3px); /* 鼠标悬浮上浮 */
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); /* 阴影放大 */
}

核心:backdrop-filter: blur(10px) 实现毛玻璃效果,hover 上浮 + 阴影增强交互反馈,固定最大宽度适配移动端。

3. 天气数据布局样式

/* 天气主信息区(温度/图标/描述) */
.weather-main {
  text-align: center;
  margin: 20px 0;
}
.temperature {
  font-size: 42px;
  font-weight: 300; /* 轻量级字体,更现代 */
  line-height: 1; /* 消除行高冗余 */
}
.temperature span {
  font-size: 22px;
  vertical-align: top; /* 摄氏度符号上对齐 */
}

/* 天气详情网格(湿度/风速/体感) */
.weather-details {
  display: grid;
  grid-template-columns: repeat(3, 1fr); /* 三等分网格 */
  gap: 12px;
  border-top: 1px solid #f0f0f0; /* 分隔线 */
  padding-top: 16px;
}
.detail-item {
  text-align: center;
  background: #f8f9fa; /* 浅灰背景 */
  border-radius: 10px;
  padding: 8px 6px;
  transition: background 0.3s ease;
}
.detail-item:hover {
  background: #e9ecef; /* hover 加深背景 */
}

核心:网格布局实现「湿度 / 风速 / 体感」三等分展示,轻量级字体 + 对齐优化提升温度显示的视觉层次,细节项 hover 背景变化增强交互。

4. 按钮与状态样式

/* 刷新按钮 */
.refresh-btn {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 与页面背景呼应 */
  color: white;
  border: none;
  padding: 10px 24px;
  border-radius: 20px; /* 胶囊按钮 */
  width: 100%; /* 全屏宽按钮,适配移动端点击 */
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.refresh-btn:hover {
  transform: scale(1.02); /* 轻微放大 */
  box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); /* 发光阴影 */
}
.refresh-btn:active {
  transform: scale(0.98); /* 点击下压 */
}

/* 加载/错误状态 */
.loading {
  text-align: center;
  color: #666;
}
.error {
  background: #fee; /* 浅红背景 */
  color: #c33; /* 深红文字 */
  padding: 12px;
  border-radius: 10px;
  text-align: center;
}

核心:按钮渐变背景与页面呼应,hover 放大 + 发光阴影,active 下压模拟物理按钮反馈;错误状态用红系配色提示,加载状态居中显示,视觉层级清晰。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

面试官:JS数组的常用方法有哪些?这篇总结让你面试稳了!

作者 前端Hardy
2026年2月27日 18:03

面试官往往会这么问:“JS 数组的常用方法有哪些?”然后追问:“哪些会改变原数组?哪些不会?”或“能举一个实际使用场景吗?”因此回答不仅要列出方法,还要讲清楚分类、返回值、是否改变原数组、典型用法与坑。

方法分类与速查

操作方法:增、删、改、查

排序方法:reverse、sort

转换方法:join

迭代方法:forEach、map、filter、some、every、find(包含 find、findIndex 等)

一、操作方法(增删改查)

  • push():末尾追加,返回新长度;改原数组
  • unshift():开头插入,返回新长度;改原数组
  • splice(start, 0, ...items):指定位置插入,返回空数组;改原数组
  • concat(...items):合并并返回新数组,不改原数组
let colors = ["red", "green"];
colors.push("blue"); // 3; colors => ["red","green","blue"]
colors.unshift("yellow"); // 4; colors => ["yellow","red","green","blue"]
colors.splice(1, 0, "purple"); // [] => 原数组被修改
let colors2 = colors.concat("black", ["white"]); // 新数组

  • pop():末尾删除,返回被删项;改原数组
  • shift():首项删除,返回被删项;改原数组
  • splice(start, deleteCount):删除指定位置项,返回被删数组;改原数组
  • slice(start, end):拷贝子数组,返回新数组;不改原数组
let colors = ["red", "green", "blue"];
let last = colors.pop(); // "blue"; colors => ["red","green"]
let first = colors.shift(); // "red"; colors => ["green"]
let removed = colors.splice(0, 1); // ["green"]; colors => []
let sub = colors.slice(1, 3); // 新数组,不改原数组

  • splice(start, deleteCount, ...items):删除并插入,返回被删数组;改原数组
let colors = ["red", "green", "blue"];
colors.splice(1, 1, "purple"); // ["green"]; colors => ["red", "purple", "blue"]

  • indexOf(item):返回索引,不存在返回 -1
  • includes(item):返回 boolean
  • find(callback):返回第一个满足条件的元素
  • findIndex(callback):返回第一个满足条件的索引
let arr = [1, 2, 3, 4];
arr.indexOf(3); // 2
arr.includes(5); // false
let found = arr.find(x => x > 2); // 3
let foundIdx = arr.findIndex(x => x > 2); // 2

二、排序方法

reverse():反转数组,改原数组,返回引用 sort(compareFn):排序,改原数组,返回引用

let nums = [3, 1, 4, 1, 5];
nums.reverse(); // [5,1,4,1,3]; 改原数组
nums.sort((a,b)=>a-b); // [1,1,3,4,5]; 改原数组

注意:不传 compareFn 时,按 UTF-16 代码单元排序,对数字排序可能不符合预期,务必传比较函数。

三、转换方法

join(separator):用指定分隔符拼接成字符串,不改原数组

let colors = ["red", "green", "blue"];
colors.join(","); // "red,green,blue"
colors.join("||"); // "red||green||blue"

四、迭代方法(不改原数组)

  • forEach(callback):遍历,无返回值
  • map(callback):映射,返回新数组
  • filter(callback):过滤,返回新数组
  • some(callback):任一满足则 true
  • every(callback):全部满足则 true
  • find(callback):返回第一个满足元素
  • findIndex(callback):返回第一个满足索引
  • reduce/reduceRight:归约,常用于累加、组合
let nums = [1, 2, 3, 4];
let doubled = nums.map(x => x * 2); // [2,4,6,8]
let evens = nums.filter(x => x % 2 === 0); // [2,4]
let has = nums.some(x => x > 3); // true
let all = nums.every(x => x > 0); // true
let first = nums.find(x => x > 2); // 3
let sum = nums.reduce((a,b)=>a+b,0); // 10

五、是否改变原数组一览

改变原数组:push、pop、shift、unshift、splice、sort、reverse

不改变原数组:concat、slice、join、forEach、map、filter、some、every、find、findIndex、reduce、reduceRight、flatMap、flat、indexOf、includes

六、典型面试追问与场景举例

问:如何在不改变原数组的前提下在末尾追加一项?

答:使用 concat 或展开运算符 [...arr, item]。 问:如何移除数组中所有 falsy 值?

答:arr.filter(Boolean)。 问:如何按某属性排序对象数组?

答:arr.sort((a,b)=>a.key.localeCompare(b.key))。 问:forEach 与 map 的区别?

答:forEach 无返回值,仅遍历;map 返回新数组,常用于转换。 问:splice 与 slice 的区别?

答:splice 会改变原数组并支持插入/删除;slice 不会改变原数组,仅拷贝子集。

七、常见坑与避坑建议

  • 直接用 sort() 对数字排序可能出错,务必传比较函数。
  • splice 的参数易混淆,牢记参数顺序与返回值。
  • 在需要保留原数组的场景,避免误用会改变原数组的方法。
  • 注意 map 等迭代方法不会提前终止,如需提前中断请用 some/every 或传统 for。

八、总结

JS 数组方法多且常用,记住“是否改变原数组”是高频考点。建议按“操作、排序、转换、迭代”四个维度掌握,并多在实际项目中用这些方法替代手动循环,代码会更简洁、易读。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

HTML&CSS:纯CSS实现随机转盘抽奖机——无JS,全靠现代CSS黑科技!

作者 前端Hardy
2026年2月27日 10:30

这个 HTML 页面实现了一个交互式转盘抽奖效果,使用了现代 CSS 的一些实验性特性 (如 random() 函数、@layer、sibling-index() 等),并结合 SVG 图标和渐变背景,营造出一个视觉吸引、功能完整的“幸运大转盘”界面。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS随机函数实现转盘效果</title>
    <style>
        @import url(https://fonts.bunny.net/css?family=jura:300,700);
        @layer base, notes, demo;

        @layer demo {
            :root {
                --items: 12;
                --spin-easing: cubic-bezier(0, 0.061, 0, 1.032);
                --slice-angle: calc(360deg / var(--items));
                --start-angle: calc(var(--slice-angle) / 2);

                --wheel-radius: min(40vw, 300px);
                --wheel-size: calc(var(--wheel-radius) * 2);
                --wheel-padding: 10%;
                --item-radius: calc(var(--wheel-radius) - var(--wheel-padding));

                --wheel-bg-1: oklch(0.80 0.16 30);
                --wheel-bg-2: oklch(0.74 0.16 140);
                --wheel-bg-3: oklch(0.80 0.16 240);
                --wheel-bg-4: oklch(0.74 0.16 320);

                --marker-bg-color: black;
                --button-text-color: white;
                --spin-duration: random(1s, 3s);

                --random-angle: random(1200deg, 4800deg, by var(--slice-angle));

                @supports not (rotate: random(1deg, 10deg)) {
                    --spin-duration: 2s;
                    --random-angle: 4800deg;
                }
            }


            .wrapper {
                position: relative;
                inset: 0;
                margin: auto;
                width: var(--wheel-size);
                aspect-ratio: 1;

                input[type=checkbox] {
                    position: absolute;
                    opacity: 0;
                    width: 1px;
                    height: 1px;
                    pointer-events: none;
                }

                &:has(input[type=checkbox]:checked) {
                    --spin-it: 1;
                    --btn-spin-scale: 0;
                    --btn-spin-event: none;
                    --btn-spin-trans-duration: var(--spin-duration);
                    --btn-reset-scale: 1;
                    --btn-reset-event: auto;
                    --btn-reset-trans-delay: var(--spin-duration);
                }

                .controls {
                    position: absolute;
                    z-index: 2;
                    inset: 0;
                    margin: auto;
                    width: min(100px, 10vw);
                    aspect-ratio: 1;
                    background: var(--marker-bg-color);
                    border-radius: 9in;
                    transition: scale 150ms ease-in-out;

                    &:has(:hover, :focus-visible) label {
                        scale: 1.2;
                        rotate: 20deg;
                    }

                    &::before {
                        content: '';
                        position: absolute;
                        top: 0;
                        left: 50%;
                        translate: -50% -50%;
                        width: 20%;
                        aspect-radio: 2/10;
                        background-color: transparent;
                        border: 2vw solid var(--marker-bg-color);
                        border-bottom-width: 4vw;
                        border-top: 0;
                        border-left-color: transparent;
                        border-right-color: transparent;
                        z-index: -1;
                    }

                    label {
                        cursor: pointer;
                        display: grid;
                        place-items: center;
                        width: 100%;
                        aspect-ratio: 1;
                        color: var(--button-text-color);
                        transition:
                            rotate 150ms ease-in-out,
                            scale 150ms ease-in-out;

                        svg {
                            grid-area: 1/1;
                            width: 50%;
                            height: 50%;
                            transition-property: scale;
                            transition-timing-function: ease-in-out;

                            &:first-child {
                                transition-duration: var(--btn-spin-trans-duration, 150ms);
                                scale: var(--btn-spin-scale, 1);
                                pointer-events: var(--btn-spin-event, auto);
                            }

                            &:last-child {
                                transition-duration: 150ms;
                                transition-delay: var(--btn-reset-trans-delay, 0ms);
                                scale: var(--btn-reset-scale, 0);
                                pointer-events: var(--btn-reset-event, none);
                            }
                        }
                    }


                }

                &:has(input[type=checkbox]:checked)>.wheel {
                    animation: --spin-wheel var(--spin-duration, 3s) var(--spin-easing, ease-in-out) forwards;
                }

                .wheel {
                    position: absolute;
                    inset: 0;
                    border-radius: 99vw;
                    border: 1px solid white;
                    user-select: none;
                    font-size: 24px;
                    font-weight: 600;
                    background: repeating-conic-gradient(from var(--start-angle),
                            var(--wheel-bg-1) 0deg var(--slice-angle),
                            var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle) * 2),
                            var(--wheel-bg-3) calc(var(--slice-angle) * 2) calc(var(--slice-angle) * 3),
                            var(--wheel-bg-4) calc(var(--slice-angle) * 3) calc(var(--slice-angle) * 4));

                    >span {
                        --i: sibling-index();

                        @supports not (sibling-index(0)) {
                            &:nth-child(1) {
                                --i: 1;
                            }

                            &:nth-child(2) {
                                --i: 2;
                            }

                            &:nth-child(3) {
                                --i: 3;
                            }

                            &:nth-child(4) {
                                --i: 4;
                            }

                            &:nth-child(5) {
                                --i: 5;
                            }

                            &:nth-child(6) {
                                --i: 6;
                            }

                            &:nth-child(7) {
                                --i: 7;
                            }

                            &:nth-child(8) {
                                --i: 8;
                            }

                            &:nth-child(9) {
                                --i: 9;
                            }

                            &:nth-child(10) {
                                --i: 10;
                            }

                            &:nth-child(11) {
                                --i: 11;
                            }

                            &:nth-child(12) {
                                --i: 12;
                            }
                        }
                        position: absolute;
                        offset-path: circle(var(--item-radius) at 50% 50%);
                        offset-distance: calc(var(--i) / var(--items) * 100%);
                        offset-rotate: auto;
                    }
                }
            }

            @keyframes --spin-wheel {
                to {
                    rotate: var(--random-angle);
                }
            }
        }

        @layer notes {
            section.notes {
                margin: auto;
                width: min(80vw, 56ch);

                p {
                    text-wrap: pretty;
                }

                > :first-child {
                    color: red;
                    background: rgb(255, 100, 103);
                    padding: .5em;
                    color: white;

                    @supports (rotate: random(1deg, 10deg)) {
                        display: none;
                    }
                }
            }
        }

        @layer base {

            *,
            ::before,
            ::after {
                box-sizing: border-box;
            }

            :root {
                color-scheme: light dark;
                --bg-dark: rgb(21 21 21);
                --bg-light: rgb(248, 244, 238);
                --txt-light: rgb(10, 10, 10);
                --txt-dark: rgb(245, 245, 245);
                --line-light: rgba(0 0 0 / .75);
                --line-dark: rgba(255 255 255 / .25);
                --clr-bg: light-dark(var(--bg-light), var(--bg-dark));
                --clr-txt: light-dark(var(--txt-light), var(--txt-dark));
                --clr-lines: light-dark(var(--line-light), var(--line-dark));
            }

            body {
                background-color: var(--clr-bg);
                color: var(--clr-txt);
                min-height: 100svh;
                margin: 0;
                padding: 2rem;
                font-family: "Jura", sans-serif;
                font-size: 1rem;
                line-height: 1.5;
                display: grid;
                place-content: center;
                gap: 2rem;
            }

            strong {
                font-weight: 700;
            }
        }
    </style>
</head>

<body>

    <section class="wrapper">
        <input type="checkbox" id="radio-spin">
        <div class="controls">
            <label for="radio-spin">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Spin the Wheel" title="Spin the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
                    <path d="M12 21c-3.314 0 -6 -2.462 -6 -5.5s2.686 -5.5 6 -5.5" />
                    <path d="M21 12c0 3.314 -2.462 6 -5.5 6s-5.5 -2.686 -5.5 -6" />
                    <path d="M12 14c3.314 0 6 -2.462 6 -5.5s-2.686 -5.5 -6 -5.5" />
                    <path d="M14 12c0 -3.314 -2.462 -6 -5.5 -6s-5.5 2.686 -5.5 6" />
                </svg>

                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Reset the Wheel" title="Reset the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M3.06 13a9 9 0 1 0 .49 -4.087" />
                    <path d="M3 4.001v5h5" />
                    <path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
                </svg>
            </label>
        </div>

        <div id="wheel" class="wheel">
            <span>乔丹</span>
            <span>詹姆斯</span>
            <span>布莱恩特</span>
            <span>约翰逊</span>
            <span>库里</span>
            <span>奥尼尔</span>
            <span>邓肯</span>
            <span>贾巴尔</span>
            <span>杜兰特</span>
            <span>哈登</span>
            <span>字母哥</span>
            <span>伦纳德</span>
        </div>
    </section>
</body>

</html>

HTML

  • section:转盘核心容器(语义化区块)相对定位,包含转盘所有子元素
  • input:转盘触发开关(核心交互控件) 视觉隐藏(opacity:0),通过「选中 / 未选中」触发动画
  • div controls:转盘中心按钮容器。绝对定位,层级高于转盘,包含点击触发的 label
  • label :绑定隐藏复选框,作为可点击按钮。点击该标签等价于点击复选框,触发状态切换
  • svg:显示「旋转」「重置」图标。两个 SVG 重叠,通过 CSS 控制显隐
  • div wheel:转盘本体。圆形布局,包含 12 个奖项文本
  • span:转盘奖项文本(12 个 NBA 球星名称) 每个 span 对应转盘一个分区,通过 CSS 定位到圆形轨道

CSS

1. 样式分层管理(@layer)

@layer base, notes, demo;
  • base:全局基础样式(盒模型、明暗色模式、页面布局),优先级最低;
  • notes:兼容提示文本样式,优先级中等;
  • demo:转盘核心样式(尺寸、动画、交互),优先级最高;

作用:按层级管理样式,避免样式冲突,便于维护。

2. 核心变量定义(:root)

:root {
  --items: 12; /* 转盘分区数量 */
  --slice-angle: calc(360deg / var(--items)); /* 每个分区角度(30°) */
  --wheel-radius: min(40vw, 300px); /* 转盘半径(自适应,最大300px) */
  --spin-duration: random(1s, 3s); /* 随机旋转时长(1-3秒) */
  --random-angle: random(1200deg, 4800deg, by var(--slice-angle)); /* 随机旋转角度(步长30°) */
  /* 浏览器兼容降级:不支持random()则固定值 */
  @supports not (rotate: random(1deg, 10deg)) {
    --spin-duration: 2s;
    --random-angle: 4800deg;
  }
}

核心:用变量统一管理转盘尺寸、角度、动画参数,random() 实现「随机旋转」核心效果,同时做浏览器兼容降级。

3. 转盘交互触发逻辑

/* 监听复选框选中状态,更新变量控制图标/动画 */
.wrapper:has(input[type=checkbox]:checked) {
  --btn-spin-scale: 0; /* 隐藏旋转图标 */
  --btn-reset-scale: 1; /* 显示重置图标 */
}
/* 选中时触发转盘旋转动画 */
.wrapper:has(input[type=checkbox]:checked)>.wheel {
  animation: --spin-wheel var(--spin-duration) var(--spin-easing) forwards;
}
/* 旋转动画:转到随机角度后保持状态 */
@keyframes --spin-wheel {
  to { rotate: var(--random-angle); }
}

核心:通过 :has() 伪类监听复选框状态,触发转盘动画,forwards 确保动画结束后不回弹。

4. 转盘视觉与布局

.wheel {
  border-radius: 99vw; /* 圆形转盘 */
  /* 四色循环锥形渐变,实现转盘分区背景 */
  background: repeating-conic-gradient(from var(--start-angle),
    var(--wheel-bg-1) 0deg var(--slice-angle),
    var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle)*2),
    var(--wheel-bg-3) calc(var(--slice-angle)*2) calc(var(--slice-angle)*3),
    var(--wheel-bg-4) calc(var(--slice-angle)*3) calc(var(--slice-angle)*4));
  >span {
    offset-path: circle(var(--item-radius) at 50% 50%); /* 圆形轨道 */
    offset-distance: calc(var(--i) / var(--items) * 100%); /* 按索引定位到对应分区 */
  }
}

核心:repeating-conic-gradient 实现转盘彩色分区,offset-path 让奖项文本沿圆形轨道均匀分布。

5. 中心按钮交互

.controls {
  position: absolute;
  z-index: 2; /* 层级高于转盘,确保可点击 */
  border-radius: 9in; /* 圆形按钮 */
  &::before { /* 转盘顶部指针 */
    content: '';
    border: 2vw solid var(--marker-bg-color);
    border-bottom-width: 4vw;
    border-top/left/right-color: transparent; /* 三角指针形状 */
  }
  label:hover { scale: 1.2; rotate: 20deg; } /* 鼠标悬浮时图标放大旋转 */
}

核心:伪元素实现转盘「指针」,hover 动效提升交互反馈,两个 SVG 图标通过 scale 控制显隐。

6. 全局基础样式

@layer base {
  :root {
    color-scheme: light dark; /* 适配系统明暗色模式 */
    --clr-bg: light-dark(var(--bg-light), var(--bg-dark)); /* 自动切换背景色 */
  }
  body {
    min-height: 100svh; /* 适配移动端安全区 */
    display: grid;
    place-content: center; /* 垂直水平居中 */
  }
}

核心:light-dark() 自动适配系统明暗模式,100svh 避免移动端地址栏遮挡,网格布局实现内容居中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

HTML&CSS:高颜值产品卡片页面,支持主题切换

作者 前端Hardy
2026年2月26日 16:01

这是一个产品卡片页面,无 JS 实现图片切换主题切换、全设备响应式适配,兼顾美观与实用性,值得大家学习。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>现代极简卧室套装</title>
    <style>
        body {
            font-family: "Roboto Serif", serif;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 0;
            min-height: 100vh;
            background-color: #f6f1ea;
            background-image: radial-gradient(circle at 15% 20%, rgba(232, 186, 142, 0.35) 0%, rgba(232, 186, 142, 0.18) 20%, rgba(232, 186, 142, 0.08) 35%, transparent 60%), radial-gradient(circle at 85% 75%, rgba(220, 160, 110, 0.35) 0%, rgba(220, 160, 110, 0.15) 25%, transparent 55%), radial-gradient(circle at 60% 10%, rgba(255, 210, 170, 0.25) 0%, transparent 50%);
            background-repeat: no-repeat;
            background-attachment: fixed;
        }

        body::after {
            content: "";
            position: fixed;
            inset: 0;
            pointer-events: none;
            z-index: 10;
            mix-blend-mode: saturation;
            background: radial-gradient(circle at 20% 25%, rgba(255, 200, 150, 0.35), rgba(255, 200, 150, 0.15) 30%, transparent 60%), radial-gradient(circle at 80% 70%, rgba(255, 170, 110, 0.3), transparent 60%);
            filter: blur(80px);
        }

        .product-card {
            border-radius: 12rem;
            corner-shape: squircle;
            padding: 1rem 1.5rem 1rem 1rem;
            max-width: 800px;
            background: linear-gradient(145deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.55));
            backdrop-filter: blur(20px);
            border: 1px solid rgba(255, 255, 255, 0.4);
            box-shadow: 0 40px 80px rgba(206, 168, 132, 0.1), 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 120px rgba(198, 169, 126, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6), -5px -5px 10px 0 #fffce9;
        }

        @media (max-width: 768px) {
            .product-card {
                border-radius: 5rem;
            }
        }

        .product-card .card-content {
            display: flex;
            justify-content: center;
            gap: 32px;
        }

        @media (max-width: 768px) {
            .product-card .card-content {
                flex-direction: column;
            }
        }

        .product-card .image-container {
            position: relative;
            border-radius: 12rem;
            width: 45vw;
            max-width: 450px;
            corner-shape: squircle;
            aspect-ratio: 1/1;
            overflow: hidden;
        }

        @media (max-width: 768px) {
            .product-card .image-container {
                width: 100%;
                border-radius: 5rem;
            }
        }

        .product-card .info-container {
            display: flex;
            flex-direction: column;
            justify-content: center;
            color: #5f5a55;
        }

        .product-card .info-container .brand {
            display: block;
            padding-bottom: 3rem;
            font-size: 0.85rem;
        }

        .product-card .info-container h1 {
            line-height: 100%;
            font-size: 2rem;
            font-weight: 600;
            letter-spacing: -0.1rem;
            margin: 0;
            padding: 0;
            transform: scaleY(2);
        }

        .product-card .info-container .price {
            font-size: 1.3rem;
            font-weight: 400;
            margin: 0;
            padding: 2.5rem 0 0;
            color: #c89b5e;
        }

        .product-card .info-container .description {
            max-width: 280px;
            font-size: 0.85rem;
            font-weight: 200;
            line-height: 150%;
            margin: 0;
            padding: 1rem 0;
        }

        .product-card .info-container .btn-primary {
            margin-top: 1rem;
            padding: 1rem 2rem;
            max-width: 200px;
            font-size: 1rem;
            letter-spacing: 1px;
            color: #ffffff;
            background: linear-gradient(145deg, #d8a45c, #b97a2f);
            border: none;
            border-radius: 40px;
            cursor: pointer;
            box-shadow: 0 8px 20px rgba(201, 155, 94, 0.35), 0 0 25px rgba(201, 155, 94, 0.15), inset 0 2px 6px rgba(255, 255, 255, 0.3);
            transition: 0.3s ease;
        }

        @media (max-width: 768px) {
            .product-card .info-container .btn-primary {
                max-width: none;
            }
        }

        .product-card .info-container .btn-primary:hover {
            transform: translateY(-2px);
            filter: brightness(1.05);
            box-shadow: 0 10px 25px rgba(185, 122, 47, 0.45), inset 0 2px 6px rgba(255, 255, 255, 0.3);
        }

        .product-card .info-container .btn-primary:active {
            transform: translateY(1px);
            box-shadow: 0 5px 15px rgba(185, 122, 47, 0.3), inset 0 3px 6px rgba(0, 0, 0, 0.15);
        }

        .image {
            aspect-ratio: 1/1;
            width: 100%;
            background: url("https://assets.codepen.io/662051/Gemini_Generated_Image_j4wi73j4wi73j4wi.png") center;
            background-size: cover;
            transition: 0.4s ease;
        }

        .theme-switch__input:checked~.image {
            background-image: url("https://assets.codepen.io/662051/Gemini_Generated_Image_2q32sc2q32sc2q32.png");
        }

        .theme-switch {
            position: absolute;
            left: 50%;
            bottom: 20px;
            transform: translateX(-50%);
            cursor: pointer;
        }

        .theme-switch__input {
            display: none;
        }

        .theme-switch__container {
            position: relative;
            width: 60px;
            height: 32px;
            padding: 5px;
            background-color: #e2e2e2;
            border-radius: 32px;
            display: flex;
            align-items: center;
            box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.1);
            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        .theme-switch__circle {
            width: 30px;
            height: 30px;
            background-color: #333;
            border-radius: 50%;
            z-index: 2;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        .theme-switch__icons {
            position: absolute;
            width: 100%;
            box-sizing: border-box;
            z-index: 2;
        }

        .theme-switch .icon {
            width: 18px;
            height: 18px;
            transition: opacity 0.3s ease;
        }

        .theme-switch .icon--sun {
            opacity: 1;
            color: #fff;
            transform: translate(6px, 2px);
        }

        .theme-switch .icon--moon {
            opacity: 0;
            color: #333;
            transform: translate(15px, 2px);
        }

        .theme-switch__input:checked+.image+.theme-switch .theme-switch__container {
            background-color: #222;
        }

        .theme-switch__input:checked+.image+.theme-switch .theme-switch__circle {
            transform: translateX(30px);
            background-color: #fff;
        }

        .theme-switch__input:checked+.image+.theme-switch .icon--sun {
            opacity: 0;
        }

        .theme-switch__input:checked+.image+.theme-switch .icon--moon {
            opacity: 1;
            color: #333;
        }

        #dev {
            font-family: "Montserrat", sans-serif;
            position: fixed;
            top: 10px;
            left: 10px;
            padding: 1em;
            font-size: 14px;
            color: #333;
            background-color: white;
            border-radius: 25px;
            cursor: pointer;
        }

        #dev a {
            text-decoration: none;
            font-weight: bold;
            color: #333;
            transition: all 0.4s ease;
        }

        #dev a:hover {
            color: #ef5350;
            text-decoration: underline;
        }

        #dev span {
            display: inline-block;
            color: pink;
            transition: all 0.4s ease;
        }

        #dev span:hover {
            transform: scale(1.2);
        }
    </style>
</head>

<body>
    <div class="product-card">
        <div class="card-content">
            <div class="image-container">
                <input type="checkbox" id="theme-toggle" class="theme-switch__input">
                <div class="image"></div>
                <label for="theme-toggle" class="theme-switch">
                    <div class="theme-switch__container">
                        <div class="theme-switch__circle"></div>
                        <div class="theme-switch__icons">
                            <svg class="icon icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
                                fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                stroke-linejoin="round">
                                <circle cx="12" cy="12" r="5"></circle>
                                <line x1="12" y1="1" x2="12" y2="3"></line>
                                <line x1="12" y1="21" x2="12" y2="23"></line>
                                <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
                                <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
                                <line x1="1" y1="12" x2="3" y2="12"></line>
                                <line x1="21" y1="12" x2="23" y2="12"></line>
                                <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
                                <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
                            </svg>
                            <svg class="icon icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
                                fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                stroke-linejoin="round">
                                <path d="M21 12.79A9 9 0 1 1 11.21 3
                       7 7 0 0 0 21 12.79z"></path>
                            </svg>
                        </div>
                    </div>
                </label>
            </div>
            <div class="info-container">
                <header>
                    <span class="brand">HOLIME</span>
                    <h1 class="title">现代极简<br />卧室套装</h1>
                    <p class="price">$50.00 <span></span></p>
                    <p class="description">
                        打造宁静休憩空间,这套现代极简卧室套装融合永恒设计与舒适体验,为您的家注入优雅与安宁。
                    </p>
                </header>
                <button class="btn-primary">加入购物车</button>
            </div>
        </div>
    </div>
</body>

</html>

HTML

  • prouct-card:产品卡片容器。核心视觉容器,用毛玻璃效果、圆角、渐变背景打造高级感
  • card-content:卡片内容区。弹性布局,分「图片区 + 信息区」,移动端自动改为垂直布局
  • image-container:产品图片容器。固定宽高比(1:1);② 包含切换图片的复选框、图片、切换按钮;圆角适配移动端
  • info-container:产品信息区。垂直布局,包含品牌、标题、价格、描述、加入购物车按钮
  • theme-toggle:图片切换复选框。隐藏的核心交互控件,通过「选中 / 未选中」切换产品图片
  • theme-switch:切换按钮(夜间/白天)。视觉化的切换控件,绑定到隐藏的复选框,实现交互反馈

CSS

全局样式 & 背景

body {
  font-family: "Roboto Serif", serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  /* 背景层:暖色调径向渐变,营造温馨的卧室氛围 */
  background-color: #f6f1ea;
  background-image: radial-gradient(...), radial-gradient(...), radial-gradient(...);
  background-repeat: no-repeat;
  background-attachment: fixed;
}
body::after {
  /* 叠加模糊渐变层,增强层次感,mix-blend-mode 提升饱和度 */
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none; /* 不遮挡交互 */
  mix-blend-mode: saturation;
  background: radial-gradient(...);
  filter: blur(80px);
}

核心:flex 实现页面居中 + 多层径向渐变背景 + 伪元素叠加模糊层,打造「柔和、有呼吸感」的视觉基底。

产品卡片核心样式

.product-card {
  border-radius: 12rem; /* 超大圆角,接近胶囊/圆角矩形 */
  corner-shape: squircle; /* 松鼠角(非标准但现代浏览器支持,更圆润的圆角) */
  padding: 1rem 1.5rem 1rem 1rem;
  max-width: 800px;
  /* 毛玻璃核心:半透明背景 + backdrop-filter */
  background: linear-gradient(145deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.55));
  backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.4);
}

核心:backdrop-filter: blur(20px) 实现毛玻璃效果,linear-gradient 半透明白色渐变增强通透感,超大圆角提升现代感。

响应式布局

@media (max-width: 768px) {
  .product-card { border-radius: 5rem; }
  .product-card .card-content { flex-direction: column; }
  .product-card .image-container { width: 100%; border-radius: 5rem; }
  .product-card .info-container .btn-primary { max-width: none; }
}

核心:屏幕宽度 ≤768px(移动端)时,① 缩小卡片 / 图片圆角;② 图片 + 信息从「横向排列」改为「垂直排列」;③ 按钮宽度自适应,适配移动端交互。

图片切换交互

/* 默认图片 */
.image {
  aspect-ratio: 1/1;
  width: 100%;
  background: url("xxx.png") center;
  background-size: cover;
  transition: 0.4s ease;
}
/* 复选框选中时切换图片 */
.theme-switch__input:checked~.image {
  background-image: url("yyy.png");
}

核心:利用 CSS 相邻兄弟选择器 ~,监听复选框 :checked 状态,切换背景图片,配合 transition 实现平滑过渡。

开关按钮 & 按钮动效

/* 开关按钮样式切换 */
.theme-switch__input:checked+.image+.theme-switch .theme-switch__container {
  background-color: #222;
}
.theme-switch__input:checked+.image+.theme-switch .theme-switch__circle {
  transform: translateX(30px);
  background-color: #fff;
}
/* 加入购物车按钮 hover/active 动效 */
.btn-primary:hover {
  transform: translateY(-2px);
  filter: brightness(1.05);
  box-shadow: ...;
}
.btn-primary:active {
  transform: translateY(1px);
  box-shadow: ...;
}

核心:① 开关按钮的「白天 / 夜间」图标显隐、背景色、滑块位置随复选框状态变化;② 按钮 hover 时上移 + 亮度提升,active 时下移 + 阴影缩小,模拟「按压反馈」。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌
❌