阅读视图
用三行代码实现圣诞树?别逗了!让我们来真的
🎄 用三行代码实现圣诞树?别逗了!让我们来真的!
🌟 圣诞节的正确打开方式
圣诞节快到了,是不是感觉家里缺了点什么?🎅 对,就是那棵 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: absolute和transform: translateX(-50%)让元素居中
3. JavaScript 的灵魂 🧠
- 动态生成装饰品:随机位置、随机颜色、随机闪烁延迟
- 雪花生成器:100 片雪花,随机大小、随机速度、随机位置
- 礼物盒生成:4 个不同颜色的礼物盒,带有弹跳效果
🎁 扩展功能
如果你觉得这个圣诞树还不够炫酷,你可以尝试:
-
添加音乐:用 HTML5 的
audio标签添加圣诞歌曲 🎵 - 交互效果:点击圣诞树会下雪或播放音乐 🎶
- 3D 效果:使用 CSS 3D 变换让圣诞树旋转 🌀
- 更多装饰品:添加彩灯、铃铛、袜子等 🧦
🤣 程序员的圣诞节
作为一个程序员,我们的圣诞节是这样的:
- 别人在装饰圣诞树,我们在装饰代码
- 别人在拆礼物,我们在拆 bug
- 别人在吃火鸡,我们在吃外卖
- 别人在看春晚,我们在看技术文档
但是没关系,我们有属于自己的快乐!当看到自己写的圣诞树在屏幕上闪闪发光时,那种成就感是无法言喻的!🌟
🎄 结语
好了,今天的圣诞树教程就到这里了!希望你能喜欢这个代码圣诞树,也希望你能在圣诞节收获满满的快乐和幸福!🎅
记住,生活就像圣诞树,需要我们用心去装饰,才能变得更加美好!✨
最后,祝大家:
- 圣诞快乐!🎄
- 代码无 bug!🐛❌
- 工资涨不停!💰
- 永远不脱发!👨💻👩💻
Merry Christmas and Happy New Year! 🎉
💡 小贴士:如果你觉得这个圣诞树不错,别忘了分享给你的朋友,让他们也感受一下程序员的圣诞浪漫!😂
Vue3 调用 Coze 工作流:从上传宠物照到生成冰球明星的完整技术解析
引言
“你家的猫,也能打冰球?”
不是玩笑——这是一次前端与 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 实现响应式逻辑。整体分为三部分:
-
<template>:用户界面(UI) -
<script setup>:业务逻辑(JS) -
<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>
-
position和shooting_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>元素的引用。 - 使用
FileReader的readAsDataURL方法,将文件转为 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;
}
逐行解析:
-
构造 FormData:
-
new FormData()是浏览器原生 API,用于构建 multipart/form-data 请求体,专为文件上传设计。 -
formData.append('file', file):Coze 要求字段名为file。
-
-
发送 POST 请求:
-
URL:
https://api.coze.cn/v1/files/upload -
Headers:
-
Authorization: Bearer <token>:Coze 使用 Bearer Token 认证。
-
-
Body:
formData自动设置正确 Content-Type(含 boundary)。
-
-
处理响应:
-
成功时返回:
{ "code": 0, "msg": "success", "data": { "id": "file_xxx", ... } } -
失败时
code !== 0,msg包含错误原因(如 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 工作流结构(图解说明)
图注:
- 开始节点:接收
picture,style,uniform_number,position,shooting_hand,uniform_color等参数。- 分支一:
imgUnderstand_1(图像理解)→ 分析上传图片内容(如动物种类、姿态)。- 分支二:
代码节点 → 根据position,shooting_hand,style等生成描述文本(如“一只狗,右手持杆,身穿红色10号队服,站在冰球场上”)。- 大模型节点:将图像理解结果与描述文本合并,生成最终提示词(prompt)。
- 图像生成节点:调用文生图模型(如豆包·1.5·Pro·32k),生成新图像。
- 结束节点:输出生成图的 URL。
前端代码的对应关系
| 前端参数 | Coze 输入字段 | 用途 |
|---|---|---|
picture |
picture |
图片文件 ID,传入 imgUnderstand_1 和 图像生成 节点 |
style |
style |
传递给 代码 节点,决定艺术风格 |
uniform_number |
uniform_number |
用于生成描述 |
position |
position |
决定角色动作(如守门员蹲姿) |
shooting_hand |
shooting_hand |
决定持杆手 |
uniform_color |
uniform_color |
用于生成队服颜色 |
💡 关键点:前端只需提供原始参数,Coze 工作流内部完成所有逻辑编排。
数据流全过程
-
前端上传文件 → 得到
file_id -
前端组装参数 → 发送至
/workflow/run -
Coze 工作流执行:
-
imgUnderstand_1:分析图片内容 → 输出text,url,content -
代码节点:根据参数生成描述 → 如"一只猫,身穿蓝色10号队服,右手持杆,站在冰球场上,风格为乐高" -
大模型节点:合并图像理解结果与描述 → 生成最终 prompt -
图像生成节点:调用模型生成图像 → 返回data字段(URL)
-
-
前端接收响应:
- 若
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
运行界面如下:
选择图片及风格等内容后,点击开始生成,运行结果如图:
总结:为什么这个项目值得学习?
-
真实场景:不是 Hello World,而是完整产品逻辑。
-
技术全面:
- Vue3 Composition API
- 文件上传与预览
- Fetch API 与错误处理
- 环境变量管理
- 响应式状态驱动 UI
-
AI 集成范式:展示了如何将复杂 AI 能力封装为简单 API,前端只需“填参数 + 拿结果”。
-
用户体验优先:状态提示、本地预览、错误反馈一应俱全。
安全与部署建议:
-
后端代理所有 Coze API 调用:
- 前端 → 自己的后端(/api/generate)
- 后端 → Coze(携带安全存储的 Token)
-
限制工作流权限:Coze 的 PAT Token 应仅授予必要权限。
-
添加速率限制:防止滥用。
最终,技术的意义在于创造快乐。
当你上传一张狗子的照片,看到它穿上红色10号球衣、右手持杆、以“乐高”风格站在冰场上——
你会笑,会分享,会说:“AI 真酷!”
而这,正是我们写代码的初心。
vue3这些常见指令你封装了吗
::: 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"是下一代互联网?——从中心化到去中心化的转变
🌐 为什么"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或许能给你一个更好的答案!
💬 互动话题
- 你用过Web3应用吗?体验如何?
- 你觉得Web3会取代Web2吗?为什么?
- 你最期待Web3在哪个领域的应用?
快来评论区聊聊你的想法!💬 点赞收藏不迷路,咱们下期继续探索计算机的"十万个为什么"!🎉
关注我,下期带你解锁更多计算机的"奇葩冷知识"!🤓
🍀vue3 + Typescript +Tdesign + HiPrint 打印下载解决方案
效果图
注册 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>
下载和打印拣货单
原理:
- 第一步需要将 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' : '',
);
扩展:直接拖拽组件实现打印下载
该部分内容篇幅比较大,后续会重新出一篇文章。。。。
CSS 全局样式污染问题复盘
一、问题现象
1.1 问题描述
VGM 编辑弹窗(使用 CmcDialog 组件)出现异常的内边距,导致弹窗内容布局错乱,表单元素间距过大。
1.2 问题截图
弹窗内容区域出现了不应有的 padding: 52px 50px 样式,导致:
- 表单内容被压缩
- 布局与设计稿不符
- 视觉效果异常
1.3 影响范围
所有使用 el-dialog 或基于 el-dialog 封装的组件(如 CmcDialog)都受到影响。
二、问题定位
2.1 排查过程
-
检查组件自身样式 -
CmcDialog组件样式正常 -
检查父组件样式 - 使用
CmcDialog的页面无异常样式 -
使用 DevTools 检查 - 发现
.el-dialog被注入了全局样式 -
全局搜索污染源 - 搜索
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>
这导致:
- Dialog DOM 脱离了组件的 DOM 树
- Scoped 样式的
data-v-xxxxx属性无法正确应用 - 必须使用
: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>
关键改动:
- 使用
class而非仅依赖modal-class - 使用组合选择器
.subscriber-dialog-box确保唯一性 - 样式只作用于带有该特定类名的 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 作用域泄漏 问题,由以下因素共同导致:
- Teleport 机制 - Dialog DOM 脱离组件树
- :global() 滥用 - 跳过 scoped 限制
- 选择器特异性不足 - 没有使用组合选择器
- 样式加载顺序 - 后加载的样式覆盖先加载的
6.2 核心教训
-
永远不要直接
:global(.el-xxx)- 必须添加特定的父选择器或组合选择器 -
组件库封装要有防御性 - 使用
!important重置关键样式 -
使用
class而非仅modal-class- 确保样式能正确应用 - 命名要有唯一性 - 使用 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 的订阅机制与性能优化
1. 引言:Context API 的双面性
Context API 诞生的核心目标是解决 React 组件树中 “props 钻取” 问题—— 当深层子组件需要使用顶层组件的状态时,无需通过中间组件逐层传递 props。
然而,这种便利性的背后也隐藏着性能上的挑战。默认情况下,任何消费了 Context 的组件都会在 Context 值发生变化时被强制重新渲染,即使它只关心该值的一小部分。这可能导致大规模且不必要的渲染,从而影响应用性能。
2. 核心机制:被动的“拉取式”订阅
要理解 Context 的工作原理,我们必须将其视为一个被动的、在“跳过更新”时进行检查的“拉取式”订阅系统,而非主动的“发布-订阅”模型。
-
React.createContext(defaultValue): 创建一个 Context 对象。这个对象本身就像一个“主题”或“事件中心”。 -
<Context.Provider value={...}>: 这是值的提供者。当它渲染时,它会将valueprop 的值推入一个全局的 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 会做两件关键的事:- 读取值:从 Context 栈中读取当前的活跃值。
-
记录依赖(订阅):将该 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 试图优化渲染时被动触发的。
-
Provider值变更:Provider的valueprop 获得了一个新的对象引用。Provider重新渲染,并将这个新值推入 Context 栈。 -
子组件渲染与检查:React 向下渲染子组件。
-
对于普通组件:由于父节点(或更上层的祖先)在渲染,它们也会默认重新渲染。在渲染过程中,它们调用
useContext,自然会读取到 Context 栈中最新的值。 -
对于希望“跳过更新”的组件(如被
React.memo包裹且 props 未变的组件):React 在准备跳过它之前,会执行一道额外的安全检查——调用内部的checkIfContextChanged函数。
-
对于普通组件:由于父节点(或更上层的祖先)在渲染,它们也会默认重新渲染。在渲染过程中,它们调用
-
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; } - 如果发现任何一个值不一致,函数返回
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 包裹的组件,以尝试优化性能:
-
UserProfile只显示用户信息。 -
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 的重新渲染。
-
ThemeToggler的渲染是必要的,因为它直接使用了theme和setTheme。 -
AppProvider重新渲染时,创建了一个新的value对象。 - 当 React 准备跳过
UserProfile的渲染时,checkIfContextChanged被触发。它比较AppContext的新旧value,发现引用不同。 - 因此,React 强制
UserProfile重新渲染。这次渲染是不必要的,因为user的值根本没有改变。
这就是 Context 导致不必要渲染的典型场景。React.memo 在这里失效了,因为它无法阻止由 Context 变更信号触发的强制更新。
4. 性能优化策略
为了解决不必要的渲染,我们可以采用以下几种策略,从易到难,层层递进。
策略一:使用 useMemo 稳定 value 对象
这是最基础的优化。我们应该确保 Provider 的 value 不会在每次渲染时都创建一个新对象。
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 变化时,只有 ThemeToggler 和 ThemeWrapper 会重新渲染。ThemeWrapper 重新渲染是必要的,因为它需要应用新的背景色。但关键在于,它接收的 children (<ExpensiveTree />) 是在 App 组件的作用域中创建的。对于 ThemeWrapper 来说,每次渲染时 props.children 的引用都是相同的。因此,React 会成功跳过对 ExpensiveTree 的渲染,避免了不必要的性能开销。
5. 总结
理解 Context API 的订阅机制和性能权衡,是成为一名高效 React 开发者的关键。通过合理地组织 Context 并采用适当的优化策略,我们可以在享受其便利性的同时,构建出高性能、可扩展的应用程序。
localStorage使用不止于getItem、setItem、removeItem
今天我们来聊聊js内置对象localStorage的使用,我们平时一般都是getItem、setItem和removeItem,很少接触其他的。
localStorage.getItem('info')
localStorage.setItem('info', '123')
localStorage.remoItem('info')
某天,突然有个小需求,需要我们清除local中所有以user_开头的数据,怎么办呢?显然光用getItem和removeItem是无法实现的。
那么,我们先来学习几个获取 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相关限制,我相信大家肯定也是了解的:
- 同源策略:localStorage 受同源策略限制,只能访问当前域名下的存储
- 数据类型:获取的 key 都是字符串类型
- 存储限制:每个域名的 localStorage 通常有 5MB 左右的存储限制
- 空值处理:如果 localStorage 为空,这些方法会返回空数组或空对象
vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用
vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用
通过设置 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>
前端Token无感刷新:让用户像在游乐园畅玩一样流畅
❤ 写在前面
如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~
个人独立开发wx小程序,感谢支持!
🎪 从游乐园门票说起
想象一下,你去游乐园玩,门票(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');
// ... 其余错误处理
}
🛡️ 安全注意事项
- Refresh Token有效期:通常比Access Token长,但也不是永久的
- 单次使用:每次使用Refresh Token后,服务端应该颁发新的Refresh Token
-
安全存储:
// 使用更安全的方式存储 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无感刷新的关键在于:
- 拦截401错误:在axios响应拦截器中捕获
- 避免并发刷新:用标志位和队列控制
- 优雅降级:刷新失败时友好引导重新登录
- 用户体验:适当的提示(但不是打断)
现在你的应用就像那个贴心的游乐园助手,让用户在不知不觉中保持登录状态,享受流畅的体验!
试试实现它,让你的应用告别烦人的"登录已过期"提示吧!🚀
小作业:你能想到在哪些场景下,即使实现了无感刷新,仍然需要主动提示用户重新登录吗?欢迎在评论区分享你的想法!💭
Function.prototype.bind实现
目标
实现函数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调用,bound2与bound运行时的this都指向obj1。该行为手写bind与原始bind表现一致。
问题
bound.prototype应该为undefined
从 0 搭建 React 待办应用:状态管理、副作用与双向绑定模拟
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 一定是最新的。
比如新增待办时:
- 调用
addTodo→ 执行setTodos→ 组件重新渲染(todos变为新值); - 渲染完成后,
useEffect检测到todos变化 → 执行保存逻辑 → 拿到的是最新的todos。
这就避免了 “异步更新导致保存旧值” 的问题,保证本地存储的数据和组件状态完全一致。
3. 逻辑聚合:一处监听,全场景生效
不管是新增、删除、切换状态、清除已完成,只要最终导致 todos 变化,useEffect 都会自动触发保存 —— 无需在每个修改 todos 的函数里重复写保存逻辑,代码简洁、易维护,后续新增修改 todos 的逻辑(比如批量修改),完全不用动保存代码,天然符合 “开闭原则”。
(4)useEffect 的设计价值:分离 “渲染逻辑” 与 “副作用逻辑”
React 追求 “组件核心逻辑纯净”—— 组件顶层只关注 “根据状态渲染什么”,副作用全部交给 useEffect 处理,这样:
- 代码结构更清晰:渲染和副作用分离,一眼能区分 “视图相关” 和 “非视图相关” 逻辑;
- 便于调试:副作用的执行时机由依赖数组明确控制,能精准定位 “什么时候执行、为什么执行”;
- 避免内存泄漏:通过清理函数可优雅处理 “组件卸载后仍执行副作用” 的问题(比如请求数据时组件卸载了,清理函数可取消请求)。
3. 受控组件:模拟双向绑定的底层逻辑
Vue 中用 v-model 就能实现 “表单值 ↔ 数据” 的双向绑定,但 React 没有内置的双向绑定语法 —— 不是 “做不到”,而是 React 坚持单向数据流,通过 “受控组件” 手动模拟双向绑定,虽然代码多了几行,但能完全掌控数据流转。
(1)双向绑定的本质:视图 ↔ 数据同步
不管是 Vue 的 v-model 还是 React 的受控组件,双向绑定的核心是两件事:
- 数据 → 视图:数据(状态)变化,视图(输入框)自动更新;
- 视图 → 数据:视图(用户输入)变化,数据(状态)自动更新。
(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)通信流程:以 “清除已完成任务” 为例
- 父组件(App) :定义状态修改逻辑 + 传递回调函数
// 步骤1:父组件定义修改状态的核心逻辑
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// 步骤2:通过 props 将回调函数传递给子组件
<TodoStats
total={todos.length}
completed={completedCount}
active={activeCount}
onClearCompleted={clearCompleted} // 传递回调
/>
- 子组件(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 却没变化,数据不一致)。
三、核心设计思想:从 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风格化处理
随着HarmonyOS Design System的演进,更为美观的分层图标处理技术通过解构图标的视觉层次,实现了设计规范统一与动态换肤能力。该技术将图标拆分为前景层与背景层资源,结合设备DPI自适应算法,显著提升了多终端场景下的视觉一致性。下面就笔者的一些经验,与大家进行分享。
技术架构解析
- 资源层结构
采用JSON描述文件实现资源声明,支持动态路径映射:
{
"layered-image": {
"background": "$media:bg_neumorphism",
"foreground": "$media:fg_gradient",
"metadata": {
"version": "5.1.1",
"compatibility": ["Phone", "TV"]
}
}
}
- 渲染引擎优化
-
多线程资源预加载机制
-
实时主题色注入系统
-
内存复用池
核心开发流程
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'
});
});
性能调优方案
- 内存管理
- 建立三级缓存策略
- 实现Native层内存复用
- 动态卸载非活跃资源
- 渲染优化
- 预生成多分辨率资源
- 硬件加速渲染管线
- 异步光栅化机制
调试与问题定位// 性能监控埋点
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…