普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月25日掘金 前端

用三行代码实现圣诞树?别逗了!让我们来真的

作者 无限大6
2025年12月25日 11:03

🎄 用三行代码实现圣诞树?别逗了!让我们来真的!

🌟 圣诞节的正确打开方式

圣诞节快到了,是不是感觉家里缺了点什么?🎅 对,就是那棵 bling bling 的圣诞树!但是买真树太麻烦,买假树又没灵魂?没关系,今天我就教你用HTML+CSS+JS打造一棵属于你的「代码圣诞树」,让你的电脑屏幕充满节日气息!🎁

🛠️ 准备工作

在开始之前,我们需要准备:

  • 一颗想搞事情的心 💡
  • 一个文本编辑器(记事本也行,但我劝你用 VS Code)
  • 一点 HTML+CSS+JS 基础
  • 还有满脑子的圣诞精神 🎄

🎨 开始制作圣诞树

第一步:搭建骨架(HTML)

首先,我们需要给圣诞树搭个骨架。就像盖房子一样,先打地基!

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>我的代码圣诞树 🎄</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="container">
      <h1>🎅 Merry Christmas! 🎄</h1>
      <div class="tree">
        <!-- 圣诞树的树干 -->
        <div class="trunk"></div>
        <!-- 圣诞树的树冠,用三个三角形组成 -->
        <div class="leaves leaves-1"></div>
        <div class="leaves leaves-2"></div>
        <div class="leaves leaves-3"></div>
        <!-- 圣诞树上的装饰品 -->
        <div class="decorations"></div>
        <!-- 树顶星星 -->
        <div class="star"></div>
      </div>
      <!-- 雪花效果 -->
      <div class="snow"></div>
      <!-- 礼物盒 -->
      <div class="gifts"></div>
    </div>
    <script src="script.js"></script>
  </body>
</html>

第二步:化妆打扮(CSS)

现在,我们需要给圣诞树穿上漂亮的衣服!这一步就像女朋友化妆,要细心!💄

/* 全局样式 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background: linear-gradient(to bottom, #1a1a2e 0%, #16213e 100%);
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  font-family: "Arial", sans-serif;
}

.container {
  text-align: center;
  position: relative;
}

/* 标题样式 */
h1 {
  color: #fff;
  margin-bottom: 30px;
  font-size: 2.5rem;
  text-shadow: 0 0 10px #ff0, 0 0 20px #ff0, 0 0 30px #ff0;
  animation: glow 2s ease-in-out infinite alternate;
}

/* 标题发光动画 */
@keyframes glow {
  from {
    text-shadow: 0 0 10px #ff0, 0 0 20px #ff0, 0 0 30px #ff0;
  }
  to {
    text-shadow: 0 0 20px #ff0, 0 0 30px #ff0, 0 0 40px #ff0;
  }
}

/* 圣诞树容器 */
.tree {
  position: relative;
  display: inline-block;
}

/* 树干样式 */
.trunk {
  width: 40px;
  height: 60px;
  background-color: #8b4513;
  position: absolute;
  bottom: -60px;
  left: 50%;
  transform: translateX(-50%);
  border-radius: 0 0 10px 10px;
}

/* 树冠样式 - 三个三角形叠加 */
.leaves {
  width: 0;
  height: 0;
  border-left: transparent solid;
  border-right: transparent solid;
  border-bottom: green solid;
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

/* 第一层树冠 */
.leaves-1 {
  border-left-width: 150px;
  border-right-width: 150px;
  border-bottom-width: 200px;
  bottom: 0;
  background: linear-gradient(to bottom, #228b22 0%, #006400 100%);
  border-radius: 50% 50% 0 0;
}

/* 第二层树冠 */
.leaves-2 {
  border-left-width: 120px;
  border-right-width: 120px;
  border-bottom-width: 160px;
  bottom: 70px;
  background: linear-gradient(to bottom, #228b22 0%, #006400 100%);
  border-radius: 50% 50% 0 0;
}

/* 第三层树冠 */
.leaves-3 {
  border-left-width: 90px;
  border-right-width: 90px;
  border-bottom-width: 120px;
  bottom: 140px;
  background: linear-gradient(to bottom, #228b22 0%, #006400 100%);
  border-radius: 50% 50% 0 0;
}

/* 树顶星星 */
.star {
  width: 0;
  height: 0;
  border-left: 25px solid transparent;
  border-right: 25px solid transparent;
  border-bottom: 43px solid #ffd700;
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 250px;
  animation: twinkle 1s ease-in-out infinite alternate;
}

/* 星星闪烁动画 */
@keyframes twinkle {
  from {
    transform: translateX(-50%) scale(1);
    opacity: 0.8;
  }
  to {
    transform: translateX(-50%) scale(1.1);
    opacity: 1;
    box-shadow: 0 0 20px #ffd700;
  }
}

/* 星星的五个角 */
.star::before,
.star::after {
  content: "";
  width: 0;
  height: 0;
  border-left: 25px solid transparent;
  border-right: 25px solid transparent;
  border-bottom: 43px solid #ffd700;
  position: absolute;
  top: 0;
  left: -25px;
}

.star::before {
  transform: rotate(72deg);
}

.star::after {
  transform: rotate(144deg);
}

/* 装饰品基础样式 */
.decoration {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  position: absolute;
  animation: blink 1.5s ease-in-out infinite alternate;
}

/* 装饰品闪烁动画 */
@keyframes blink {
  from {
    transform: scale(1);
    opacity: 0.8;
  }
  to {
    transform: scale(1.2);
    opacity: 1;
    box-shadow: 0 0 10px currentColor;
  }
}

/* 不同颜色的装饰品 */
.decoration.red {
  background-color: #ff0000;
  box-shadow: 0 0 10px #ff0000;
}

.decoration.blue {
  background-color: #0000ff;
  box-shadow: 0 0 10px #0000ff;
}

.decoration.yellow {
  background-color: #ffff00;
  box-shadow: 0 0 10px #ffff00;
}

.decoration.pink {
  background-color: #ff1493;
  box-shadow: 0 0 10px #ff1493;
}

/* 雪花样式 */
.snowflake {
  position: absolute;
  background-color: #fff;
  border-radius: 50%;
  animation: fall linear infinite;
  opacity: 0.8;
}

/* 雪花下落动画 */
@keyframes fall {
  from {
    transform: translateY(-100px) rotate(0deg);
    opacity: 0;
  }
  10% {
    opacity: 0.8;
  }
  to {
    transform: translateY(100vh) rotate(360deg);
    opacity: 0;
  }
}

/* 礼物盒容器 */
.gifts {
  position: absolute;
  bottom: -100px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 20px;
}

/* 礼物盒样式 */
.gift {
  width: 60px;
  height: 60px;
  position: relative;
  animation: bounce 2s ease-in-out infinite;
}

/* 礼物盒弹跳动画 */
@keyframes bounce {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-10px);
  }
}

/* 不同颜色的礼物盒 */
.gift.red {
  background-color: #ff0000;
}

.gift.green {
  background-color: #008000;
}

.gift.blue {
  background-color: #0000ff;
}

.gift.yellow {
  background-color: #ffff00;
}

/* 礼物盒丝带 */
.gift::before,
.gift::after {
  content: "";
  position: absolute;
  background-color: #fff;
}

.gift::before {
  width: 100%;
  height: 10px;
  top: 50%;
  transform: translateY(-50%);
}

.gift::after {
  width: 10px;
  height: 100%;
  left: 50%;
  transform: translateX(-50%);
}

第三步:让它动起来(JS)

现在,我们的圣诞树还只是个「静态美人」,让我们用 JavaScript 给它注入灵魂!✨

// 圣诞树装饰品生成
function createDecorations() {
  const decorationsContainer = document.querySelector(".decorations");
  const colors = ["red", "blue", "yellow", "pink"];
  const count = 20;

  for (let i = 0; i < count; i++) {
    const decoration = document.createElement("div");
    decoration.className = `decoration ${
      colors[Math.floor(Math.random() * colors.length)]
    }`;

    // 随机位置(在树冠范围内)
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.random() * 120 + 30;
    const x = Math.cos(angle) * radius;
    const y = Math.sin(angle) * radius - 100;

    decoration.style.left = `calc(50% + ${x}px)`;
    decoration.style.bottom = `${y}px`;
    decoration.style.animationDelay = `${Math.random() * 2}s`;

    decorationsContainer.appendChild(decoration);
  }
}

// 雪花生成器
function createSnow() {
  const snowContainer = document.querySelector(".snow");
  const snowflakeCount = 100;

  for (let i = 0; i < snowflakeCount; i++) {
    const snowflake = document.createElement("div");
    snowflake.className = "snowflake";

    // 随机大小
    const size = Math.random() * 8 + 2;
    snowflake.style.width = `${size}px`;
    snowflake.style.height = `${size}px`;

    // 随机位置
    snowflake.style.left = `${Math.random() * 100}vw`;

    // 随机下落速度
    const duration = Math.random() * 10 + 5;
    snowflake.style.animationDuration = `${duration}s`;

    // 随机延迟
    snowflake.style.animationDelay = `${Math.random() * 5}s`;

    snowContainer.appendChild(snowflake);
  }
}

// 礼物盒生成
function createGifts() {
  const giftsContainer = document.querySelector(".gifts");
  const colors = ["red", "green", "blue", "yellow"];
  const count = 4;

  for (let i = 0; i < count; i++) {
    const gift = document.createElement("div");
    gift.className = `gift ${
      colors[Math.floor(Math.random() * colors.length)]
    }`;
    gift.style.animationDelay = `${i * 0.5}s`;
    giftsContainer.appendChild(gift);
  }
}

// 页面加载完成后执行
window.addEventListener("DOMContentLoaded", () => {
  createDecorations();
  createSnow();
  createGifts();
});

🎉 让圣诞树跑起来

现在,让我们把所有代码合并到一个完整的 HTML 文件中,你可以直接复制下面的代码保存为 christmas-tree.html,然后用浏览器打开它,就能看到你的圣诞树了!🎄

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>🎄 我的代码圣诞树</title>
    <style>
      /* 全局样式 */
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        background: linear-gradient(to bottom, #1a1a2e 0%, #16213e 100%);
        height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
        overflow: hidden;
        font-family: "Arial", sans-serif;
        margin: 0;
        padding: 0;
      }

      .container {
        text-align: center;
        position: relative;
        height: 500px;
        width: 600px;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        margin: 0 auto;
      }

      /* 标题样式 */
      h1 {
        color: #fff;
        margin-bottom: 100px;
        font-size: 2.5rem;
        text-shadow: 0 0 10px #ff0, 0 0 20px #ff0, 0 0 30px #ff0;
        animation: glow 2s ease-in-out infinite alternate;
        z-index: 20;
        position: relative;
      }

      /* 标题发光动画 */
      @keyframes glow {
        from {
          text-shadow: 0 0 10px #ff0, 0 0 20px #ff0, 0 0 30px #ff0;
        }
        to {
          text-shadow: 0 0 20px #ff0, 0 0 30px #ff0, 0 0 40px #ff0;
        }
      }

      /* 圣诞树容器 */
      .tree {
        position: relative;
        display: inline-block;
      }

      /* 树干样式 */
      .trunk {
        width: 40px;
        height: 60px;
        background-color: #8b4513;
        position: absolute;
        bottom: -60px;
        left: 50%;
        transform: translateX(-50%);
        border-radius: 0 0 10px 10px;
      }

      /* 树冠样式 - 三个三角形叠加 */
      .leaves {
        width: 0;
        height: 0;
        position: absolute;
        left: 50%;
        transform: translateX(-50%);
        filter: drop-shadow(0 0 10px rgba(0, 255, 0, 0.3));
      }

      /* 第一层树冠 */
      .leaves-1 {
        border-left: 150px solid transparent;
        border-right: 150px solid transparent;
        border-bottom: 200px solid #2e8b57;
        bottom: 0;
        animation: sway 3s ease-in-out infinite alternate;
      }

      /* 第二层树冠 */
      .leaves-2 {
        border-left: 120px solid transparent;
        border-right: 120px solid transparent;
        border-bottom: 160px solid #3cb371;
        bottom: 70px;
        animation: sway 3s ease-in-out infinite alternate-reverse;
      }

      /* 第三层树冠 */
      .leaves-3 {
        border-left: 90px solid transparent;
        border-right: 90px solid transparent;
        border-bottom: 120px solid #228b22;
        bottom: 140px;
        animation: sway 3s ease-in-out infinite alternate;
      }

      /* 树摇摆动画 */
      @keyframes sway {
        from {
          transform: translateX(-50%) rotate(-1deg);
        }
        to {
          transform: translateX(-50%) rotate(1deg);
        }
      }

      /* 树顶星星 - 使用更简单的方式实现 */
      .star {
        width: 50px;
        height: 50px;
        background-color: #ffd700;
        clip-path: polygon(
          50% 0%,
          61% 35%,
          98% 35%,
          68% 57%,
          79% 91%,
          50% 70%,
          21% 91%,
          32% 57%,
          2% 35%,
          39% 35%
        );
        position: absolute;
        left: 50%;
        transform: translateX(-50%);
        bottom: 250px;
        animation: twinkle 1s ease-in-out infinite alternate;
        z-index: 10;
      }

      /* 星星闪烁动画 */
      @keyframes twinkle {
        from {
          transform: translateX(-50%) scale(1);
          opacity: 0.8;
        }
        to {
          transform: translateX(-50%) scale(1.1);
          opacity: 1;
          box-shadow: 0 0 20px #ffd700;
        }
      }

      /* 装饰品基础样式 */
      .decoration {
        width: 20px;
        height: 20px;
        border-radius: 50%;
        position: absolute;
        animation: blink 1.5s ease-in-out infinite alternate;
        box-shadow: 0 0 10px currentColor;
      }

      /* 装饰品闪烁动画 */
      @keyframes blink {
        from {
          transform: scale(1) rotate(0deg);
          opacity: 0.8;
        }
        to {
          transform: scale(1.3) rotate(360deg);
          opacity: 1;
          box-shadow: 0 0 20px currentColor, 0 0 30px currentColor;
        }
      }

      /* 不同颜色的装饰品,增加发光效果 */
      .decoration.red {
        background-color: #ff0000;
        box-shadow: 0 0 15px #ff0000, inset 0 0 5px rgba(255, 255, 255, 0.5);
      }

      .decoration.blue {
        background-color: #0000ff;
        box-shadow: 0 0 15px #0000ff, inset 0 0 5px rgba(255, 255, 255, 0.5);
      }

      .decoration.yellow {
        background-color: #ffff00;
        box-shadow: 0 0 15px #ffff00, inset 0 0 5px rgba(255, 255, 255, 0.5);
      }

      .decoration.pink {
        background-color: #ff1493;
        box-shadow: 0 0 15px #ff1493, inset 0 0 5px rgba(255, 255, 255, 0.5);
      }

      /* 添加一些不同大小的装饰品 */
      .decoration.large {
        width: 25px;
        height: 25px;
      }

      .decoration.small {
        width: 15px;
        height: 15px;
        animation-duration: 2s;
      }

      /* 雪花样式 */
      .snowflake {
        position: absolute;
        background-color: #fff;
        border-radius: 50%;
        animation: fall linear infinite;
        opacity: 0.8;
      }

      /* 雪花下落动画 */
      @keyframes fall {
        from {
          transform: translateY(-100px) rotate(0deg);
          opacity: 0;
        }
        10% {
          opacity: 0.8;
        }
        to {
          transform: translateY(100vh) rotate(360deg);
          opacity: 0;
        }
      }

      /* 礼物盒容器 */
      .gifts {
        position: absolute;
        bottom: -80px;
        left: 50%;
        transform: translateX(-50%);
        display: flex;
        gap: 25px;
        z-index: 5;
      }

      /* 礼物盒样式 - 立体效果 */
      .gift {
        width: 50px;
        height: 40px;
        position: relative;
        animation: bounce 2s ease-in-out infinite;
        border-radius: 3px;
        box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
      }

      /* 礼物盒弹跳动画 - 更自然的效果 */
      @keyframes bounce {
        0%,
        100% {
          transform: translateY(0) scale(1);
        }
        50% {
          transform: translateY(-15px) scale(1.05);
        }
      }

      /* 不同颜色的礼物盒,添加渐变和立体效果 */
      .gift.red {
        background: linear-gradient(135deg, #ff0000 0%, #cc0000 100%);
      }

      .gift.green {
        background: linear-gradient(135deg, #008000 0%, #006400 100%);
      }

      .gift.blue {
        background: linear-gradient(135deg, #0000ff 0%, #0000cc 100%);
      }

      .gift.yellow {
        background: linear-gradient(135deg, #ffff00 0%, #cccc00 100%);
      }

      /* 礼物盒盖子 - 立体效果 */
      .gift::before {
        content: "";
        position: absolute;
        top: -8px;
        left: 0;
        right: 0;
        height: 8px;
        background: linear-gradient(
          135deg,
          rgba(255, 255, 255, 0.3) 0%,
          rgba(255, 255, 255, 0.1) 100%
        );
        border-radius: 2px 2px 0 0;
        box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2);
      }

      /* 礼物盒丝带 - 更美观的设计 */
      .gift::after {
        content: "";
        position: absolute;
        background-color: #fff;
        width: 8px;
        height: 100%;
        left: 50%;
        transform: translateX(-50%);
        box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
      }

      /* 礼物盒底部丝带 */
      .gift {
        position: relative;
      }

      /* 礼物盒丝带装饰 */
      .gift span {
        position: absolute;
        background-color: #fff;
        width: 100%;
        height: 8px;
        top: 50%;
        transform: translateY(-50%);
        box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>🎅 Merry Christmas! 🎄</h1>
      <div class="tree">
        <!-- 圣诞树的树干 -->
        <div class="trunk"></div>
        <!-- 圣诞树的树冠,用三个三角形组成 -->
        <div class="leaves leaves-1"></div>
        <div class="leaves leaves-2"></div>
        <div class="leaves leaves-3"></div>
        <!-- 圣诞树上的装饰品 -->
        <div class="decorations"></div>
        <!-- 树顶星星 -->
        <div class="star"></div>
      </div>
      <!-- 雪花效果 -->
      <div class="snow"></div>
      <!-- 礼物盒 -->
      <div class="gifts"></div>
    </div>
    <script>
      // 圣诞树装饰品生成
      function createDecorations() {
        const decorationsContainer = document.querySelector(".decorations");
        const colors = ["red", "blue", "yellow", "pink"];
        const sizes = ["", "large", "small"];
        const count = 25; // 增加数量,让树更丰富

        for (let i = 0; i < count; i++) {
          const decoration = document.createElement("div");
          decoration.className = `decoration ${
            colors[Math.floor(Math.random() * colors.length)]
          } ${sizes[Math.floor(Math.random() * sizes.length)]}`;

          // 简单的随机位置,确保在树内部
          const x = Math.random() * 200 - 100; // -100到100之间
          const y = Math.random() * 180; // 0到180之间

          // 确保在三角形树冠范围内
          const distanceFromCenter = Math.abs(x);
          const maxWidthAtHeight = 150 - (y / 180) * 100;

          if (distanceFromCenter < maxWidthAtHeight) {
            decoration.style.left = `calc(50% + ${x}px)`;
            decoration.style.bottom = `${y}px`;
            decoration.style.animationDelay = `${Math.random() * 2}s`;
            decoration.style.zIndex = 2;

            decorationsContainer.appendChild(decoration);
          }
        }
      }

      // 雪花生成器
      function createSnow() {
        const snowContainer = document.querySelector(".snow");
        const snowflakeCount = 100;

        for (let i = 0; i < snowflakeCount; i++) {
          const snowflake = document.createElement("div");
          snowflake.className = "snowflake";

          // 随机大小
          const size = Math.random() * 8 + 2;
          snowflake.style.width = `${size}px`;
          snowflake.style.height = `${size}px`;

          // 随机位置
          snowflake.style.left = `${Math.random() * 100}vw`;

          // 随机下落速度
          const duration = Math.random() * 10 + 5;
          snowflake.style.animationDuration = `${duration}s`;

          // 随机延迟
          snowflake.style.animationDelay = `${Math.random() * 5}s`;

          snowContainer.appendChild(snowflake);
        }
      }

      // 礼物盒生成
      function createGifts() {
        const giftsContainer = document.querySelector(".gifts");
        const colors = ["red", "green", "blue", "yellow"];
        const count = 5; // 增加一个礼物盒

        for (let i = 0; i < count; i++) {
          const gift = document.createElement("div");
          gift.className = `gift ${
            colors[Math.floor(Math.random() * colors.length)]
          }`;
          gift.style.animationDelay = `${i * 0.3}s`;

          // 添加丝带装饰
          const ribbon = document.createElement("span");
          gift.appendChild(ribbon);

          giftsContainer.appendChild(gift);
        }
      }

      // 页面加载完成后执行
      window.addEventListener("DOMContentLoaded", () => {
        createDecorations();
        createSnow();
        createGifts();
      });
    </script>
  </body>
</html>

🎨 代码解析

1. 圣诞树的结构 🏗️

圣诞树的结构其实很简单:

  • 树干:一个棕色的长方形
  • 树冠:三个大小不一的三角形叠加在一起
  • 树顶星星:一个金色的五角星(用 CSS 边框实现)
  • 装饰品:彩色的小圆点,随机分布在树冠上
  • 雪花:白色的小圆点,从天上飘落
  • 礼物盒:彩色的正方形,带有白色丝带

2. CSS 的魔法 ✨

  • 渐变背景:让树干和树冠看起来更有层次感
  • 动画效果
    • 标题发光动画 glow
    • 星星闪烁动画 twinkle
    • 装饰品闪烁动画 blink
    • 雪花下落动画 fall
    • 礼物盒弹跳动画 bounce
  • 定位技巧:使用 position: absolutetransform: translateX(-50%) 让元素居中

3. JavaScript 的灵魂 🧠

  • 动态生成装饰品:随机位置、随机颜色、随机闪烁延迟
  • 雪花生成器:100 片雪花,随机大小、随机速度、随机位置
  • 礼物盒生成:4 个不同颜色的礼物盒,带有弹跳效果

🎁 扩展功能

如果你觉得这个圣诞树还不够炫酷,你可以尝试:

  1. 添加音乐:用 HTML5 的 audio 标签添加圣诞歌曲 🎵
  2. 交互效果:点击圣诞树会下雪或播放音乐 🎶
  3. 3D 效果:使用 CSS 3D 变换让圣诞树旋转 🌀
  4. 更多装饰品:添加彩灯、铃铛、袜子等 🧦

🤣 程序员的圣诞节

作为一个程序员,我们的圣诞节是这样的:

  • 别人在装饰圣诞树,我们在装饰代码
  • 别人在拆礼物,我们在拆 bug
  • 别人在吃火鸡,我们在吃外卖
  • 别人在看春晚,我们在看技术文档

但是没关系,我们有属于自己的快乐!当看到自己写的圣诞树在屏幕上闪闪发光时,那种成就感是无法言喻的!🌟

🎄 结语

好了,今天的圣诞树教程就到这里了!希望你能喜欢这个代码圣诞树,也希望你能在圣诞节收获满满的快乐和幸福!🎅

记住,生活就像圣诞树,需要我们用心去装饰,才能变得更加美好!✨

最后,祝大家:

  • 圣诞快乐!🎄
  • 代码无 bug!🐛❌
  • 工资涨不停!💰
  • 永远不脱发!👨‍💻👩‍💻

Merry Christmas and Happy New Year! 🎉


💡 小贴士:如果你觉得这个圣诞树不错,别忘了分享给你的朋友,让他们也感受一下程序员的圣诞浪漫!😂

Vue3 调用 Coze 工作流:从上传宠物照到生成冰球明星的完整技术解析

作者 AAA阿giao
2025年12月25日 11:00

 引言

“你家的猫,也能打冰球?”
不是玩笑——这是一次前端与 AI 工作流的完美邂逅。

在当今 AI 应用爆发的时代,开发者不再满足于调用单一模型 API,而是通过 工作流(Workflow) 编排多个能力节点,实现复杂业务逻辑。而前端作为用户交互的第一线,如何优雅地集成这些 AI 能力,成为现代 Web 开发的重要课题。

本文将带你深入剖析一个真实项目:使用 Vue3 前端调用 Coze 平台的工作流 API,上传一张宠物照片,生成穿着定制队服、手持冰球杆的运动员形象图。我们将逐行解读 App.vue 源码,解释每一个 API 调用、每一段逻辑设计,并结合完整的 Coze 工作流图解,还原整个数据流转过程。文章内容严格引用原始代码(一字不变),确保技术细节 100% 准确。


一、项目背景与目标

AI 应用之冰球前端应用 vue3:冰球协会,上传宠物照片,生成运动员的形象照片。

这个应用的核心功能非常明确:

  • 用户上传一张宠物(或人物)照片;
  • 选择冰球队服编号、颜色、场上位置、持杆手、艺术风格等参数;
  • 点击“生成”,系统调用 AI 工作流;
  • 返回一张合成后的“冰球运动员”图像。

而这一切的实现,完全依赖于 Coze 平台提供的工作流 API。前端负责收集输入、上传文件、发起请求、展示结果——典型的“轻前端 + 重 AI 后端”架构。


二、App.vue 整体结构概览

App.vue 是一个标准的 Vue3 单文件组件(SFC),采用 <script setup> 语法糖,结合 Composition API 实现响应式逻辑。整体分为三部分:

  1. <template> :用户界面(UI)
  2. <script setup> :业务逻辑(JS)
  3. <style scoped> :样式(CSS)

我们先从模板入手,理解用户看到什么、能做什么。


三、模板(Template)详解:用户交互层

3.1 文件上传与预览

<div class="file-input">
  <input 
    type="file" 
    ref="uploadImage" 
    accept="image/*" 
    @change="updateImageData" required />
</div>
<img :src="imgPreview" alt="" v-if="imgPreview"/>
  • <input type="file">:原生文件选择器,限制只接受图片(accept="image/*")。
  • ref="uploadImage":通过 ref 获取该 DOM 元素,便于 JS 中读取文件。
  • @change="updateImageData":当用户选择文件后,立即触发 updateImageData 方法,生成本地预览。
  • imgPreview 是一个响应式变量,用于显示 Data URL 格式的预览图,无需上传即可看到效果。

用户体验亮点:即使图片很大、上传很慢,用户也能立刻确认自己选对了图。

3.2 表单参数设置

接下来是两组设置项,全部使用 v-model 双向绑定:

第一组:队服信息

<div class="settings">
  <div class="selection">
    <label>队服编号:</label>
    <input type="number" v-model="uniform_number"/>
  </div>
  <div class="selection">
    <label>队服颜色:</label>
    <select v-model="uniform_color">
      <option value="红"></option>
      <option value="蓝"></option>
      <option value="绿">绿</option>
      <option value="白"></option>
      <option value="黑"></option>
    </select>
  </div>
</div>
  • uniform_number:默认值为 10(见 script 部分),支持任意数字。
  • uniform_color:限定五种颜色,值为中文字符串(如 "红")。

第二组:角色与风格

<div class="settings">
  <div class="selection">
    <label>位置:</label>
    <select v-model="position">
      <option value="0">守门员</option>
      <option value="1">前锋</option>
      <option value="2">后卫</option>
    </select>
  </div>
  <div class="selection">
    <label>持杆:</label>
    <select v-model="shooting_hand">
      <option value="0">左手</option>
      <option value="1">右手</option>
    </select>
  </div>
  <div class="selection">
    <label>风格:</label>
    <select v-model="style">
      <option value="写实">写实</option>
      <option value="乐高">乐高</option>
      <option value="国漫">国漫</option>
      <option value="日漫">日漫</option>
      <option value="油画">油画</option>
      <option value="涂鸦">涂鸦</option>
      <option value="素描">素描</option>
    </select>
  </div>
</div>
  • positionshooting_hand 的值虽然是数字字符串("0"/"1"/"2"),但前端显示为中文,兼顾可读性与后端兼容性。
  • style 提供 7 种艺术风格,极大增强趣味性和分享欲。

3.3 生成按钮与输出区域

<div class="generate">
  <button @click="generate">生成</button>
</div>

点击后触发 generate() 函数,启动整个 AI 生成流程。

输出区域:

<div class="output">
  <div class="generated">
    <img :src="imgUrl" alt="" v-if="imgUrl"/>
    <div v-if="status">{{ status }}</div>
  </div>
</div>
  • imgUrl:存储 Coze 返回的生成图 URL。
  • status:动态显示当前状态(如“上传中…”、“生成失败”等),避免用户焦虑。

💡 设计哲学:状态反馈是良好 UX 的核心。没有反馈的“生成”按钮,等于黑盒。


四、脚本逻辑(Script Setup)深度解析

现在进入最核心的部分——JavaScript 逻辑。

4.1 环境配置与常量定义

import { ref, onMounted } from 'vue'

const patToken = import.meta.env.VITE_PAT_TOKEN;
const uploadUrl = 'https://api.coze.cn/v1/files/upload';
const workflowUrl = 'https://api.coze.cn/v1/workflow/run';
const workflow_id = '7584046136391630898';
  • import.meta.env.VITE_PAT_TOKEN:Vite 提供的环境变量注入机制。.env 文件中应包含:

    VITE_PAT_TOKEN=cztei_lvNwngHgch9rxNlx4KiXuky3UjfW9iqCZRe17KDXjh22RLL8sPLsb8Vl10R3IHJsW
    
  • uploadUrl:Coze 官方文件上传接口(文档)。

  • workflowUrl:触发工作流的入口(文档)。

  • workflow_id:在 Coze 控制台创建的工作流唯一 ID,内部已配置好图像生成逻辑(如调用文生图模型、叠加队服等)。

⚠️ 安全警告:将 PAT Token 放在前端仅适用于演示或内部工具。生产环境应通过后端代理 API,避免 Token 泄露。

4.2 响应式状态声明

const uniform_number = ref(10);
const uniform_color = ref('红');
const position = ref(0);
const shooting_hand = ref(0);
const style = ref('写实');

const status = ref('');
const imageUrl = ref('');
  • 所有表单字段均为 ref 响应式对象,确保视图自动更新。
  • status 初始为空,后续将显示:“图片上传中...” → “图片上传成功, 正在生成...” → 成功清空 或 错误信息。
  • imageUrl 初始为空,生成成功后赋值为图片 URL。

4.3 核心函数 1:图片预览(updateImageData)

const uploadImage = ref(null);
const imgPreview = ref('');

const updateImageData = () => {
  const input = uploadImage.value;
  if (!input.files || input.files.length === 0) {
    return;
  }
  const file = input.files[0];
  const reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onload = (e) => {
    imgPreview.value = e.target.result;
  };
}
  • uploadImage 是对 <input> 元素的引用。
  • 使用 FileReaderreadAsDataURL 方法,将文件转为 Base64 编码的 Data URL。
  • onload 回调中,将结果赋给 imgPreview,触发 <img> 标签渲染。

优势:纯前端实现,零网络请求,秒级响应。

4.4 核心函数 2:文件上传(uploadFile)

const uploadFile = async () => {
  const formData = new FormData();
  const input = uploadImage.value;
  if (!input.files || input.files.length <= 0) return;
  formData.append('file', input.files[0]);

  const res = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  });

  const ret = await res.json();
  console.log(ret);
  if (ret.code !== 0) {
    status.value = ret.msg;
    return;
  }
  return ret.data.id;
}

逐行解析:

  1. 构造 FormData

    • new FormData() 是浏览器原生 API,用于构建 multipart/form-data 请求体,专为文件上传设计。
    • formData.append('file', file):Coze 要求字段名为 file
  2. 发送 POST 请求

    • URL:https://api.coze.cn/v1/files/upload

    • Headers:

      • Authorization: Bearer <token>:Coze 使用 Bearer Token 认证。
    • Body:formData 自动设置正确 Content-Type(含 boundary)。

  3. 处理响应

    • 成功时返回:

      { "code": 0, "msg": "success", "data": { "id": "file_xxx", ... } }
      
    • 失败时 code !== 0msg 包含错误原因(如 Token 无效、文件过大等)。

    • 函数返回 file_id(如 "file_abc123"),供下一步使用。

关键点:Coze 的文件上传是独立步骤,必须先上传获取 file_id,才能在工作流中引用。


五、核心函数 3:调用工作流(generate)

这是整个应用的“大脑”。我们结合 Coze 工作流图,深入分析其逻辑与数据流。

const generate = async () => {
  status.value = "图片上传中...";
  const file_id = await uploadFile();
  if (!file_id) return;

  status.value = "图片上传成功, 正在生成...";

  const parameters = {
    picture: JSON.stringify({ file_id }),
    style: style.value,
    uniform_color: uniform_color.value,
    uniform_number: uniform_number.value,
    position: position.value,
    shooting_hand: shooting_hand.value,
  };

  try {
    const res = await fetch(workflowUrl, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${patToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ workflow_id, parameters })
    });

    const ret = await res.json();
    console.log("Workflow API response:", ret);
    if (ret.code !== 0) {
      status.value = ret.msg;
      return;
    }

    // 检查返回数据结构
    console.log("Return data:", ret.data);
    console.log("Return data type:", typeof ret.data);

    // 尝试解析数据
    let data;
    if (typeof ret.data === 'string') {
      try {
        data = JSON.parse(ret.data);
        console.log("Parsed data:", data);
      } catch (e) {
        console.error("JSON parse error:", e);
        status.value = "数据解析错误";
        return;
      }
    } else {
      data = ret.data;
    }

    // 检查data.data是否存在
    if (data && data.data) {
      console.log("Generated image URL:", data.data);
      status.value = '';
      imageUrl.value = data.data;
    } else {
      console.error("Invalid data structure, missing 'data' field:", data);
      status.value = "返回数据结构错误";
    }
  } catch (error) {
    console.error("Generate error:", error);
    status.value = "生成失败,请检查网络连接";
  }
}

逻辑拆解(结合 Coze 工作流图)

Coze 工作流结构(图解说明)

图注

  1. 开始节点:接收 picture, style, uniform_number, position, shooting_hand, uniform_color 等参数。
  2. 分支一imgUnderstand_1(图像理解)→ 分析上传图片内容(如动物种类、姿态)。
  3. 分支二代码 节点 → 根据 position, shooting_hand, style 等生成描述文本(如“一只狗,右手持杆,身穿红色10号队服,站在冰球场上”)。
  4. 大模型节点:将图像理解结果与描述文本合并,生成最终提示词(prompt)。
  5. 图像生成节点:调用文生图模型(如豆包·1.5·Pro·32k),生成新图像。
  6. 结束节点:输出生成图的 URL。

前端代码的对应关系

前端参数 Coze 输入字段 用途
picture picture 图片文件 ID,传入 imgUnderstand_1图像生成 节点
style style 传递给 代码 节点,决定艺术风格
uniform_number uniform_number 用于生成描述
position position 决定角色动作(如守门员蹲姿)
shooting_hand shooting_hand 决定持杆手
uniform_color uniform_color 用于生成队服颜色

💡 关键点:前端只需提供原始参数,Coze 工作流内部完成所有逻辑编排。


数据流全过程

  1. 前端上传文件 → 得到 file_id

  2. 前端组装参数 → 发送至 /workflow/run

  3. Coze 工作流执行

    • imgUnderstand_1:分析图片内容 → 输出 text, url, content
    • 代码 节点:根据参数生成描述 → 如 "一只猫,身穿蓝色10号队服,右手持杆,站在冰球场上,风格为乐高"
    • 大模型 节点:合并图像理解结果与描述 → 生成最终 prompt
    • 图像生成 节点:调用模型生成图像 → 返回 data 字段(URL)
  4. 前端接收响应

    • ret.data 是字符串 → 尝试 JSON.parse
    • 若是对象 → 直接取 data.data
    • 最终赋值给 imageUrl

为什么需要双重解析?
因为 Coze 的“图像生成”节点可能直接返回 URL 字符串,也可能返回 { data: "url" } 结构。前端必须兼容两种情况。


六、样式(Style)简析

<style scoped>
.container {
  display: flex;
  flex-direction: row;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: .85rem;
}
.generated {
  width: 400px;
  height: 400px;
  border: solid 1px black;
  display: flex;
  justify-content: center;
  align-items: center;
}
.output img {
  width: 100%;
}
</style>
  • 使用 Flex 布局,左右分栏(输入区固定宽度,输出区自适应)。
  • .generated 容器固定 400x400,图片居中显示,无论原始比例如何都不变形。
  • scoped 确保样式仅作用于当前组件,避免污染全局。

七、项目运行

在项目终端运行命令 :npm run dev

运行界面如下:

选择图片及风格等内容后,点击开始生成,运行结果如图:


总结:为什么这个项目值得学习?

  1. 真实场景:不是 Hello World,而是完整产品逻辑。

  2. 技术全面

    • Vue3 Composition API
    • 文件上传与预览
    • Fetch API 与错误处理
    • 环境变量管理
    • 响应式状态驱动 UI
  3. AI 集成范式:展示了如何将复杂 AI 能力封装为简单 API,前端只需“填参数 + 拿结果”。

  4. 用户体验优先:状态提示、本地预览、错误反馈一应俱全。

安全与部署建议

  • 后端代理所有 Coze API 调用

    • 前端 → 自己的后端(/api/generate)
    • 后端 → Coze(携带安全存储的 Token)
  • 限制工作流权限:Coze 的 PAT Token 应仅授予必要权限。

  • 添加速率限制:防止滥用。

最终,技术的意义在于创造快乐。
当你上传一张狗子的照片,看到它穿上红色10号球衣、右手持杆、以“乐高”风格站在冰场上——
你会笑,会分享,会说:“AI 真酷!”

而这,正是我们写代码的初心。

完整项目源码:lesson_zp/ai/app/iceball: AI + 全栈学习仓库 - Gitee.com

vue3这些常见指令你封装了吗

作者 林太白
2025年12月25日 10:53

::: tip

个人网站 (nexuslin.github.io/

源码地址,欢迎star,你的star是我努力的动力!

【GIthub地址】(github.com/lintaibai/T…

【Gitee地址】(gitee.com/lintaibai/T…

:::

vue3这些常见指令你封装了吗

👉指令搭建

vue3之中会有一些常见的指令操作,接下来我们就写一下,之前我们写了权限按钮,其实是类似的

指令的最主要文件如下,我们主要是主模块之中使用,其他的模块之中分割写好方法即可

指令主要文件

src\utils\directive\index.ts

import type { App, Directive } from 'vue'
const directives={};
// 导出插件对象
export const registerDirectives = {
  install(app: App) {
    Object.keys(directives).forEach((key) => {
      app.directive(key, directives[key])
    })
  }
}

指令使用


// 指令使用
import {registerDirectives} from '@/utils/directive'// 导入全局指令
app.use(registerDirectives);//全局指令注册

👉指令编写

复制指令

指令编写
import type { Directive, App } from 'vue'

// 扩展 HTMLElement 接口
declare global {
  interface HTMLElement {
    copyData?: string
  }
}

// 定义指令值的类型
interface CopyBinding {
  value: string
}

// 复制指令配置
const copy: Directive<HTMLElement, string> = {
  mounted(el: HTMLElement, binding: CopyBinding) {
    // 保存要复制的值
    el.copyData = binding.value
    // 添加点击事件监听
    el.addEventListener('click', handleClick)
  },
  updated(el: HTMLElement, binding: CopyBinding) {
    // 更新要复制的值
    el.copyData = binding.value
  },
  beforeUnmount(el: HTMLElement) {
    // 移除事件监听
    el.removeEventListener('click', handleClick)
  }
}

// 处理复制功能
const handleClick = async (event: Event) => {
  const el = event.currentTarget as HTMLElement
  if (!el.copyData) return

  try {
    // 使用现代的 Clipboard API
    await navigator.clipboard.writeText(el.copyData)
    // 可以在这里添加成功提示
    console.log('复制成功')
  } catch (err) {
    // 降级方案:使用传统方法
    const input = document.createElement('input')
    input.value = el.copyData
    document.body.appendChild(input)
    input.select()
    try {
      document.execCommand('Copy')
      console.log('复制成功')
    } catch (err) {
      console.error('复制失败:', err)
    }
    document.body.removeChild(input)
  }
}
// 导出指令对象
export { copy }

引入指令
// 复制指令
import {copy} from './modules/copy'

// 定义所有指令
const directives: Record<string, Directive> = {

  // 复制指令
  copy,
}
使用指令

接下来演示一下在项目之中进行使用指令

<template>
  <div class="flex gap-3">
    <input 
      class="flex-1 px-4 py-2 bg-gray-50 border-0 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200" 
      placeholder="请输入要复制的内容" 
      type="text" 
      v-model="data"
    >
    <el-button 
      class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200"
      v-copy="data"
    >
      复制
    </el-button>
  </div>
</template>

<script setup>
const data = ref('我是被复制的内容 🍒 🍉 🍊')
</script>

水印指令

接下来写一个水印指令,我们设置的是采取canvas实现的水印效果,接下来我们就编写一下

引入指令

接下来我们就在这里编写水印

src\utils\directive\modules\watermark .ts
// 水印指令
import {watermark} from './modules/watermark'

// 定义所有指令
const directives: Record<string, Directive> = {
  // 水印指令
  watermark,
}
指令编写
// modules/watermark.ts

export interface WatermarkConfig {
  text?: string
  color?: string
  fontSize?: number
  fontFamily?: string
  width?: number
  height?: number
  rotate?: number
  zIndex?: number
}

interface HTMLElementWithWatermark extends HTMLElement {
  _watermarkElement?: HTMLDivElement
}

const defaultConfig: Required<WatermarkConfig> = {
  text: 'Watermark',
  color: 'rgba(0, 0, 0, 0.15)',
  fontSize: 16,
  fontFamily: 'Arial',
  width: 200,
  height: 200,
  rotate: -20,
  zIndex: 9999
}

const createWatermark = (config: WatermarkConfig): string => {
  const finalConfig = { ...defaultConfig, ...config }
  
  const canvas = document.createElement('canvas')
  canvas.width = finalConfig.width
  canvas.height = finalConfig.height
  const ctx = canvas.getContext('2d')!

  // 设置画布样式
  ctx.rotate((finalConfig.rotate * Math.PI) / 180)
  ctx.font = `${finalConfig.fontSize}px ${finalConfig.fontFamily}`
  ctx.fillStyle = finalConfig.color
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  
  // 绘制水印文本
  ctx.fillText(finalConfig.text, finalConfig.width / 2, finalConfig.height / 2)

  return canvas.toDataURL()
}

const watermark = {
  mounted(el: HTMLElementWithWatermark, binding: { value: WatermarkConfig }) {
    const config = binding.value || {}
    const dataURL = createWatermark(config)

    // 创建水印层
    const watermarkDiv = document.createElement('div')
    watermarkDiv.style.position = 'absolute'
    watermarkDiv.style.top = '0'
    watermarkDiv.style.left = '0'
    watermarkDiv.style.width = '100%'
    watermarkDiv.style.height = '100%'
    watermarkDiv.style.pointerEvents = 'none'
    watermarkDiv.style.backgroundImage = `url(${dataURL})`
    watermarkDiv.style.backgroundRepeat = 'repeat'
    watermarkDiv.style.zIndex = String(config.zIndex || defaultConfig.zIndex)

    // 设置父元素为相对定位
    el.style.position = 'relative'
    // 添加水印层
    el.appendChild(watermarkDiv)

    // 保存水印元素引用
    el._watermarkElement = watermarkDiv
  },

  updated(el: HTMLElementWithWatermark, binding: { value: WatermarkConfig; oldValue: WatermarkConfig }) {
    // 如果配置发生变化,重新渲染水印
    if (JSON.stringify(binding.value) !== JSON.stringify(binding.oldValue)) {
      // 移除旧水印
      if (el._watermarkElement) {
        el.removeChild(el._watermarkElement)
      }
      // 创建新水印
      watermark.mounted(el, binding)
    }
  },

  unmounted(el: HTMLElementWithWatermark) {
    // 组件卸载时移除水印
    if (el._watermarkElement) {
      el.removeChild(el._watermarkElement)
      delete el._watermarkElement
    }
  }
}
export { watermark }
export default watermark;

指令使用

这个时候使用我们的指令,可以看到我们的效果

<template>
  <div class="flex gap-3 content" v-watermark="watermarkConfig">
    <h3 class="text-lg font-semibold mb-4 text-gray-800">水印指令</h3>
    <input 
      class="flex-1 px-4 py-2  border-0 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-200" 
      placeholder="请输入要复制的内容" 
      type="text" 
      v-model="data"
    >
  </div>
</template>

<script setup>
import { ref,computed } from 'vue'
// 将原来的 compute 方法改为计算属性
const watermarkText = computed(() => data.value)

const data = ref('水印内容🍒 🍉 🍊')

// 然后在 watermarkConfig 中使用这个计算属性
const watermarkConfig = computed(() => ({
  text: watermarkText.value,
  color: 'rgba(0, 0, 0, 0.15)',
  fontSize: 16,
  fontFamily: 'Arial',
  width: 200,
  height: 200,
  rotate: -20,
  zIndex: 9999,
}))
</script>
<style scoped>
.content {
  position: relative;
  width: 100%;
  height: 100%;
  background: #fff;
}
</style>

拖拽指令

指令编写
src\utils\directive\modules\draggable.ts

指令内容,这里需要注意一个部分,指令的位置是相对于我们父元素位置,而不是相对于我们视口的位置

// 记录初始位置
const rect = el.getBoundingClientRect()
dragData.initialLeft = rect.left
dragData.initialTop = rect.top

=>更改为

// 获取当前位置,如果没有设置则默认为0
dragData.initialLeft = parseInt(el.style.left) || 0
dragData.initialTop = parseInt(el.style.top) || 0

完整修改以后我们的版本如下

import type { Directive, DirectiveBinding } from 'vue'

interface DraggableElement extends HTMLElement {
  _dragData?: {
    isDragging: boolean
    startX: number
    startY: number
    initialLeft: number
    initialTop: number
    initialPosition: string
    zIndex: string
  }
  _cleanup?: () => void  // 添加这一行
}


const draggable: Directive<DraggableElement, boolean> = {
  mounted(el: DraggableElement, binding: DirectiveBinding<boolean>) {
    if (binding.value === false) return

    const dragData = {
      isDragging: false,
      startX: 0,
      startY: 0,
      initialLeft: 0,
      initialTop: 0,
      initialPosition: '',
      zIndex: ''
    }
    el._dragData = dragData

    // 设置初始样式
    el.style.cursor = 'move'
    el.style.position = el.style.position || 'absolute'

    const handleMouseDown = (e: MouseEvent) => {
      dragData.isDragging = true
      dragData.startX = e.clientX
      dragData.startY = e.clientY
      dragData.initialPosition = el.style.position
      dragData.zIndex = el.style.zIndex
      
      // 获取当前位置,如果没有设置则默认为0
      dragData.initialLeft = parseInt(el.style.left) || 0
      dragData.initialTop = parseInt(el.style.top) || 0

      // 提高层级
      el.style.zIndex = '9999'
      
      // 添加移动时的样式
      el.style.transition = 'none'
      el.style.userSelect = 'none'
    }

    const handleMouseMove = (e: MouseEvent) => {
      if (!dragData.isDragging) return

      const deltaX = e.clientX - dragData.startX
      const deltaY = e.clientY - dragData.startY

      el.style.left = `${dragData.initialLeft + deltaX}px`
      el.style.top = `${dragData.initialTop + deltaY}px`
    }

    const handleMouseUp = () => {
      if (!dragData.isDragging) return
      
      dragData.isDragging = false
      
      // 恢复样式
      el.style.zIndex = dragData.zIndex
      el.style.userSelect = ''
      el.style.transition = ''
    }

    // 添加事件监听
    el.addEventListener('mousedown', handleMouseDown)
    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleMouseUp)

    // 保存清理函数
    el._cleanup = () => {
      el.removeEventListener('mousedown', handleMouseDown)
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
    }
  },

  unmounted(el: DraggableElement) {
    // 清理事件监听
    if (el._cleanup) {
      el._cleanup()
    }
    delete el._dragData
  },

  updated(el: DraggableElement, binding: DirectiveBinding<boolean>) {
    // 如果指令值改变,更新状态
    if (binding.value === false && el._dragData) {
      el.style.cursor = ''
    } else if (binding.value === true) {
      el.style.cursor = 'move'
    }
  }
}

export {draggable}

指令使用

我们在指令之中进行使用,效果ok

<template>
  <div class="relative">
    <div v-draggable class="draggable-box">
      可拖拽的内容
    </div>
    <!-- 也可以动态控制是否可拖拽 -->
    <div v-draggable="isDraggable" class="draggable-box">
      条件拖拽的内容
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const isDraggable = ref(true)
</script>
<style>
.draggable-box {
  width: 200px;
  height: 200px;
  background-color: #409EFF;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
}
</style>

防抖指令

指令编写
// modules/debounce.ts
/**
 * 防抖函数
 * @param fn 需要防抖的函数
 * @param delay 延迟时间,单位毫秒,默认300ms
 * @param immediate 是否立即执行,默认false
 * @returns 返回防抖处理后的函数
 */
interface DebounceBinding {
  value: Function;
  arg?: string; // 延迟时间参数
}

// 防抖函数
function debounceFn(func: Function, wait: number) {
  let timeout: NodeJS.Timeout;
  return function(this: any, ...args: any[]) {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(this, args);
    }, wait);
  };
}

export const debounce = {
  mounted(el: HTMLElement, binding: DebounceBinding) {
    // 获取延迟时间,默认为 500ms
    const delay = Number(binding.arg) || 500;
    
    // 创建防抖函数
    const debouncedFn = debounceFn(binding.value, delay);
    
    // 保存原始函数和防抖函数到元素的 dataset 中
    el.dataset.debounceFn = JSON.stringify({
      original: binding.value.toString(),
      debounced: debouncedFn.toString()
    });
    
    // 添加事件监听器
    el.addEventListener('click', debouncedFn);
  },
  
  updated(el: HTMLElement, binding: DebounceBinding) {
    // 如果值发生变化,更新防抖函数
    const delay = Number(binding.arg) || 500;
    const debouncedFn = debounceFn(binding.value, delay);
    
    // 移除旧的事件监听器
    const oldFn = new Function('return ' + JSON.parse(el.dataset.debounceFn || '{}').debounced)();
    el.removeEventListener('click', oldFn);
    
    // 更新 dataset
    el.dataset.debounceFn = JSON.stringify({
      original: binding.value.toString(),
      debounced: debouncedFn.toString()
    });
    
    // 添加新的事件监听器
    el.addEventListener('click', debouncedFn);
  },
  
  unmounted(el: HTMLElement) {
    // 组件卸载时移除事件监听器
    const fn = new Function('return ' + JSON.parse(el.dataset.debounceFn || '{}').debounced)();
    el.removeEventListener('click', fn);
    delete el.dataset.debounceFn;
  }
};

// 导出防抖函数供其他地方使用
export { debounceFn };

指令使用
<template>
  <div class="flex flex-wrap gap-4 p-6">
    <!-- 基础防抖按钮 -->
    <button 
      v-debounce="handleClick"
      class="px-6 py-2.5 bg-blue-600 text-white font-medium text-sm leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out"
    >
      防抖按钮
    </button>

    <!-- 500ms防抖按钮 -->
    <button 
      v-debounce:500="handleClick"
      class="px-6 py-2.5 bg-green-600 text-white font-medium text-sm leading-tight uppercase rounded shadow-md hover:bg-green-700 hover:shadow-lg focus:bg-green-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-green-800 active:shadow-lg transition duration-150 ease-in-out"
    >
      500ms防抖按钮
    </button>

    <!-- 立即执行防抖按钮 -->
    <button 
      v-debounce.immediate="handleClick"
      class="px-6 py-2.5 bg-purple-600 text-white font-medium text-sm leading-tight uppercase rounded shadow-md hover:bg-purple-700 hover:shadow-lg focus:bg-purple-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-purple-800 active:shadow-lg transition duration-150 ease-in-out"
    >
      立即执行防抖按钮
    </button>
  </div>
</template>
<script setup>
const handleClick = () => {
  console.log('防抖按钮点击');
}
</script>

节流指令

指令编写
/**
 * v-throttle 指令
 * @param {Function} fn 需要节流的函数
 * @param {Number} delay 延迟时间
 * @param {Boolean} immediate 是否立即执行
 * @returns {Function} 返回一个节流后的函数
 */

// modules/throttle.ts

interface ThrottleBinding {
  value: Function;
  arg?: string | number; // 延迟时间参数
  modifiers?: {
    immediate?: boolean;
  };
}

// 节流函数
function throttleFn(
  func: Function,
  wait: number,
  immediate: boolean = false
) {
  let timeout: NodeJS.Timeout | null = null;
  let previous = 0;

  return function(this: any, ...args: any[]) {
    const now = Date.now();
    const remaining = wait - (now - previous);

    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func.apply(this, args);
    } else if (!timeout && !immediate) {
      timeout = setTimeout(() => {
        previous = immediate ? 0 : Date.now();
        timeout = null;
        if (!immediate) {
          func.apply(this, args);
        }
      }, remaining);
    }

    if (immediate && !timeout) {
      func.apply(this, args);
      previous = now;
    }
  };
}

export const throttle = {
  mounted(el: HTMLElement, binding: ThrottleBinding) {
    const delay = Number(binding.arg) || 500;
    const immediate = binding.modifiers?.immediate || false;
    
    const throttledFn = throttleFn(binding.value, delay, immediate);
    
    el.dataset.throttleFn = JSON.stringify({
      original: binding.value.toString(),
      throttled: throttledFn.toString()
    });
    
    el.addEventListener('click', throttledFn);
  },
  
  updated(el: HTMLElement, binding: ThrottleBinding) {
    const delay = Number(binding.arg) || 500;
    const immediate = binding.modifiers?.immediate || false;
    const throttledFn = throttleFn(binding.value, delay, immediate);
    
    const oldFn = new Function('return ' + JSON.parse(el.dataset.throttleFn || '{}').throttled)();
    el.removeEventListener('click', oldFn);
    
    el.dataset.throttleFn = JSON.stringify({
      original: binding.value.toString(),
      throttled: throttledFn.toString()
    });
    
    el.addEventListener('click', throttledFn);
  },
  
  unmounted(el: HTMLElement) {
    const fn = new Function('return ' + JSON.parse(el.dataset.throttleFn || '{}').throttled)();
    el.removeEventListener('click', fn);
    delete el.dataset.throttleFn;
  }
};

export { throttleFn };

指令使用
<template>
  <div class="flex flex-wrap gap-4 p-6">
    <!-- 基础节流按钮 -->
    <button 
      v-throttle="handleClick"
      class="px-6 py-2.5 bg-blue-600 text-white font-medium text-sm leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out"
    >
      节流按钮
    </button>

    <!-- 500ms节流按钮 -->
    <button 
      v-throttle:500="handleClick"
      class="px-6 py-2.5 bg-green-600 text-white font-medium text-sm leading-tight uppercase rounded shadow-md hover:bg-green-700 hover:shadow-lg focus:bg-green-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-green-800 active:shadow-lg transition duration-150 ease-in-out"
    >
      500ms节流按钮
    </button>

    <!-- 立即执行节流按钮 -->
    <button 
      v-throttle.immediate="handleClick"
      class="px-6 py-2.5 bg-purple-600 text-white font-medium text-sm leading-tight uppercase rounded shadow-md hover:bg-purple-700 hover:shadow-lg focus:bg-purple-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-purple-800 active:shadow-lg transition duration-150 ease-in-out"
    >
      立即执行节流按钮
    </button>
  </div>
</template>

<script setup lang="ts">
const handleClick = () => {
  console.log('按钮被点击');
};
</script>

长按指令

指令编写
src\utils\directive\modules\longPress.ts
// modules/longPress.ts

interface LongPressBinding {
  value: Function;
  arg?: number; // 长按时间,单位毫秒,默认500ms
  modifiers?: {
    stop?: boolean; // 是否阻止事件冒泡
    prevent?: boolean; // 是否阻止默认事件
  };
}

export const longPress = {
  mounted(el: HTMLElement, binding: LongPressBinding) {
    if (typeof binding.value !== 'function') {
      console.warn('v-longPress 指令需要一个函数作为值');
      return;
    }

    let pressTimer: NodeJS.Timeout | null = null;
    let startTime: number = 0;
    const duration = Number(binding.arg) || 500;
    const isStop = binding.modifiers?.stop || false;
    const isPrevent = binding.modifiers?.prevent || false;

    const start = (e: MouseEvent | TouchEvent) => {
      if (isPrevent) {
        e.preventDefault();
      }
      if (isStop) {
        e.stopPropagation();
      }

      startTime = Date.now();
      
      pressTimer = setTimeout(() => {
        binding.value(e);
      }, duration);
    };

    const cancel = () => {
      if (pressTimer) {
        clearTimeout(pressTimer);
        pressTimer = null;
      }
    };

    const end = (e: MouseEvent | TouchEvent) => {
      const endTime = Date.now();
      const timeDiff = endTime - startTime;
      
      // 如果按住时间小于设定时间,则视为普通点击
      if (timeDiff < duration && pressTimer) {
        cancel();
        return;
      }
      
      cancel();
    };

    // 添加事件监听器
    el.addEventListener('mousedown', start);
    el.addEventListener('touchstart', start);
    el.addEventListener('mouseup', end);
    el.addEventListener('touchend', end);
    el.addEventListener('mouseleave', cancel);
    el.addEventListener('touchcancel', cancel);

    // 保存清理函数到元素上
    (el as any)._longPressCleanup = () => {
      el.removeEventListener('mousedown', start);
      el.removeEventListener('touchstart', start);
      el.removeEventListener('mouseup', end);
      el.removeEventListener('touchend', end);
      el.removeEventListener('mouseleave', cancel);
      el.removeEventListener('touchcancel', cancel);
      cancel();
    };
  },

  unmounted(el: HTMLElement) {
    // 清理事件监听器
    if ((el as any)._longPressCleanup) {
      (el as any)._longPressCleanup();
    }
  }
};

指令使用

测试一下我们的按钮指令,效果ok

<template>
  <div class="p-6 space-y-4">
    <!-- 基础用法,默认500ms -->
    <button 
      v-longPress="handleLongPress"
      class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
    >
      长按按钮
    </button>

    <!-- 自定义长按时间 -->
    <button 
      v-longPress:1000="handleLongPress"
      class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
    >
      1秒长按按钮
    </button>

    <!-- 阻止事件冒泡 -->
    <button 
      v-longPress.stop="handleLongPress"
      class="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600"
    >
      阻止冒泡长按按钮
    </button>

    <!-- 阻止默认事件 -->
    <button 
      v-longPress.prevent="handleLongPress"
      class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
    >
      阻止默认事件长按按钮
    </button>
  </div>
</template>
<script setup lang="ts">
const handleLongPress = (event: MouseEvent | TouchEvent) => {
  console.log('长按触发', new Date().toISOString());
  // 这里可以添加你的长按处理逻辑
  if (event instanceof MouseEvent) {
    console.log('鼠标事件');
  } else {
    console.log('触摸事件');
  }
};
</script>

为什么"Web3"是下一代互联网?——从中心化到去中心化的转变

作者 无限大6
2025年12月25日 10:27

🌐 为什么"Web3"是下一代互联网?——从中心化到去中心化的转变 🚀

大家好,我是无限大,欢迎收看十万个为什么系列文章

希望今天的内容能对大家有所帮助

今天咱们来聊聊Web3这个"互联网的下一代"!想象一下,你在社交媒体上发的照片被平台随意删除;你辛苦创作的内容,平台说下架就下架;你的个人数据被平台偷偷卖钱——这些糟心的体验,都是Web2时代的痛点!而Web3,就是要解决这些问题!

🤔 核心问题:Web3和Web2有什么区别?为什么需要去中心化互联网?

很多人觉得Web3是"虚无缥缈的概念",其实Web3离我们很近!Web3就像"互联网的民主革命",让用户真正拥有自己的数据和内容,而不是被平台控制。

Web3的本质

Web3是一种去中心化的互联网,基于区块链技术,强调用户数据所有权、去中心化应用和价值互联网。它就像"把互联网从公司手里还给用户",让每个人都能公平地参与和受益。

为什么需要去中心化互联网?

  • 🔑 数据所有权:用户真正拥有自己的数据,不再被平台垄断
  • 🚫 拒绝审查:内容和应用不容易被随意删除或下架
  • 💰 价值回归:创作者可以直接获得收益,中间没有平台抽成
  • 🔗 互操作性:不同应用之间可以无缝协作,没有"围墙花园"
  • ⚖️ 公平参与:每个人都可以参与网络建设,获得相应的奖励

📜 互联网的"进化史":从只读到价值互联网

1. 📖 Web1:"只读互联网"(1990-2004)

Web1时代,互联网就像"只读的百科全书",用户只能浏览内容,不能发布或交互。网站都是静态的,内容由少数人创建。

这就像"只能看不能写的黑板报",你只能看别人写的内容,自己不能上去画。代表网站:早期的雅虎、新浪、网易。

2. 💬 Web2:"读写互联网"(2004-2020)

Web2时代,互联网变成了"互动的社交媒体",用户可以发布内容、评论、分享。但所有数据都保存在平台的服务器上,平台拥有绝对控制权。

这就像"你在别人家里写日记",虽然你可以写,但本子是别人的,别人可以随意翻看、修改甚至销毁你的日记。代表平台:Facebook、微信、抖音、淘宝。

3. 💰 Web3:"价值互联网"(2020-至今)

Web3时代,互联网进化为"价值交换网络",用户真正拥有自己的数据和内容。基于区块链技术,所有数据都保存在去中心化的网络中,没有人能随意控制。

这就像"你在自己家里写日记",本子是你自己的,想怎么写就怎么写,别人没有权利干涉。代表应用:以太坊、Uniswap、OpenSea、Decentraland。

🔧 技术原理:Web3的核心技术

1. ⛓️ 区块链底层技术:"去中心化的数据库"

区块链是Web3的"地基",它是一种去中心化的分布式账本,所有交易都被记录在多个节点上,没有人能随意篡改。

区块链的核心特性

  • 📝 不可篡改:一旦记录,就无法修改
  • 🔗 去中心化:没有中心服务器,所有节点平等
  • 🔒 加密安全:使用密码学保证数据安全
  • ⚖️ 透明公开:所有交易都可以公开查询

2. 📱 去中心化应用(DApp):"不被控制的应用"

DApp是Web3的"应用层",它运行在区块链上,不依赖任何中心化服务器。DApp的代码是开源的,任何人都可以审查和使用。

DApp的特点

  • 🔓 开源代码:所有代码都可以公开查看
  • 🚫 无单点故障:不会因为某个服务器故障而停止运行
  • 🔑 用户控制:用户掌握自己的私钥,拥有完全控制权
  • 自动执行:使用智能合约自动执行规则

代码实例:用Python调用以太坊区块链API

from web3 import Web3

# 连接到以太坊测试网络
web3 = Web3(Web3.HTTPProvider('https://sepolia.infura.io/v3/YOUR_API_KEY'))

# 检查连接是否成功
if web3.is_connected():
    print("✅ 成功连接到以太坊测试网络")
  
    # 获取当前区块号
    block_number = web3.eth.block_number
    print(f"当前区块号:{block_number}")
  
    # 获取账户余额
    account = "0x742d35Cc6634C0532925a3b81643FeD747a70a7D"
    balance_wei = web3.eth.get_balance(account)
    balance_eth = web3.from_wei(balance_wei, 'ether')
    print(f"账户 {account} 的余额:{balance_eth:.6f} ETH")
  
    # 获取最新区块信息
    latest_block = web3.eth.get_block('latest')
    print(f"最新区块哈希:{latest_block.hash.hex()}")
    print(f"最新区块包含交易数:{len(latest_block.transactions)}")
else:
    print("❌ 连接以太坊网络失败")

运行结果

✅ 成功连接到以太坊测试网络
当前区块号:5000000
账户 0x742d35Cc6634C0532925a3b81643FeD747a70a7D 的余额:0.123456 ETH
最新区块哈希:0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
最新区块包含交易数:128

3. 🔑 用户数据所有权:"数据是你的资产"

在Web3中,用户真正拥有自己的数据。数据不再保存在平台的服务器上,而是保存在区块链上,用户通过私钥控制自己的数据。

数据所有权的优势

  • 📊 数据价值回归:用户可以将自己的数据变现
  • 🔒 隐私保护:用户可以选择分享哪些数据
  • 🚫 防止滥用:平台不能随意使用用户数据
  • 💸 数据资产化:数据可以作为资产进行交易

📊 趣味对比:Web2 vs Web3

对比项 Web2(中心化互联网) Web3(去中心化互联网)
数据所有权 数据归平台所有 数据归用户所有
应用控制 平台控制,想下架就下架 社区治理,开源透明
收益分配 平台拿走大部分收益 创作者直接获得收益
内容审查 平台决定内容生死 社区自治,抗审查
隐私保护 平台收集并滥用数据 用户掌握隐私控制权
参与门槛 平台决定谁可以参与 开放,任何人都可以参与
互操作性 平台之间相互隔离 应用之间无缝协作
信任机制 信任平台 信任代码和算法

🏢 Web3的应用场景:已经到来的未来

Web3已经在多个领域开始应用,改变着我们的生活方式:

应用场景 代表项目 Web3的优势
🛍️ 去中心化金融(DeFi) Uniswap、Aave 无需中介,更低手续费
🎨 数字艺术品(NFT) OpenSea、Foundation 真正的所有权,不可篡改
🎮 链游 Axie Infinity、Decentraland 玩游戏也能赚钱
📱 社交媒体 Lens Protocol、Mastodon 用户拥有数据,内容抗审查
🏠 元宇宙 Decentraland、The Sandbox 去中心化的虚拟世界
🔍 搜索 Presearch 隐私保护,用户控制数据
💼 身份认证 Civic、uPort 去中心化身份,更安全

🔍 常见误区纠正

1. "Web3就是区块链,区块链就是Web3?"

不!Web3是一种互联网理念,区块链是实现Web3的技术之一。Web3还包括其他技术,比如IPFS、DAO等。

2. "Web3就是炒币,就是割韭菜?"

不!炒币只是Web3的一小部分,Web3的核心是去中心化和用户数据所有权。真正的Web3应用正在改变各个行业。

3. "Web3太复杂,普通人用不了?"

不!随着技术的发展,Web3应用的易用性正在不断提高。就像早期的互联网一样,Web3会变得越来越简单易用。

4. "Web3会完全取代Web2?"

不!Web3和Web2会长期共存,就像现在Web2和Web1共存一样。Web3会在某些领域取代Web2,但不会完全取代。

5. "去中心化就一定比中心化好?"

不一定!去中心化和中心化各有优缺点。去中心化更安全、更公平,但效率可能较低;中心化效率更高,但容易被滥用。

🔮 未来展望:Web3的发展趋势

1. 🤖 AI + Web3:"智能+去中心化"

AI和Web3的结合会创造出更智能、更公平的应用。比如,AI可以帮助用户管理Web3资产,Web3可以让AI更加透明和可控。

2. 📱 移动Web3:"人人都能使用"

随着Web3钱包和DApp的移动端优化,Web3会变得更加普及。就像现在的移动互联网一样,Web3会通过手机走进每个人的生活。

3. 🏛️ DAO治理:"社区当家作主"

DAO(去中心化自治组织)会成为Web3的重要治理形式。用户可以通过DAO参与项目决策,真正实现"社区当家作主"。

4. 🔗 跨链互操作:"互联互通的Web3"

不同区块链之间的互操作性会越来越强,用户可以在不同区块链之间自由转移资产和数据,实现真正的"互联互通"。

5. 📊 数据经济:"你的数据就是你的资产"

用户的数据会真正成为可交易的资产。用户可以选择将自己的数据出售给需要的企业,获得相应的收益。

🎓 互动小测验:你答对了吗?

问题 答案 你答对了吗?
Web3的核心是什么? 去中心化和用户数据所有权 ✅/❌
Web1、Web2、Web3分别是什么? 只读互联网、读写互联网、价值互联网 ✅/❌
全球Web3钱包用户数量超过多少? 1亿 ✅/❌
去中心化交易所日交易量达多少? 50亿美元 ✅/❌
DApp的特点是什么? 开源、去中心化、用户控制 ✅/❌

🎯 结语:互联网的民主革命

Web3的发展,就是互联网从"中心化控制"到"去中心化自治"的革命。它让用户真正拥有自己的数据和内容,让互联网变得更加公平、透明、开放。

虽然Web3还处于早期阶段,存在很多问题和挑战,但它代表了互联网的未来方向。就像20年前的Web2一样,Web3会逐渐改变我们的生活方式。

下次使用互联网时,不妨想想:你的数据属于谁?你真的拥有自己的内容吗?Web3或许能给你一个更好的答案!


💬 互动话题

  1. 你用过Web3应用吗?体验如何?
  2. 你觉得Web3会取代Web2吗?为什么?
  3. 你最期待Web3在哪个领域的应用?

快来评论区聊聊你的想法!💬 点赞收藏不迷路,咱们下期继续探索计算机的"十万个为什么"!🎉

关注我,下期带你解锁更多计算机的"奇葩冷知识"!🤓

🍀vue3 + Typescript +Tdesign + HiPrint 打印下载解决方案

作者 H_四叶草
2025年12月25日 10:23

效果图

动画.gif

注册 hiPrint

在 main.ts 中进行注册

import { disAutoConnect, hiPrintPlugin } from '@/plugins/hiprint/index';

// 先不要自动连接打印组件,调用打印再去连接
disAutoConnect();

app.use(hiPrintPlugin);

定义配置字段

const baseMessage = {
    id: 1,
    title: '基本信息',
    fields: [
      { label: '打印用户', value: 'printUser' },
      { label: '打印时间', value: 'printTime' },
      { label: '包裹数量', value: 'packageNum' },
      { label: 'SKU 数量', value: 'skuNum' },
      { label: '货品总数', value: 'stockGoodsNum' },
      { label: '仓库', value: 'warehouseName' },
    ],
};

定义模板数据

const orderTotal = {
    printUser: '拣货员1',
    printTime: '2025-01-01 18:00:00',
    packageNum: '2',
    skuNum: '3',
    stockGoodsNum: '6',
    warehouseName: '货代深圳仓',
};

定义渲染模板

<!-- RenderPickTemplate -->
<template>
  <div>
     <!-- 根据传入的数据进行渲染 -->  
  </div>
</template>
<script lang="ts" setup>
interface Field {
    label: string;
    value: string;
}
interface FieldList {
    id: number;
    title: string;
    fields: Field;
}
interface Props {
    fieldList: FieldList;
    selectedField: string[];
    data: {[key: string]: any };
}
    
const props = defineProps<Props>();
</script>

下载和打印拣货单

1.png

原理:

  • 第一步需要将 vue 组件转化成 HTML。这一步是关键
import { h, render } from 'vue';
import RenderPickTemplate from './RenderPickTemplate.vue';
export const customFormatter = (
  data: { [key: string]: any }, // 需要打印的数据
  selectedField: string[], // 选中的字段
  fieldList: FieldList,   // 完整的字段列表 
) => {
   const container = document.createElement('div');
   const vnode = h(RenderPickTemplate, {
    data,
    selectedField,
    fieldList,
   });
   render(vnode, container);
   const html = container.firstElementChild.innerHTML;
   return html;
}
  • 第二步生成 hiPrint 支持的渲染对象
export const getTemplateRendFunc = (html: string, type?: string) => {
  const sizeMap: any = {
    A4: {
      width: 210,
      height: 296.6,
      printElementsWidth: 500,
    },
  };

  let size = {
    width: 100,
    height: 150,
    printElementsWidth: 300,
  };
  if (type && sizeMap[type]) {
    size = sizeMap[type];
  }
    
  return {
    panels: [
      {
        index: 0,
        name: 1,
        ...size,
        printElements: [
          {
            options: {
              left: 0,
              top: 0,
              width: size.printElementsWidth,
              options: {
                html,
              },
              formatter: (title: string, data: any, customOptions: any) => {
                const { options } = customOptions;
                // 将 html 给到 hiPrint 进行渲染
                return options.html;
              },
            },
            printElementType: {
              type: 'longText',
            },
          },
        ],
      },
    ],
  }
}
  • 第三步借助打印组件将渲染对象打印或下载下来
// 根据模板创建 hiprint 实例
const hiprintTemplate = new hiprint.PrintTemplate({
    template: json,
});

// 开始打印
hiprintTemplate.print2(null, {
  printer: printer, // printer:若为空,则根据打印组件配置的打印机进行打印
});

 // 成功
hiprintTemplate.on('printSuccess', function() {
  done();
});

 // 失败
hiprintTemplate.on('printError', function() {
  done();
  console.log('打印失败');
});

优化:批量打印

import { hiprint } from '@/plugins/hiprint/index';
export const batchPrint = (printDataList: Array<PrintData>, printer?: string) => {
  const len = printDataList.length;

  // @ts-ignore
  const runner = new TaskRunner();
  runner.setConcurrency(1); // 同时执行数量

  const task = [];
  const tasksKey = `open${Date.now()}`;
  for (let i = 0; i < len; i++) {
    let key = `task_${i}`;
    const printData = printDataList[i];

    task.push((done: Function) => {
      realPrint(runner, done, key, `${i + 1}`, printData, tasksKey, printer);
    });
  }

  // 开始任务
  runner.addMultiple(task);
  runner.start();
};

const realPrint = (runner: any, done: Function, key: string, i: string, printData: PrintData, tasksKey: string, printer: string) => {
  if (printData.type === 'template') {
    // 根据模板创建 hiprint 实例
    const hiprintTemplate = new hiprint.PrintTemplate({
      template: printData.template,
    });

    // 开始打印
    hiprintTemplate.print2(printData.data, {
      printer: printer
    });

    // 成功
    hiprintTemplate.on('printSuccess', function() {
      done();
    });

    // 失败
    hiprintTemplate.on('printError', function() {
      done();
      console.log('打印失败');
    });
  } else if (printData.type === 'online') {
    printOnlinePdf(printData.online as string, printer, (state: string) => {
      done();
      if (state === 'error') {
        console.log('打印线上 pdf 失败');
      }
    });
  }
};

// 打印线上 PDF
export const printOnlinePdf = (url: string, printer?: string, callback?: Function) => {
  let params = {
    type: 'url_pdf',
    templateId: 'online_pdf_1',
    pdf_path: url,
  }
  if (printer) {
    params = Object.assign(params, {
      printer,
    })
  }

  hiprint.hiwebSocket.send(params);
  hiprint.hiwebSocket.socket.on('success', () => {
    if (typeof callback === 'function') {
      callback('success');
    }
  });
  hiprint.hiwebSocket.socket.on('error', () => {
    if (typeof callback === 'function') {
      callback('error');
    }
  });
};

使用

batchPrint(
    [
      {
        type: 'template',
        template: json,
      },
    ],
    type === 'download' ? 'Microsoft Print to PDF' : '',
);

扩展:直接拖拽组件实现打印下载

动画1.gif

12.png

该部分内容篇幅比较大,后续会重新出一篇文章。。。。

CSS 全局样式污染问题复盘

2025年12月25日 10:22

一、问题现象

1.1 问题描述

VGM 编辑弹窗(使用 CmcDialog 组件)出现异常的内边距,导致弹窗内容布局错乱,表单元素间距过大。

1.2 问题截图

弹窗内容区域出现了不应有的 padding: 52px 50px 样式,导致:

  • 表单内容被压缩
  • 布局与设计稿不符
  • 视觉效果异常

1.3 影响范围

所有使用 el-dialog 或基于 el-dialog 封装的组件(如 CmcDialog)都受到影响。


二、问题定位

2.1 排查过程

  1. 检查组件自身样式 - CmcDialog 组件样式正常
  2. 检查父组件样式 - 使用 CmcDialog 的页面无异常样式
  3. 使用 DevTools 检查 - 发现 .el-dialog 被注入了全局样式
  4. 全局搜索污染源 - 搜索 padding: 52px 50px 定位到问题文件

2.2 问题根源

src/views/search_service/ship-schedules/components/Subscribe.vue 中发现以下代码:

<style scoped lang="scss">
.subscriber-dialog {
  :global(.el-dialog) {
    padding: 52px 50px;
  }
}
</style>

2.3 为什么会造成全局污染?

这里涉及到 Vue Scoped CSS 和 :global() 的工作原理:

Vue Scoped CSS 原理

<!-- 编译前 -->
<style scoped>
  .subscriber-dialog {
    color: red;
  }
</style>

<!-- 编译后 -->
<style>
  .subscriber-dialog[data-v-xxxxx] {
    color: red;
  }
</style>

Vue 会为 scoped 样式添加唯一的 data-v-xxxxx 属性选择器,确保样式只作用于当前组件。

:global() 的作用

:global() 是 CSS Modules 和 Vue 的一个特性,用于跳过 scoped 限制,生成全局样式:

// 编译前
.subscriber-dialog {
  :global(.el-dialog) {
    padding: 52px 50px;
  }
}

// 编译后(注意:.el-dialog 没有 data-v 属性!)
.subscriber-dialog[data-v-xxxxx] .el-dialog {
  padding: 52px 50px;
}

关键问题:el-dialog 的 DOM 结构

Element Plus 的 el-dialog 默认会通过 append-to-body 将 DOM 挂载到 <body> 下:

<body>
  <!-- 页面内容 -->
  <div id="app">
    <div class="subscriber-dialog" data-v-xxxxx>
      <!-- 触发按钮 -->
    </div>
  </div>

  <!-- Dialog 被 teleport 到 body 下 -->
  <div class="el-overlay subscriber-dialog">
    <!-- modal-class 应用在这里 -->
    <div class="el-dialog">
      <!-- 实际的 dialog -->
      ...
    </div>
  </div>
</body>

由于 modal-class="subscriber-dialog" 应用到了 el-overlay 上,而 .el-dialog 是其子元素,所以选择器 .subscriber-dialog .el-dialog 能够匹配到!

但问题在于:global(.el-dialog) 生成的样式没有足够的特异性限制,当其他页面的 dialog 也被挂载到 body 时,如果 CSS 加载顺序导致这个样式后加载,就会覆盖其他 dialog 的样式。


三、深度原理剖析

3.1 CSS 特异性(Specificity)

CSS 特异性决定了当多个规则应用于同一元素时,哪个规则优先:

选择器类型 特异性值
内联样式 1000
ID 选择器 100
类/属性/伪类 10
元素/伪元素 1
// 特异性:20(两个类选择器)
.subscriber-dialog .el-dialog {
  padding: 52px 50px;
}

// 特异性:20(两个类选择器)
.cmc-dialog.el-dialog {
  padding: 0;
}

当特异性相同时,后加载的样式会覆盖先加载的样式

3.2 样式加载顺序问题

在 SPA 应用中,组件样式是按需加载的:

1. 用户访问首页 → 加载首页组件样式
2. 用户访问船期页面 → 加载 Subscribe.vue 样式(包含全局污染)
3. 用户访问 VGM 页面 → CmcDialog 样式被污染样式覆盖

3.3 Teleport/Portal 的影响

Element Plus Dialog 使用 Vue 3 的 Teleport 特性:

<Teleport to="body">
  <div class="el-overlay">
    <div class="el-dialog">...</div>
  </div>
</Teleport>

这导致:

  1. Dialog DOM 脱离了组件的 DOM 树
  2. Scoped 样式的 data-v-xxxxx 属性无法正确应用
  3. 必须使用 :global():deep() 才能样式化 dialog

四、修复方案

4.1 修复污染源(治本)

修改前(错误写法):

<el-dialog modal-class="subscriber-dialog">
  ...
</el-dialog>

<style scoped lang="scss">
.subscriber-dialog {
  :global(.el-dialog) {
    padding: 52px 50px;
  }
}
</style>

修改后(正确写法):

<el-dialog class="subscriber-dialog-box" modal-class="subscriber-dialog">
  ...
</el-dialog>

<style scoped lang="scss">
// 使用 class 属性直接应用到 el-dialog 上
// 组合选择器确保只影响特定的 dialog
:global(.subscriber-dialog-box) {
  padding: 52px 50px;

  .el-dialog__header {
    display: none;
  }
}
</style>

关键改动:

  1. 使用 class 而非仅依赖 modal-class
  2. 使用组合选择器 .subscriber-dialog-box 确保唯一性
  3. 样式只作用于带有该特定类名的 dialog

4.2 加固组件库(治标 + 防御)

CmcDialog 组件中添加高优先级样式重置:

.cmc-dialog {
  &.el-dialog {
    // 使用 !important 确保不被外部样式覆盖
    padding: 0 !important;
    padding-top: 0 !important;
    padding-bottom: 0 !important;
    padding-left: 0 !important;
    padding-right: 0 !important;
  }

  .el-dialog__header {
    padding: 0 !important;
    margin: 0 !important;
  }

  .el-dialog__body {
    padding: 0 !important;
  }

  .el-dialog__footer {
    padding: 0 !important;
    margin: 0 !important;
  }
}


五、同类问题预防指南

5.1 ❌ 错误写法示例

// 错误1:直接使用 :global 修改 Element Plus 组件
:global(.el-dialog) { ... }
:global(.el-table) { ... }
:global(.el-form) { ... }

// 错误2:在 scoped 样式中使用过于宽泛的选择器
.my-page {
  :global(.el-button) {
    background: red;
  }
}

// 错误3:在全局样式文件中直接修改组件样式
// src/assets/styles/index.scss
.el-dialog {
  padding: 52px 50px;
}

5.2 ✅ 正确写法示例

// 正确1:使用组合选择器,确保唯一性
:global(.my-specific-dialog.el-dialog) {
  padding: 52px 50px;
}

// 正确2:使用 BEM 命名 + 组合选择器
:global(.page-name__dialog.el-dialog) {
  // 样式
}

// 正确3:在组件上使用 class 属性
<el-dialog class="my-unique-dialog">

// 正确4:使用 CSS 变量进行定制
.my-dialog {
  --el-dialog-padding-primary: 52px 50px;
}

5.3 代码审查检查清单

在 Code Review 时,检查以下内容:

  • 是否使用了 :global(.el-xxx) 直接修改 Element Plus 组件?
  • 全局样式文件中是否有直接修改组件库样式的代码?
  • 使用 :global() 时是否添加了足够特异性的父选择器?
  • Dialog/Drawer 等 Teleport 组件是否使用了 class 属性?
  • 样式是否可能影响其他页面的同类组件?

5.4 ESLint/Stylelint 规则建议

可以配置 Stylelint 规则来检测潜在的全局污染:

// stylelint.config.js
module.exports = {
  rules: {
    // 禁止直接使用 Element Plus 类名作为选择器
    'selector-disallowed-list': [
      '/^\\.el-(?!.*\\.)/', // 匹配单独的 .el-xxx 选择器
      {
        message: '请使用组合选择器避免全局污染,如 .my-class.el-dialog'
      }
    ]
  }
}

六、总结

6.1 问题本质

这是一个典型的 CSS 作用域泄漏 问题,由以下因素共同导致:

  1. Teleport 机制 - Dialog DOM 脱离组件树
  2. :global() 滥用 - 跳过 scoped 限制
  3. 选择器特异性不足 - 没有使用组合选择器
  4. 样式加载顺序 - 后加载的样式覆盖先加载的

6.2 核心教训

  1. 永远不要直接 :global(.el-xxx) - 必须添加特定的父选择器或组合选择器
  2. 组件库封装要有防御性 - 使用 !important 重置关键样式
  3. 使用 class 而非仅 modal-class - 确保样式能正确应用
  4. 命名要有唯一性 - 使用 BEM 或页面前缀避免冲突

6.3 推荐的 Dialog 样式定制模式

<template>
  <el-dialog
    class="feature-name__dialog"
    modal-class="feature-name__overlay"
  >
    ...
  </el-dialog>
</template>

<style scoped lang="scss">
// 使用组合选择器,确保只影响当前组件的 dialog
:global(.feature-name__dialog.el-dialog) {
  // 自定义样式
}
</style>

七、相关资源


Context API 的订阅机制与性能优化

作者 day1
2025年12月25日 10:08

1. 引言:Context API 的双面性

Context API 诞生的核心目标是解决 React 组件树中 “props 钻取” 问题—— 当深层子组件需要使用顶层组件的状态时,无需通过中间组件逐层传递 props。

然而,这种便利性的背后也隐藏着性能上的挑战。默认情况下,任何消费了 Context 的组件都会在 Context 值发生变化时被强制重新渲染,即使它只关心该值的一小部分。这可能导致大规模且不必要的渲染,从而影响应用性能。

2. 核心机制:被动的“拉取式”订阅

要理解 Context 的工作原理,我们必须将其视为一个被动的、在“跳过更新”时进行检查的“拉取式”订阅系统,而非主动的“发布-订阅”模型。

  • React.createContext(defaultValue): 创建一个 Context 对象。这个对象本身就像一个“主题”或“事件中心”。

  • <Context.Provider value={...}>: 这是值的提供者。当它渲染时,它会将 value prop 的值推入一个全局的 Context 栈中,使其成为当前活跃的值。它不会主动通知任何组件。

    这个“栈”是 React 用来管理嵌套 Provider 值的关键。你可以把它想象成一摞盘子:当遇到一个新的 Provider 时,React 会把新值(新盘子)放到最上面;当这个 Provider 的渲染结束后,React 会把最上面的值(盘子)拿走,从而恢复上一层 Provider 的值。这个“后进先出”的机制确保了无论嵌套多深,组件总能读取到离它最近的 Provider 的值。

    这个过程由 pushProvider 函数完成,它将旧值保存到栈上,然后更新 Context 对象的当前值。

    export function pushProvider<T>(
      providerFiber: Fiber,
      context: ReactContext<T>,
      nextValue: T
    ): void {
      if (isPrimaryRenderer) {
        // 将旧值推入栈中
        push(valueCursor, context._currentValue, providerFiber);
        // 更新 context 的当前值
        context._currentValue = nextValue;
      } else {
        push(valueCursor, context._currentValue2, providerFiber);
        context._currentValue2 = nextValue;
      }
    }
    
  • useContext(Context): 这是订阅者。当组件调用 useContext 时,React 会做两件关键的事:

    1. 读取值:从 Context 栈中读取当前的活跃值。
    2. 记录依赖(订阅):将该 Context 和本次读取到的值(作为 memoizedValue)记录到当前组件 Fiber 的 dependencies 列表中。这一步就是“订阅”,它告诉 React:“这个组件依赖此 Context,并且它上次读取的值是 X”。

    useContext 内部调用 readContext,最终由 readContextForConsumer 完成工作。它读取当前值,然后创建一个依赖项并附加到当前组件 Fiber 的 dependencies 链表上。

    // src/react/packages/react-reconciler/src/ReactFiberNewContext.js
    
    export function readContext<T>(context: ReactContext<T>): T {
      // ...
      return readContextForConsumer(currentlyRenderingFiber, context);
    }
    
    function readContextForConsumer<T>(
      consumer: Fiber | null,
      context: ReactContext<T>
    ): T {
      // 读取当前 context 的值
      const value = isPrimaryRenderer
        ? context._currentValue
        : context._currentValue2;
    
      const contextItem = {
        context: ((context: any): ReactContext<mixed>),
        memoizedValue: value, // 记录读取到的值
        next: null,
      };
    
      if (lastContextDependency === null) {
        // ... 创建新的依赖列表
        lastContextDependency = contextItem;
        consumer.dependencies = {
          lanes: NoLanes,
          firstContext: contextItem,
        };
      } else {
        // 追加到依赖链表末尾
        lastContextDependency = lastContextDependency.next = contextItem;
      }
      return value;
    }
    

内部更新检查流程

Context 的更新通知并非由 Provider 主动发起,而是在 Consumer 端,当 React 试图优化渲染时被动触发的。

  1. Provider 值变更Providervalue prop 获得了一个新的对象引用。Provider 重新渲染,并将这个新值推入 Context 栈。

  2. 子组件渲染与检查:React 向下渲染子组件。

    • 对于普通组件:由于父节点(或更上层的祖先)在渲染,它们也会默认重新渲染。在渲染过程中,它们调用 useContext,自然会读取到 Context 栈中最新的值。
    • 对于希望“跳过更新”的组件(如被 React.memo 包裹且 props 未变的组件):React 在准备跳过它之前,会执行一道额外的安全检查——调用内部的 checkIfContextChanged 函数。
  3. checkIfContextChanged 的工作:此函数会遍历该组件的 dependencies 列表,用 Object.is 比较每一个依赖的“旧值” (memoizedValue) 和 Context 栈中的“当前值”。

    • 如果发现任何一个值不一致,函数返回 true。这个信号会阻止 React 跳过该组件,强制其重新渲染。
    • 如果所有值都一致,函数返回 false,组件被成功跳过,避免了不必要的渲染。

    源码清晰地展示了这个过程:遍历 dependencies 链表,使用 is 函数(Object.is 的内部实现)比较 memoizedValue_currentValue

    // src/react/packages/react-reconciler/src/ReactFiberNewContext.js
    
    export function checkIfContextChanged(
      currentDependencies: Dependencies
    ): boolean {
      let dependency = currentDependencies.firstContext;
      while (dependency !== null) {
        const context = dependency.context;
        const newValue = isPrimaryRenderer
          ? context._currentValue
          : context._currentValue2;
        const oldValue = dependency.memoizedValue;
        if (!is(newValue, oldValue)) {
          // 只要有一个 context 的值变了,就返回 true
          return true;
        }
        dependency = dependency.next;
      }
      return false;
    }
    

9-1.png

3. 性能瓶颈:必要 vs. 不必要的渲染

在优化性能之前,我们需要区分两种渲染:

  • 必要的渲染 (Necessary re-render):当组件自身的状态变更,或者它直接使用的信息(如 props 或 context 的一部分)发生变化时,它的重新渲染是必要的。例如,当用户在输入框打字时,管理该输入的组件必须渲染。
  • 不必要的渲染 (Unnecessary re-render):因为架构问题或 React 的渲染机制,一个组件在它依赖的数据完全没有变化的情况下也被重新渲染了。

Context API 的主要性能瓶颈,就在于它很容易导致不必要的渲染。

根本原因在于其检查的粒度太大。一个组件一旦通过 useContext 订阅了某个 Context,它就依赖了整个 value 对象。只要 value 对象的引用发生变化,checkIfContextChanged 检查就会失败,从而强制该组件重新渲染——无论组件实际使用的是 value 中的哪个属性。

示例:一个典型的不必要渲染场景

假设我们有一个包含用户认证和主题设置的全局 Context:

const AppContext = React.createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  // 注意:每次 AppProvider 渲染,都会创建一个全新的 value 对象
  const value = { user, theme, setTheme };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

现在,我们有两个被 React.memo 包裹的组件,以尝试优化性能:

  1. UserProfile 只显示用户信息。
  2. ThemeToggler 只切换主题。
const UserProfile = React.memo(function UserProfile() {
  const { user } = useContext(AppContext);
  console.log("UserProfile rendered (unnecessary)");
  return <div>{user ? user.name : "Guest"}</div>;
});

const ThemeToggler = React.memo(function ThemeToggler() {
  const { theme, setTheme } = useContext(AppContext);
  console.log("ThemeToggler rendered (necessary)");
  return (
    <button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
      {theme}
    </button>
  );
});

问题在于:当我们点击 ThemeToggler 按钮时,setTheme 会触发 AppProvider 的重新渲染。

  1. ThemeToggler 的渲染是必要的,因为它直接使用了 themesetTheme
  2. AppProvider 重新渲染时,创建了一个新的 value 对象
  3. 当 React 准备跳过 UserProfile 的渲染时,checkIfContextChanged 被触发。它比较 AppContext 的新旧 value,发现引用不同。
  4. 因此,React 强制 UserProfile 重新渲染。这次渲染是不必要的,因为 user 的值根本没有改变。

这就是 Context 导致不必要渲染的典型场景。React.memo 在这里失效了,因为它无法阻止由 Context 变更信号触发的强制更新。

4. 性能优化策略

为了解决不必要的渲染,我们可以采用以下几种策略,从易到难,层层递进。

策略一:使用 useMemo 稳定 value 对象

这是最基础的优化。我们应该确保 Providervalue 不会在每次渲染时都创建一个新对象。

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  // 只有当 user 或 theme 变化时,value 的引用才会改变
  const value = useMemo(() => ({ user, theme, setTheme }), [user, theme]);

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

效果:此举可以防止因 AppProvider 的父组件渲染而导致的、不相关的重新渲染。但它仍然没有解决我们上面的核心问题:UserProfile 依然会因为 theme 的变化而渲染。

策略二:拆分 Context

这是解决 Context 性能问题的最有效、最符合 React 理念的方法:保持 Context 的单一职责

不要创建一个包罗万象的“巨石”Context,而应该根据状态的关联性和更新频率,将其拆分为多个更小的、独立的 Context。

// 1. 创建独立的 Context
const UserContext = React.createContext();
const ThemeContext = React.createContext();

// 2. 创建独立的 Provider
function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const value = useMemo(() => ({ user, setUser }), [user]);
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

// 3. 组合 Provider
function AppProviders({ children }) {
  return (
    <UserProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </UserProvider>
  );
}

// 4. 组件按需消费
function UserProfile() {
  const { user } = useContext(UserContext); // 只订阅 UserContext
  console.log("UserProfile rendered");
  return <div>{user ? user.name : "Guest"}</div>;
}

function ThemeToggler() {
  const { theme, setTheme } = useContext(ThemeContext); // 只订阅 ThemeContext
  console.log("ThemeToggler rendered");
  return (
    <button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
      {theme}
    </button>
  );
}

效果:现在,当 ThemeToggler 更新 ThemeContext 时,只有订阅了 ThemeContext 的组件会收到更新信号。UserProfile 因为只订阅了 UserContext,所以完全不受影响,其不必要的渲染被彻底消除。

策略三:组件组合

核心思想是:将那些不关心 Context 变化的、昂贵的组件作为 children prop 传递给一个消费了 Context 的父组件。

这样,当 Context 变化导致父组件重新渲染时,React 会发现 children prop 的引用没有改变(它是在父组件的父组件中定义的),因此会跳过对 children 的重新渲染。

让我们看一个正确应用的例子:

// AppProvider 包含 theme 和 setTheme
// ThemeToggler 用于改变 theme

// 1. 父组件消费 Context,并接受一个 children prop
function ThemeWrapper({ children }) {
  const { theme } = useContext(AppContext);
  console.log(`ThemeWrapper rendered, theme is: ${theme}`);

  // 这个 div 的背景色会变,但它的 children 不会重新渲染
  return (
    <div
      style={{
        backgroundColor: theme === "light" ? "#fff" : "#333",
        padding: "10px",
      }}
    >
      {children}
    </div>
  );
}

// 2. 昂贵的组件,自身不消费 Context
const ExpensiveTree = React.memo(function ExpensiveTree() {
  console.log("ExpensiveTree rendered (should not happen on theme change)");
  // ... 假设这里有非常复杂的 UI
  return <div>这是一个非常昂贵的组件树,它不应该因为主题变化而重绘。</div>;
});

// 3. 在应用中使用
function App() {
  return (
    <AppProvider>
      <ThemeToggler />
      <hr />
      <ThemeWrapper>
        {/* ExpensiveTree 在 App 中定义,作为 children 传递 */}
        <ExpensiveTree />
      </ThemeWrapper>
    </AppProvider>
  );
}

效果:当 theme 变化时,只有 ThemeTogglerThemeWrapper 会重新渲染。ThemeWrapper 重新渲染是必要的,因为它需要应用新的背景色。但关键在于,它接收的 children (<ExpensiveTree />) 是在 App 组件的作用域中创建的。对于 ThemeWrapper 来说,每次渲染时 props.children 的引用都是相同的。因此,React 会成功跳过对 ExpensiveTree 的渲染,避免了不必要的性能开销。

5. 总结

理解 Context API 的订阅机制和性能权衡,是成为一名高效 React 开发者的关键。通过合理地组织 Context 并采用适当的优化策略,我们可以在享受其便利性的同时,构建出高性能、可扩展的应用程序。

localStorage使用不止于getItem、setItem、removeItem

作者 追梦_life
2025年12月25日 09:49

今天我们来聊聊js内置对象localStorage的使用,我们平时一般都是getItemsetItemremoveItem,很少接触其他的。

localStorage.getItem('info')
localStorage.setItem('info', '123')
localStorage.remoItem('info')

某天,突然有个小需求,需要我们清除local中所有以user_开头的数据,怎么办呢?显然光用getItemremoveItem是无法实现的。

那么,我们先来学习几个获取 localStorage 中所有缓存的 key的方法:

方法一:使用 for 循环

function getAllLocalStorageKeys() {
  const keys = []
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i)
    keys.push(key)
  }
  return keys
}

// 使用
const allKeys = getAllLocalStorageKeys()
console.log(allKeys)

方法二:使用扩展运算符和 map

const keys = [...Array(localStorage.length)].map((_, i) => localStorage.key(i))
console.log(keys)

方法三:获取键值对

如果你想同时获取键和对应的值:

function getAllLocalStorageItems() {
  const items = {}
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i)
    const value = localStorage.getItem(key)
    items[key] = value
  }
  return items
}

// 使用
const allItems = getAllLocalStorageItems()
console.log(allItems)

方法四:使用 Object.keys 的替代方法

const keys = Object.keys(localStorage)
console.log(keys)  // 这会返回所有 localStorage 的 key

方法五:封装成实用函数

class LocalStorageHelper {
  static getAllKeys() {
    return Object.keys(localStorage)
  }
  
  static getAllItems() {
    return Object.keys(localStorage).reduce((obj, key) => {
      obj[key] = localStorage.getItem(key)
      return obj
    }, {})
  }
  
  static getKeysByPrefix(prefix) {
    return Object.keys(localStorage).filter(key => key.startsWith(prefix))
  }
}

// 使用
const allKeys = LocalStorageHelper.getAllKeys()
const allItems = LocalStorageHelper.getAllItems()

示例:统计存储情况

function analyzeLocalStorage() {
  const keys = Object.keys(localStorage)
  const totalSize = keys.reduce((total, key) => {
    return total + (localStorage.getItem(key).length || 0)
  }, 0)
  
  console.log(`总条目数: ${keys.length}`)
  console.log(`总大小: ${totalSize} 字符`)
  console.log(`所有键名:`, keys)
  
  return {
    count: keys.length,
    totalSize: totalSize,
    keys: keys
  }
}

analyzeLocalStorage()

推荐使用 方法一方法四,它们简单直接且兼容性好。

知道了这些方法后,清除local中所有以user_开头的数据这个需求就很简单了。

Object.keys(localStorage).forEach(key => {
if (key.startsWith('user_')) {
       localStorage.removeItem(key)
     }
})

最后,localStorage相关限制,我相信大家肯定也是了解的:

  1. 同源策略:localStorage 受同源策略限制,只能访问当前域名下的存储
  2. 数据类型:获取的 key 都是字符串类型
  3. 存储限制:每个域名的 localStorage 通常有 5MB 左右的存储限制
  4. 空值处理:如果 localStorage 为空,这些方法会返回空数组或空对象

vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用

2025年12月25日 09:45

vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用

gantt.vxeui.com/

extend_gantt_chart_gantt_milestone_links

通过设置 task-bar-milestone-config 和 type=moveable 启用里程碑类型,当设置为里程碑类型时,只需要设置 start 开始日期就可以,无需设置 end 结束日期,设置 links 定义连接线,from 对应源任务的行主键,tom 对应目标任务的行主键

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType, VxeGanttTaskType } from 'vxe-gantt'

const ganttOptions = reactive({
  border: true,
  height: 500,
  rowConfig: {
    keyField: 'id' // 行主键
  },
  taskBarConfig: {
    showProgress: true, // 是否显示进度条
    showContent: true, // 是否在任务条显示内容
    moveable: true, // 是否允许拖拽任务移动日期
    resizable: true, // 是否允许拖拽任务调整日期
    barStyle: {
      round: true, // 圆角
      bgColor: '#fca60b', // 任务条的背景颜色
      completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 280 // 表格宽度
    },
    gridding: {
      leftSpacing: 1, // 左侧间距多少列
      rightSpacing: 4 // 右侧间距多少列
    }
  },
  taskBarMilestoneConfig: {
    // 自定义里程碑图标
    icon ({ row }) {
      if (row.id === 10001) {
        return 'vxe-icon-warning-triangle-fill'
      }
      if (row.id === 10007) {
        return 'vxe-icon-square-fill'
      }
      if (row.id === 10009) {
        return 'vxe-icon-warning-circle-fill'
      }
      return 'vxe-icon-radio-unchecked-fill'
    },
    // 自定义里程碑图标样式
    iconStyle ({ row }) {
      if (row.id === 10001) {
        return {
          color: '#65c16f'
        }
      }
      if (row.id === 10007) {
        return {
          color: '#dc3cc7'
        }
      }
    }
  },
  taskLinkConfig: {
    lineType: 'flowDashed'
  },
  links: [
    { from: 10001, to: 10002, type: VxeGanttDependencyType.StartToFinish },
    { from: 10003, to: 10004, type: VxeGanttDependencyType.StartToStart },
    { from: 10007, to: 10008, type: VxeGanttDependencyType.StartToStart },
    { from: 10008, to: 10009, type: VxeGanttDependencyType.FinishToFinish },
    { from: 10009, to: 10010, type: VxeGanttDependencyType.FinishToStart }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'title', title: '任务名称' }
  ],
  data: [
    { id: 10001, title: '项目启动会议', start: '2024-03-01', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
    { id: 10002, title: '项目启动与计划', start: '2024-03-03', end: '2024-03-08', progress: 80, type: '' },
    { id: 10003, title: '需求评审完成', start: '2024-03-03', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
    { id: 10004, title: '技术及方案设计', start: '2024-03-05', end: '2024-03-11', progress: 80, type: '' },
    { id: 10005, title: '功能开发', start: '2024-03-08', end: '2024-03-15', progress: 70, type: '' },
    { id: 10007, title: '测试环境发布', start: '2024-03-11', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
    { id: 10008, title: '系统测试', start: '2024-03-14', end: '2024-03-19', progress: 80, type: '' },
    { id: 10009, title: '测试完成', start: '2024-03-19', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
    { id: 10010, title: '正式发布上线', start: '2024-03-20', end: '', progress: 0, type: VxeGanttTaskType.Milestone }
  ]
})
</script>

gitee.com/x-extends/v…

前端Token无感刷新:让用户像在游乐园畅玩一样流畅

作者 JS_Likers
2025年12月25日 09:41

❤ 写在前面
如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~
个人独立开发wx小程序,感谢支持! small.png


🎪 从游乐园门票说起

想象一下,你去游乐园玩,门票(Token)有一定有效期。传统方式中,门票过期时:

  • 保安拦下你:“票过期了,去售票处重新买!”
  • 你不得不离开项目,排队重新买票,再回来继续玩

无感刷新就像有个贴心助手:

  • 门票快过期时,助手悄悄帮你续期
  • 你完全感知不到,继续畅玩各个项目

这就是我们今天要实现的用户体验!

🔍 为什么需要Token刷新?

Token的生命周期

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  登录获取   │────▶│  使用Token  │────▶│  Token过期  │
│ AccessToken │     │  访问接口   │     │   401错误   │
└─────────────┘     └─────────────┘     └─────────────┘
                                              │
                        ┌─────────────────────┘
                        ▼
                 ┌─────────────┐     ┌─────────────┐
                 │ 传统方式:   │────▶│ 用户需重新  │
                 │ 跳转登录页  │     │    登录     │
                 └─────────────┘     └─────────────┘

问题来了:每次Token过期都让用户重新登录,体验极差!

🎯 无感刷新的核心思路

graph TD
    A[用户发起请求] --> B{Token是否有效?}
    B -- 有效 --> C[正常请求]
    B -- 已过期 --> D[拦截请求]
    D --> E{是否正在刷新?}
    E -- 否 --> F[发起刷新请求]
    F --> G[获取新Token]
    G --> H[重试原请求]
    E -- 是 --> I[加入等待队列]
    I --> J[刷新完成后重试]
    C --> K[返回数据]
    H --> K
    J --> K

💻 实战代码实现(基于axios)

第一步:基础配置

// tokenManager.js
class TokenManager {
  constructor() {
    this.accessToken = localStorage.getItem('access_token');
    this.refreshToken = localStorage.getItem('refresh_token');
    this.isRefreshing = false; // 是否正在刷新
    this.requestsQueue = []; // 请求等待队列
  }
  
  // 保存token
  setTokens(accessToken, refreshToken) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    localStorage.setItem('access_token', accessToken);
    localStorage.setItem('refresh_token', refreshToken);
  }
  
  // 清除token
  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
  }
}

第二步:axios拦截器设置

// http.js
import axios from 'axios';
import TokenManager from './tokenManager';

const tokenManager = new TokenManager();
const http = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 10000
});

// 请求拦截器
http.interceptors.request.use(
  (config) => {
    if (tokenManager.accessToken) {
      config.headers.Authorization = `Bearer ${tokenManager.accessToken}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器 - 核心逻辑在这里!
http.interceptors.response.use(
  (response) => {
    // 正常响应直接返回
    return response;
  },
  async (error) => {
    const originalRequest = error.config;
    
    // 如果不是401错误,直接返回
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }
    
    // 标记这个请求已经重试过,避免无限循环
    originalRequest._retry = true;
    
    // 如果没有refreshToken,跳转到登录页
    if (!tokenManager.refreshToken) {
      tokenManager.clearTokens();
      window.location.href = '/login';
      return Promise.reject(error);
    }
    
    // 如果正在刷新token,将请求加入队列
    if (tokenManager.isRefreshing) {
      return new Promise((resolve) => {
        tokenManager.requestsQueue.push(() => {
          originalRequest.headers.Authorization = `Bearer ${tokenManager.accessToken}`;
          resolve(http(originalRequest));
        });
      });
    }
    
    // 开始刷新token
    tokenManager.isRefreshing = true;
    
    try {
      // 调用刷新接口
      const { data } = await axios.post('/api/auth/refresh', {
        refresh_token: tokenManager.refreshToken
      });
      
      // 保存新token
      tokenManager.setTokens(data.access_token, data.refresh_token);
      
      // 执行等待队列中的所有请求
      tokenManager.requestsQueue.forEach(callback => callback());
      tokenManager.requestsQueue = [];
      
      // 重试原始请求
      originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
      return http(originalRequest);
      
    } catch (refreshError) {
      // 刷新失败,清除token并跳转登录
      tokenManager.clearTokens();
      tokenManager.requestsQueue = [];
      window.location.href = '/login';
      return Promise.reject(refreshError);
    } finally {
      tokenManager.isRefreshing = false;
    }
  }
);

export default http;

第三步:使用示例

// userService.js
import http from './http';

export const getUserInfo = async () => {
  try {
    const response = await http.get('/api/user/info');
    return response.data;
  } catch (error) {
    console.error('获取用户信息失败:', error);
    throw error;
  }
};

export const updateProfile = async (data) => {
  try {
    const response = await http.post('/api/user/profile', data);
    return response.data;
  } catch (error) {
    console.error('更新资料失败:', error);
    throw error;
  }
};

🎨 增强体验:添加视觉提示

虽然说是"无感",但适当的提示能让体验更好:

// 在刷新token时显示加载提示
let refreshLoading = null;

// 修改响应拦截器中的刷新部分
try {
  // 显示轻量级提示
  refreshLoading = showLoading('正在更新登录状态...');
  
  const { data } = await axios.post('/api/auth/refresh', {
    refresh_token: tokenManager.refreshToken
  });
  
  // 隐藏提示
  refreshLoading?.hide();
  showToast('登录状态已更新', 'success', 2000);
  
  // ... 其余逻辑
} catch (error) {
  refreshLoading?.hide();
  showToast('登录已过期,请重新登录', 'error');
  // ... 其余错误处理
}

🛡️ 安全注意事项

  1. Refresh Token有效期:通常比Access Token长,但也不是永久的
  2. 单次使用:每次使用Refresh Token后,服务端应该颁发新的Refresh Token
  3. 安全存储
    // 使用更安全的方式存储
    const secureStorage = {
      setItem: (key, value) => {
        if (window.crypto && window.crypto.subtle) {
          // 考虑使用加密存储
          localStorage.setItem(key, value);
        } else {
          // 降级方案
          localStorage.setItem(key, value);
        }
      },
      getItem: (key) => localStorage.getItem(key)
    };
    

🎪 回到游乐园比喻

现在我们的系统就像这样工作:

游乐园项目(API请求) → 检票口(拦截器)
    │
    ├── 票有效 → 直接进入
    │
    ├── 票过期,有续票资格 → 助手悄悄续票 → 继续游玩
    │
    └── 票过期,无续票资格 → 引导重新购票(登录)

📊 性能优化小贴士

// 1. 预刷新:在token即将过期时提前刷新
const shouldRefreshToken = () => {
  const tokenExpiry = getTokenExpiry(tokenManager.accessToken);
  const now = Date.now();
  // 在过期前5分钟开始刷新
  return tokenExpiry - now < 5 * 60 * 1000;
};

// 2. 定时检查
setInterval(() => {
  if (shouldRefreshToken() && !tokenManager.isRefreshing) {
    refreshTokenSilently();
  }
}, 60000); // 每分钟检查一次

// 3. 并发控制优化
const MAX_QUEUE_SIZE = 50;
if (tokenManager.requestsQueue.length > MAX_QUEUE_SIZE) {
  // 队列过长,可能是异常情况
  tokenManager.requestsQueue = [];
  window.location.reload(); // 或采取其他恢复措施
}

🎉 总结

实现Token无感刷新的关键在于:

  1. 拦截401错误:在axios响应拦截器中捕获
  2. 避免并发刷新:用标志位和队列控制
  3. 优雅降级:刷新失败时友好引导重新登录
  4. 用户体验:适当的提示(但不是打断)

现在你的应用就像那个贴心的游乐园助手,让用户在不知不觉中保持登录状态,享受流畅的体验!

试试实现它,让你的应用告别烦人的"登录已过期"提示吧!🚀


小作业:你能想到在哪些场景下,即使实现了无感刷新,仍然需要主动提示用户重新登录吗?欢迎在评论区分享你的想法!💭

Function.prototype.bind实现

2025年12月25日 09:19

目标

实现函数Function.prototype.mybind,效果等同于Function.prototype.bind

bind接受参数为:(thisArg, ...args)

实现

利用apply函数实现:

Function.prototype.mybind = function(thisArg, ...args) {
  const fn = this;
  
  function bound(...innerArgs) {
    const context = (this instanceof bound) ? this : thisArg;
  
    return fn.apply(context, [...args, ...innerArgs]);
  }
  
  if (fn.prototype){
    bound.prototype = Object.create(fn.prototype);
    bound.prototype.constructor = bound;
  }

  return bound;
}

这里有一个细节,当得到了bound = fn.bind(obj1)后,再次调用bound2 = bound.bind(obj2),会忽略这个bind调用,bound2bound运行时的this都指向obj1。该行为手写bind与原始bind表现一致。

问题

bound.prototype应该为undefined

从 0 搭建 React 待办应用:状态管理、副作用与双向绑定模拟

作者 T___T
2025年12月25日 00:01

React 作为前端主流框架,其单向数据流 组件化 状态驱动视图的设计理念,看似抽象却能通过一个简单的 TodoList 案例彻底吃透。本文不只是 “解释代码”,而是从设计初衷、底层逻辑、实际价值三个维度,拆解 useState useEffect、受控组件模拟双向绑定、父子通信等核心知识点,让你不仅 “会用”,更 “懂为什么这么用”。

一、案例整体架构:先懂 “拆分逻辑”,再看 “代码细节”

在动手写代码前,React 开发的第一步是组件拆分—— 遵循单一职责原则,把复杂页面拆成独立、可复用的小组件,这是 React 组件化思想的核心。

本次 TodoList 的组件拆分如下:

组件名 核心职责 核心交互
App(根组件) 全局状态管理 + 核心逻辑封装 定义新增 / 删除 / 切换待办、数据持久化等方法
TodoInput 待办输入 + 提交 收集用户输入,触发 “新增待办” 逻辑
TodoList 待办列表渲染 展示待办项,转发 “删除 / 切换完成状态” 事件
TodoStats 待办数据统计 展示总数 / 已完成 / 未完成数,触发 “清除已完成” 逻辑

这种拆分的核心价值:每个组件只做一件事,便于维护、复用和调试(比如后续想改输入框样式,只动 TodoInput 即可,不影响列表和统计逻辑)。

二、核心 API 深度拆解:不止 “会用”,更懂 “为什么这么设计”

1. useState:React 状态管理的 “灵魂”

React 中所有可变数据都必须通过**状态(State)**管理,而 useState 是最基础、最核心的状态钩子 —— 它解决了 “函数组件无法拥有自身状态” 的问题,也是 “状态驱动视图” 的核心载体。

(1)基础原理:为什么需要 useState?

纯函数组件本身是 “无状态” 的(执行完就销毁,无法保存数据),而用户交互(比如输入待办、切换完成状态)必然需要 “保存可变数据”。useState 本质是给函数组件提供了持久化的状态存储空间,且这个存储空间和组件渲染周期绑定:

  • 状态更新 → 组件重新渲染 → 视图同步更新;
  • 状态不更新 → 组件不会重复渲染,保证性能。

(2)两种初始化方式:普通初始化 vs 惰性初始化

// 方式1:普通初始化(适合简单、无计算的初始值)
const [count, setCount] = useState(0);

// 方式2:惰性初始化(重点! TodoList 中用的就是这种)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

关键区别与设计初衷

  • 普通初始化:useState(初始值) 中,初始值表达式会在组件每次渲染时都执行(哪怕状态没变化);
  • 惰性初始化:useState(() => { ... }) 中,传入的函数仅在**组件首次渲染*时执行一次,后续渲染不会再跑。

TodoList 中用惰性初始化的核心原因:localStorage.getItem('todos') 是浏览器本地读取操作,虽然开销小,但如果放在普通初始化里,每次组件渲染(比如新增 / 删除待办)都会重复读取本地存储,完全没必要;而惰性初始化只执行一次,既拿到了初始数据,又避免了性能浪费 —— 这是 React 性能优化的 “小细节”,也是理解 useState 设计的关键。

(3)状态更新的 “不可变原则”:为什么必须返回新值?

React 规定:状态是只读的,修改状态必须返回新值,不能直接修改原状态。比如这里的 “新增待办” 逻辑:

const addTodo = (text) => {
  // 错误写法:直接修改原数组(React 无法检测到状态变化,视图不更新)
  // todos.push({ id: Date.now(), text, completed: false });
  // setTodos(todos);

  // 正确写法:解构原数组 + 新增项,返回新数组
  setTodos([...todos, {
    id: Date.now(),
    text,
    completed: false
  }]);
};

底层逻辑:React 判断状态是否变化的依据是引用是否改变。数组 / 对象是引用类型,直接修改原数组(todos.push),数组的引用没变化,React 会认为 “状态没改”,因此不会触发组件重新渲染;而通过 [...todos] 解构生成新数组,引用变了,React 才能检测到状态变化,进而更新视图。

这也是 React “单向数据流” 的核心体现:状态更新是 “不可变” 的,每一次状态变化都会生成新值,便于追踪数据流转(比如调试时能清晰看到每次状态更新的前后值)。

2. useEffect:副作用处理的 “专属管家”

React 组件的核心职责是根据状态渲染视图,而像 “读取本地存储、发送网络请求、绑定事件监听、修改 DOM” 这类不直接参与渲染,但又必须执行的操作,统称为 “副作用(Side Effect)”。useEffect 是 React 专门为处理副作用设计的钩子,替代了类组件中 componentDidMount componentDidUpdate componentWillUnmount 等生命周期方法,且逻辑更集中。

(1)核心语法与执行机制

useEffect(() => {
  // 副作用逻辑:比如保存数据到本地存储
  localStorage.setItem('todos', JSON.stringify(todos));

  // 可选的清理函数(比如取消事件监听、清除定时器)
  return () => {
    // 组件卸载/依赖变化前执行
  };
}, [todos]); // 依赖数组:决定副作用的执行时机

执行时机的深度解析

  • 依赖数组为空 []:仅在组件首次渲染完成后执行一次(对应类组件 componentDidMount);
  • 依赖数组有值 [todos]:组件首次渲染执行 + 每次依赖项(todos)变化后执行(对应 componentDidMount + componentDidUpdate);
  • 无依赖数组:组件每次渲染完成后都执行(极少用,易导致性能问题);
  • 清理函数:组件卸载前 / 下一次副作用执行前触发(比如监听窗口大小变化后,卸载组件时要取消监听,避免内存泄漏)。

(2)在 TodoList 中的核心应用:数据持久化

代码中,useEffect 用来将 todos 同步到 localStorage,这是前端 “数据持久化” 的经典场景,我们拆解其价值:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
  • 为什么 localStorage 只能存字符串? localStorage 是浏览器提供的本地存储 API,其底层设计只支持字符串键值对存储,因此存储数组 / 对象时,必须用 JSON.stringify 转为字符串;读取时用 JSON.parse 转回原数据类型,这是前端本地存储的通用规则。

(3)useEffect 在这里的核心价值(为什么非它不可)

1. 精准触发:只在需要时执行,保证性能

useEffect 的第二个参数(依赖数组 [todos])是关键:

  • 组件首次渲染时,执行一次(把初始的 todos 保存到本地);
  • 只有 todos 发生实际变化时,才会再次执行(新增 / 删除 / 切换状态 / 清除已完成,只要 todos 变了,就同步保存);
  • todos 没变化时(比如组件因其他状态重新渲染),完全不执行,避免无效操作。

对比 “写在组件顶层” 的无差别执行,useEffect 实现了 “按需执行”,既保证数据同步,又不浪费性能。

2. 时机正确:拿到最新的状态,避免数据不一致

useEffect 的执行时机是「组件渲染完成后」—— 也就是说,当 useEffect 里的代码执行时,setTodos 已经完成了状态更新,todos 一定是最新的。

比如新增待办时:

  1. 调用 addTodo → 执行 setTodos → 组件重新渲染(todos 变为新值);
  2. 渲染完成后,useEffect 检测到 todos 变化 → 执行保存逻辑 → 拿到的是最新的 todos

这就避免了 “异步更新导致保存旧值” 的问题,保证本地存储的数据和组件状态完全一致。

3. 逻辑聚合:一处监听,全场景生效

不管是新增、删除、切换状态、清除已完成,只要最终导致 todos 变化,useEffect 都会自动触发保存 —— 无需在每个修改 todos 的函数里重复写保存逻辑,代码简洁、易维护,后续新增修改 todos 的逻辑(比如批量修改),完全不用动保存代码,天然符合 “开闭原则”。

(4)useEffect 的设计价值:分离 “渲染逻辑” 与 “副作用逻辑”

React 追求 “组件核心逻辑纯净”—— 组件顶层只关注 “根据状态渲染什么”,副作用全部交给 useEffect 处理,这样:

  • 代码结构更清晰:渲染和副作用分离,一眼能区分 “视图相关” 和 “非视图相关” 逻辑;
  • 便于调试:副作用的执行时机由依赖数组明确控制,能精准定位 “什么时候执行、为什么执行”;
  • 避免内存泄漏:通过清理函数可优雅处理 “组件卸载后仍执行副作用” 的问题(比如请求数据时组件卸载了,清理函数可取消请求)。

3. 受控组件:模拟双向绑定的底层逻辑

Vue 中用 v-model 就能实现 “表单值 ↔ 数据” 的双向绑定,但 React 没有内置的双向绑定语法 —— 不是 “做不到”,而是 React 坚持单向数据流,通过 “受控组件” 手动模拟双向绑定,虽然代码多了几行,但能完全掌控数据流转。

(1)双向绑定的本质:视图 ↔ 数据同步

不管是 Vue 的 v-model 还是 React 的受控组件,双向绑定的核心是两件事:

  1. 数据 → 视图:数据(状态)变化,视图(输入框)自动更新;
  2. 视图 → 数据:视图(用户输入)变化,数据(状态)自动更新。

(2)React 受控组件的实现:拆解每一步

以 TodoInput 组件为例,逐行解析双向绑定的实现逻辑:

const TodoInput = ({ onAdd }) => {
  // 步骤1:定义状态存储输入框值(数据层)
  const [inputValue, setInputValue] = useState('');

  // 步骤2:处理表单提交
  const handleSubmit = (e) => {
    // 关键:阻止表单默认提交行为
    e.preventDefault();
    // 输入内容校验:去除首尾空格,避免空提交
    const text = inputValue.trim();
    if (!text) return;
    // 步骤3:将输入内容传给父组件(父子通信)
    onAdd(text);
    // 步骤4:清空输入框(修改状态 → 视图清空)
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        // 核心1数据视图状态控制输入框显示value={inputValue}
        // 核心2视图数据输入变化同步更新状态onChange={e => setInputValue(e.target.value)}
        placeholder="请输入待办事项..."
      />
      <button type="submit">Add</button>
    </form>
  );
};

逐点深度解析

  • 数据 → 视图value={inputValue} 是 “单向绑定” 的核心 —— 输入框显示的内容完全由 inputValue 状态决定,而非 DOM 自身的 value。比如执行 setInputValue('')inputValue 变为空,输入框就会立刻清空,这是 “状态驱动视图” 的体现。
  • 视图 → 数据onChange 事件监听输入框的每一次字符变化,e.target.value 是输入框当前的 DOM 取值,通过 setInputValue 将其同步到 inputValue 状态 —— 这一步是 “手动补全” 双向绑定的反向流程,也是 React 与 Vue 的核心区别(Vue 把这一步封装成了 v-model,React 让开发者手动控制,更灵活)。
  • e.preventDefault() :表单的默认行为是 “提交并刷新页面”,而 React 是单页应用,刷新页面会导致所有状态丢失,因此必须阻止这个默认行为 —— 这是前端开发的通用知识点,也是 React 处理表单的 “必做步骤”。
  • 为什么用 form + onSubmit 而非 button + onClick除了点击按钮提交,用户在输入框按回车键也能触发 onSubmit,而单纯的 onClick 无法响应回车提交,这是语义化 + 用户体验的双重考量。

(3)受控组件的核心优势:完全可控

相比 Vue 的 v-model 黑盒封装,React 受控组件的 “手动操作” 带来了两个核心价值:

  • 可校验性:在 onChange 或 handleSubmit 中可随时对输入内容做校验(比如禁止输入特殊字符、限制长度、去除空格),比如在代码中 inputValue.trim() 就是简单的校验,若需要更复杂的校验(比如手机号格式),可直接在这一步处理;
  • 可追溯性:输入框的每一次值变化都必须通过 setInputValue 触发,在调试工具中能清晰看到 inputValue 的每一次更新记录,便于定位 “输入异常” 问题(比如输入框值不变,可直接查 setInputValue 是否执行)。

4. 父子组件通信:单向数据流的极致体现

React 的 “单向数据流” 不是 “限制”,而是 “保障”—— 数据只能从父组件通过 props 流向子组件,子组件不能直接修改父组件的状态,只能通过父组件传递的回调函数 “通知” 父组件修改状态。这种设计让数据流转路径清晰,避免了 “多个组件随意修改数据导致的混乱”。

(1)通信流程:以 “清除已完成任务” 为例

  1. 父组件(App) :定义状态修改逻辑 + 传递回调函数
// 步骤1:父组件定义修改状态的核心逻辑
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

// 步骤2:通过 props 将回调函数传递给子组件
<TodoStats 
  total={todos.length}
  completed={completedCount}
  active={activeCount}
  onClearCompleted={clearCompleted} // 传递回调
/>
  1. 子组件(TodoStats) :接收回调函数 + 触发回调
const TodoStats = ({ total, completed, active, onClearCompleted }) => {
  return (
    <div>
      <p>Total: {total}</p>
      <p>Completed: {completed}</p>
      <p>Active: {active}</p>
      {/* 条件渲染:有已完成任务才显示按钮 */}
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          清除已完成任务
        </button>
      )}
    </div>
  );
};

深度解析

  • 子组件 TodoStats 只负责 “展示数据 + 触发交互”,不关心 “清除已完成任务” 的具体逻辑 —— 哪怕后续修改清除逻辑(比如加确认弹窗),只需改父组件的 clearCompleted,子组件完全不用动,符合 “开闭原则”。
  • 回调函数是 “子组件通知父组件” 的唯一方式:子组件无法直接访问父组件的 todos 状态,也不能直接调用 setTodos,只能通过父组件传递的 onClearCompleted 回调,触发父组件的状态修改逻辑 —— 这就是 “单向数据流”:数据向下传(父→子),事件向上传(子→父),所有状态修改都集中在父组件,便于追踪和调试。

(2)props 的本质:只读的 “数据桥梁” (后面会单独来讲)

props 是父子组件通信的唯一桥梁,但有一个核心规则:子组件不能修改 props。比如 TodoStats 接收的 completed total 等 props,子组件只能读取,不能修改 —— 因为 props 是父组件状态的 “快照”,修改 props 会导致数据源头混乱(比如子组件改了 completed,父组件的 completedCount 却没变化,数据不一致)。

image.png

三、核心设计思想:从 TodoList 看 React 的底层逻辑

通过这个 TodoList 案例,我们能提炼出 React 最核心的 4 个设计思想,这也是理解 React 的关键:

1. 状态驱动视图

React 中 “视图是什么样” 完全由 “状态是什么样” 决定,没有 “手动操作 DOM” 的场景(比如不用 document.getElementById 改输入框值,不用 appendChild 加待办项)。所有视图变化,都是先修改状态,再由 React 自动更新 DOM—— 这避免了手动操作 DOM 的繁琐和易出错,也让代码更易维护(只需关注状态变化,不用关注 DOM 变化)。

2. 单向数据流

数据只有一个流向:父组件 → 子组件,状态只有一个修改入口:定义状态的组件(比如 todos 定义在 App,只有 App 能改,子组件只能通过回调通知 App 改)。这种设计让数据流转 “可预测”—— 不管项目多复杂,都能顺着 props 找到数据的源头,顺着回调找到状态修改的地方。

3. 组件化与单一职责

每个组件只做一件事:TodoInput 只处理输入,TodoList 只渲染列表,TodoStats 只展示统计。这种拆分让组件 “高内聚、低耦合”:

  • 高内聚:组件内部逻辑围绕核心职责展开,不掺杂其他功能;
  • 低耦合:组件之间通过 props 通信,修改一个组件不会影响其他组件。

4. 副作用与渲染分离

useEffect 将 “副作用逻辑”(比如本地存储)与 “渲染逻辑”(比如展示待办列表)分离,让组件的核心逻辑(根据状态渲染视图)保持 “纯净”—— 纯净的组件逻辑更易测试、更易复用,这也是 React 推崇的 “函数式编程” 思想的体现。

四、总结:从 TodoList 到 React 核心能力

这个看似简单的 TodoList,实则涵盖了 React 日常开发的核心知识点:

  • useState 实现状态管理,理解 “不可变更新” 和 “惰性初始化”;
  • useEffect 处理副作用,理解 “依赖数组” 和 “数据持久化”;
  • 受控组件模拟双向绑定,理解 “状态驱动视图” 和 “单向数据流”;
  • 父子组件通信,理解 props 的 “只读特性” 和回调函数的作用。

鸿蒙开发日记:如何对应用ICON进行HarmonyOS风格化处理

作者 文轩702
2025年12月24日 23:53

随着HarmonyOS Design System的演进,更为美观的分层图标处理技术通过解构图标的视觉层次,实现了设计规范统一与动态换肤能力。该技术将图标拆分为前景层与背景层资源,结合设备DPI自适应算法,显著提升了多终端场景下的视觉一致性。下面就笔者的一些经验,与大家进行分享。

技术架构解析

  1. 资源层结构

采用JSON描述文件实现资源声明,支持动态路径映射:

{

  "layered-image": {

    "background": "$media:bg_neumorphism",

    "foreground": "$media:fg_gradient",

    "metadata": {

      "version": "5.1.1",

      "compatibility": ["Phone", "TV"]

    }

  }

}
  1. 渲染引擎优化
  • 多线程资源预加载机制

  • 实时主题色注入系统

  • 内存复用池

核心开发流程

1 工程配置规范// 资源管理器初始化

const resManager: resourceManager.ResourceManager = context.resourceManager;

const layeredDrawableDescriptor = new LayeredDrawableDescriptor({

  density: display.getDefaultDisplaySync().density,

  themeMode: systemConfiguration.getColorMode()

});

2 动态渲染实现@Component

struct AdaptiveIcon {

  @State processedIcon: image.PixelMap | undefined = undefined;

  async aboutToAppear() {

    try {

      const result = await hdsDrawable.processLayeredIcon({

        background: $r('app.media.background'),

        foreground: $r('app.media.foreground'),

        config: {

          size: 48,

          cornerRadius: '12vp',

          shadowConfig: {

            elevation: 3,

            ambientColor: '#20000000',

            spotColor: '#40000000'

          }

        }

      });

      this.processedIcon = result.pixelMap;

    } catch (error) {

      logger.error('Icon processing failed:', error.code);

    }

  }

  build() {

    Stack() {

      if (this.processedIcon) {

        Image(this.processedIcon)

          .transition(EffectType.OPACITY)

      }

    }

  }

}

高级特性实现

1 批量处理优化// 应用列表场景下的性能优化方案

const batchProcessor = new hdsDrawable.BatchProcessor({

  maxConcurrent: 4,

  cacheStrategy: 'LRU',

  memoryLimit: 50 * 1024 * 1024

});

const results = await batchProcessor.processIcons([

  {bundleName: 'com.example.app1', config: iconConfig},

  {bundleName: 'com.example.app2', config: iconConfig}

]);

2 动态主题适配// 实时主题切换监听

systemConfiguration.on('colorModeChange', (newMode) => {

  this.iconRenderer.updateTheme({

    primaryColor: newMode === 'DARK' ? '#FFFFFFFF' : '#FF000000',

    backgroundColor: newMode === 'DARK' ? '#1A1A1A' : '#FFFFFF'

  });

});

性能调优方案

  1. 内存管理
  • 建立三级缓存策略
  • 实现Native层内存复用
  • 动态卸载非活跃资源
  1. 渲染优化
  • 预生成多分辨率资源
  • 硬件加速渲染管线
  • 异步光栅化机制

调试与问题定位// 性能监控埋点

const perfMonitor = new hdsDrawable.PerformanceMonitor();

perfMonitor.on('frameUpdate', (metrics) => {

  if (metrics.renderTime > 16) {

    logger.warn('Render frame drop detected:', metrics);

  }

});

技术总结

分层图标处理技术通过架构级创新,解决了多设备适配与动态换肤的核心痛点。开发者应当重点关注资源声明规范、内存管理策略以及渲染性能优化,同时结合业务场景选择合适的批量处理方案。随着HarmonyOS设计系统的持续演进,该技术将成为构建高端视觉体验的基础能力。

欢迎大家加入我们的班级一起学习:developer.huawei.com/consumer/cn…

❌
❌