普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月28日技术

文件Base64转换工具升级:从图片到多格式文件的全新体验

2025年10月28日 15:28

文件Base64转换工具升级:从图片到多格式文件的全新体验

image.png

图片和Base64 互相转换工具:一个简单但实用的离线工具

图片Base64转换工具新增剪贴板粘贴功能

在日常使用中发现,原有的“图片 ↔ Base64 离线转换工具”已经无法满足更复杂的场景。尤其是在需要处理压缩包、文档等非图片文件时,单个图片转换工具的局限性变得明显。为此,我开发了全新的“文件Base64转换工具集合”,为用户带来更强大、更便捷的文件转换体验,方便一次性处理多个文件。


为什么要升级?

  • 图片工具的局限:原工具仅支持图片格式,无法处理ZIP、RAR等压缩文件,也不支持文件名保留。
  • 多样化需求:实际应用中,除了图片,常常需要传输或存储压缩包、文档、音频等多种文件格式。
  • 操作不便:当有多个文件类型需要转换时,用户不得不寻找不同的工具,效率低下。

新工具亮点

1. 多格式支持

  • 支持图片(JPG、PNG、GIF、BMP、WebP)与压缩文件(ZIP、RAR、7Z、TAR、GZ、BZ2、XZ)与Base64互转。

2. 文件名保留

  • 转换为Base64时自动保存原始文件名,还原时自动恢复,方便文件管理和分享。

3. 剪贴板操作

  • 支持从剪贴板粘贴图片、Base64文本,提升操作效率。

4. 响应式设计

  • 完美适配桌面和移动设备,界面美观,操作流畅。

5. 现代化UI

  • 左侧导航栏分类,支持后续扩展更多文件类型。

使用场景举例

  • 前端开发:快速将图片或压缩包转为Base64嵌入代码或配置文件。
  • 数据传输:将文件编码为Base64,便于API传输或文本存储。
  • 文件分享:保留原始文件名,方便接收方还原文件。

项目地址与推荐

🚀 推荐体验:文件Base64转换工具集合

支持图片、压缩包等多种文件格式与Base64互转,功能强大,完全离线,保护隐私!


技术细节解析

压缩文件如何转Base64?

  1. 文件读取

    • 工具采用浏览器原生的 FileReader API,支持直接读取本地的 ZIP、RAR、7Z、TAR、GZ、BZ2、XZ 等压缩文件。
    • 用户选择文件后,FileReader 会以 DataURL 方式读取整个文件内容。
  2. Base64编码

    • DataURL 本质上就是 data:[MIME类型];base64,[数据] 格式,自动将文件内容转为Base64字符串。
    • 工具会自动识别文件类型,设置正确的 MIME 类型,保证还原时格式不丢失。
  3. 文件名保留机制

    • 为了让还原后的文件名与原始一致,工具会将文件名以自定义前缀方式编码到Base64字符串中:
      • 格式:FILENAME:[编码的文件名]|[原始Base64数据]
    • 还原时自动解析前缀,恢复原始文件名。
  4. 完全本地处理,安全可靠

    • 所有转换和解析操作均在浏览器本地完成,不上传任何数据,保护用户隐私。
  5. 兼容性与扩展性

    • 支持所有现代浏览器,无需安装插件。
    • 代码结构模块化,便于后续扩展更多文件类型(如PDF、音频、视频等)。

代码片段示例

// 压缩文件转Base64
const reader = new FileReader();
reader.onload = (e) => {
  // 文件名编码
  const base64WithFileName = `FILENAME:${btoa(encodeURIComponent(file.name))}|${e.target.result}`;
  // 显示/复制/下载
};
reader.readAsDataURL(file);

// Base64还原文件名
function extractFileNameFromBase64(base64Data) {
  if (base64Data.startsWith('FILENAME:')) {
    const parts = base64Data.split('|');
    const fileName = decodeURIComponent(atob(parts[0].replace('FILENAME:', '')));
    const actualBase64 = parts.slice(1).join('|');
    return { fileName, base64Data: actualBase64 };
  }
  return { fileName: null, base64Data };
}

结语

新版本工具不仅解决了原有图片工具的局限,还为未来扩展更多文件类型(如文档、音频、视频)打下了坚实基础。欢迎大家体验并提出建议,让工具变得更好用!

如果你觉得有用,欢迎Star支持!

干货!Python采集淘宝商品详情数据,淘宝API接口系列(json数据返回)

2025年10月28日 14:45

以下是基于淘宝开放平台API的Python商品详情采集深度指南,包含完整技术实现与合规注意事项:

一、前置条件准备

  1. 开放平台入驻

    • 注册平台账号
    • 创建应用获取app_keyapp_secret
    • 申请taobao.item.get接口权限(需企业认证)
  2. 商品ID获取技巧

    python
    # 从商品链接提取num_iid
    import re
    url = "https://detail.tmall.com/item.htm?id=68543210987"
    item_id = re.search(r'id=(\d+)', url).group(1)
    

二、完整API调用实现(增强版)

python
import requests
import hashlib
import time
import json
from urllib.parse import urlparse

class TaobaoAPI:
    def __init__(self, app_key, app_secret, sandbox=False):
        self.app_key = app_key
        self.app_secret = app_secret
        self.sandbox = sandbox
        self.base_url = "https://gw.api.tbsandbox.com/router/rest" if sandbox else "https://eco.taobao.com/router/rest"

    def _generate_sign(self, params):
        """生成符合淘宝规范的MD5签名"""
        param_str = "".join(f"{k}{params[k]}" for k in sorted(params.keys()))
        sign_str = f"{self.app_secret}{param_str}{self.app_secret}"
        return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()

    def get_item_detail(self, item_id, fields="num_iid,title,price,pic_url,desc,skus,props_name,quantity"):
        """获取商品详情(含错误重试机制)"""
        params = {
            'method': 'taobao.item.get',
            'app_key': self.app_key,
            'timestamp': time.strftime("%Y-%m-%d %H:%M:%S"),
            'format': 'json',
            'v': '2.0',
            'sign_method': 'md5',
            'num_iid': item_id,
            'fields': fields
        }
        
        # 添加签名
        params['sign'] = self._generate_sign(params)
        
        # 添加公共参数
        params.update(self._get_common_params())
        
        try:
            response = requests.get(self.base_url, params=params, timeout=5)
            response.raise_for_status()
            return response.json()
        except (requests.exceptions.RequestException, KeyError) as e:
            return self._handle_error(e, item_id)

    def _get_common_params(self):
        """获取公共请求参数"""
        return {
            'partner_id': 'open-api-sdk',
            'target_app_key': '12345678',  # 替换为目标APPKEY
            'sdk_version': '2.0',
            'simplify': 'false'
        }

    def _handle_error(self, error, item_id):
        """错误处理与重试逻辑"""
        if isinstance(error, requests.exceptions.Timeout):
            return {"error": "Request timeout"}
        elif 'code' in str(error):
            error_code = json.loads(error).get('error_response', {}).get('code')
            return self._map_error_code(error_code, item_id)
        return {"error": str(error)}

    def _map_error_code(self, code, item_id):
        """错误码映射处理"""
        error_map = {
            11: "API权限不足,请检查应用权限",
            27: f"商品不存在或无权限访问: {item_id}",
            100: "参数错误,请检查请求参数",
            10001: "系统内部错误,请重试"
        }
        return {"error": error_map.get(code, "未知错误")}

# 使用示例
if __name__ == "__main__":
    APP_KEY = "YOUR_APP_KEY"
    APP_SECRET = "YOUR_APP_SECRET"
    ITEM_ID = "68543210987"
    
    taobao = TaobaoAPI(APP_KEY, APP_SECRET)
    result = taobao.get_item_detail(ITEM_ID)
    
    # 解析响应数据
    if 'error' not in result:
        item_data = result['taobao_item_get_response']['item']
        print(f"商品标题: {item_data['title']}")
        print(f"价格: ¥{item_data['price']}")
        print(f"主图: {item_data['pic_url']}")
        
        # 处理SKU数据
        skus = item_data.get('skus', {}).get('sku', [])
        for sku in skus:
            print(f"规格: {sku['properties']} | 价格: {sku['price']} | 库存: {sku['quantity']}")
    else:
        print(f"错误信息: {result['error']}")

三、关键技术细节解析

  1. 签名算法优化

    • 采用参数名排序+值拼接的MD5加密方式
    • 示例签名串:secretkeyapp_key12345fieldsnum_iid,titleformatjsonmethodtaobao.item.getnum_iid123456timestamp2025-10-28 12:00:00v2.0secretkey
  2. 字段选择策略

    • 基础字段:num_iid,title,price,pic_url
    • 扩展字段:desc(详情描述)、props_name(属性名)、quantity(库存)
    • 规格数据:通过skus字段获取多规格商品信息
  3. 错误处理增强

    • 网络请求超时重试机制
    • 错误码映射系统(如11→权限不足,27→商品不存在)
    • 沙箱环境测试支持

四、合规与反爬策略

  1. 频率控制

    python
    # 请求间隔控制示例
    import time
    def safe_request(item_id):
        time.sleep(0.2)  # 5秒内不超过25次请求
        return taobao.get_item_detail(item_id)
    
  2. **User-Agent设置

    python
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
    }
    
  3. IP代理轮换

    • 建议使用代理IP池应对高频访问限制
    • 可集成scrapy-rotating-proxy等中间件

五、数据解析示例

返回JSON典型结构:

json
{
  "taobao_item_get_response": {
    "item": {
      "num_iid": "68543210987",
      "title": "2025春季新款男士休闲裤",
      "price": "129.00",
      "pic_url": "https://img.alicdn.com/example.jpg",
      "desc": "<img src='...' />商品详细描述",
      "skus": {
        "sku": [
          {
            "properties": "颜色:深蓝;尺码:30",
            "price": "129.00",
            "quantity": 200
          },
          {
            "properties": "颜色:黑色;尺码:32",
            "price": "139.00",
            "quantity": 150
          }
        ]
      }
    }
  }
}

六、常见问题解决方案

  1. 权限不足(错误码11)

    • 检查应用权限配置
    • 确认已申请taobao.item.get接口
    • 联系开放平台客服提升权限
  2. 商品不存在(错误码27)

    • 确认商品ID正确性
    • 检查商品是否下架或区域限制
    • 验证应用是否有权访问该商品
  3. 签名验证失败

    • 检查时间戳格式(YYYY-MM-DD HH:MM:SS)
    • 确认参数排序正确
    • 验证app_secret是否泄露

企业级用户登录Token存储最佳实践,吊打面试官

2025年10月28日 14:37

目录

  1. 引言
  2. Token存储位置对比
  3. 常见安全威胁
  4. HttpOnly + Secure + SameSite Cookie方案详解
  5. 双Token认证方案
  6. 不同场景下的最佳实践
  7. 代码实现示例
  8. 总结

引言

用户登录后获取的Token(令牌)是用户身份的临时凭证,正确存储和使用Token对应用安全至关重要。本文将详细讨论Token的存储位置、安全威胁、防御措施以及最佳实践,帮助开发者构建更安全的认证系统。

Token存储位置对比

localStorage/sessionStorage

  • 优点
    • 使用简单,API友好
    • 前端可随时读写
    • 容量较大(通常5MB)
    • sessionStorage在会话结束后自动清除
  • 缺点
    • 易受XSS攻击(JavaScript可直接读取)
    • 不适合存储敏感信息
    • 无法设置过期时间(需手动管理)

内存(变量/状态管理)

  • 优点
    • 页面刷新后丢失,降低被窃取的时间窗口
    • JavaScript不易持久化
    • 不受同源策略限制
  • 缺点
    • 页面刷新会丢失,需要刷新机制
    • 标签页关闭后无法恢复
    • 无法在多标签页间共享

Cookie

  • 优点
    • 可设置HttpOnly防止JavaScript读取
    • 自动随请求发送到服务器
    • 可设置过期时间和域范围
    • 配合SameSite可抵御部分CSRF攻击
  • 缺点
    • 容量小(通常4KB)
    • 默认随请求自动发送,需防CSRF
    • 跨域复杂,受同源策略限制
    • 用户可手动清除或禁用

常见安全威胁

XSS(跨站脚本攻击)

攻击原理:攻击者在网页中注入恶意JavaScript代码,当用户访问该页面时,恶意代码会在用户的浏览器中执行。

生活案例:就像有人在银行大厅安装了隐形摄像头,当你输入密码时,他可以看到你的一举一动。

Token风险:如果Token存储在localStorage或普通Cookie中,恶意JavaScript可以读取并发送到攻击者的服务器。

CSRF(跨站请求伪造)

攻击原理:攻击者诱导已登录用户访问恶意网站,该网站会"代替用户"向目标网站发送请求,利用浏览器会自动携带Cookie的特性。

生活案例:想象你收到一封看似银行的邮件,点击链接后,实际上触发了一个转账请求。因为你已登录银行网站,银行会认为这是你本人操作。

场景演示

  1. 用户登录了银行网站A
  2. 用户访问恶意网站B
  3. B网站包含一个表单,自动提交到A网站的转账接口
  4. 浏览器发送请求时会自动携带A网站的Cookie
  5. A网站验证Cookie有效,执行转账操作

关键点:攻击者不需要读取Cookie内容,只需让浏览器自动携带Cookie发起请求。

中间人攻击

攻击原理:攻击者位于用户与服务器之间,可以拦截和修改通信内容。

生活案例:你以为在和银行柜员对话,实际上中间有人在传话,可能篡改你的指令。

Token风险:如果不使用HTTPS,Token在传输过程中可能被窃取。

HttpOnly + Secure + SameSite Cookie方案详解

HttpOnly

  • 作用:禁止JavaScript通过document.cookie访问Cookie
  • 防御:有效防止XSS攻击读取Cookie中的Token
  • 限制:只防止读取,不防止CSRF(因为浏览器仍会自动发送Cookie)

Secure

  • 作用:仅在HTTPS连接中发送Cookie
  • 防御:防止明文传输被窃听
  • 必要性:现代Web应用必须启用

SameSite

  • 作用:控制跨站请求是否携带Cookie
  • 选项
    • Lax(默认):顶级导航(如点击链接)会发送Cookie,但大多数跨站子请求(如图片加载)不会
    • Strict:只有同站请求才发送Cookie
    • None:允许跨站请求发送Cookie,但必须同时设置Secure
  • 防御效果
    • Lax可防御大部分CSRF攻击,但不完美
    • Strict安全性最高,但用户体验可能受影响
    • None需要额外CSRF防御措施

配置示例

Set-Cookie: token=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600

双Token认证方案

概念解释

  • Access Token:短期访问令牌,用于API请求认证
  • Refresh Token:长期刷新令牌,用于获取新的Access Token

工作流程

  1. 用户登录成功,服务器返回Access Token和Refresh Token
  2. Access Token存储在内存中,Refresh Token存储在HttpOnly Cookie中
  3. 每次API请求使用Access Token认证
  4. Access Token过期后,使用Refresh Token获取新的Access Token
  5. 如果Refresh Token也过期,用户需要重新登录

安全增强措施

  • Token轮换:每次刷新都生成新的Refresh Token,旧Token立即失效
  • 复用检测:如果旧Refresh Token被再次使用,说明可能被盗用,立即撤销所有Token
  • 设备绑定:将Token与设备指纹关联,防止跨设备使用

生活案例

就像游乐园的"腕带+身份证"系统:

  • 腕带(Access Token):当天有效,用于快速进入各项目,丢失影响小
  • 身份证(Refresh Token):长期有效,只在腕带失效时用于换取新腕带,平时妥善保管

不同场景下的最佳实践

Web单页应用(SPA)

  • Access Token存内存,通过Authorization头发送
  • Refresh Token存HttpOnly + Secure + SameSite Cookie
  • 实现CSRF Token机制
  • 全站HTTPS

服务端渲染(SSR)

  • 优先使用服务器会话
  • 通过HttpOnly会话Cookie维持状态
  • 前端不直接接触Token

移动应用

  • 使用系统安全存储(iOS Keychain/Android Keystore)
  • 实现证书固定(Certificate Pinning)
  • 考虑生物认证(指纹/面部识别)

跨域应用

  • 谨慎设置Cookie Domain
  • 必要时使用SameSite=None + Secure
  • 强化CSRF防御和CORS配置

代码实现示例

后端实现(Node.js + Express)

// 登录接口
app.post('/api/login', (req, res) => {
  // 验证用户凭据
  const { username, password } = req.body;
  
  // 假设验证通过
  const accessToken = generateAccessToken(username);
  const refreshToken = generateRefreshToken(username);
  
  // 设置Refresh Token为HttpOnly Cookie
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
  });
  
  // 返回Access Token
  res.json({
    accessToken,
    expiresIn: 900 // 15分钟
  });
});

// 刷新Token接口
app.post('/api/refresh', (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  
  if (!refreshToken) {
    return res.status(401).json({ message: '未授权' });
  }
  
  // 验证Refresh Token
  try {
    const user = verifyRefreshToken(refreshToken);
    
    // 生成新Token
    const newAccessToken = generateAccessToken(user.username);
    const newRefreshToken = rotateRefreshToken(user.username, refreshToken);
    
    // 设置新的Refresh Token
    res.cookie('refresh_token', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });
    
    res.json({
      accessToken: newAccessToken,
      expiresIn: 900
    });
  } catch (err) {
    res.clearCookie('refresh_token');
    return res.status(401).json({ message: '刷新Token无效' });
  }
});

// 登出接口
app.post('/api/logout', (req, res) => {
  // 清除Refresh Token Cookie
  res.clearCookie('refresh_token');
  // 在服务器端将Refresh Token加入黑名单
  blacklistRefreshToken(req.cookies.refresh_token);
  
  res.json({ message: '登出成功' });
});

前端实现(React)

// 认证上下文
const AuthContext = createContext();

function AuthProvider({ children }) {
  const [accessToken, setAccessToken] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // 初始化检查登录状态
  useEffect(() => {
    checkAuth();
  }, []);
  
  // 登录函数
  const login = async (username, password) => {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
        credentials: 'include' // 重要:允许发送和接收Cookie
      });
      
      if (!response.ok) throw new Error('登录失败');
      
      const data = await response.json();
      setAccessToken(data.accessToken);
      
      // 设置自动刷新
      setTimeout(refreshToken, (data.expiresIn - 60) * 1000);
      
      return true;
    } catch (error) {
      console.error('登录错误:', error);
      return false;
    }
  };
  
  // 刷新Token
  const refreshToken = async () => {
    try {
      const response = await fetch('/api/refresh', {
        method: 'POST',
        credentials: 'include'
      });
      
      if (!response.ok) {
        setAccessToken(null);
        return false;
      }
      
      const data = await response.json();
      setAccessToken(data.accessToken);
      
      // 设置下次刷新
      setTimeout(refreshToken, (data.expiresIn - 60) * 1000);
      
      return true;
    } catch (error) {
      setAccessToken(null);
      return false;
    }
  };
  
  // 登出
  const logout = async () => {
    try {
      await fetch('/api/logout', {
        method: 'POST',
        credentials: 'include'
      });
    } finally {
      setAccessToken(null);
    }
  };
  
  // API请求拦截器
  const authFetch = async (url, options = {}) => {
    // 添加Authorization头
    const authOptions = {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${accessToken}`
      }
    };
    
    try {
      const response = await fetch(url, authOptions);
      
      // 如果返回401,尝试刷新Token
      if (response.status === 401) {
        const refreshed = await refreshToken();
        if (refreshed) {
          // 使用新Token重试请求
          authOptions.headers.Authorization = `Bearer ${accessToken}`;
          return fetch(url, authOptions);
        } else {
          throw new Error('未授权');
        }
      }
      
      return response;
    } catch (error) {
      console.error('请求错误:', error);
      throw error;
    }
  };
  
  // 检查是否已登录
  const checkAuth = async () => {
    setLoading(true);
    try {
      const refreshed = await refreshToken();
      setLoading(false);
      return refreshed;
    } catch (error) {
      setLoading(false);
      return false;
    }
  };
  
  const value = {
    accessToken,
    isAuthenticated: !!accessToken,
    login,
    logout,
    authFetch,
    loading
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// 使用Hook
function useAuth() {
  return useContext(AuthContext);
}

CSRF防御实现

// 后端生成CSRF Token
app.get('/api/csrf-token', (req, res) => {
  const csrfToken = generateRandomToken();
  
  // 存储在普通Cookie中
  res.cookie('csrf_token', csrfToken, {
    secure: true,
    sameSite: 'lax'
  });
  
  res.json({ csrfToken });
});

// CSRF保护中间件
function csrfProtection(req, res, next) {
  // 跳过GET请求
  if (req.method === 'GET') return next();
  
  const cookieToken = req.cookies.csrf_token;
  const headerToken = req.headers['x-csrf-token'];
  
  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ message: 'CSRF验证失败' });
  }
  
  next();
}

// 应用到需要保护的路由
app.post('/api/sensitive-action', csrfProtection, (req, res) => {
  // 处理敏感操作
});

// 前端实现
async function performSensitiveAction() {
  // 从Cookie中读取CSRF Token
  const csrfToken = document.cookie
    .split('; ')
    .find(row => row.startsWith('csrf_token='))
    ?.split('=')[1];
  
  // 发送请求时在头部包含CSRF Token
  const response = await fetch('/api/sensitive-action', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    credentials: 'include',
    body: JSON.stringify({ /* 数据 */ })
  });
  
  return response.json();
}

总结

最佳实践清单

  1. Web应用

    • Access Token存内存,通过Authorization头发送
    • Refresh Token存HttpOnly + Secure + SameSite Cookie
    • 实现CSRF Token机制
    • 全站HTTPS
    • 敏感操作不使用GET方法
  2. Token安全

    • Access Token短期有效(5-15分钟)
    • Refresh Token适中有效期(7-30天)
    • 实现Token轮换与复用检测
    • 使用JTI(JWT ID)管理Token撤销
  3. 防御措施

    • XSS防御:CSP、输入验证、输出编码
    • CSRF防御:SameSite Cookie + CSRF Token
    • 中间人防御:HTTPS + 证书固定

安全与用户体验平衡

安全性和用户体验往往需要权衡。最佳方案应根据应用场景、用户群体和安全需求来确定。双Token方案在大多数情况下能提供良好的安全性和用户体验平衡。

最终建议

无论选择哪种方案,都应定期审计安全措施,关注新的安全威胁,并及时更新防御策略。安全是一个持续过程,而非一次性工作。

vite框架下大屏适配方案

作者 李剑一
2025年10月28日 14:34

前情

最近打算写几个大屏,作为后续工作的一个简历吧。毕竟现在技术类的找工作不单单的是得会忽悠,还得有点儿作品。

怎么好像在说UI岗一样,在网上扒了扒效果,想要写的炫酷一些,页面开发完了以后在公司给一哥们演示的时候拉跨了。

家里的电脑屏幕本身是2K的,而公司里的是普通的1080p的。

原本在家里适配的非常好的页面在公司非常别扭,而且大量的宽度、高度、字体因为是写死的,在1080p的屏幕上出现了换行。

唉,百密一疏啊!页面效果、动画展示、数据加载等等都考虑到了,就是没考虑屏幕兼容...

image.png

方案选择

紧急调研了一下,现在比较主流的兼容方案一般有以下几种:

  1. 相对单位适配:
  • 使用相对单位如vw、vh、vmin、vmax,这些单位会根据屏幕的宽度和高度进行适配,从而实现不同屏幕下的适配。
  • 但是问题在于这种情况下可能出现小屏幕拥挤、大屏幕空旷的问题。
  1. 媒体查询适配:
  • 通过媒体查询(@media)来实现不同屏幕下的适配。各种屏幕都考虑到了,并且针对性的进行优化,能够保证效果相对来说处于最佳。
  • 但是问题在于,如果要兼容的屏幕很多,那么代码就会很复杂,并且容易出错。
  1. 缩放适配
  • 通过使用transform: scale() 来实现不同屏幕下的适配。简单快捷,几乎不用额外的考虑极端情况。
  • 但是问题在于,缩放后的页面可能不太好看。尤其是特殊屏幕可能会出现大黑边的情况。

层层筛选还是打断用相对单位做,但是可以使用 less 函数来简化这个过程。

简单地说就是使用 less 函数让他在编译过程中自动的将 px 转化为 vw/vh,实现相对比较完美的显示效果。

20251028.png

代码开发

  1. 首先安装 less
npm i less --save-dev
  1. 创建一个util.less作为工具函数
@charset "utf-8";

// 默认设计稿的宽度
@designWidth: 1920;

// 默认设计稿的高度
@designHeight: 1080;

// px 转 vw
.px2vw(@name, @px) {
  @{name}: (@px / @designWidth) * 100vw;
}

// px 转 vh
.px2vh(@name, @px) {
  @{name}: (@px / @designHeight) * 100vh;
}
  1. 在vite中使用 preprocessorOptions 为每个less插入工具函数
export default defineConfig({
    css: {
        preprocessorOptions: {
            less: {
                additionalData: `@import "@/assets/css/utils.less";`
            }
        }
    }
})
  1. vue 中使用
<style scope lang='less'>
    .card {
        .px2vw(width, 400);
        .px2vh(height, 300);
    }
</style>

总结

非常简单的一个小方案,但是却实现了相当不错的效果。适配上来说也还算比较的好吧,但是肯定是不如媒体查询那么完美。

image.png

不过从实现上来说比媒体查询的难度下降了不是一个量级。

最大的难度或许来自于你在 css 中写 .px2vw(width, 200) 这种别扭的感觉。

《CSS3 星球大战》页面实现笔记:用代码演绎银河史诗

作者 UIUV
2025年10月28日 14:33

《CSS3 星球大战》页面实现笔记:用代码演绎银河史诗

在前端开发的世界里,HTML 是剧本,CSS 是导演,而 JavaScript 则是演员。今天,我们将深入剖析一个极具视觉冲击力的前端项目——“CSS3 星球大战”页面。这个项目不仅展示了 HTML 与 CSS 的强大表现力,更体现了开发者如何通过精心设计的动画与布局,将一段经典的电影开场字幕完美复刻到网页之上。

一、项目背景与目标

“星球大战”(Star Wars)系列电影以其标志性的开场字幕闻名于世:巨大的黄色文字在深邃的宇宙背景中缓缓向上倾斜移动,仿佛从遥远星系穿越而来。我们的目标是使用纯 HTML 和 CSS 技术,在网页上重现这一经典视觉效果。

本项目不依赖 JavaScript,完全通过 CSS3 动画、3D 变换、关键帧动画(@keyframes)和定位技术 实现动态滚动字幕与 Logo 的浮现效果,是一次对现代 CSS 能力的深度探索。

image.png

二、HTML 结构设计:语义化与可维护性

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 星球大战</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="starwars">
<img src="./star.svg" alt="star" class="star">
<img src="./wars.svg" alt="wars" class="wars">
<h2 class="byLine" id="byLine">
<span>T</span><span>h</span><span>e</span>
<span>F</span><span>o</span><span>r</span><span>c</span><span>e</span>
<span>A</span><span>w</span><span>a</span><span>k</span><span>e</span>
</h2>
</div>
</body>
</html>
  1. 语义化标签选择 使用 <h2> 标签包裹“The Force Awakens”,符合语义化原则,表示这是页面中的二级标题。 每个字母用 <span> 包裹,便于后续对单个字符进行独立动画控制。
  2. 图片资源处理 “Star” 和 “Wars” 使用 SVG 矢量图,保证在不同分辨率下清晰显示。 SVG 文件独立引入,便于维护和替换,也利于 SEO 和无障碍访问(通过 alt 属性)。

三、CSS 布局核心:3D 空间与居中策略

  1. 全局样式设置
body {
height: 100vh;
background: #000 url(./bg.jpg);
}

设置全屏高度(100vh),背景为黑色并加载宇宙星空背景图,营造太空氛围。 黑色背景与黄色文字形成高对比度,还原电影原作风格。

  1. 容器 .starwars 的 3D 环境构建
.starwars {
perspective: 800px;
transform-style: preserve-3d;
width: 34em;
height: 17em;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

(1)perspective: 800px 定义用户观察 3D 元素的距离,数值越小,3D 效果越强烈。 800px 提供适中的透视感,使动画自然而不夸张。 (2)transform-style: preserve-3d 确保子元素在进行 3D 变换时保持其三维空间位置,而不是被扁平化渲染。 若不设置此属性,Z 轴变换将失效。 (3)水平垂直居中 使用 position: absolute 配合 top: 50%; left: 50% 将元素定位到视口中心。 再通过 transform: translate(-50%, -50%) 将自身中心点对齐到视口中心,实现精准居中。

四、视觉元素定位与样式

  1. Logo 图片定位
img {
width: 100%;
}
.star, .wars, .byLine {
position: absolute;
}
.star { top: -0.75em; }
.wars { bottom: -0.5em; }

所有图片宽度设为 100%,填充父容器宽度。 .star 和 .wars 分别向上、向下偏移,形成错落有致的排版,模仿电影片头 Logo 的布局。

  1. 文字标题 .byLine 样式
.byLine {
left: -2em;
right: -2em;
top: 45%;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 1.6em;
color: white;
}

使用 left 和 right 拉伸宽度,确保文字居中对齐。 text-transform: uppercase 统一为大写,符合电影字体风格。 letter-spacing 增加字母间距,提升可读性和科技感。

五、动画系统设计:时间轴与视觉节奏

整个页面的核心在于 三组关键帧动画,分别控制 “Star”、“Wars” 和 “The Force Awakens” 的出现与消失。

  1. 主标题动画:move-byLine
@keyframes move-byLine {
0% { transform: translateZ(5em); }
100% { transform: translateZ(0); }
}

利用 Z 轴位移,让文字从“远处”向用户“推进”,增强 3D 感。 虽然 Z 轴移动本身不会改变元素在屏幕上的位置,但在透视环境下会产生“靠近”的视觉错觉。

  1. 字符级动画:span-byLine
.byLine span {
display: inline-block;
animation: span-byLine 10s linear infinite;
}

@keyframes span-byLine {
0%,10% { opacity: 0; transform: rotateY(90deg); }
30% { opacity: 1; }
70%,86% { transform: rotateY(0); opacity: 1; }
95%,100% { opacity: 0; }
}

将每个 <span> 设为 inline-block,使其支持 transform 和 opacity 动画。 字符从右侧旋转进入(rotateY(90deg) → 0),配合淡入淡出,模拟“逐字浮现”效果。 动画周期为 10 秒,循环播放,营造持续滚动的错觉。

  1. Logo 动画:star 与 wars
@keyframes star {
0% { opacity: 0; transform: scale(1.5) translateY(-0.75em); }
20% { opacity: 1; }
89% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: translateZ(-1000em); }
}

初始状态:透明、放大、轻微上移。 20% 时完全显现,随后缓慢缩小至正常尺寸。 最终通过 translateZ(-1000em) 将元素“推入”远处并隐藏,模拟远去效果。 wars 动画逻辑相同,仅起始位置和关键帧时间不同(以实现更好的效果)。

@keyframes wars {
0% { opacity: 0; transform: scale(1.5) translateY(0.5em); }
20% { opacity: 1; }
89% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: translateZ(-1000em); }
}

六、动画性能与浏览器兼容性考量

  1. 使用 transform 和 opacity 提升性能 这两个属性触发的是 合成层(composite layer) 变化,不涉及重排(reflow)和重绘(repaint),动画更流畅。 避免使用 left、top、width 等会触发布局计算的属性做动画。
  2. 动画循环与时间控制 所有动画时长统一为 10s,保证同步性。 使用 infinite 实现无限循环,适合演示场景。 实际项目中可根据需求改为 1 次播放。
  3. 浏览器兼容性 transform、animation、@keyframes 在现代浏览器中支持良好。 对于老旧浏览器,建议提供降级方案(如静态图片展示)。

七、总结:前端即艺术

这个“CSS3 星球大战”项目不仅是技术实现,更是一场视觉艺术的编程表达。它告诉我们: CSS 不只是样式工具,更是动画引擎。 精准的定位 + 强大的变换 = 无限可能。 每一个像素的移动,都是代码写就的诗意。

正如你所说:“前端是代码界的导演。” 在这个舞台上,我们用 HTML 搭建布景,用 CSS 编排镜头,用动画讲述故事。而“星球大战”这一经典开场,正是我们向世界宣告:前端,也可以很浪漫。

致谢:本项目灵感来源于经典电影《星球大战》,技术实现基于现代 CSS3 特性。愿原力与你同在(May the Force be with you)。

react中 所有给 children 传值的方式

作者 云中雾丽
2025年10月28日 14:33

我为您整理一份更加全面的所有给 children 传值的方式完整指南。


📚 所有给 Children 传值的方式(完全指南)

1️⃣ Context API(上下文API)

使用场景:全局状态共享、避免prop drilling、跨级组件通信

/**
 * 场景:需要跨越多个中间组件传递数据
 * 示例:主题、语言、用户信息等全局配置
 */
import React, { createContext, useContext, ReactNode, useState } from 'react';

// 创建Context
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => {},
});

// Provider组件
interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  // 通过Context.Provider向所有后代组件传值
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// 使用 useContext Hook 访问Context中的值
const Button: React.FC = () => {
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <button 
      onClick={toggleTheme}
      style={{ background: theme === 'light' ? '#fff' : '#000' }}
    >
      当前主题: {theme}
    </button>
  );
};

// 使用示例
// <ThemeProvider>
//   <Button />
//   <OtherComponents />  {/* 任何深层组件都能访问Context */}
// </ThemeProvider>

2️⃣ React.Children.map + React.cloneElement

使用场景:统一修改所有子组件的props、类型过滤、条件渲染

/**
 * 场景:ButtonGroup 需要统一给所有Button子组件设置大小和颜色
 */
import React, { ReactNode } from 'react';

interface ButtonGroupProps {
  children: ReactNode;
  size?: 'small' | 'medium' | 'large';
  color?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
}

const ButtonGroup: React.FC<ButtonGroupProps> = ({
  children,
  size = 'medium',
  color = 'primary',
  disabled = false
}) => {
  // 使用React.Children.map遍历所有子组件
  const enhancedChildren = React.Children.map(children, (child) => {
    // 检查是否为有效的React元素
    if (!React.isValidElement(child)) {
      return child;
    }

    // 可选:过滤只处理特定类型的子组件
    // if (child.type.name !== 'Button') {
    //   return child;
    // }

    // 使用React.cloneElement克隆并合并新props
    return React.cloneElement(child, {
      size,      // 注入统一的size
      color,     // 注入统一的color
      disabled,  // 注入统一的disabled状态
      // 保留原有props(新props优先级更高)
    });
  });

  return <div className="button-group">{enhancedChildren}</div>;
};

// 使用示例
// <ButtonGroup size="large" color="primary" disabled={false}>
//   <Button>确定</Button>
//   <Button>取消</Button>
//   <Button>删除</Button>
// </ButtonGroup>

3️⃣ Render Props(函数作为 children)

使用场景:需要children访问父组件的状态/方法、灵活的渲染控制

/**
 * 场景:List组件让使用者控制每项如何渲染
 */
import React, { ReactNode } from 'react';

interface ListProps<T> {
  // children是一个函数,接收item和index,返回ReactNode
  children: (item: T, index: number, isEven: boolean) => ReactNode;
  data: T[];
}

function List<T>({ data, children }: ListProps<T>) {
  return (
    <ul>
      {data.map((item, index) => (
        <li key={index}>
          {/* 调用children函数并传入数据 */}
          {children(item, index, index % 2 === 0)}
        </li>
      ))}
    </ul>
  );
}

// 使用示例
const users = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' },
  { id: 3, name: 'Charlie', role: 'user' },
];

// <List data={users}>
//   {(user, index, isEven) => (
//     <div style={{ background: isEven ? '#f0f0f0' : '#fff' }}>
//       <span>{index + 1}. {user.name}</span>
//       <span>角色: {user.role}</span>
//     </div>
//   )}
// </List>

4️⃣ Render Props + 状态管理

使用场景:children需要访问父组件的复杂状态和多个方法

/**
 * 场景:Form组件提供表单状态和验证方法给children
 */
import React, { ReactNode, useState, useCallback } from 'react';

interface FormValue {
  [key: string]: any;
}

interface FormRenderPropsArgs {
  values: FormValue;
  setValues: (values: FormValue) => void;
  setFieldValue: (field: string, value: any) => void;
  errors: { [key: string]: string };
  setFieldError: (field: string, error: string) => void;
  submit: () => void;
}

interface FormProps {
  children: (args: FormRenderPropsArgs) => ReactNode;
  onSubmit?: (values: FormValue) => void;
  initialValues?: FormValue;
}

const Form: React.FC<FormProps> = ({
  children,
  onSubmit,
  initialValues = {}
}) => {
  const [values, setValues] = useState<FormValue>(initialValues);
  const [errors, setErrors] = useState<{ [key: string]: string }>({});

  // 提供设置单个字段值的方法
  const setFieldValue = useCallback((field: string, value: any) => {
    setValues(prev => ({ ...prev, [field]: value }));
  }, []);

  // 提供设置单个字段错误的方法
  const setFieldError = useCallback((field: string, error: string) => {
    setErrors(prev => ({ ...prev, [field]: error }));
  }, []);

  // 提供提交方法
  const submit = useCallback(() => {
    onSubmit?.(values);
  }, [values, onSubmit]);

  // 将所有状态和方法通过Render Props传给children
  return (
    <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
      {children({
        values,
        setValues,
        setFieldValue,
        errors,
        setFieldError,
        submit
      })}
    </form>
  );
};

// 使用示例
// <Form initialValues={{ email: '', password: '' }}>
//   {({ values, setFieldValue, errors, submit }) => (
//     <>
//       <input
//         value={values.email}
//         onChange={(e) => setFieldValue('email', e.target.value)}
//       />
//       <input
//         type="password"
//         value={values.password}
//         onChange={(e) => setFieldValue('password', e.target.value)}
//       />
//       <button onClick={submit}>提交</button>
//     </>
//   )}
// </Form>

5️⃣ forwardRef + useImperativeHandle

使用场景:父组件需要调用children组件的命令式方法

/**
 * 场景:Modal组件暴露open/close方法给使用者
 */
import React, {
  forwardRef,
  useImperativeHandle,
  useRef,
  ReactNode,
  useState
} from 'react';

interface ModalHandle {
  open: () => void;
  close: () => void;
  toggle: () => void;
}

interface ModalProps {
  children: ReactNode;
  title?: string;
}

const Modal = forwardRef<ModalHandle, ModalProps>(
  ({ children, title }, ref) => {
    const [isOpen, setIsOpen] = useState(false);

    // 使用useImperativeHandle暴露方法给ref
    useImperativeHandle(ref, () => ({
      open: () => setIsOpen(true),
      close: () => setIsOpen(false),
      toggle: () => setIsOpen(prev => !prev)
    }));

    if (!isOpen) return null;

    return (
      <div className="modal">
        <div className="modal-header">
          <h2>{title}</h2>
        </div>
        <div className="modal-body">
          {children}
        </div>
        <div className="modal-footer">
          <button onClick={() => setIsOpen(false)}>关闭</button>
        </div>
      </div>
    );
  }
);

Modal.displayName = 'Modal';

// 使用示例
const ModalExample: React.FC = () => {
  const modalRef = useRef<ModalHandle>(null);

  return (
    <>
      <button onClick={() => modalRef.current?.open()}>打开弹窗</button>
      <Modal ref={modalRef} title="我的弹窗">
        <p>这是弹窗内容</p>
      </Modal>
    </>
  );
};

6️⃣ 高阶组件(HOC)

使用场景:为多个组件添加统一的增强功能、功能复用

/**
 * 场景:为所有子组件添加loading状态
 */
import React, { ComponentType, ReactNode, useState } from 'react';

interface WithLoadingProps {
  isLoading?: boolean;
  loadingText?: string;
}

// HOC工厂函数
function withLoading<P extends object>(
  WrappedComponent: ComponentType<P>
): React.FC<P & WithLoadingProps> {
  return ({ isLoading = false, loadingText = '加载中...', ...props }) => {
    if (isLoading) {
      return <div className="loading">{loadingText}</div>;
    }
    return <WrappedComponent {...(props as P)} />;
  };
}

// 或者用HOC增强children
interface EnhancedContainerProps {
  children: ReactNode;
  isLoading?: boolean;
  loadingComponent?: ReactNode;
}

const EnhancedContainer: React.FC<EnhancedContainerProps> = ({
  children,
  isLoading = false,
  loadingComponent = <div>加载中...</div>
}) => {
  // 在children外面包裹loading状态
  if (isLoading) {
    return <>{loadingComponent}</>;
  }

  // 使用map和cloneElement给children注入props
  const enhancedChildren = React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child;
    
    return React.cloneElement(child, {
      'data-loading': isLoading,
      ...child.props
    });
  });

  return <>{enhancedChildren}</>;
};

// 使用示例
// <EnhancedContainer isLoading={false}>
//   <UserList />
//   <UserProfile />
// </EnhancedContainer>

7️⃣ 复合组件模式(Compound Components)

使用场景:组件间有强关联,需要相互通信(如Select/Option、Menu/Item)

/**
 * 场景:Tabs组件及其子组件TabPane的协作
 */
import React, { createContext, useContext, ReactNode, useState } from 'react';

// 创建Context用于组件间通信
interface TabsContextType {
  activeKey: string;
  onTabChange: (key: string) => void;
}

const TabsContext = createContext<TabsContextType>({
  activeKey: '',
  onTabChange: () => {},
});

// 主组件:Tabs
interface TabsProps {
  children: React.ReactElement<TabPaneProps>[];
  defaultActiveKey?: string;
}

const Tabs: React.FC<TabsProps> = ({ children, defaultActiveKey = '' }) => {
  const [activeKey, setActiveKey] = useState(defaultActiveKey);

  // 通过Context向子组件传递状态和方法
  return (
    <TabsContext.Provider value={{ activeKey, onTabChange: setActiveKey }}>
      <div className="tabs">
        {/* 标签栏 */}
        <div className="tabs-nav">
          {children.map((pane) => (
            <button
              key={pane.key}
              className={activeKey === pane.key ? 'active' : ''}
              onClick={() => setActiveKey(pane.key as string)}
            >
              {pane.props.label}
            </button>
          ))}
        </div>

        {/* 内容区 */}
        <div className="tabs-content">
          {children}
        </div>
      </div>
    </TabsContext.Provider>
  );
};

// 子组件:TabPane
interface TabPaneProps {
  key?: string;
  label: string;
  children: ReactNode;
}

const TabPane: React.FC<TabPaneProps> = ({ children }) => {
  const { activeKey } = useContext(TabsContext);
  
  // 子组件从Context获取数据,判断是否应该显示
  // 注意:这里的key应该通过props.key访问
  if (activeKey !== (children as any)?.key) {
    return null;
  }

  return <div className="tab-pane">{children}</div>;
};

// 优化版本:让TabPane正确获取key
const TabPaneContent: React.FC<TabPaneProps & { activeKey: string }> = ({
  children,
  activeKey,
  label
}) => {
  return (
    <div className="tab-pane" style={{ display: activeKey === label ? 'block' : 'none' }}>
      {children}
    </div>
  );
};

// 使用示例
// <Tabs defaultActiveKey="tab1">
//   <TabPane key="tab1" label="标签1">内容1</TabPane>
//   <TabPane key="tab2" label="标签2">内容2</TabPane>
// </Tabs>

8️⃣ useContext Hook(直接使用Context)

使用场景:子组件需要直接访问父组件通过Context提供的值

/**
 * 场景:表单组件库中,FormItem需要访问Form的状态
 */
import React, { createContext, useContext, ReactNode, useState } from 'react';

// FormContext
interface FormContextType {
  formData: { [key: string]: any };
  updateField: (name: string, value: any) => void;
  errors: { [key: string]: string };
}

const FormContext = createContext<FormContextType | null>(null);

// Form组件 - 提供Context
interface FormProps {
  children: ReactNode;
  onSubmit?: (data: any) => void;
}

const Form: React.FC<FormProps> = ({ children, onSubmit }) => {
  const [formData, setFormData] = useState<{ [key: string]: any }>({});
  const [errors, setErrors] = useState<{ [key: string]: string }>({});

  const updateField = (name: string, value: any) => {
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  return (
    <FormContext.Provider value={{ formData, updateField, errors }}>
      <form onSubmit={(e) => {
        e.preventDefault();
        onSubmit?.(formData);
      }}>
        {children}
      </form>
    </FormContext.Provider>
  );
};

// FormItem组件 - 使用Context
interface FormItemProps {
  name: string;
  label: string;
  children: ReactNode;
}

const FormItem: React.FC<FormItemProps> = ({ name, label, children }) => {
  const context = useContext(FormContext);

  if (!context) {
    throw new Error('FormItem必须在Form组件内使用');
  }

  const { formData, updateField } = context;

  return (
    <div className="form-item">
      <label>{label}</label>
      {React.isValidElement(children) &&
        React.cloneElement(children, {
          value: formData[name] || '',
          onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
            updateField(name, e.target.value);
          }
        } as any)}
    </div>
  );
};

// Input组件
const Input: React.FC<{ value?: string; onChange?: (e: any) => void }> = (props) => (
  <input {...props} />
);

// 使用示例
// <Form>
//   <FormItem name="username" label="用户名">
//     <Input />
//   </FormItem>
//   <FormItem name="email" label="邮箱">
//     <Input />
//   </FormItem>
// </Form>

9️⃣ Consumer + Provider 组合

使用场景:需要同时获取多个Context或在类组件中使用Context

/**
 * 场景:类组件中使用Context(旧API)
 */
import React, { createContext } from 'react';

interface User {
  id: number;
  name: string;
}

const UserContext = createContext<User | null>(null);

// 使用Consumer访问Context
class UserProfile extends React.Component {
  render() {
    return (
      <UserContext.Consumer>
        {(user) => (
          <div>
            {user ? (
              <>
                <h1>{user.name}</h1>
                <p>ID: {user.id}</p>
              </>
            ) : (
              <p>未登录</p>
            )}
          </div>
        )}
      </UserContext.Consumer>
    );
  }
}

// 或在函数组件中使用多个Consumer
const MultiContextExample: React.FC = () => {
  return (
    <UserContext.Consumer>
      {(user) => (
        <div>
          <p>用户信息: {user?.name}</p>
          {/* 可以嵌套多个Consumer */}
        </div>
      )}
    </UserContext.Consumer>
  );
};

// 使用示例
// <UserContext.Provider value={{ id: 1, name: 'Alice' }}>
//   <UserProfile />
// </UserContext.Provider>

🔟 自定义 Hook 共享逻辑

使用场景:多个组件需要共享相同的逻辑和状态

/**
 * 场景:多个表单字段需要共享验证逻辑
 */
import React, { useState, useCallback, ReactNode } from 'react';

// 自定义Hook - 用于共享表单字段逻辑
interface UseFieldProps {
  initialValue?: string;
  validate?: (value: string) => string;
}

function useField({ initialValue = '', validate }: UseFieldProps = {}) {
  const [value, setValue] = useState(initialValue);
  const [error, setError] = useState('');

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    setValue(newValue);
    
    // 实时验证
    if (validate) {
      setError(validate(newValue));
    }
  }, [validate]);

  return { value, setValue, error, setError, handleChange };
}

// 父组件使用自定义Hook
interface FormExampleProps {
  children: ReactNode;
}

const FormExample: React.FC<FormExampleProps> = ({ children }) => {
  const emailField = useField({
    validate: (value) => {
      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : '邮箱格式不正确';
    }
  });

  const passwordField = useField({
    validate: (value) => {
      return value.length >= 6 ? '' : '密码至少6位';
    }
  });

  // 通过render props传递hooks的返回值
  return (
    <div>
      {typeof children === 'function' ? (
        (children as Function)({ emailField, passwordField })
      ) : (
        children
      )}
    </div>
  );
};

// 使用示例
// <FormExample>
//   {({ emailField, passwordField }) => (
//     <>
//       <input {...emailField} placeholder="邮箱" />
//       {emailField.error && <p>{emailField.error}</p>}
//       <input {...passwordField} type="password" placeholder="密码" />
//       {passwordField.error && <p>{passwordField.error}</p>}
//     </>
//   )}
// </FormExample>

1️⃣1️⃣ 直接修改 React.Children 数组

使用场景:需要对children进行复杂的数组操作

/**
 * 场景:Carousel组件需要处理children的循环滚动
 */
import React, { ReactNode } from 'react';

interface CarouselProps {
  children: ReactNode;
  autoplay?: boolean;
  speed?: number;
}

const Carousel: React.FC<CarouselProps> = ({
  children,
  autoplay = true,
  speed = 3000
}) => {
  // 将children转换为数组便于处理
  const childArray = React.Children.toArray(children).filter(
    (child) => React.isValidElement(child)
  );

  // 可以进行数组操作
  const reversed = [...childArray].reverse(); // 反向排列
  const filtered = childArray.filter((_, i) => i % 2 === 0); // 过滤偶数项
  const duplicated = [...childArray, ...childArray]; // 复制

  return (
    <div className="carousel">
      {childArray.map((child, index) => (
        <div key={index} className="carousel-item">
          {child}
        </div>
      ))}
    </div>
  );
};

// 使用示例
// <Carousel autoplay speed={5000}>
//   <Slide>Slide 1</Slide>
//   <Slide>Slide 2</Slide>
//   <Slide>Slide 3</Slide>
// </Carousel>

1️⃣2️⃣ 通过 Props Drilling(逐级传递)

使用场景:层级不深、只需传递简单数据

/**
 * 场景:简单的props逐级传递
 */
import React, { ReactNode } from 'react';

interface LevelAProps {
  children: ReactNode;
  userRole: 'admin' | 'user';
}

const LevelA: React.FC<LevelAProps> = ({ children, userRole }) => {
  // 逐级传递userRole给子组件
  return (
    <div className="level-a">
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            userRole: userRole,
            ...child.props
          } as any);
        }
        return child;
      })}
    </div>
  );
};

interface LevelBProps {
  children: ReactNode;
  userRole?: 'admin' | 'user';
}

const LevelB: React.FC<LevelBProps> = ({ children, userRole }) => {
  // 继续传递给下一级
  return (
    <div className="level-b">
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            userRole: userRole,
            ...child.props
          } as any);
        }
        return child;
      })}
    </div>
  );
};

interface LevelCProps {
  userRole?: 'admin' | 'user';
}

const LevelC: React.FC<LevelCProps> = ({ userRole }) => {
  return <div>当前角色: {userRole}</div>;
};

// 使用示例
// <LevelA userRole="admin">
//   <LevelB>
//     <LevelC />
//   </LevelB>
// </LevelA>

1️⃣3️⃣ 事件系统/事件总线

使用场景:兄弟组件间通信、解耦合通信

/**
 * 场景:使用事件总线在不相关的组件间传递数据
 */
import React, { ReactNode, useEffect, useRef } from 'react';

// 简单的事件总线
class EventBus {
  private listeners: { [key: string]: Function[] } = {};

  on(event: string, callback: Function) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  off(event: string, callback: Function) {
    if (this.listeners[event]) {
      this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
    }
  }

  emit(event: string, data: any) {
    if (this.listeners[event]) {
      this.listeners[event].forEach((callback) => callback(data));
    }
  }
}

const eventBus = new EventBus();

// 发送者组件
const Sender: React.FC = () => {
  const handleClick = () => {
    // 发送事件和数据
    eventBus.emit('user-login', { id: 1, name: 'Alice' });
  };

  return <button onClick={handleClick}>发送登录事件</button>;
};

// 接收者组件
interface ReceiverProps {
  children: ReactNode;
}

const Receiver: React.FC<ReceiverProps> = ({ children }) => {
  const callbackRef = useRef((data: any) => {
    console.log('收到数据:', data);
  });

  useEffect(() => {
    // 监听事件
    eventBus.on('user-login', callbackRef.current);

    return () => {
      // 清理监听
      eventBus.off('user-login', callbackRef.current);
    };
  }, []);

  return <>{children}</>;
};

// 使用示例
// <div>
//   <Sender />
//   <Receiver>
//     <UserProfile />
//   </Receiver>
// </div>

1️⃣4️⃣ React Query / SWR(数据获取库)

使用场景:需要获取和共享异步数据

/**
 * 场景:使用React Query获取数据并供children使用
 * 注:这里是概念展示,实际需要安装react-query
 */
import React, { ReactNode } from 'react';

interface UserData {
  id: number;
  name: string;
}

interface DataProviderProps {
  children: ReactNode;
}

// 模拟的DataProvider(类似useQuery的使用)
const DataProvider: React.FC<DataProviderProps> = ({ children }) => {
  const [data, setData] = React.useState<UserData | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(null);

  React.useEffect(() => {
    // 模拟异步数据获取
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  // 通过children函数传递数据
  return (
    <>
      {typeof children === 'function'
        ? (children as Function)({ data, loading, error })
        : children}
    </>
  );
};

// 使用示例
// <DataProvider>
//   {({ data, loading, error }) => (
//     <>
//       {loading && <p>加载中...</p>}
//       {error && <p>错误: {error}</p>}
//       {data && <p>用户: {data.name}</p>}
//     </>
//   )}
// </DataProvider>

1️⃣5️⃣ 状态管理库(Redux/Zustand/Recoil)

使用场景:大型应用、复杂的全局状态管理

/**
 * 场景:使用Redux Store作为全局状态容器
 */
import React, { ReactNode } from 'react';

// 模拟Redux(实际使用react-redux库)
interface State {
  user: { id: number; name: string } | null;
  theme: 'light' | 'dark';
}

interface Action {
  type: string;
  payload?: any;
}

// Store
class Store {
  private state: State = {
    user: null,
    theme: 'light'
  };
  private listeners: Function[] = [];

  getState() {
    return this.state;
  }

  subscribe(listener: Function) {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter((l) => l !== listener);
    };
  }

  dispatch(action: Action) {
    // 根据action更新state
    if (action.type === 'SET_USER') {
      this.state = { ...this.state, user: action.payload };
    }
    // 通知所有监听者
    this.listeners.forEach((listener) => listener());
  }
}

const store = new Store();

// Provider组件
interface StoreProviderProps {
  children: ReactNode;
  store: Store;
}

const StoreProvider: React.FC<StoreProviderProps> = ({ children, store }) => {
  return (
    <div>
      {/* 将store提供给所有children */}
      {children}
    </div>
  );
};

// 使用示例
// <StoreProvider store={store}>
//   <UserProfile />
//   <ThemeSwitcher />
// </StoreProvider>

1️⃣6️⃣ Slot 模式(Vue中常见的模式)

使用场景:模板插槽,允许自定义内容区域

/**
 * 场景:Card组件的多个插槽
 */
import React, { ReactNode } from 'react';

interface CardSlots {
  header?: ReactNode;
  body?: ReactNode;
  footer?: ReactNode;
  actions?: ReactNode;
}

interface CardProps {
  children?: ReactNode;
  slots?: CardSlots;
}

const Card: React.FC<CardProps> = ({ children, slots }) => {
  // 如果提供了slots,使用slots;否则使用children
  return (
    <div className="card">
      {slots?.header && (
        <div className="card-header">
          {slots.header}
        </div>
      )}

      <div className="card-body">
        {slots?.body || children}
      </div>

      {slots?.actions && (
        <div className="card-actions">
          {slots.actions}
        </div>
      )}

      {slots?.footer && (
        <div className="card-footer">
          {slots.footer}
        </div>
      )}
    </div>
  );
};

// 使用示例
// <Card
//   slots={{
//     header: <h2>标题</h2>,
//     body: <p>内容</p>,
//     actions: <button>操作</button>,
//     footer: <small>页脚</small>
//   }}
// />

1️⃣7️⃣ ref.current 直接操作(不推荐)

使用场景:需要直接操作DOM或调用子组件方法

/**
 * 场景:直接通过ref操作子组件
 * 注:尽量避免,应该优先使用props或Context
 */
import React, { useRef, ReactNode } from 'react';

interface ScrollableProps {
  children: ReactNode;
}

const Scrollable = React.forwardRef<HTMLDivElement, ScrollableProps>(
  ({ children }, ref) => {
    return (
      <div
        ref={ref}
        style={{ height: '300px', overflow: 'auto' }}
      >
        {children}
      </div>
    );
  }
);

Scrollable.displayName = 'Scrollable';

// 使用示例
const ScrollableExample: React.FC = () => {
  const scrollRef = useRef<HTMLDivElement>(null);

  const handleScroll = () => {
    if (scrollRef.current) {
      // 直接操作DOM
      scrollRef.current.scrollTop = 0;
    }
  };

  return (
    <>
      <button onClick={handleScroll}>回到顶部</button>
      <Scrollable ref={scrollRef}>
        {/* 长内容 */}
      </Scrollable>
    </>
  );
};

📊 完整对比表

方式 跨级传值 性能 复杂度 推荐指数 主要场景
Context API ✅ 优秀 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ 全局状态、主题、语言
CloneElement ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ 统一修改子组件props
Render Props ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ 灵活渲染、访问父状态
useImperativeHandle ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ 命令式操作
HOC ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐ 功能增强、代码复用
复合组件 ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 强关联组件库
useContext ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ 访问Context值
Props Drilling ⚠️ 有限 ⭐⭐⭐⭐⭐ 简单层级传递
事件系统 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ 解耦通信、兄弟组件
数据获取库 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐ 异步数据管理
状态管理库 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ 大型应用全局状态

✅ 最佳实践建议

  1. 优先使用 Context - 大多数全局状态场景
  2. 使用 CloneElement - 需要修改所有子组件props
  3. 使用 Render Props - children需要访问父组件状态
  4. 使用复合组件 - 组件间有强关联
  5. 避免 Props Drilling - 超过3层就考虑Context
  6. 谨慎使用 HOC - 更推荐使用Hooks
  7. 避免事件总线 - 除非真的需要解耦

希望这份完整指南能帮助您理解React中所有给children传值的方式!

从零搭建 Vue3 富文本编辑器:基于 Quill可扩展方案

作者 胖虎265
2025年10月28日 14:31

在现代前端开发中,富文本编辑器是许多场景的核心组件 —— 无论是博客平台的内容创作、社交应用的评论系统,还是企业 CMS 的编辑模块,都离不开一款功能完善、体验流畅的富文本工具。但市面上现成的编辑器要么体积庞大、要么定制化能力弱,很难完美适配业务需求。

今天分享一个我基于 Vue3 生态开发的轻量级富文本编辑器方案,整合了 Quill 的强大编辑能力,支持按需扩展,可直接集成到现有项目中。

view20251028.png

一、组件结构设计:分层管理视图与交互

编辑器组件采用了「核心编辑区 + 工具栏 + 辅助功能区」的三层结构,通过 Vue3 的模板语法实现清晰的视图分层:

<template> 
    <div class="write-editor-wrapper"> 
    <!-- 1. 核心编辑区(Quill 实例挂载点) -->
    <div ref="editorContainer" class="quill-editor"></div>
    <!-- 2. 标签与字数统计区 -->
    <div class="custom-tags">...</div> 
    <!-- 3. 自定义工具栏(含格式控制与功能按钮) --> 
    <div class="custom-toolbar">...</div>
    <!-- 4. 功能弹窗(如插入链接对话框) -->
    <InsertLinkDialog ... /> </div>
</template>

二、Quill 初始化:自定义配置与事件监听

在 <script setup> 中,通过 onMounted 钩子完成 Quill 实例的初始化:

  onMounted(() => {
    quill = new Quill(editorContainer.value!, {
      theme: 'snow',
      modules: {
        toolbar: false,
        history: {
          delay: 2000,
          maxStack: 500,
          userOnly: true
        }
      },
      placeholder: props.placeholder || '请输入内容...'
    })
    if (props.modelValue) {
      quill.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
    }
    updateTextLength()
    quill.on('text-change', () => {
      emit('update:modelValue', quill.root.innerHTML)
      updateCurrentFormats()
      updateTextLength()
    })
    quill.on('selection-change', (range: any) => {
      updateCurrentFormats()
      isEditorFocused.value = !!range
    })
    quill.on('editor-change', (eventName: any) => {
      if (eventName === 'undo' || eventName === 'redo' || eventName === 'text-change') {
        updateRedoState()
      }
    })
    updateCurrentFormats()
    updateRedoState()
    tryInsertAtUser()
  })

关键设计点:

  1. 禁用 Quill 默认工具栏,完全自定义工具栏样式与交互,实现多端适配
  2. 通过 dangerouslyPasteHTML 支持初始内容渲染,配合 v-model 实现双向绑定
  3. 监听三大核心事件,分别处理内容更新、格式状态同步和历史记录管理

三、核心功能实现:从基础编辑到高级交互

1. 格式控制:基于 Quill API 的样式切换

通过 Quill 的 format 方法实现文本格式控制,配合状态管理实现按钮激活状态同步:

// 粗体
  function toggleFormat(name: string, value: any = true) {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    const isActive = !!currentFormats[name]
    quill.format(name, isActive ? false : value)
    updateCurrentFormats()
  }
// 标题
  function toggleHeader(level: number) {
    quill.focus()
    const isActive = currentFormats.header === level
    if (isActive) {
      quill.format('header', false)
    } else {
      quill.format('list', false)
      quill.format('blockquote', false)
      quill.format('header', level)
    }
  }
 // 列表
  function toggleList(type: 'ordered' | 'bullet') {
    quill.focus()
    const isActive = currentFormats.list === type
    if (isActive) {
      quill.format('list', false)
    } else {
      quill.format('header', false)
      quill.format('blockquote', false)
      quill.format('list', type)
    }
  }
 // 引用
  function toggleBlockquote() {
    quill.focus()
    const isActive = !!currentFormats.blockquote
    if (isActive) {
      quill.format('blockquote', false)
    } else {
      quill.format('header', false)
      quill.format('list', false)
      quill.format('blockquote', true)
    }
  }

这里的核心逻辑是「状态同步」:通过 quill.getFormat() 获取当前选区格式,存入 currentFormats 响应式对象,再通过 :class="{ active: ... }" 绑定到按钮,实现 UI 与实际格式的一致。

2. 媒体插入:图片 / 视频上传与嵌入

通过隐藏的 <input type="file"> 实现文件选择,配合 Quill 的 insertEmbed 方法插入媒体:

 // 图片上传
  function onImageChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const reader = new FileReader()
      reader.onload = function (evt) {
        const url = evt.target?.result as string
        quill.focus()
        const range = quill.getSelection()
        if (range) {
          quill.insertEmbed(range.index, 'image', url, 'user')
          quill.setSelection(range.index + 1)
        }
      }
      reader.readAsDataURL(file)
      imageInput.value!.value = ''
    }
  }
  
 // 视频上传
  function onVideoChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const url = URL.createObjectURL(file)
      quill.focus()
      const range = quill.getSelection()
      if (range) {
        quill.insertEmbed(range.index, 'video', url, 'user')
        quill.setSelection(range.index + 1)
      }
      videoInput.value!.value = ''
    }
  }

实际项目中,这里通常需要扩展替换为后端上传(通过 axios 发送文件,拿到 URL 后再插入)

3. 自定义功能:@用户与链接嵌入

通过 Quill 的自定义 Blot 机制实现富文本中的特殊元素(如 @用户、链接)

// @用户插入逻辑
  function handleAtSelect(user: any) {
    if (!quill) return
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      // 插入自定义的 'at-user' 类型内容
      quill.insertEmbed(range.index, 'at-user', { id: user.id, name: user.name })
      quill.setSelection(range.index + user.name.length + 1)
    }
  }
// 链接嵌入
  function handleLinkSubmit(data: { url: string; text: string }) {
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'link-embed', { url: data.url, text: data.text })
      quill.setSelection(range.index + 1)
    }
  }

注意:自定义 Blot 需要提前定义(对应代码中的 import './editor-blots'),通过继承 Quill 的 Embed 类实现自定义元素的渲染与交互

4. 状态管理:结合 Pinia 处理跨组件数据

使用 Pinia 管理选中的话题标签,实现编辑器与话题选择页的数据共享:

// 引入 Pinia 仓库 
import { useArticleStore } from '@/store/article'
const articleStore = useArticleStore()
// 移除话题标签
function removeTopic(topic: any) { articleStore.removeTopic(topic) }
// 跳转到话题选择页
function onTopicClick() { router.push('/topic?from=article') }

四、样式设计:响应式布局

通过 SCSS 结合 CSS Modules 实现样式隔离与响应式设计:

// 核心编辑区样式
.quill-editor {
  flex: 1;
  min-height: 0;
  border: none;
  overflow-y: auto;
}

// 自定义工具栏样式(移动端优化)
.custom-toolbar .toolbar-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-top: 1px solid $border-color;
  border-bottom: 1px solid $border-color;
}

// 格式面板采用弹性布局,适配小屏设备
.custom-toolbar .set-style-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  padding: 12px;
}

// 标签区域横向滚动,避免溢出
.selected-tags {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scrollbar-width: none; // 隐藏滚动条
  -ms-overflow-style: none;
  white-space: nowrap;
}
  • 编辑区使用 flex: 1 实现高度自适应,配合 min-height: 0 解决滚动问题
  • 标签区域使用横向滚动,避免标签过多时换行影响布局

富文本编辑组件代码如下:

<template>
  <div class="write-editor-wrapper">
    <div ref="editorContainer" class="quill-editor"></div>
    <div class="custom-tags">
      <van-button
        v-if="hasTopic"
        style="
          background-color: #f5f5f5;
          border: none;
          color: #666;
          font-size: 13px;
          padding: 0 8px;
        "
        icon="plus"
        round
        size="mini"
        type="default"
        @click="onTopicClick"
        >话题
        <span class="topic-count" v-if="articleStore.selectedTopics.length"
          >{{ articleStore.selectedTopics.length }}/{{ articleStore.maxTopics }}</span
        ></van-button
      >
      <div v-if="articleStore.selectedTopics.length" class="selected-tags">
        <span v-for="topic in articleStore.selectedTopics" :key="topic.id" class="tag">
          #{{ topic.name }} <van-icon name="cross" @click="removeTopic(topic)" size="12" />
        </span>
      </div>
      <div class="text-length">{{ textLength }} · 草稿</div>
    </div>
    <div class="custom-toolbar">
      <div class="toolbar-row">
        <button @click="toggleSetStyle" :disabled="!isEditorFocused">A</button>
        <button @click="triggerImageInput" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_image.png')" alt="上传图片" />
        </button>
        <input
          ref="imageInput"
          type="file"
          accept="image/*"
          style="display: none"
          @change="onImageChange"
        />
        <button @click="triggerVideoInput" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_video.png')" alt="上传视频" />
        </button>
        <input
          ref="videoInput"
          type="file"
          accept="video/*"
          style="display: none"
          @change="onVideoChange"
        />
        <button @click="undo" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_undo.png')" alt="撤销" />
        </button>
        <button @click="redo" :disabled="!isEditorFocused || !canRedo">
          <img :src="getAssetUrl('icon_redo.png')" alt="重做" />
        </button>
        <button @click="toggleMore" :disabled="!isEditorFocused">
          <img :src="getAssetUrl('icon_more.png')" alt="更多" />
        </button>
      </div>
      <transition name="fade">
        <div class="set-style-row" v-if="showSetStyle">
          <button :class="{ active: currentFormats.header === 3 }" @click="toggleHeader(3)">
            <i class="iconfont icon-title">H</i>标题
          </button>
          <button :class="{ active: currentFormats.bold }" @click="toggleFormat('bold')">
            <i class="iconfont icon-bold">B</i> 粗体
          </button>
          <button :class="{ active: currentFormats.blockquote }" @click="toggleBlockquote()">
            <svg-icon name="blockquote"></svg-icon> 引用
          </button>
          <button @click="insertDivider"><svg-icon name="divider"></svg-icon> 分割线</button>
          <button
            :class="{ active: currentFormats.list === 'ordered' }"
            @click="toggleList('ordered')"
          >
            <svg-icon name="orderedList"></svg-icon> 有序列表
          </button>
          <button
            :class="{ active: currentFormats.list === 'bullet' }"
            @click="toggleList('bullet')"
          >
            <svg-icon name="bulletList"></svg-icon> 无序列表
          </button>
        </div>
      </transition>
      <transition name="fade">
        <div class="more-row" v-if="showMore">
          <button @click="insertLink"><svg-icon name="link"></svg-icon> 添加链接</button>
          <button @click="insertAttachment">
            <svg-icon name="attachment"></svg-icon> 添加附件
          </button>
          <button @click="goToAt"><i class="iconfont icon-at">@</i> 提到</button>
          <button @click="saveDraft"><svg-icon name="draft"></svg-icon> 草稿备份</button>
        </div>
      </transition>
    </div>

    <!-- 插入链接弹框 -->
    <InsertLinkDialog
      :show="showLinkDialog"
      @update:show="value => (showLinkDialog = value)"
      @submit="handleLinkSubmit"
      @cancel="handleLinkCancel"
    />
  </div>
</template>

<script setup lang="ts">
  import { ref, onMounted, reactive } from 'vue'
  import { getAssetUrl } from '@/utils/index'
  import { useRouter } from 'vue-router'
  import Quill from 'quill'
  import 'quill/dist/quill.snow.css'
  import InsertLinkDialog from './InsertLinkDialog.vue'
  import './editor-blots' // 导入Blot
  import { useArticleStore } from '@/store/article'

  const router = useRouter()
  const articleStore = useArticleStore()

  const props = defineProps({
    modelValue: { type: String, default: '' },
    placeholder: { type: String, default: '' },
    hasTopic: { type: Boolean, default: true }
  })
  const emit = defineEmits(['update:modelValue'])

  const editorContainer = ref<HTMLDivElement | null>(null)
  let quill: Quill

  // 插入链接弹框相关
  const showLinkDialog = ref(false)

  // quill初始化与相关更新
  const textLength = ref(0)
  const currentFormats = reactive<{ [key: string]: any }>({})
  const isEditorFocused = ref(false)
  const canRedo = ref(false)

  // 更新文本长度
  function updateTextLength() {
    if (!quill) {
      textLength.value = 0
    } else {
      textLength.value = getFullTextLength(quill)
    }
  }
  function getFullTextLength(quill: Quill) {
    const delta = quill.getContents()
    let length = 0
    delta.ops.forEach((op: any) => {
      if (typeof op.insert === 'string') {
        length += op.insert.replace(/\s/g, '').length // 只统计非空白
      } else if (op.insert['at-user']) {
        length += ('@' + op.insert['at-user'].name).length
      } else if (op.insert['link-embed']) {
        length += (op.insert['link-embed'].text || '').length
      }
      // 其他自定义Blot可继续扩展
    })
    return length
  }
  // 更新当前格式
  function updateCurrentFormats() {
    if (!quill) return
    const range = quill.getSelection()
    if (range) {
      const formats = quill.getFormat(range)
      Object.keys(currentFormats).forEach(key => delete currentFormats[key])
      Object.assign(currentFormats, formats)
    } else {
      Object.keys(currentFormats).forEach(key => delete currentFormats[key])
    }
  }

  // 更新重做状态
  function updateRedoState() {
    if (quill) {
      canRedo.value = quill.history.stack.redo.length > 0
    }
  }

  onMounted(() => {
    quill = new Quill(editorContainer.value!, {
      theme: 'snow',
      modules: {
        toolbar: false,
        history: {
          delay: 2000,
          maxStack: 500,
          userOnly: true
        }
      },
      placeholder: props.placeholder || '请输入内容...'
    })
    if (props.modelValue) {
      quill.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
    }
    updateTextLength()
    quill.on('text-change', () => {
      emit('update:modelValue', quill.root.innerHTML)
      updateCurrentFormats()
      updateTextLength()
    })
    quill.on('selection-change', (range: any) => {
      updateCurrentFormats()
      isEditorFocused.value = !!range
    })
    quill.on('editor-change', (eventName: any) => {
      if (eventName === 'undo' || eventName === 'redo' || eventName === 'text-change') {
        updateRedoState()
      }
    })
    updateCurrentFormats()
    updateRedoState()
    tryInsertAtUser()
  })

  // 暴露 setContent 方法
  function setContent(html: string) {
    if (quill) {
      // 清空编辑器内容
      quill.setText('')
      // 设置新内容
      quill.clipboard.dangerouslyPasteHTML(html || '', 'silent')
      // 触发内容更新
      emit('update:modelValue', quill.root.innerHTML)
      // 更新文本长度
      updateTextLength()
    }
  }
  defineExpose({ setContent })

  // 工具栏/格式相关
  const showSetStyle = ref(false)
  const showMore = ref(false)
  function toggleSetStyle() {
    showSetStyle.value = !showSetStyle.value
    showMore.value = false
  }
  function toggleMore() {
    showMore.value = !showMore.value
    showSetStyle.value = false
  }
  function toggleFormat(name: string, value: any = true) {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    const isActive = !!currentFormats[name]
    quill.format(name, isActive ? false : value)
    updateCurrentFormats()
  }
  function toggleHeader(level: number) {
    quill.focus()
    const isActive = currentFormats.header === level
    if (isActive) {
      quill.format('header', false)
    } else {
      quill.format('list', false)
      quill.format('blockquote', false)
      quill.format('header', level)
    }
  }
  function toggleList(type: 'ordered' | 'bullet') {
    quill.focus()
    const isActive = currentFormats.list === type
    if (isActive) {
      quill.format('list', false)
    } else {
      quill.format('header', false)
      quill.format('blockquote', false)
      quill.format('list', type)
    }
  }
  function toggleBlockquote() {
    quill.focus()
    const isActive = !!currentFormats.blockquote
    if (isActive) {
      quill.format('blockquote', false)
    } else {
      quill.format('header', false)
      quill.format('list', false)
      quill.format('blockquote', true)
    }
  }
  function undo() {
    quill.history.undo()
  }
  function redo() {
    if (!isEditorFocused.value || !canRedo.value) return
    quill.history.redo()
  }

  //  媒体插入相关
  const imageInput = ref<HTMLInputElement | null>(null)
  const videoInput = ref<HTMLInputElement | null>(null)
  function triggerImageInput() {
    imageInput.value?.click()
  }
  function triggerVideoInput() {
    videoInput.value?.click()
  }
  function onImageChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const reader = new FileReader()
      reader.onload = function (evt) {
        const url = evt.target?.result as string
        quill.focus()
        const range = quill.getSelection()
        if (range) {
          quill.insertEmbed(range.index, 'image', url, 'user')
          quill.setSelection(range.index + 1)
        }
      }
      reader.readAsDataURL(file)
      imageInput.value!.value = ''
    }
  }
  function onVideoChange(e: Event) {
    const files = (e.target as HTMLInputElement).files
    if (files && files[0]) {
      const file = files[0]
      const url = URL.createObjectURL(file)
      quill.focus()
      const range = quill.getSelection()
      if (range) {
        quill.insertEmbed(range.index, 'video', url, 'user')
        quill.setSelection(range.index + 1)
      }
      videoInput.value!.value = ''
    }
  }
  function insertDivider() {
    quill.focus()
    const range = quill.getSelection()
    if (!range) return
    quill.insertEmbed(range.index, 'hr', true, 'user')
    //将光标定位到文档末尾
    const len = quill.getLength()
    quill.setSelection(len - 1, 0)
  }

  // 标签相关
  function removeTopic(topic: any) {
    articleStore.removeTopic(topic)
  }

  // 话题选择
  function onTopicClick() {
    router.push('/topic?from=article')
  }

  function tryInsertAtUser() {
    const atUserStr = sessionStorage.getItem('atUser')
    if (atUserStr) {
      const atUser = JSON.parse(atUserStr)
      handleAtSelect(atUser)
      sessionStorage.removeItem('atUser')
    }
  }
  function handleAtSelect(user: any) {
    if (!quill) return
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'at-user', { id: user.id, name: user.name })
      quill.setSelection(range.index + user.name.length + 1)
    }
  }

  function insertLink() {
    showLinkDialog.value = true
  }

  function handleLinkSubmit(data: { url: string; text: string }) {
    quill.focus()
    const range = quill.getSelection()
    if (range) {
      quill.insertEmbed(range.index, 'link-embed', { url: data.url, text: data.text })
      quill.setSelection(range.index + 1)
    }
  }

  function handleLinkCancel() {
    // 取消插入链接,不做任何操作
  }

  // @用户功能
  function goToAt() {
    router.push('/at')
  }

  function insertAttachment() {
    quill.focus()
    alert('此处可集成附件上传逻辑')
  }
  function saveDraft() {
    quill.focus()
    alert('此处可集成草稿保存逻辑')
  }
</script>

<style lang="scss" scoped>
  .write-editor-wrapper {
    width: 100%;
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }

  .quill-editor {
    flex: 1;
    min-height: 0;
    border: none;
    overflow-y: auto;
  }

  :deep(.ql-editor) {
    line-height: 1.8;
  }

  :deep(.ql-editor.ql-blank::before) {
    color: #bfbfbf;
    font-size: 15px;
    font-style: normal;
  }

  .custom-toolbar .toolbar-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    border-top: 1px solid $border-color;
    border-bottom: 1px solid $border-color;
  }

  .custom-toolbar .toolbar-row button {
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: transparent;
    font-size: 20px;
    width: 44px;
    height: 44px;
    line-height: 44px;
    box-sizing: border-box;
    cursor: pointer;
  }

  .custom-toolbar .toolbar-row button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .custom-toolbar button img {
    width: 20px;
    height: 20px;
  }

  .custom-toolbar button i {
    font-size: 20px;
    font-style: normal;
  }

  .custom-toolbar .set-style-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 12px;
    padding: 12px;
  }

  .custom-toolbar .set-style-row button {
    width: calc(50% - 8px);
    height: 44px;
    line-height: 44px;
    background-color: #f5f5f5;
    border-radius: 4px;
    font-size: 14px;
    color: #333;
    cursor: pointer;
    padding: 0 24px;
    text-align: left;
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .custom-toolbar .set-style-row button.active {
    color: $primary-color;
    border: 1px solid $primary-color;
    background-color: rgba($primary-color, 0.1);
  }

  .custom-toolbar .more-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px;
  }

  .custom-toolbar .more-row button {
    width: calc(25% - 8px);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-size: 14px;
  }

  .custom-toolbar .more-row button i {
    font-size: 24px;
    font-style: normal;
    width: 50px;
    height: 50px;
    line-height: 50px;
    background-color: #f5f5f5;
    border-radius: 4px;
  }

  .custom-toolbar .more-row button svg {
    width: 50px;
    height: 50px;
    line-height: 50px;
    background-color: #f5f5f5;
    border-radius: 4px;
    padding: 13px;
    box-sizing: border-box;
  }

  .custom-tags {
    display: flex;
    align-items: center;
    padding: 0 12px 12px;
    gap: 8px;
  }
  .custom-tags button {
    flex-shrink: 0;
  }
  .selected-tags {
    display: flex;
    flex-wrap: nowrap;
    gap: 8px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
    white-space: nowrap;
  }
  .selected-tags::-webkit-scrollbar {
    display: none;
  }
  .selected-tags .tag {
    display: flex;
    align-items: center;
    background: rgba($color: $primary-color, $alpha: 0.1);
    color: $primary-color;
    border-radius: 12px;
    height: 24px;
    padding: 0 10px;
    font-size: 13px;
    white-space: nowrap;
    flex-shrink: 0;
  }

  .selected-tags .tag i {
    cursor: pointer;
    margin-left: 4px;
  }

  .custom-tags .text-length {
    flex-shrink: 0;
    font-size: 13px;
    color: #666;
    margin-left: auto;
  }

  :deep(.at-user),
  :deep(.link-embed) {
    color: #1989fa;
    padding: 0 4px;
    cursor: pointer;
  }
</style>

HTML5 敲击乐:从静态页面到动态交互的前端实战

2025年10月28日 14:31

HTML5 敲击乐:从静态页面到动态交互的前端实战

一、项目背景

在学习前端开发的过程中,“HTML5 敲击乐”是一个极具代表性的练习项目。它看似简单,却完整体现了前端三大核心技术——HTML、CSS、JavaScript 的分层协作:

  • HTML 负责结构
  • CSS 负责样式
  • JavaScript 负责交互

二、HTML5 Web 应用的基本理念

在进入代码前,我们要先理解 Web 应用的结构化思维。

一个完整的前端页面通常包含以下部分:

  • HTML:定义网页结构与语义;
  • CSS:负责页面的外观与布局;
  • JavaScript:实现交互逻辑。

这种 职责分离(Separation of Concerns) 的设计原则,让代码具备更强的可维护性和可拓展性。
HTML 是骨架,CSS 是外衣,JavaScript 则赋予页面灵魂。

现代浏览器的加载顺序通常是:

  1. 下载并解析 HTML;
  2. 同步加载 CSS;
  3. 等页面结构构建完毕后,再执行 JavaScript。

这也是为什么开发中常将 <script> 标签放在 <body> 的底部——防止脚本阻塞页面渲染,提升加载性能。


三、从静态页面开始:HTML + CSS 构建基础舞台

1. HTML:页面结构与语义

<div class="keys">
  <div class="key" data-key="65">
    <h3>A</h3>
    <span class="sound">clap</span>
  </div>
  <div class="key" data-key="83">
    <h3>S</h3>
    <span class="sound">hihat</span>
  </div>
  ...
</div>

HTML 代码定义了九个“按键”,每个按键通过 data-key 属性与键盘按键绑定,例如 A 键的键码是 65
data-* 属性是 HTML5 的新特性,可以为元素绑定自定义数据,非常适合在 JS 中进行 DOM 操作。

结构上,我们可以看到典型的前端组件化思想:每个 key 是独立模块,由一个 <h3> 标签显示键位,<span> 显示对应的声音名称。


2. CSS:美化与布局

CSS 样式的重点在于两部分:

  • Reset(样式重置)
  • 布局与交互效果
(1) Reset 样式

不同浏览器对元素有默认样式(如 marginpaddingfont-size 等),为避免兼容性问题,我们使用业界广泛采用的 Eric Meyer Reset CSS

html, body, div, span, applet, object, iframe,
h1, h2, h3, ... {
  margin: 0;
  padding: 0;
  border: 0;
  font: inherit;
  vertical-align: baseline;
}

Reset 的核心作用是 统一浏览器默认样式差异,为后续自定义样式提供一致的起点。

(2) 背景与布局

项目背景使用 background 属性实现全屏铺设:

html {
  font-size: 10px;
  background: url(./background.jpg) bottom center no-repeat;
  background-size: cover;
}
  • cover:背景图片按比例缩放,覆盖整个区域;

  • contain:背景图片完整显示,但可能留空白;

  • vh / rem:现代 CSS 常用相对单位,解决移动端不同分辨率适配问题。

    • vh 相对视窗高度;
    • rem 相对根元素字体大小。

避免使用 px 等绝对单位是移动端开发的重要经验。

(3) 弹性布局(Flexbox)

核心布局使用 Flex 实现完美居中:

.keys {
  display: flex;
  min-height: 100vh;
  align-items: center;
  justify-content: center;
}

align-items 控制垂直对齐,justify-content 控制水平分布。
Flex 是现代前端布局的“魔法盒”,简洁高效,几乎取代了过去的浮动(float)与定位(position)方案。

(4) 动态视觉效果

当按键被激活时,添加 .playing 类,实现放大与发光效果:

.playing {
  transform: scale(1.1);
  border-color: #ffc600;
  box-shadow: 0 0 1rem #ffc600;
}

这就是 CSS 动画的核心:状态切换 + 视觉反馈


四、JavaScript:为页面注入生命力

前端页面的“交互”主要通过 JavaScript 实现。

在项目中,我们监听键盘事件,根据用户按下的按键触发相应效果:

document.addEventListener('DOMContentLoaded', function() {
  function playSound(event) {
    const keyCode = event.keyCode;
    const element = document.querySelector('.key[data-key="' + keyCode + '"]');
    if (!element) return;
    element.classList.add('playing');
  }
  window.addEventListener('keydown', playSound);
});

代码解析:

  1. DOMContentLoaded
    确保脚本在 HTML 加载完成后再执行,避免找不到 DOM 元素。

  2. 事件监听(Event Listener)
    window.addEventListener('keydown', playSound)
    监听键盘事件,每当用户按下某个键时,就调用 playSound() 函数。

  3. DOM 查询与动态修改

    • querySelector() 定位对应按键;
    • element.classList.add('playing') 动态添加 CSS 类,实现视觉变化。

可拓展方向:

可以进一步绑定 <audio> 标签,实现真实的鼓点声音:

<audio data-key="65" src="sounds/clap.wav"></audio>

并在 playSound() 中加入播放逻辑:

const audio = document.querySelector(`audio[data-key="${keyCode}"]`);
if (!audio) return;
audio.currentTime = 0; // 防止延迟
audio.play();

至此,我们就实现了一个真正可“敲”的网页鼓机!


五、总结:从静态到动态的前端思维跃迁

通过 “HTML5 敲击乐” 项目,我们经历了一个典型的前端开发流程:

阶段 技术 关键目标
页面结构 HTML5 构建语义化 DOM 结构
页面样式 CSS3 实现布局与动画视觉
页面交互 JavaScript 捕捉事件,实现动态反馈

这个过程让我们理解了前端的本质:用结构、样式和逻辑共同描绘交互体验


结语

“HTML5 敲击乐” 虽然只是一个小项目,但它涵盖了前端开发的核心思维:

结构清晰、样式独立、逻辑分层。

从 Reset CSS 到 Flexbox 布局,从 DOM 操作到事件绑定,每一个细节都体现了现代前端的工程化思维。

学会了它,就像学会了第一首“前端交响曲”。
从此,网页不再只是静态的画面,而是一场灵动的交互乐章。

HTML 敲击乐 PART--2

作者 inx177
2025年10月28日 14:22

HTML5 敲击乐项目PAST--2

一、项目概述

“HTML5 敲击乐”是一个基于现代前端技术栈的交互式 Web 应用,用户通过按下键盘上的特定按键,页面中对应的虚拟“乐器键”会高亮并播放相应的声音效果,模拟真实的打击乐器体验。该项目不仅实现了基本的音效播放功能,还充分体现了前端开发中的结构、样式与行为分离原则,并结合响应式设计思想,适配多种设备屏幕,是学习 HTML5、CSS3 和 JavaScript 基础知识的优秀实践案例。

整个项目以简洁明了的代码组织结构为核心,强调模块化、可维护性和可扩展性,为后续添加更多功能(如音效切换、节奏记录等)打下良好基础。

这是一份非常有意思是项目,跟着我的步伐一起来实现它吧!


二、HTML5 页面结构设计

HTML 是网页的骨架,负责定义页面的内容和结构。在本项目中,我们遵循语义化 HTML 的原则,使用清晰的标签来组织内容:

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8" />
  <title>HTML5 敲击乐</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="keys">
    <div class="key" data-key="65">
      <h3>A</h3>
      <p class="sound">Clap</p>
    </div>
    <!-- 更多 key 元素 -->
  </div>
  <audio data-key="65" src="sounds/clap.wav"></audio>
  <!-- 更多 audio 元素 -->
  <script src="script.js"></script>
</body>
</html>

1. 结构解析

  • 使用 <div class="keys"> 作为容器,包裹所有“乐器键”。
  • 每个 .key 是一个独立的功能模块,包含显示字符(<h3>)和音效名称(.sound)。
  • data-key 属性用于绑定键盘按键的 keyCode,实现按键与 UI 元素的映射。
  • <audio> 标签预加载音效文件,通过 data-key.key 元素对应。

2. 模块化与职责分离

项目严格遵循前端开发的最佳实践:

  • HTML 负责结构:只定义内容和标签结构。
  • CSS 负责样式:通过外部文件引入,置于 <head> 中。
  • JavaScript 负责交互:脚本文件置于 <body> 底部,避免阻塞页面渲染。

这种分离方式提高了代码的可读性、维护性和团队协作效率。


三、CSS 样式设计与重置

CSS 负责将静态 HTML 转变为美观的视觉界面。本项目中,我们特别注重跨浏览器兼容性与响应式布局。

1. CSS Reset 的重要性

不同浏览器对 HTML 元素(如 bodyh1ul 等)有各自的默认样式(如 margin、padding),这会导致页面在不同浏览器中显示不一致。因此,使用 CSS Reset 统一所有元素的初始样式是构建稳定 UI 的第一步。

项目采用了业界广泛认可的 Eric Meyer’s Reset CSS,清除了所有元素的默认边距、填充、边框等,并统一了字体和垂直对齐方式。此外,还补充了现代开发常用的全局设置:

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

box-sizing: border-box 确保元素的 width 和 height 包含 padding 和 border,使布局计算更加直观和可控。

2. 图片与链接优化

  • img { max-width: 100%; height: auto; } 防止图片溢出容器,适应不同屏幕尺寸。
  • a { text-decoration: none; color: inherit; } 去除默认下划线,保持链接样式与父元素一致。

四、响应式布局实现

移动端设备屏幕尺寸多样,必须采用相对单位和弹性布局来实现良好的适配效果。

1. 相对单位的使用

  • 移动端web端单位的不统一 : 为了保证在我们的网站中都可以实现,我们选择不使用px ,pt,这样的绝对单位,转而改用成vh,rem等相对单位。
  • vh(视口高度单位)1vh 等于视口高度的 1%。项目中使用 min-height: 100vh 确保 .keys 容器始终占满整个屏幕高度,无论设备尺寸如何变化。
  • rem(根元素字体大小单位)1rem 等于根元素(<html>)的 font-size。项目中设置 html { font-size: 10px; },后续所有尺寸(如 border: .4rem, font-size: 1.5rem)均基于此,便于统一缩放和适配。

相比固定单位 pxvhrem 能更好地适应不同分辨率的设备,提升用户体验。

2. Flexbox 弹性布局

项目使用 display: flex 实现居中布局:

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

Flexbox 具有强大的对齐和分布能力,特别适合一维布局(行或列)。即使 .key 元素数量变化或屏幕尺寸调整,容器仍能自动调整子元素的位置,无需额外计算。


五、背景与视觉效果

1. 背景图片处理

通过以下属性设置全屏背景:

background: url('./background.jpg') bottom center no-repeat;
background-size: cover;
  • background-size: cover 使背景图等比缩放以覆盖整个容器,可能裁剪部分内容。
  • contain 则确保图片完整显示,但可能留白。
  • background-position: bottom center 控制图片在容器中的对齐位置。

2. 动态视觉反馈

.playing 类定义了按键时的视觉反馈:

.playing {
  border-color: #ffc600;
  box-shadow: 0 0 1rem #ffc600;
  transform: scale(1.1);
}

当用户按下对应键时,JavaScript 动态添加该类,实现边框变色、发光和轻微放大效果,增强交互感。


六、JavaScript 交互逻辑

JavaScript 是实现动态行为的核心。项目通过事件监听机制响应用户输入。

1. DOMContentLoaded 事件

document.addEventListener('DOMContentLoaded', function() {
  // 页面结构加载完成后执行
});

该事件确保脚本在 HTML 解析完成后运行,避免因元素未加载而导致的错误。

2. 键盘事件监听

window.addEventListener('keydown', playSound);

监听全局 keydown 事件,获取用户按下的键的 keyCode(如 A=65, S=83)。

3. 动态 DOM 操作

function playSound(event) {
  const keyCode = event.keyCode;
  const element = document.querySelector(`.key[data-key="${keyCode}"]`);
  if (element) element.classList.add('playing');
}

通过 data-key 属性匹配 .key 元素,并使用 classList.add() 动态添加 playing 类,触发动效。


七、性能与最佳实践

1. 脚本加载位置

<script> 放在 </body> 前,避免阻塞 HTML 解析,提升页面加载速度。

2. 选择器性能

避免使用通配符 * 进行样式重置(性能差),推荐明确列出需重置的元素,如 Eric Meyer 的方案。

3. 可访问性与扩展性

  • 使用语义化标签提升可访问性。
  • data-* 属性便于数据绑定,利于后续扩展(如添加音效切换、音量控制等)。

八、总结

“HTML5 敲击乐”项目虽小,却涵盖了现代 Web 开发的核心概念:结构、样式、行为分离响应式设计事件驱动编程用户体验优化。通过合理使用 flexboxrem/vhCSS Reset 和 DOM 操作,构建了一个美观、高效、跨平台的交互应用。此项目不仅是学习前端基础的绝佳范例,也为更复杂的应用开发提供了可复用的模式和思路。

git 常用命令行

作者 子非鱼373
2025年10月28日 14:12

核心命令

bash

git config --global user.name "你的用户名"
git config --global user.email "你的邮箱"

1. 全局设置(推荐)

这是最常用的方式,为你电脑上的所有 Git 项目设置默认的用户信息。

bash

# 设置全局用户名
git config --global user.name "Your Name"

# 设置全局邮箱
git config --global user.email "your.email@example.com"

何时使用?

  • 如果你在个人电脑上工作,并且所有项目都使用同一个身份(比如你的 GitHub 身份),那么用这个最方便。

  • 例如:

    bash

    git config --global user.name "zhangsan"
    git config --global user.email "zhangsan@example.com"
    

2. 项目特定设置

如果你需要在某个特定的项目中使用不同的用户名和邮箱(例如,为公司项目使用公司邮箱,为个人项目使用个人邮箱),你可以在该项目目录下执行不带 --global 选项的命令。

bash

# 进入你的项目根目录
cd /path/to/your/project

# 设置本项目专用的用户名和邮箱
git config user.name "Your Work Name"
git config user.email "your.work.email@company.com"

何时使用?

  • 在工作电脑上,区分个人项目和工作项目。
  • 在开源项目贡献中,使用你公开的 GitHub 邮箱。

3. 检查当前配置

设置完成后,你可以检查配置是否正确。

bash

# 列出所有当前的配置
git config --list

# 或者只查看用户名和邮箱
git config user.name
git config user.email

# 检查全局的用户名和邮箱
git config --global user.name
git config --global user.email

重要提示

  1. 为什么这很重要?

    • 你设置的 user.name 和 user.email 会作为提交者信息被永久记录在每一次提交的历史中。
    • GitHub、Gitee 等平台依靠邮箱地址来将你的提交与你的账户关联起来。如果你在 GitHub 上使用的邮箱是 abc@example.com,那么本地配置的邮箱也必须是这个,你的提交才会被算作是你的贡献。
  2. 如何选择邮箱?

    • 对于 GitHub:你可以在 GitHub 的 Settings -> Emails 中查看你有哪些"认证邮箱"。使用其中任何一个(推荐使用主邮箱或为你配置的 noreply 隐私邮箱)都可以。
    • 对于公司内部:使用公司分配的邮箱地址。
  3. 修改配置

    • 如果之前设置错了,只需重新执行一遍正确的设置命令即可覆盖。

总结

对于大多数个人用户,只需在终端中执行以下两条命令即可一劳永逸:

bash

git config --global user.name "你的GitHub用户名或常用名"
git config --global user.email "你的GitHub认证邮箱"

Git 工作流程与核心概念

在理解命令之前,先要了解 Git 的三个主要工作区,这能帮助你明白每个命令在哪个阶段使用:

  1. 工作区:你电脑上能看到的项目目录,在这里你编辑文件。
  2. 暂存区:一个中间区域。你把工作区的改动“暂存”到这里,准备下一次提交。
  3. 版本库:存放所有提交历史的地方。每次提交都是对暂存区内容的一个快照。

基本的流程是:工作区 -> git add -> 暂存区 -> git commit -> 版本库


一、 基础必备命令

这些命令是使用 Git 的日常,必须熟练掌握。

命令 含义与作用
git init 初始化一个新仓库。在当前目录创建一个新的 .git 子目录,这意味着 Git 开始对这个目录进行版本控制。
git clone <url> 克隆远程仓库。将远程仓库(如 GitHub、Gitee 上的项目)完整地下载到本地。例如:git clone https://github.com/user/repo.git
git add <file> 将文件添加到暂存区。准备提交哪些文件。
git add .:添加当前目录下所有变动的文件到暂存区。
git add README.md:只添加 README.md 这个文件。
git status 查看仓库状态。显示哪些文件已被修改、哪些已暂存、哪些未被跟踪。这是最常用的诊断命令。
git commit -m "提交信息" 提交暂存区的文件。将暂存区的内容创建一个新的版本快照,并保存到版本库中。"提交信息" 应清晰描述本次提交的目的。
git push 推送提交到远程仓库。将本地的提交上传到远程仓库(如 GitHub),与他人分享你的代码。
git pull 拉取远程仓库更新。从远程仓库下载最新的代码并合并到本地分支。相当于 git fetch(获取更新) + git merge(合并到当前分支)。

二、 查看与比较

这些命令帮助你了解项目的当前状态和历史。

命令 含义与作用
git log 查看提交历史。按时间顺序显示所有的提交记录,包括提交哈希值、作者、日期和提交信息。
git log --oneline:以简洁的单行模式显示历史。
git log -p:显示每次提交的具体内容差异。
git diff 查看工作区与暂存区的差异。显示尚未暂存的文件改动内容。
git diff --staged:查看暂存区与上一次提交的差异。
git show <commit-id> 显示某次提交的详细信息。包括修改的文件和具体的代码变动。

三、 分支管理

分支是 Git 的杀手锏功能,允许你在独立的线上开发,而不会影响主线(master/main)。

命令 含义与作用
git branch 列出所有本地分支。当前分支前面会有一个 * 号。
git branch <branch-name> 创建一个新分支
git checkout <branch-name> 切换到指定分支
git switch <branch-name> 切换到指定分支(较新的 Git 版本推荐使用,语义更清晰)。
git checkout -b <branch-name> 创建并立即切换到新分支。这是非常常用的组合命令。
git switch -c <branch-name> 同上,新版本的推荐写法。
git merge <branch-name> 合并指定分支到当前分支。例如,你在 feature 分支开发完,切换回 main 分支后,执行 git merge feature 来合并。
git branch -d <branch-name> 删除一个已合并的分支(安全删除)。
git branch -D <branch-name> 强制删除一个分支,即使它还没有被合并。

四、 撤销与回退

当你犯错时,这些命令是你的“后悔药”。

命令 含义与作用 风险
git restore <file> 撤销工作区的修改。将文件恢复到最后一次提交(或暂存)的状态。 (新版本推荐) 会丢失工作区的修改
git checkout -- <file> 同上,是老版本 Git 的命令。 会丢失工作区的修改
git restore --staged <file> 将文件从暂存区移回工作区。取消 git add 的操作,文件内容不变,但状态变为未暂存。 (新版本推荐)
git reset HEAD <file> 同上,是老版本 Git 的命令。
git reset --hard <commit-id> 强制回退到某次提交。工作区、暂存区和版本库都会变成该次提交的样子。 高风险,会丢失之后的所有修改
git revert <commit-id> 创建一个新的提交来撤销某次提交的更改。这是一种安全的撤销方式,因为它不会重写历史,适用于团队合作。 安全

五、 远程仓库协作

与 GitHub、Gitee 等远程仓库交互。

命令 含义与作用
git remote -v 查看已配置的远程仓库地址。通常名为 origin
git remote add origin <url> 添加一个远程仓库地址并命名为 origin。通常在 git init 后使用,将本地仓库与远程仓库关联。
git push -u origin main 首次推送并建立追踪关系-u 参数表示将本地的 main 分支与远程的 origin/main 分支关联起来,之后直接 git push 即可。
git fetch 从远程仓库获取最新信息,但不会自动合并到工作区。让你可以看到别人做了什么。
git pull --rebase 拉取更新并使用变基方式合并。这可以使提交历史线更整洁,避免不必要的合并提交。

实用命令组合示例

场景1:日常提交代码

bash

git status // 先看看改了哪些文件
git add . // 把所有改动都暂存起来
git commit -m "feat: 添加用户登录功能" // 提交到本地仓库
git push // 推送到远程仓库

场景2:在新分支上开发功能

bash

git switch -c new-feature // 创建并切换到新功能分支
// ... 进行开发,修改代码 ...
git add .
git commit -m "完成新功能开发"
git switch main // 切换回主分支
git merge new-feature // 将新功能合并到主分支
git branch -d new-feature // 删除已合并的功能分支

场景3:不小心改乱了文件,想重来

bash

git restore . // 撤销所有工作区的修改,恢复到最后一次提交的状态

建议你将此作为速查表,在实践中多多使用,自然就能熟记于心。

CSS复合选择器

2025年10月28日 14:10

复合选择器:

介绍:

在CSS中,可以根据选择器的类型把选择器分为基础选择器和复合选择器,复合 选择器是建立在基础选择器之上,对基本选择器进行组合形成的.

  • 复合选择器可以更准确,更高效的选择目标元素(标签)
  • 复合选择器是由多个或两个基础选择器,通过不同的方式组合而成的.
  • 常用的复合选择器包括:后代选择器、子选择器、并集选择器、伪类选择器等等

后代选择器

介绍

后代选择器又称为包含选择器,可选择父元素里子元素。其写法就是把外层标签写在前面,内层标签写在后面,中间用空格分隔,当标签发生嵌套时,内层标签就成为外层标签的后代。

语法:

元素1 元素2 {样式声明} 上述语法表示选择元素1里面的所有元素2(后代元素) 例如: ul li {样式声明} /选择ul里面所有的li标签元素 /

  • 元素1和元素2中间用空格隔开。
  • 元素1是父级,元素2是子级,最终选择的是元素2
  • 元素2可以是儿子,也可以是孙子等,只要是元素1的后代即可
  • 元素1和元素2可以是任意基础选择器

示例代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
    <style>
        ul li a {
            color: red;
        }
        ul a {
            color: red;
        }
    </style>
</head>
<body>
    <ul>
        <li>这是ul标签下li标签里的标签 <a></a ></li>
        <li>这是ul li标签里的标签 <a></li>
    </ul>
    <ol>
        <li>这是ol li标签里的标签 <a></li>
    </ol>
</body>
</html>

子选择器

介绍:

子选择器(也可理解为基于元素层级的子选择器),只能选择作为某元素的最近一级子元素,简单理解就是“选亲儿子元素”。

语法:

元素1 > 元素2 {样式声明} 上述语法表示:选择“元素1”里面的所有直接后代(子元素)元素2。 div > p {样式声明} /* 选择div里面所有最近一级的p标签元素 */

  • 元素1和元素2中间用大于号(>)隔开。
  • 元素1是父级,元素2是子级,最终选择的是元素2
  • 元素2必须是“亲儿子”,其孙子、重孙等更下层的后代不归它管,所以子选择器也可称为“亲儿子选择器”。

示例代码:

<style>
ul li a {
    color: red;
}
ol li a {
    color: blue;
}
</style>
</head>
<body>
<ul class="a">
    超链接
    <li>这是ul标签中li标签里的a标签</li>
    <li>这是ul标签中li标签里的a标签</li>
    <li><p>这是ul标签中li标签里的a标签</p ></li>
</ul>
<ol>
    <li>这是ol标签中li标签里的a标签</li>
    <li>这是ol标签中li标签里的a标签</li>
</ol>
</body>

并集选择器:

介绍:

并集选择器可选择多组标签,同时为他们定义相同的样式,通常用于集体声明 并集选择器是各选择器(用英文逗号,)连接而成,任何形式的选择器都可以作为并集选择器的一部分

语法:

元素1,元素2 {样式声明} 上述语法表示选择元素1和元素2。 例: ul, div {样式声明} /选择ul和div标签元素 /

  1. 元素1和元素2中间用逗号分隔
  2. 逗号可以理解为“和”的意思
  3. 并集选择器通常用于集体声明

示例代码:

<style>
p,div,span {
    color: red;
}
</style>
</head>
<body>
    <p>大数据招1班</p >
    <div>网页设计</div>
    <span>学习周</span>
</body>

伪类选择器

介绍:

伪类选择器用于向某些选择器添加特殊的效果,比如给链接添加特殊效果,或选择第1个、第n个元素。

特点

是用冒号(:)表示,比如:hover、:first-child。 伪类选择器很多,比如有链接伪类、结构伪类

1.hover 鼠标放上去后显示的效果
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
    <style>
        #t1:hover {
            color: coral;
        }
    </style>
</head>
<body>
    <div id="t1">你好</div>
</body>
</html>

focus选择器

2.focus 获取焦点之后显示的效果
<style>
input:focus {<!--当input获取焦点之后,将框的背景颜色设置成coral。-->
    background-color: coral;
}
</style>
</head>
<body>
    <p>这是p标签</p >
    <div>这是div标签</div>
    <!-- 用法示例 -->
    用户名:<input type="text" />
    密码:<input type="password" />
</body>

复合选择器总结:

复合选择器类型 作用 特征 使用情况 隔开符号及写法
后代选择器 用来选择后代元素 选择所有后代(包含孙子) 较多 符号是空格(例:nav a
子选择器 选择最近一级元素 只选亲儿子 较少 符号是大于号(例:nav>p
并集选择器 选择某些相同样式的元素 可用于集体声明 较多 符号是逗号(例:nav,header
链接伪类选择器 选择不同状态的链接 跟链接相关 较多 重点记 a{}a:hover 实现
focus选择器 选择获取光标的表单元素 跟表单相关 较少 记作 input:focus 这种写法

CSS3星球大战:前端代码界的视觉导演艺术

作者 Tzarevich
2025年10月28日 14:01

CSS3星球大战:前端代码界的视觉导演艺术

在当今的前端开发领域,CSS已不再是简单的样式描述语言,而是成为了一种强大的视觉表达工具。通过巧妙的CSS3技术,我们能够创造出令人惊叹的动画效果,就如同导演在舞台上编排一场精彩的演出。本文将深入探讨如何利用CSS3实现《星球大战》经典开场效果,揭示前端开发中的"导演艺术"。

从静态到动态:CSS3的视觉革命

前端开发者本质上就是代码世界的导演。我们不仅需要构建网页的结构和功能,更要赋予它们生命和情感。CSS3的出现彻底改变了网页的视觉表现能力,引入了过渡(transition)、动画(animation)、变换(transform)和3D效果等强大特性,使静态页面跃然成为动态体验。

在《星球大战》开场动画的实现中,我们充分利用了这些CSS3特性,创造出了文字从屏幕深处飞驰而来的经典效果。这种效果不仅是对技术的展示,更是对电影艺术的致敬和数字再现。

构建宇宙舞台:HTML结构设计

任何精彩的演出都需要一个合适的舞台。在HTML结构中,我们精心设计了以下元素:

<div class="starwars">
  <div class="star">
    <img src="star.svg" alt="Star">
  </div>
  <div class="wars">
    <img src="wars.svg" alt="Wars">
  </div>
  <p class="byline">
    <span>E</span><span>p</span><span>i</span><span>s</span><span>o</span><span>d</span><span>e</span>
    <span> </span>
    <span>V</span><span>I</span><span>I</span><span>I</span>
  </p>
</div>

为什么选择这样的结构?首先,整个动画容器使用div.starwars作为舞台,这符合语义化设计原则。内部的starwars两部分分别包含SVG图像,这种分离设计为后续的独立动画控制提供了可能。

特别值得注意的是byline部分,我们不仅使用了p标签作为副标题容器,更将每个字母分别包裹在span元素中。这种看似繁琐的设计实际上是为了实现每个字母的独立旋转动画,创造出那种经典的逐个字母旋转出现的效果。这13个span元素就像是导演手中的13个演员,每个都有自己独特的出场方式。

舞台搭建:CSS基础布局与定位

宇宙背景的营造

body {
  height: 100vh;
  background: #000 url(./bg.jpg);
}

我们首先设置了全视口高度的黑色背景,并添加星空背景图,营造出深邃的宇宙氛围。这里的100vh确保了背景始终覆盖整个可视区域,无论设备尺寸如何。

3D舞台的创建

.starwars {
  perspective: 800px;
  transform-style: preserve-3d; 
  width: 34em;
  height: 17em;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%,-50%);
}

这段代码是整个3D效果的核心:

  • perspective: 800px定义了3D空间的透视效果,数值越小透视效果越强,数值越大效果越温和。这里的800像素模拟了观众眼睛距离屏幕的视觉距离。
  • transform-style: preserve-3d确保所有子元素在3D空间中保持其立体位置关系,而不是被压扁在二维平面上。
  • 使用em单位定义容器尺寸,这提供了更好的响应式适应性。
  • 绝对定位配合top: 50%; left: 50%; transform: translate(-50%,-50%)实现了完美的水平垂直居中。这种居中技巧是现代CSS布局中的重要技术,它不依赖固定的尺寸,而是通过相对计算实现精准定位。

元素定位策略

.star, .wars, .byline {
  position: absolute;
}

.star {
  top: -0.75em;
}

.wars {
  bottom: -0.5em;
}

.byline {
  left: -2em;
  right: -2em;
  top: 45%;
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.3em;
  font-size: 1.6em;
  color: white;
}

我们使用绝对定位将三个主要元素精确放置在舞台上。starwars分别位于容器的上下两端,而byline则通过负值的左右边距扩展了其空间,确保文字有足够的展示区域。

动画编排:CSS3关键帧动画的艺术

动画基础概念

CSS3动画通过@keyframes规则定义,可以精确控制元素在不同时间点的样式状态。在我们的星球大战动画中,我们定义了多个动画来实现复杂的效果:

.star {
  animation: star 10s ease-out infinite;
}

.wars {
  animation: wars 10s ease-out infinite;
}

.byline {
  animation: move-byline 10s linear infinite;
}

每个动画都持续10秒,无限循环,但使用了不同的时序函数(timing-function):ease-out使动画结束时更加平滑,而linear则保持匀速运动。

文字飞入效果

@keyframes star {
  0% {
    opacity: 0;
    transform: scale(1.5) translateY(-0.75em);
  }
  20% {
    opacity: 1;
  }
  89% {
    opacity: 1;
    transform: scale(1);
  }
  100% {
    opacity: 0;
    transform: translateZ(-1000em);
  }
}

这个动画实现了"STAR"文字的飞入效果:

  • 起始状态(0%):文字完全透明,放大1.5倍并向上偏移,为飞入效果做准备。
  • 20%关键帧:文字完全显现,创造渐入效果。
  • 89%关键帧:文字保持可见并恢复正常尺寸。
  • 结束状态(100%):文字再次消失,并通过translateZ(-1000em)在Z轴上向后移动,创造出向屏幕深处飞去的效果。

wars动画采用了相似的逻辑,但方向相反,创造出上下呼应的视觉效果。

副标题动画分解

副标题动画更加复杂,由两个独立的动画组成:

@keyframes move-byline {
  0% {
    transform: translateZ(5em);
  }
  100% {
    transform: translateZ(0);
  }
}

@keyframes spin-letters {
  0%,10% {
    opacity: 1;
    transform: rotateY(90deg);
  }
  30% {
    opacity: 1;
  }
  70%,86% {
    transform: rotateY(0);
    opacity: 1;
  }
  95%,100% {
    opacity: 0;
  }
}

move-byline动画控制整个副标题容器的运动,从屏幕深处(Z轴5em位置)向前移动到正常位置。

spin-letters动画则控制每个字母的旋转效果:

  • 前10%的时间:每个字母保持90度旋转(侧面),准备开始旋转。
  • 30%关键帧:字母开始显现。
  • 70%-86%关键帧:字母完成旋转到正面,完全可见。
  • 最后阶段:字母逐渐消失。

这种分层动画的设计——容器动画与字母个体动画的结合——创造了丰富而协调的视觉效果,每个字母像是跳着优雅的"钢管舞"依次旋转进入视野。

调试技巧与最佳实践

在开发过程中,我们使用了经典的CSS调试技术:

/* CSS 调试手法,背景颜色调试大法 */
/* background-color: white; */

通过临时添加背景色,我们可以清晰地看到每个元素的边界和位置,这在复杂布局和动画调试中非常有用。

此外,我们遵循了以下最佳实践:

  1. 使用相对单位em单位的使用使得整个动画能够根据字体大小进行缩放,提高了可维护性和响应式适应性。

  2. 性能优化:选择使用transformopacity属性进行动画,因为这些属性可以由浏览器的合成器处理,不会引起重排或重绘,从而保证动画的流畅性。

  3. 代码组织:将相关属性分组,动画定义集中管理,提高了代码的可读性和可维护性。

响应式与可访问性考虑

虽然本文主要关注动画实现,但在实际项目中,我们还需要考虑:

  • 响应式设计:确保动画在不同屏幕尺寸下都能良好展示。
  • 可访问性:为动画提供适当的暂停控件,避免对运动敏感用户造成不适。
  • 性能备选方案:在低性能设备上提供简化版本的动画。

结语:前端开发的导演艺术

通过这个CSS3星球大战动画的实现,我们看到了前端开发如何超越单纯的功能实现,成为一种真正的视觉艺术形式。作为前端开发者,我们不仅是代码的编写者,更是用户体验的导演。我们通过HTML搭建舞台,通过CSS设计视觉效果,通过JavaScript添加交互,共同创造出引人入胜的数字体验。

CSS3动画不仅仅是技术的展示,更是表达创意和情感的工具。掌握这些技术,意味着我们能够为用户创造更加丰富、更加动人的网络体验。正如电影导演通过镜头语言讲述故事,前端开发者通过代码创造出数字世界的视觉奇迹。

在这个星球大战动画案例中,我们见证了CSS3如何将静态元素转化为动态叙事,如何通过几行代码就能唤起观众的情感共鸣。这正是前端开发的魅力所在——我们不仅是技术的实践者,更是数字艺术的创造者。

随着CSS标准的不断发展(包括已经开始制定的CSS4模块),前端开发者将拥有更多工具来创造令人惊叹的视觉效果。但无论技术如何演进,核心的设计原则和创意表达将始终是优秀前端开发的基石。

深入解析 forEach 与 for...of 在循环体中执行 await 时的区别

作者 Juzisuan
2025年10月28日 13:46

循环的差异

最近在处理一组需要严格 串行执行 的异步任务时,又撞上了一个老生常谈却总被忽略的坑:当循环体里出现 await,不同的迭代方式表现完全不同。有的会等上一个任务结束再继续;有的则把所有异步任务触发后不等待,导致“串行”不再串行。

先给结论

  • 需要 串行执行 时,用 do...whilewhileforfor...offor...infor await...of(下文统称为 “for...of 一类”)。
  • 需要 并发执行 时,用 forEach(或 Promise.all 这类聚合器)。

我原本以为forforEachwhile这几个基础方法的表现应该是一样的,要么这几个都能"串行",要么这几个都不能"串行"。

但是结果让我大吃一惊,你没看错,我们经常使用的forwhile方法的表现跟for...of一样,都能让异步任务串行!只有forEach做不到这一点。

下面分别从场景复现、规范条文以及编译产物三个角度,把差异讲透。

场景复现

先准备一组会返回 Promise 的任务:

let list = [];
for (let i = 0; i < 3; i++) {
  list.push(() => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(i);
      }, 1000);
    });
  });
}
  1. 使用 for...of 串行(whilefor 等同理):
const test = async () => {
  for (const task of list) {
    console.log(`for of 循环体内执行`);
    const data = await task();
    console.log(data);
  }
};

test();

// 输出顺序:严格串行,等待当前任务结束后才进入下一轮
// for of 循环体内执行
// 0
// for of 循环体内执行
// 1
// for of 循环体内执行
// 2
  1. 使用 forEach
const test = () => {
  list.forEach(async (task) => {
    console.log(`forEach 循环体内执行`);
    const data = await task();
    console.log(data);
  });
};

test();

// 输出顺序:回调被同步触发,无法等待前一个任务完成
// forEach 循环体内执行
// forEach 循环体内执行
// forEach 循环体内执行
// 0
// 1
// 2

forEachfor...of 一类都是用来遍历集合,为啥连最基础的 forwhile 都能串行,forEach 却偏偏不行?答案写在 ECMAScript 规范里。

从 ECMAScript 规范看差异

for...of 这一类能“等”的根本原因

for of类在ECMAScript中的定义

从图中就能明细看出,forwhile 是跟 for...of "混"一起的,且并不包含forEach

ECMAScript 规范中,Iteration Statement(迭代语句)包含 do...whilewhileforfor...offor...in。它们的执行流程会配合 迭代器与生成器 来管理循环:next 决定迭代推进,yield 描述等待点。

在异步函数中,async 可以类比为 function*await 则好比 yield。当执行到 await 时,上下文被挂起,迭代暂停,待 Promise 完成后再恢复执行。因此,在当前迭代体中的 await 结束前,下一次迭代根本不会开始。

forEach 为什么“不能等”

forEach在ECMAScript中的定义Array.prototype.forEach ( callbackfn [ , thisArg ] ) 的规范算法(节选,意译):

对数组的每一个有效索引 k:

  • 取出元素值 kValue
  • 执行 Call(callbackfn, thisArg, « kValue, k, O »)
  • 继续下一项;
  • 最终 Return undefined

注意整个流程没有任何 await/yieldcallbackfn 即便返回了 Promise,forEach 也不会关心结果。它不过是同步地挨个触发回调,然后立刻返回 undefined

换个视角:看代码转译

把前面的示例交给 SWC(或 Babel)转译到不支持原生 async/await 的环境,差异会更形象。以下是去掉样板代码后的核心结构:

  • for...of 编译后:
// 源码
const test = async () => {
  for (const task of list) {
    console.log(`for of 循环体内执行`);
    const data = await task();
    console.log(data);
  }
};

test();

// SWC编译后的代码示意
function _async_to_generator(fn) { ... }
function _ts_generator(thisArg, body) {...}

var test = function () {
  return _async_to_generator(function () { // 整个函数是一个generator, 由 async 转化
    // ...
    return _ts_generator(this, function (_state) {
      switch (_state.label) {
        // ...
        case 2: // 循环开始/继续
          if (!!(_iteratorNormalCompletion = (_step = _iterator.next()).done))
            return [3, 5];
          task = _step.value;
          console.log("for of 循环体内执行");
          return [4, task()]; // 遇到 await,暂停
        case 3: // Promise resolve 后的恢复点
          data = _state.sent(); // 获取 Promise 的结果
          console.log(data);
          _state.label = 4;
        case 4:
          _iteratorNormalCompletion = true;
          return [3, 2]; // 跳回到 case 2,开始下一次循环
        // ...
      }
    });
  })();
};

重点:在 SWC 编译后的代码中,原本线性的 for...of 循环被拆解成了多个状态,由 switch 语句根据 _state.label 的值来调度执行。这形成了一个状态机,模拟了循环的行为。

  1. 单一的 generator:整个函数被包装成一个 generator_async_to_generator),既掌控 await 的暂停与恢复,也管理 for...of 的迭代进度。
  2. 遇到 await 就暂停:执行到 case 2return [4, task()] 时,相当于碰到 await task()generator 在此暂停,等待 task() 的 Promise settle。
  3. Promise 完成再继续:Promise 完成后,在 case 3 处恢复,_state.sent() 接收结果。随后 case 4 通过 return [3, 2] 回到 case 2,开启下一轮循环。
  • forEach 编译后:
// 源码
const test = () => {
  list.forEach(async (task) => {
    console.log(`forEach 循环体内执行`);
    const data = await task();
    console.log(data);
  });
};

test();
// SWC编译后的代码示意
function _async_to_generator(fn) { ... }
function _ts_generator(thisArg, body) {...}

var test = function () {
  list1.forEach(function (task) { // 循环方法在外面
    return _async_to_generator(function () { // !!! async 的转换发生在循环内 !!!
      var data;
      return _ts_generator(this, function (_state) {
        // ...
      });
    })(); // !!! async 转换后立即执行 !!!
  });
};
  1. forEach 把回调转换成了 generator(通过 _async_to_generator)。
  2. 每次循环都会创建一个新的 generator 并立刻执行,主流程并不会等待它完成。

回调确实是异步的,但 forEach 只是把它们同步地触发调用。外层既不收集,也不等待这些 Promise,流程自然不会串行。

编译后的完整代码
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  try {
    var info = gen[key](arg);
    var value = info.value;
  } catch (error) {
    reject(error);
    return;
  }
  if (info.done) {
    resolve(value);
  } else {
    Promise.resolve(value).then(_next, _throw);
  }
}
function _async_to_generator(fn) {
  return function () {
    var self = this,
      args = arguments;
    return new Promise(function (resolve, reject) {
      var gen = fn.apply(self, args);
      function _next(value) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
      }
      function _throw(err) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
      }
      _next(undefined);
    });
  };
}
function _ts_generator(thisArg, body) {
  var f,
    y,
    t,
    _ = {
      label: 0,
      sent: function () {
        if (t[0] & 1) throw t[1];
        return t[1];
      },
      trys: [],
      ops: [],
    },
    g = Object.create(
      (typeof Iterator === "function" ? Iterator : Object).prototype
    );
  return (
    (g.next = verb(0)),
    (g["throw"] = verb(1)),
    (g["return"] = verb(2)),
    typeof Symbol === "function" &&
      (g[Symbol.iterator] = function () {
        return this;
      }),
    g
  );
  function verb(n) {
    return function (v) {
      return step([n, v]);
    };
  }
  function step(op) {
    if (f) throw new TypeError("Generator is already executing.");
    while ((g && ((g = 0), op[0] && (_ = 0)), _))
      try {
        if (
          ((f = 1),
          y &&
            (t =
              op[0] & 2
                ? y["return"]
                : op[0]
                  ? y["throw"] || ((t = y["return"]) && t.call(y), 0)
                  : y.next) &&
            !(t = t.call(y, op[1])).done)
        )
          return t;
        if (((y = 0), t)) op = [op[0] & 2, t.value];
        switch (op[0]) {
          case 0:
          case 1:
            t = op;
            break;
          case 4:
            _.label++;
            return {
              value: op[1],
              done: false,
            };
          case 5:
            _.label++;
            y = op[1];
            op = [0];
            continue;
          case 7:
            op = _.ops.pop();
            _.trys.pop();
            continue;
          default:
            if (
              !((t = _.trys), (t = t.length > 0 && t[t.length - 1])) &&
              (op[0] === 6 || op[0] === 2)
            ) {
              _ = 0;
              continue;
            }
            if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
              _.label = op[1];
              break;
            }
            if (op[0] === 6 && _.label < t[1]) {
              _.label = t[1];
              t = op;
              break;
            }
            if (t && _.label < t[2]) {
              _.label = t[2];
              _.ops.push(op);
              break;
            }
            if (t[2]) _.ops.pop();
            _.trys.pop();
            continue;
        }
        op = body.call(thisArg, _);
      } catch (e) {
        op = [6, e];
        y = 0;
      } finally {
        f = t = 0;
      }
    if (op[0] & 5) throw op[1];
    return {
      value: op[0] ? op[1] : void 0,
      done: true,
    };
  }
}
var _loop = function (i) {
  list1.push(function () {
    return new Promise(function (resolve, reject) {
      setTimeout(function () {
        resolve(i);
      }, 1000);
    });
  });
  list2.push(function () {
    return new Promise(function (resolve, reject) {
      setTimeout(function () {
        resolve(i);
      }, 1000);
    });
  });
};
var list1 = [];
var list2 = [];
for (var i = 0; i < 3; i++) _loop(i);
var test1 = function () {
  list1.forEach(function (task) {
    return _async_to_generator(function () {
      var data;
      return _ts_generator(this, function (_state) {
        switch (_state.label) {
          case 0:
            console.log("forEach 循环体内执行");
            return [4, task()];
          case 1:
            data = _state.sent();
            console.log(data);
            return [2];
        }
      });
    })();
  });
};
var test2 = function () {
  return _async_to_generator(function () {
    var _iteratorNormalCompletion,
      _didIteratorError,
      _iteratorError,
      _iterator,
      _step,
      task,
      data,
      err;
    return _ts_generator(this, function (_state) {
      switch (_state.label) {
        case 0:
          ((_iteratorNormalCompletion = true),
            (_didIteratorError = false),
            (_iteratorError = undefined));
          _state.label = 1;
        case 1:
          _state.trys.push([1, 6, 7, 8]);
          _iterator = list2[Symbol.iterator]();
          _state.label = 2;
        case 2:
          if (!!(_iteratorNormalCompletion = (_step = _iterator.next()).done))
            return [3, 5];
          task = _step.value;
          console.log("for of 循环体内执行");
          return [4, task()];
        case 3:
          data = _state.sent();
          console.log(data);
          _state.label = 4;
        case 4:
          _iteratorNormalCompletion = true;
          return [3, 2];
        case 5:
          return [3, 8];
        case 6:
          err = _state.sent();
          _didIteratorError = true;
          _iteratorError = err;
          return [3, 8];
        case 7:
          try {
            if (!_iteratorNormalCompletion && _iterator.return != null) {
              _iterator.return();
            }
          } finally {
            if (_didIteratorError) {
              throw _iteratorError;
            }
          }
          return [7];
        case 8:
          return [2];
      }
    });
  })();
};
test1();
test2();

小结

当循环体里执行异步任务时:

  • 要串行:使用 do...whilewhileforfor...offor...infor await...of
  • 要并发:使用 forEach(或显式的 Promise.all)。

ECMAScript 规范明确区分了 forEachfor...of 一类迭代的执行模型,SWC 的编译结果则从另外一个角度验证了这一点:for...of 天生会等待当前迭代结束,而 forEach 只负责触发回调,不负责等待。

React 与 Vue 开发差异——CSS 样式

作者 Carry345
2025年10月28日 13:20

react 中似乎与vue 不同,vue中在使用 scoped 后需要通过:deep()才能修改子组件的样式,但react 中看起来可以随意修改,不会造成样式污染吗?如何解决?

存在全局污染的情况:

情况 1:使用 createGlobalStyle

import { createGlobalStyle } from 'styled-components'

const GlobalStyle = createGlobalStyle`
  body {
    margin: 0;
    color: red;
  }

  .ant-btn {
    border-radius: 0; /* 影响所有 antd 按钮 */
  }
`

就是显式地往全局注入 CSS。
✅ 合理用于基础 reset / normalize.css。
❌ 但不能随便在业务组件里定义,否则污染全局。

情况 2:在 styled 里写了全局选择器(嵌套 CSS)

const Wrapper = styled.div`
  .ant-btn {
    color: red;
  }
`

这看似“写在组件里”,但其实会生成类似:

.sc-xxx .ant-btn { color: red }

.ant-btn 被渲染在这个组件内,就被强行覆盖了样式。

如何避免污染(最佳实践)

1️⃣ 禁止直接写全局类选择器

错误示例 ❌:

const Box = styled.div`
  .ant-btn {
    color: red;
  }
`

正确做法 ✅:

import { Button } from 'antd'
import styled from 'styled-components'

const StyledButton = styled(Button)`
  color: red;
`

通过“组件包裹”而非“全局类名”方式修改样式,能彻底避免污染。

2️⃣ 使用命名空间前缀统一组件风格

给每个 styled 组件统一一个命名前缀:

const AppButton = styled.button`
  &.app-btn {
    border-radius: 4px;
  }
`

或者使用 BEM 风格

const Wrapper = styled.div`
  &.app {
    &__header { ... }
    &__content { ... }
  }
`

这样生成的类名会被 styled-components 的哈希包裹,冲突概率几乎为零。

我删光了项目里的 try-catch,老板:6

作者 前端九哥
2025年10月28日 12:21

相信我们经常这样写bug(不是 👇:
image_90.gif

try {
  const res = await api.getUser()
  console.log('✅ 用户信息', res)
} catch (err) {
  console.error('❌ 请求失败', err)
}

看似没问题

  • 每个接口都要 try-catch,太啰嗦了!
  • 错误处理逻辑分散,不可控!
  • 代码又臭又长💨!

image_432.gif

💡 目标:不抛异常的安全请求封装

我们希望实现这样的调用👇:

const [err, data] = await safeRequest(api.getUser(1))

if (err) {
  console.warn('❌ 获取用户失败:', err.message)
} else {
  console.log('✅ 用户信息:', data)
}

是不是清爽多了?✨
没有 try-catch,却能同时拿到错误和数据。


🧩 实现步骤

1️⃣ 先封装 Axios 实例

// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'

const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
})

// 🧱 请求拦截器
service.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) config.headers.Authorization = `Bearer ${token}`
    return config
  },
  (error) => Promise.reject(error)
)

// 🧱 响应拦截器
service.interceptors.response.use(
  (response) => {
    const res = response.data
    if (res.code !== 0) {
      ElMessage.error(res.message || '请求失败')
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    return res.data
  },
  (error) => {
    ElMessage.error(error.message || '网络错误')
    return Promise.reject(error)
  }
)

export default service

拦截器的作用:

  • ✅ 统一处理 token;
  • ✅ 统一处理错误提示;
  • ✅ 保证业务层拿到的永远是“干净的数据”。

2️⃣ 封装一个「安全请求函数」

// src/utils/safeRequest.js
export async function safeRequest(promise) {
  try {
    const data = await promise
    return [null, data] // ✅ 成功时返回 [null, data]
  } catch (err) {
    return [err, null] // ❌ 失败时返回 [err, null]
  }
}

这就是关键!
它让所有 Promise 都变得「温柔」——不再抛出异常,而是返回结构化结果。


3️⃣ 封装 API 模块

// src/api/user.js
import request from '@/utils/request'

export const userApi = {
  getUser(id) {
    return request.get(`/user/${id}`)
  },
  updateUser(data) {
    return request.put('/user', data)
  },
}

4️⃣ 在业务层优雅调用

<script setup>
import { ref, onMounted } from 'vue'
import { userApi } from '@/api/user'
import { safeRequest } from '@/utils/safeRequest'

const user = ref(null)

onMounted(async () => {
  const [err, data] = await safeRequest(userApi.getUser(1))

  if (err) {
    console.warn('❌ 获取用户失败:', err.message)
  } else {
    user.value = data
  }
})
</script>

是不是很优雅、数据逻辑清晰、不需要 try-catch、 错误不崩溃。

老板说:牛🍺,你小子有点东西

image_443.gif

🧱 我们还可以进一步优化:实现自动错误提示

我们可以给 safeRequest 增加一个选项,让错误自动提示:

// src/utils/safeRequest.js
import { ElMessage } from 'element-plus'

export async function safeRequest(promise, { showError = true } = {}) {
  try {
    const data = await promise
    return [null, data]
  } catch (err) {
    if (showError) {
      ElMessage.error(err.message || '请求失败')
    }
    return [err, null]
  }
}

使用时👇:

const [err, data] = await safeRequest(userApi.getUser(1), { showError: false })

这样你可以灵活控制是否弹出错误提示,
比如某些静默请求就可以关闭提示。


🧠 进阶:TypeScript 支持(超丝滑)

如果你用的是 TypeScript,可以让返回类型更智能👇:

export async function safeRequest<T>(
  promise: Promise<T>
): Promise<[Error | null, T | null]> {
  try {
    const data = await promise
    return [null, data]
  } catch (err) {
    return [err as Error, null]
  }
}

调用时:

const [err, user] = await safeRequest<User>(userApi.getUser(1))
if (user) console.log(user.name) // ✅ 自动提示类型

老板:写得很好,下次多写点,明天你来当老板

20240809104358583cfe95dc770206b95ebe14e51a0737.png

有27届前端仔要实习的可以来看看广州双休小工厂:juejin.cn/post/756520…

Flutter项目使用 buf.build

作者 傅里叶
2025年10月28日 11:02

buf.build 是现在主流的 Protocol Buffers 规范化、版本管理与发布平台。 无论是单机开发还是企业 CI/CD 场景,它都能让 *.proto 文件像 npm 包一样统一管理。

适合 Flutter、Go、Dart、Rust、Python 等多语言开发环境。


🧭 Buf.build 使用笔记(2025 实用整理版)


✨ 一、Buf 是什么

Buf 是一个针对 Protocol Buffers(Protobuf) 生态的工具链与云平台,提供:

功能 说明
buf lint 检查 Protobuf 文件风格规范(统一命名、包结构)
buf breaking 检查 API 向后兼容性(避免破坏性变更)
buf build 统一生成 .bin 格式描述符集
buf generate 自动生成多语言代码
buf registry 类似 GitHub,用于托管 .proto 文件与版本发布
buf.yaml / buf.gen.yaml 项目配置文件,定义结构与生成方案

⚙️ 二、安装与初始化

🧩 安装 CLI

Windows
choco install buf

或使用 Scoop:

scoop install buf
Mac / Linux
brew install buf

或:

curl -sSL https://github.com/bufbuild/buf/releases/latest/download/buf-WINDOWS-x86_64.exe -o buf.exe

确认版本:

buf --version

🚀 初始化项目

进入你的 proto 根目录:

cd proto/
buf init

这会生成:

# buf.yaml
version: v2
modules:
  - path: .

再添加生成配置:

touch buf.gen.yaml

示例:

version: v2
plugins:
  - plugin: buf.build/protocolbuffers/python
    out: gen/python
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
  - plugin: buf.build/community/dart
    out: gen/dart

📁 三、推荐项目结构

proto/
 ├── buf.yaml
 ├── buf.gen.yaml
 ├── buf.lock
 ├── example.proto
 ├── google/
 │    └── api/
 │         └── annotations.proto
 └── mypackage/
      ├── message.proto
      ├── service.proto
      └── types.proto

🧪 四、常用命令

命令 功能
🩰 buf lint 检查命名、包名、service 格式
🧱 buf build 生成中间描述文件(buf.bin
🔄 buf generate 根据 buf.gen.yaml 生成多语言代码
🧩 buf breaking --against ... 比较与上次发布版本是否兼容
🚢 buf push 将当前 module 发布到 buf.build
📥 buf export 下载远程仓库的 proto 文件

🎯 五、与 buf.build 注册中心配合使用

  1. 登录

    buf login
    

    (首次会要求访问 buf.build 复制 Token)

  2. 创建仓库(类似 GitHub) 在 buf.build → “New Repository”

  3. 推送模块

    buf push buf.build/<organization>/<repository>
    
  4. 拉取别人模块

    buf export buf.build/googleapis/googleapis --output ./third_party
    

Buf 会自动管理依赖 resolution(在 buf.lock 中记录版本和哈希)。


🧰 六、常见文件说明

buf.yaml

项目自身的配置:

version: v2
modules:
  - path: .
deps:
  - buf.build/googleapis/googleapis
lint:
  use:
    - DEFAULT
breaking:
  use:
    - FILE

buf.gen.yaml

生成配置示例:

version: v2
managed:
  enabled: true
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
  - plugin: buf.build/community/dart
    out: gen/dart

🧩 七、Dart 与 Flutter 集成示例

在 pubspec.yaml 中添加依赖:

dependencies:
  protobuf: ^3.1.0
  grpc: ^3.2.4

生成 Dart 代码:

buf generate

生成后文件位置:

lib/
  └── proto/
       └── mypackage.pb.dart

🧠 八、最佳实践笔记

场景 建议
多人协作开发 proto 使用 buf lint & breaking,防止不兼容修改
CI 检查 在 GitHub Actions 执行 buf lintbuf breaking
开源接口依赖 使用 buf.build/googleapis/googleapis 代替手动下载 .proto
本地构建多语言 SDK 修改 buf.gen.yaml 支持多 plugin 输出
Flutter + Rust/Go 后端共用协议 一次定义,buf generate 多端生成

🧩 九、CI/CD 集成示例(GitHub Actions)

.github/workflows/buf.yaml

name: buf check

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bufbuild/buf-action@v1
        with:
          args: lint
  breaking:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bufbuild/buf-action@v1
        with:
          args: breaking --against buf.build/myorg/myrepo

🧾 十、资源

链接 内容
🏠 buf.build 官方主站
📘 docs.buf.build 官方文档
🧰 github.com/bufbuild/bu… CLI 源码
🪄 Protobuf 官方 语法和编译器规范

🔚 总结

Buf.build 是 Protobuf 的现代化解决方案, 核心价值是——一致性(Lint)、安全演进(Breaking)、多语言生成、集中化发布。 掌握以下 4 条命令,你就几乎能完成全部常见任务:

buf mod update     # 更新依赖
buf lint           # 检查规范
buf generate       # 生成代码
buf push           # 发布到 registry

提示: 如果版本不匹配,可以降版本。具体操作如下:

buf.gen.yaml

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/dart:v22.5.0
    out: lib/protos/raw/src

直接复制上面的 buf.build/protocolbuffers/dart 到浏览器,然后测试不同的版本

image.png

HelloGitHub 第 115 期

2025年10月28日 08:04
本期共有 41 个项目,包含 C 项目 (2),C# 项目 (3),C++ 项目 (3),Go 项目 (3),Java 项目 (2),JavaScript 项目 (5),Kotlin 项目 (2),Python 项目 (5),Rust 项目 (2),Swift 项目 (2),人工智能 (5),其它 (6),开源书籍 (1)
昨天 — 2025年10月27日技术

dify案例分享-用 Dify 一键生成教学动画 HTML!AI 助力,3 分钟搞定专业级课件

作者 wwwzhouhui
2025年10月27日 21:11

1.前言

在教育信息化的今天,如何用更生动、更直观的方式讲解抽象的知识点,一直是教育工作者面临的挑战。传统的教学课件制作往往需要教师花费大量时间学习复杂的动画制作软件,而制作出的效果又往往难以达到理想水平。

AI辅助生成精美动画HTML技术的出现,彻底改变了这一局面。它是一种结合了人工智能大语言模型和现代Web技术的创新解决方案,能够根据用户输入的教学主题,自动生成视觉精美、内容准确、交互流畅的HTML5动画页面。这些动画页面不仅可以在浏览器中直接播放,还能导出为MP4视频或GIF格式,便于在各种教学场景中使用。

img

对教育教学的深远影响

img

img

最近使用dify做了一个AI教学动画HTML网页,效果如下:

img

gif动画

bubble_sort_animation

工作流如下:

img

那么这样的工作流是如何制作的呢?下面小编带大家手把手制作这个工作流

2.工作流制作

这个工作流主要有哪些组成部分构成的呢?我们通过上面的截图就可以看出它主要有开始节点、Agent、LLM大语言模型、代码执行、直接回复组成。

img

开始

这个开始节点我们这里就没有设置用户定义的提示词,就用sys.query的提示词。配置内容如下:

img

LLM大语言模型

这个LLM大语言模型这块作用是将前面的Agent联网搜到的金句使用大语言模型生成精美的HTML 页面。

模型这里我们使用google gemini2.5pro模型。当然国内以下主要几个模型也都是可以的,比如Qwen/Qwen3-Coder-480B-A35B-Instruct、deepseek-ai/DeepSeek-V3.2-Exp 、glm4.6等

img

系统提示词

# Role: 精美动态动画生成专家

## Profile
- author: 周辉
- version: 2.0
- language: 中文
- description: 专注于生成符合2K分辨率标准的、视觉精美的、自动播放的教育动画HTML页面,确保所有元素正确布局且无视觉缺陷

## Skills
1. 精通HTML5、CSS3、JavaScript和SVG技术栈
2. 擅长响应式布局和固定分辨率容器设计
3. 熟练掌握动画时间轴编排和视觉叙事
4. 精通浅色配色方案和现代UI设计美学
5. 能够实现双语字幕和旁白式文字解说系统

## Background
用户需要生成一个完整的单文件HTML动画,用于知识点讲解。该动画必须在固定的2K分辨率容器(1280px × 720px)中完美呈现,避免任何布局错误、元素穿模或字幕遮挡问题。

## Goals
1. 生成视觉精美、设计感强的动态动画页面
2. 确保所有元素在1280px × 720px容器内正确定位
3. 实现清晰的开场、讲解过程和收尾结构
4. 提供双语字幕和旁白式解说
5. 在动画结束时插入完结标记供录制判断

## Constraints
1. 分辨率约束:所有内容必须在固定的1280px宽 × 720px高的容器内呈现
2. 视觉完整性:禁止出现元素穿模、字幕遮挡、图形位置错误
3. 技术栈:仅使用HTML + CSS + JS + SVG,不依赖外部库,资源尽量内嵌
4. 自动播放:页面加载后立即开始播放,无交互按钮
5. 单文件输出:所有资源内嵌在一个HTML文件中
6. 完结标记:动画结束时必须执行指定的JavaScript完结逻辑

## OutputFormat
请严格输出以下结构的完整HTML文档,并使用代码块包裹(```html 开头,``` 结尾):
```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>
    /* 确保容器固定为2K分辨率 */
    :root {{
      --bg: #f6f7fb;
      --panel: #ffffff;
      --text: #223;
      --accent: #4a90e2;
      --sub: #7b8ba8;
    }}
    html, body {{ height: 100%; }}
    body {{
      margin: 0; padding: 0; display: flex; justify-content: center; align-items: center;
      min-height: 100vh; background: var(--bg); overflow: hidden; color: var(--text);
      font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
    }}
    #animation-container {{
      position: relative; width: 1280px; height: 720px; background: var(--panel); overflow: hidden;
      box-shadow: 0 0 50px rgba(0,0,0,0.08); border-radius: 20px;
    }}
    /* 建议的字幕区域(底部居中,150-200px 高) */
    .subtitles {{ position: absolute; left: 0; right: 0; bottom: 40px; height: 180px; display: flex; align-items: center; justify-content: center; pointer-events: none; }}
    .subtitles .line {{
      background: rgba(255,255,255,0.85); color: #111; border-radius: 12px; padding: 18px 24px; font-size: 40px; line-height: 1.3; max-width: 80%; text-align: center; box-shadow: 0 8px 24px rgba(0,0,0,.08);
    }}
    /* 其他样式... */
  </style>
  </head>
  <body>
    <div id=\"animation-container\">
      <!-- 在此放置SVG/图形/讲解元素,确保关键视觉位于中心区域的60-70%,保留20-30px安全边距 -->
      <div class=\"subtitles\"><div id=\"sub-cn\" class=\"line\"></div></div>
      <div class=\"subtitles\" style=\"bottom: 240px;\"><div id=\"sub-en\" class=\"line\"></div></div>
    </div>
    <script>
      // 动画逻辑示例:请在此实现开场(5-10s)  讲解(30-60s)  收尾(5-10s) 的时间轴
      // 并确保元素动画流畅、无穿模,与字幕同步。

      function setSubtitles(cn, en) {{
        const cnEl = document.getElementById('sub-cn');
        const enEl = document.getElementById('sub-en');
        if (cnEl) cnEl.textContent = cn || '';
        if (enEl) enEl.textContent = en || '';
      }}

      // 动画结束时的完结标记(必须包含)
      function markAnimationFinished() {{
        try {{
          window.playFinished = true;
          window.dispatchEvent(new Event('recording:finished'));
          var flag = document.createElement('div');
          flag.id = 'finished-flag';
          flag.style.display = 'none';
          document.body.appendChild(flag);
        }} catch (e) {{ /* no-op */ }}
      }}

      // 请在最后一个动画结束后调用 markAnimationFinished();
      // markAnimationFinished();
    </script>
  </body>
</html>
```

## Workflows
1. 接收主题:获取用户指定的知识点主题(本次主题:{{#sys.query#}})。
2. 结构规划:设计开场(5-10秒)→ 核心讲解(30-60秒)→ 收尾(5-10秒)的时间轴。
3. 视觉设计:选择和谐浅色配色,精准布局到 1280×720 容器,字幕区域底部居中。
4. 动画编排:用CSS动画/JS控制时间轴,保证流畅与无穿模,字幕与视觉同步。
5. 完结逻辑:在最后一个动画完成后必须调用 markAnimationFinished()。
6. 质量检查:元素不越界、字幕不遮挡关键视觉、配色和谐易读。
7. 输出交付:仅输出完整单文件HTML,并用 ```html 代码块包裹。

## Suggestions
1. 使用CSS Grid或Flexbox精确控制1280×720容器内的布局。
2. 字幕字号建议32-48px,确保2K分辨率下清晰可读。
3. 关键视觉元素应占据容器中心区域的60-70%。
4. 使用CSS变量统一管理配色方案。
5. 动画总时长建议40-90秒。
6. 关键内容保持20-30px安全边距,防止溢出。

## Output Rule
- 仅输出完整、可直接保存为 .html 的单文件源码。
- 必须使用 ```html 代码块包裹;不得输出说明文字或多余内容。

用户提示词

请根据用户输入的信息{{#1750772708073.text#}}生成HTML代码

img

代码处理

客户端代码,这里我们使用我自己搭建的服务端代码来实现html页面的生成的。 有的小伙伴可能会问了。怎么不用腾讯的EdgeOne Pages 实现静态HTML 部署呢? 这里我们使用大模型部署MCP 主要是慢。这里为了节约时间就用代码直接生成处理了。这个处理代码生成大概在1秒就可以完成,如果用大模型至少要10秒以上时间。

输入参数 json_html 输入值 上个LLM大模型输出

apikey 和apiurl 是我们定义的远程调用服务端代码的apikey 和apiurl

我们在环境变量里面设置

img

apiurl:http://14.103.204.132:8080/generate-html/

apikey:sk-zhouhuixxx

这个有小伙伴问过 这个apikey从哪来的,这个是我们服务端代码自己定义的。

关于服务端代码部署发布和使用可以看我之前的文章dify案例分享-探秘:AI 怎样颠覆财报分析,打造酷炫 HTML 可视化

重点看代码处理生成html调用 这部分

img

服务端代码可以从我开源项目中github.com/wwwzhouhui/… 获取

客户端代码如下

import json
import re
import time
import requests

def main(json_html: str, apikey: str,apiurl: str) -> dict:
    try:
        # 去除输入字符串中的 ```html 和 ``` 标记
        match = re.search(r'```html\s*([\s\S]*?)```', json_html, re.DOTALL)
        
        if match:
            # group(1) 获取第一个捕获组的内容,即纯HTML代码
            # .strip() 去除可能存在的前后空白
            html_content = match.group(1).strip()
        else:
            # 如果在输入中找不到HTML代码块,则返回错误
            raise ValueError("未能在输入中找到 ```html ... ``` 代码块。")
        
        # 生成时间戳,确保文件名唯一
        timestamp = int(time.time())
        filename = f"makehtml_{timestamp}.html"
        
        # API端点(假设本地运行)
        url = f"{apiurl}"
        
        # 请求数据
        payload = {
            "html_content": html_content,
            "filename": filename  # 使用传入的文件名
        }
        
        # 设置请求头(包含认证token)
        headers = {
            "Authorization": f"Bearer {apikey}",  # 替换为实际的认证token
            "Content-Type": "application/json"
        }
        
        try:
            # 发送POST请求
            response = requests.post(url, json=payload, headers=headers)
            
            # 检查响应状态
            if response.status_code == 200:
                result = response.json()
                html_url = result.get("html_url", "")
                generated_filename = result.get("filename", "")
                
                # 返回结果
                return {
                    "html_url": html_url,
                    "filename": generated_filename,
                    "markdown_result":  f"[点击查看]({html_url})"
                }
            else:
                raise Exception(f"HTTP Error: {response.status_code}, Message: {response.text}")
        
        except requests.exceptions.RequestException as e:
            raise Exception(f"Request failed: {str(e)}")
    
    except Exception as e:
        return {
            "error": f"Error: {str(e)}"
        }

输入变量 我这里设置三个html_url、filename、markdown_result 返回的变量类型是string

img

img

直接回复

这个直接回复我们这里输出2个值,一个是代码执行返回的URL ,一个是URL markdown地址

img

以上我们就完成了工作流的制作。

3.验证及测试

我们打开工作流预览按钮。聊天窗口中输入如下提示词

冒泡排序

img

工作流执行完成后我们看到生成的HTML 页面链接

我们点击查看,现在链接。

img

保存本地打开。

img

大家也可以打开我这个链接地址下载本地直观的感受。

dify-1258720957.cos.ap-nanjing.myqcloud.com/makehtml_17…

有的小伙伴就说这个只是一个冒泡排序算法,其他的内容支持不支持呢?这个当然是可以的。

我这里有个开源项目github.com/wwwzhouhui/…

img

你可以基于各种数学、物理、化学、生物、计算机原理等生成各种科普类视频,该项目涵盖了小学、初中、高中、大学各种原理理解。

上面的开源项目除了生成html页面外还能基于html生成视频方便教学使用。感兴趣的小伙伴可以关注我这个开源项目。

dify体验地址difyhs.duckcloud.fun/chat/SPtLwd…

dify备用地址(http://14.103.204.132/chat/SPtLwdDGZkVSjrln)

4.总结

今天主要带大家了解并实现了基于 Dify 工作流构建 AI 辅助教学动画 HTML 页面生成工具的完整流程,该流程以 Google Gemini 2.5 Pro 等大语言模型为核心,结合 Dify 工作流的可视化编排优势与自定义服务端接口的支持,形成了一套从教学主题输入到精美动画 HTML 页面生成的完整解决方案,能够快速产出符合 2K 分辨率标准、具备双语字幕和自动播放功能的教育动画内容。

感兴趣的小伙伴可以通过文中提供的 Dify 工作流体验地址直接试用,也可以参考工作流配置进行自定义扩展开发,基于开源项目进一步实现视频生成等进阶功能。今天的分享就到这里结束了,我们下一篇文章见。

如何处理管理系统中(Vue PC + uni-app 移动端):业务逻辑复用基本方案

2025年10月27日 17:07

本文主要从以下几个方面入手:

  1. API请求、

  2. 状态管理、

  3. 工具函数

  4. 路由

  5. 包管理工具

一、整体设计:分层解耦,复用优先

核心思路是  “业务逻辑抽离为公共层,两端仅保留平台特有逻辑” ,整体架构分为 5 层,从下到上分别为:

├─ 1. 基础工具层(utils):纯函数工具,两端完全复用
├─ 2. API 通信层(api):统一请求逻辑,适配两端差异
├─ 3. 状态管理层(store):核心业务状态,两端共享
├─ 4. 业务逻辑层(services):封装业务方法,两端复用
└─ 5. 视图层(components/views):平台特有组件/页面,差异化实现

核心原则

  1. 公共逻辑 “上提” :API、状态、工具函数等无平台依赖的逻辑,抽离到独立包 / 目录,避免两端重复编写;
  2. 平台差异 “下沉” :UI 组件、路由、原生能力(如扫码、推送)等平台特有逻辑,在两端单独实现,通过 “适配层” 对接公共逻辑;
  3. 依赖统一管理:统一包管理工具(如 npm),公共依赖(如 axios、lodash)在两端共享版本,避免兼容性问题。

二、具体技术方案:分层实现复用

1. 基础工具层(utils):100% 复用,纯函数设计

核心目标

提供无副作用的纯函数工具,覆盖格式化、验证、计算等通用能力,两端完全复用。

实现方案

  • 目录结构:在项目根目录创建 packages/utils(或独立 npm 包),按功能分类:

    utils/
    ├─ format/:时间格式化(dateFormat)、金额格式化(moneyFormat)
    ├─ validate/:手机号验证(isPhone)、邮箱验证(isEmail)、表单规则(formRules)
    ├─ compute/:分页计算(calcPageInfo)、权限判断(hasPermission)
    └─ common/:深拷贝(deepClone)、防抖节流(debounce/throttle)
    
  • 代码示例(时间格式化工具):

    // packages/utils/format/dateFormat.js
    export const dateFormat = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
      // 纯函数实现,无平台依赖
      const dt = new Date(date);
      const options = {
        'YYYY': dt.getFullYear(),
        'MM': String(dt.getMonth() + 1).padStart(2, '0'),
        // ... 其他格式化逻辑
      };
      return Object.entries(options).reduce((res, [key, val]) => res.replace(key, val), format);
    };
    
  • 复用方式:两端通过 import { dateFormat } from '@/utils/format/dateFormat' 直接引入,无需修改。

2. API 通信层(api):统一请求逻辑,适配两端差异

核心目标

封装 API 请求逻辑(请求拦截、响应拦截、错误处理),统一接口调用方式,仅适配两端的请求库差异(Vue 用 axios,uni-app 用 uni.request)。

实现方案

  • 分层设计:分为 “基础请求适配层” 和 “业务接口层”,前者处理平台差异,后者纯业务逻辑复用:

    api/
    ├─ request/:基础请求适配(区分 Vue/uni-app)
    │  ├─ index.js:入口文件(根据环境导出对应请求实例)
    │  ├─ webRequest.js:Vue 端(基于 axios)
    │  └─ uniRequest.js:uni-app 端(基于 uni.request)
    └─ modules/:业务接口(两端完全复用)
       ├─ user.js:用户相关(登录、权限)
       ├─ order.js:订单相关(列表、详情)
       └─ goods.js:商品相关(新增、编辑)
    
  • 关键实现:请求适配层(处理平台差异):

    // api/request/webRequest.js(Vue 端)
    import axios from 'axios';
    const service = axios.create({
      baseURL: import.meta.env.VITE_API_BASE_URL, // PC 端环境变量
      timeout: 10000
    });
    // 请求拦截:添加 token
    service.interceptors.request.use(config => {
      config.headers.token = localStorage.getItem('token');
      return config;
    });
    // 响应拦截:统一错误处理
    service.interceptors.response.use(
      res => res.data,
      err => { /* 统一错误提示(如 Element Plus Message) */ }
    );
    export default service;
    
    // api/request/uniRequest.js(uni-app 端)
    export default function uniRequest(config) {
      return new Promise((resolve, reject) => {
        uni.request({
          url: config.baseURL || import.meta.env.VITE_API_BASE_URL, // 移动端环境变量
          method: config.method || 'GET',
          data: config.data,
          header: { token: uni.getStorageSync('token') }, // uni-app 存储 API
          success: res => resolve(res.data),
          fail: err => { /* 统一错误提示(如 uni.showToast) */ }
        });
      });
    }
    
    // api/request/index.js(入口:自动适配环境)
    let request;
    if (process.env.VUE_APP_PLATFORM === 'web') {
      request = import('./webRequest').then(m => m.default);
    } else {
      request = import('./uniRequest').then(m => m.default);
    }
    export default request;
    
  • 业务接口层(纯逻辑复用,无平台依赖):

    // api/modules/user.js(两端完全复用)
    import request from '../request';
    
    // 登录接口
    export const login = (params) => request({
      url: '/api/user/login',
      method: 'POST',
      data: params
    });
    
    // 获取用户权限列表
    export const getUserPermissions = () => request({
      url: '/api/user/permissions',
      method: 'GET'
    });
    

3. 状态管理层(store):核心业务状态共享

核心目标

用 Vuex/Pinia 管理全局状态(如用户信息、权限、全局配置),两端共享状态定义和 mutations/actions,仅适配平台特有存储(如 token 存储)。

实现方案

  • 技术选型:优先用 Pinia(Vue 3 推荐,支持 TypeScript,更轻量,如果项目的搭建时不要求ts,推荐vuex),两端共用 Pinia 实例。

  • 目录结构

    store/
    ├─ index.js:Pinia 实例创建(适配两端存储)
    ├─ modules/
    │  ├─ userStore.js:用户状态(登录、权限)
    │  ├─ appStore.js:应用配置(主题、语言)
    │  └─ orderStore.js:订单状态(待办数量、筛选条件)
    
  • 关键实现:适配两端存储

    // store/index.js(Pinia 实例创建)
    import { createPinia } from 'pinia';
    import { createPersistedState } from 'pinia-plugin-persistedstate'; // 持久化插件
    
    const pinia = createPinia();
    
    // 适配两端持久化存储:Vue 用 localStorage,uni-app 用 uniStorage
    const storage = process.env.VUE_APP_PLATFORM === 'web' 
      ? window.localStorage 
      : {
          getItem: uni.getStorageSync,
          setItem: uni.setStorageSync,
          removeItem: uni.removeStorageSync
        };
    
    // 安装持久化插件,指定存储方式
    pinia.use(createPersistedState({
      storage: {
        getItem: (key) => storage.getItem(key),
        setItem: (key, value) => storage.setItem(key, value),
        removeItem: (key) => storage.removeItem(key)
      }
    }));
    
    export default pinia;
    
  • 业务状态示例(用户状态,两端复用):

    // store/modules/userStore.js
    import { defineStore } from 'pinia';
    import { login, getUserPermissions } from '@/api/modules/user';
    import { hasPermission } from '@/utils/compute/permission';
    
    export const useUserStore = defineStore('user', {
      state: () => ({
        token: '',
        info: {}, // 用户信息
        permissions: [] // 权限列表
      }),
      actions: {
        // 登录:业务逻辑完全复用
        async loginAction(params) {
          const res = await login(params);
          this.token = res.token;
          await this.getPermissionsAction(); // 登录后获取权限
        },
        // 获取权限:业务逻辑完全复用
        async getPermissionsAction() {
          const res = await getUserPermissions();
          this.permissions = res.list;
        },
        // 权限判断:复用工具函数
        hasPerm(perm) {
          return hasPermission(this.permissions, perm);
        }
      },
      persist: true // 持久化状态(适配两端存储)
    });
    

4. 业务逻辑层(services):封装复杂业务流程

核心目标

将跨组件的复杂业务流程(如表单提交、数据导出、批量操作)抽离为 service 方法,两端复用业务逻辑,仅调用平台特有 UI 交互(如弹窗、加载提示)。

实现方案

  • 目录结构:按业务模块分类,每个 service 方法接收 “平台适配回调” 处理 UI 差异:

    plaintext

    services/
    ├─ userService.js:用户相关(密码重置、信息修改)
    ├─ orderService.js:订单相关(批量审核、导出订单)
    └─ formService.js:表单相关(复杂表单提交、数据校验)
    
  • 代码示例(订单批量审核,适配两端 UI):

    // services/orderService.js
    import { useOrderStore } from '@/store/modules/orderStore';
    import { batchAuditOrder } from '@/api/modules/order';
    
    /**
     * 批量审核订单
     * @param {Array} orderIds - 订单ID列表
     * @param {Function} loadingCallback - 平台加载提示(如 Vue 的 ElLoading、uni 的 showLoading)
     * @param {Function} successCallback - 平台成功提示(如 ElMessage、uni.showToast)
     */
    export const batchAuditOrderService = async (orderIds, loadingCallback, successCallback) => {
      const orderStore = useOrderStore();
      // 1. 调用平台加载提示(通过回调适配)
      const closeLoading = loadingCallback();
      
      try {
        // 2. 业务逻辑:调用 API + 更新状态(两端复用)
        await batchAuditOrder({ ids: orderIds });
        await orderStore.getOrderListAction(); // 重新获取订单列表
        // 3. 调用平台成功提示(通过回调适配)
        successCallback('审核成功');
      } catch (err) {
        throw err; // 抛错让调用方处理(如统一错误提示)
      } finally {
        closeLoading(); // 关闭加载提示
      }
    };
    
  • 两端调用示例

    // Vue PC 端调用
    import { batchAuditOrderService } from '@/services/orderService';
    import { ElLoading, ElMessage } from 'element-plus';
    
    const handleBatchAudit = async () => {
      await batchAuditOrderService(
        selectedIds,
        () => ElLoading.service({ text: '审核中...' }), // PC 加载提示
        (msg) => ElMessage.success(msg) // PC 成功提示
      );
    };
    
    // uni-app 移动端调用
    import { batchAuditOrderService } from '@/services/orderService';
    
    const handleBatchAudit = async () => {
      await batchAuditOrderService(
        selectedIds,
        () => { // 移动端加载提示
          uni.showLoading({ title: '审核中...' });
          return () => uni.hideLoading(); // 返回关闭方法
        },
        (msg) => uni.showToast({ title: msg, icon: 'success' }) // 移动端成功提示
      );
    };
    

5. 视图层:差异化实现,复用公共组件

核心目标

UI 组件和页面因平台交互差异(PC 用鼠标 / 键盘,移动端用触摸)需单独实现,但可抽离 “无交互纯展示组件”(如数据卡片、空状态)两端复用。

实现方案

  • 公共 UI 组件:在 components/common 目录创建纯展示组件(无平台依赖),如:

    components/
    ├─ common/:两端复用组件
    │  ├─ EmptyState.vue:空状态(无数据提示)
    │  ├─ DataCard.vue:数据卡片(展示统计数据)
    │  └─ TablePagination.vue:分页控件(适配两端表格)
    ├─ web/:Vue PC 特有组件(如 ElTable 封装)
    └─ uni/:uni-app 特有组件(如 uni-table 封装)
    
  • 页面差异化:两端页面目录分开,但复用公共业务逻辑:

    // Vue PC 端页面
    views/web/order/OrderList.vue:用 Element Plus 组件,调用 orderService
    // uni-app 移动端页面
    pages/uni/order/OrderList.vue:用 uni-app 组件,调用 same orderService
    

三、工程化保障:统一规范,降低维护成本

1. 环境配置统一

  • 用 .env 文件统一管理环境变量(API 地址、环境标识),两端共享变量名:

    // Vue PC 端 .env.development
    VITE_API_BASE_URL = 'https://pc-api.xxx.com'
    VITE_APP_PLATFORM = 'web'
    
    // uni-app 端 .env.development
    VITE_API_BASE_URL = 'https://mobile-api.xxx.com'
    VITE_APP_PLATFORM = 'uni'
    

2. 代码规范统一

  • 用 ESLint + Prettier 统一代码风格,两端共用配置文件(.eslintrc.js.prettierrc);
  • 用 Husky + lint-staged 做提交校验,避免不规范代码提交。

3. 构建部署统一

  • Vue PC 端:用 Vite 构建,部署到 Web 服务器(如 Nginx);
  • uni-app 端:用 HBuilderX 或 CLI 构建,发布为微信小程序 / APP;
  • 公共层(utils/api/store)可打包为 npm 私有包,两端通过 npm 安装,避免代码复制。
❌
❌