普通视图

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

React Hooks 的“天条”:为啥绝对不能写在 if 语句里?

2025年12月25日 22:54

在折腾 React 的时候,你肯定被这个报错恶心过:

Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

或者 ESLint 那个经典的黄牌警告:

React Hook "useState" is called conditionally...

这大概是 React 开发里最出名的“天条”了:Hooks 必须在最顶层调用,别往条件判断、循环或者嵌套函数里塞。

很多老铁可能只是习惯性遵守,但心里估计在嘀咕:React 为啥这么“轴”?多加个判断怎么就崩了?它不能智能点吗?

今天咱们就拆开来看看,React 内部到底是咋玩这套“排队游戏”的。

核心真相:React 其实是个“大脸盲”

理解这事的关键就在于:React 压根不知道你定义的 Hook 叫什么名字。

在你代码里它是 [count, setCount],但在 React 眼里,它只认序号:

  1. 哦,这是第 1 个 Hook。
  2. 哦,这是第 2 个 Hook。
  3. 哦,这是第 3 个 Hook。 ...

它完全是靠调用顺序来给状态“对号入座”的。

你可以把 React 管理 Hook 的方式想象成一个没有标签的储物柜

第一次渲染,React 在心里默默记账:

[1号柜子] -> 给第一个 useState 存个 0
[2号柜子] -> 给第一个 useEffect 存个副作用配置
[3号柜子] -> 给第二个 useState 存个 'Hello'

每次组件重新渲染,React 就像个“盲盒玩家”,按顺序开柜子:

  • 碰到代码里第 1 个 hook,它就去开 1 号柜子拿数据。
  • 碰到代码里第 2 个 hook,它就去开 2 号柜子拿数据。

翻车现场:当 if 搞乱了队伍

假设我们写了段挺“合理”的逻辑:如果是登录状态,就记个名字;不然就只记个计数器。

翻车代码长这样:

function BadComponent({ isLoggedIn }) {
  // 🔴 危险动作!把 Hook 塞 if 里面了
  if (isLoggedIn) {
    const [name, setName] = useState('Alice'); // 咱本意是想存个名字
  }

  const [count, setCount] = useState(0); // 咱本意是想存个数字

  return <div>{count}</div>;
}

第一次:登录状态(isLoggedIn = true)

一切看起来很丝滑,React 默默排好了队:

执行顺序 你代码里的 Hook React 开的柜子 存的数据
第 1 个 useState('Alice') 1号柜 'Alice' (字符串)
第 2 个 useState(0) 2号柜 0 (数字)

第二次:退出了(isLoggedIn = false)

用户一退出,if 里的代码直接被跳过了。

这时候,第一个被执行的 Hook 变成了 useState(0)

React 的脑回路: “好嘞,碰到第 1 个 Hook 了。不管你代码里叫它 count 还是啥,我直接去开1号柜取东西。”

结果就尴尬了:

执行顺序 你代码里的 Hook React 开的柜子 拿到的数据 结果
第 1 个 useState(0) 1号柜 'Alice' 💥 炸了!

原本该拿 0count 变量,反手抓到了一个字符串 'Alice'。整个组件逻辑瞬间乱套,报错直接甩你脸上。

这就是为啥顺序绝对不能乱。只要中间少了一个或多了一个,后面的所有 Hook 统统都会“串位”。

为啥 React 不给 Hook 起个名字(Key)?

肯定有人想过:React 既然“脸盲”,那给 Hook 加个 ID 不就结了?

// ❌ 这种 API 纯属假想
const [count, setCount] = useState('myCountId', 0);

有了 ID,顺序乱了也能找着啊!

React 团队当时确实琢磨过这招,但最后觉得太心累了

  1. 起名困难症:组件大了之后,你得给几十个 Hook 起唯一的名字,还得防着重名。
  2. 自定义 Hook 难搞:你要是写个自定义 Hook,里面的 key 怎么保证不跟别人的冲突?
  3. 代码太丑:到处都是字符串 ID,写起来一点都不简洁。

所以 React 选了**“约定优于配置”**。它跟你达成一个默契:只要你保证不乱排队,它就能给你提供最干净的 API。

那正确姿势是啥?

既然不能在 if 里写 Hook,碰到需要判断的情况咋办?

其实很简单:Hook 照样跑,逻辑往里挪。

1. 把判断往外移(最推荐)

Hook 永远在顶层乖乖排队,只有展示的时候才去判断。

function GoodComponent({ isLoggedIn }) {
  // ✅ 大家都出来排队,谁也别缺席
  const [name, setName] = useState('Alice'); 
  const [count, setCount] = useState(0);

  // ✅ 在 return 的时候再看要不要显摆
  return (
    <div>
      {isLoggedIn && <span>{name}</span>}
      <span>{count}</span>
    </div>
  );
}

2. 判断写在 Effect 里面

useEffect(() => {
  // ✅ Hook 本身是稳定执行的,只是里面的逻辑可以按需触发
  if (isLoggedIn) {
    console.log('偷偷干点活');
  }
}, [isLoggedIn]); 

最后一句话总结

React 的 Hooks 就像一群排队领盒饭的小朋友,必须按顺序站好。React 是闭着眼睛发饭的,谁要是敢插队或者中间溜了,后面的人领到的就不是鸡腿而是炸弹了。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

vibe coding 原理学习

  • qwen-cli 学习网站 - 学习 qwen-cli 时整理的笔记,40+ 交互式动画演示 AI CLI 内部机制

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

鸿蒙ArkUI如何使用RGB、十六进制等设置颜色值?

作者 90后晨仔
2025年12月25日 21:02

1. 概述

在鸿蒙ArkUI中,颜色值是UI设计的基础元素之一。ArkUI支持多种颜色表示方式,包括关键字颜色、RGB/RGBA、十六进制等。本文将详细介绍这些颜色值的使用方法,特别是透明度的设置技巧。

2. 颜色值的基本表示方法

2.1 关键字颜色

ArkUI支持常见的CSS颜色关键字:

// 在ArkUI组件中使用
Text('红色文本')
  .fontColor(Color.Red)  // 使用Color枚举
  .fontColor('red')      // 或直接使用字符串

// 常用颜色关键字
'red', 'blue', 'green', 'black', 'white', 
'gray', 'yellow', 'orange', 'purple', 'pink'

2.2 RGB颜色表示

RGB颜色通过红、绿、蓝三原色的混合来定义颜色:

基本RGB(不透明)

// 格式:rgb(红, 绿, 蓝)
// 每个参数取值范围:0-255

Text('RGB颜色示例')
  .fontColor('rgb(255, 0, 0)')      // 红色
  .backgroundColor('rgb(0, 255, 0)') // 绿色背景
  .fontColor('rgb(0, 0, 255)')      // 蓝色

RGBA(带透明度)

// 格式:rgba(红, 绿, 蓝, 透明度)
// 透明度alpha取值范围:0.0-1.0

Text('RGBA带透明度')
  .fontColor('rgba(255, 0, 0, 0.5)')     // 半透明红色
  .backgroundColor('rgba(0, 255, 0, 0.3)') // 30%不透明度绿色背景

2.3 十六进制颜色表示

6位十六进制(不透明)

// 格式:#RRGGBB
// RR: 红色分量(00-FF)
// GG: 绿色分量(00-FF)
// BB: 蓝色分量(00-FF)

Text('十六进制颜色')
  .fontColor('#FF0000')    // 红色
  .fontColor('#00FF00')    // 绿色
  .fontColor('#0000FF')    // 蓝色
  .fontColor('#FFA500')    // 橙色

8位十六进制(带透明度)

// 格式:#AARRGGBB 或 #ARGB
// AA: 透明度分量(00-FF)
// 00表示完全透明,FF表示完全不透明

Text('带透明度的十六进制颜色')
  .fontColor('#80FF0000')    // 50%透明度的红色
  .fontColor('#4000FF00')    // 25%透明度的绿色
  .backgroundColor('#200000FF') // 12.5%透明度的蓝色背景

// 简写格式(4位)#ARGB
// 会被扩展为#AARRGGBB
Text('简写格式')
  .fontColor('#8F00')  // 相当于#8800FF00

3. 透明度设置的多种方式

3.1 使用RGBA的Alpha通道

// 最常用的透明度设置方式
@Component
struct TransparencyExample {
  build() {
    Column() {
      // 不同透明度级别
      Text('100% 不透明').fontColor('rgba(255, 0, 0, 1.0)')
      Text('75% 不透明').fontColor('rgba(255, 0, 0, 0.75)')
      Text('50% 不透明').fontColor('rgba(255, 0, 0, 0.5)')
      Text('25% 不透明').fontColor('rgba(255, 0, 0, 0.25)')
      Text('完全透明').fontColor('rgba(255, 0, 0, 0.0)')
    }
  }
}

3.2 使用8位十六进制

// 透明度计算:0x00-0xFF 对应 0%-100%
// 透明度百分比 = (AA / 255) * 100%

@Component
struct HexTransparencyExample {
  build() {
    Column() {
      // 十六进制透明度示例
      Text('FF = 100%').fontColor('#FFFF0000')
      Text('BF = 75%').fontColor('#BFFF0000')
      Text('80 = 50%').fontColor('#80FF0000')
      Text('40 = 25%').fontColor('#40FF0000')
      Text('00 = 0%').fontColor('#00FF0000')
    }
  }
}

3.3 使用Color类的静态方法

// ArkUI提供了Color类的静态方法
@Component
struct ColorClassExample {
  build() {
    Column() {
      // 使用Color类创建带透明度的颜色
      Text('Color.Red透明度')
        .fontColor(Color.Red)
        .opacity(0.5)  // 设置整个组件的透明度
      
      // Color.argb方法
      Text('Color.argb示例')
        .fontColor(Color.argb(128, 255, 0, 0)) // 50%透明红色
      
      // Color.rgb方法
      Text('Color.rgb示例')
        .fontColor(Color.rgb(255, 0, 0))
    }
  }
}

4. 颜色资源的定义与使用

4.1 在资源文件中定义颜色

resources/base/element/color.json 中:

{
  "color": [
    {
      "name": "primary_color",
      "value": "#FF6200"
    },
    {
      "name": "primary_color_transparent",
      "value": "#806200"
    },
    {
      "name": "text_color",
      "value": "rgba(0, 0, 0, 0.87)"
    },
    {
      "name": "background_transparent",
      "value": "rgba(255, 255, 255, 0.8)"
    }
  ]
}

4.2 在ArkUI中使用颜色资源

@Component
struct ColorResourceExample {
  build() {
    Column() {
      Text('使用颜色资源')
        .fontColor($r('app.color.primary_color'))
        .backgroundColor($r('app.color.background_transparent'))
      
      Text('带透明度的资源颜色')
        .fontColor($r('app.color.primary_color_transparent'))
    }
    .padding(20)
  }
}

5. 实际应用示例

5.1 渐变背景与透明度

@Component
struct GradientExample {
  @State opacityValue: number = 0.5
  
  build() {
    Column() {
      // 线性渐变背景
      Text('渐变背景示例')
        .fontSize(20)
        .fontColor('#FFFFFF')
        .width('100%')
        .height(100)
        .backgroundImage(
          `linear-gradient(
            rgba(255, 0, 0, ${this.opacityValue}), 
            rgba(0, 0, 255, ${this.opacityValue})
          )`
        )
      
      // 动态调整透明度
      Slider({
        value: this.opacityValue,
        min: 0,
        max: 1,
        step: 0.1
      })
      .onChange((value: number) => {
        this.opacityValue = value
      })
      .margin({ top: 20 })
    }
    .padding(20)
  }
}

5.2 卡片阴影与背景透明度

@Component
struct CardExample {
  build() {
    Column() {
      // 半透明卡片
      Column() {
        Text('透明卡片标题')
          .fontSize(18)
          .fontColor('#333333')
        
        Text('卡片内容描述')
          .fontSize(14)
          .fontColor('rgba(51, 51, 51, 0.7)')
          .margin({ top: 10 })
      }
      .padding(20)
      .width('90%')
      .backgroundColor('rgba(255, 255, 255, 0.9)')  // 90%不透明度白色
      .borderRadius(10)
      .shadow({
        radius: 10,
        color: 'rgba(0, 0, 0, 0.1)',  // 10%不透明度黑色阴影
        offsetX: 0,
        offsetY: 2
      })
    }
    .width('100%')
    .height('100%')
    .backgroundImage('/common/background.png')
  }
}

5.3 主题色与透明度结合

// 定义主题
const Theme = {
  primary: '#FF6200',
  primaryLight: 'rgba(255, 98, 0, 0.2)',
  primarySemi: 'rgba(255, 98, 0, 0.6)',
  textPrimary: 'rgba(0, 0, 0, 0.87)',
  textSecondary: 'rgba(0, 0, 0, 0.6)'
}

@Component
struct ThemeExample {
  build() {
    Column({ space: 20 }) {
      // 主要按钮
      Button('主要按钮', { type: ButtonType.Capsule })
        .backgroundColor(Theme.primary)
        .fontColor('#FFFFFF')
        .width('80%')
      
      // 次要按钮(带透明度)
      Button('次要按钮', { type: ButtonType.Capsule })
        .backgroundColor(Theme.primaryLight)
        .fontColor(Theme.primary)
        .width('80%')
      
      // 文本示例
      Text('主要文本')
        .fontColor(Theme.textPrimary)
        .fontSize(16)
      
      Text('次要文本')
        .fontColor(Theme.textSecondary)
        .fontSize(14)
    }
    .padding(20)
    .width('100%')
  }
}

6. 最佳实践与注意事项

6.1 性能优化建议

// 1. 复用颜色常量
const Colors = {
  transparent: 'rgba(0, 0, 0, 0)',
  white: '#FFFFFF',
  black: '#000000',
  gray100: 'rgba(0, 0, 0, 0.02)',
  gray200: 'rgba(0, 0, 0, 0.05)',
  // ... 更多颜色
}

// 2. 避免频繁创建颜色对象
@Component
struct OptimizedExample {
  // 在build外定义颜色
  private readonly highlightColor = 'rgba(255, 98, 0, 0.1)'
  
  build() {
    Column() {
      Text('优化示例')
        .backgroundColor(this.highlightColor)  // 复用颜色
    }
  }
}

6.2 可访问性考虑

@Component
struct AccessibilityExample {
  build() {
    Column() {
      // 确保文本与背景有足够的对比度
      Text('高对比度文本')
        .fontColor('rgba(0, 0, 0, 0.87)')  // 87%不透明度黑色
        .backgroundColor('rgba(255, 255, 255, 0.95)')  // 95%不透明度白色
      
      // 半透明背景上的文字
      Text('半透明背景文字')
        .fontColor('#000000')
        .backgroundColor('rgba(255, 255, 255, 0.7)')  // 足够的对比度
        .padding(10)
    }
  }
}

6.3 响应式设计中的颜色使用

@Component
struct ResponsiveExample {
  @StorageLink('darkMode') isDarkMode: boolean = false
  
  build() {
    Column() {
      Text('响应式颜色示例')
        .fontColor(this.isDarkMode ? 
          'rgba(255, 255, 255, 0.87)' :  // 暗色模式
          'rgba(0, 0, 0, 0.87)'          // 亮色模式
        )
        .backgroundColor(this.isDarkMode ?
          'rgba(0, 0, 0, 0.8)' :         // 暗色背景
          'rgba(255, 255, 255, 0.9)'     // 亮色背景
        )
    }
  }
}

7. 调试技巧

7.1 颜色调试组件

@Component
struct ColorDebugger {
  @State colors: Array<{ name: string, value: string }> = [
    { name: 'Primary', value: '#FF6200' },
    { name: 'Primary 50%', value: '#80FF6200' },
    { name: 'Gray', value: 'rgba(0, 0, 0, 0.12)' },
    { name: 'White Transparent', value: 'rgba(255, 255, 255, 0.8)' }
  ]
  
  build() {
    Column({ space: 10 }) {
      ForEach(this.colors, (color) => {
        Row({ space: 10 }) {
          // 颜色预览
          Rect()
            .width(40)
            .height(40)
            .fill(color.value)
          
          // 颜色信息
          Column() {
            Text(color.name)
              .fontSize(14)
            Text(color.value)
              .fontSize(12)
              .fontColor('#666666')
          }
        }
        .width('100%')
        .padding(10)
        .border({ width: 1, color: 'rgba(0, 0, 0, 0.1)' })
      })
    }
    .padding(20)
  }
}

总结

ArkUI提供了丰富灵活的颜色表示方式,开发者可以根据具体场景选择合适的方法:

  1. 简单场景:使用关键字或6位十六进制
  2. 需要透明度:优先使用RGBA格式,直观易读
  3. 性能敏感:预定义颜色常量,避免重复创建
  4. 设计系统:使用资源文件管理颜色,便于维护和主题切换
  5. 可访问性:确保颜色对比度符合WCAG标准

通过合理使用颜色和透明度,可以创建出既美观又具有良好用户体验的鸿蒙应用界面。

C# 正则表达式(3):分组与捕获——从子串提取到命名分组

作者 烛阴
2025年12月25日 20:07

一、分组是什么:让一段模式成为“一个单元”

1. 普通分组:(...)

分组最直接的作用是“打包”,使用()完成打包,一个括号就是一个分组。例如你想匹配 ab 重复多次:

  • 错误:ab+ 只会让 b 重复
  • 正确:(ab)+ab 作为整体重复

2. 分组 + 量词:控制重复结构

示例:匹配 2025-12-18 这种日期结构

(\d{4})-(\d{2})-(\d{2})

解析:

  • 这里有 3 个分组:年、月、日。它们不仅能帮助你“读懂结构”,更重要的是能在代码里提取出来。

二、捕获组:Match.Groups 到底是什么?

示例:

using System;
using System.Text.RegularExpressions;

var input = "2025-12-18";
var pattern = @"^(\d{4})-(\d{2})-(\d{2})$";

Match match = Regex.Match(input, pattern);
if (!match.Success)
{
    Console.WriteLine("No match");
    return;
}

Console.WriteLine($"Full: {match.Groups[0].Value}");
Console.WriteLine($"Year: {match.Groups[1].Value}");
Console.WriteLine($"Month:{match.Groups[2].Value}");
Console.WriteLine($"Day:  {match.Groups[3].Value}");

解析:

  • Groups[0] 永远是“整个匹配到的字符串”
  • Groups[1] 开始才是你写的第 1、2、3… 个捕获组

三、命名分组:让提取更稳、更好维护

当分组多了以后,Groups[7] 这种写法非常容易错。命名分组就是解决这个问题的。

语法:

(?<name>...)

把上面的日期改成命名分组:

var input = "2025-12-18";
var input = "2025-12-18";
var pattern = @"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$";

Match match = Regex.Match(input, pattern);
Console.WriteLine(match.Groups["year"].Value);  // 2025
Console.WriteLine(match.Groups["month"].Value); // 12
Console.WriteLine(match.Groups["day"].Value);   // 18

// 依然可以用序号访问到目标内容
Console.WriteLine($"Full: {match.Groups[0].Value}");
Console.WriteLine($"Year: {match.Groups[1].Value}");
Console.WriteLine($"Month:{match.Groups[2].Value}");
Console.WriteLine($"Day:  {match.Groups[3].Value}");

四、Regex.Replace 的“重排”:11 与 {name}

1. 按序号引用:$1$2

序号从1开始

var input = "2025-12-18";
var pattern = @"^(\d{4})-(\d{2})-(\d{2})$";

var output = Regex.Replace(input, pattern, "$3/$2/$1");
Console.WriteLine(output); // 18/12/2025

2. 按名称引用:${year}${month}

var input = "2025-12-18";
var pattern = @"^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$";

var output = Regex.Replace(input, pattern, "${day}/${month}/${year}");
Console.WriteLine(output); // 18/12/2025

五、非捕获分组:(?:...)

当我们想匹配整体,并且分组内容不存入 Groups 集合。此时用非捕获分组:

(?:...)

示例:

string input = "I love C# and I love Java";

// 使用非捕获分组 (?:C#|Java)
// 它只是为了让 | (或) 逻辑生效,而不建立单独的提取组
string pattern = @"I love (?:C#|Java)";

MatchCollection matches = Regex.Matches(input, pattern);

foreach (Match m in matches)
{
    Console.WriteLine($"完全匹配内容: {m.Value}");
    Console.WriteLine($"分组数量: {m.Groups.Count}"); // 索引 0 永远是完整匹配,并且只有一个,没有为C#|Java单独分组
}
/*输出
完全匹配内容: I love C#
分组数量: 1
完全匹配内容: I love Java
分组数量: 1
*/

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

从一则内存快照看iframe泄漏:活跃与Detached状态的回收差异

作者 eason_fan
2025年12月25日 19:02

从一则内存快照看iframe泄漏:活跃与Detached状态的回收差异

内存泄漏是前端性能优化中的隐蔽痛点。近期项目排查中,通过Chrome DevTools内存快照定位到典型iframe泄漏问题:iframe移除后内部window对象未释放,导致内存持续堆积。本文从该案例切入,拆解泄漏根源,深入剖析活跃iframe与Detached iframe的内存回收差异,最终给出可落地的解决方案。

一、案例还原:内存快照中的泄漏真相

项目中存在一个“动态加载-移除iframe”的场景:点击按钮加载iframe展示内容,关闭弹窗时移除iframe。但随着操作次数增加,页面内存占用持续上升,最终导致页面卡顿。通过Chrome DevTools的Memory面板拍摄快照,发现了关键异常。

1. 快照核心发现:Detached Window的“顽固存在”

快照中出现了多个Detached Window对象(保留大小均超过50kB),且每个对象都关联着Detached HTMLDocument、DOM元素(如、自定义组件)和未销毁的事件监听(resize、touchend等)。

这里的Detached Window,正是被移除后仍滞留在内存中的iframe内部window对象——它已脱离文档流,但内存未被释放,是本次泄漏的核心对象。

2. 泄漏引用链路:外部引用+内部闭环的“双重锁死”

image.png 通过快照的“保留器链”(Retainers)功能,梳理出完整的泄漏链路(注意:链路方向并非“外部→内部”,而是外部引用锚定内部对象后,内部闭环加固引用):

image.png

Detached Window(iframe内部window)
↓ 被外部引用链锚定
global_proxy_object(iframe window的全局代理对象)
↓ 浏览器内置Symbol属性(如Symbol(unscopables))关联
Detached HTMLDocument(iframe的document)
↓ 关联iframe内部DOM元素
↓ 元素绑定未销毁的事件监听(形成闭包)
↓ 最终锁死整个对象链

核心逻辑:外部代码通过全局代理对象锚定Detached Window,而其内部文档、DOM、事件形成闭环,导致垃圾回收器(GC)无法回收任一关联对象,最终造成泄漏。

3. 泄漏核心原因:外部引用未断+内部资源未清

结合代码排查,定位两个关键问题:

  • 外部引用未清空:父页面通过const iframeWin = iframe.contentWindow保存iframe内部window引用,移除iframe时未置空该变量;
  • 内部资源未清理:iframe内部通过addEventListener绑定的resize、touchend等事件,移除前未通过removeEventListener销毁,形成闭包引用。

二、深入理解:从反直觉疑问切入,解析两种iframe的回收差异

排查过程中易产生反直觉疑问:若不清理外部引用,仅斩断Detached Window内部引用链(如断开window与文档、事件的关联),被斩断的内部资源会被回收吗?

答案是否定的:只要外部对Detached Window的引用未断,即便内部引用链被拆碎,所有内部资源仍会被“锁死”在内存中。这一结论的核心是活跃iframe与Detached iframe的执行上下文本质不同,可用通俗类比理解:

• 活跃iframe = 有人居住的正常房子:内部杂物(对象)无人使用(无引用)时,会被主人(内部GC)主动清理; • Detached iframe = 被外部绳子拴住的孤立房子:即便拆碎内部杂物(斩断内部引用链),只要绳子未断(外部引用未清),房子及内部所有物品均不会被清运(GC回收)——绳子证明“该资源仍被关联”。

1. 先明确前提:现代浏览器GC的“可达性分析”核心规则

这一反直觉结论的根源,是现代浏览器(Chrome、Node.js等)GC核心为“可达性分析”,而非老旧的“引用计数”,核心逻辑可概括为:

  • 从根对象(父页面window、全局变量、活跃函数调用栈等)出发,可触达的对象标记为“存活”,不会被回收;
  • 完全无法从根对象触达的对象,无论内部是否有闭环,均标记为“死亡”并回收。

核心结论:GC判断“是否回收”的唯一标准是“是否被根对象触达”,而非“内部是否有引用”。这是区分两种iframe回收差异的核心依据。

2. 活跃iframe:内部GC正常工作,无引用对象会被回收

活跃iframe指“仍存在于文档流中(未被remove)”的iframe,其window是浏览器认可的“有效执行上下文”——类比“有人居住的正常房子”,内部会独立运行GC线程(主人),主动清理无用杂物(无引用对象)。

即便父页面通过iframe.contentWindow保留引用(类比外部拴绳),也不影响内部GC工作:绳子仅代表“外部关注”,不干扰主人清理内部无用物品。

实例验证:在活跃iframe内部创建100M大对象,断开引用后触发GC,内存会正常回收:

// 活跃iframe内部代码
function createBigObj() {
  // 创建100M大对象
  return new Array(1024 * 1024 * 100).fill(0);
}

let bigObj = createBigObj(); // 内存占用上升
bigObj = null; // 断开引用
// 触发GC后,100M内存被回收,内存占用下降

核心原因:活跃iframe的内部GC线程独立运行,只要内部对象无存活引用,无论父页面是否保留iframe引用,均会被主动回收,内存不会无限堆积。

3. Detached iframe:内部GC停止,再零散的资源也不会回收

Detached iframe指“已被remove(脱离文档流)但父页面仍保留其window引用”的iframe——类比“被外部绳子拴住的孤立房子”,此时会发生两个关键变化:

  • 内部GC线程停止:浏览器判定其为“废弃上下文”,不再执行内部资源清理;
  • 外部引用锚定存活:父页面的引用(绳子)让Detached Window被根对象触达,GC判定“该对象链仍在被关联”。

即便斩断内部引用链(拆碎杂物),只要外部绳子未断,这些零散资源仍会被标记为“存活”——因它们属于“根可达对象关联的资源”,GC会一并保留。

实例验证:移除iframe后保留外部引用,再断开内部大对象引用:

// 父页面代码
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeWin = iframe.contentWindow; // 保留外部引用
iframe.remove(); // iframe变为Detached状态

// Detached iframe内部代码
let bigObj = createBigObj(); // 内存占用上升
bigObj = null; // 断开内部引用
// 触发GC后,100M内存仍未回收,内存持续占用

结果:100M内存仍未回收。核心原因:Detached Window被外部引用锚定,其内部所有资源均被连带标记为“存活”,直至外部引用断开(剪断绳子)。

最终表现:Detached iframe的内存只会持续堆积,直至页面刷新,具体包括:

4. 两种状态核心差异对比(结合类比)

对比维度 活跃iframe(未移除) Detached iframe(已移除+外部引用未断)
执行上下文 有效,内部GC正常运行 僵尸状态,内部GC停止
内存回收规则 无引用对象正常回收,内存有增有减 所有内部对象均无法回收,内存只增不减
根可达性 可触达,但内部GC独立工作 可触达,且全局GC无法回收
常见场景 页面固定iframe、动态加载未关闭的iframe 动态移除但未清外部引用的iframe

三、解决方案:从根源避免iframe内存泄漏

结合前文分析,iframe泄漏的核心是“Detached Window被外部引用锚定+内部资源未清理”。解决方案核心为“断开外部引用+清理内部资源”,具体分两步实施:

1. 必要操作:断开父页面对iframe的所有外部引用

这是回收Detached Window的唯一必要条件:只要断开外部引用,即便内部存在少量未清理闭环,全局GC也会将其识别为“不可触达孤立链”并回收。

具体代码:

// 父页面:移除iframe的完整流程
function removeIframe(iframe) {
  // 1. 拿到iframe内部window(若之前保存过)
  const iframeWin = iframe.contentWindow;
  
  // 2. 断开父页面所有相关引用(关键步骤)
  iframeWin = null; // 清空保存的window引用
  iframe = null; // 清空iframe元素引用
  
  // 3. 移除iframe元素
  document.body.removeChild(iframe);
}

// 触发GC(可选,可通过DevTools手动触发)
performance.memory;

2. 可选但推荐:清理iframe内部资源

清理内部资源是保险项,可避免因外部引用未清干净导致的二次泄漏。核心清理范围包括:事件监听、定时器、全局变量、闭包引用等。

推荐实现方式:iframe内部暴露清理方法,由父页面在移除前调用,具体代码:

// iframe内部代码:暴露清理方法
window.cleanup = function() {
  // 1. 移除事件监听
  window.removeEventListener('resize', handleResize);
  window.removeEventListener('touchend', handleTouchEnd);
  
  // 2. 清除定时器/计时器
  clearInterval(timer);
  clearTimeout(timeout);
  
  // 3. 清空全局变量/闭包引用
  window.globalData = null;
  window.bigObj = null;
  
  // 4. 清理自定义组件/框架资源(如Vue/React实例)
  if (app) {
    app.unmount(); // Vue实例卸载
  }
};

// 父页面:移除前调用内部清理方法
function removeIframe(iframe) {
  const iframeWin = iframe.contentWindow;
  // 调用内部清理方法
  if (iframeWin.cleanup) {
    iframeWin.cleanup();
  }
  
  // 后续步骤:断开外部引用、移除元素(同前)
  iframeWin = null;
  iframe = null;
  document.body.removeChild(iframe);
}

3. 验证方法:确认泄漏已解决

可通过Chrome DevTools验证泄漏是否解决,步骤如下:

  1. 加载并多次移除iframe;
  2. 拍摄内存快照,搜索Detached Window
  3. 若快照中无Detached Window,且内存占用稳定(多次操作后无明显上升),则说明泄漏已解决。

四、总结

本次iframe内存泄漏案例,本质是对“Detached Window根可达性”及“iframe不同状态回收规则”理解不足。核心结论可浓缩为三点:

  • 现代浏览器GC只看“根可达性”,不看引用计数;
  • 活跃iframe的内部GC正常工作,内存不会无限增加;
  • Detached iframe泄漏的唯一必要条件是“外部引用未断”,解决核心是“断开外部引用+清理内部资源”。

实际开发中,只需遵循“动态移除iframe必清外部引用”原则,并配合内部资源清理,即可从根源避免这类泄漏。希望本文能帮助开发者清晰理解iframe内存机制,为前端性能优化提供有效指引。

前端一行代码生成数千页PDF,dompdf.js新增分页功能| 掘金一周 12.25

作者 掘金一周
2025年12月25日 17:44

本文字数1800+ ,阅读时间大约需要 4分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

前端一行代码生成数千页PDF,dompdf.js新增分页功能 @刘发财

前端生成 PDF 不清晰?文字无法搜索选中编辑?体积太大?分页切割不精准?生成页数太少?dompdf.jsV1.1.0 版本更新后,这些都不在是问题,只需要一行代码,就可以将 html 页面生成数千页 PDF 文件,这可能是前端首个实现这一功能的 js 库。

别再让 JavaScript 抢 CSS 的活儿了,css原生虚拟化来了 @Moment

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

从原理到落地:大屏适配适配 + 高并发弹幕的企业级技术手册 @洞窝技术

随着数字化转型的加速,数据可视化大屏与实时消息场景迎来了爆发式增长。从电商监控到直播互动,再到政务指挥,大屏应用无处不在。

后端

基于Nacos的轻量任务调度方案 —— 从 XXL-Job 的痛点说起 -@踏浪无痕

于是我们在思考:在云原生时代,中间件应该是独立的"平台",还是内嵌的"能力模块"?这就是 JobFlow 这个想法的由来。

Java 设计模式:原理、框架应用与实战全解析|得物技术@得物技术

设计模式(Design Pattern)是前辈们对代码开发经验的总结,它不是语法规定,是解决特定问题的一系列思想,是面向对象设计原则的具象化实现, 是解决 “需求变更” 与 “系统复杂度” 矛盾的标准化方案 —— 并非孤立的 “代码模板”,而是 “高内聚、低耦合” 思想的落地工具。其核心价值在于提升代码的可复用性、可维护性、可读性、稳健性及安全性。

Android

Android 宣布 Runtime 编译速度史诗级提升:在编译时间上优化了 18%@恋猫de小郭

近期,Android 官方宣布了 Android Runtime 在编译时间上实现了 18% 的显著优化,同时不牺牲编译代码的质量,也没有增加峰值内存使用,换句话说,这属于是一个“速度提升 + 零损失”的优化成果。

OpenAI :你不需要跨平台框架,只需要在 Android 和 iOS 上使用 Codex@恋猫de小郭

在这个过程里,团队可以将将 Codex 看作是一名“高能力但缺乏背景的资深新员工”,所以开发者负责架构设计、用户体验和最终决策,而 Codex 负责写代码、单元测试和跨平台逻辑转换。

Android15适配之世上本无坑,targetSdkVersion升到35后全是坑@Coffeeee

自从2024年初时候,谷歌发布了第一个Android15的预览版,我就一直在关注着这个版本的走向,为什么呢?

人工智能

RAG实战|8种RAG架构浅析@周末程序猿

因为项目的需要,之前研究了一段时间的RAG,于是本文总结 8 种 RAG 架构,对每种架构进行简要介绍,并用 langchain 实现其参考代码。

别搞混了!MCP 和 Agent Skill 到底有什么区别? @也无风雨也雾晴

它们看起来都是"扩展 AI 能力"的方式,但具体有什么区别?为什么需要两套机制?什么时候该用哪个? 这篇文章会从设计哲学、技术架构、使用场景三个维度,把这两个概念彻底讲清楚。

从千问灵光 App 看生成式 UI 技术的发展 @OpenTiny社区

在新的范式下,应用不再是预先固化的静态资产,而是根据用户自然语言意图实时生成。闪应用所展现的数十秒构建能力,是生成式 UI 将界面从预先设计转变为即时生成的体现,它让应用“按需生成、用后即弃”。

LangGraph1.0速通指南(一)—— LangGraph1.0 核心概念、点、边 @大模型真好玩

从本期开始笔者将逐步介绍 LangGraph 1.0 的这些核心特性,并最终使用 LangGraph 搭建一个 邮件自动回复工具流。本期先从基础入手,讲解 LangGraph 1.0 的核心概念,重点解析   “点”与“边”   的设计与使用。

IOS

Flutter 官方正式解决 WebView 在 iOS 26 上有点击问题 @恋猫de小郭

上个月和大家聊到了 《为什么你的 Flutter WebView 在 iOS 26 上有点击问题?》 ,源头是因为 WKWebView(WebKit)内部的手势识别器与 Flutter 在 Engine 里用于“阻止/延迟”手势的 recognizer 之间的冲突,因为 Flutter 和 UIKit 都各自有手势识别系统(GestureRecognizer),为了防止互相抢事件,Flutter engine 在 iOS 上加入了一个“delaying gesture recognizer”(延迟识别器),这也最终导致了 iOS 26 上的 bug。

再次紧急修复,Flutter 针对 WebView 无法点击问题增加新的快速修复 @恋猫de小郭

所以针对这个场景,作者又提交了一个“骚操作”的快速修复,#179908 这个 PR 的修复方案非常“暴力”但也有效:找到那些特定的手势识别器,先禁用它们,然后立即重新启用, 这相当于重置了识别器的状态。

活动日历

活动名称 活动时间
晒TRAE 2025 年度报告赢定制年终奖 2025年12月25日-2025年12月30日

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

大前端框架汇总/产品交互参考UE

作者 潍坊中登
2025年12月25日 17:27

产品交互参考UE

(水文 哈哈 )

大厂出品

1- 阿里 ant.design/ ,小程序:mini.ant.design/

2- 抖音 semi.design/ 适配抖音 / 头条小程序,基于 UniApp 生态,组件覆盖电商 / 内容场景,字节系小程序首选; 官网:nutui.jd.com/uniapp/

3- 腾讯 移动端跨平台 tmui.design , 同时做 Web 中后台 + 微信小程序 + Flutter App: static.tdesign.tencent.com

4- 金蝶 www.kingdee.design

5- 饿了摸 :后台 element.eleme.cn/#/zh-CN

6- 其他热门 www.uviewui.com/ :(免费), www.iviewui.com/price :(收费)

7- 滴滴集团 didi.github.io/cube-ui/#/z… ,didi.github.io/

原文 gitee.com/jeffcat/art…

🎉TinyVue v3.27.0 正式发布:增加 Space 新组件,ColorPicker 组件支持线性渐变

2025年12月25日 17:22

你好,我是 Kagol,个人公众号:前端开源星球

我们非常高兴地宣布 TinyVue v3.27.0 正式发布🎉。该版本聚焦于增强可定制性与可访问性,新增若干实用组件能力,并修复了大量用户反馈的关键问题,提升了在 SaaS 主题与移动端的兼容性与稳定性。

主要亮点

  • 新增插槽date-picker 增加了 footer 插槽,提升自定义底部交互能力。
  • 更精细的日期控制calendar-view 与 date-picker 支持按天指定日期与换行显示,日历展示更灵活。
  • 选择器改进select 增加 autoSelect 属性并优化可搜索场景下的中断问题,提高选择体验与可靠性。
  • 组件扩展steps 支持单链环形节点图标插槽,space 组件被新增以方便布局间距管理。
  • 样式与主题exception 组件补充了 PC 模板与深色模式支持,并对 Saas 主题做了多项样式调整(包含 ip-addressbuttondividerbadge 等)。
  • 配色与面板color-select-panel 支持线性渐变,color-select 新增 color-mode 属性,色彩选择更强大。
  • 树形菜单与搜索tree-menu 优化 demo 数据并暴露搜索事件,便于构建可搜索的侧边/树型导航。
  • Grid 功能增强: 新增 valid-config 的 highlightError、鼠标悬停显示对齐线等多项体验改进。

重要修复

  • 移动端兼容: 修复 mobile-first 场景下 tag 可选但不生效的问题;修复 Saas 模式下若干控件的样式显示异常。
  • 交互与显示: 修复 notify 垂直偏移、tabs 同时使用 overflow-title 与 with-close 的渲染问题、slider 横竖模式切换错误、calendar-view 同时段多任务显示异常等。
  • 性能与稳定性: 修复 grid 中 filterStore 的响应性问题、加载组件错误、分页尺寸变更导致的 body 高度错误等。
  • 兼容性与测试: 修复 infinite-scroll 在同页使用两处时报错的问题;完善各组件在 E2E 和示例中的兼容处理(dialog-selectinputnotify 等)。
  • 工具链与构建: 修复打包后 CSS 缺失 tiny 前缀的问题,并修复发布流程相关错误。

升级与迁移建议

  • 安装升级: 推荐在项目中将依赖升级到 v3.27.0,例如:

    • npm install @opentiny/vue@3.27.0
    • 或使用 pnpm: pnpm add @opentiny/vue@3.27.0
  • 回归测试: 升级后请重点回归以下场景:

    • 自定义 Saas 主题与样式(按钮、表单项、分隔线等视觉差异)
    • select 的可搜索行为与 autoSelect 新属性的交互
    • date-picker/calendar-view 的自定义槽位、日期展示(包含换行显示)
    • 使用 grid 的自定义校验配置与分页行为
    • infinite-scroll 在页面多处实例化的稳定性
  • 样式注意: 若项目依赖 SaaS 模板或定制 less/样式,请检查示例与主题调整(本次修复中新增/修改了若干 Saas 相关 less 文件与样式规范)。

  • 兼容 props: 关注新增的 popperOptions(Picker)、hideSaas(示例隐藏)等属性,调整自定义逻辑以兼容新选项。

社区与贡献

  • 本次发布汇集了大量社区贡献,特别感谢以下贡献者(部分举例):

    • @discreted66(多项 date-picker、calendar、exception 改进)
    • @chenxi-20(tabs、steps、notify 修复与改进)
    • @shenjunjian(select、input、picker 修复与增强)
    • @gimmyhehe(grid 相关改进)
    • @wuyiping0628、@zzcr、@James-9696、@KevinAndrewDong 等多人提交大量 PR 和修复
  • 欢迎更多新贡献者加入:本版本中 @gausszhou 与 @ynnnny 完成了他们的首次贡献。

详细的更新信息请查看 Release Notes

小结

v3.27.0 是一次以可定制性、体验与稳定性为核心的迭代:新增插槽、色彩/布局组件、以及大量围绕 Saas 与移动端的修复,将帮助你在实际应用中获得更一致、更可控的表现。升级后请务必执行回归测试并关注样式与交互的边缘场景。

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.design/tiny-vue

个人博客:kagol.github.io/blogs

小助手微信:opentiny-official

公众号:OpenTiny

漏洞复现指南:利用 phpinfo() 绕过 HttpOnly Cookie 保护

2025年12月25日 17:22

1. 实验综述

本实验旨在展示一个经典的“漏洞链”:利用服务器配置错误(泄露的 phpinfo.php)配合跨站脚本攻击(XSS),成功绕过浏览器对 HttpOnly Cookie 的安全保护。


2. 环境搭建

你需要一个支持 PHP 的本地环境(如 XAMPP 或 Ubuntu + Apache/Nginx)。

2.1 服务器环境确认

确保 PHP 环境已正常运行。

截图参考:系统环境确认(如 PHP 7.4.3 版本)。

企业微信截图_17666500389926.png

2.2 创建漏洞页面 vuln.php

该页面模拟两个关键点:手动设置一个受 HttpOnly 保护的敏感 Cookie,以及一个未经过滤的 XSS 注入点。

<?php
// vuln.php
// 1. 模拟登录:设置一个 HttpOnly 的 Cookie
setcookie("SecretSessionID", "A1B2C3D4E5_VERY_SECRET", [
    'expires' => time() + 3600,
    'path' => '/',
    'httponly' => true, // 关键点:浏览器脚本理论上无法读取此 Cookie
    'samesite' => 'Strict'
]);
// 2. 模拟 XSS 漏洞:直接回显用户输入的 'name' 参数
$name = isset($_GET['name']) ? $_GET['name'] : 'Guest';
?>
<!-- HTML 部分省略,包含一个显示 $name 的位置 -->

2.3 创建信息泄露页面 info.php

模拟生产环境中被遗留的诊断文件。

<?php
// info.php
phpinfo();
?>

3. 复现流程

步骤 1:确认 HttpOnly 保护生效

  1. 访问 http://localhost/vuln.php
  2. 打开开发者工具 (F12) -> Storage -> Cookies。
  3. 确认 SecretSessionIDHttpOnly 属性已勾选。此时,在控制台输入 document.cookie 将无法看到该值。

截图参考:浏览器中 Cookie 的 HttpOnly 状态。企业微信截图_17666504672500.png

步骤 2:确认 phpinfo() 信息泄露

  1. 访问 http://localhost/info.php
  2. 搜索 HTTP_COOKIE 字段。
  3. 发现风险:由于服务器会打印完整的 HTTP 请求头,受保护的 SecretSessionID 明文显示在 HTML 页面中。

步骤 3:构造并注入攻击 Payload

由于 document.cookie 被封锁,攻击者利用 JavaScript 发起异步请求来读取 info.php 的响应内容,并从中提取 Cookie。

攻击 Payload 逻辑

  1. 使用 fetch() 访问同域下的 /info.php
  2. 获取响应的文本内容。
  3. 使用正则表达式匹配 SecretSessionID 后的字符串。
  4. 将提取到的值通过弹窗或外传展示。

URL 注入链接

http://localhost/vuln.php?name=<script>fetch('/info.php').then(r=>r.text()).then(t=>{alert(t.match(/SecretSessionID=[a-zA-Z0-9_-]+/))})</script>

注意:在实际测试中,Payload 需要进行转义或 URL 编码以防语法错误。

步骤 4:执行攻击与结果验证

  1. 将构造好的链接粘贴至浏览器访问。
  2. 预期结果:页面执行注入的脚本,并弹出包含受保护 Cookie 的警告框。

截图参考:成功绕过 HttpOnly 提取到的 Cookie 弹窗。 企业微信截图_17666513368794.png


4. 结论与风险总结

  • 高危风险:此漏洞允许攻击者完全接管用户会话,即便开启了 HttpOnly 防御。
  • 防御失效:原本用于防止 XSS 窃取 Cookie 的安全标志,在 phpinfo() 的“协助”下彻底失效。

比喻理解: 设置 HttpOnly 标志就像给房子安装了防盗门,让小偷没法直接拿到钥匙;但公开暴露 phpinfo.php 就像在门旁的告示牌上贴了一张钥匙的详细蓝图。攻击者通过 XSS 潜入院子后,只需照着蓝图“复刻”一把,依然能打开你的防盗门。

30秒搞懂ERC-2981:NFT版税的终极解决方案!

作者 木西
2025年12月25日 17:06

前言

本文围绕 ERC-2981 版税标准展开,先系统梳理其核心定义、功能、解决的行业痛点及典型使用场景,再基于 OpenZeppelin 库整合 ERC-721 与 ERC-2981 标准实现版税 NFT 智能合约,最后通过 Hardhat V3 完成合约的开发、测试、部署全流程落地。

概述

ERC-2981 是以太坊 NFT 的链上版税标准,为 ERC-721/ERC-1155 合约提供统一的版税查询接口,让市场能自动获取分成规则并向创作者支付二级市场收益,核心解决早期版税碎片化、不可靠与跨平台不兼容问题,广泛用于数字艺术、游戏道具等需持续收益的 NFT 场景

ERC-2981 是什么

  • 定义:以太坊改进提案 EIP-2981(又称 ERC-2981),是 NFT 领域的标准化版税查询接口标准,兼容 ERC-721 与 ERC-1155,通过 EIP-165 接口识别,不强制市场执行版税,而是提供统一的链上版税信息查询能力。

  • 核心接口(IERC2981)

    1. royaltyInfo(uint256 tokenId, uint256 salePrice):返回版税接收地址与应付金额(以基点计算,1 基点 = 0.01%)。
    2. 可选实现:_setTokenRoyalty(单 token 版税)、_setDefaultRoyalty(全局默认版税),用于设置版税规则。
  • 关键特性:链上透明、可组合、兼容主流 NFT 标准,不依赖平台规则,由合约自主定义版税策略。

ERC-2981 能做什么

  1. 标准化版税信息存储与查询:在 NFT 合约中嵌入版税规则(比例、接收地址),市场通过统一接口读取,无需自定义解析逻辑。
  2. 自动分成触发:二级市场交易时,合规市场调用royaltyInfo计算分成,自动将版税划转至创作者 / 权利人,剩余款项给卖家。
  3. 灵活规则配置:支持单 token 独立版税、全局默认版税,可通过权限控制更新接收地址,适配动态分成场景。
  4. 跨平台互通:统一接口让 NFT 在不同市场交易时,版税规则一致,提升生态可组合性。
  5. 链上追溯:版税信息与交易记录上链,便于审计与纠纷排查,降低信任成本。

ERC-2981 解决了什么

痛点 解决方案
版税碎片化 统一接口替代各平台专有规则,开发者无需重复适配
收益不可靠 链上存储规则,减少依赖平台中心化结算的信用风险
跨平台不兼容 标准接口让市场无缝读取版税,保障创作者跨平台收益
信息不透明 公开可查询的版税比例与接收地址,避免暗箱操作
开发成本高 复用 OpenZeppelin 等库的实现,降低版税功能开发门槛

使用场景

  1. 数字艺术 / 收藏品:艺术家铸造 NFT 时设置 5%-10% 版税,每次转售自动分成,如 CryptoPunks(兼容后)、Art Blocks 项目。
  2. 游戏资产:游戏道具 NFT 转售时,开发者按比例获取分成,用于持续开发与运营,适配 ERC-1155 批量资产场景。
  3. 音乐 / 影视 NFT:版权方在二次交易中获得收益,支持多权利人按比例分配(需结合多签 / 分账合约)。
  4. IP 衍生品:IP 方通过版税获取长期收益,如品牌联名 NFT 的持续分成。
  5. 创作者 DAO / 社区:版税收入进入 DAO 金库,用于生态建设或社区分红,提升治理效率。
  6. 跨链 NFT:通过跨链桥同步版税信息,实现多链交易时的自动分成(需跨链协议支持)。

智能合约开发、测试、部署

版税NFT智能合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol"; // 新增:用于tokenURI

contract MyRoyaltyNFT is ERC721, ERC2981, Ownable {
    // 使用 uint256 替代 Counters.Counter(5.x版本已移除该库)
    uint256 private _nextTokenId;
    
    string private _baseTokenURI;
    uint96 private constant MAX_ROYALTY_BPS = 1000; // 10% 版税上限

    // 新增:可选的最大供应量限制(设为0则无限制)
    uint256 public immutable maxSupply;

    // 新增:合约部署事件
    event Minted(address indexed to, uint256 indexed tokenId);

    constructor(
        string memory name,
        string memory symbol,
        string memory baseURI,
        address royaltyReceiver,
        uint96 royaltyBps,
        uint256 _maxSupply // 新增参数,设为0表示无上限
    ) ERC721(name, symbol) Ownable(msg.sender) {
        _baseTokenURI = baseURI;
        maxSupply = _maxSupply;
        
        // 设置默认版税(basis points: 100 = 1%)
        require(royaltyBps <= MAX_ROYALTY_BPS, "Royalty too high");
        _setDefaultRoyalty(royaltyReceiver, royaltyBps);
    }

    // ======================== 铸造功能 ========================
    
    // 优化:原生递增 + 可选供应上限
    function safeMint(address to) public onlyOwner {
        require(
            maxSupply == 0 || _nextTokenId < maxSupply, 
            "Max supply reached"
        );
        
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        emit Minted(to, tokenId); // 记录铸造事件
    }

    // 批量铸造(新增:高效铸造多个)
    function safeMintBatch(address[] calldata recipients) external onlyOwner {
        for (uint256 i = 0; i < recipients.length; i++) {
            safeMint(recipients[i]);
        }
    }

    // 获取已铸造总量
    function totalSupply() external view returns (uint256) {
        return _nextTokenId;
    }

    // ======================== 版税管理 ========================
    
    // 优化:统一版税验证逻辑
    function setTokenRoyalty(
        uint256 tokenId,
        address receiver,
        uint96 feeNumerator
    ) external onlyOwner {
        _validateRoyalty(feeNumerator);
        _setTokenRoyalty(tokenId, receiver, feeNumerator);
    }

    function setDefaultRoyalty(
        address receiver, 
        uint96 feeNumerator
    ) external onlyOwner {
        _validateRoyalty(feeNumerator);
        _setDefaultRoyalty(receiver, feeNumerator);
    }

    function resetTokenRoyalty(uint256 tokenId) external onlyOwner {
        _resetTokenRoyalty(tokenId);
    }

    // 内部函数:验证版税比例
    function _validateRoyalty(uint96 feeNumerator) internal pure {
        require(feeNumerator <= MAX_ROYALTY_BPS, "Royalty exceeds 10%");
    }

    // ======================== 元数据 ========================
    
    // 优化:自动拼接tokenId
    function tokenURI(uint256 tokenId) 
        public 
        view 
        virtual 
        override 
        returns (string memory) 
    {
        _requireOwned(tokenId); // 5.x推荐:替代require(_exists())
        
        string memory baseURI = _baseURI();
        return bytes(baseURI).length > 0 
            ? string.concat(baseURI, Strings.toString(tokenId), ".json") // 自动添加.json扩展名
            : "";
    }

    function _baseURI() internal view virtual override returns (string memory) {
        return _baseTokenURI;
    }

    function setBaseURI(string memory newBaseURI) external onlyOwner {
        _baseTokenURI = newBaseURI;
    }

    // ======================== 接口支持 ========================
    
    // 必须重写 supportsInterface 以支持 ERC165 接口检测
    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(ERC721, ERC2981)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

编译指令

npx hardhat compile

智能合约部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const artifact = await artifacts.readArtifact("MyRoyaltyNFT");
  const ipfsjsonuri="https://zygomorphic-magenta-bobolink.myfilebase.com/ipfs/QmQT8VpmWQVhUhoDCEK1mdHXaFaJ3KawkRxHm96GUhrXLB"
  // 部署(构造函数参数:recipient, initialOwner)
  const hash = await deployer.deployContract({
    abi: artifact.abi,//获取abi
    bytecode: artifact.bytecode,//硬编码
    args: ["MyRoyaltyNFT","MRNFT",ipfsjsonuri,deployerAddress,100,0],//nft名称,nft符号,ipfsjsonuri,部署者地址, royaltiesNumerator,royaltiesDenominator
  });

  // 等待确认并打印地址
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  console.log("合约地址:", receipt.contractAddress);
}

main().catch(console.error);

部署指令

npx hardhat run ./scripts/xxx.ts

智能合约测试脚本

import assert from "node:assert/strict";
import { describe, it,beforeEach  } from "node:test";
import { formatEther,parseEther } from 'viem'
import { network } from "hardhat";
describe("MyRoyaltyNFT", async function () {
    let viem: any;
    let publicClient: any;
    let owner: any, user1: any, user2: any, user3: any;
    let deployerAddress: string;
    let MyRoyaltyNFT: any;
    beforeEach (async function () {
        const { viem } = await network.connect();
         publicClient = await viem.getPublicClient();//创建一个公共客户端实例用于读取链上数据(无需私钥签名)。
         [owner,user1,user2,user3] = await viem.getWalletClients();//获取第一个钱包客户端 写入联合交易
        deployerAddress = owner.account.address;//钱包地址
       const ipfsjsonuri="https://zygomorphic-magenta-bobolink.myfilebase.com/ipfs/QmQT8VpmWQVhUhoDCEK1mdHXaFaJ3KawkRxHm96GUhrXLB";
       
        MyRoyaltyNFT = await viem.deployContract("MyRoyaltyNFT", [
            "My Royalty NFT",
            "MRNFT",
            ipfsjsonuri,
            deployerAddress,
            200,//版税1%
            0,
        ]);//部署合约
        console.log("MyRoyaltyNFT合约地址:", MyRoyaltyNFT.address); 
    });
    it("测试MyRoyaltyNFT", async function () {
        //查询nft名称和符号
       const name= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "name",
            args: [],
        });
       const symbol= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "symbol",
            args: [],
        });
        //查询总供应量和最大供应量
        const totalSupply= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "totalSupply",
            args: [],
        });
        const maxSupply= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "maxSupply",
            args: [],
        });
        //查询合约拥有者
       const ownerAddress= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "owner",
            args: [],
        });
        console.log(name,symbol,totalSupply,maxSupply,ownerAddress)
        //铸造单个nft
        await owner.writeContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "safeMint",
            args: [user1.account.address],
        });
        //批量铸造nft
        const nftaddress=[user1.account.address,user2.account.address,user3.account.address]
        await owner.writeContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "safeMintBatch",
            args: [nftaddress],
        });

        //查询单个nft的tokenURI
        const TokenURI= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "tokenURI",
            args: [0],
        });
        console.log(TokenURI)
        //查询余额和拥有者
        const balanceOf=await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "balanceOf",
            args: [user1.account.address],
        });
        console.log(balanceOf)
        //查询nft的拥有者
        const ownerOf=await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "ownerOf",
            args: [0],
        });
        console.log(ownerOf)
        //查询版税信息
        const royaltyInfo=await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "royaltyInfo",
            args: [0,parseEther("2")],
        });
        console.log(royaltyInfo)
        const GETAPPROVED=await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "getApproved",
            args: [0],
        });
        console.log(GETAPPROVED)
        //设置BaseURI
        const ipfsjsonuri1="https://zygomorphic-magenta-bobolink.myfilebase.com/ipfs/QmcN49MKt4MbSXSGckAcpvFqtea43uuPD2tvmuER1mG67s";
       const setBaseURI=await owner.writeContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "setBaseURI",
            args: [ipfsjsonuri1],
        });
        console.log(setBaseURI)
        //查询更新后的tokenURI
        const TokenURI1= await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "tokenURI",
            args: [0],
        });
        console.log("更新后",TokenURI1)
        //设置默认版税
        // const SETDEFAULTROYALTY=await owner.writeContract({
        //     address: MyRoyaltyNFT.address,
        //     abi: MyRoyaltyNFT.abi,
        //     functionName: "setDefaultRoyalty",
        //     args: [user3.account.address,"500"],
        // });
        // console.log(SETDEFAULTROYALTY)
        //设置版税
       const setTokenRoyalty = await owner.writeContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "setTokenRoyalty",
            args: [0,user3.account.address,"500"],
        });
        //查询版税信息
        const royaltyInfo1=await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "royaltyInfo",
            args: [0,parseEther("3")],
        });
        console.log("更新后版税信息",royaltyInfo1)
        //转账nft
        const TRANSFERFROM=await user1.writeContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "transferFrom",
            args: [user1.account.address,user2.account.address,0],
        });
        //查询nft的新拥有者
        const ownerOf1=await publicClient.readContract({
            address: MyRoyaltyNFT.address,
            abi: MyRoyaltyNFT.abi,
            functionName: "ownerOf",
            args: [0],
        });
        console.log(ownerOf1)
    });

});

测试指令

npx hardhat test ./test/xxx.ts

总结

至此,关于ERC-2981 版税标准从理论梳理到代码实现、工程落地的全流程实践,既验证了该标准的核心价值,也为开发者提供了可直接复用的版税 NFT 开发范式,是 NFT 生态从 “交易” 向 “持续价值分配” 演进的重要落地参考。

Node.js第一课:实现简易的命令行任务管理器

作者 Drift_Dream
2025年12月25日 16:56

Node.js简介

  • Node.js不是语言,是JavaScript的运行环境
  • 基于Chrome V8引擎,使JS可以脱离浏览器运行
  • 异步非阻塞I/O模型,适合高并发场景
  • 应用场景:Web服务、API开发、CLI工具、微服务等

第一个Node程序

创建项目结构

task-manager/
├── package.json
├── index.js
└── tasks.json  运行index.js后自动生成

package.json 配置

{
  "name": "task-manager",
  "version": "1.0.0",
  "description": "命令行任务管理器",
  "main": "index.js",
  "bin": {
    "task": "./index.js"
  },
  "scripts": {
    "start": "node index.js"
  }
}

index.js 内容

#!/usr/bin/env node

const fs = require('fs');
const path = require('path');

// 任务文件路径
const TASK_FILE = path.join(__dirname, 'tasks.json');

// 初始化任务文件
function initTasks() {
  if (!fs.existsSync(TASK_FILE)) {
    fs.writeFileSync(TASK_FILE, JSON.stringify([], null, 2));
  }
}

// 读取任务
function loadTasks() {
  try {
    const data = fs.readFileSync(TASK_FILE, 'utf8');
    return JSON.parse(data);
  } catch (error) {
    return [];
  }
}

// 保存任务
function saveTasks(tasks) {
  fs.writeFileSync(TASK_FILE, JSON.stringify(tasks, null, 2));
}

// 添加任务
function addTask(description) {
  const tasks = loadTasks();
  const newTask = {
    id: Date.now(),
    description,
    completed: false,
    createdAt: new Date().toISOString()
  };
  tasks.push(newTask);
  saveTasks(tasks);
  console.log(`✅ 任务添加成功: ${description}`);
}

// 列出任务
function listTasks() {
  const tasks = loadTasks();
  
  if (tasks.length === 0) {
    console.log("📝 还没有任务,快添加一个吧!");
    return;
  }
  
  console.log("\n📋 你的任务列表:");
  tasks.forEach((task, index) => {
    const status = task.completed ? '✅' : '⏳';
    console.log(`${index + 1}. ${status} ${task.description}`);
  });
}

// 完成任务
function completeTask(index) {
  const tasks = loadTasks();
  if (index >= 0 && index < tasks.length) {
    tasks[index].completed = true;
    saveTasks(tasks);
    console.log(`🎉 任务完成: ${tasks[index].description}`);
  } else {
    console.log("❌ 任务不存在");
  }
}

// 删除任务
function deleteTask(index) {
  const tasks = loadTasks();
  if (index >= 0 && index < tasks.length) {
    const deleted = tasks.splice(index, 1)[0];
    saveTasks(tasks);
    console.log(`🗑️ 任务删除: ${deleted.description}`);
  } else {
    console.log("❌ 任务不存在");
  }
}

// 主函数
function main() {
  initTasks();
  
  const [,, command, ...args] = process.argv;
  
  switch (command) {
    case 'add':
      if (args.length > 0) {
        addTask(args.join(' '));
      } else {
        console.log("❌ 请提供任务描述");
      }
      break;
      
    case 'list':
      listTasks();
      break;
      
    case 'done':
      if (args.length > 0) {
        completeTask(parseInt(args[0]) - 1);
      } else {
        console.log("❌ 请提供任务编号");
      }
      break;
      
    case 'delete':
      if (args.length > 0) {
        deleteTask(parseInt(args[0]) - 1);
      } else {
        console.log("❌ 请提供任务编号");
      }
      break;
      
    default:
      console.log(`
        使用说明:
          node index.js add <任务描述>  添加任务
          node index.js list           列出所有任务
          node index.js done <序号>    标记任务为完成
          node index.js delete <序号>  删除任务
      `);
  }
}

main();

运行与测试

# 添加任务
node index.js add "学习Node.js"
node index.js add "完成第一个项目"

# 查看任务
node index.js list

# 完成任务
node index.js done 1

# 删除任务
node index.js delete 2

知识点总结

  • CommonJS模块系统:使用 require导入模块

  • 核心模块fs(文件系统)、path(路径处理)

  • 全局对象process获取进程信息

  • JSON操作:读写JSON文件

  • 命令行参数process.argv处理用户输入

🔥🔥高效易用的 Vue3 公告滚动组件:打造丝滑的内容滚动体验(附源码)

作者 同学80796
2025年12月25日 16:38

在各类后台管理系统、营销页面或信息展示场景中,公告滚动是高频且基础的交互需求 —— 既要实现内容自动向上滚动的展示效果,也要兼顾用户手动操作的灵活性。基于 Vue3 Setup 语法糖封装的这款公告滚动组件,完美平衡了「自动展示」与「手动交互」的需求,为前端开发提供了开箱即用、高度可定制的解决方案。

核心特性:兼顾体验与灵活性

这款组件围绕 “用户体验优先” 设计,核心功能覆盖场景全、交互细节拉满:

  1. 丝滑自动滚动:支持像素级缓慢向上滚动,滚动条与内容同步移动,滚到底部后无缝重置至顶部,避免内容断层;可通过autoScrollSpeed参数自定义滚动速度(默认 1px / 帧),兼顾展示效率与视觉舒适度。
  2. 灵活的手动交互
    • 鼠标悬浮即时暂停滚动,移出后立即恢复,方便用户聚焦查看单条公告;
    • 滚轮操作大幅提速(默认单次滚动 40px),可通过wheelStep参数调整灵敏度,满足快速浏览需求;
    • 保留原生滚动条并支持自定义样式,手动拖拽滚动条后 1 秒自动恢复滚动,兼顾不同操作习惯。
  3. 响应式与兼容性:监听公告列表数据变化,数据更新后自动重新计算高度并重启滚动;兼容 Chrome、Firefox、Edge 等现代浏览器,适配不同内核的滚动条样式。
  4. 轻量且易扩展:无第三方依赖,基于 Vue3 原生 API 开发;组件样式、容器宽高可通过外部样式灵活覆盖,无需修改源码即可适配不同 UI 风格。

快速上手:极简集成,开箱即用

组件采用 Vue3 Setup 语法糖开发,集成流程极简:

  1. 引入组件:将NoticeScroll.vue文件放入项目组件目录,在需要使用的页面直接导入;
  2. 传入数据:仅需传递核心参数list(公告列表数组),即可启动基础滚动功能;
  3. 定制化配置(可选):通过autoScrollSpeed(滚动速度)、wheelStep(滚轮步长)、pauseOnHover(悬浮暂停)等参数,快速适配业务场景。

示例代码简洁直观:

vue

<NoticeScroll
  :list="noticeList"
  :autoScrollSpeed="1"
  :wheelStep="40"
  style="width: 500px; height: 200px;"
/>

适用场景:覆盖多类信息展示需求

无论是后台系统的系统公告、电商页面的营销通知,还是资讯类产品的动态资讯,这款组件都能适配 —— 既满足 “无人操作时自动轮播展示” 的基础需求,也解决了 “用户想手动快速浏览 / 聚焦查看” 的交互痛点。组件内置的内存泄漏防护(卸载时清理定时器、事件监听),也保证了在复杂页面中使用的稳定性。

代码如下:

<template>
  <div 
    class="notice-scroll-wrapper"
    ref="wrapperRef"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
    @wheel="handleWheel"
  >
    <ul class="notice-scroll-list">
      <li 
        class="notice-scroll-item"
        v-for="(item, index) in list"
        :key="index"
      >
        {{ item }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';

// 定义组件属性
const props = defineProps({
  // 公告列表数据
  list: {
    type: Array,
    required: true,
    default: () => []
  },
  // 自动滚动速度(像素/帧)
  autoScrollSpeed: {
    type: Number,
    default: 1
  },
  // 滚轮单次滚动步长(像素)
  wheelStep: {
    type: Number,
    default: 40
  },
  // 帧率(建议60)
  frameRate: {
    type: Number,
    default: 60
  },
  // 鼠标悬浮是否暂停
  pauseOnHover: {
    type: Boolean,
    default: true
  }
});

// DOM 引用
const wrapperRef = ref(null);
// 状态变量
const timer = ref(null); // 自动滚动定时器
const autoScrollTimer = ref(null); // 滚轮/滚动条后恢复定时器
const isHover = ref(false); // 是否悬浮
const wrapperH = ref(0); // 容器高度
const contentH = ref(0); // 内容总高度
const maxScrollTop = ref(0); // 滚动条最大位置

// 初始化
const init = () => {
  if (!wrapperRef.value) return;
  // 计算容器/内容高度
  wrapperH.value = wrapperRef.value.offsetHeight;
  contentH.value = wrapperRef.value.querySelector('.notice-scroll-list').offsetHeight;
  maxScrollTop.value = contentH.value - wrapperH.value;
  // 启动自动滚动
  startAutoScroll();
};

// 启动自动滚动
const startAutoScroll = () => {
  // 内容高度 ≤ 容器高度时,不滚动
  if (contentH.value <= wrapperH.value) return;
  // 清除旧定时器
  clearInterval(timer.value);
  // 逐帧更新滚动条位置
  timer.value = setInterval(() => {
    let current = wrapperRef.value.scrollTop;
    current += props.autoScrollSpeed;
    // 无缝重置:滚到底部回到顶部
    if (current >= maxScrollTop.value) {
      current = 0;
    }
    wrapperRef.value.scrollTop = current;
  }, 1000 / props.frameRate);
};

// 停止自动滚动
const stopAutoScroll = () => {
  clearInterval(timer.value);
  timer.value = null;
};

// 鼠标移入:暂停滚动
const handleMouseEnter = () => {
  if (!props.pauseOnHover) return;
  isHover.value = true;
  stopAutoScroll();
};

// 鼠标移出:恢复滚动
const handleMouseLeave = () => {
  if (!props.pauseOnHover) return;
  isHover.value = false;
  startAutoScroll();
};

// 滚轮控制:提速滚动
const handleWheel = (e) => {
  e.preventDefault();
  // 暂停自动滚动
  stopAutoScroll();
  // 计算新滚动位置
  const newScrollTop = wrapperRef.value.scrollTop + (e.deltaY > 0 ? props.wheelStep : -props.wheelStep);
  // 边界限制
  wrapperRef.value.scrollTop = Math.max(0, Math.min(newScrollTop, maxScrollTop.value));
  // 1秒后恢复自动滚动
  clearTimeout(autoScrollTimer.value);
  autoScrollTimer.value = setTimeout(() => {
    if (!isHover.value) startAutoScroll();
  }, 1000);
};

// 监听滚动条手动拖动:恢复自动滚动
const handleScroll = () => {
  // 排除自动滚动触发的scroll事件
  if (timer.value) return;
  stopAutoScroll();
  clearTimeout(autoScrollTimer.value);
  autoScrollTimer.value = setTimeout(() => {
    if (!isHover.value) startAutoScroll();
  }, 1000);
};

// 监听列表数据变化:重新初始化
watch(
  () => props.list,
  () => {
    // 清除旧定时器
    clearInterval(timer.value);
    clearTimeout(autoScrollTimer.value);
    // 重新计算高度并启动
    setTimeout(init, 0); // 异步等待DOM更新
  },
  { deep: true }
);

// 生命周期:挂载时初始化
onMounted(() => {
  init();
  // 绑定滚动条拖动事件
  if (wrapperRef.value) {
    wrapperRef.value.addEventListener('scroll', handleScroll);
  }
});

// 生命周期:卸载时清理
onUnmounted(() => {
  clearInterval(timer.value);
  clearTimeout(autoScrollTimer.value);
  if (wrapperRef.value) {
    wrapperRef.value.removeEventListener('scroll', handleScroll);
  }
});
</script>

<style scoped>
.notice-scroll-wrapper {
  width: 100%;
  height: 200px; /* 默认高度,可通过父组件覆盖 */
  border: 1px solid #e5e5e5;
  border-radius: 4px;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin; /* Firefox 滚动条样式 */
  scrollbar-color: #ccc #f5f5f5;
}

/* 自定义滚动条 - Chrome/Edge/Safari */
.notice-scroll-wrapper::-webkit-scrollbar {
  width: 6px;
}
.notice-scroll-wrapper::-webkit-scrollbar-track {
  background: #f5f5f5;
  border-radius: 3px;
}
.notice-scroll-wrapper::-webkit-scrollbar-thumb {
  background: #ccc;
  border-radius: 3px;
}
.notice-scroll-wrapper::-webkit-scrollbar-thumb:hover {
  background: #999;
}

.notice-scroll-list {
  list-style: none;
  padding: 0 20px;
  margin: 0;
}

.notice-scroll-item {
  line-height: 1.6;
  padding: 8px 0;
  color: #333;
  word-break: break-all;
}

.notice-scroll-item:hover {
  color: #1890ff;
  transition: color 0.2s;
}
</style>

调用示例:

<template>
  <div class="demo-container">
    <h3>公告滚动示例</h3>
    <!-- 使用公告滚动组件 -->
    <NoticeScroll
      :list="noticeList"
      :autoScrollSpeed="1"
      :wheelStep="40"
      :pauseOnHover="true"
      style="width: 500px; height: 200px;"
    />
  </div>
</template>

<script setup>
import NoticeScroll from './NoticeScroll.vue';

// 公告列表数据
const noticeList = [
  '【公告1】系统将于2025-12-30 23:00进行维护升级,预计耗时2小时,维护期间将暂停所有线上服务,敬请谅解。',
  '【公告2】新用户注册即可领取88元新人礼包,包含5张满减券+1张免运费券,有效期7天,仅限首次注册用户使用。',
  '【公告3】企业版新增数据导出功能,支持Excel/PDF格式,可导出近3个月的客户跟进数据、成交数据、报表数据等。',
  '【公告4】本周累计成交满10000元,可享9折优惠,优惠可叠加会员权益,活动截止至2025-12-31。',
  '【公告5】移动端APP已更新至v2.8.0版本,新增扫码核销、离线缓存功能,建议所有用户及时升级。',
  '【公告6】客服工作时间调整为9:00-22:00,节假日正常值班,如有问题可随时联系在线客服。',
  '【公告7】会员等级体系升级,新增钻石会员等级,可享专属客服、优先发货等权益。'
];
</script>

<style scoped>
.demo-container {
  padding: 50px;
}
</style>

iOS 和 HarmonyOS 兼容笔记

作者 吴汉三
2025年12月25日 16:31

1. iOS 审核域名限制问题

问题描述

iOS 审核人员位于国外,App 内访问的 API 域名需要能在外网访问,否则可能导致审核失败。

解决方案

确保所有 API 域名都能在外网访问,或提供审核专用的 API 环境。

代码示例

// 配置 API 域名时考虑审核需求
const isProduction = process.env.NODE_ENV === 'production';
const isReview = process.env.VUE_APP_REVIEW === 'true';

// 审核环境使用可外网访问的域名
const baseUrl = isReview ? 'https://review-api.example.com' : 
               isProduction ? 'https://api.example.com' : 
               'http://dev-api.example.com';

export default {
  baseUrl
};

2. iOS scroll-view 内 fixed 定位弹框被遮挡

问题描述

在 iOS 设备上,scroll-view 组件内部的 fixed 定位弹框内容会被遮挡,无法正常显示。

解决方案

将弹框移出 scroll-view 组件,或使用其他定位方式。

代码示例

<!-- 错误示例:fixed 定位弹框在 scroll-view 内部 -->
<scroll-view scroll-y="true" style="height: 100vh;">
  <view>scroll-view 内容</view>
  <view class="popup" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
    弹框内容
  </view>
</scroll-view>

<!-- 正确示例:fixed 定位弹框在 scroll-view 外部 -->
<scroll-view scroll-y="true" style="height: 100vh;">
  <view>scroll-view 内容</view>
</scroll-view>
<view class="popup" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
  弹框内容
</view>

3. iOS plus.io.chooseFile() 返回路径处理问题

问题描述

在 iOS 上,plus.io.chooseFile().then(res => {}) 返回的 res.files[0] 已经是可直接读取的绝对路径,不能再用 plus.io.convertLocalFileSystemURL 去转换,否则会拼接出不合法的路径,导致 uni.uploadFile 读取不到文件体。

解决方案

根据平台判断是否需要转换路径。

代码示例

plus.io.chooseFile({
  title: '选择文件',
  filter: {
    mimeTypes: ['image/*']
  }
}).then(res => {
  let filePath = res.files[0];
  
  // 根据平台判断是否需要转换路径
  if (uni.getSystemInfoSync().platform !== 'ios') {
    // 非 iOS 平台需要转换路径
    filePath = plus.io.convertLocalFileSystemURL(filePath);
  }
  
  // 使用转换后的路径进行文件上传
  uni.uploadFile({
    url: 'https://api.example.com/upload',
    filePath: filePath,
    name: 'file',
    success: (uploadRes) => {
      console.log('上传成功', uploadRes);
    },
    fail: (err) => {
      console.error('上传失败', err);
    }
  });
});

4. HarmonyOS picker 组件异步数据渲染问题

问题描述

在 HarmonyOS 设备上,内置组件 picker 的 range 使用异步数据时,需要使用 v-if 或 v-show 控制在获取到数据后再渲染 picker 组件,否则 picker 组件弹框不会显示。

解决方案

使用 v-if 或 v-show 控制 picker 组件的渲染时机,确保在数据加载完成后再渲染。

代码示例

<template>
  <view>
    <button @click="showPicker = true">显示选择器</button>
    
    <!-- 使用 v-if 控制 picker 组件渲染时机 -->
    <picker
      v-if="pickerData.length > 0"
      v-model="selectedIndex"
      :range="pickerData"
      @change="onPickerChange"
      v-show="showPicker"
    ></picker>
  </view>
</template>

<script>
export default {
  data() {
    return {
      pickerData: [],
      selectedIndex: 0,
      showPicker: false
    };
  },
  onLoad() {
    // 异步获取数据
    this.loadPickerData();
  },
  methods: {
    loadPickerData() {
      // 模拟异步请求
      setTimeout(() => {
        this.pickerData = ['选项1', '选项2', '选项3', '选项4', '选项5'];
      }, 1000);
    },
    onPickerChange(e) {
      console.log('选择了', this.pickerData[e.detail.value]);
      this.showPicker = false;
    }
  }
};
</script>

5. HarmonyOS uni.getLocation 坐标系转换问题

问题描述

在鸿蒙设备上使用 uni.getLocation 获取定位是通过鸿蒙系统定位,获取到的定位坐标系是国际坐标系(wgs84),需要进行坐标系转换得到国测局坐标系(gcj02)才能在高德地图 API 上使用。

解决方案

使用坐标系转换算法将 wgs84 转换为 gcj02。

代码示例

// 坐标系转换算法(wgs84 转 gcj02)
function wgs84togcj02(lng, lat) {
  const pi = 3.1415926535897932384626;
  const a = 6378245.0;
  const ee = 0.00669342162296594323;
  
  if (out_of_china(lng, lat)) {
    return [lng, lat];
  } else {
    let dlat = transformlat(lng - 105.0, lat - 35.0);
    let dlng = transformlng(lng - 105.0, lat - 35.0);
    const radlat = lat / 180.0 * pi;
    let magic = Math.sin(radlat);
    magic = 1 - ee * magic * magic;
    const sqrtmagic = Math.sqrt(magic);
    dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi);
    dlng = (dlng * 180.0) / (a / sqrtmagic * Math.cos(radlat) * pi);
    const mglat = lat + dlat;
    const mglng = lng + dlng;
    return [mglng, mglat];
  }
}

function transformlat(lng, lat) {
  const pi = 3.1415926535897932384626;
  let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * pi) + 20.0 * Math.sin(2.0 * lng * pi)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lat * pi) + 40.0 * Math.sin(lat / 3.0 * pi)) * 2.0 / 3.0;
  ret += (160.0 * Math.sin(lat / 12.0 * pi) + 320 * Math.sin(lat * pi / 30.0)) * 2.0 / 3.0;
  return ret;
}

function transformlng(lng, lat) {
  const pi = 3.1415926535897932384626;
  let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng));
  ret += (20.0 * Math.sin(6.0 * lng * pi) + 20.0 * Math.sin(2.0 * lng * pi)) * 2.0 / 3.0;
  ret += (20.0 * Math.sin(lng * pi) + 40.0 * Math.sin(lng / 3.0 * pi)) * 2.0 / 3.0;
  ret += (150.0 * Math.sin(lng / 12.0 * pi) + 300.0 * Math.sin(lng / 30.0 * pi)) * 2.0 / 3.0;
  return ret;
}

function out_of_china(lng, lat) {
  return (lng < 72.004 || lng > 137.8347) || ((lat < 0.8293 || lat > 55.8271) || false);
}

// 使用示例
uni.getLocation({
  type: 'gcj02',
  success: (res) => {
    let lng = res.longitude;
    let lat = res.latitude;
    
    // 如果是鸿蒙设备,需要进行坐标系转换
    const systemInfo = uni.getSystemInfoSync();
    if (systemInfo.platform === 'harmony') {
      // 鸿蒙设备获取的是 wgs84 坐标系,需要转换为 gcj02
      const gcj02 = wgs84togcj02(lng, lat);
      lng = gcj02[0];
      lat = gcj02[1];
    }
    
    console.log('转换后的坐标', lng, lat);
    // 使用转换后的坐标调用高德地图 API
  }
});

6. HarmonyOS uni.chooseImage 不兼容 5.0

问题描述

uni.chooseImage 不兼容 HarmonyOS 5.0,在该版本上无法正常使用。

解决方案

使用 plus.io.chooseFile 替代 uni.chooseImage,或使用其他兼容方案。

代码示例

// 兼容 HarmonyOS 5.0 的图片选择方法
function chooseImage(options) {
  const systemInfo = uni.getSystemInfoSync();
  
  // 如果是 HarmonyOS 5.0 或以上版本,使用 plus.io.chooseFile
  if (systemInfo.platform === 'harmony' && parseFloat(systemInfo.osVersion) >= 5.0) {
    return new Promise((resolve, reject) => {
      plus.io.chooseFile({
        title: '选择图片',
        filter: {
          mimeTypes: ['image/*']
        },
        multiple: options.count > 1,
        maxSelect: options.count
      }).then(res => {
        const tempFilePaths = res.files.map(file => {
          return plus.io.convertLocalFileSystemURL(file);
        });
        resolve({ tempFilePaths });
      }).catch(err => {
        reject(err);
      });
    });
  } else {
    // 其他平台使用 uni.chooseImage
    return new Promise((resolve, reject) => {
      uni.chooseImage({
        count: options.count,
        success: resolve,
        fail: reject
      });
    });
  }
}

// 使用示例
chooseImage({ count: 1 }).then(res => {
  console.log('选择的图片', res.tempFilePaths);
}).catch(err => {
  console.error('选择图片失败', err);
});

7. 高频同步 I/O 操作导致卡顿/闪退

问题描述

在列表渲染期间,高频地在主线程上执行同步 I/O 操作,会严重阻塞 UI 渲染,导致应用卡顿、无响应(ANR),而在一些对主线程管控更严格的系统(如 HarmonyOS Next)上,这很容易被判定为异常并导致闪退。

解决方案

将同步 I/O 操作改为异步,或减少调用次数,或使用缓存。

代码示例

<template>
  <view>
    <list>
      <list-item v-for="item in listData" :key="item.id">
        <text>{{ item.title }}</text>
        <text>{{ item.status }}</text>
      </list-item>
    </list>
  </view>
</template>

<script>
export default {
  data() {
    return {
      listData: []
    };
  },
  onLoad() {
    this.loadListData();
  },
  methods: {
    // 错误示例:列表渲染时多次调用 uni.getStorageSync
    loadListData() {
      // 模拟获取列表数据
      const list = [];
      for (let i = 0; i < 100; i++) {
        list.push({ id: i, title: '项目' + i });
      }
      
      // 错误:在循环中多次调用同步 I/O 操作
      list.forEach(item => {
        // 这会导致严重的性能问题
        const status = uni.getStorageSync(`item_status_${item.id}`);
        item.status = status || '默认状态';
      });
      
      this.listData = list;
    }
  }
};
</script>

<script>
// 正确示例:使用异步方法或减少调用次数
export default {
  data() {
    return {
      listData: []
    };
  },
  onLoad() {
    this.loadListData();
  },
  methods: {
    async loadListData() {
      // 模拟获取列表数据
      const list = [];
      for (let i = 0; i < 100; i++) {
        list.push({ id: i, title: '项目' + i });
      }
      
      // 正确:一次性获取所有需要的数据
      const allStatus = await this.getAllItemStatus(list);
      
      // 合并数据
      list.forEach(item => {
        item.status = allStatus[`item_status_${item.id}`] || '默认状态';
      });
      
      this.listData = list;
    },
    // 一次性获取所有需要的数据
    getAllItemStatus(list) {
      return new Promise((resolve) => {
        // 在实际应用中,可以使用异步方法批量获取数据
        // 这里模拟一次性获取所有状态
        const result = {};
        // 模拟异步操作
        setTimeout(() => {
          list.forEach(item => {
            result[`item_status_${item.id}`] = '状态' + item.id;
          });
          resolve(result);
        }, 100);
      });
    }
  }
};
</script>

8. HarmonyOS 页面多次调用 uni.getStorageSync 性能问题

问题描述

页面中多次调用 uni.getStorageSync 类似的同步方法,在鸿蒙设备上非常消耗性能,会造成页面加载渲染卡顿。

解决方案

将多次调用改为单次调用,或使用异步方法,或使用缓存。

代码示例

// 错误示例:多次调用 uni.getStorageSync
function loadUserData() {
  const userId = uni.getStorageSync('user_id');
  const userName = uni.getStorageSync('user_name');
  const userAvatar = uni.getStorageSync('user_avatar');
  const userGender = uni.getStorageSync('user_gender');
  const userAge = uni.getStorageSync('user_age');
  
  return {
    userId,
    userName,
    userAvatar,
    userGender,
    userAge
  };
}

// 正确示例:使用异步方法一次性获取所有数据
async function loadUserData() {
  // 方式一:使用异步方法
  const userInfo = await uni.getStorage({ key: 'user_info' });
  
  // 方式二:如果数据分散存储,使用 Promise.all 并行获取
  /*
  const [userIdRes, userNameRes, userAvatarRes, userGenderRes, userAgeRes] = await Promise.all([
    uni.getStorage({ key: 'user_id' }),
    uni.getStorage({ key: 'user_name' }),
    uni.getStorage({ key: 'user_avatar' }),
    uni.getStorage({ key: 'user_gender' }),
    uni.getStorage({ key: 'user_age' })
  ]);
  
  const userInfo = {
    userId: userIdRes.data,
    userName: userNameRes.data,
    userAvatar: userAvatarRes.data,
    userGender: userGenderRes.data,
    userAge: userAgeRes.data
  };
  */
  
  return userInfo.data;
}

// 正确示例:将分散的数据合并存储
// 存储数据时
uni.setStorageSync('user_info', {
  userId: '123',
  userName: '张三',
  userAvatar: 'https://example.com/avatar.jpg',
  userGender: '男',
  userAge: 25
});

9. uni.onuni.on、uni.emit 跨页面通信在 H5 打包后失效

问题描述

uni.$onuni.$emit 事件,在打包成 H5 页面后不能跨页面通信。

解决方案

使用其他跨页面通信方式,如 URL 参数、localStorage、vuex 等。

代码示例

// 方案一:使用 URL 参数传递数据
// 页面 A
uni.navigateTo({
  url: `/pages/pageB/pageB?data=hello`
});

// 页面 B
onLoad(options) {
  console.log(options.data); // hello
}

// 方案二:使用 localStorage 进行跨页面通信
// 页面 A
uni.setStorageSync('pageData', { message: 'hello' });

// 页面 B
onShow() {
  const data = uni.getStorageSync('pageData');
  console.log(data.message); // hello
}

// 方案三:使用 vuex 进行状态管理
// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    message: ''
  },
  mutations: {
    setMessage(state, message) {
      state.message = message;
    }
  },
  actions: {
    updateMessage({ commit }, message) {
      commit('setMessage', message);
    }
  },
  getters: {
    getMessage(state) {
      return state.message;
    }
  }
});

// 页面 A
import store from '@/store';
store.dispatch('updateMessage', 'hello');

// 页面 B
import store from '@/store';
onShow() {
  console.log(store.getters.getMessage); // hello
}

// 方案四:使用事件总线(适用于 Vue 2)
// main.js
Vue.prototype.$bus = new Vue();

// 页面 A
this.$bus.$emit('customEvent', { message: 'hello' });

// 页面 B
created() {
  this.$bus.$on('customEvent', (data) => {
    console.log(data.message); // hello
  });
}

总结

以上是 iOS 和 HarmonyOS 兼容开发中的常见问题及解决方案,包括:

  1. iOS 审核域名限制问题
  2. iOS scroll-view 内 fixed 定位弹框被遮挡
  3. iOS plus.io.chooseFile() 返回路径处理问题
  4. HarmonyOS picker 组件异步数据渲染问题
  5. HarmonyOS uni.getLocation 坐标系转换问题
  6. HarmonyOS uni.chooseImage 不兼容 5.0
  7. 高频同步 I/O 操作导致卡顿/闪退
  8. HarmonyOS 页面多次调用 uni.getStorageSync 性能问题
  9. uni.onuni.on、uni.emit 跨页面通信在 H5 打包后失效

在开发跨平台应用时,需要充分考虑不同平台的特性和限制,编写兼容代码,确保应用在各平台上都能正常运行。

Flutter 开发的极简风格聊天界面

作者 DreamMachine
2025年12月25日 16:26

770shots_so.jpeg

总结

  • 花时间重构了之前写的聊天程序的页面。
  • 使用最新的Flutter Sdk 重写。
  • 优化了高斯模糊的写法。
  • 优化了对安全区域的判断逻辑。
  • 增加文字和图片的发送效果。
  • 增加图片查看功能。

项目中使用到的一些第三方组件

get: ^4.7.3

# 系统接口
device_info_plus: ^12.2.0           # 设备信息(全平台支持)
permission_handler: ^12.0.1         # 权限处理(不支持PC - MacOS)

# 图像渲染
flutter_svg: ^2.2.3
extended_image: ^10.0.1             # 图片展示

# 数据存储
xml: ^6.6.1                         # xml解析 - 解析表情包文件
shared_preferences: ^2.5.4

# UI交互
easy_refresh: ^3.4.0                # 下拉刷新
flutter_slidable: ^4.0.3            # 滑动删除
flutter_smart_dialog: ^4.9.8+9      # 消息弹窗
flutter_keyboard_visibility: ^6.0.0 # 键盘状态监听
chat_bottom_container: ^0.4.0       # 输入框切换动画组件

# 本地资源选择
wechat_assets_picker: ^10.0.0       # 图片\视频选择器(不支持PC)
wechat_camera_picker: ^4.4.0        # 图片\视频拍摄器(不支持PC)

关于IOS上的一些权限配置

Podfile 文件

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
      target.build_configurations.each do |config|
        config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
      '$(inherited)',
      'PERMISSION_CAMERA=1',
      'PERMISSION_PHOTOS=1',
      'PERMISSION_MICROPHONE=1',
      ]
    end
  end
end

Info.plist 中增加对权限的描述

<key>NSCameraUsageDescription</key>
<string>xxx</string>
<key>NSAppleMusicUsageDescription</key>
<string>xxx</string>
<key>NSMicrophoneUsageDescription</key>
<string>xxx</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>xxx</string>
<key>NSPhotoLibraryUsageDescription</key>
  <string>xxx</string>

自定义滚动透明化的Appbar标题

12月25日.gif

/// 滚动动画的App标题
mixin AppBarMixin on GetxController {
  /// 控制Appbar标题透明度的控制器
  AnimationController? fadeController;

  /// 透明度动画参数
  late Animation<double> fadeAnimation;

  /// 滚动列表的控制器
  final ScrollController scrollController = ScrollController();

  /// 初始化
  void onInitAnimation(GetSingleTickerProviderStateMixin item) {
    fadeController = AnimationController(
      duration: Duration(milliseconds: 300),
      vsync: item,
    );
    fadeAnimation = Tween<double>(begin: 0, end: 1).animate(fadeController!);

    scrollController.addListener(() {
      if (scrollController.offset >= 50) {
        //titleOpacity.value = 1;
        fadeController?.forward();
      } else {
        if (fadeAnimation.value != 0 && scrollController.offset > 0) {
          var fade = scrollController.offset / 50;
          fadeController?.animateTo(fade);
        } else {
          //titleOpacity.value = 0;
          fadeController?.reverse();
        }
      }
    });
  }

  @override
  void onClose() {
    fadeController?.dispose();
    scrollController.dispose();
    super.onClose();
  }
}

Hero动画

动画的Key需要传递到第二个页面。

12月25日.gif

Get.to(
       () => PreviewImagePage(),
        transition: Transition.noTransition,
        arguments: {
       "hero": message.heroKey,
       "source": imageMessage.image,
  },
);

 /// Hero 动画 Key
String heroKey = Get.arguments['hero'];

Hero(
   tag: heroKey,
   child: widget,
),

输入框切换动画

12月25日(1).gif

使用的第三方组件
chat_bottom_container: ^0.4.0       # 输入框切换动画组件

消息定义

import 'dart:math';

/// 消息基类
abstract class Message {
  final String name;
  final String avatar;
  final bool self;

  final String id = Random().nextInt(100000000).toString();

  /// 正在发送
  final bool sending = false;

  /// 发送失败
  final bool failed = false;

  /// 是否群聊
  final bool isGroup = false;

  abstract final bool center;
  abstract final bool canClick;
  abstract final MessageKind kind;

  Message({
    required this.avatar,
    required this.name,
    required this.self,
  });
}

enum MessageKind {
  unkonw,
  text,
  image,
  video,
}

/// 文本消息
class TextMessage extends Message {
  final String text;

  TextMessage({
    required this.text,
    required super.avatar,
    required super.name,
    required super.self,
  });

  @override
  bool get center => false;

  @override
  bool get canClick => false;

  @override
  MessageKind get kind => MessageKind.text;
}

/// 图片消息
class ImageMessage extends Message {
  final String image;
  final double w;
  final double h;

  ImageMessage({
    required this.image,
    required super.avatar,
    required super.name,
    required super.self,
    required this.w,
    required this.h,
  });

  /// 跳转的页动画Key
  String get heroKey => "PreviewImage-$id";

  @override
  bool get center => false;

  @override
  bool get canClick => true;

  @override
  MessageKind get kind => MessageKind.image;
}

前端处理用户离开当前页面的方案及对比解析

作者 DEMO派
2025年12月25日 16:25

判断用户是否离开当前页面主要有以下几种方法,每种方法有不同的适用场景和优缺点

1. visibilitychange 事件

当用户切换标签页、最小化窗口或离开浏览器时触发

document.addEventListener('visibilitychange', function() {
  if (document.hidden) {
    // 页面隐藏(用户离开)
    console.log('用户离开了页面');
  } else {
    // 页面可见(用户返回)
    console.log('用户返回了页面');
  }
});

优点: 标准API,兼容性好(IE10+) 性能消耗小 准确判断标签页切换 支持移动设备

缺点: 无法判断浏览器关闭或电脑休眠 用户可能在同一个标签页内操作其他应用

2. beforeunload 事件

用户关闭标签页、刷新页面或导航到其他页面时触发。

window.addEventListener('beforeunload', function(e) {
  // 可以显示确认对话框(某些浏览器限制自定义消息)
  e.preventDefault();
  e.returnValue = ''; // Chrome等现代浏览器要求设置returnValue
  return null; // 兼容老版本
});

优点: 能捕获页面关闭/刷新 可以阻止离开(在某些浏览器中)

缺点: 不能用于发送异步请求(浏览器可能不等待) 用户体验较差(弹出确认框) 移动端支持有限 某些浏览器限制自定义消息

3. pagehide 事件

类似beforeunload,但更现代。

window.addEventListener('pagehide', function() {
  // 发送数据到服务器
  navigator.sendBeacon('/api/log-exit', data);
});

优点: 支持sendBeacon,适合发送离开数据 比beforeunload更可靠

缺点: IE10+支持,但老版本IE不支持

4. unload 事件

window.addEventListener('unload', () => {
    // 页面即将卸载
});

优点: 明确表示页面卸载

缺点: 现代浏览器中异步请求可能被取消 影响 bfcache 不推荐用于发送请求

5. Beacon API(navigator.sendBeacon)

window.addEventListener('pagehide', () => {
    navigator.sendBeacon('/api/log', data);
});

优点: 专为页面卸载时发送数据设计 异步发送,可靠且不阻塞页面卸载 不受 bfcache 影响

缺点: 只能发送少量数据(通常限制在 64KB) 无法接收服务器响应 需要后端配合接收数据

6. 心跳检测(Heartbeat)

// 客户端
let lastActive = Date.now();

setInterval(() => {
  fetch('/api/heartbeat', {
    method: 'POST',
    keepalive: true
  });
}, 30000); // 每30秒发送一次

// 监听用户活动
document.addEventListener('mousemove', updateLastActive);
document.addEventListener('keypress', updateLastActive);

function updateLastActive() {
  lastActive = Date.now();
}

优点: 最准确,能判断真实离开 不受浏览器标签页切换影响

缺点: 需要服务器支持 增加网络负载 实时性较差

7. 关闭前发送同步请求

window.addEventListener('beforeunload', () => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', '/api/log', false); // 同步请求
    xhr.send(data);
});

优点: 可确保请求发送

缺点: 阻塞页面卸载,影响用户体验 现代浏览器可能限制同步请求 不推荐使用

总结与建议

在这里插入图片描述

推荐方案

1. 需要提示用户保存数据

window.addEventListener('beforeunload', (e) => {
    if (hasUnsavedChanges) {
        e.preventDefault();
        e.returnValue = '';
    }
});

2. 需要在离开时上报数据

window.addEventListener('pagehide', () => {
    navigator.sendBeacon('/api/log', analyticsData);
});

3. 需要暂停页面资源

document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
        videoElement.pause();
    }
});

4. 单页应用(SPA)的路由离开:

使用路由守卫(如 Vue Router 的 beforeEach,React Router 的 useBlocker)

5. 组合使用

// 页面隐藏时先尝试上报
document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
        sendAnalytics();
    }
});
// 页面卸载时用 Beacon 补发
window.addEventListener('pagehide', () => {
    navigator.sendBeacon('/api/log', finalData);
});

注意事项

  1. 避免在卸载事件中执行耗时操作

  2. 优先使用 visibilitychange 和 pagehide,避免使用 beforeunload 除非必要

  3. 数据上报优先使用 Beacon API,其次考虑 fetch 的 keepalive 选项

  4. 单页应用应结合路由生命周期进行状态管理

在这里插入图片描述

useMemo

作者 frontend丶CV
2025年12月25日 15:42

上次我分享了关于 useCallback的相关内容,有兴趣的可以去看一下,并且也欢迎大家对我的文章提出问题

组件涉及到的优化方式

为什么我们需要去优化React组件?,React在组件的渲染上会有什么问题?

  • React组件有个特性,在不进行优化处理时父组件更新的时,子组件一定会重新渲染

优化的角度

  • 减少组件的不必要渲染
  • 提高组件的可读性以及减少复杂度,避免出现难以发现的bug

useMemo

useMemo 是一个 React Hook,它在每次重新渲染的时候能够缓存计算的结果。

  • 也就是说通过useMemo后在依赖项不发生变化时,可以不去进行大量非必要的计算,直接得到上次计算的结果
const cachedValue = useMemo(calculateValue, dependencies)

上代码


const filterTodo = (num: number) => {
  console.time("start");
  for (let index = 0; index < num; index++) {
    const elementNode = document.createElement("div");
  }
  console.timeEnd("start");
  return;
};
const Com2 = React.memo((props: any) => {
  console.log("Com2===> 重新渲染了");
  const [com2Data, setCom2Data] = useState(10000);
  
  const [data, setData] = useState(0);
  
  const visibleTodos = filterTodo(com2Data);

  return (
    <>
      <span>Com2的数据: {com2Data}</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      <br />
      <br />
      <button
        onClick={() => {
          setCom2Data(com2Data+1);
        }}
      >
        点击Com2数据
      </button>
    </>
  );
});

function App() {
  const [appData, setAppData] = useState(0);

  console.log("App===> 重新渲染了");

  const handleFun = useCallback(() => {
    console.log("我是一个函数");
  }, []);

  return (
    <>
      <span>Com2的数据: {com2Data}</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      <br />
       <span>Com2的数据data: {data}</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      <br />
      <button
        onClick={() => {
          setCom2Data(com2Data);
        }}
      >
        点击Com2数据
      </button>
      <button
        onClick={() => {
          setCom2Data(com2Data);
        }}
      >
        点击Com2数据
      </button>
    </>
  );
}

export default App;

image.png

  • 点击下appData按钮

image.png 不出意外,这也是我们期待的,因为唯一的props被useCallback包裹

  • 点击下Com2按钮

image.png

也不出意外,是预料之内的,但是每次渲染都会触发filterTodo这里也没啥太大问题,损耗的时间也不多,但是如果大量计算的时候,这里的损耗就要值得关注了

这时候useMemo就该出场了

const Com2 = React.memo((props: any) => {
  console.log("Com2===> 重新渲染了");
  const [com2Data, setCom2Data] = useState(10000);
  const [data, setData] = useState(0);

  const visibleTodos = useMemo(() =>filterTodo(100000),[com2Data]);

  return (
    <>
      <span>Com2的数据: {com2Data}</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      <br />
       <span>Com2的数据data: {data}</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      <br />
      <button
        onClick={() => {
          setCom2Data(com2Data);
        }}
      >
        点击Com2Data
      </button>
      <button
        onClick={() => {
          setCom2Data(com2Data);
        }}
      >
        点击Com2Data
      </button>
    </>
  );
});

基于 node-rtsp-stream 的 Web 直播方案详解

作者 明月_清风
2025年12月25日 15:41

node-rtsp-stream 是一个经典的 Node.js 库,用于将 RTSP 流(如 IP 摄像头)通过 FFmpeg 转码为 MPEG1 格式,并经 WebSocket 推送给浏览器,使用 jsmpeg 客户端解码播放,实现低延迟的网页直播。适合监控、实时视频等场景。

注意:该库最后更新于 6 年前(npm 版本 0.0.9),但核心功能仍可用(依赖 FFmpeg 和 WebSocket)。如果遇到兼容问题,可考虑 fork 版本如 node-rtsp-stream-es6 或更现代的替代(如 rtsp-relay + WebRTC)。

1. 环境准备

  • 安装 Node.js(推荐 v18+)
  • 安装 FFmpeg(必须,确保系统 PATH 中可访问 ffmpeg 命令)
    • Windows:从官网下载并添加 PATH
    • Linux:sudo apt install ffmpeg
    • macOS:brew install ffmpeg
  • 创建项目文件夹:
    mkdir rtsp-web-live
    cd rtsp-web-live
    npm init -y
    npm install node-rtsp-stream
    

2. 服务器端代码(server.js)

创建一个 Node.js 服务器,启动 RTSP 转 WebSocket 流。

const Stream = require('node-rtsp-stream');

const stream = new Stream({
  name: 'camera-live',                          // 流名称(可选)
  streamUrl: 'rtsp://your-rtsp-url',            // 替换为你的 RTSP 地址,例如:
                                                // rtsp://admin:12345@192.168.1.100:554/Streaming/Channels/101
                                                // 测试用公开流:rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov
  wsPort: 9999,                                 // WebSocket 监听端口
  ffmpegOptions: {                              // FFmpeg 参数(可选优化)
    '-stats': '',                               // 显示统计
    '-r': 30,                                   // 输出帧率(建议与源匹配)
    '-b:v': '1M',                               // 视频比特率(控制画质/流量)
    '-bf': 0,                                   // 禁用 B 帧(降低延迟)
    '-rtsp_transport': 'tcp'                    // 使用 TCP 传输(更稳定,UDP 可能丢包)
  }
});

console.log('RTSP 流已启动,WebSocket 监听在 ws://你的服务器IP:9999');

运行服务器:

node server.js

3. 客户端网页播放(index.html)

创建一个简单的 HTML 文件,使用 jsmpeg 在浏览器中播放。

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>RTSP Web 直播</title>
  <style>
    body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
    canvas { max-width: 100%; max-height: 100vh; }
  </style>
</head>
<body>
  <canvas id="videoCanvas"></canvas>

  <!-- 从 CDN 加载 jsmpeg(最新版) -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jsmpeg/0.2/jsmpeg.min.js"></script>
  <script>
    const player = new JSMpeg.Player('ws://你的服务器IP:9999', {
      canvas: document.getElementById('videoCanvas'),
      autoplay: true,          // 自动播放
      audio: false,            // 大多数 RTSP 无音频,或关闭以减少负载
      videoBufferSize: 512 * 1024,  // 增大缓冲(可选,改善卡顿)
      onVideoDecode: () => console.log('视频解码中...')  // 可选日志
    });
  </script>
</body>
</html>
  • 用浏览器打开此 HTML 文件(或通过简单 HTTP 服务器如 npx serve 托管)。
  • 替换 ws://你的服务器IP:9999 为实际地址(局域网测试用本地 IP,公网需端口映射)。

4. 多路摄像头支持

每个流需独立 WebSocket 端口:

// 多路示例
new Stream({ streamUrl: 'rtsp://cam1-url', wsPort: 9999 });
new Stream({ streamUrl: 'rtsp://cam2-url', wsPort: 10000 });
// 客户端对应不同 wsPort 播放

5. 常见问题与优化

  • 延迟:通常 1-3 秒(MPEG1 + WebSocket 特性),适合监控,不适合超低延迟(<1s 用 WebRTC 方案)。
  • 画质/卡顿:调整 -r-b:v、分辨率(如添加 -s 640x480)。
  • 无视频:检查 RTSP URL(用 VLC 测试),确保摄像头帧率 ≥15fps。
  • 音频:若需音频,设 audio: true,但 MPEG1 音频支持有限。
  • 安全性:公网部署加 HTTPS/WSS,或认证。
  • 错误日志:服务器运行时观察控制台 FFmpeg 输出。

6. 测试建议

用公开 RTSP 测试流验证: rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov

pyenv 安装的 python 版本缺少 tkinter 报错 import _tkinter # If this fails your Python xxx

作者 卡尔特斯
2025年12月25日 15:25

一、简介

  • 使用 tkinter 生成页面的时候报错:

    Traceback (most recent call last):
      File "/Users/xxx/Desktop/Project/python/duanju_python_pczs/gui.py", line 20, in <module>
        import tkinter as tk
      File "/Users/xxx/.pyenv/versions/3.11.0/lib/python3.11/tkinter/__init__.py", line 38, in <module>
        import _tkinter # If this fails your Python may not be configured for Tk
        ^^^^^^^^^^^^^^^
    ImportError: dlopen(/Users/xxx/.pyenv/versions/3.11.0/lib/python3.11/lib-dynload/_tkinter.cpython-311-darwin.so, 0x0002): Library not loaded: /opt/homebrew/opt/tcl-tk/lib/libtk8.6.dylib
      Referenced from: <E1D3F9E7-858B-3AC7-9D7B-9827F56D3FEF> /Users/xxx/.pyenv/versions/3.11.0/lib/python3.11/lib-dynload/_tkinter.cpython-311-darwin.so
      Reason: tried: '/opt/homebrew/opt/tcl-tk/lib/libtk8.6.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/opt/homebrew/opt/tcl-tk/lib/libtk8.6.dylib' (no such file), '/opt/homebrew/opt/tcl-tk/lib/libtk8.6.dylib' (no such file), '/opt/homebrew/Cellar/tcl-tk/9.0.2/lib/libtk8.6.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/opt/homebrew/Cellar/tcl-tk/9.0.2/lib/libtk8.6.dylib' (no such file), '/opt/homebrew/Cellar/tcl-tk/9.0.2/lib/libtk8.6.dylib' (no such file)
    
  • 原因是:

    当前的 Python 是用 pyenv 装的,但系统里没有它期望版本的 tcl-tk (8.6),只装了 tcl-tk 9.x,导致 _tkinter 动态库加载失败。

二、Mac 解决方案

  • 使用 brew 搜索 $ brew search tcl-tk 并安装 brew install tcl-tk@8

  • 配置环境变量

    $ open ~/.zshrc
    

    不推荐是 /opt/homebrew/Cellar/tcl-tk@8/ 目录下的,推荐使用 /opt/homebrew/opt/tcl-tk@8/ 目录下的:

    export PATH="/opt/homebrew/opt/tcl-tk@8/bin:$PATH"
    export LDFLAGS="-L/opt/homebrew/opt/tcl-tk@8/lib"
    export CPPFLAGS="-I/opt/homebrew/opt/tcl-tk@8/include"
    export PKG_CONFIG_PATH="/opt/homebrew/opt/tcl-tk@8/lib/pkgconfig"
    

    报错后执行:

    $ source ~/.zshrc
    
  • 然后需要重装 python 版本,⚠️ 不重装 Python 是没用的,_tkinter 是编译期决定的

    $ pyenv uninstall 3.11.0
    $ pyenv install 3.11.0
    

    当然也有临时补丁方案,配置的环境变量不同,但是不能一劳永逸。

  • 重新安装好后,重新运行项目即可。

    image.png

二、Windows 解决方案

  • 暂时没遇到,遇到再补充....

Chrome 插件开发科普:从零开始打造你的浏览器小工具

作者 明月_清风
2025年12月25日 15:08

你有没有想过,为什么 Chrome 浏览器那么强大?很大程度上是因为它的“扩展程序”(俗称 Chrome 插件)。这些小工具可以帮你屏蔽广告、翻译网页、管理密码,甚至自动填写表单。它们就像浏览器的“超级英雄披风”,让普通浏览器变得无所不能。

其实,开发一个 Chrome 插件并不难!它本质上就是用你熟悉的 Web 技术(HTML、CSS、JavaScript)构建的小程序,加上一些 Chrome 专属的 API,就能实现神奇的功能。目前,Chrome 插件的主流标准是 Manifest V3(简称 MV3),这是 Google 从 2023 年起强制推行的版本,比老的 V2 更安全、更高效。

什么是 Chrome 插件?为什么值得学?

Chrome 插件(官方叫 Extensions)是一个压缩包,里面包含配置文件、脚本和资源文件。它可以:

  • 修改网页内容(比如自动高亮关键词)。
  • 添加浏览器按钮(点击弹出小窗口)。
  • 在后台运行(监听事件、存储数据)。
  • 与网页互动(注入脚本)。

学习开发插件的好处:

  • 门槛低:只需会前端基础。
  • 实用性强:解决个人痛点,或分享给别人。
  • 潜力大:上传到 Chrome 网上应用店,就能被全球用户安装。

插件的核心结构

一个最简单的插件只需要一个文件夹,里面放几个文件:

  1. manifest.json:插件的“身份证”,必须放在根目录。它定义插件名称、版本、权限和功能。基本内容大概这样:

    {
      "manifest_version": 3,
      "name": "我的第一个插件",
      "version": "1.0",
      "description": "一个简单的 Hello World 插件",
      "action": {
        "default_popup": "popup.html",
        "default_icon": "icon.png"
      },
      "permissions": ["storage", "activeTab"]
    }
    
  2. popup.html:点击插件图标时弹出的小窗口界面。弹出窗口(popup)界面。你可以用 HTML + CSS 随意设计。

  3. 其他常见文件

    • background.js(服务工作者):后台脚本,处理事件。
    • content.js:注入到网页的脚本,能直接操作页面 DOM。
    • icon.png:插件图标(推荐 128x128 像素)。

如何从零开始开发一个插件?

  1. 创建文件夹:新建一个空文件夹,比如叫 my-extension

  2. 写 manifest.json:复制上面的示例。

  3. 添加 popup.html

    <!DOCTYPE html>
    <html>
    <body>
      <h1>Hello World!</h1>
      <script src="popup.js"></script>
    </body>
    </html>
    
  4. 加载测试

    • 打开 Chrome,输入 chrome://extensions/
    • 开启右上角“开发者模式”。
    • 点击“加载已解压的扩展程序”,选择你的文件夹。
    • 刷新页面,插件图标就出现在工具栏了!点击试试。
  5. 调试:修改代码后,在扩展页面点击“重新加载”。用开发者工具(F12)查看 console 日志。

进阶功能举例

  • 改变网页背景:用 content script 注入 JS 修改 document.body.style.backgroundColor
  • 存储数据:用 chrome.storage API 保存用户设置。
  • 通信:popup 和 background 用 chrome.runtime.sendMessage 互相发消息。

官方推荐的入门教程:从一个 “Hello World” 开始,逐步添加功能(参考 Chrome 官方文档)。

发布你的插件

开发好了?打包成 .zip,注册 Chrome Web Store 开发者账号(需付一次性 5 美元),上传审核,就能上架了!

结语

Chrome 插件开发就像搭积木:简单部件组合出强大功能。很多人从一个“小痒点”开始,比如“自动跳过视频广告”,最后开发出热门插件。感兴趣的话,从官方文档起步(developer.chrome.com/docs/extens…

动手试试吧,你的第一个插件可能就藏在下一个灵感里!

npm Classic Token 作废后,CI/CD 自动发包如何改?一份完整踩坑复盘

2025年12月24日 11:03

背景

最近在给 npm 包做 CI 发版时,突然开始频繁失败,npm 的提示也非常直白:

image.png

Classic tokens have been revoked.
Granular tokens are now limited to 90 days and require 2FA by default.
Update your CI/CD workflows to avoid disruption.

一句话总结就是:

老的 npm token 方案,已经不适合 CI 了。

现在就记录一次完整的 CI/CD 改造过程,包含:

  • npm 新规则到底改了什么
  • CI 为什么会突然发布失败
  • 过程中遇到的几个“必踩坑”
  • 最终一套可长期运行的 GitHub Actions 发包方案

一、npm 这次到底动了谁的蛋糕?

先说结论:

npm 正在彻底废弃“长期有效 token + npm login”的发布模型。

核心变化有三点。

1. Classic Token 作废

以前最常见的做法是:

env:
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

这种 Classic Token

  • 长期有效
  • 没有明确权限边界
  • 一旦泄露,后果很严重

现在 npm 明确表态:
Classic Token 不再推荐,并会逐步失效。


2. Granular Token 默认 90 天 + 2FA

新的 Granular Token:

  • 必须开启 2FA
  • 默认 90 天过期
  • 非常不适合无人值守的 CI

👉 这意味着:
即使你换成 Granular Token,CI 依然不稳定。


3. npm 开始强制推广 provenance(供应链校验)

这是最关键、也是最容易被忽略的一点。

npm 现在会校验:

  • 这个包是不是在 GitHub Actions 里发布的
  • 发布它的仓库是谁
  • package.json.repository 指向哪里
  • GitHub OIDC 身份是否匹配

校验失败,直接拒绝发布。


二、为什么“明明登录了”,还是发布失败?

我们最初遇到的错误包括:

❌ ENEEDAUTH

npm error need auth This command requires you to be logged in

原因很简单:

  • CI 里已经不再支持老的登录方式
  • token 被判定为过期或 revoked

❌ 404 / E404

404 '@scope/package@x.y.z' is not in this registry

这个错误非常迷惑,但本质上是:

npm 拒绝了你的发布请求,并不是包不存在。


❌ 422 Unprocessable Entity(最坑)

Error verifying sigstore provenance bundle:
package.json: "repository.url" is ""
expected to match "https://github.com/infinilabs/ci"

这一步,几乎是所有人都会踩的坑。


三、npm provenance 校验在校验什么?

npm 的逻辑是:

我不只要你能发布,我还要知道你是从哪里发布的。

它会做一组严格匹配:

校验项 来源
GitHub Actions 所在仓库 CI
OIDC 身份 GitHub
package.json.repository.url 包元数据

只要有一个不一致:

👉 422,拒绝发布。


四、为什么构建产物里的 package.json 会出问题?

我们的发布流程是:

pnpm run build:web
cd out/search-chat
npm publish

注意重点:

真正 publish 的不是源码目录,而是构建产物目录。

很多项目在 build 后:

  • package.json 是重新生成的
  • repository 为空
  • 或指向源码仓库,而不是 CI 仓库

这在 npm provenance 时代,直接就是死刑。


五、正确的 CI 改造思路

目标很明确:

不用 npm token,不用 npm login,让 CI 自己完成身份认证。

核心方案是:

✅ GitHub Actions + OIDC + npm provenance


六、关键改造点一:npm 账户设置以及包设置

npm 账户设置:

image.png

npm 对应包设置:

image.png

image.png


七、关键改造点二:开启 OIDC 权限

在 workflow 顶部增加:

permissions:
  contents: read
  id-token: write

这一步非常关键:

  • id-token: write 是 npm provenance 的前提
  • 没有它,npm 无法验证 CI 身份

八、关键改造点三:彻底移除 npm token

我们做了三件事:

  • 删除 NODE_AUTH_TOKEN
  • 删除 .npmrc
  • 不再执行 npm login

CI 里只保留:

npm publish

GitHub Actions 会自动用 OIDC 身份和 npm 通信。


九、关键改造点四:修正构建产物里的 repository

在真正 publish 前,强制修正 package.json

jq '.repository = {
  "type": "git",
  "url": "https://github.com/infinilabs/ci"
}' package.json > tmp.json && mv tmp.json package.json

注意几个要点:

  • URL 必须是当前 CI 所在仓库
  • 不能是源码仓库
  • 不能为空
  • 必须是 https GitHub 地址

十、最终发布流程(核心片段)

pnpm run build:web
cd out/search-chat

# 修正 repository
jq '.repository = {
  "type": "git",
  "url": "https://github.com/infinilabs/ci"
}' package.json > tmp.json && mv tmp.json package.json

# 发布
npm publish

发布成功时,npm 会输出:

  • provenance 已签名
  • 已写入 sigstore transparency log

十一、为什么不直接关 provenance?

npm 提供了:

npm publish --no-provenance

但这只是一个 临时选项

  • 官方趋势是默认开启
  • 未来可能直接强制
  • CI 早适配,后面少折腾

十二、这次改造后的收益

最终我们得到的是一套:

  • ✅ 无 token
  • ✅ 无 2FA 人工参与
  • ✅ 不会过期
  • ✅ 可审计、可追溯
  • ✅ 符合 npm 官方长期路线

真正意义上的 无人值守 CI 发包


十三、小结

npm 不再相信“你是谁”,
它只相信 你从哪里来

在这个前提下,
OIDC + provenance,不是可选项,而是必选项。

❌
❌