普通视图

发现新文章,点击刷新页面。
昨天以前首页

惊艳同事的 Canvas 事件流程图,这篇教会你

作者 前端Hardy
2025年9月12日 14:49

HTML5 Canvas 绘制一个高颜值、支持交互的事件流程图,展示从起飞降落的完整飞行事件时间线,包含播放 / 暂停 / 重置动画控制功能。


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

演示效果

演示效果

演示效果

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>Canvas事件流程图</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }

        body {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            padding: 20px;
            color: #333;
        }

        .container {
            width: 100%;
            max-width: 1200px;
            background-color: rgba(255, 255, 255, 0.95);
            border-radius: 15px;
            box-shadow: 0 15px 30px rgba(0, 0, 0, 0.2);
            overflow: hidden;
            padding: 20px;
        }

        header {
            text-align: center;
            padding: 20px 0;
            margin-bottom: 20px;
            border-bottom: 1px solid #eee;
        }

        h1 {
            font-size: 2.5rem;
            color: #4a4a4a;
            margin-bottom: 10px;
            background: linear-gradient(to right, #667eea, #764ba2);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }

        .description {
            color: #666;
            font-size: 1.1rem;
            max-width: 800px;
            margin: 0 auto;
            line-height: 1.6;
        }

        .canvas-container {
            position: relative;
            width: 100%;
            height: 500px;
            margin: 20px 0;
            border-radius: 10px;
            overflow: hidden;
            background-color: #f8f9fa;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
        }

        canvas {
            display: block;
            width: 100%;
            height: 100%;
        }

        .controls {
            display: flex;
            justify-content: center;
            gap: 15px;
            margin: 20px 0;
            flex-wrap: wrap;
        }

        button {
            padding: 12px 25px;
            border: none;
            border-radius: 50px;
            background: linear-gradient(to right, #667eea, #764ba2);
            color: white;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s ease;
            box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
        }

        button:hover {
            transform: translateY(-3px);
            box-shadow: 0 7px 15px rgba(0, 0, 0, 0.2);
        }

        button:active {
            transform: translateY(0);
        }

        footer {
            text-align: center;
            margin-top: 30px;
            color: rgba(255, 255, 255, 0.8);
            font-size: 0.9rem;
        }

        @media (max-width: 768px) {
            h1 {
                font-size: 2rem;
            }

            .canvas-container {
                height: 400px;
            }

            .event-list {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>

<body>
    <div class="container">
        <header>
            <h1>事件流程图</h1>
            <p class="description">使用Canvas实现的高颜值事件流程图,展示从起飞到降落的完整飞行过程,支持交互和动画效果。</p>
        </header>

        <div class="canvas-container">
            <canvas id="flowchartCanvas"></canvas>
        </div>

        <div class="controls">
            <button id="playBtn">播放动画</button>
            <button id="pauseBtn">暂停动画</button>
            <button id="resetBtn">重置视图</button>
        </div>
    </div>

    <footer>
        <p>© 2025 事件流程图 - 使用HTML5 Canvas实现</p>
    </footer>

    <script>
        document.addEventListener('DOMContentLoaded', function () {
            const canvas = document.getElementById('flowchartCanvas');
            const ctx = canvas.getContext('2d');
            const playBtn = document.getElementById('playBtn');
            const pauseBtn = document.getElementById('pauseBtn');
            const resetBtn = document.getElementById('resetBtn');

            // 设置Canvas尺寸
            function resizeCanvas() {
                canvas.width = canvas.offsetWidth;
                canvas.height = canvas.offsetHeight;
                drawFlowchart();
            }

            // 初始化事件数据
            const events = [
                { time: '2025-09-12 11:40', event: '起飞', position: 0.05 },
                { time: '2025-09-12 11:42', event: '转弯', position: 0.15 },
                { time: '2025-09-12 11:42', event: '发现问题', position: 0.25 },
                { time: '2025-09-12 11:51', event: '返航', position: 0.35 },
                { time: '2025-09-12 11:53', event: '飞行', position: 0.45 },
                { time: '2025-09-12 11:55', event: '转弯', position: 0.55 },
                { time: '2025-09-12 12:00', event: '飞行', position: 0.65 },
                { time: '2025-09-12 12:30', event: '降落', position: 0.75 },
                { time: '2025-09-12 12:30', event: '降落', position: 0.85 },
                { time: '2025-09-12 13:41', event: '返航', position: 0.95 }
            ];

            // 动画状态
            let animationProgress = 0;
            let animationId = null;
            let isAnimating = false;

            // 绘制流程图
            function drawFlowchart() {
                const width = canvas.width;
                const height = canvas.height;
                const timelineY = height / 2;
                const nodeRadius = 12;

                // 清除画布
                ctx.clearRect(0, 0, width, height);

                // 绘制时间轴
                ctx.beginPath();
                ctx.moveTo(width * 0.05, timelineY);
                ctx.lineTo(width * 0.95, timelineY);
                ctx.strokeStyle = '#667eea';
                ctx.lineWidth = 3;
                ctx.stroke();

                // 绘制箭头
                ctx.beginPath();
                ctx.moveTo(width * 0.95, timelineY);
                ctx.lineTo(width * 0.93, timelineY - 8);
                ctx.lineTo(width * 0.93, timelineY + 8);
                ctx.closePath();
                ctx.fillStyle = '#667eea';
                ctx.fill();

                // 绘制事件节点和标签
                events.forEach((ev, index) => {
                    const x = width * ev.position;
                    const isEven = index % 2 === 0;
                    const nodeY = isEven ? timelineY - 50 : timelineY + 50;

                    // 绘制连接线
                    ctx.beginPath();
                    ctx.moveTo(x, timelineY);
                    ctx.lineTo(x, nodeY);
                    ctx.strokeStyle = '#adb5bd';
                    ctx.lineWidth = 1.5;
                    ctx.setLineDash([5, 3]);
                    ctx.stroke();
                    ctx.setLineDash([]);

                    // 绘制节点
                    ctx.beginPath();
                    ctx.arc(x, nodeY, nodeRadius, 0, Math.PI * 2);
                    ctx.fillStyle = isAnimating && animationProgress >= ev.position ?
                        '#F2050A' : '#667eea';
                    ctx.fill();
                    ctx.strokeStyle = 'white';
                    ctx.lineWidth = 2;
                    ctx.stroke();

                    // 绘制事件文本
                    ctx.font = '14px Segoe UI, sans-serif';
                    ctx.textAlign = 'center';
                    ctx.textBaseline = 'middle';
                    ctx.fillStyle = '#495057';
                    ctx.fillText(ev.event, x, nodeY + (isEven ? -30 : 30));

                    // 绘制时间文本
                    ctx.font = '12px Segoe UI, sans-serif';
                    ctx.fillStyle = '#6c757d';
                    ctx.fillText(ev.time, x, nodeY + (isEven ? -50 : 50));
                });

                // 绘制动画进度
                if (isAnimating) {
                    ctx.beginPath();
                    ctx.moveTo(width * 0.05, timelineY);
                    ctx.lineTo(width * animationProgress, timelineY);
                    ctx.strokeStyle = '#F2050A';
                    ctx.lineWidth = 4;
                    ctx.stroke();
                }
            }

            // 动画函数
            function animate() {
                if (animationProgress < 0.95) {
                    animationProgress += 0.005;
                    drawFlowchart();
                    animationId = requestAnimationFrame(animate);
                } else {
                    isAnimating = false;
                }
            }

            // 事件监听器
            playBtn.addEventListener('click', function () {
                if (!isAnimating) {
                    isAnimating = true;
                    animate();
                }
            });

            pauseBtn.addEventListener('click', function () {
                if (isAnimating) {
                    cancelAnimationFrame(animationId);
                    isAnimating = false;
                }
            });

            resetBtn.addEventListener('click', function () {
                if (isAnimating) {
                    cancelAnimationFrame(animationId);
                    isAnimating = false;
                }
                animationProgress = 0;
                drawFlowchart();
            });

            // 初始化和响应式调整
            window.addEventListener('resize', resizeCanvas);
            resizeCanvas();

            // 初始绘制
            drawFlowchart();
        });
    </script>
</body>

</html>

HTML

  • container:包裹所有内容的核心容器,用于统一控制页面布局与背景
  • header:包含页面标题与描述,用于引导用户理解页面功能
  • h1:页面主标题,视觉焦点之一
  • description:说明页面用途(飞行过程事件流展示),提升用户体验
  • canvas-container:包裹 Canvas 标签,用于控制画布的尺寸、阴影与边框等样式
  • flowchartCanvas:核心绘图元素,通过 JavaScript 获取其上下文(getContext('2d'))实现绘图
  • controls:包含 “播放”“暂停”“重置” 三个按钮,用于控制动画交互
  • playBtn:触发动画播放的交互入口
  • pauseBtn:触发动画暂停的交互入口
  • resetBtn:将画布恢复到初始状态的交互入口
  • footer:显示版权信息,提升页面完整性

CSS

  • .container :控制核心容器的宽度(最大 1200px)、白色半透明背景、圆角与阴影,增强立体感
  • h1 :通过背景裁剪实现文字渐变效果,替代传统纯色文字
  • .canvas-container :固定画布高度(500px)、浅灰色背景与轻微阴影,让画布与容器区分开
  • button :按钮采用圆角(50px)、渐变背景、阴影,transition 实现 hover 动画过渡
  • button:hover:鼠标悬浮时按钮向上偏移 3px,阴影加深,增强交互反馈
  • button:active:点击按钮时恢复原位置,模拟 “按压” 手感
  • @media (max-width: 768px):在移动设备上缩小标题字体、降低画布高度(400px),避免内容溢出

JavaScript 部分:交互与动画实现

负责 Canvas 绘图、动画控制与用户交互逻辑,核心分为初始化绘图动画事件监听四大模块。

1. 初始化:准备工作

首先通过 DOMContentLoaded 事件确保 DOM 加载完成后再执行代码,避免获取不到元素的问题:

document.addEventListener('DOMContentLoaded', function() {
  // 1. 获取DOM元素
  const canvas = document.getElementById('flowchartCanvas');
  const ctx = canvas.getContext('2d'); // 获取2D绘图上下文(核心)
  const playBtn = document.getElementById('playBtn');
  const pauseBtn = document.getElementById('pauseBtn');
  const resetBtn = document.getElementById('resetBtn');

  // 2. 响应式调整Canvas尺寸
  function resizeCanvas() {
    canvas.width = canvas.offsetWidth; // 让Canvas宽度等于父容器宽度
    canvas.height = canvas.offsetHeight; // 让Canvas高度等于父容器高度
    drawFlowchart(); // 尺寸变化后重新绘图
  }

  // 3. 定义事件数据(飞行过程的关键事件)
  const events = [
    { time: '2025-09-12 11:40', event: '起飞', position: 0.05 }, // position:事件在时间轴上的比例(0~1)
    { time: '2025-09-12 11:42', event: '转弯', position: 0.15 },
    { time: '2025-09-12 11:42', event: '发现问题', position: 0.25 },
    { time: '2025-09-12 11:51', event: '返航', position: 0.35 },
    { time: '2025-09-12 11:53', event: '飞行', position: 0.45 },
    { time: '2025-09-12 11:55', event: '转弯', position: 0.55 },
    { time: '2025-09-12 12:00', event: '飞行', position: 0.65 },
    { time: '2025-09-12 12:30', event: '降落', position: 0.75 },
    { time: '2025-09-12 12:30', event: '降落', position: 0.85 },
    { time: '2025-09-12 13:41', event: '返航', position: 0.95 }
  ];

  // 4. 动画状态变量
  let animationProgress = 0; // 动画进度(0~0.95,对应时间轴比例)
  let animationId = null; // 动画请求ID(用于暂停动画)
  let isAnimating = false; // 动画是否正在播放
});

2. 核心函数:drawFlowchart () 绘图逻辑

该函数是 Canvas 绘图的核心,负责绘制时间轴、箭头、事件节点、事件文本,并根据动画进度更新节点颜色:

function drawFlowchart() {
  const width = canvas.width; // 画布宽度
  const height = canvas.height; // 画布高度
  const timelineY = height / 2; // 时间轴的Y坐标(垂直居中)
  const nodeRadius = 12; // 事件节点的半径

  // 步骤1:清除画布(每次绘图前清空,避免重叠)
  ctx.clearRect(0, 0, width, height);

  // 步骤2:绘制时间轴(水平直线)
  ctx.beginPath(); // 开始路径绘制
  ctx.moveTo(width * 0.05, timelineY); // 起点(左侧留5%空白)
  ctx.lineTo(width * 0.95, timelineY); // 终点(右侧留5%空白)
  ctx.strokeStyle = '#667eea'; // 线条颜色(紫蓝色)
  ctx.lineWidth = 3; // 线条宽度
  ctx.stroke(); // 执行绘制

  // 步骤3:绘制时间轴箭头(终点处的三角形)
  ctx.beginPath();
  ctx.moveTo(width * 0.95, timelineY); // 箭头顶点
  ctx.lineTo(width * 0.93, timelineY - 8); // 左上点
  ctx.lineTo(width * 0.93, timelineY + 8); // 左下点
  ctx.closePath(); // 闭合路径(形成三角形)
  ctx.fillStyle = '#667eea'; // 填充颜色
  ctx.fill(); // 执行填充

  // 步骤4:绘制每个事件的节点、连接线与文本
  events.forEach((ev, index) => {
    const x = width * ev.position; // 事件节点的X坐标(按比例计算)
    const isEven = index % 2 === 0; // 判断索引是否为偶数(控制节点在时间轴上下两侧)
    const nodeY = isEven ? timelineY - 50 : timelineY + 50; // 节点Y坐标(偶数在上,奇数在下)

    // 4.1 绘制连接线(时间轴到节点的虚线)
    ctx.beginPath();
    ctx.moveTo(x, timelineY); // 起点(时间轴上的点)
    ctx.lineTo(x, nodeY); // 终点(事件节点)
    ctx.strokeStyle = '#adb5bd'; // 虚线颜色(浅灰色)
    ctx.lineWidth = 1.5; // 线条宽度
    ctx.setLineDash([5, 3]); // 设置虚线样式(5px实线,3px空白)
    ctx.stroke();
    ctx.setLineDash([]); // 重置为实线(避免影响后续绘图)

    // 4.2 绘制事件节点(圆形)
    ctx.beginPath();
    ctx.arc(x, nodeY, nodeRadius, 0, Math.PI * 2); // 画圆(x,y,半径,起始角度,结束角度)
    // 节点颜色:动画进度覆盖时为红色(#F2050A),否则为紫蓝色
    ctx.fillStyle = isAnimating && animationProgress >= ev.position ? '#F2050A' : '#667eea';
    ctx.fill(); // 填充圆形
    ctx.strokeStyle = 'white'; // 节点边框颜色(白色)
    ctx.lineWidth = 2; // 边框宽度
    ctx.stroke(); // 绘制边框

    // 4.3 绘制事件名称(如“起飞”“转弯”)
    ctx.font = '14px Segoe UI, sans-serif'; // 字体样式
    ctx.textAlign = 'center'; // 文本水平居中
    ctx.textBaseline = 'middle'; // 文本垂直居中
    ctx.fillStyle = '#495057'; // 文本颜色(深灰色)
    ctx.fillText(ev.event, x, nodeY + (isEven ? -30 : 30)); // 文本位置(节点上下30px处)

    // 4.4 绘制事件时间(如“2025-09-12 11:40”)
    ctx.font = '12px Segoe UI, sans-serif'; // 字体缩小
    ctx.fillStyle = '#6c757d'; // 文本颜色(浅灰色)
    ctx.fillText(ev.time, x, nodeY + (isEven ? -50 : 50)); // 文本位置(事件名称外侧)
  });

  // 步骤5:绘制动画进度条(红色实线,跟随动画进度)
  if (isAnimating) {
    ctx.beginPath();
    ctx.moveTo(width * 0.05, timelineY); // 起点(与时间轴一致)
    ctx.lineTo(width * animationProgress, timelineY); // 终点(随进度变化)
    ctx.strokeStyle = '#F2050A'; // 进度条颜色(红色)
    ctx.lineWidth = 4; // 进度条宽度(比时间轴粗)
    ctx.stroke();
  }
}

3. 动画控制:animate () 与按钮事件

动画通过 requestAnimationFrame(浏览器原生动画 API)实现,配合按钮事件控制播放、暂停、重置:

// 动画函数:逐帧更新进度并重新绘图
function animate() {
  if (animationProgress < 0.95) { // 进度未到终点(0.95)
    animationProgress += 0.005; // 每次帧更新进度(控制动画速度)
    drawFlowchart(); // 重新绘图(更新进度条与节点颜色)
    animationId = requestAnimationFrame(animate); // 请求下一帧动画
  } else {
    isAnimating = false; // 进度到终点,停止动画
  }
}

// 播放按钮事件:启动动画(仅当未播放时)
playBtn.addEventListener('click', function() {
  if (!isAnimating) {
    isAnimating = true;
    animate();
  }
});

// 暂停按钮事件:取消动画请求(停止动画)
pauseBtn.addEventListener('click', function() {
  if (isAnimating) {
    cancelAnimationFrame(animationId); // 取消下一帧动画
    isAnimating = false;
  }
});

// 重置按钮事件:恢复初始状态
resetBtn.addEventListener('click', function() {
  if (isAnimating) {
    cancelAnimationFrame(animationId); // 先停止动画
    isAnimating = false;
  }
  animationProgress = 0; // 进度重置为0
  drawFlowchart(); // 重新绘图(恢复初始样式)
});

// 窗口 resize 事件:响应式调整画布尺寸
window.addEventListener('resize', resizeCanvas);

// 初始化:首次加载时调整尺寸并绘图
resizeCanvas();
drawFlowchart();

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

现代贪吃蛇游戏的进化:从经典玩法到多人在线体验

2025年9月12日 14:36

Hi,我是前端人类学! 贪吃蛇游戏自1976年诞生以来,已经从简单的像素游戏发展成为具有丰富功能的现代游戏体验。本文将通过一个功能增强版的贪吃蛇游戏,探讨如何将经典游戏概念与现代Web技术相结合,创造出既保留经典玩法又具备现代特性的游戏体验。

一、技术架构与设计理念

这个增强版贪吃蛇游戏采用HTML5 Canvas作为渲染核心,结合现代CSS布局和JavaScript模块化设计,实现了以下核心功能:

  1. 基础游戏机制:蛇的移动、食物生成、碰撞检测
  2. 多人游戏模式:实时多玩家支持与竞争机制
  3. 成就系统:进度追踪与玩家激励
  4. 关卡编辑器:用户生成内容支持
  5. 资源管理系统:自定义皮肤与背景

二、多人在线模式的实现

多人在线功能是本项目的核心创新之一。通过模拟的网络通信机制,游戏支持2-4名玩家同时参与,并提供三种不同的游戏模式:

// 多人在线模式状态
let multiplayerState = {
  players: [
    { id: 1, name: '玩家1', score: 0, color: '#4CAF50', alive: true, snake: [], dx: 1, dy: 0 },
    { id: 2, name: '玩家2', score: 0, color: '#FF5252', alive: true, snake: [], dx: 1, dy: 0 },
    { id: 3, name: '玩家3', score: 0, color: '#FFC107', alive: true, snake: [], dx: 1, dy: 0 },
    { id: 4, name: '玩家4', score: 0, color: '#9C27B0', alive: true, snake: [], dx: 1, dy: 0 }
  ],
  mode: 'competition',
  status: 'lobby',
  food: []
}

这种设计允许玩家根据偏好选择不同的游戏体验,从合作共嬴到激烈竞争,大大扩展了游戏的可玩性。

三、成就系统的心理激励

成就系统通过提供明确的目标和奖励,有效增强了玩家的参与度和长期投入:

// 成就数据结构
const achievements = [
    {
        id: "first_blood",
        name: "初出茅庐",
        description: "获得100分",
        icon: "fas fa-star",
        progress: 0,
        target: 100,
        unlocked: false
    },
    {
        id: "speed_demon",
        name: "速度之王",
        description: "以最高速度游戏1分钟",
        icon: "fas fa-fire",
        progress: 0,
        target: 60,
        unlocked: false
    }
];

// 成就解锁检查
function checkAchievements() {
    achievements.forEach(achievement => {
        if (!achievement.unlocked && achievement.progress >= achievement.target) {
            unlockAchievement(achievement.id);
        }
    });
}

这种成就系统不仅提供了短期目标,还通过进度可视化给予玩家持续的正向反馈,符合游戏化设计的基本原则。

四、关卡编辑器的创意表达

关卡编辑器功能将玩家从被动的消费者转变为主动的创作者,极大地扩展了游戏的内容生命周期:

canvas.addEventListener('click', e => {
    if (!isEditing) return

    const rect = canvas.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top

    const gridX = Math.floor(x / gridSize)
    const gridY = Math.floor(y / gridSize)

    if (currentTool === 'wall') {
      // 添加墙壁
      obstacles.push({ x: gridX, y: gridY })
    } else if (currentTool === 'obstacle') {
      // 添加障碍物
      obstacles.push({ x: gridX, y: gridY })
    } else if (currentTool === 'erase') {
      // 删除墙壁或障碍物
      obstacles = obstacles.filter(obs => !(obs.x === gridX && obs.y === gridY))
    }

    draw()
  })

通过简单的点击交互,玩家可以创建复杂多样的游戏关卡,分享给其他玩家,形成活跃的创作者社区。

五、响应式设计与跨平台体验

游戏采用完全响应式设计,确保在不同设备上都能提供一致的用户体验:

/* 响应式布局系统 */
.container {
  width: 100%;
  max-width: 1400px;
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  gap: 20px;
  margin-top: 20px;
}

@media (max-width: 900px) {
  .container {
    grid-template-columns: 1fr;
  }

  .left-panel,
  .right-panel {
    display: none;
  }

  .mobile-controls {
    display: grid;
  }
}

/* 移动端控制优化 */
.mobile-controls {
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: repeat(2, 1fr);
}

.mobile-controls button {
    height: 70px;
    font-size: 1.5rem;
}

这种设计确保从桌面电脑到移动手机,玩家都能享受完整的游戏功能,包括多人游戏和关卡编辑等高级特性。

六、完整代码实现

**页面结构(snake-game.html) **

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

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
  <link rel="stylesheet" href="snake-game.css">
  <title>高级贪吃蛇游戏</title>
</head>

<body>
  <header>
    <h1><i class="fas fa-snake"></i> 贪吃蛇游戏 - 多人在线版</h1>
    <div class="tabs">
      <div class="tab active" data-tab="single">单人游戏</div>
      <div class="tab" data-tab="multiplayer">多人游戏</div>
      <div class="tab" data-tab="achievements">成就系统</div>
      <div class="tab" data-tab="level-editor">关卡编辑器</div>
    </div>
    <div class="game-info">
      <div class="info-box">
        <i class="fas fa-star"></i>
        <span>得分: <span id="score">0</span></span>
      </div>
      <div class="info-box">
        <i class="fas fa-tachometer-alt"></i>
        <span>速度: <span id="speed">5</span></span>
      </div>
      <div class="info-box">
        <i class="fas fa-clock"></i>
        <span>时间: <span id="time">00:00</span></span>
      </div>
    </div>
    <div class="multiplayer-status" id="multiplayerStatus">
      <div class="player-indicator">
        <div class="player-color player-1"></div>
        <span>玩家1: <span id="player1Score">0</span></span>
      </div>
      <div class="player-indicator">
        <div class="player-color player-2"></div>
        <span>玩家2: <span id="player2Score">0</span></span>
      </div>
      <div class="player-indicator">
        <div class="player-color player-3"></div>
        <span>玩家3: <span id="player3Score">0</span></span>
      </div>
      <div class="player-indicator">
        <div class="player-color player-4"></div>
        <span>玩家4: <span id="player4Score">0</span></span>
      </div>
    </div>
  </header>

  <div class="container">
    <div class="left-panel panel">
      <h3 class="section-title"><i class="fas fa-cog"></i> 游戏设置</h3>

      <div class="settings-group">
        <label for="gameSpeed">游戏速度</label>
        <input type="range" id="gameSpeed" min="1" max="10" value="5">
        <div class="range-value"><span id="speedValue">5</span>/10</div>
      </div>

      <div class="settings-group">
        <label for="gridSize">网格大小</label>
        <select id="gridSize">
          <option value="15">小 (15x15)</option>
          <option value="20" selected>中 (20x20)</option>
          <option value="25">大 (25x25)</option>
        </select>
      </div>

      <div class="settings-group">
        <label for="wallMode">墙壁模式</label>
        <select id="wallMode">
          <option value="solid">实心墙 (游戏结束)</option>
          <option value="pass-through">穿透 (循环边界)</option>
        </select>
      </div>

      <h3 class="section-title"><i class="fas fa-upload"></i> 上传资源</h3>

      <div class="upload-area" id="uploadBg">
        <i class="fas fa-image"></i>
        <p>上传背景</p>
        <input type="file" id="bgUpload" accept="image/*" style="display: none;">
      </div>

      <div class="upload-area" id="uploadSkin">
        <i class="fas fa-palette"></i>
        <p>上传蛇皮肤</p>
        <input type="file" id="skinUpload" accept="image/*" style="display: none;">
      </div>

      <button class="secondary" id="resetSettings">
        <i class="fas fa-undo"></i> 重置设置
      </button>
    </div>

    <div class="game-area panel">
      <canvas id="gameCanvas" width="600" height="600"></canvas>

      <div class="level-editor" id="levelEditor">
        <h3><i class="fas fa-edit"></i> 关卡编辑器</h3>
        <div class="editor-tools">
          <button id="wallTool"><i class="fas fa-wall"></i> 墙壁</button>
          <button id="obstacleTool"><i class="fas fa-mountain"></i> 障碍物</button>
          <button id="eraseTool"><i class="fas fa-eraser"></i> 擦除</button>
        </div>
        <div>
          <input type="text" id="levelName" placeholder="关卡名称">
          <button id="saveLevel"><i class="fas fa-save"></i> 保存关卡</button>
          <button id="loadLevel"><i class="fas fa-folder-open"></i> 加载关卡</button>
        </div>
      </div>

      <div class="game-over" id="gameOver">
        <h2>游戏结束!</h2>
        <p>得分: <span id="finalScore">0</span></p>
        <div class="controls">
          <button id="restartBtn">
            <i class="fas fa-redo"></i> 重新开始
          </button>
          <button class="secondary" id="menuBtn">
            <i class="fas fa-home"></i> 返回菜单
          </button>
        </div>
      </div>
    </div>

    <div class="right-panel panel">
      <div class="tab-content active" id="singleTab">
        <h3 class="section-title"><i class="fas fa-trophy"></i> 排行榜</h3>

        <ul class="leaderboard" id="leaderboard">
          <li>
            <span><span class="rank">1</span> 玩家1</span>
            <span class="score">450</span>
          </li>
          <li>
            <span><span class="rank">2</span> 玩家2</span>
            <span class="score">320</span>
          </li>
          <li>
            <span><span class="rank">3</span> 玩家3</span>
            <span class="score">280</span>
          </li>
        </ul>

        <h3 class="section-title"><i class="fas fa-gamepad"></i> 游戏控制</h3>

        <div class="controls">
          <button id="startBtn">
            <i class="fas fa-play"></i> 开始游戏
          </button>
          <button class="secondary" id="pauseBtn">
            <i class="fas fa-pause"></i> 暂停
          </button>
          <button class="accent" id="soundBtn">
            <i class="fas fa-volume-up"></i> 音效: 开
          </button>
          <button id="saveBtn">
            <i class="fas fa-save"></i> 保存设置
          </button>
        </div>

        <p style="margin-top: 15px; text-align: center;">
          使用 <i class="fas fa-arrow-up"></i> <i class="fas fa-arrow-down"></i>
          <i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-right"></i> 方向键控制
        </p>
      </div>

      <div class="tab-content" id="multiplayerTab">
        <h3 class="section-title"><i class="fas fa-users"></i> 多人游戏</h3>

        <div class="settings-group">
          <label for="playerCount">玩家数量</label>
          <select id="playerCount">
            <option value="2">2 玩家</option>
            <option value="3">3 玩家</option>
            <option value="4">4 玩家</option>
          </select>
        </div>

        <div class="settings-group">
          <label for="gameMode">游戏模式</label>
          <select id="gameMode">
            <option value="coop">合作模式</option>
            <option value="competition">竞争模式</option>
            <option value="last-standing">生存模式</option>
          </select>
        </div>

        <div class="player-controls">
          <div class="player-control">
            <div class="online-indicator"></div>
            <span>玩家1</span>
            <select>
              <option value="human">人类玩家</option>
              <option value="easy">简单AI</option>
              <option value="medium">中等AI</option>
              <option value="hard">困难AI</option>
            </select>
          </div>
          <div class="player-control">
            <div class="online-indicator"></div>
            <span>玩家2</span>
            <select>
              <option value="human">人类玩家</option>
              <option value="easy">简单AI</option>
              <option value="medium">中等AI</option>
              <option value="hard">困难AI</option>
            </select>
          </div>
          <div class="player-control">
            <div class="online-indicator offline"></div>
            <span>玩家3</span>
            <select>
              <option value="none">无玩家</option>
              <option value="human">人类玩家</option>
              <option value="easy">简单AI</option>
              <option value="medium">中等AI</option>
              <option value="hard">困难AI</option>
            </select>
          </div>
          <div class="player-control">
            <div class="online-indicator offline"></div>
            <span>玩家4</span>
            <select>
              <option value="none">无玩家</option>
              <option value="human">人类玩家</option>
              <option value="easy">简单AI</option>
              <option value="medium">中等AI</option>
              <option value="hard">困难AI</option>
            </select>
          </div>
        </div>

        <button id="createLobby">
          <i class="fas fa-plus"></i> 创建房间
        </button>

        <button id="joinLobby">
          <i class="fas fa-sign-in-alt"></i> 加入房间
        </button>

        <div id="lobbyList" style="margin-top: 15px;">
          <h4>可用房间</h4>
          <ul style="list-style: none;">
            <li>房间1 (2/4玩家)</li>
            <li>房间2 (1/2玩家)</li>
          </ul>
        </div>
      </div>

      <div class="tab-content" id="achievementsTab">
        <h3 class="section-title"><i class="fas fa-trophy"></i> 成就系统</h3>

        <div class="achievements">
          <div class="achievement unlocked">
            <i class="fas fa-star"></i>
            <h4>初出茅庐</h4>
            <p>获得100分</p>
            <div class="achievement-progress">
              <div class="achievement-progress-bar" style="width: 100%"></div>
            </div>
          </div>

          <div class="achievement">
            <i class="fas fa-fire"></i>
            <h4>速度之王</h4>
            <p>以最高速度游戏1分钟</p>
            <div class="achievement-progress">
              <div class="achievement-progress-bar" style="width: 30%"></div>
            </div>
          </div>

          <div class="achievement unlocked">
            <i class="fas fa-users"></i>
            <h4>团队合作</h4>
            <p>完成一局合作模式</p>
            <div class="achievement-progress">
              <div class="achievement-progress-bar" style="width: 100%"></div>
            </div>
          </div>

          <div class="achievement">
            <i class="fas fa-ghost"></i>
            <h4>幽灵模式</h4>
            <p>在穿透模式下获得500分</p>
            <div class="achievement-progress">
              <div class="achievement-progress-bar" style="width: 65%"></div>
            </div>
          </div>

          <div class="achievement">
            <i class="fas fa-infinity"></i>
            <h4>无限挑战</h4>
            <p>蛇身长度达到50节</p>
            <div class="achievement-progress">
              <div class="achievement-progress-bar" style="width: 40%"></div>
            </div>
          </div>

          <div class="achievement">
            <i class="fas fa-crown"></i>
            <h4>贪吃蛇大师</h4>
            <p>解锁所有成就</p>
            <div class="achievement-progress">
              <div class="achievement-progress-bar" style="width: 25%"></div>
            </div>
          </div>
        </div>
      </div>

      <div class="tab-content" id="levelEditorTab">
        <h3 class="section-title"><i class="fas fa-edit"></i> 我的关卡</h3>

        <div style="margin-bottom: 15px;">
          <input type="text" placeholder="搜索关卡..." style="width: 100%;">
        </div>

        <ul class="leaderboard">
          <li>
            <span>迷宫挑战</span>
            <span><i class="fas fa-play"></i> 游玩</span>
          </li>
          <li>
            <span>极限模式</span>
            <span><i class="fas fa-play"></i> 游玩</span>
          </li>
          <li>
            <span>合作关卡</span>
            <span><i class="fas fa-play"></i> 游玩</span>
          </li>
        </ul>

        <button style="margin-top: 15px;">
          <i class="fas fa-share"></i> 分享关卡
        </button>
      </div>
    </div>
  </div>

  <div class="mobile-controls">
    <button id="upBtn"><i class="fas fa-arrow-up"></i></button>
    <button id="leftBtn"><i class="fas fa-arrow-left"></i></button>
    <button id="downBtn"><i class="fas fa-arrow-down"></i></button>
    <button id="rightBtn"><i class="fas fa-arrow-right"></i></button>
  </div>

  <script src="snake-game.js"></script>
</body>

</html>

页面样式(snake-game.css)

:root {
  --primary-color: #4CAF50;
  --secondary-color: #2196F3;
  --accent-color: #FF5722;
  --dark-color: #2c3e50;
  --light-color: #ecf0f1;
  --success-color: #2ecc71;
  --danger-color: #e74c3c;
  --warning-color: #f39c12;
  --multiplayer-color-1: #FF5252;
  --multiplayer-color-2: #FFC107;
  --multiplayer-color-3: #9C27B0;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

body {
  background: linear-gradient(135deg, var(--dark-color), #34495e);
  color: var(--light-color);
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px;
}

.container {
  width: 100%;
  max-width: 1400px;
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  gap: 20px;
  margin-top: 20px;
}

header {
  text-align: center;
  margin-bottom: 20px;
  width: 100%;
}

h1 {
  font-size: 2.5rem;
  margin-bottom: 10px;
  color: var(--primary-color);
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}

.tabs {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-bottom: 20px;
}

.tab {
  padding: 10px 20px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s;
}

.tab.active {
  background: var(--primary-color);
}

.tab-content {
  display: none;
}

.tab-content.active {
  display: block;
}

.game-info {
  display: flex;
  justify-content: center;
  gap: 20px;
  margin-bottom: 15px;
}

.info-box {
  background: rgba(0, 0, 0, 0.3);
  padding: 10px 20px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 10px;
}

.info-box i {
  font-size: 1.5rem;
  color: var(--primary-color);
}

.panel {
  background: rgba(0, 0, 0, 0.5);
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
  backdrop-filter: blur(10px);
  max-height: 80vh;
  overflow-y: auto;
}

.left-panel,
.right-panel {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.game-area {
  position: relative;
}

canvas {
  background-color: #1a1a2e;
  border-radius: 12px;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
  display: block;
  width: 100%;
}

.controls {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

button {
  background: var(--primary-color);
  color: white;
  border: none;
  padding: 12px;
  border-radius: 8px;
  cursor: pointer;
  font-weight: bold;
  transition: all 0.3s;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

button:hover {
  background: #3e8e41;
  transform: translateY(-2px);
}

button.secondary {
  background: var(--secondary-color);
}

button.secondary:hover {
  background: #0b7dda;
}

button.accent {
  background: var(--accent-color);
}

button.accent:hover {
  background: #d84315;
}

.section-title {
  font-size: 1.2rem;
  margin-bottom: 15px;
  padding-bottom: 8px;
  border-bottom: 2px solid var(--primary-color);
  color: var(--primary-color);
}

.settings-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: 500;
}

input[type="range"] {
  width: 100%;
  margin: 10px 0;
}

.range-value {
  text-align: center;
  font-weight: bold;
  color: var(--primary-color);
}

select,
input[type="text"] {
  width: 100%;
  padding: 10px;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.1);
  color: white;
  border: 1px solid rgba(255, 255, 255, 0.2);
  margin-bottom: 10px;
}

.upload-area {
  border: 2px dashed rgba(255, 255, 255, 0.3);
  border-radius: 8px;
  padding: 20px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s;
  margin-top: 10px;
}

.upload-area:hover {
  border-color: var(--primary-color);
  background: rgba(76, 175, 80, 0.1);
}

.upload-area i {
  font-size: 2rem;
  margin-bottom: 10px;
  color: var(--primary-color);
}

.achievements {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.achievement {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  padding: 10px;
  text-align: center;
  position: relative;
}

.achievement.locked {
  opacity: 0.6;
}

.achievement i {
  font-size: 2rem;
  margin-bottom: 5px;
  color: var(--warning-color);
}

.achievement.unlocked i {
  color: gold;
}

.achievement-progress {
  height: 5px;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 3px;
  margin-top: 5px;
  overflow: hidden;
}

.achievement-progress-bar {
  height: 100%;
  background: var(--primary-color);
  border-radius: 3px;
}

.leaderboard {
  list-style: none;
}

.leaderboard li {
  padding: 12px;
  background: rgba(255, 255, 255, 0.1);
  margin-bottom: 8px;
  border-radius: 6px;
  display: flex;
  justify-content: space-between;
}

.leaderboard .rank {
  font-weight: bold;
  color: var(--primary-color);
  margin-right: 10px;
}

.leaderboard .score {
  font-weight: bold;
  color: var(--accent-color);
}

.game-over {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.8);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  border-radius: 12px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.5s;
}

.game-over.show {
  opacity: 1;
  pointer-events: all;
}

.game-over h2 {
  font-size: 3rem;
  color: var(--danger-color);
  margin-bottom: 20px;
}

.game-over p {
  font-size: 1.5rem;
  margin-bottom: 30px;
}

.multiplayer-status {
  display: flex;
  gap: 10px;
  margin-bottom: 15px;
}

.player-indicator {
  display: flex;
  align-items: center;
  gap: 5px;
}

.player-color {
  width: 15px;
  height: 15px;
  border-radius: 50%;
}

.player-1 {
  background: var(--primary-color);
}
.player-2 {
  background: var(--multiplayer-color-1);
}
.player-3 {
  background: var(--multiplayer-color-2);
}
.player-4 {
  background: var(--multiplayer-color-3);
}

.level-editor {
  display: none;
  position: absolute;
  top: 10px;
  right: 10px;
  background: rgba(0, 0, 0, 0.7);
  padding: 10px;
  border-radius: 8px;
  z-index: 10;
}

.editor-tools {
  display: flex;
  gap: 5px;
  margin-bottom: 10px;
}

.editor-tools button {
  padding: 8px;
  font-size: 0.9rem;
}

.mobile-controls {
  display: none;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(2, 1fr);
  gap: 10px;
  margin-top: 20px;
}

.mobile-controls button {
  height: 70px;
  font-size: 1.5rem;
}

.mobile-controls button:nth-child(1) {
  grid-column: 2;
  grid-row: 1;
}

.mobile-controls button:nth-child(2) {
  grid-column: 1;
  grid-row: 2;
}

.mobile-controls button:nth-child(3) {
  grid-column: 2;
  grid-row: 2;
}

.mobile-controls button:nth-child(4) {
  grid-column: 3;
  grid-row: 2;
}

/* 多人在线模式特定样式 */
.player-controls {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 15px;
}

.player-control {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 6px;
}

.player-control select {
  flex: 1;
  margin: 0;
}

.online-indicator {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--success-color);
}

.online-indicator.offline {
  background: var(--danger-color);
}

@media (max-width: 900px) {
  .container {
    grid-template-columns: 1fr;
  }

  .left-panel,
  .right-panel {
    display: none;
  }

  .mobile-controls {
    display: grid;
  }
}

业务逻辑(snake-game.js)

// 游戏主要变量
const canvas = document.getElementById('gameCanvas')
const ctx = canvas.getContext('2d')
const scoreElement = document.getElementById('score')
const speedElement = document.getElementById('speed')
const timeElement = document.getElementById('time')
const gameOverElement = document.getElementById('gameOver')
const finalScoreElement = document.getElementById('finalScore')

let gridSize = 20
let tileCount = canvas.width / gridSize
let snake = []
let food = {}
let dx = 0
let dy = 0
let score = 0
let gameSpeed = 150
let gameInterval
let isPaused = false
let isGameOver = false
let gameTime = 0
let timeInterval
let isSoundOn = true
let isMultiplayer = false
let obstacles = []
let currentTool = 'wall'
let isEditing = false

// 多人在线模式状态
let multiplayerState = {
  players: [
    { id: 1, name: '玩家1', score: 0, color: '#4CAF50', alive: true, snake: [], dx: 1, dy: 0 },
    { id: 2, name: '玩家2', score: 0, color: '#FF5252', alive: true, snake: [], dx: 1, dy: 0 },
    { id: 3, name: '玩家3', score: 0, color: '#FFC107', alive: true, snake: [], dx: 1, dy: 0 },
    { id: 4, name: '玩家4', score: 0, color: '#9C27B0', alive: true, snake: [], dx: 1, dy: 0 }
  ],
  mode: 'competition',
  status: 'lobby',
  food: []
}

// 初始化游戏
function initGame() {
  // 初始化蛇
  snake = [
    { x: 10, y: 10 },
    { x: 9, y: 10 },
    { x: 8, y: 10 }
  ]

  // 生成食物
  generateFood()

  // 生成障碍物
  generateObstacles()

  // 重置游戏状态
  score = 0
  dx = 1
  dy = 0
  gameTime = 0
  isGameOver = false

  // 更新UI
  scoreElement.textContent = score
  gameOverElement.classList.remove('show')

  // 绘制初始状态
  draw()

  // 启动计时器
  startTimer()
}

// 初始化多人游戏
function initMultiplayerGame() {
  const playerCount = parseInt(document.getElementById('playerCount').value)
  const gameMode = document.getElementById('gameMode').value

  // 设置玩家数量
  multiplayerState.players = multiplayerState.players.slice(0, playerCount)

  // 初始化每个玩家的蛇
  multiplayerState.players.forEach((player, index) => {
    const startX = 5 + index * 5
    player.snake = [
      { x: startX, y: 10 },
      { x: startX - 1, y: 10 },
      { x: startX - 2, y: 10 }
    ]
    player.score = 0
    player.alive = true

    // 设置初始方向
    player.dx = 1
    player.dy = 0
  })

  // 生成多个食物
  generateMultiplayerFood()

  // 更新玩家状态显示
  updateMultiplayerStatus()

  // 设置游戏模式
  multiplayerState.mode = gameMode
  multiplayerState.status = 'playing'

  // 开始游戏循环
  if (gameInterval) clearInterval(gameInterval)
  gameInterval = setInterval(multiplayerGameLoop, gameSpeed)
}

// 多人游戏循环
function multiplayerGameLoop() {
  if (!isPaused && multiplayerState.status === 'playing') {
    moveMultiplayerSnakes()
    drawMultiplayerGame()
    checkMultiplayerGameOver()
  }
}

// 移动所有玩家的蛇
function moveMultiplayerSnakes() {
  multiplayerState.players.forEach(player => {
    if (player.alive) {
      const head = {
        x: player.snake[0].x + player.dx,
        y: player.snake[0].y + player.dy
      }

      // 墙壁碰撞检测
      const wallMode = document.getElementById('wallMode').value
      if (wallMode === 'solid') {
        if (head.x < 0 || head.y < 0 || head.x >= tileCount || head.y >= tileCount) {
          player.alive = false
          return
        }
      } else {
        if (head.x < 0) head.x = tileCount - 1
        if (head.y < 0) head.y = tileCount - 1
        if (head.x >= tileCount) head.x = 0
        if (head.y >= tileCount) head.y = 0
      }

      // 添加到蛇头
      player.snake.unshift(head)

      // 检查是否吃到食物
      let ateFood = false
      for (let i = 0; i < multiplayerState.food.length; i++) {
        const food = multiplayerState.food[i]
        if (head.x === food.x && head.y === food.y) {
          player.score += 10
          multiplayerState.food.splice(i, 1)
          generateMultiplayerFood() // 生成新食物
          ateFood = true
          break
        }
      }

      // 如果没吃到食物,移除蛇尾
      if (!ateFood) {
        player.snake.pop()
      }

      // 检查碰撞
      checkMultiplayerCollisions(player)
    }
  })
}

// 检查多人游戏碰撞
function checkMultiplayerCollisions(player) {
  const head = player.snake[0]

  // 检查是否撞到自己
  for (let i = 1; i < player.snake.length; i++) {
    if (head.x === player.snake[i].x && head.y === player.snake[i].y) {
      player.alive = false
      return
    }
  }

  // 检查是否撞到障碍物
  for (let obs of obstacles) {
    if (head.x === obs.x && head.y === obs.y) {
      player.alive = false
      return
    }
  }

  // 检查是否撞到其他玩家
  multiplayerState.players.forEach(otherPlayer => {
    if (otherPlayer.id !== player.id && otherPlayer.alive) {
      for (let part of otherPlayer.snake) {
        if (head.x === part.x && head.y === part.y) {
          player.alive = false
          return
        }
      }
    }
  })
}

// 绘制多人游戏
function drawMultiplayerGame() {
  // 清空画布
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  // 绘制网格
  drawGrid()

  // 绘制障碍物
  drawObstacles()

  // 绘制所有玩家的蛇
  multiplayerState.players.forEach(player => {
    if (player.alive) {
      for (let i = 0; i < player.snake.length; i++) {
        const part = player.snake[i]

        if (i === 0) {
          // 蛇头
          ctx.fillStyle = player.color
        } else {
          // 蛇身
          ctx.fillStyle = shadeColor(player.color, -20)
        }

        ctx.fillRect(part.x * gridSize, part.y * gridSize, gridSize - 1, gridSize - 1)

        // 为蛇身添加圆角效果
        ctx.beginPath()
        ctx.arc(part.x * gridSize + gridSize / 2, part.y * gridSize + gridSize / 2, gridSize / 2 - 1, 0, Math.PI * 2)
        ctx.fill()
      }
    }
  })

  // 绘制食物
  multiplayerState.food.forEach(food => {
    ctx.fillStyle = '#FF5252'
    ctx.beginPath()
    ctx.arc(food.x * gridSize + gridSize / 2, food.y * gridSize + gridSize / 2, gridSize / 2 - 1, 0, Math.PI * 2)
    ctx.fill()
  })

  // 更新玩家分数显示
  updateMultiplayerStatus()
}

// 生成多人游戏食物
function generateMultiplayerFood() {
  multiplayerState.food = []
  const foodCount = multiplayerState.players.length * 2

  for (let i = 0; i < foodCount; i++) {
    const food = {
      x: Math.floor(Math.random() * tileCount),
      y: Math.floor(Math.random() * tileCount)
    }

    // 确保食物不会出现在蛇身上或障碍物上
    let validPosition = true

    multiplayerState.players.forEach(player => {
      for (let part of player.snake) {
        if (part.x === food.x && part.y === food.y) {
          validPosition = false
        }
      }
    })

    for (let obs of obstacles) {
      if (obs.x === food.x && obs.y === food.y) {
        validPosition = false
      }
    }

    if (validPosition) {
      multiplayerState.food.push(food)
    } else {
      i-- // 重试
    }
  }
}

// 检查多人游戏是否结束
function checkMultiplayerGameOver() {
  let alivePlayers = 0
  multiplayerState.players.forEach(player => {
    if (player.alive) alivePlayers++
  })

  if (alivePlayers <= 1) {
    multiplayerState.status = 'finished'

    // 显示游戏结束画面
    finalScoreElement.textContent = `玩家${multiplayerState.players.findIndex(p => p.alive) + 1}获胜!`
    gameOverElement.classList.add('show')

    // 播放游戏结束音效
    if (isSoundOn) {
      playSound('gameover')
    }
  }
}

// 更新多人游戏状态显示
function updateMultiplayerStatus() {
  multiplayerState.players.forEach((player, index) => {
    document.getElementById(`player${index + 1}Score`).textContent = player.score
  })
}

// 工具函数:调整颜色亮度
function shadeColor(color, percent) {
  let R = parseInt(color.substring(1, 3), 16)
  let G = parseInt(color.substring(3, 5), 16)
  let B = parseInt(color.substring(5, 7), 16)

  R = parseInt((R * (100 + percent)) / 100)
  G = parseInt((G * (100 + percent)) / 100)
  B = parseInt((B * (100 + percent)) / 100)

  R = R < 255 ? R : 255
  G = G < 255 ? G : 255
  B = B < 255 ? B : 255

  R = R < 0 ? 0 : R
  G = G < 0 ? 0 : G
  B = B < 0 ? 0 : B

  const RR = R.toString(16).length === 1 ? '0' + R.toString(16) : R.toString(16)
  const GG = G.toString(16).length === 1 ? '0' + G.toString(16) : G.toString(16)
  const BB = B.toString(16).length === 1 ? '0' + B.toString(16) : B.toString(16)

  return '#' + RR + GG + BB
}

// 生成食物
function generateFood() {
  food = {
    x: Math.floor(Math.random() * tileCount),
    y: Math.floor(Math.random() * tileCount)
  }

  // 确保食物不会出现在蛇身上或障碍物上
  for (let part of snake) {
    if (part.x === food.x && part.y === food.y) {
      generateFood()
      return
    }
  }

  for (let obs of obstacles) {
    if (obs.x === food.x && obs.y === food.y) {
      generateFood()
      return
    }
  }
}

// 生成障碍物
function generateObstacles() {
  obstacles = []
  const obstacleCount = Math.floor(tileCount * 0.1) // 10%的格子作为障碍物

  for (let i = 0; i < obstacleCount; i++) {
    let obstacle = {
      x: Math.floor(Math.random() * tileCount),
      y: Math.floor(Math.random() * tileCount)
    }

    // 确保障碍物不会出现在蛇的初始位置或食物上
    let validPosition = true
    for (let part of snake) {
      if (part.x === obstacle.x && part.y === obstacle.y) {
        validPosition = false
        break
      }
    }

    if (obstacle.x === food.x && obstacle.y === food.y) {
      validPosition = false
    }

    if (validPosition) {
      obstacles.push(obstacle)
    } else {
      i-- // 重试
    }
  }
}

// 绘制游戏
function draw() {
  // 清空画布
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  // 绘制网格
  drawGrid()

  // 绘制障碍物
  drawObstacles()

  // 绘制蛇
  for (let i = 0; i < snake.length; i++) {
    const part = snake[i]

    // 蛇头用不同颜色
    if (i === 0) {
      ctx.fillStyle = '#4CAF50'
    } else {
      ctx.fillStyle = '#8BC34A'
    }

    ctx.fillRect(part.x * gridSize, part.y * gridSize, gridSize - 1, gridSize - 1)

    // 为蛇身添加圆角效果
    ctx.beginPath()
    ctx.arc(part.x * gridSize + gridSize / 2, part.y * gridSize + gridSize / 2, gridSize / 2 - 1, 0, Math.PI * 2)
    ctx.fill()
  }

  // 绘制食物
  ctx.fillStyle = '#FF5252'
  ctx.beginPath()
  ctx.arc(food.x * gridSize + gridSize / 2, food.y * gridSize + gridSize / 2, gridSize / 2 - 1, 0, Math.PI * 2)
  ctx.fill()
}

// 绘制网格
function drawGrid() {
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
  ctx.lineWidth = 0.5

  for (let i = 0; i < tileCount; i++) {
    // 垂直线
    ctx.beginPath()
    ctx.moveTo(i * gridSize, 0)
    ctx.lineTo(i * gridSize, canvas.height)
    ctx.stroke()

    // 水平线
    ctx.beginPath()
    ctx.moveTo(0, i * gridSize)
    ctx.lineTo(canvas.width, i * gridSize)
    ctx.stroke()
  }
}

// 绘制障碍物
function drawObstacles() {
  ctx.fillStyle = '#607D8B'
  for (let obs of obstacles) {
    ctx.fillRect(obs.x * gridSize, obs.y * gridSize, gridSize - 1, gridSize - 1)

    // 添加纹理效果
    ctx.fillStyle = '#455A64'
    ctx.fillRect(obs.x * gridSize + 2, obs.y * gridSize + 2, gridSize - 5, gridSize - 5)
    ctx.fillStyle = '#607D8B'
  }
}

// 移动蛇
function moveSnake() {
  // 计算新的头部位置
  const head = { x: snake[0].x + dx, y: snake[0].y + dy }

  // 检查墙壁模式
  const wallMode = document.getElementById('wallMode').value
  if (wallMode === 'solid') {
    // 实心墙模式 - 检查是否撞墙
    if (head.x < 0 || head.y < 0 || head.x >= tileCount || head.y >= tileCount) {
      gameOver()
      return
    }
  } else {
    // 穿透模式 - 从对面出现
    if (head.x < 0) head.x = tileCount - 1
    if (head.y < 0) head.y = tileCount - 1
    if (head.x >= tileCount) head.x = 0
    if (head.y >= tileCount) head.y = 0
  }

  // 检查是否撞到自己
  if (isSnakeCollision(head)) {
    gameOver()
    return
  }

  // 检查是否撞到障碍物
  for (let obs of obstacles) {
    if (head.x === obs.x && head.y === obs.y) {
      gameOver()
      return
    }
  }

  // 将新头部添加到蛇的起始位置
  snake.unshift(head)

  // 检查是否吃到食物
  if (head.x === food.x && head.y === food.y) {
    // 增加分数
    score += 10
    scoreElement.textContent = score

    // 检查成就
    checkAchievements()

    // 生成新食物
    generateFood()

    // 播放吃食物音效
    if (isSoundOn) {
      playSound('eat')
    }
  } else {
    // 如果没吃到食物,移除尾部
    snake.pop()
  }
}

// 检查蛇是否撞到自己
function isSnakeCollision(head) {
  for (let i = 1; i < snake.length; i++) {
    if (head.x === snake[i].x && head.y === snake[i].y) {
      return true
    }
  }
  return false
}

// 检查成就
function checkAchievements() {
  // 这里简化处理,实际应用中会有更复杂的成就系统
  if (score >= 100) {
    unlockAchievement('初出茅庐')
  }

  if (snake.length >= 50) {
    unlockAchievement('无限挑战')
  }
}

// 解锁成就
function unlockAchievement(name) {
  console.log(`成就已解锁: ${name}`)
  // 在实际应用中,这里会更新UI并播放成就解锁动画
}

// 游戏循环
function gameLoop() {
  if (!isPaused && !isGameOver) {
    moveSnake()
    draw()
  }
}

// 开始游戏
function startGame() {
  if (isGameOver) {
    initGame()
  }

  if (!gameInterval) {
    isPaused = false
    isGameOver = false
    gameInterval = setInterval(gameLoop, gameSpeed)
    document.getElementById('startBtn').innerHTML = '<i class="fas fa-play"></i> 重新开始'
  } else if (isPaused) {
    isPaused = false
    document.getElementById('pauseBtn').innerHTML = '<i class="fas fa-pause"></i> 暂停'
  }
}

// 暂停游戏
function pauseGame() {
  if (!isGameOver && gameInterval) {
    isPaused = !isPaused
    document.getElementById('pauseBtn').innerHTML = isPaused ? '<i class="fas fa-play"></i> 继续' : '<i class="fas fa-pause"></i> 暂停'
  }
}

// 游戏结束
function gameOver() {
  isGameOver = true
  clearInterval(gameInterval)
  clearInterval(timeInterval)
  gameInterval = null

  finalScoreElement.textContent = score
  gameOverElement.classList.add('show')

  // 播放游戏结束音效
  if (isSoundOn) {
    playSound('gameover')
  }

  // 更新排行榜
  updateLeaderboard('玩家', score)
}

// 播放音效
function playSound(type) {
  // 在实际应用中,这里会播放音频文件
  console.log(`Playing sound: ${type}`)
}

// 更新排行榜
function updateLeaderboard(name, score) {
  // 这里简化处理,实际应用中会保存到本地存储或服务器
  const leaderboard = document.getElementById('leaderboard')
  console.log(`Updating leaderboard with ${name}: ${score}`)
}

// 开始计时器
function startTimer() {
  clearInterval(timeInterval)
  gameTime = 0
  updateTimer()

  timeInterval = setInterval(() => {
    if (!isPaused && !isGameOver) {
      gameTime++
      updateTimer()
    }
  }, 1000)
}

// 更新计时器显示
function updateTimer() {
  const minutes = Math.floor(gameTime / 60)
  const seconds = gameTime % 60
  timeElement.textContent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}

// 切换音效
function toggleSound() {
  isSoundOn = !isSoundOn
  document.getElementById('soundBtn').innerHTML = isSoundOn ? '<i class="fas fa-volume-up"></i> 音效: 开' : '<i class="fas fa-volume-mute"></i> 音效: 关'
}

// 保存设置
function saveSettings() {
  const settings = {
    speed: document.getElementById('gameSpeed').value,
    gridSize: document.getElementById('gridSize').value,
    wallMode: document.getElementById('wallMode').value,
    sound: isSoundOn
  }

  localStorage.setItem('snakeGameSettings', JSON.stringify(settings))
  alert('设置已保存!')
}

// 加载设置
function loadSettings() {
  const savedSettings = localStorage.getItem('snakeGameSettings')
  if (savedSettings) {
    const settings = JSON.parse(savedSettings)
    document.getElementById('gameSpeed').value = settings.speed
    document.getElementById('speedValue').textContent = settings.speed
    speedElement.textContent = settings.speed
    document.getElementById('gridSize').value = settings.gridSize
    document.getElementById('wallMode').value = settings.wallMode
    isSoundOn = settings.sound

    // 更新音效按钮状态
    document.getElementById('soundBtn').innerHTML = isSoundOn ? '<i class="fas fa-volume-up"></i> 音效: 开' : '<i class="fas fa-volume-mute"></i> 音效: 关'

    // 更新游戏速度
    gameSpeed = 210 - settings.speed * 10
  }
}

// 重置设置
function resetSettings() {
  localStorage.removeItem('snakeGameSettings')
  document.getElementById('gameSpeed').value = 5
  document.getElementById('speedValue').textContent = 5
  speedElement.textContent = 5
  document.getElementById('gridSize').value = 20
  document.getElementById('wallMode').value = 'solid'
  isSoundOn = true

  document.getElementById('soundBtn').innerHTML = '<i class="fas fa-volume-up"></i> 音效: 开'
  gameSpeed = 160

  alert('设置已重置为默认值!')
}

// 切换标签页
function switchTab(tabName) {
  // 隐藏所有标签页
  document.querySelectorAll('.tab-content').forEach(tab => {
    tab.classList.remove('active')
  })

  // 取消所有标签的激活状态
  document.querySelectorAll('.tab').forEach(tab => {
    tab.classList.remove('active')
  })

  // 激活选中的标签页
  document.getElementById(`${tabName}Tab`).classList.add('active')

  // 激活选中的标签
  document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active')

  // 根据标签页显示/隐藏编辑器
  if (tabName === 'level-editor') {
    document.getElementById('levelEditor').style.display = 'block'
    isEditing = true
  } else {
    document.getElementById('levelEditor').style.display = 'none'
    isEditing = false
  }

  // 根据标签页显示/隐藏多人游戏状态
  if (tabName === 'multiplayer') {
    document.getElementById('multiplayerStatus').style.display = 'flex'
    isMultiplayer = true
  } else {
    document.getElementById('multiplayerStatus').style.display = 'none'
    isMultiplayer = false
  }
}

// 初始化事件监听
function initEvents() {
  // 键盘控制
  document.addEventListener('keydown', e => {
    // 防止按键滚动页面
    if (e.key.startsWith('Arrow')) {
      e.preventDefault()
    }

    // 根据按键改变方向(防止180度转向)
    switch (e.key) {
      case 'ArrowUp':
        if (dy === 0) {
          dx = 0
          dy = -1
        }
        break
      case 'ArrowDown':
        if (dy === 0) {
          dx = 0
          dy = 1
        }
        break
      case 'ArrowLeft':
        if (dx === 0) {
          dx = -1
          dy = 0
        }
        break
      case 'ArrowRight':
        if (dx === 0) {
          dx = 1
          dy = 0
        }
        break
      case ' ':
        pauseGame()
        break
    }
  })

  // 标签切换事件
  document.querySelectorAll('.tab').forEach(tab => {
    tab.addEventListener('click', () => {
      switchTab(tab.dataset.tab)
    })
  })

  // 按钮事件
  document.getElementById('startBtn').addEventListener('click', startGame)
  document.getElementById('pauseBtn').addEventListener('click', pauseGame)
  document.getElementById('restartBtn').addEventListener('click', startGame)
  document.getElementById('soundBtn').addEventListener('click', toggleSound)
  document.getElementById('saveBtn').addEventListener('click', saveSettings)
  document.getElementById('resetSettings').addEventListener('click', resetSettings)
  document.getElementById('createLobby').addEventListener('click', initMultiplayerGame)

  // 移动端控制按钮
  document.getElementById('upBtn').addEventListener('click', () => {
    if (dy === 0) {
      dx = 0
      dy = -1
    }
  })

  document.getElementById('downBtn').addEventListener('click', () => {
    if (dy === 0) {
      dx = 0
      dy = 1
    }
  })

  document.getElementById('leftBtn').addEventListener('click', () => {
    if (dx === 0) {
      dx = -1
      dy = 0
    }
  })

  document.getElementById('rightBtn').addEventListener('click', () => {
    if (dx === 0) {
      dx = 1
      dy = 0
    }
  })

  // 速度滑块事件
  document.getElementById('gameSpeed').addEventListener('input', e => {
    const speedValue = e.target.value
    document.getElementById('speedValue').textContent = speedValue
    speedElement.textContent = speedValue
    gameSpeed = 210 - speedValue * 10

    if (gameInterval) {
      clearInterval(gameInterval)
      gameInterval = setInterval(gameLoop, gameSpeed)
    }
  })

  // 网格大小改变事件
  document.getElementById('gridSize').addEventListener('change', e => {
    gridSize = parseInt(e.target.value)
    tileCount = canvas.width / gridSize
    initGame()
  })

  // 上传功能
  document.getElementById('uploadBg').addEventListener('click', () => {
    document.getElementById('bgUpload').click()
  })

  document.getElementById('uploadSkin').addEventListener('click', () => {
    document.getElementById('skinUpload').click()
  })

  document.getElementById('bgUpload').addEventListener('change', handleBackgroundUpload)
  document.getElementById('skinUpload').addEventListener('change', handleSkinUpload)

  // 编辑器工具选择
  document.getElementById('wallTool').addEventListener('click', () => {
    currentTool = 'wall'
  })

  document.getElementById('obstacleTool').addEventListener('click', () => {
    currentTool = 'obstacle'
  })

  document.getElementById('eraseTool').addEventListener('click', () => {
    currentTool = 'erase'
  })

  // 画布点击事件(用于关卡编辑器)
  canvas.addEventListener('click', e => {
    if (!isEditing) return

    const rect = canvas.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top

    const gridX = Math.floor(x / gridSize)
    const gridY = Math.floor(y / gridSize)

    if (currentTool === 'wall') {
      // 添加墙壁
      obstacles.push({ x: gridX, y: gridY })
    } else if (currentTool === 'obstacle') {
      // 添加障碍物
      obstacles.push({ x: gridX, y: gridY })
    } else if (currentTool === 'erase') {
      // 删除墙壁或障碍物
      obstacles = obstacles.filter(obs => !(obs.x === gridX && obs.y === gridY))
    }

    draw()
  })
}

// 处理背景上传
function handleBackgroundUpload(e) {
  const file = e.target.files[0]
  if (file) {
    const reader = new FileReader()
    reader.onload = function(event) {
      // 创建背景图像
      const bgImage = new Image()
      bgImage.onload = function() {
        // 应用背景
        ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height)
      }
      bgImage.src = event.target.result
    }
    reader.readAsDataURL(file)
  }
}

// 处理皮肤上传
function handleSkinUpload(e) {
  const file = e.target.files[0]
  if (file) {
    const reader = new FileReader()
    reader.onload = function(event) {
      // 在实际应用中,这里会设置蛇的皮肤
      console.log('Skin uploaded:', file.name)
      alert(`蛇皮肤 "${file.name}" 上传成功!`)
    }
    reader.readAsDataURL(file)
  }
}

// 初始化游戏
function init() {
  loadSettings()
  initEvents()
  initGame()
}

// 启动游戏
window.onload = init

七、性能优化策略

  1. Canvas渲染优化:使用离屏渲染和局部重绘技术
  2. 游戏循环控制:基于requestAnimationFrame的自适应帧率
  3. 内存管理:及时释放不再使用的资源
  4. 数据序列化:高效的游戏状态保存与加载

这个增强版贪吃蛇游戏展示了如何通过现代Web技术赋予经典游戏新的生命力。它不仅保留了原始游戏的简单乐趣,还通过多人模式、成就系统和关卡编辑器等现代游戏元素,创造了深度和广度都大大扩展的游戏体验。 这种开发模式证明了即使是最简单的游戏概念,也可以通过技术创新和用户体验优化,发展成为吸引现代玩家的复杂产品。对于开发者而言,这个项目展示了如何平衡传统与创新,在保留经典魅力的同时引入现代功能。 最终,这个贪吃蛇游戏的进化之旅提醒我们,优秀的游戏设计不仅仅是关于尖端技术或复杂机制,而是关于创造引人入胜的体验,让玩家愿意一次又一次地回来享受游戏的乐趣。

Flexbox布局上手:10分钟告别垂直居中难题

2025年9月11日 09:45

你是否曾经为网页布局中的垂直居中问题头疼过?传统的CSS布局方法让我们在实现垂直居中时写了大量代码,甚至不得不使用一些"黑魔法"技巧。今天,就将Flexbox布局做一个小小的总结,只需10分钟,就可以完全理解透彻。

那就从概念都爱实际例子这几个步骤进行吧。

什么是Flexbox?

Flexbox(弹性盒子布局)是CSS3中一种新的布局模式,专门为解决复杂布局而设计。它让我们可以更轻松地创建响应式布局,特别是处理元素在容器中的对齐和分布。

核心概念:Flex容器和Flex项目

使用Flexbox只需要记住两个核心概念:

  • Flex容器:设置了display: flex的元素
  • Flex项目:Flex容器内的直接子元素
<div class="container"> <!-- Flex容器 -->
  <div class="item">项目1</div> <!-- Flex项目 -->
  <div class="item">项目2</div> <!-- Flex项目 -->
  <div class="item">项目3</div> <!-- Flex项目 -->
</div>

快速开始

让我们创建一个Flex容器:

.container {
  display: flex; /* 这就创建了一个Flex容器 */
}

就是这么简单!现在.container的所有直接子元素都变成了Flex项目,会自动排列在一行上。

解决垂直居中问题

现在来到重点:如何实现垂直居中?传统方法需要各种技巧,但Flexbox只需几行代码:

.container {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center;    /* 垂直居中 */
  height: 300px; /* 需要给容器一个高度 */
}

看!就这么简单,容器内的所有项目都会在水平和垂直方向上都居中显示。

实际例子

让我们创建一个简单的示例来演示Flexbox的强大之处:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Flexbox示例</title>
  <style>
    .container {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh; /* 视口高度 */
      background-color: #f0f0f0;
    }
    
    .box {
      width: 100px;
      height: 100px;
      background-color: #4CAF50;
      color: white;
      display: flex;
      justify-content: center;
      align-items: center;
      margin: 10px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="box">1</div>
    <div class="box">2</div>
    <div class="box">3</div>
  </div>
</body>
</html>

在这个例子中,我们创建了一个全屏高度的容器,里面的三个盒子在水平和垂直方向上都完美居中。

image.png

其他常用Flexbox属性

除了居中外,Flexbox还有很多实用属性:

  1. flex-direction:控制项目排列方向(行或列)

    .container {
      flex-direction: row; /* 默认值,水平排列 */
      flex-direction: column; /* 垂直排列 */
    }
    
  2. flex-wrap:控制项目是否换行

    .container {
      flex-wrap: wrap; /* 允许换行 */
    }
    
  3. flex:控制项目的伸缩比例

    .item {
      flex: 1; /* 项目会平均分配剩余空间 */
    }
    

浏览器支持

现代浏览器对Flexbox的支持已经非常好了。对于旧版浏览器,可以考虑使用自动前缀工具(如Autoprefixer)来添加必要的浏览器前缀。

总结

Flexbox彻底改变了CSS布局的方式,特别是解决了令人头疼的垂直居中问题。只需要记住display: flex创建容器,justify-content控制水平对齐,align-items控制垂直对齐,你就掌握了Flexbox的核心用法。

tailwindcss 究竟比 unocss 快多少?

作者 icebreaker
2025年9月10日 20:02

tailwind-v-uno.jpg

tailwindcss 究竟比 unocss 快多少?

前言

大家好,我是去年一篇测评 《unocss 究竟比 tailwindcss 快多少?》 的作者 icebreaker

一晃到了 2025 年tailwindcss@4 也正式发布了,现在最新版本是 4.1.13

新版本不仅在功能和性能上大升级,甚至定位也发生了变化: 从一个 PostCSS 插件变成了样式预处理器

与此同时,unocss 也一直在进步,一路也更新到了 66.5.1,新的 preset-wind4 写法上也对 tailwindcss@4 做了一定的兼容。

但有一点还是不一样:它还没办法像 tailwindcss@4 一样,把所有配置都直接写在 css 里。

开始测试

这次测试,我还是沿用了去年的基准用例,不过加了更多场景。

比如,我在里面加入了等量的 @apply 指令,来模拟真实开发时的情况。这样一来,不管是 tailwindcss 还是 unocss,都得老老实实去解析 CSS AST,算是“加点负重”。

测试环境保持一致,依旧还是我的老伙计 MacBook M1 Pro (2021)(想换新的 M4 Pro了)

200 次,提取并生成 1656 个工具类,取 75% 分位数(避免极端值干扰)。

测试代码大家也可以自己跑跑 👉 源代码

测试报告

测试结果如下:

2025/9/10 16:41:50
1656 utilities | x200 runs (75% build time)

none                                         14.03 ms / delta.      0.00 ms
@tailwindcss/vite        v4.1.13            114.44 ms / delta.    100.41 ms (x1.00)
@tailwindcss/postcss     v4.1.13            229.34 ms / delta.    215.31 ms (x2.14)
unocss/vite              v66.5.1            330.98 ms / delta.    316.95 ms (x3.16)
tailwindcss3             v3.4.17            710.17 ms / delta.    696.14 ms (x6.93)
@unocss/postcss          v66.5.1            864.50 ms / delta.    850.47 ms (x8.47)

分析结果

从数据里可以很直观地看出几个结论:

  • 最快的是 tailwindcss@vite,平均 114ms
  • 最慢的是 @unocss/postcss,接近 864ms
  • @tailwindcss/vite vs unocss/vite
    • unocss/vite(330ms)对比 tailwindcss@vite(114ms),大概 慢 3 倍
  • postcss 模式的开销真的很大
    • tailwindcss@postcss 比 vite 版本慢一倍(229ms)。
    • @unocss/postcss 更是接近 vitetailwindcss@47.5 倍
  • 老的 tailwindcss@3710ms)基本没法和新版本比,性能差距太明显。

因为这个结果,所以这篇文章起了这个标题 tailwindcss 究竟比 unocss 快多少?,正好和去年的反过来了。

为什么会这样?

个人总结了一些原因:

  1. tailwindcss@4 的技术升级

    • 它的 Token 提取器用 Rust 重写,效率高很多。(可能这点加了大分)
    • 定位从 PostCSS 插件转为“预处理器”,内部对 AST 解析和构建做了深度优化。
    • Vite 集成模式下,性能直接拉满。
  2. unocss 的灵活性代价

    • unocss 胜在灵活和可扩展,但灵活带来额外性能开销。
    • 特别是 runtime 动态生成规则、插件抽象这些地方,都会拖慢速度。
    • PostCSS 模式下表现更差。
  3. vite 的加成

    • vite 本身 HMR 和插件体系就很快。
    • tailwindcss@vite 能直接吃到 vite 的缓存和优化红利。

我们应该选用哪个方案?

从生态的角度考虑

从生态上来说,tailwindcss 基本上是“既成事实的标准”

无论是前端社区里大大小小的 UI 组件库,还是各种脚手架、模版项目,AI 生成的代码,大多数都优先支持 tailwindcss。

比如:

  • UI 组件库:像 shadcn/uidaisyUIflowbite 等,几乎全是基于 tailwindcss 打造。
  • 框架模版Next.jsNuxtRemixAstro 的官方或社区 starter 里,大多数开箱即配好 tailwindcss
  • 文档和教程tailwindcss 的学习资料、视频课程、最佳实践文章,数量远超 unocss

换句话说,如果你用 tailwindcss,几乎可以无缝接入整个生态,不用自己花太多心思去适配。

所以,如果你项目需要稳定的生态支持、丰富的组件库,首选 tailwindcss 基本没悬念

从自定义和灵活性的角度考虑

但如果你追求的是极致的灵活性,那 unocss 依旧是很强的选项。

unocss 的特点是:

  1. 规则引擎化:你可以像写正则一样,自定义规则来生成工具类。
  2. 预设体系:除了官方的 @unocss/preset-uno,还能叠加 attributifyiconstypography 等预设,甚至自己写预设。
  3. 任意属性模式:不仅仅是类名,甚至可以用类似 text="red-500" 这样的写法。

这类灵活性,在 tailwindcss 里要么需要写 plugin,要么使用内联样式。而在 unocss 里就是一条正则规则的事情。

而且 unocss 能够使用 Attributify 模式

<!-- unocss 支持直接在属性里写 -->
<div flex="~ col" text="center gray-700" m="y-4">
  <p>Hello World</p>
</div>

这种写法比 tailwindcss 的“长串 class”要简洁很多,特别适合喜欢 HTML 语义化的人。

不过 unocss 的灵活性,导致 unocss-merge 相对难产, www.npmjs.com/package/uno… 这个包没人用,个位数下载量。

不像 tailwind-merge 已经成为各个 tailwindcss 组件库的标配了。(Weekly Downloads 将近1千200万次)

所以结论是:

  • tailwindcss = 生态第一,几乎是“官方标配”。
  • unocss = 灵活度第一,适合“想自己捏规则”的场景。

结语

今天的数据用一句话总结:

  • tailwindcss 的性能全面超越 unocss

所以,如果你追求开发体验 + 构建速度,那现在毫无疑问是 tailwindcss@4 + vite 最优解。

最后,也再期待一波 unocss 的大更新,再和 tailwindcss 它们之间相互卷起来,未来给我们开发者带来更多的惊喜!

10个你可能不知道的实用CSS技巧,立竿见影提升开发效率

2025年9月10日 10:57

CSS是前端开发的基石,但它的奥秘远不止colormargin。掌握一些实用的高级技巧,能让你在开发中少写代码,事半功倍,瞬间提升效率。下面总结的这10个技巧,也许对你会有些许帮助!

1. 使用 :empty 隐藏空元素

场景:有时动态生成内容时,某些元素可能会空着,导致页面上出现不必要的空白或边框。

技巧:使用 :empty 伪类可以直接隐藏这些空元素,无需编写额外的JavaScript逻辑。

/* 隐藏没有任何内容(包括空格、换行)的元素 */
.container:empty {
    display: none;
}

注意:只有真正空无一物(无内容、无空格、无换行)的元素才会被匹配。

2. 精灵图(CSS Sprites)与 background-position

场景:页面有多个小图标(Icon),每次加载都产生多个HTTP请求,影响性能。

技巧:将所有小图标合并到一张图上(精灵图),然后通过 background-position 来精准定位每个图标。这是老牌但极其高效的性能优化技巧。

.icon {
    background-image: url('sprite.png');
    background-repeat: no-repeat;
    display: inline-block;
    width: 24px;
    height: 24px;
}

.icon-home {
    /* 将背景图向左移动0px,向上移动0px,显示出“家”图标 */
    background-position: 0 0;
}

.icon-user {
    /* 将背景图向左移动24px,显示出下一个图标 */
    background-position: -24px 0;
}

3. 超好用的 currentColor 关键字

场景:想让一个元素的边框、阴影或背景颜色和它的文字颜色保持一致,但又不想写重复的值。

技巧:使用 currentColor 关键字,它代表当前元素的 color 值。修改 color,所有用到 currentColor 的属性都会自动改变。

.box {
    color: #ff5733; /* 文字颜色 */
    border: 2px solid currentColor; /* 边框颜色和文字颜色一致 */
    box-shadow: 0 0 10px currentColor; /* 阴影颜色也和文字颜色一致 */
    background-color: rgba(255, 87, 51, 0.1); /* 稍微麻烦点,但思路类似 */
}

4. 使用 calc() 进行动态计算

场景:想要一个元素的宽度是“100% - 50px”,这种动态计算在响应式布局中非常常见。

技巧:使用 calc() 函数,它可以在CSS中进行简单的数学运算,加减乘除都没问题。

.sidebar {
    width: 250px;
    float: left;
}

.main-content {
    /* 宽度等于父元素的100%减去侧边栏的宽度和外边距 */
    width: calc(100% - 250px - 20px);
    float: left;
    margin-left: 20px;
}

5. 使用 filter 制作毛玻璃效果

场景:想实现那种半透明模糊的苹果风毛玻璃(Glassmorphism)背景效果。

技巧:使用 backdrop-filter: blur() 为元素后面的区域添加模糊效果。

.frosted-glass {
    background-color: rgba(255, 255, 255, 0.2); /* 半透明背景 */
    backdrop-filter: blur(10px); /* 关键:模糊背景 */
    -webkit-backdrop-filter: blur(10px); /*  Safari 支持 */
    padding: 20px;
    border-radius: 10px;
}

6. 灵活控制大小写的 text-transform

场景:从API或数据库获取的文本大小写不规范,但又想在页面上统一显示。

技巧:用 text-transform 直接控制文本的大小写,无需在JavaScript中处理。

.uppercase {
    text-transform: uppercase; /* 全部大写 */
}
.lowercase {
    text-transform: lowercase; /* 全部小写 */
}
.capitalize {
    text-transform: capitalize; /* 每个单词首字母大写 */
}

7. 防止文本换行溢出 text-overflow

场景:单行文字内容过长,希望超出容器部分用“...”省略号表示。

技巧:配合 white-space: nowrap(不换行)和 overflow: hidden(隐藏溢出),使用 text-overflow: ellipsis 实现省略号。

.ellipsis {
    width: 200px;
    white-space: nowrap; /* 1. 强制一行显示 */
    overflow: hidden; /* 2. 隐藏溢出内容 */
    text-overflow: ellipsis; /* 3. 溢出显示... */
}

8. 使用 object-fit 处理图片比例

场景:用户上传的头像图片尺寸比例不一,直接设置宽高会导致图片被拉伸变形。

技巧:使用 object-fit 属性,让图片在容器内以类似 background-size 的方式显示。

.avatar {
    width: 150px;
    height: 150px;
    border-radius: 50%;
    object-fit: cover; /* 关键:填充容器,并保持比例,可能裁剪图片 */
}

cover(覆盖),contain(包含),fill(填充)等值非常实用。

9. 原生CSS变量(Custom Properties)

场景:网站有主题色,到处都在用,哪天要换颜色得一个一个找。

技巧:在 :root 选择器上定义CSS变量(自定义属性),在整个项目中引用它。

:root {
    --main-color: #3498db;
    --accent-color: #e74c3c;
    --default-radius: 8px;
}

.button {
    background-color: var(--main-color);
    border-radius: var(--default-radius);
    color: white;
    padding: 10px 20px;
}

.header {
    color: var(--accent-color);
}

要修改主题,只需在 :root 里改一次变量值,全局生效!

10. 一行代码实现居中布局

场景:让一个元素在水平和垂直方向上居中,一直是经典的布局问题。

技巧:现代CSS给出了终极简洁方案:Flexbox 和 Grid。

Flexbox 终极居中:

.parent-flex {
    display: flex;
    justify-content: center; /* 水平居中 */
    align-items: center; /* 垂直居中 */
    height: 300px;
}

Grid 终极居中(更简洁):

.parent-grid {
    display: grid;
    place-items: center; /* 一行代码,两者都居中 */
    height: 300px;
}

告别 margin: 0 auto; 和绝对定位的复杂计算吧!


这次文章就分享到这里了!感谢各位大神们的观看!

只用2行CSS实现响应式布局,比媒体查询更优雅的布局方案

作者 前端Hardy
2025年9月9日 18:10

你是不是有过这样的经历:辛辛苦苦做好的网页,在电脑上看起来整整齐齐,但一放到手机或平板上,布局就变得乱七八糟,要么挤成一团,要么空白一大片。

然后你上网一搜解决办法,全都是让你写一堆看起来超复杂的 @media(媒体查询)代码,不同的屏幕尺寸就要写不同的样式,光是想想就头大。

别担心!其实,只需要 2 行 CSS 代码,就能让你的一排排卡片、图片、内容块自动适应屏幕宽度,再也不用写繁琐的媒体查询了!

这就是 CSS Grid 的 auto-fillauto-fit 属性。

先想再学

假设我们要做一排卡片,每个卡片最小宽度是 200px。我们希望:

  • 屏幕很宽时,自动显示很多列,并且每列平均分配剩余空间。

  • 屏幕变窄时,卡片自动换行,保证布局不会乱。

以往:可能需要写 3-4 个媒体查询,分别针对手机、平板、电脑。

现在:只需要给卡片的爸爸(父容器)加上 2 行 CSS!

.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px; /* (可选)增加卡片之间的间距 */
}

把这段代码放到样式里,无论你怎么拉浏览器的窗口,卡片都会自动调整排列

auto-fit演示效果

代码拆解:理解每部分的作用

这行核心代码 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 看起来有点复杂,我们把它拆开揉碎了讲,保证你能懂!

display: grid:将元素定义为网格容器

grid-template-columns:定义网格的列数和每列的宽度

repeat() :重复函数,避免重复书写相同的模式

auto-fit:自动调整网格轨道数量以填充容器

minmax(200px, 1fr) :定义每列的最小和最大宽度

minmax 函数详解

  • minmax()函数接受两个参数:最小值和最大值。例如:

    minmax(200px, 1fr)表示列宽最小 200px,最大为 1fr(剩余空间的等分)

  • minmax(auto, 300px)表示高度自适应内容,但不超过 300px

fr 单位的作用

  • fr(fraction)单位按比例分配剩余空间。例如:

grid-template-columns: 2fr 1fr 1fr 会创建三列,比例为 2:1:1

auto-fill vs auto-fit:关键区别

虽然两者都用于自动创建网格,但它们的行为有重要区别:

特性 auto-fill auto-fit
行为特点 保留空轨道,保持网格结构,内容不拉伸 折叠空轨道,让内容拉伸
内容拉伸 不拉伸内容 拉伸内容填满容器
适用场景 需要固定网格数的布局(日历、表格、固定布局) 需要内容自适应填满的布局(卡片、图片)

auto-fill (填充模式) :它会老老实实地生成 10 个位置,5 个有内容块,另外 5 个是空的但占着位置。这样你的 5 个块宽度就是固定的 200px,不会变。

auto-fit (适配模式) :它很聪明!它发现后面 5 个位置是空的,就会把它们折叠掉,然后让已有的 5 个内容块自动拉伸变宽,平分一整行的空间

auto-fit和auto-fill 对比演示

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>auto-fit 和 auto-fill 对比演示</title>
    <style>
        .container {
            display: grid;
            gap: 16px; /* 设置网格间隙 */
        }
        /* auto-fill 效果 - 保留空位 */
        .container.fill {
            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
            background-color: #ffe6e6; /* 浅红色背景,方便看容器范围 */
        }
        /* auto-fit 效果 - 拉伸填充 */
        .container.fit {
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            background-color: #e6ffe6; /* 浅绿色背景 */
        }
        .item {
            background-color: rgb(141, 141, 255);
            height: 100px;
            border-radius: 8px;
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <h2>auto-fill (保留空轨道,适合日历/表格)</h2>
    <div class="container fill">
      <div class="item">卡片1</div>
      <div class="item">卡片2</div>
      <div class="item">卡片3</div>
    </div>

    <h2>auto-fit (拉伸填充,适合卡片/相册)</h2>
    <div class="container fit">
      <div class="item">卡片1</div>
      <div class="item">卡片2</div>
      <div class="item">卡片3</div>
    </div>
</body>
</html>

下次当你再需要做响应式布局时,别第一时间就想着一堆媒体查询了。试试这个,让它帮你自动搞定,省时又省力!

Auto-Fit vs Auto-Fill 演练场

Auto-Fit vs Auto-Fill演练场


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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Auto-Fit vs Auto-Fill 可视化对比</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: #333;
            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
            min-height: 100vh;
            padding: 2rem;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        header {
            text-align: center;
            margin-bottom: 3rem;
            padding: 2rem;
            background: rgba(255, 255, 255, 0.8);
            border-radius: 12px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
        }

        h1 {
            font-size: 2.5rem;
            margin-bottom: 1rem;
            color: #2c3e50;
        }

        .subtitle {
            font-size: 1.2rem;
            color: #7f8c8d;
            max-width: 800px;
            margin: 0 auto;
        }

        .comparison {
            display: flex;
            gap: 2rem;
            margin-bottom: 3rem;
        }

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

        .panel {
            flex: 1;
            background: white;
            border-radius: 12px;
            overflow: hidden;
            box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
        }

        .panel-header {
            padding: 1.5rem;
            background: #3498db;
            color: white;
            text-align: center;
        }

        .auto-fill .panel-header {
            background: #e74c3c;
        }

        .auto-fit .panel-header {
            background: #2ecc71;
        }

        .panel-content {
            padding: 2rem;
            border: 2px dashed #ddd;
            margin: 1rem;
            border-radius: 8px;
            transition: all 0.3s ease;
            position: relative;
            min-height: 300px;
        }

        .auto-fill .panel-content {
            background: rgba(231, 76, 60, 0.05);
        }

        .auto-fit .panel-content {
            background: rgba(46, 204, 113, 0.05);
        }

        .grid {
            display: grid;
            gap: 1rem;
            grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
        }

        .auto-fit .grid {
            grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
        }

        .card {
            background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
            color: white;
            border-radius: 8px;
            padding: 1.5rem;
            text-align: center;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            min-height: 120px;
            transition: transform 0.3s ease, box-shadow 0.3s ease;
        }

        .auto-fill .card {
            background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
        }

        .auto-fit .card {
            background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
        }

        .card:hover {
            transform: translateY(-5px);
            box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
        }

        .card-number {
            font-size: 2rem;
            font-weight: bold;
            margin-bottom: 0.5rem;
        }

        .controls {
            background: white;
            padding: 2rem;
            border-radius: 12px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
            margin-bottom: 2rem;
        }

        .control-group {
            margin-bottom: 1.5rem;
        }

        .control-group h3 {
            margin-bottom: 0.5rem;
            color: #2c3e50;
        }

        .slider-container {
            display: flex;
            align-items: center;
            gap: 1rem;
        }

        .slider {
            flex: 1;
            -webkit-appearance: none;
            height: 8px;
            border-radius: 4px;
            background: #ddd;
            outline: none;
        }

        .slider::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 20px;
            height: 20px;
            border-radius: 50%;
            background: #3498db;
            cursor: pointer;
        }

        .auto-fill .slider::-webkit-slider-thumb {
            background: #e74c3c;
        }

        .auto-fit .slider::-webkit-slider-thumb {
            background: #2ecc71;
        }

        .value-display {
            min-width: 60px;
            text-align: center;
            font-weight: bold;
        }

        .button-group {
            display: flex;
            gap: 1rem;
        }

        button {
            padding: 0.8rem 1.5rem;
            border: none;
            border-radius: 4px;
            background: #3498db;
            color: white;
            font-weight: bold;
            cursor: pointer;
            transition: background 0.3s ease;
        }

        button:hover {
            background: #2980b9;
        }

        .explanation {
            background: white;
            padding: 2rem;
            border-radius: 12px;
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
        }

        .explanation h2 {
            margin-bottom: 1rem;
            color: #2c3e50;
        }

        .key-differences {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 2rem;
            margin-top: 2rem;
        }

        @media (max-width: 768px) {
            .key-differences {
                grid-template-columns: 1fr;
            }
        }

        .difference {
            padding: 1.5rem;
            border-radius: 8px;
        }

        .auto-fill-difference {
            background: rgba(231, 76, 60, 0.1);
            border-left: 4px solid #e74c3c;
        }

        .auto-fit-difference {
            background: rgba(46, 204, 113, 0.1);
            border-left: 4px solid #2ecc71;
        }

        .difference h3 {
            display: flex;
            align-items: center;
            margin-bottom: 1rem;
        }

        .difference-icon {
            margin-right: 0.5rem;
            font-size: 1.5rem;
        }

        .grid-track-visual {
            height: 4px;
            background: rgba(255, 255, 255, 0.5);
            margin-top: 0.5rem;
            border-radius: 2px;
        }

        .grid-track-indicator {
            height: 100%;
            background: white;
            border-radius: 2px;
            width: 100%;
        }

        .code-snippet {
            background: #2d3436;
            color: #dfe6e9;
            padding: 1rem;
            border-radius: 4px;
            font-family: 'Fira Code', monospace;
            margin: 1rem 0;
            overflow-x: auto;
        }

        .property {
            color: #81ecec;
        }

        .value {
            color: #fab1a0;
        }
    </style>
</head>
<body>
    <div class="container">
        <header>
            <h1>Auto-Fit vs Auto-Fill 可视化对比</h1>
            <p class="subtitle">通过实时演示直观了解CSS Grid中auto-fit和auto-fit属性的区别,以及它们如何影响响应式布局</p>
        </header>

        <div class="controls">
            <div class="control-group">
                <h3>容器宽度控制</h3>
                <div class="slider-container">
                    <span></span>
                    <input type="range" min="200" max="1000" value="800" class="slider" id="width-slider">
                    <span></span>
                    <span class="value-display" id="width-value">800px</span>
                </div>
            </div>

            <div class="control-group">
                <h3>卡片数量</h3>
                <div class="slider-container">
                    <span></span>
                    <input type="range" min="1" max="12" value="6" class="slider" id="card-slider">
                    <span></span>
                    <span class="value-display" id="card-value">6 卡片</span>
                </div>
            </div>

            <div class="button-group">
                <button id="reset-btn">重置演示</button>
                <button id="toggle-cards-btn">切换卡片可见性</button>
            </div>
        </div>

        <div class="comparison">
            <div class="panel auto-fill">
                <div class="panel-header">
                    <h2>Auto-Fill 布局</h2>
                    <p>保留空轨道,适合需要固定网格结构的布局</p>
                </div>
                <div class="panel-content" id="auto-fill-container">
                    <div class="grid" id="auto-fill-grid">
                        <div class="card"><div class="card-number">1</div>卡片</div>
                        <div class="card"><div class="card-number">2</div>卡片</div>
                        <div class="card"><div class="card-number">3</div>卡片</div>
                        <div class="card"><div class="card-number">4</div>卡片</div>
                        <div class="card"><div class="card-number">5</div>卡片</div>
                        <div class="card"><div class="card-number">6</div>卡片</div>
                    </div>
                </div>
            </div>

            <div class="panel auto-fit">
                <div class="panel-header">
                    <h2>Auto-Fit 布局</h2>
                    <p>折叠空轨道,内容拉伸填充可用空间</p>
                </div>
                <div class="panel-content" id="auto-fit-container">
                    <div class="grid" id="auto-fit-grid">
                        <div class="card"><div class="card-number">1</div>卡片</div>
                        <div class="card"><div class="card-number">2</div>卡片</div>
                        <div class="card"><div class="card-number">3</div>卡片</div>
                        <div class="card"><div class="card-number">4</div>卡片</div>
                        <div class="card"><div class="card-number">5</div>卡片</div>
                        <div class="card"><div class="card-number">6</div>卡片</div>
                    </div>
                </div>
            </div>
        </div>

        <div class="explanation">
            <h2>理解 Auto-Fit 和 Auto-Fill</h2>
            <p>CSS Grid布局中的<code>auto-fit</code><code>auto-fill</code>关键字允许我们创建灵活的响应式布局,而无需编写复杂的媒体查询。它们都用于<code>repeat()</code>函数中,但与<code>minmax()</code>结合使用时,行为有所不同。</p>

            <div class="code-snippet">
                <span class="property">grid-template-columns</span>: <span class="value">repeat(<select id="code-type">
                    <option value="auto-fill">auto-fill</option>
                    <option value="auto-fit" selected>auto-fit</option>
                </select>, minmax(150px, 1fr))</span>;
            </div>

            <div class="key-differences">
                <div class="difference auto-fill-difference">
                    <h3><span class="difference-icon">↔️</span> Auto-Fill 行为</h3>
                    <p>Auto-fill会尽可能多地创建网格轨道,即使没有网格项填充它们。空轨道仍然占用空间,影响布局。</p>
                    <ul>
                        <li>保持网格结构完整</li>
                        <li>空轨道保持最小宽度</li>
                        <li>适合需要严格对齐的布局</li>
                        <li>在内容数量变化但网格结构需要保持不变时非常有用</li>
                    </ul>
                </div>

                <div class="difference auto-fit-difference">
                    <h3><span class="difference-icon">↕️</span> Auto-Fit 行为</h3>
                    <p>Auto-fit会折叠任何空轨道,并拉伸现有网格项以填充可用空间。它更注重内容的填充而非网格结构的保留。</p>
                    <ul>
                        <li>折叠空轨道</li>
                        <li>内容拉伸填满容器</li>
                        <li>适合卡片、画廊和内容块布局</li>
                        <li>在希望内容充分利用可用空间时非常有用</li>
                    </ul>
                </div>
            </div>
        </div>
    </div>

    <script>
        // 获取DOM元素
        const widthSlider = document.getElementById('width-slider');
        const widthValue = document.getElementById('width-value');
        const cardSlider = document.getElementById('card-slider');
        const cardValue = document.getElementById('card-value');
        const autoFillContainer = document.getElementById('auto-fill-container');
        const autoFitContainer = document.getElementById('auto-fit-container');
        const autoFillGrid = document.getElementById('auto-fill-grid');
        const autoFitGrid = document.getElementById('auto-fit-grid');
        const resetBtn = document.getElementById('reset-btn');
        const toggleCardsBtn = document.getElementById('toggle-cards-btn');
        const codeType = document.getElementById('code-type');

        // 初始化卡片
        const initialCards = Array.from(autoFillGrid.children).map(card => card.outerHTML);

        // 更新容器宽度
        function updateContainerWidth() {
            const width = widthSlider.value;
            widthValue.textContent = `${width}px`;

            autoFillContainer.style.width = `${width}px`;
            autoFitContainer.style.width = `${width}px`;
        }

        // 更新卡片数量
        function updateCardCount() {
            const count = parseInt(cardSlider.value);
            cardValue.textContent = `${count} 卡片`;

            // 更新auto-fill网格
            autoFillGrid.innerHTML = '';
            for (let i = 0; i < count; i++) {
                autoFillGrid.innerHTML += initialCards[i % initialCards.length];
            }

            // 更新auto-fit网格
            autoFitGrid.innerHTML = '';
            for (let i = 0; i < count; i++) {
                autoFitGrid.innerHTML += initialCards[i % initialCards.length];
            }
        }

        // 切换卡片可见性
        function toggleCards() {
            const cards = document.querySelectorAll('.card');
            const isVisible = cards[0].style.opacity !== '0';

            cards.forEach(card => {
                card.style.opacity = isVisible ? '0' : '1';
                card.style.visibility = isVisible ? 'hidden' : 'visible';
            });

            toggleCardsBtn.textContent = isVisible ? '显示卡片' : '隐藏卡片';
        }

        // 重置演示
        function resetDemo() {
            widthSlider.value = 800;
            cardSlider.value = 6;
            updateContainerWidth();
            updateCardCount();

            const cards = document.querySelectorAll('.card');
            cards.forEach(card => {
                card.style.opacity = '1';
                card.style.visibility = 'visible';
            });

            toggleCardsBtn.textContent = '切换卡片可见性';
        }

        // 初始化
        updateContainerWidth();
        updateCardCount();

        // 事件监听器
        widthSlider.addEventListener('input', updateContainerWidth);
        cardSlider.addEventListener('input', updateCardCount);
        resetBtn.addEventListener('click', resetDemo);
        toggleCardsBtn.addEventListener('click', toggleCards);
        codeType.addEventListener('change', function() {
            if (this.value === 'auto-fill') {
                autoFillGrid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(150px, 1fr))';
                autoFitGrid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(150px, 1fr))';
            } else {
                autoFillGrid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(150px, 1fr))';
                autoFitGrid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(150px, 1fr))';
            }
        });
    </script>
</body>
</html>

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

前端img与background-image渲染图片对H5页面性能的影响

作者 很多石头
2025年9月8日 11:19

一、背景

最近在前端性能优化中,业务线H5总是在97.5%指标上下波动,查其原因是页面内大量使用了img元素作为装饰,通过相关系统分析,图片性能达标率波动影响最大。

二、问题分析

在固定的环境中(同一应用、相同的cnd图片地址、以vu访问量60w为基准)使用img出现单图访问错误率约为0.07%,因为对同一cdn的一批图片地址,出错率基本接近,开始猜测是cdn问题造成的,为了更准确的定位到问题,还是先从前端的角度做了分析,在出问题的img地址中没有发现background-image使用的,大胆猜测,是不是background-image引用的图片不会出错,于是在页面内拿几个指定图片,由img方式改为background-image方式渲染。 经过几天观察,确实指定的几个图片不在出现在错误列表中,出错率直接为0。

三、深入调研

于是对img和background-image做了深入的分析汇总:

1. 加载顺序与首屏渲染

  • img标签:在HTML解析时直接触发图片下载,优先加载内容相关图片,对Largest Contentful Paint(LCP) 指标更友好。例如,产品图、文章配图等语义化内容会随HTML同步加载,减少视觉闪烁。
  • background图片:通过CSS加载,需等待CSS解析完成后才下载图片。若背景图在外部CSS文件中,可能延迟首屏渲染(如全屏背景图可能导致LCP超时)。解决方案包括内联关键CSS、使用<link rel="preload">预加载背景图,或通过媒体查询按需加载。

2. 缓存与资源复用

  • img标签:每个图片独立请求,但浏览器缓存可复用相同URL的图片。若多图场景未优化(如未启用HTTP/2多路复用),可能增加请求数。
  • background图片:支持CSS雪碧图(Sprite)合并小图标,减少HTTP请求;通过background-sizemedia-query实现响应式适配,但重复使用大图可能增加内存占用。

3. 响应式与适应性

  • img标签:通过srcsetsizes属性实现设备适配(如高分辨率屏加载2x/3x图),配合<picture>元素实现艺术指导(Art Direction)。例如:

    html
    <picture>
    
      <source media="(min-width: 768px)" srcset="desktop.jpg 2x, mobile.jpg 1x">
    
      <img src="default.jpg" alt="示例">
    
    </picture>
    
  • background图片:需通过媒体查询或不同CSS类切换图片,代码量较大。例如:

    css
    .hero {
    
      background-image: url("mobile.jpg");
    
    }
    
    @media (min-width: 768px) {
    
      .hero {
    
        background-image: url("desktop.jpg");
    
      }
    
    }
    

4. 懒加载与性能优化

  • img标签:支持原生懒加载(loading="lazy"),浏览器自动延迟加载视口外图片,减少初始负载。
  • background图片:需通过JavaScript(如Intersection Observer)或内联样式实现懒加载,复杂度较高。工具如FlyingPress可自动检测并优化内联背景图的加载。

5. 渲染性能与动画

  • img标签:作为替换元素(Replace Element),浏览器直接渲染图片内容,动画性能更优。测试显示,大量img元素的360°旋转动画比background更流畅(因background涉及样式重计算)。
  • background图片:动画可能触发重排(Reflow)和重绘(Repaint),尤其在使用background-positionbackground-size时。CSS硬件加速(如transform: translateZ(0))可缓解此问题。

6. 可访问性与SEO

  • img标签:支持alt属性,对SEO和屏幕阅读器友好,适合内容相关图片(如产品图、信息图表)。
  • background图片:无语义化支持,不适合承载关键内容,但可用于装饰性元素(如渐变、纹理)。

最佳实践建议

  • 内容图片:优先使用img标签,结合srcsetsizes和懒加载优化性能,确保语义化和可访问性。
  • 装饰性图片:使用background属性,配合雪碧图、媒体查询和预加载提升效率。
  • 关键路径优化:将首屏背景图内联或预加载,避免阻塞LCP;非首屏图片使用懒加载。
  • 格式与压缩:统一采用现代格式(如WebP/AVIF),并通过工具(如Squoosh)压缩图片,减少文件体积。

四、解决问题

以此为准继续做第二次实验,将所有的装饰图片换为background-image; 经过几天观察,替换调用img作为装饰的图片,没有再出现访问出错,图片的正确率从原来的97-98% 提升稳定到到99%以上。公司要求的综合性能指标97.5%,此方面的优化提升,直接将综合性能指标由原来的97.5%上下波动到稳定到98%以上。

五、结语

个人是不习惯用img作为装饰元素来使用的,但是不同协同开发者,习惯不一样,在代码review中没有重视这种问题,也忽略了这种写法的累积对页面性能的影响。以此记录共大家参考,避免在同一个坑中崴脚。

欢迎大家共同探讨!

完美圆角,渐变边框,兼容chrome 60,两层背景的视觉差

作者 拜无忧
2025年9月7日 13:51

image.png

不直接使用 border 属性,因为它的渐变和圆角在旧浏览器上表现不佳。我们反过来,用“两层背景的视觉差”来模拟出边框。

  1. 外层容器 (.card) —— 充当“边框”

    • 设置渐变背景:我们给这个最外层的 div 设置了您想要的从上到下的渐变背景。这个渐变就是我们最终看到的“边框”颜色。
    • 定义边框宽度:通过设置 padding: 1px;,我们让这个渐变背景层有了 1px 的厚度。这个 padding 的大小,就是“边框”的宽度。
    • 设置外圆角:给它设置最终想要的 border-radius: 6px;
  2. 内层容器 (.card-content) —— 充当“内容区域”

    • 设置内容背景:这个 div 位于外层容器的 padding 内部,我们给它设置了卡片内容的实际背景色(半透明的红色)。
    • 设置内圆角:它的圆角比外层容器小 1px(即 border-radius: 5px;),这样就能严丝合缝地贴在外层容器的内侧。

最终效果:

当这个内层的 .card-content 覆盖住外层的 .card 的中心区域后,.card 的渐变背景就只有 padding 定义的那 1px 边缘能够被看见。

这样一来,视觉上就形成了一个拥有完美平滑圆角、颜色从上到下渐变的边框,并且这个方法只用了最基础的 CSS 属性,可以很好地兼容到您要求的 Chrome 60 等旧版浏览器。

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>兼容性渐变圆角边框</title>
<style>
    body {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
        margin: 0;
        background: #333;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    }

    .card {
        width: 420px;
        box-sizing: border-box;
        text-align: center;
        color: white;
        
        /* 关键点 1: 渐变背景作为“边框”,从上到下渐变并融入背景 */
        background: linear-gradient(to bottom, #E09393, #c1292e);
        
        border-radius: 6px; /* 外层容器圆角 */
        padding: 1px; /* 边框的宽度 */
        
        /* 确保背景从 padding 区域开始绘制,增强兼容性 */
        -webkit-background-clip: padding-box;
        background-clip: padding-box;
    }

    .card-content {
        padding: 40px 30px;
        /* 关键点 2: 内部内容的背景 */
        background: rgba(193, 41, 46, 0.8);
        border-radius: 5px; /* 内层圆角比外层小 1px,完美贴合 */
        backdrop-filter: blur(10px); /* 毛玻璃效果,现代浏览器支持 */
        -webkit-backdrop-filter: blur(10px);
    }
    
    .card-title {
        font-size: 24px;
        font-weight: bold;
        color: #ffd700; /* 金色标题 */
        margin: 0 0 15px 0;
    }

    .card-text {
        font-size: 16px;
        line-height: 1.6;
        opacity: 0.9;
        margin: 0 0 30px 0;
    }

    .perfect-button {
        display: inline-block;
        padding: 12px 28px;
        border: none;
        border-radius: 6px; /* 与卡片一致的圆角 */
        font-size: 16px;
        font-weight: bold;
        color: white;
        cursor: pointer;
        text-decoration: none;
        position: relative;
        overflow: hidden; /* 隐藏伪元素超出的部分 */
        z-index: 1;
        transition: color 0.3s ease;
    }

    .perfect-button::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        /* 关键点 3: 按钮也用同样的技巧 */
        background: linear-gradient(135deg, #a052ff, #56ccf2);
        z-index: -1;
        transition: opacity 0.3s ease;
    }

    .perfect-button:hover::before {
        opacity: 0.8;
    }

</style>
</head>
<body>

    <div class="card">
        <div class="card-content">
            <h2 class="card-title">完美圆角</h2>
            <p class="card-text">
                保持6px圆角的同时,渐变边框也能完美呈现圆角效果,无任何瑕疵。
            </p>
            <a href="#" class="perfect-button">完美圆角</a>
        </div>
    </div>

</body>
</html>


最全Scss语法,赶紧收藏起来吧

2025年9月6日 13:55

Sass/SCSS 的嵌套

作用

  • 减少重复:不再处处写 .card 前缀。
  • 表达结构:一眼看出父子/状态/修饰的关系。
  • 就地维护:媒体查询、状态样式和主体样式放在一起,改组件更集中。
  • BEM 更顺手:&__el&--mod&.is-xxx 自然生成。

下面我们来看下它嵌套编译的效果

.card {
  .title { color: #333; }        // 后代
  &:hover { box-shadow: ...; }   // 父的伪类
  .actions > .btn { ... }        // 组合器保留
}

编译后:

.card .title{color:#333}
.card:hover{box-shadow:...}
.card .actions > .btn{...}

变量与类型

$size: 16px;
$brand: #0b81ff;
$title: "Hello";
$on: true;
$none: null;

// 列表 & 映射
$spaces: 4px 8px 16px;
$palette: (primary: #0b81ff, success: #22c55e);
  • null 的属性会被跳过。
  • map.get($palette, primary) 取值。

选择器嵌套与父选择器 &

.card {
  padding: 16px;
  &--active { box-shadow: 0 4px 12px rgba(0,0,0,.08); } // BEM 修饰
  &:hover { transform: translateY(-1px); }               // 伪类
  .title { font-weight: 600; }                           // 子元素
}

嵌套尽量 ≤ 3 层,避免选择器过长。

插值 #{}(拼接变量)

$base: btn;
$radius: 8;
.#{$base} { border-radius: #{$radius}px; } // → .btn { border-radius: 8px }

Mixin / Include(可复用片段)

@mixin flex-center($gap: 8px) {
  display: flex; align-items: center; justify-content: center; gap: $gap;
}
.toolbar { @include flex-center(12px); }

// 带内容插槽
@mixin layer($z: 1) { position: relative; z-index: $z; @content; }
.badge { @include layer(10) { pointer-events: none; } }

Function(返回计算值)

@use "sass:math";
@function px2rem($px, $base: 16) { @return math.div($px, $base) * 1rem; }
.title { font-size: px2rem(20); }

Dart Sass 用 math.div 代替 / 除法。

控制指令

$theme: dark;

@if $theme == dark { body { background:#0f172a; color:#e2e8f0; } }
@else { body { background:#fff; color:#111; } }

@each $gap in (4px, 8px, 12px) { .gap-#{$gap} { gap: $gap; } }

@for $i from 1 through 3 { .col-#{$i} { width: (100%/3)*$i; } }

$i: 0;
@while $i < 3 { .ring-#{$i} { outline-width: $i+1px; } $i: $i + 1; }

占位选择器与继承(谨慎用)

%btn-base { font: inherit; padding: .5em 1em; border: 1px solid transparent; }
.primary { @extend %btn-base; background: #0b81ff; color: #fff; }

@extend 可能引发“选择器膨胀”,组件库更推荐用 mixin 复用样式。

模块化导入(现代写法)

/* tokens/_color.scss */
$primary: #0b81ff !default;
@mixin btn() { padding: 8px 12px; }

/* design/_index.scss —— 聚合出口 */
@forward "../tokens/color";

/* app.scss —— 使用与配置 */
@use "./design/index" as d with ($primary: #0052d9);
.button { color: d.$primary; @include d.btn(); }
  • @use:有命名空间、成员只读;用 with 配置带 !default 的变量。
  • @forward:做“总入口”(barrel),对外转发变量/函数/mixin。
  • 旧式 @import 已弃用;@import url(...)CSS 导入(不会带来 Sass 变量)。

调试与错误

@use "sass:meta";
@debug meta.type-of((a: b));
@warn "Deprecated var, will be removed.";
@error "Invalid token";

全局方法(旧写法)

颜色相关

写法 说明 示例/结果
darken(#fff, 10%) 调暗亮度 10%(不是透明度) #fff → #e6e6e6
lighten(#fff, 30%) 调亮亮度 30%(纯白几乎无变化;也不是透明度) #fff → #fff
hsl(0, 100%, 50%) 色相/饱和度/亮度 构造颜色(红) #ff0000
adjust-hue(#fff, 180deg) 色相旋转 180°(对白/灰无可见变化) #fff
saturate(#fff, 10%) 提高饱和度(对白/灰无效) #fff
desaturate(#fff, 10%) 降低饱和度 #fff
transparentize(#fff, 0.1) 增加透明度(α 减 0.1) rgba(255,255,255,0.9)
opacify(#fff, 0.1) 增加不透明度(α 加 0.1) rgba(255,255,255,1)

注:transparentize/opacify 的第二个参数是 0–1 的无单位数,不是百分比。

计算

写法 说明 示例/结果
abs(-10px) 绝对值 10px
ceil(-12.5px) 向上取整(朝 +∞) -12px
round(12.3px) 四舍五入 12px
floor(12.8px) 向下取整 12px
percentage(650px / 1000px) 比例转百分比 65%(因为 px/px → 无单位 0.65)
min(1, 2, 3) 取最小值(编译期) 1
max(1, 2, 3) 取最大值(编译期) 3

提醒:min/max 比较带单位时需同维度可换算(如 pxin);px vs rem/vw 等不同维度请用 CSSmin()/max()/clamp() 在浏览器端计算。

字符串相关

写法 说明 示例/结果
to-upper-case("hello") 转大写 "HELLO"
to-lower-case("HELLO") 转小写 "hello"
str-length("hello") 字符串长度 5
str-index("hello", "h") 返回首次出现位置(1 基;找不到为 null 1
str-insert("hello", "world", 5) 在索引处插入子串(1 基;负数为倒数) "hellworldo"

内置模块Api(新写法)

这里主要介绍 sass:color、sass:math、sass:string,其他有需要的可以自行去了解

sass:color(调色/透明度/混色)

@use "sass:color";
$brand: #1677ff;

/* 1) 按比例靠近极值(推荐,变化更自然) */
.bg-hover  { background: color.scale($brand, $lightness: 10%); }   // 变亮
.bg-active { background: color.scale($brand, $lightness: -12%); }  // 变暗
.bg-ghost  { background: color.scale($brand, $alpha: -30%); }      // 更透明

/* 2) 加/减固定量(严格步长) */
.more-sat  { background: color.adjust($brand, $saturation: 15%); }
.rotate    { background: color.adjust($brand, $hue: 30deg); }      // 旋转色相

/* 3) 设定绝对值(直接锁定到目标) */
.fixed-a   { background: color.change($brand, $alpha: 0.6); }      // α=0.6
.fixed-l   { background: color.change($brand, $lightness: 40%); }  // L=40%

/* 4) 混色(做浅/深阶) */
.light-1   { background: color.mix(#fff, $brand, 20%); }           // 更浅
.dark-1    { background: color.mix(#000, $brand, 15%); }           // 更深

/* 5) 反相(生成对比色) */
.invert    { color: color.invert($brand, 100%); }

小抄:

  • 变亮/变暗 → scale($lightness: ±x%);透明度 → scale($alpha: ±x%)
  • 固定步长 → adjust;锁定目标值 → change;两色过渡 → mix

sass:math(数值/单位运算)

@use "sass:math";

/* 1) 除法一定用 math.div */
.title { font-size: math.div(20, 16) * 1rem; }  // 1.25rem

/* 2) 取整/比较/钳制(编译期) */
.box  { margin: math.round(4.6px); }            // 5px
.maxw { max-width: math.min(960px, 1in); }      // 单位可换算才行
/* 混单位(px vs rem/vw)请用 CSS 的 min()/clamp() 在运行时算 */

/* 3) 常用函数 */
$pi: math.$pi;                 // 3.14159…
$len: math.hypot(3, 4);        // 5(向量长度)
$angle: math.atan2(1, -1);     // 135deg(可直接放到 rotate())

/* 4) 小工具:px→rem */
@function px2rem($px, $base: 16) { @return math.div($px, $base) * 1rem; }
.btn { padding: px2rem(10) px2rem(14); }

要点:

  • min/max/clamp 仅比较同维度可换算单位(px↔in 等);混单位用 CSSmin()/max()/clamp()
  • 乘除尽量“有单位 × 无单位”,避免产生非法复合单位。

sass:string(拼接/切片/大小写/引号)

@use "sass:string";

/* 1) 拼接请用插值 #{} */
$ns: "app"; $block: btn;
.selector { content: "#{$ns}-#{$block}"; }   // "app-btn"

/* 2) 长度/索引/切片/插入(索引从 1 开始,负数从尾部数) */
@debug string.length("hello");                    // 5
@debug string.index("btn--primary", "--");        // 4
@debug string.slice("abcdef", 2, 4);              // "bcd"
@debug string.insert("color", "-primary", 6);     // "color-primary"

/* 3) 大小写/引号 */
@debug string.to-upper-case("btn");               // "BTN"
@debug string.quote(btn);                         // "btn"
@debug string.unquote("bold");                    // bold(变标识符)

/* 4) 唯一 id(避免命名冲突) */
$uid: string.unique-id();                         // 比如 "u5ab9"
@keyframes fade-#{$uid} { from{opacity:0} to{opacity:1} }
.fade { animation: fade-#{$uid} .2s ease; }

小抄:

  • 串联字符串 → **插值 #{}**;结构化处理再用 length/index/slice/insert
  • quote/unquote 控制是否带引号;unique-id() 做不冲突的 keyframes/变量名。

Scss 的四种导入方式你都知道吗

2025年9月6日 00:45

想把样式拆模块、做主题、又不想全局变量乱飞?这篇把 SCSS 的四种“导入/组织”方式一次讲透:@use@forward、Sass 旧式 @import、以及 CSS 的 @import url(...)。含示例、对比表、迁移步骤与避坑清单。


@import "xxx":Sass 旧式导入(已弃用

作用
编译期把被导入的 SCSS 内容“直接拼接”到当前位置,变量/混入进入全局,容易重复加载、顺序踩坑。Dart Sass 已弃用,推荐使用@use/@forward

示例

@import "./tokens";   // 编译时把 tokens.scss 的内容贴进来(非 CSS 运行时)

@import url(...)CSS 的导入

作用
浏览器在运行时请求外部 CSS 文件;不会引入 Sass 的变量/混入/函数。

示例

@import url("/base.css");           /* 或者 @import "/print.css" print */

位置限制
必须在样式表最前面(在任何普通规则前;前面最多有 @charset / 其它 @import)。影响性能,一般不建议使用。


@use:模块化导入(推荐

作用
将另一个 SCSS 文件当作“模块”加载,默认通过命名空间访问变量/函数/混入;只加载一次、不污染全局,避免命名冲突。

示例

/* tokens/_color.scss */
$primary: #0b81ff !default;
@mixin btn { padding: 8px 12px; border-radius: 8px; }

/* app.scss */
@use "./tokens/color" as c; // 起别名 c

.button {
  color: c.$primary;
  @include c.btn();
}

配置默认变量(with + !default)

@use "./tokens/color" as c with (
  $primary: #0052d9   // 仅能配置标了 !default 的顶层公开变量
);

要点

  • 命名空间:@use "x" as x;不建议as *(去掉前缀,易冲突)。
  • 变量只读:c.$primary 不能在引入方直接赋值,只能用 with加载时配置。

@forward:聚合/转发导出

作用
把若干模块“转发”出去,做一个统一出口(barrel/index)。自己文件里不能直接使用转发来的成员;若要自用,再 @use 一次。

示例

/* design/_index.scss —— 聚合出口 */
@forward "../tokens/color" show $primary, btn;  // 只暴露想给外部用的成员
@forward "../tokens/spacing" as space-*;        // 导出时加前缀

/* app.scss —— 使用统一出口 */
@use "./design/index" as d;

.card {
  color: d.$primary;
  margin: d.$space-lg;
}

在出口处预配置主题(with)

/* design/_index.scss */
@forward "../tokens/color" with ($primary: #333);

注意:一个模块只能被配置一次。上游已通过 @forward ... with 配过,下游再配会报错。


五分钟速查表

能力/特性 @use @forward Sass @import "x" CSS @import url(...)
发生时机 编译期 编译期 编译期 运行时(浏览器请求)
命名空间 有(可 as * 去掉) 对外导出,不给本文件用 无(全局拼接)
去重加载 ❌ 可能重复 N/A(浏览器请求)
变量可配置 通过 with 配置 !default 通过 with 预配置并转发 通过“先定义后导入”覆盖(老做法)
全局污染
位置限制 任意 任意 任意(编译期) 顶部(在普通规则前)
适用场景 模块化使用 统一出口/SDK 旧项目遗留 引入纯 CSS(不推荐)

常见误区 & 快速排坑

  1. @import url('./variable.scss') 用后 $color 未定义

    • 这是 CSS 导入,不会带来 Sass 变量。
    • 用:@use "./variable" as v;color: v.$color;
  2. 我在文件里写 $primary: red,为啥 @use 来的变量没变?

    • @use 是模块隔离;你改的是当前文件$primary,不是模块里的。
    • 正确:@use "./tokens" with ($primary: red); 且源变量需 !default
  3. 去命名空间 as *@import 一样吗?

    • 不是。as * 只是省掉前缀,仍是模块系统(只加载一次、不污染全局)。
    • 有冲突风险:多个模块导出同名变量会报错。
  4. @forward 文件里用不了转发来的变量?

    • 对。@forward 只是出口;若当前文件要用,@use 一次
  5. 位置问题:为啥我的 @import 被忽略?

    • CSS 的 @import 必须在最顶部(普通规则之前)。Sass 旧式 @import 不限位置,但已经弃用。

迁移指北:从 @import@use/@forward

目录建议

styles/
  tokens/
    _color.scss     // 变量均加 !default
    _spacing.scss
  mixins/
    _typography.scss
  design/
    _index.scss     // 聚合出口(只 forward)
  app.scss          // 项目入口(只 use design)

改造步骤

  1. 给需要对外可配的变量都加 !default

  2. 建“总出口”:

    /* design/_index.scss */
    @forward "../tokens/color";
    @forward "../tokens/spacing";
    @forward "../mixins/typography";
    
  3. 入口使用:

    /* app.scss */
    @use "./design/index" as d with (
      $primary: #0052d9,
      $space-lg: 24px
    );
    
    .btn { color: d.$primary; margin: d.$space-lg; }
    
  4. 删除旧式 @import,逐步把业务文件改为 @use 指向 design/_index.scss


结语

  • 新项目:只用 @use + @forward
  • 做 SDK/设计系统:@forward 聚合成一个总入口,外部只 @use 它。
  • 变量可配:源头加 !default,引入处用 with
  • 避免 @import 与 CSS 的 @import url(...)(性能差、易踩坑)。

需要我按你的项目目录,直接给一份可跑的 @use/@forward 模板吗?把目录贴过来我就给你落地版。

❌
❌