阅读视图

发现新文章,点击刷新页面。

我震惊了,jQuery 竟然发布了 4.0 !

作为近 10 年来的首次重大更新,jQuery 4.0.0 经历了漫长的开发周期与多个预发布版本后正式推出。

我真的以为 jQuery 死了

jq.jpeg

这款仍被广泛使用的 JavaScript 库 jQuery 现已推出 4.0.0 版本。作为近 10 年来的首次重大更新,jQuery 4.0.0 新增了可信类型(Trusted Types)支持,并提供了一个更精简的构建包。

该版本于 1 月 17 日正式发布,可从 jquery.com 下载。jQuery 4.0.0 中的可信类型功能确保,只有 TrustedHTML 接口的 HTML 内容才能传入 jQuery 的 DOM 操作方法,从而严格遵守浏览器内容安全策略(CSP)的 require-trusted-types-for 指令。

此外,尽管部分 AJAX 请求已在使用 <script> 标签来维护 crossdomain 等属性,jQuery 开发团队仍将大多数异步脚本请求切换为使用 <script> 标签,以避免内联脚本引发的 CSP 错误。仅在少数场景(如传递 headers 选项)下仍会使用 XHR 发起异步脚本请求,但只要条件允许,就会优先使用 <script> 标签。

jQuery 4.0.0 还带来了更精简的构建包,移除了 Deferred 对象和回调函数。

Deferred 长期以来支持 Promises A+ 标准,用于实现可互操作的 JavaScript Promise;不过在绝大多数场景下,除 IE 11 外,所有 jQuery 支持的浏览器都已支持原生 Promise。

官方表示,虽然 Deferred 具备一些原生 Promise 没有的额外功能,但大多数用法都可以迁移到 Promise 方法。如果你的项目仍需支持 IE 11,建议使用主构建包,或为原生 Promise 添加 polyfill。需要注意的是,jQuery 4.0.0 不再支持 IE 10 及更早版本。

根据 Web 技术调查机构 W3Techs 的数据,已诞生 20 年的 jQuery 目前仍被 70.9% 的网站使用。

该项目现由 OpenJS 基金会管理,旨在通过跨浏览器的 API 简化 HTML 文档遍历与操作、事件处理和动画等功能。jQuery 4.0.0 的其他重要更新包括:

  • 焦点事件顺序标准化:现在遵循万维网联盟(W3C)规范,使 jQuery 与大多数浏览器最新版本支持的事件顺序保持一致。这一事件顺序与旧版 jQuery 不同,属于破坏性变更。从 4.0.0 开始,库不再支持覆盖原生行为,将严格遵循当前 W3C 规范的顺序:blurfocusoutfocusfocusin
  • 移除内部私有方法:从 jQuery 原型中移除了仅内部使用的数组方法(如 pushsortsplice)。这些方法的行为与其他 jQuery 方法不一致,仅用于内部逻辑。开发者如果用到了这些被移除的方法,可以用 [].push.call($elems, elem) 替代原来的 $elems.push(elem)
  • 3.x 版本进入维护模式:jQuery 3.x 系列今后将只接收关键安全更新。

总结

我想起了那句话,老兵不死,只是慢慢凋零。

16 个前端冷知识:用一次就忘不掉的那种

“这个Bug我调了俩小时!” “早知道有这个属性就好了……”

这种对话,在程序员之间可以说是太常见了。

很多问题,一旦知道诀窍,三五分钟就能解决;可如果不知道,很可能就需要耗上大半天的时间去处理。

于是我就决定,把这些平时可能没人专门讲,但又特别实用的前端冷知识整理了一下,保准你看完有收获。

1. CSS中的:hover伪类也可以用于非链接元素

很多人以为:hover只能用在a标签上,其实不然!任何元素都可以使用:hover伪类。

/* 不只是链接,div也可以有悬停效果 */
div:hover {
  background-color: #f0f0f0;
  transition: all 0.3s ease;
}

实用场景:为表格行、卡片组件等添加悬停效果,提升用户体验。

2. 箭头函数没有自己的this绑定

这是ES6箭头函数的一个重要特性,但经常被忽略:

const obj = {
  name: "前端小白",
  regularFunc: function() {
    console.log(this.name); // "前端小白"
  },
  arrowFunc: () => {
    console.log(this.name); // undefined(这里的this是外层作用域的this)
  }
};

原理分析:箭头函数不绑定自己的this,而是继承父级作用域的this值。

3. 快速浮点数转整数

// 这三种方式都可以将浮点数转为整数
console.log(~~3.14);        // 3
console.log(3.14 | 0);      // 3
console.log(3.14 >> 0);     // 3

注意:这些方法只适用于32位整数,大数情况下可能会出现问题。

4. 使用dataset操作自定义数据属性

<div id="user" data-id="123" data-user-name="小明"></div>

<script>
const user = document.getElementById('user');
console.log(user.dataset.id);        // "123"
console.log(user.dataset.userName); // "小明"(注意驼峰命名)
</script>

优势:比getAttribute/setAttribute更简洁,且自动进行数据类型转换。

5. 使用navigator.onLine检测网络状态

// 检测用户是否在线
if (navigator.onLine) {
  // 在线逻辑
} else {
  // 离线逻辑
}

// 监听网络状态变化
window.addEventListener('online', () => {
  console.log('网络已连接');
});

应用场景:PWA应用、资源加载优化等。

6. 使用contenteditable使元素可编辑

<div contenteditable="true">
  点击我就可以直接编辑内容!
</div>

实用技巧:可以结合localStorage实现简单的实时预览编辑器。

7. 使用currentScript获取当前执行的script标签

<script>
console.log('当前脚本:', document.currentScript.src);
</script>

应用场景:在脚本中动态加载依赖资源时非常有用。

8. 使用passive优化滚动性能

// 不好的做法(可能阻塞滚动)
document.addEventListener('touchmove', function(e) {
  // 处理逻辑
});

// 好的做法
document.addEventListener('touchmove', function(e) {
  // 处理逻辑
}, { passive: true });

原理:告诉浏览器事件处理函数不会调用preventDefault(),从而提升滚动性能。

9. 使用clamp实现响应式字体大小

.text {
  font-size: clamp(16px, 4vw, 24px);
}
/* 字体大小会在16px-24px之间自适应 */

优势:比媒体查询更简洁,实现真正的流体排版。

10. 使用in操作符检查对象属性

const obj = { name: '小明', age: 20 };

// 检查属性是否存在
if ('name' in obj) {
  console.log('name属性存在');
}

与hasOwnProperty的区别:in会检查原型链上的属性,而hasOwnProperty只检查自身属性。

11. 使用Array.from将类数组转为真实数组

// 将NodeList转为数组
const divs = Array.from(document.querySelectorAll('div'));

// 将arguments转为数组
function example() {
  const args = Array.from(arguments);
  // 现在可以使用数组方法了
}

12. 使用performance API进行性能监控

// 标记开始时间
performance.mark('start');

// 执行一些操作
for(let i = 0; i < 1000000; i++) {}

// 标记结束时间并测量
performance.mark('end');
performance.measure('操作耗时', 'start', 'end');

const measure = performance.getEntriesByName('操作耗时')[0];
console.log(`操作耗时: ${measure.duration}毫秒`);

13. 使用structuredClone进行深拷贝

const obj = { name: "小明", hobbies: ["篮球", "游泳"] };
const cloned = structuredClone(obj); // 真正的深拷贝

优势:比JSON.parse(JSON.stringify(obj))更可靠,可以处理循环引用。

14. 使用CSS的:where和:is简化选择器

/* 传统写法 */
header h1, header h2, header h3 {
  margin-bottom: 1rem;
}

/* 简化写法 */
header :is(h1, h2, h3) {
  margin-bottom: 1rem;
}

优势:代码更简洁,易于维护。

15. 使用requestIdleCallback进行任务调度

function processTask(deadline) {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    // 在空闲时间执行任务
    processTask(tasks.shift());
  }
  
  if (tasks.length > 0) {
    requestIdleCallback(processTask);
  }
}

requestIdleCallback(processTask);

应用场景:大数据渲染、复杂计算等需要优化性能的场景。

16. 你可能不知道的console.log黑科技

最后一个,你可能不知道console.log还有这些用法:

// 1. 使用CSS样式
console.log('%c这是红色大字', 'color: red; font-size: 20px;');

// 2. 分组打印
console.group('用户信息');
console.log('姓名: 小明');
console.log('年龄: 20');
console.groupEnd();

// 3. 条件打印
console.assert(1 === 2, '这个条件为false时会打印');

总结

说实话,上面这些小技巧,我现在也记不全。平时写业务代码,可能80%的时间都用不上它们。

但看上一两遍有点印象之后,在哪天碰到某种问题时,你也会突然想起来:

“好像有个属性/方法就是干这个的”

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

从静态数组到环形数组:手把手实现与底层原理剖析

从静态数组到环形数组:手把手实现与底层原理剖析

数组作为最基础的数据结构,其核心优势是O(1)级随机访问能力,而动态数组与环形数组则是在静态数组基础上的优化与拓展。本文将先拆解静态数组的存储原理与增删查改逻辑,再一步步实现新手友好的闭区间环形数组,帮你吃透数组类结构的底层运行机制。

TL;DR

circle_arr.png

一、数组顺序存储的基本原理

静态数组是一段连续的内存空间,通过首地址和索引即可直接计算目标元素的内存地址,这也是其随机访问高效的核心原因。

1. 静态数组的内存机制

以C++代码为例,静态数组的内存分配与访问逻辑如下:


// 开辟10个int类型的连续内存空间,共40字节(1个int占4字节)
// arr为指针,指向这段空间的首地址
int arr[10];

// 初始化内存,避免二手内存数据干扰
memset(arr, 0, sizeof(arr));

// 给首地址对应的内存写入1
arr[0] = 1;
// 给首地址偏移4字节(1*sizeof(int))的位置写入2
arr[1] = 2;

// 随机访问:通过索引计算地址,时间复杂度O(1)
int a = arr[0];

由于内存寻址时间可视为O(1),静态数组的随机访问(查、改)操作时间复杂度均为O(1),但增删操作受限于连续内存特性,效率会因位置不同而有差异。

2. 基于静态数组的实现动态数组的增删查改操作

数组的核心职责是增删查改,其中查和改基于随机访问特性已实现高效,重点在于增删操作的逻辑与复杂度分析。 通过对静态数组的操作来理解动态数组的API。

(1)增加操作

增加操作分“空间未满”和“空间已满”两种场景,复杂度随插入位置变化。

场景1:空间未满

现有长度为10的数组,前4个元素为0、1、2、3:

  • 尾部追加(push):直接给索引4赋值,时间复杂度O(1),代码为arr[4] = 4

  • 中间插入(insert):需倒序移动元素腾位置,避免覆盖已有数据,时间复杂度O(N)。例如在索引2插入666:


// 倒序移动索引2及后续元素,给新元素腾位置
for (int i = 4; i > 2; i--) {
    arr[i] = arr[i - 1];
}
// 插入新元素
arr[2] = 666;

场景2:空间已满

当数组填满时,需执行“扩容”操作:重新申请更大内存、复制原数据、释放旧内存,整体时间复杂度O(N)。例如给满元素数组追加10:


// 申请容量为20的新数组
int newArr[20];
// 复制原数组数据
for (int i = 0; i < 10; i++) {
    newArr[i] = arr[i];
}
// 释放旧数组内存(避免内存泄漏)
// ...
// 追加新元素
newArr[10] = 10;

(2)删除操作

删除操作同样分尾部和中间位置,核心是数据搬移与标记删除。

现有长度为10的数组,前4个元素为0、1、2、3:

  • 尾部删除:直接标记尾部元素为特殊值(如-1),时间复杂度O(1),代码为arr[3] = -1

  • 中间删除:需正序移动元素覆盖待删除位置,时间复杂度O(N)。例如删除索引1的元素:


// 正序移动元素,覆盖待删除位置
for (int i = 1; i < 4; i++) {
    arr[i] = arr[i + 1];
}
// 标记最后一个位置为删除状态
arr[4] = -1;

(3)时间复杂度总结

  • 增:尾部追加O(1),中间插入O(N),扩容O(N);

  • 删:尾部删除O(1),中间删除O(N);

  • 查/改:随机访问O(1)。

动态数组的本质的是对静态数组的封装,自动处理扩缩容,简化用户操作,而环形数组则是进一步优化了动态数组的空间利用率。

二、手把手实现闭区间环形数组(

环形数组通过“首尾衔接”的逻辑复用数组空间,闭区间版本的核心是让startend均指向有效元素(start为第一个,end为最后一个),逻辑更贴合直觉,下面从0到1分步实现。

1. 核心规则前置(必记)

所有操作均围绕以下规则展开,避免指针混乱和逻辑错误:

核心概念 定义/规则
capacity 物理容量,底层数组的长度,支持动态扩缩容
count 有效元素个数,判断空/满的核心依据(优先使用,不依赖指针)
start 闭区间起点,指向第一个有效元素的索引
end 闭区间终点,指向最后一个有效元素的索引
空数组 count === 0(禁止用start === end判断,满数组也可能出现该情况)
满数组 count === capacity(唯一判断标准)
新增元素 先移动指针,再赋值(尾部增移end,头部增移start)
删除元素 先清空值,再移动指针(尾部删移end,头部删移start)

2. 第一步:初始化类与基础辅助方法

先搭建类的骨架,实现判断空/满、获取有效个数、遍历等基础方法,为后续核心操作铺垫。


/**
 * 闭区间环形数组(新手友好版)
 * 核心规则:[start, end] 均为有效元素,start=第一个,end=最后一个
 */
class CycleArrayClosed {
  // 构造函数:初始化物理容量(默认1)
  constructor(initSize = 1) {
    this.capacity = initSize; // 物理容量
    this.arr = new Array(initSize); // 底层存储数组
    this.start = 0; // 有效元素起始索引(闭区间)
    this.end = 0; // 有效元素结束索引(闭区间)
    this.count = 0; // 有效元素个数(核心判断依据)
  }

  // 辅助方法1:判断数组是否为空
  isEmpty() {
    return this.count === 0;
  }

  // 辅助方法2:判断数组是否已满
  isFull() {
    return this.count === this.capacity;
  }

  // 辅助方法3:获取有效元素个数(对外暴露)
  getCount() {
    return this.count;
  }

  // 辅助方法4:遍历所有有效元素(调试/展示用)
  traverse() {
    const result = [];
    if (this.isEmpty()) return result;

    // 分两种场景遍历,避免环形场景漏元素
    if (this.start <= this.end) {
      // 场景1:线性区间(未绕圈)
      for (let i = this.start; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    } else {
      // 场景2:环形区间(已绕圈),分两段遍历
      for (let i = this.start; i < this.capacity; i++) {
        result.push(this.arr[i]);
      }
      for (let i = 0; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    }
    return result;
  }
}

测试基础方法


// 初始化容量为3的环形数组
const arr = new CycleArrayClosed(3);
console.log("是否为空:", arr.isEmpty()); // true
console.log("有效个数:", arr.getCount()); // 0
console.log("遍历结果:", arr.traverse()); // []

3. 第二步:实现扩缩容(resize)核心方法

环形数组扩容时,需将旧数组的有效元素“平铺”到新数组开头,重置指针使新数组回归线性状态,避免后续操作复杂。缩容则在有效元素过少时触发,优化空间利用率。


/**
 * 扩容/缩容方法
 * @param {number} newCapacity - 新的物理容量
 */
resize(newCapacity) {
  const newArr = new Array(newCapacity);
  // 复制旧数组有效元素到新数组
  for (let i = 0; i < this.count; i++) {
    // 核心公式:环形遍历旧数组有效元素
    // (start + i) % capacity 可适配线性/环形两种场景
    const oldIndex = (this.start + i) % this.capacity;
    newArr[i] = this.arr[oldIndex];
  }
  // 替换底层数组,重置指针和容量
  this.arr = newArr;
  this.start = 0; // 新数组有效元素从0开始
  this.end = this.count - 1; // 闭区间终点为最后一个有效元素索引
  this.capacity = newCapacity;
}

测试扩容逻辑


const arr = new CycleArrayClosed(3);
// 手动模拟填充元素
arr.arr[0] = 1;
arr.arr[1] = 2;
arr.arr[2] = 3;
arr.start = 0;
arr.end = 2;
arr.count = 3;

console.log("扩容前遍历:", arr.traverse()); // [1,2,3]
arr.resize(5); // 扩容到5
console.log("扩容后容量:", arr.capacity); // 5
console.log("扩容后遍历:", arr.traverse()); // [1,2,3]
console.log("扩容后指针:start=", arr.start, "end=", arr.end); // start=0, end=2

4. 第三步:实现尾部添加(addLast)

尾部添加遵循“先移指针再赋值”规则,空数组需特殊处理,满数组先扩容。


/**
 * 尾部添加元素(时间复杂度O(1))
 * 规则:满了先扩容 → 空数组特殊处理 → 非空先移end再赋值
 */
addLast(val) {
  // 满数组先扩容(扩容2倍为行业通用策略,平衡效率与空间)
  if (this.isFull()) {
    this.resize(this.capacity * 2);
  }

  // 空数组:直接赋值到0位置,指针均指向0
  if (this.isEmpty()) {
    this.arr[0] = val;
    this.start = 0;
    this.end = 0;
  } else {
    // 非空数组:右移end指针(取模实现环形衔接),再赋值
    this.end = (this.end + 1) % this.capacity;
    this.arr[this.end] = val;
  }

  this.count++; // 有效元素个数+1
}

测试尾部添加


const arr = new CycleArrayClosed(3);
// 填充3个元素(填满容量)
arr.addLast(1);
arr.addLast(2);
arr.addLast(3);
console.log("添加3个元素后遍历:", arr.traverse()); // [1,2,3]
console.log("是否满:", arr.isFull()); // true

// 追加第4个元素(触发扩容到6)
arr.addLast(4);
console.log("扩容后容量:", arr.capacity); // 6
console.log("扩容后遍历:", arr.traverse()); // [1,2,3,4]

5. 第四步:实现头部添加(addFirst)

头部添加遵循“先移指针再赋值”规则,空数组可复用addLast逻辑,左移指针时需加capacity避免负数。


/**
 * 头部添加元素(时间复杂度O(1))
 * 规则:满了先扩容 → 空数组调用addLast → 非空先移start再赋值
 */
addFirst(val) {
  if (this.isFull()) {
    this.resize(this.capacity * 2);
  }

  // 空数组复用尾部添加逻辑,避免重复代码
  if (this.isEmpty()) {
    this.addLast(val);
  } else {
    // 左移start指针(+capacity确保索引非负)
    this.start = (this.start - 1 + this.capacity) % this.capacity;
    this.arr[this.start] = val;
    this.count++;
  }
}

测试头部添加


const arr = new CycleArrayClosed(3);
arr.addFirst(1);
console.log("头部加1后遍历:", arr.traverse()); // [1]
console.log("start指针:", arr.start); // 2((0-1+3)%3=2)

// 再添加2个元素(填满容量)
arr.addFirst(2);
arr.addFirst(3);
console.log("填满后遍历:", arr.traverse()); // [3,2,1]

// 追加第4个元素(触发扩容)
arr.addFirst(4);
console.log("扩容后遍历:", arr.traverse()); // [4,3,2,1]

6. 第五步:实现尾部删除(removeLast)

尾部删除遵循“先清空值再移指针”规则,只剩1个元素时需重置指针,有效元素过少时触发缩容。


/**
 * 尾部删除元素(时间复杂度O(1))
 * 规则:空数组抛错 → 清空end值 → 移指针 → 缩容判断
 */
removeLast() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, cannot remove last element");
  }

  // 清空当前end位置的值,避免内存泄漏
  this.arr[this.end] = null;

  // 只剩1个元素:删除后变为空数组,重置指针
  if (this.count === 1) {
    this.start = 0;
    this.end = 0;
  } else {
    // 左移end指针(+capacity确保索引非负)
    this.end = (this.end - 1 + this.capacity) % this.capacity;
  }

  this.count--;

  // 缩容:有效元素为容量1/4时缩容到1/2,避免频繁缩容
  if (this.count > 0 && this.count === this.capacity / 4) {
    this.resize(Math.floor(this.capacity / 2));
  }
}

7. 第六步:实现头部删除(removeFirst)

头部删除遵循“先清空值再移指针”规则,只剩1个元素时复用removeLast逻辑,缩容逻辑与尾部删除一致。


/**
 * 头部删除元素(时间复杂度O(1))
 * 规则:空数组抛错 → 只剩1个元素调用removeLast → 清空start值 → 移指针 → 缩容
 */
removeFirst() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, cannot remove first element");
  }

  // 只剩1个元素,复用尾部删除逻辑
  if (this.count === 1) {
    this.removeLast();
    return;
  }

  // 清空当前start位置的值
  this.arr[this.start] = null;
  // 右移start指针
  this.start = (this.start + 1) % this.capacity;
  this.count--;

  // 缩容判断
  if (this.count > 0 && this.count === this.capacity / 4) {
    this.resize(Math.floor(this.capacity / 2));
  }
}

8. 第七步:实现首尾元素获取(getFirst/getLast)

直接通过start/end指针取值,只需判断空数组避免报错。


/**
 * 获取头部元素
 */
getFirst() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, no first element");
  }
  return this.arr[this.start];
}

/**
 * 获取尾部元素
 */
getLast() {
  if (this.isEmpty()) {
    throw new Error("CycleArray is empty, no last element");
  }
  return this.arr[this.end];
}

9. 完整代码与综合测试

完整代码


class CycleArrayClosed {
  constructor(initSize = 1) {
    this.capacity = initSize;
    this.arr = new Array(initSize);
    this.start = 0;
    this.end = 0;
    this.count = 0;
  }

  isEmpty() {
    return this.count === 0;
  }

  isFull() {
    return this.count === this.capacity;
  }

  getCount() {
    return this.count;
  }

  traverse() {
    const result = [];
    if (this.isEmpty()) return result;
    if (this.start <= this.end) {
      for (let i = this.start; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    } else {
      for (let i = this.start; i < this.capacity; i++) {
        result.push(this.arr[i]);
      }
      for (let i = 0; i <= this.end; i++) {
        result.push(this.arr[i]);
      }
    }
    return result;
  }

  resize(newCapacity) {
    const newArr = new Array(newCapacity);
    for (let i = 0; i< this.count; i++) {
      const oldIndex = (this.start + i) % this.capacity;
      newArr[i] = this.arr[oldIndex];
    }
    this.arr = newArr;
    this.start = 0;
    this.end = this.count - 1;
    this.capacity = newCapacity;
  }

  addLast(val) {
    if (this.isFull()) {
      this.resize(this.capacity * 2);
    }
    if (this.isEmpty()) {
      this.arr[0] = val;
      this.start = 0;
      this.end = 0;
    } else {
      this.end = (this.end + 1) % this.capacity;
      this.arr[this.end] = val;
    }
    this.count++;
  }

  addFirst(val) {
    if (this.isFull()) {
      this.resize(this.capacity * 2);
    }
    if (this.isEmpty()) {
      this.addLast(val);
    } else {
      this.start = (this.start - 1 + this.capacity) % this.capacity;
      this.arr[this.start] = val;
      this.count++;
    }
  }

  removeLast() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, cannot remove last element");
    }
    this.arr[this.end] = null;
    if (this.count === 1) {
      this.start = 0;
      this.end = 0;
    } else {
      this.end = (this.end - 1 + this.capacity) % this.capacity;
    }
    this.count--;
    if (this.count > 0 && this.count === this.capacity / 4) {
      this.resize(Math.floor(this.capacity / 2));
    }
  }

  removeFirst() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, cannot remove first element");
    }
    if (this.count === 1) {
      this.removeLast();
      return;
    }
    this.arr[this.start] = null;
    this.start = (this.start + 1) % this.capacity;
    this.count--;
    if (this.count > 0 && this.count === this.capacity / 4) {
      this.resize(Math.floor(this.capacity / 2));
    }
  }

  getFirst() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, no first element");
    }
    return this.arr[this.start];
  }

  getLast() {
    if (this.isEmpty()) {
      throw new Error("CycleArray is empty, no last element");
    }
    return this.arr[this.end];
  }
}

综合测试用例


// 初始化数组
const arr = new CycleArrayClosed(3);

// 测试新增操作
arr.addLast(1);
arr.addLast(2);
arr.addFirst(0);
console.log("新增后遍历:", arr.traverse()); // [0,1,2]
console.log("首尾元素:", arr.getFirst(), arr.getLast()); // 0 2

// 测试删除操作
arr.removeFirst();
arr.removeLast();
console.log("删除后遍历:", arr.traverse()); // [1]
console.log("有效个数:", arr.getCount()); // 1

// 测试扩容与环形遍历
arr.addLast(3);
arr.addLast(4);
arr.addFirst(5);
console.log("环形遍历:", arr.traverse()); // [5,1,3,4]

// 测试缩容
arr.removeFirst();
arr.removeLast();
arr.removeLast();
console.log("缩容后容量:", arr.capacity); // 3
console.log("缩容后遍历:", arr.traverse()); // [1]

三、核心易错点总结(新手必避坑)

  1. 忘记更新count:增删操作后必须同步增减count,否则空/满判断会完全失效。

  2. 指针移动顺序颠倒:新增需“先移指针再赋值”,删除需“先清空再移指针”,顺序错会导致数据覆盖或丢失。

  3. 左移指针未加capacity:直接start-1可能得到负数索引,需通过(start-1 + capacity) % capacity确保索引合法。

  4. 用指针判断空/满:start === end既可能是空数组,也可能是满数组,唯一可靠的判断是count===0(空)、count===capacity(满)。

  5. 遍历漏环形场景:当start > end时,需分[start, capacity-1][0, end]两段遍历,否则会漏元素。

四、总结与拓展

闭区间环形数组通过指针逻辑复用空间,解决了静态数组中间增删效率低、空间利用率不足的问题,其核心优势是首尾增删均能达到O(1)时间复杂度,仅扩缩容和遍历(环形场景)需O(N)时间。

本文从静态数组原理铺垫,到分步实现环形数组,核心是帮大家理解“封装”与“优化”的思路——动态数组封装了扩缩容,环形数组则进一步优化了指针逻辑。实际开发中,JavaScript的Array本质是动态数组,而环形数组可用于实现队列、循环缓冲区等场景。

若需拓展功能,可基于本文代码实现按索引访问、修改元素等操作,核心是通过(start + index) % capacity计算目标元素索引。

五、练习

多标签页强提醒不重复打扰:从“弹框轰炸”到“共享待处理队列”的实战

场景:我在多标签页里“接力”处理紧急待办

这篇文章讨论的不是“消息列表怎么做”,而是紧急待办的强提醒体验应该如何落地。我的核心需求很明确:

  • 紧急消息必须强制弹框提醒(不能靠用户自己去小铃铛里找)
  • 弹框不能手动关闭,只能通过“去处理/已读”等业务动作逐条消解
  • 刷新后仍要继续弹:只要还有“高优先级且未处理”的消息,就必须再次弹框
  • 多标签页不重复打扰:同一时间只允许一个标签页弹;未处理的消息能跨标签页接力,不丢失 ✅

问题 1:多标签页重复强弹(“弹框轰炸”)💥

现象

  • A 中点“去处理”打开 B
  • B 打开后会立即执行轮询(而 A 里此时还有 3 条未处理)
  • 于是 B 会再次强弹:同一批剩余 3 条被重复弹出 😵

一句话总结:两个入口(WS + 初始化轮询)叠加在“多标签页”上,会让强提醒被重复触发

我认为合理的产品设计应该是什么样?🧩

我的判断标准很简单:既要“强提醒不遗漏”,也要“用户不被打断到崩溃”。

  • 同一时刻只能有一个强提醒弹框(避免轰炸)✅
  • 弹框容器支持多条消息(用户能逐条处理)✅
  • 点击“去处理”后,新标签页应该进入处理模式
    • 不再重复强弹当前未处理的那一批(否则每开一个 tab 都弹一次)✋
    • 但消息仍需保留在“小铃铛/待处理列表”里(避免漏掉)✅
  • 当“处理标签页关闭或处理结束”,系统再允许其他标签页接力弹框 ✅

解决思路

先把“是否允许弹框”这件事独立出来:
用一个全局锁控制“同一时间只有一个标签页允许弹框” 👇

flowchart LR
  M[紧急消息到达] --> L{全局锁存在?}
  L -- 是 --> Q[不弹框/仅记录]
  L -- 否 --> S[获得锁并弹框]

解决方案选择:锁放哪儿?锁归属怎么判?

要让“别的标签页不弹”很简单,但我还需要保证:当前弹框页可以继续追加新紧急消息
这就引出了一个细节:我不仅要知道“有没有锁”,还要知道“锁属于谁” 👉

我当时的选型路径是一个很典型的逐步排除法(先快后稳 👍):

  • sessionStorage:上手快,但“同标签页跳转仍共享”,A→B 会错判“我还是持锁页” ✋
  • window(自定义 key):可跨页保存,但 window 全局属性容易被别的脚本覆盖 ⚠️
  • Pinia(不持久化)与应用状态一致、可控、风险低

为什么 Pinia 不持久化

  • Pinia 的这个 key 本质是“临时归属标记”,只服务于当前运行时
  • 如果持久化,浏览器异常关闭/崩溃导致未清理,会出现锁遗留,后续可能一直不弹强提醒 😵

最终方案(问题 1)

  • localStorage:存“全局锁”本体(跨标签页共享)
  • Pinia:存“当前标签页持有的锁 key”(仅当前标签页生效)

示例代码(与实现一致):

const urgentDialogActivePrefix = 'crm.urgent_dialog_active:';

export function setUrgentDialogActive() {
  const store = useNotificationStore();
  const existingKey = findUrgentDialogActiveKey();
  if (existingKey) return existingKey;
  try {
    const key = `${urgentDialogActivePrefix}${Date.now()}`;
    localStorage.setItem(key, '1');
    store.setUrgentDialogActiveKey(key);
    return key;
  } catch {
    return null;
  }
}

export function isUrgentDialogActiveForCurrentTab() {
  const store = useNotificationStore();
  try {
    const key = store.urgentDialogActiveKey;
    if (!key) return false;
    return localStorage.getItem(key) === '1';
  } catch {
    return false;
  }
}

问题 2:关闭 A 后,B 只弹新消息,旧的 3 条“丢了”😵

现象

在问题 1 的锁机制生效后:

  • B 不会重复弹框 ✅
  • WS 的新紧急消息会继续 push 到 A 的弹框 ✅
  • 但当 A 关闭后,B 再收到新消息时,只展示新来的 1 条 ❌

本质问题:弹框是“唯一入口”,但紧急消息的“待处理状态”没有被稳定地“先存起来”。一旦持锁页关闭,下一标签页如果只基于“新来的 WS 消息”触发弹框,就容易出现“旧的未处理没带上”的错觉。

解决思路

把“消息状态”从“弹框状态”里解耦出来:
弹框只是 UI,待处理列表才是关键。

这里我后来更偏向一个更轻量的实现:队列不跨标签页持久化,而是交给“页面加载必定会执行一次的轮询”来重建——

  • 先轮询一次,把“高优先级且未处理”的消息塞进 Pinia 队列
  • 轮询成功后再连接 WS
  • 后面无论是轮询刷新还是 WS 推送,先把消息写入 Pinia 队列;能弹时一次性把队列里的都弹出来 ✅

解决方案选择:未处理队列放 localStorage 还是 Pinia?

这里的核心不是“哪个存储更强”,而是我们的事实源是什么
既然页面加载(以及后续定时)都会轮询到“高优先级且未处理”的消息,那么队列完全可以由轮询在每个标签页内重建;此时把队列写进 localStorage 反而会引入额外风险。

  • 方案 A:localStorage 存队列(跨标签页共享/持久化)
    • 优点:跨标签页天然共享;刷新/崩溃后仍可恢复
    • 代价:有空间上限(通常几 MB),队列稍大或字段稍多就可能触发 setItem 失败;还要额外设计 TTL/容量上限/清理策略,否则容易“越积越多”
  • 方案 B:Pinia 存队列(内存态,每 tab 自己维护)
    • 优点:没有 localStorage 的序列化/配额风险;状态更新更直接、可控;与“页面加载立即轮询一次”的事实源一致
    • 代价:队列不跨标签页共享,因此需要把“接力”交给轮询:持锁页关闭后,其他标签页通过轮询重建队列再弹框

我选择 Pinia 队列 + localStorage 只存锁
队列的权威来源是“轮询返回的未处理紧急消息”,而不是浏览器本地持久化;这样做能把失败面缩到最小,同时仍能满足“接力不丢”的体验目标 ✅

最终方案(问题 2):先轮询后 WS + Pinia 队列 + 正确的执行顺序

关键点不在“有没有队列”,而在“先后顺序”:

  1. 先把轮询结果入队(页面加载立刻执行一次,先拿到“历史未处理”)
  2. 轮询成功后再连接 WS(避免 WS 抢跑导致“只弹新来的”)
  3. 任何来源的紧急消息都先入队,再判断锁(不持锁也要缓存)
  4. 能弹时直接渲染队列(一次性补齐旧的 + 新的) ✅
sequenceDiagram
  participant Poll as 轮询(立即执行)
  participant WS as WebSocket
  participant Tab as 当前标签页
  participant Q as Pinia(待处理队列)
  participant Lock as localStorage(锁)
  participant UI as 强提醒弹框

  Poll->>Tab: 拉取未处理紧急消息 list
  Tab->>Q: replacePending(list) ✅
  Tab->>WS: connect() ✅
  WS->>Tab: 收到紧急消息 item
  Tab->>Q: upsertPending(item) ✅
  Tab->>Lock: isLocked?
  alt 被其他标签页持锁
    Tab-->>UI: 不弹框,仅等待
  else 可持锁/已持锁
    Tab->>Lock: setLock()
    Tab->>UI: render(Q.pendingList) ✅
  end

示例代码(与实现一致):

const store = useNotificationStore();

const maybeOpenUrgentDialog = () => {
  if (store.urgentPendingList.length === 0) return;
  if (!isUrgentDialogActiveForCurrentTab() && isUrgentDialogActive()) return;
  setUrgentDialogActive();
  setUrgentDialogItems(store.urgentPendingList);
};

const handleUrgentIncoming = (item: NotificationMineItem) => {
  store.upsertUrgentPending({ key: getUrgentNotificationKey(item), item });
  maybeOpenUrgentDialog();
};

const fetchNotifications = async () => {
  const list = await getNotificationList({ status: 0 });
  store.replaceUrgentPending(
    list
      .filter((x) => isUrgentNotification(x) && !x.isRead)
      .map((x) => ({ key: getUrgentNotificationKey(x), item: x })),
  );
  maybeOpenUrgentDialog();
  startEcho();
};

最终效果(两类问题一起解决)🙌

  • 多标签页不再重复强弹:只有一个标签页持锁展示弹框 ✅
  • 紧急消息不会“被关掉的标签页带走”:轮询重建 + Pinia 队列兜底,能接力 ✅
  • 新消息到来时会补齐历史未处理:B 会弹 3 条旧的 + 1 条新的 ✅

总结

这次问题本质上是“同一份紧急消息,在多标签页环境下如何做到不重复打扰不遗漏”:

  • 问题 1(重复弹框):用 localStorage 全局锁保证同一时刻只允许一个标签页弹框;锁归属用 Pinia 记录,避免误判
  • 问题 2(接力丢历史):把“待处理紧急消息”从弹框组件里抽出来,改为 Pinia 队列;并通过先轮询后 WS的时序,确保“历史未处理”一定先入队,再叠加 WS 的实时增量

最终效果是:紧急消息仍然强制弹框、不可手动关闭、刷新后仍可通过轮询重建继续弹,同时多标签页不会被同一批消息反复轰炸。

Three.js 入门:30行代码画出你的第一条3D线条

核心概念:3个必备元素

在 Three.js 中,想要渲染任何东西,你需要理解3个核心概念:

  1. 场景 (Scene) - 就像一个舞台,所有物体都放在这里
  2. 相机 (Camera) - 就像你的眼睛,决定从哪个角度看舞台
  3. 渲染器 (Renderer) - 把场景和相机的内容画到屏幕上

完整代码

import * as THREE from 'three';

// 1️⃣ 创建场景 - 所有物体的容器
const scene = new THREE.Scene();

// 2️⃣ 创建相机 - 决定我们从哪里看
const camera = new THREE.PerspectiveCamera(
  75,                           // 视野角度
  innerWidth / innerHeight,     // 宽高比
  0.1,                          // 近裁剪面
  1000                          // 远裁剪面
);
camera.position.z = 5;          // 把相机往后移,才能看到原点的物体

// 3️⃣ 创建渲染器 - 把3D内容画到网页上
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

// 4️⃣ 画线!
// 定义线条经过的点
const points = [
  new THREE.Vector3(-2, 0, 0),   // 左边的点
  new THREE.Vector3(0, 2, 0),    // 顶部的点
  new THREE.Vector3(2, 0, 0)     // 右边的点
];

// 用这些点创建几何体
const geometry = new THREE.BufferGeometry().setFromPoints(points);

// 创建线条材质(绿色)
const material = new THREE.LineBasicMaterial({ color: 0x00ff00 });

// 组合几何体和材质,创建线条对象
const line = new THREE.Line(geometry, material);

// 把线条添加到场景中
scene.add(line);

// 5️⃣ 渲染!
renderer.render(scene, camera);

代码解析

画线三步曲

步骤 代码 说明
1 BufferGeometry().setFromPoints(points) 定义线条的形状(经过哪些点)
2 LineBasicMaterial({ color }) 定义线条的外观(颜色)
3 new THREE.Line(geometry, material) 把形状和外观组合成线条对象

📂 核心代码与完整示例:      my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

前端性能优化之首屏时间采集篇

所谓首屏,就是用户看到当前页面的第一屏,首屏时间就是第一屏被完全加载出来的时间点。

比如一个电商网站,首屏就包括导航栏、搜索框、商品头图等内容。那么,如何采集用户的首屏时间呢?

你可能会说,我直接用 Chrome DevTools 看一下就行了。

1、容易误导开发者的 Chrome DevTools

每次拿 Chrome DevTools 一看,好像自家网站的性能杠杠的,页面加载嘎嘎快,但结果却是用户反馈进入网站很卡,究其原因,这是由 Chrome DevTools 的局限性导致的:

  • 网络环境差异:使用 Chrome DevTools 是内网访问,往往网络环境很好,而用户的网络环境就很复杂,在偏远地区或者电梯、地铁等弱网环境体验会更差。
  • 访问方式不同:调试工具和真机有一定的差距。
  • 访问设备有限:测试机观察到的首屏时间机型有限,而真实用户的手机机型五花八门。

所以通过 Chrome DevTools 采集到的数据是不够准确的。所以我们需要通过添加相关代码进行采集,然后把采集到的数据上报到服务器中,这样就能获取大量用户的首屏时间数据。

采集方式一般有两种,手动采集自动化采集

2、手动采集

手动采集一般是通过埋点的方式来实现:

  • 比如是电商网站首页,需要在导航栏、搜索框、商品头图等内容加载完毕的位置打上点。
  • 如果是一个列表页,需要根据各个机型下的首屏位置,计算出一个平均的首屏位置,打上点。
  • 如果首屏仅仅是一张图片,则需要在图片加载完成之后,打上点。

优点

  • 灵活性强,可以根据网页的特点随时改变打点策略,以保证首屏时间采集的准确性。
  • 去中心化,各个业务部分自己打自己点即可,自行采集和维护。

缺点

  • 通用性差,各个业务要自己去设计打点方案。
  • 和业务代码耦合在一起,维护性差,而且随着业务的变化,打点代码也需要调整,较为麻烦。
  • 依赖人,不同人对首屏的理解不一样,导致不同人采集的结果有差异,还需要花时间和成本去校正,或者忘记打点。

3、自动化采集

自动化采集就是指插入一段通用代码进行自动化采集。

优点

  • 通用性强,多个业务线都可用,使用和接入简单。

缺点

  • 无法满足业务的个性化需求。

自动化采集对于不同的场景,采集方案也不一样:

  • 对于服务端渲染 SSR 来说,客户端拿到的就是拼接好的 html 字符串,所以直接采集 DOMContentLoaded 的时间即可。
  • 对于客户端渲染的 SPA 应用来说,DOMContentLoaded 的时间并不一定准确,因为里面的内容开始只有一个容器 <div id="app"></div>,后续内容是通过 js 动态渲染出来的,而用户需要看到完整的首屏实际内容,才能算首屏加载完成了。

那么,如何准确采集单页面(SPA)应用的首屏时间呢?

4、单页面(SPA)应用的首屏采集方案

首先先了解下单页应用的渲染大概流程:

  1. 输入网址,从服务器拿到 index.html 文件;
  2. 浏览器使用 html 解析器解析 html 文件,并加载 cssjs 等资源。
  3. 执行 js 代码,初始化框架 Vue/React/Angular,执行里面相关生命周期钩子,使用 xhr/axios 请求数据,并渲染 DOM 到页面上。

那么,我们的核心就是需要知道,渲染 DOM 到页面上的时间。以 Vue 框架为例,它有一个 mounted(Vue2 Options API)、onMounted(Vue3 Composition API ) 钩子,可以拿到 DOM 加载的时间,那么我们是不是能利用这个钩子来进行首屏时间的采集呢?

显然是不行的,这样做有如下缺点:

  1. 如果页面数据是通过请求异步拿到并渲染到页面上,mounted 采集的首屏时间就不准确了,如果要知道准确的时间,需要等请求完成的时间点进行采集,这样会侵入业务代码,违背了通用性,再说如果有多个请求抽离在各个地方,还需要用类似 Promise.all 进行整合,还是需要修改业务代码。
  2. 如果首页是一张图片,而 mounted 的时间,图片内容可能并没有加载完,用户也看不到内容。

5、使用 MutationObserver 采集首屏时间

所以,我们应该采用 MutationObserver 进行采集。它能监听 DOM 树的更改并执行相关的回调。核心的统计思路就是:在页面初始化时,使用 MutationObserver 监听 DOM 元素,当其发生变化时,程序会标记变化的元素,并记录时间点和分数,存储到数组中,当达到如下条件时,说明首屏渲染已经结束:

  • 计算时间超过 30s 还没结束。
  • 计算了 4 次且 1s 内分数不再变化。
  • 计算了 9 次且分数不再变化。

统计分数过程如下:

  • 递归遍历 DOM 元素及其子元素,根据元素层级设定元素权重。层级越深的元素最接近用户看到的内容,权重也就越高。比如第一层权重为 1 ,渲染完成得 1 分,没增加一层权重增加 0.5,第三层的权重为 3.5,也就是渲染完成得 3.5 分。

最终,我们拿到一个记录了时间点和分数的数组,然后通过数组的后一项 - 数组前一项求出元素分数变化率,找到变化率最大点的分数对应的时间,即为首屏时间。

那这样算出来的首屏时间是否准确呢?其实不然,像我们之前说的首屏为一张图片的情况,就采集的不准。

所以对于图片来说,我们需要拿到页面中所有的 img,其来源主要有两方面:

  • img 标签:通过拿到 dom 节点,判断其 nodeName.toUpperCase === 'IMG'
  • CSS 背景中的图片background: url("https://static.xxx.png")。可以通过如下方式来拿到:
if (dom.nodeName.toUpperCase !== 'IMG') {
  const domStyle = window.getComputedStyle(dom);
  const imgUrl = domStyle.getPropertyValue('background-image') || domStyle.getPropertyValue('background');
}

拿到图片的 url 之后,通过 performance.getEntriesByName(imgUrl)[0].responseEnd 获取图片的加载时间,然后拿到图片最长的加载时间和之前变化率最大点的分数对应的时间进行对比,哪个更长哪个就是最终的首屏时间。

小结

  • 首屏时间会受用户设备、网络环境的影响,使用 Chrome DevTools 拿到的首屏时间存在偏差。
  • 手动采集方案较为灵活,能满足个性化需求,去中心化,但没有自动采集通用性好,会跟业务代码耦合,接入成本也更高,会受人为影响,所以一般都会选择自动化采集方案。
  • 采集时,服务端 SSR 应用和单页 SPA 应用的采集有很大不同,SSR 应用只需要采集 DOMContentLoaded 时间即可,而单页应用则需要使用 MutationObserver 监听 DOM,并设置元素权重,统计每个元素的分数和时间,最终拿到变化率最大的分数及时间点。
  • 计算出所有图片的加载时间,与变化率最大的分数的时间进行比较,更大的作为最终的首屏时间。

往期回顾

🚀 @empjs/skill:让 AI SKill 管理变得前所未有的简单

一个命令,管理所有 AI Skill。告别重复安装,拥抱统一管理。

💡 你是否遇到过这样的困扰?

想象一下这个场景:你同时使用 CursorClaude CodeWindsurf 等多个 AI 编程助手。每次发现一个好用的技能(Skill),你都需要:

  • 🔄 在 Cursor 的目录下手动安装一次
  • 🔄 在 Claude Code 的目录下再安装一次
  • 🔄 在 Windsurf 的目录下还要安装一次
  • 📁 每个 AI 代理都有自己的技能目录,文件散落各处
  • 🔍 想查看安装了哪些技能?得一个个目录去翻
  • 🗑️ 想删除某个技能?得记住它在哪些地方,一个个删除

更糟糕的是,如果你是一个技能开发者,想要测试你的技能在不同 AI 代理上的表现,你需要:

  • 📦 打包发布到 NPM
  • 🔄 在每个代理上分别安装
  • 🔄 修改代码后,重新打包、重新安装...
  • 😫 开发效率低到令人抓狂

✨ eskill:一次安装,全平台可用

eskill 是一个革命性的 CLI 工具,它彻底改变了 AI 代理技能的管理方式。

🎯 核心价值

一个统一的技能库,自动分发到所有 AI 代理

# 安装一个技能
eskill install my-awesome-skill

# ✨ 自动检测并链接到:
#   ✅ Cursor (~/.cursor/skills)
#   ✅ Claude Code (~/.claude/skills)  
#   ✅ Windsurf (~/.windsurf/skills)
#   ✅ Cline (~/.cline/skills)
#   ✅ Gemini Code (~/.gemini/skills)
#   ✅ GitHub Copilot (~/.copilot/skills)
#   ✅ ... 还有更多!

就这么简单! 一次安装,所有已安装的 AI 代理都能立即使用。

🌟 五大核心亮点

1️⃣ 统一存储架构

所有技能统一存储在 ~/.emp-agent/skills/,通过符号链接(symlink)技术智能分发到各个 AI 代理。

优势:

  • 📦 单一数据源:技能只存储一份,节省磁盘空间
  • 🔄 自动同步:更新一次,所有代理自动生效
  • 🎯 集中管理:所有技能一目了然

2️⃣ 智能代理检测

自动检测你系统中已安装的所有 AI 代理,无需手动配置。

支持的 AI 代理(13+):

  • Claude Code
  • Cursor
  • Windsurf
  • Cline
  • Gemini Code
  • GitHub Copilot
  • OpenCode
  • Antigravity
  • Kiro
  • Codex CLI
  • Qoder
  • Roo Code
  • Trae
  • Continue

还在不断增加中!

3️⃣ 多源安装支持

支持从多种来源安装技能:

# 从 NPM 安装
eskill install @myorg/react-skill

# 从 Git 仓库安装
eskill install https://github.com/user/repo/tree/main/skills/my-skill

# 从本地目录安装(开发模式)
eskill install ./my-local-skill --link

4️⃣ 开发模式:即时更新

这是技能开发者的福音

# 进入你的技能目录
cd ~/projects/my-skill

# 链接到开发环境
eskill install . --link

# ✨ 现在修改代码,所有 AI 代理立即生效!
# 无需重新打包,无需重新安装

开发体验提升 10 倍!

5️⃣ 灵活的安装策略

# 安装到所有代理(默认)
eskill install my-skill

# 只安装到特定代理
eskill install my-skill --agent cursor

# 强制重新安装
eskill install my-skill --force

🎨 技术架构亮点

符号链接技术

使用操作系统的符号链接功能,实现零拷贝的技能分发:

~/.emp-agent/skills/my-skill/     # 实际存储位置
    ├── SKILL.md
    └── references/

~/.cursor/skills/my-skill -> ~/.emp-agent/skills/my-skill  # 符号链接
~/.claude/skills/my-skill -> ~/.emp-agent/skills/my-skill  # 符号链接

优势:

  • 零延迟:链接创建瞬间完成
  • 💾 省空间:不占用额外存储
  • 🔄 自动同步:源文件更新,所有链接自动反映

智能路径解析

支持复杂的 Git URL 解析:

# 支持分支
eskill install https://github.com/user/repo/tree/dev/skills/my-skill

# 支持子目录
eskill install https://github.com/user/repo/tree/main/packages/skill

# 自动提取技能名称

完善的错误处理

  • ⏱️ 超时控制:网络请求自动超时,避免无限等待
  • 🔍 详细错误提示:遇到问题,提供清晰的解决方案
  • 🛡️ 权限处理:智能处理文件权限问题,提供修复建议

📊 使用场景

场景 1:多 AI 代理用户

痛点: 需要在多个 AI 代理上使用相同的技能

解决方案:

eskill install react-best-practices
# 自动在所有已安装的代理上可用

场景 2:技能开发者

痛点: 开发技能时需要频繁测试

解决方案:

eskill install . --link
# 修改代码,立即在所有代理上测试

场景 3:团队协作

痛点: 团队成员需要统一管理技能

解决方案:

# 统一从 NPM 或 Git 安装
eskill install @team/shared-skills
# 确保团队使用相同版本的技能

场景 4:技能探索

痛点: 想尝试新技能,但不确定是否适合

解决方案:

eskill install experimental-skill
eskill list  # 查看所有已安装的技能
eskill remove experimental-skill  # 轻松卸载

🚀 快速开始

安装 eskill

# 使用 pnpm(推荐)
pnpm add -g @empjs/skill

# 或使用 npm
npm install -g @empjs/skill

# 或使用 yarn
yarn global add @empjs/skill

# 或使用 bun
bun install -g @empjs/skill

安装你的第一个技能

# 查看可用的技能
eskill list

# 安装一个技能
eskill install <skill-name>

# 查看已安装的技能
eskill list

# 查看支持的 AI 代理
eskill agents

💎 为什么选择 eskill?

✅ 对比传统方式

特性 传统方式 eskill
安装步骤 每个代理单独安装 一次安装,全平台可用
存储空间 每个代理一份副本 统一存储,节省空间
更新效率 需要逐个更新 一次更新,全部生效
开发体验 打包→安装→测试循环 链接模式,即时生效
管理复杂度 高(多个目录) 低(统一管理)

🎯 核心优势总结

  1. 🚀 效率提升:一次操作,全平台生效
  2. 💾 空间节省:统一存储,避免重复
  3. 🛠️ 开发友好:链接模式,即时测试
  4. 🔧 灵活配置:支持多源、多代理、多模式
  5. 📦 生态兼容:支持 NPM、Git、本地目录

🔮 未来展望

eskill 正在快速发展,未来将支持:

  • 📊 技能市场:内置技能发现和评分系统
  • 🔄 版本管理:技能版本控制和回滚
  • 👥 团队协作:技能共享和权限管理
  • 📈 使用统计:技能使用情况分析
  • 🔌 插件系统:扩展更多 AI 代理支持

🤔 还在犹豫?

试试看,只需要 30 秒:

# 1. 安装 eskill
pnpm add -g @empjs/skill

# 2. 查看你的 AI 代理
eskill agents

# 3. 安装一个技能试试
eskill install <any-skill-name>

如果它不能提升你的效率,卸载它只需要:

npm uninstall -g @empjs/skill

但相信我,一旦你体验过统一管理的便利,就再也回不去了! 🎉

📚 了解更多

  • 📖 完整文档:查看项目 README
  • 🐛 问题反馈:GitHub Issues
  • 💬 社区讨论:加入我们的社区
  • 🔧 贡献代码:欢迎 Pull Request

现在就试试 eskill,让 AI 代理技能管理变得前所未有的简单!

pnpm add -g @empjs/skill

一个命令,改变你的工作流。

nuxt 配 modules模块 以及 数据交互

  • 类型 Array

modulesNuxt.js扩展,可以扩展它的核心功能并添加无限。

例如(nuxt.config.js):

export default {
  modules: [
    // Using package name
    '@nuxtjs/axios',
    
    // Relative to your project srcDir
    '~/modules/awesome.js',
    
    // Providing options
    ['@nuxtjs/google-analytics', { ua: 'X1234567' }],
    
    // Inline definition
    function() {}
  ]
}

安装过程中,它会让我们选择模块。

image.png

Axios - Promise based HTTP client

// nuxt.config.js
{
  modules: [
    '@nuxtjs/axios' // 前面安装nuxtjs的时候没选,也可以后续一条命令去装上去 ==> npm install @nuxtjs/axios -initial-scale
  ]
}

// 笔记.html

一、安装nuxt的axios
    1.1 npm install @nuxtjs/axios -S
    1.2 nuxt.config.js进行配置
    
    modules: [
      '@nuxtjs/axios',
    ]

二、安装axios
    2.1 npm install axios -S
    

在每一个页面中或者每个component中用axios。

<template>
  <div>页面</div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'IndexPage'
}
</script>

在nuxt中如何请求接口。两种方式:

异步数据

请求接口一定是首先先把服务器端的接口拿到了。然后再打开页面,这个时候源代码中就有接口数据了。那这个时候蜘蛛就可以爬取到这个数据了。如果还是像vue一样,这个页面打开了,再把数据返回来,那蜘蛛就抓取不到了。

在页面中有一个生命周期。叫asyncData

Nuxt.js扩展了Vue.js,增加了一个叫asyncData的方法,使得我们可以在设置组件的数据之前能异步获取或处理数据。

asyncData

这个是个生命周期。

asyncData方法会在组件(限于页面组件)每次加载之前被调用。可在服务端或路由更新之前被调用。

在这个方法被调用的时候,第一个参数被设定为当前页面的上下文对象,可以用asyncData方法来获取数据,

三、asyncData生命周期 || 方法
  
  pages 目录中的页面组件才可以去用
  
    ***注意components内的.vue文件是不可以使用的。
  
// index.vue
<template>
  <div>首页</div>
</template>

<script type="text/script">
export default {
  data() {},
  asyncData(app) {
    console.log(app)
  }
}
</script>

可以看到,app对象下面有一个$axios

在控制台,和在服务端都可以打印出来。

所以也可以这样子写:

// index.vue
<template>
  <div>首页</div>
</template>

<script type="text/script">
export default {
  data() {},
  asyncData({ $axios }) {
    console.log($axios)
  }
}
</script>

这样子就可以去请求接口,放进去。

接口给过来,都是后端代码上面去解决跨域问题。至于nuxt如何解决跨域,待会说。

这里写一个async 和 await

// pages/index.vue
<template>
  <div>首页</div>
</template>

<script>
export default {
  name: 'IndexPage',
  async asyncData({ $axios }) {
    const res = await $axios('后端给的接口地址')
    console.log(res)
  }
}
</scirpt>

拿到数据之后呢,要把数据渲染到也米娜上,如果是vue的话,

// pages/index.vue
<template>
  <div>
    <ul>
      <li v-for='item in list' :key='item.id'>
        {{ item.imageName }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'IndexPage',
  data () {
    return {
      list: []
    }
  },
  async asyncData({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
    console.log(res)
    // 要这样
    return { list }
    // 不要这样
    this.list = list
  }
}
</scirpt>

按照vue来讲,这上面完全没问题,但是nuxt中不行。其实不是nuxt不行,而是asyncData不行,在asyncData中是不能写this的,因为在asyncData中this是undefined。

注意:在asyncData中没有this

其实在这个地方,有写到,说要return。说白了,nuxt.js会将asyncData返回的数据融合组件data方法返回的数据并返回给当前组件。

其实就是data () { return { list: [] } },和asyncData里面的return 合并数据,然后。

然后重新去看页面,就可以看到页面生效了。

fetch

还有方式请求接口。

四、fetch生命周期 || 方法

首先fetch是在aysncData之后的生命周期,然后fetch也有参数({ $axios }),它也是当前组件的上下文,所以这里的$axios也有接口请求,

// pages/index.vue
<template>
  <div>
    <News />
    <ul>
      <li v-for='item in list' :key='item.id'>
        {{ item.imageName }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'IndexPage',
  data () {
    return {
      list: [],
      items: []
    }
  },
  async asyncData({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
    console.log(res)
    // 要这样
    return { list }
    // 不要这样
    this.list = list
  },
  // 注意fetch里面是可以有this的
  async fetch({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
   
    this.items = list
  }
}
</scirpt>

不过在页面上能拿到数据,不过在template上打印出来是空数组。所以说在页面级的请求用asyncData。

四、fetch生命周期 || 方法
  fetch是有this的

components下创建个News.vue:

<template>
  <div>111</div>
</template>
<script>
export default {
  // 注意fetch里面是可以有this的
  async fetch({ $axios }) {
    const res = await $axios('后端给的接口地址')
    const list = res.data
   
    this.items = list
  }
}
</script>

刚刚提到的一点,asyncData在页面级别的组件是可以拿到的,可以执行的,在某个组件中,component中。asyncData是不能用在component上的,那它这种只能引入fetch,必须用fetch。

fetch方法用于在渲染页面前填充应用的状态树(store)数据,与asyncData方法类似,不同的是它不会设置组件的数据。

四、fetch生命周期 || 方法
  fetch是有this的

components下创建个News.vue:

<template>
  <div>111</div>
</template>
<script>
export default {
  // 注意fetch里面是可以有this的
  async fetch({ $axios }) {
    // 在组件中,这里是没有$axios的,
    const res = await $axios('后端给的接口地址')
    const list = res.data
   
    this.items = list
  }
}
</script>
<template>
  <div>
      111
      {{ list }}
  </div>
</template>
<script>
export default {
  data () {
    return {
      list
    }
  },
  // 注意fetch里面是可以有this的
  async fetch() {
    // 正确
    const res = await this.$axios('后端给的接口地址')
    const list = res.data
    this.list = list
  }
}
</script>

ThreeJS 着色器图形特效

本文档涵盖Three.js中高级着色器图形特效的实现方法,基于实际代码示例进行讲解。

最终效果如图: Title

1. 着色器图形特效基础

1.1 复杂着色器材质创建

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import deepVertexShader from "../shaders/deep/vertex.glsl";
import deepFragmentShader from "../shaders/deep/fragment.glsl";

// 创建带有多个uniforms的着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: deepVertexShader,
  fragmentShader: deepFragmentShader,
  uniforms: {
    uColor: {
      value: new THREE.Color("purple"),
    },
    // 波浪的频率
    uFrequency: {
      value: params.uFrequency,
    },
    // 波浪的幅度
    uScale: {
      value: params.uScale,
    },
    // 动画时间
    uTime: {
      value: 0,
    },
    uTexture: {
      value: texture,
    },
  },
  side: THREE.DoubleSide,
  transparent: true,
});

1.2 GUI参数控制

通过dat.GUI实时控制着色器参数:

// 控制频率参数
gui
  .add(params, "uFrequency")
  .min(0)
  .max(50)
  .step(0.1)
  .onChange((value) => {
    shaderMaterial.uniforms.uFrequency.value = value;
  });

// 控制幅度参数
gui
  .add(params, "uScale")
  .min(0)
  .max(1)
  .step(0.01)
  .onChange((value) => {
    shaderMaterial.uniforms.uScale.value = value;
  });

2. 高级片元着色器技术

2.1 UV坐标操作

UV坐标是纹理映射的基础,也是创建各种图形效果的关键:

void main(){
    // 1. 通过顶点对应的uv,决定每一个像素在uv图像的位置,通过这个位置x,y决定颜色
    // gl_FragColor =vec4(vUv,0,1) ;

    // 2. 对第一种变形
    // gl_FragColor = vec4(vUv,1,1);

    // 3. 利用uv实现渐变效果,从左到右
    float strength = vUv.x;
    gl_FragColor =vec4(strength,strength,strength,1);
}

2.2 数学函数应用

利用GLSL内置数学函数创建复杂效果:

// 随机函数
float random (vec2 st) {
    return fract(sin(dot(st.xy,vec2(12.9898,78.233)))*43758.5453123);
}

// 噪声函数
float noise (in vec2 _st) {
    vec2 i = floor(_st);
    vec2 f = fract(_st);

    // 四个角落的随机值
    float a = random(i);
    float b = random(i + vec2(1.0, 0.0));
    float c = random(i + vec2(0.0, 1.0));
    float d = random(i + vec2(1.0, 1.0));

    vec2 u = f * f * (3.0 - 2.0 * f);

    return mix(a, b, u.x) +
            (c - a)* u.y * (1.0 - u.x) +
            (d - b) * u.x * u.y;
}

2.3 几何图形绘制

使用数学函数绘制各种几何图形:

// 绘制圆形
float strength = 1.0 - step(0.5,distance(vUv,vec2(0.5))+0.25) ;
gl_FragColor =vec4(strength,strength,strength,1);

// 绘制圆环
float strength = step(0.5,distance(vUv,vec2(0.5))+0.35) ;
strength *= (1.0 - step(0.5,distance(vUv,vec2(0.5))+0.25)) ;
gl_FragColor =vec4(strength,strength,strength,1);

// 波浪效果
vec2 waveUv = vec2(
    vUv.x+sin(vUv.y*100.0)*0.1,
    vUv.y+sin(vUv.x*100.0)*0.1
);
float strength = 1.0 - step(0.01,abs(distance(waveUv,vec2(0.5))-0.25)) ;
gl_FragColor =vec4(strength,strength,strength,1);

3. 动画与时间控制

3.1 时间uniform应用

在动画循环中更新时间uniform:

const clock = new THREE.Clock();
function animate(t) {
  const elapsedTime = clock.getElapsedTime();
  shaderMaterial.uniforms.uTime.value = elapsedTime;  // 更新时间
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

3.2 着色器中的动画效果

// 使用时间创建波浪动画
float strength = step(0.9,sin(cnoise(vUv * 10.0)*20.0+uTime)) ;

// 波纹效果
float strength = sin(cnoise(vUv * 10.0)*5.0+uTime) ;

4. 颜色混合与插值

4.1 颜色混合函数

// 使用混合函数混颜色
vec3 purpleColor = vec3(1.0, 0.0, 1.0);
vec3 greenColor = vec3(1.0, 1.0, 1.0);
vec3 uvColor = vec3(vUv,1.0);
float strength = step(0.9,sin(cnoise(vUv * 10.0)*20.0)) ;

vec3 mixColor =  mix(greenColor,uvColor,strength);
gl_FragColor =vec4(mixColor,1.0);

5. 纹理与采样

5.1 纹理采样

uniform sampler2D uTexture;

void main(){
    vec4 textureColor = texture2D(uTexture,vUv);
    textureColor.rgb*=height;
    gl_FragColor = textureColor;
}

6. 几何变换

6.1 旋转函数

// 旋转函数
vec2 rotate(vec2 uv, float rotation, vec2 mid)
{
    return vec2(
      cos(rotation) * (uv.x - mid.x) + sin(rotation) * (uv.y - mid.y) + mid.x,
      cos(rotation) * (uv.y - mid.y) - sin(rotation) * (uv.x - mid.x) + mid.y
    );
}

// 使用旋转函数
vec2 rotateUv = rotate(vUv,-uTime*5.0,vec2(0.5));

7. 复杂效果实现

7.1 万花筒效果

// 万花筒效果
float angle = atan(vUv.x-0.5,vUv.y-0.5)/PI;
float strength = mod(angle*10.0,1.0);
gl_FragColor =vec4(strength,strength,strength,1);

7.2 雷达扫描效果

// 雷达扫描效果
vec2 rotateUv = rotate(vUv,-uTime*5.0,vec2(0.5));
float alpha =  1.0 - step(0.5,distance(vUv,vec2(0.5)));
float angle = atan(rotateUv.x-0.5,rotateUv.y-0.5);
float strength = (angle+3.14)/6.28;
gl_FragColor =vec4(strength,strength,strength,alpha);

8. 性能优化与调试

8.1 性能优化技巧

  1. 减少复杂计算:避免在着色器中进行过于复杂的数学运算
  2. 合理使用纹理:预先计算复杂效果并存储在纹理中
  3. 简化几何体:在不影响视觉效果的前提下减少顶点数

8.2 调试技巧

  1. 逐步构建:从简单效果开始,逐步增加复杂性
  2. 输出中间值:将中间计算结果输出为颜色进行调试
  3. 使用常量验证:先用常量验证逻辑,再引入变量

总结

本章深入探讨了Three.js中高级着色器图形特效的实现方法,包括:

  1. 复杂着色器材质的创建和参数控制
  2. 数学函数在图形生成中的应用
  3. UV坐标操作和几何图形绘制
  4. 时间动画和颜色混合技术
  5. 纹理采样和几何变换
  6. 复杂视觉效果的实现方法
  7. 性能优化和调试技巧

通过掌握这些技术,可以创建出丰富的视觉效果和动态图形。

ThreeJS 着色器编程基础入门

本文档涵盖Three.js中着色器编程的基础概念和实现方法,基于实际代码示例进行讲解。

最终效果如图: 懂王在风中凌乱

1. 着色器基础概念

着色器(Shader)是运行在GPU上的小程序,用于计算3D场景中每个像素的颜色。在Three.js中,有两种主要的着色器:

  • 顶点着色器(Vertex Shader):处理每个顶点的位置变换
  • 片元着色器(Fragment Shader):确定每个像素的最终颜色

1.1 着色器导入和初始化

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";

// 顶点着色器
import basicVertexShader from "../shader/raw/vertex.glsl";
// 片元着色器
import basicFragmentShader from "../shader/raw/fragment.glsl";

2. 着色器材质创建

2.1 RawShaderMaterial vs ShaderMaterial

RawShaderMaterial直接使用GLSL代码,不会自动添加默认的uniforms和attributes:

// 创建原始着色器材质
const rawShaderMaterial = new THREE.RawShaderMaterial({
  vertexShader: basicVertexShader,
  fragmentShader: basicFragmentShader,
  side: THREE.DoubleSide,
  uniforms: {
    uTime: {
      value: 0,
    },
    uTexture: {
      value: texture,
    },
  },
});

2.2 基础着色器材质

// 创建着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: `
    void main(){
        gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 ) ;
    }
  `,
  fragmentShader: `
    void main(){
        gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0);
    }
  `,
});

3. 顶点着色器详解

顶点着色器负责处理3D空间中的顶点位置,以下是一个包含动画效果的顶点着色器:

precision lowp float;
attribute vec3 position;
attribute vec2 uv;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;

// 获取时间
uniform float uTime;

varying vec2 vUv;
varying float vElevation;

void main(){
    vUv = uv;
    vec4 modelPosition = modelMatrix * vec4( position, 1.0 );
    
    // 添加基于时间的波浪动画
    modelPosition.z = sin((modelPosition.x+uTime) * 10.0)*0.05 ;
    modelPosition.z += sin((modelPosition.y+uTime)  * 10.0)*0.05 ;
    vElevation = modelPosition.z;

    gl_Position = projectionMatrix * viewMatrix * modelPosition ;
}

4. 片元着色器详解

片元着色器负责确定每个像素的颜色,以下是一个处理纹理和高度的片元着色器:

precision lowp float;
varying vec2 vUv;
varying float vElevation;

uniform sampler2D uTexture; 

void main(){
    // 根据UV,取出对应的颜色
    float height = vElevation + 0.05 * 20.0;
    vec4 textureColor = texture2D(uTexture,vUv);
    textureColor.rgb*=height;
    gl_FragColor = textureColor;
}

5. Uniforms统一变量

Uniforms是在JavaScript代码和着色器之间传递数据的变量:

const rawShaderMaterial = new THREE.RawShaderMaterial({
  vertexShader: basicVertexShader,
  fragmentShader: basicFragmentShader,
  side: THREE.DoubleSide,
  uniforms: {
    uTime: {
      value: 0,  // 时间变量,用于动画
    },
    uTexture: {
      value: texture,  // 纹理变量
    },
  },
});

在动画循环中更新uniform值:

const clock = new THREE.Clock();
function animate(t) {
  const elapsedTime = clock.getElapsedTime();
  // 更新着色器中的时间uniform
  rawShaderMaterial.uniforms.uTime.value = elapsedTime;
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

6. 几何体与着色器结合

使用平面几何体展示着色器效果:

// 创建平面
const floor = new THREE.Mesh(
  new THREE.PlaneBufferGeometry(1, 1, 64, 64),  // 细分更多,波浪效果更明显
  rawShaderMaterial
);

scene.add(floor);

7. 基础着色器示例

创建一个简单的黄色平面着色器:

// 创建基础着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: `
    void main(){
        gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 ) ;
    }
  `,
  fragmentShader: `
    void main(){
        gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0);  // 黄色
    }
  `,
});

8. 着色器开发最佳实践

  1. 精度声明:在片元着色器中声明精度

    precision lowp float;  // 低精度
    precision mediump float;  // 中等精度
    precision highp float;  // 高精度
    
  2. 变量类型

    • attribute:每个顶点独有的数据(如位置、UV坐标)
    • uniform:所有顶点共享的数据(如时间、纹理)
    • varying:在顶点着色器和片元着色器之间传递的数据
  3. 性能优化:避免在着色器中使用复杂运算,尽可能在CPU端预计算

  4. 调试技巧:通过将中间计算结果输出到颜色来调试着色器

总结

本章介绍了Three.js中着色器编程的基础知识,包括:

  1. 着色器的基本概念和类型
  2. 如何创建和使用着色器材质
  3. 顶点着色器和片元着色器的编写
  4. 如何通过uniforms在JavaScript和着色器间传递数据
  5. 基础的着色器动画实现

通过掌握这些基础知识,可以进一步探索更复杂的着色器效果。

三个方法优化JS的setTimeout实现的倒计误差,看完包会!

你肯定遇到过这种情况。页面上有一个倒计时,显示“距离活动结束还有 10 秒”。你屏住呼吸,准备在最后一刻点击抢购按钮。但奇怪的是,倒计时从 10 跳到 9 时,好像停顿了一下,或者跳得特别快。最终,你点击按钮时,系统提示“活动已结束”。

这不是你的错觉。前端实现的倒计时,确实存在误差。今天,我们就来聊聊这个误差是怎么产生的,以及我们能做些什么来减小它。

误差从何而来?

要理解误差,我们得先看看最常见的前端倒计时是怎么工作的。

1. 核心机制:setInterval 与 setTimeout

大多数倒计时使用 JavaScript 的 setInterval 或递归的 setTimeout 来实现。代码逻辑很简单:

  1. 设定一个目标时间(比如活动结束时间)。
  2. 每秒执行一次函数,计算“当前时间”与“目标时间”的差值。
  3. 将这个差值转换成天、时、分、秒,显示在页面上。 看起来天衣无缝,对吗?问题就藏在“每秒执行一次”这个动作里。

2. 误差的三大“元凶”

元凶一:JavaScript 的单线程与事件循环

JavaScript 是单线程语言。这意味着它一次只能做一件事。setInterval 和 setTimeout 指定的延迟时间,并不是精确的“等待 X 毫秒后执行”,而是“等待至少 X 毫秒后,将回调函数放入任务队列”。

什么时候执行呢?要等主线程上当前的任务都执行完了,才会从队列里取出这个回调来执行。

想象一下:

  • 你设定 setInterval(fn, 1000),希望每秒跑一次。
  • 第0秒,fn 执行了。
  • 第1秒,fn 被放入队列。但此时主线程正在处理一个复杂的动画计算,花了 200 毫秒。
  • 结果,fn 直到第1.2秒才真正开始执行。

这就产生了至少 200 毫秒的延迟。

元凶二:浏览器标签页休眠

为了节省电量,当用户切换到其他标签页或最小化浏览器时,当前页面的 setInterval 和 setTimeout 会被“限流”。它们的执行频率会大大降低,可能变成每秒一次,甚至更慢。

如果你的倒计时在后台运行了5分钟,再切回来,它可能直接显示“已结束”,或者时间跳了一大截。

元凶三:系统时间依赖

很多倒计时是这样计算剩余时间的:

剩余秒数 = Math.floor((目标时间戳 - Date.now()) / 1000);

这里有两个潜在问题:

  1.  Date.now() 的精度:它返回的是系统时间。如果用户手动修改了电脑时间,或者系统时间同步有微小偏差,倒计时就会出错。
  2.  计算时机:这个计算发生在回调函数执行的那一刻。如果回调函数本身被延迟了,那么用来计算的“当前时刻”也已经晚了。

如何减小误差?试试这些方案

知道了原因,我们就可以对症下药。解决方案的目标是:让显示的时间尽可能接近真实的世界时间

方案一:优化计时器逻辑

这是最基础的改进,核心思想是:不依赖计时器的周期,而是依赖绝对时间

具体做法:

  1. 在倒计时启动时,记录一个精确的开始时间戳startTime = Date.now())和目标结束时间戳endTime)。

  2. 在每次更新函数中,不再简单地“减1秒”,而是重新计算:

    const now = Date.now();
    const elapsed = now - startTime; // 已经过去的时间
    const remainingTime = endTime - now; // 剩余时间
    const displaySeconds = Math.floor(remainingTime / 1000);
    
  3. 动态调整下一次执行的时间。例如,我们希望每 1000 毫秒更新一次显示,但上次执行晚了 50 毫秒,那么下次就只延迟 950 毫秒。

    function updateTimer() {
      // ... 计算并显示时间
      const deviation = Date.now() - (startTime + expectedElapsed); // 计算偏差
      const nextTick = 1000 - deviation; // 调整下次间隔
      setTimeout(updateTimer, Math.max(0, nextTick)); // 确保间隔不为负数
    }
    

优点:

• 实现相对简单。 • 能有效抵消单次延迟的累积。一次慢了,下次会找补回来一些。

缺点:

• 无法解决浏览器标签页休眠导致的长时间停滞。 • 仍然依赖 Date.now(),受系统时间影响。

方案二:使用 Web Worker(隔离线程)

既然主线程繁忙会导致延迟,那我们就把计时任务放到一个独立的线程里去。

Web Worker 可以让脚本在后台线程运行。在这个线程里运行的 setInterval 不容易被主线程的繁重任务阻塞。

实现思路:

  1. 创建一个 Web Worker 文件(timer.worker.js),在里面用 setInterval 向主线程发送消息。
  2. 主线程接收消息,更新界面。

优点:

• 计时更稳定,受主线程影响小。 • 代码分离,逻辑清晰。

缺点:

• 仍然无法解决浏览器标签页休眠限流的问题。 • 增加了一定的架构复杂度。

方案三:终极方案:服务器时间同步 + 前端补偿

这是目前最精确、最可靠的方案。核心原则是:前端不再信任本地时间,而是以服务器时间为准,并持续校准。

步骤拆解:

第一步:获取权威的服务器时间
在页面加载或倒计时开始时,向服务器发送一个请求。服务器在响应中返回当前的服务器时间戳

注意:这个时间戳应该放在 HTTP 响应的 Date 头或 body 里,避免受到网络传输时间的影响。更专业的做法是,计算一个往返延迟(RTT),然后估算出当前的准确服务器时间。

第二步:在前端建立一个“虚拟的服务器时钟”
我们不在前端直接使用 Date.now(),而是自己维护一个时钟:

// 假设通过 API 得到:serverTime 是服务器当前时间,rtt 是网络往返延迟
const initialServerTime = serverTime + rtt / 2; // 估算的准确服务器时间
const localTimeAtThatMoment = Date.now();

// 此后,要获取“当前服务器时间”,就用这个公式:
function getCurrentServerTime() {
  const nowLocal = Date.now();
  const elapsedLocal = nowLocal - localTimeAtThatMoment;
  return initialServerTime + elapsedLocal;
}

这个时钟的原理是:服务器告诉我们一个“起点时间”,我们记录下那个时刻的本地时间。之后,我们相信本地时间的流逝速度是基本准确的(电脑的晶体振荡器很稳定),用本地流逝的时间加上服务器的起点时间,就得到了连续的“服务器时间”。

第三步:用这个虚拟时钟驱动倒计时
倒计时的更新函数,使用 getCurrentServerTime() 来计算剩余时间,而不是 Date.now()

第四步:定期校准
本地时钟的流逝速度可能有微小偏差(时钟漂移)。我们可以设置一个间隔(比如每1分钟或5分钟),悄悄地再向服务器请求一次时间,来修正我们的 initialServerTime 和 localTimeAtThatMoment,让虚拟时钟始终与服务器保持同步。

这个方案的优点非常突出:

• 抗干扰:用户修改本地时间,完全不影响倒计时。 • 高精度:误差主要来自时钟漂移和网络延迟,通过定期校准可以控制在极低水平(百毫秒内)。 • 一致性:所有用户看到的倒计时基于同一时间源,公平公正。

当然,它的实现也最复杂,需要前后端配合。

实战建议:如何选择?

面对不同的场景,你可以这样选择:

• 对精度要求不高的展示型倒计时(如文章发布后的阅读时间):使用方案一(优化计时器逻辑)  就足够了。简单有效。 • 营销活动、秒杀抢购倒计时:必须使用方案三(服务器时间同步) 。这是保证公平性和准确性的底线。方案一和方案二可以作为辅助,让更新更平滑。

React记录之context:useContext、use-context-selector

原生context、useContext详解

React 的 Context API 是一种组件间共享数据的机制,它允许你在组件树中传递数据而不必手动逐层传递 props,特别适合"全局"数据的共享(如主题、用户认证信息等)。

基本使用:

创建context:

import { createContext, useContext } from 'react';

export type ThemeType = 'light' | 'dark';

export interface ThemeContextType {
  theme: ThemeType;
  toggleTheme: () => void;
}

// 1. 创建 Context
export const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => {},
});

type ThemeProviderProps = {
  children: React.ReactNode;
} & ThemeContextType;

// 2. 创建 Provider 组件
export const ThemeProvider = ({
  children,
  theme,
  toggleTheme,
}: ThemeProviderProps) => {
  return (
    <ThemeContext.Provider value={{
      theme,
      toggleTheme,
    }}>
      {children}
    </ThemeContext.Provider>
  );
};

// 3. 自定义 Hook(可选,提升可读性)
export const useTheme = () => useContext(ThemeContext);

顶层组件 top.tsx

"use client";

import React, { useState } from 'react';
import { ThemeContext, ThemeContextType, ThemeType } from './context';
import Button from '../../components/button';

function App() {
  const [theme, setTheme] = useState<ThemeType>('light');

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

  const value: ThemeContextType = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={value}>
      <div
        style={{
          padding: '20px',
          background: theme === 'dark' ? '#000' : '#fff',
          color: theme === 'dark' ? '#fff' : '#000'
        }}
      >
        <h1>Current theme: {theme}</h1>
        <Button />
      </div>
    </ThemeContext.Provider>
  );
}

export default App;

Button组件

import React from 'react';
import { useTheme } from '../hook-api/use-context/context';

export default function Button() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>Toggle Theme {theme}</button>
  );
}

使用场景

  • 全局主题(亮色/暗色模式)
  • 用户认证状态(登录用户信息)
  • 多语言国际化(i18n)
  • 全局配置或状态(如购物车、通知设置)

注意事项:

性能问题:当 Provider 的 value 发生变化时,所有使用该 Context 的子组件都会重新渲染(即使只用到部分字段)。为避免不必要的重渲染:

  • value 拆分为多个 Context;
  • 使用 useMemo 稳定 value 引用;
  • 将不依赖 Context 的子组件提取到 Provider 外部。

不要滥用:Context 不是万能的状态管理工具。对于复杂状态逻辑,建议结合 useReducer 或使用 Redux、Zustand 等状态库。

use-context-selector

use-context-selector 是一个 React 上下文(Context)优化库,它解决了 React 原生 useContext 在性能上的一个关键问题:当上下文值变化时,所有使用该上下文的组件都会重新渲染,即使它们只依赖上下文中的一小部分数据。

核心特性

  1. 选择性订阅:允许组件只订阅上下文中的特定部分数据
  2. 精确更新:只有当下文中的选定部分变化时才会触发组件更新
  3. 与原生Context API兼容:使用方式与React原生Context相似
  4. 轻量级:体积小,对应用包大小影响小

基本使用:

App.tsx

'use client'

import React, { StrictMode } from 'react';
import { MyProvider } from './context';
import CounterA from './components/CounterA';
import CounterB from './components/CounterB';

function App() {
  return (
    <StrictMode>
      <MyProvider>
        <CounterA />
        <CounterB />
      </MyProvider>
    </StrictMode>
  );
}

export default App;

context.tsx

'use client'

import { useState } from 'react';
import{ createContext } from 'use-context-selector';

const MyContext = createContext({} as any);

export function MyProvider({ children }: any) {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  const state: any = {
    countA,
    setCountA,
    countB,
    setCountB,
  };

  return (
    <MyContext.Provider value={state}>
      {children}
    </MyContext.Provider>
  );
}

export default MyContext;

CounterA.tsx

'use client'


import React from 'react';
import { useContextSelector, useContext } from 'use-context-selector';
import MyContext from '../context';

function CounterA() {
  const countA = useContextSelector(MyContext, (v) => v.countA);
  const setCountA = useContextSelector(MyContext, (v) => v.setCountA);

  const increment = () =>
    setCountA((s) => s -1);

  console.log('CounterA rendered');

  return (
    <div>
      <p>{new Date().getTime()}</p>
      <p>Counter A: {countA}</p>
      <button onClick={increment}>
        Increment A
      </button>
    </div>
  );
}

export default CounterA;

CounterB.tsx

'use client'

import React from 'react';
import { useContextSelector, useContext } from 'use-context-selector';
import MyContext from '../context';

function CounterB() {
  const countB = useContextSelector(MyContext, (v) => v.countB);
  const setCountB = useContextSelector(MyContext, (v) => v.setCountB);

  const increment = () =>
    setCountB((s) => s -1);

  console.log('CounterB rendered');

  return (
    <div>
      <button onClick={increment}>
        Increment B
      </button>
      <p>{new Date().getTime()}</p>
      <p>Counter B: {countB}</p>
    </div>
  );
}

export default CounterB;

从 0 到 1 实战 Flutter 蓝牙通信:无硬件,用手机完成 BLE 双向通信

🚀 手把手教你从 0 到 1 完成 Flutter 蓝牙通信(无硬件实战)

适合人群

  • Flutter 开发者,想入门 BLE
  • 手上没有任何蓝牙硬件
  • 经常遇到「设备能连上,但怎么都通信不了」

本教程将带你 从 0 到 1 跑通一套完整的 BLE 通信流程

  • Android 手机 + nRF Connect 👉 模拟蓝牙服务端
  • iOS + Flutter 👉 作为蓝牙客户端
  • 完成 Write + Notify 的双向通信

🧠 一、先建立一个正确的 BLE 心智模型(很重要)

BLE 并不是「连上设备就能发数据」。

它的真实结构是:

设备(Device)
 └── 服务(Service,UUID)
      └── 特征(Characteristic,UUID + 属性)

这意味着什么?

  • 通信不是对设备,而是对 Characteristic
  • 写数据,必须写到“支持 Write 的 Characteristic”
  • 收数据,必须监听“支持 Notify 的 Characteristic”

👉 Service / Characteristic 选错一个,通信必失败


📦 二、准备工作:安装 nRF Connect(模拟服务端)

我们使用 Nordic 官方工具 nRF Connect 来模拟一个 BLE 外设(Server)。

1️⃣ 下载地址(APK)

github.com/NordicSemic…

安装到 Android 真机 后打开。


🧩 三、配置 GATT Server(创建服务端能力)

3.1 进入 GATT Server 配置

首页点击:

Configure GATT Server

配置GATT服务.jpg


3.2 使用 Sample configuration(推荐新手)

选择系统内置:

Sample configuration

然后找到 Test Service,复制它的 Service UUID

模版配置.jpg

📌 这个 UUID 后面会反复用到

  • 客户端扫描设备
  • 发现服务
  • 选择特征通信

📡 四、配置广播(让客户端能扫描到)

回到首页,点击 ADVERTISE,新建一个广播。

关键配置点

  • Service UUID 添加到 Scan Response Data
  • 这样客户端在扫描时,才能识别你这个设备提供了什么服务

配置设备.jpg


服务端完成标志 ✅

当你在设备列表看到该设备:

设备列表.jpg

说明:

  • ✅ GATT Server 已启动
  • ✅ 服务 UUID 已广播

📱 五、Flutter 客户端完整流程(新手最关键部分)

5.1 扫描设备(Scan)

Flutter 启动后扫描 BLE 设备:

扫描设备.PNG

📌 此时只是 看到设备,还不能通信。


5.2 点击设备,建立连接(Connect)

点击设备后:

scan → connect

连接成功,才有后续步骤。


5.3 发现服务(Discover Services)

连接完成后,客户端会向服务端请求:

你这个设备,提供了哪些 Service?

获取设备服务.PNG


❗5.4 进入你创建的 Service(UUID 必须一致)

在服务列表中:

  • 找到 UUID 与 Test Service 完全一致的 Service
  • 点击进入

👉 Service 点错,后面全部白做


5.5 查看 Characteristic(真正的通信入口)

进入 Service 后,会看到多个 Characteristic:

  • 有的支持 Read
  • 有的支持 Write
  • 有的支持 Notify

❗❗5.6 客户端必须选对 Characteristic

写数据(客户端 → 服务端)
  • 选择 支持 Write / Write Without Response 的 Characteristic
  • 使用它发送数据
收数据(服务端 → 客户端)
  • 选择 支持 Notify 的 Characteristic
  • 开启监听(subscribe)

📌 对不支持 Write 的 Characteristic 写数据:

不报错,但一定没反应


🔁 六、双向通信验证

6.1 客户端写入数据

客户端通信.PNG


6.2 服务端 Notify 客户端

服务端通信.jpg


⚠️ 七、一个 nRF Connect 的 UI 坑

服务端数据不会自动刷新:

  • 切换页面
  • 再切回 SERVER
  • 才能看到最新数据

❗不是通信失败,是 UI 问题。


📎 示例代码

github.com/chengshixin…


✅ 总结一句话

BLE 通信 = 连设备 + 找对 Service + 用对 Characteristic

告别"移动端重构噩梦":TinyPro移动端适配上线!

本文由TinyPro贡献者王晨光同学原创。

一、背景:让 TinyPro 真正“走到掌心里”

TinyPro 是一套基于 TinyVue 打造的前后端分离后台管理系统,支持菜单配置、国际化、多页签、权限管理等丰富特性。 TinyPro 在桌面端具备良好的体验和模块化架构,但随着移动办公、平板展示等场景增多,移动端体验的短板逐渐显现:

  • 页面缩放不均衡,布局出现溢出或错位;
  • 模态框在小屏上遮挡内容;
  • 图表和表格在横屏与竖屏间切换时无法自适应;
  • 操作区过于密集,不符合触控习惯。

为此启动了 TinyPro 移动端适配项目,目标是在不破坏现有结构的前提下,实现“一次开发,跨端流畅”的体验。

二、技术选型与总体架构

本次移动端适配要求在复杂的中后台系统中实现「一次开发,多端自适应」,既要保证样式灵活,又要维持可维护性和构建性能。

在技术选型阶段,综合评估了三种常见方案:

方案 优点 缺点
纯 CSS 媒体查询 简单直接、依赖少 样式分散、逻辑重复、维护困难
TailwindCSS 响应式类 社区成熟、类名直观、生态完善 样式表体积大、断点固定、不够灵活
UnoCSS 原子化方案 按需生成、性能极轻、断点与变体完全可定制 需要自行配置规范与规则体系

最终选择了 UnoCSS + Less 的混合架构

  • UnoCSS:负责通用布局、间距、排版等高频样式,原子化写法提升开发效率;
  • Less 媒体查询:用于模态框、导航栏等复杂场景的精细控制;
  • 统一断点配置:集中管理屏幕尺寸分级,保持视觉一致性;
  • 自定义变体(max-<bp>:支持“桌面端优先”策略,通过 max-width 实现移动端自适应,样式逻辑更直观。

UnoCSS:轻量、灵活、即时生成

UnoCSS 是一个 按需生成的原子化 CSS 引擎,最大的特点是 零冗余与高度可定制。 不同于 TailwindCSS 的预编译方式,UnoCSS 会在构建阶段根据实际使用的类名即时生成样式规则,从而显著提升构建性能与灵活性.

在配置中通过 presetMini()presetAttributify() 组合使用,使开发者既可以写:

<div class="p-4 text-center bg-gray-100 max-md:p-2"></div>

也可以使用属性化语法:

<div p="4" text="center" bg="gray-100" max-md:p="2"></div>

presetMini 提供轻量原子类体系,presetAttributify 则允许以声明式方式书写样式,更直观、组件化友好。

断点配置与响应式策略

TinyPro 的适配核心之一,是在 uno.config.ts 中建立统一的断点体系,并通过自定义 max-<bp> 前缀实现“桌面端优先”的响应式策略。

const breakpoints = {
  sm: '641px',     // 手机(小屏)
  md: '769px',     // 平板竖屏
  lg: '1025px',    // 平板横屏 / 小型笔电
  xl: '1367px',    // 常规笔电
  '2xl': '1441px', // 高清笔电
  '3xl': '1921px', // 桌面大屏
}

并通过自定义 variants 扩展 max-<bp> 前缀:

variants: [
    (matcher) => {
      const match = matcher.match(/^max-([a-z0-9]+):/)
      if (match) {
        const bp = match[1]
        const value = breakpoints[bp]
        if (!value) return
        return {
          matcher: matcher.replace(`max-${bp}:`, ''),
          parent: `@media (max-width: ${value})`,
        }
      }
    },
  ]

让开发者能自然地书写:

<div class="w-1/2 max-md:w-full"></div>

含义:

默认宽度为 50%,在宽度小于 769px 的设备上改为 100%。

TinyPro 采用「桌面端优先(max-width)」的布局策略:默认以桌面端布局为基础,在移动设备上再进行针对性优化。相比常见的「移动端优先(min-width)」方式,这种做法更符合中后台系统的特性,同时让 UnoCSS 的断点逻辑更直观,并确保主屏体验的稳定性。

三、样式与编码策略

  • 优先级

    • 简单场景:使用 UnoCSS 原子类。
    • 复杂样式:使用 Less 媒体查询。
  • 布局与滚动

    • 首页及核心业务模块完成适配,小屏模式下侧边栏默认收起、导航栏折叠,确保主要内容可见。
    • 页面主要容器避免横向滚动,必要时在小屏下开启局部横向滚动。
    • 表格与大区块在不同断点下自动调整宽度、栅格与间距,小屏下支持横向滚动;分页与密度支持响应式控制。

    布局与滚动.gif

  • 图表自适应

    • 图表组件接入 resize 监听,在侧边栏展开/收起、窗口缩放、语言切换等场景下保持自适应。
    • 小屏下使用 vw 宽度与较小字号,保证图表展示效果与可读性。

    图表自适应.gif

  • 表单与模态框

    • 接入 useResponsiveSize(),控制弹窗在小屏下铺满显示,大屏保持固定宽度。
    • 表单项在不同断点下动态调整排布与间距,优化触控体验。

    表单与模态框.gif

  • 导航与交互

    • 小屏下隐藏导航栏非关键元素,操作聚合到"折叠菜单"。
    • 移动端默认收起侧边菜单栏,提升主要内容展示区域。

    导航与交互.gif

  • 性能优化

    • responsive.ts 中对 resize 事件处理增加节流机制,避免窗口缩放等场景下的频繁无效渲染。

四、常用代码片段

  1. 基于栅格系统 + 响应式断点工具类,通过为 tiny-row 和 tiny-col 添加不同屏幕宽度下的样式规则,实现自适应布局:
<tiny-layout>
    <tiny-row class="flex justify-center max-md:flex-wrap">
        <tiny-col class="w-1/4 max-md:w-1/2 max-sm:w-full max-md:mb-4">···</tiny-col>
        ···
        <tiny-col class="w-1/4 max-md:w-1/2 max-sm:w-full max-md:mb-4">···</tiny-col>
    </tiny-row>
</tiny-layout>

<div class="theme-line flex max-sm:grid max-sm:grid-cols-4 max-sm:gap-2">
  <div···
  </div>
</div>
  1. 基于 响应式工具类 + 自定义响应式 Hook,解决(1)对话框宽度自适应;(2)表格尺寸和密度自适应;(3)逻辑层响应式控制
<template>
  <section class="p-4 sm:p-6 lg:p-8 max-sm:text-center">
    <tiny-dialog :width="modalSize">...</tiny-dialog>
  </section>
</template>

<script setup lang="ts">
import { useResponsiveSize } from '@/hooks/responsive'
const { modalSize } = useResponsiveSize() // 小屏 100%,大屏 768px
</script>
<template>
  <div class="container">
    <tiny-grid ref="grid" :fetch-data="fetchDataOption" :pager="pagerConfig" :size="gridSize" :auto-resize="true" align="center">
      ···
    </tiny-grid>
  </div>
</template>

<script setup lang="ts">
import { useResponsiveSize } from '@/hooks/responsive'
const { gridSize } = useResponsiveSize() // 小屏为mini grid,大屏为medium grid
</script>
  1. 通过 useResponsive 获取屏幕断点状态 sm/md/lg,如:在模板中结合 v-if="!lg" 控制分隔线的渲染,从而实现了小屏下纵向菜单才显示分隔线的效果
<template>
  <ul class="right-side" :class="{ open: menuOpen }">
    <!-- 小屏下才显示分隔线 -->
    <li v-if="!lg">
      <div class="divider"></div>
    </li>
    ···
  </ul>
</template>

<script lang="ts" setup>
import { useResponsive } from '@/hooks/responsive'
const { lg } = useResponsive()
</script>

五、结语

通过本次移动端适配, TinyPro 实现了“从桌面到掌心”的统一体验: 开发者可以继续沿用熟悉的组件体系与布局方式,同时享受 UnoCSS 带来的原子化灵活性与性能优势。在不改变核心架构的前提下,TinyPro 变得更轻盈、更顺滑,也更符合移动时代的使用场景。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyPro、TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

深度复刻小米AI官网交互动画

近日在使用小米AI大模型MIMO时,被其顶部的透视跟随动画深深吸引,移步官网( mimo.xiaomi.com/zh/

效果演示

效果图.gif

1. 交互梳理

  1. 初始状态底部有浅色水印,且水印奇数行和偶数行有错位
  2. 初始状态中间文字为黑色的汉字
  3. 鼠标移入后,会在以鼠标为中心形成一个黑色圆形,黑色圆中有第二种背景水印,且水印依旧奇数行和偶数行有错位
  4. 鼠标移动到中间汉字部分,会有白色英文显示
  5. 鼠标迅速移动时,会根据鼠标移动轨迹有一个拉伸椭圆跟随,然后恢复成圆形的动画效果

现在基于这个交互的拆解,逐步来复刻交互效果

2. 组件结构与DOM设计

2.1 模板结构

采用「静态底层+动态上层」的双层视觉结构,通过CSS绝对定位实现图层叠加,既保证初始状态的视觉完整性,又能让交互效果精准作用于上层,不干扰底层基础展示。两层分工明确,具体如下:

图层 类名 内容 功能
底层 .z-1 中文标题 "你好,世界!" 和灰色 "HELLO" 文字矩阵 静态背景展示
上层 .z-2 英文标题 "Hello , World!" 和白色 "HELLO" 文字矩阵 鼠标交互时的动态效果层

2.2 核心 DOM 结构

<div class="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousemove="onMouseMove">
  <!-- 底层内容 -->
  <div class="z-1">
    <div class="line" v-for="line in 13">
      <span class="line-item" v-for="item in 13">HELLO</span>
    </div>
  </div>
  <h1 class="title-1">你好,世界!</h1>
  
  <!-- 上层交互内容 -->
  <div class="z-2" :style="{ 'clip-path': circleClipPath }">
    <div class="hidden-div">
      <div class="line" v-for="line in 13">
        <span class="line-item" v-for="item in 13">HELLO</span>
      </div>
    </div>
    <h1 class="title-2">Hello , World!</h1>
  </div>
</div>

关键说明:hidden-div用于包裹上层文字矩阵,配合.z-2的定位规则,确保遮罩效果精准覆盖;两层文字矩阵尺寸一致,保证视觉对齐,增强透视沉浸感。

3. 技术实现

3.1 核心功能模块

3.1.1 轨迹点系统

轨迹点系统是实现平滑鼠标跟随效果的核心,通过维护6个轨迹点的位置信息,创建出具有弹性延迟的跟随动画。

// 轨迹点系统 
const trailSystem = ref({
  targetX: 0,
  targetY: 0,
  trailPoints: Array(6).fill(null).map(() => ({ x: 0, y: 0 })),
  animationId: 0,
  isInside: false
});

设计思路:6个轨迹点是兼顾流畅度与性能的平衡值——点太少则拖尾效果不明显,点太多则增加计算开销,配合递减阻尼系数,实现“头快尾慢”的自然跟随。

3.1.2 动态 Clip-Path 计算

通过计算鼠标位置和轨迹点的关系,动态生成 clip-path CSS 属性值,实现跟随鼠标的圆形/椭圆形遮罩效果。

// 计算clip-path值
const circleClipPath = computed(() => {
  if (!showCircle.value) {
    return 'circle(0px at -300px -300px)'; // 完全隐藏状态
  }

  // 复制轨迹系统数据进行计算
  const system = JSON.parse(JSON.stringify(trailSystem.value));
  
  // 更新轨迹点
  for (let t = 0; t < 6; t++) {
    const prevX = t === 0 ? system.targetX : system.trailPoints[t - 1].x;
    const prevY = t === 0 ? system.targetY : system.trailPoints[t - 1].y;
    const damping = 0.7 - 0.04 * t; // 阻尼系数,后面的点移动更慢
    
    const deltaX = prevX - system.trailPoints[t].x;
    const deltaY = prevY - system.trailPoints[t].y;
    
    // 平滑插值
    system.trailPoints[t].x += deltaX * damping;
    system.trailPoints[t].y += deltaY * damping;
  }
  
  // 获取第一个点(头部)和最后一个点(尾部)
  const head = system.trailPoints[0];
  const tail = system.trailPoints[5];
  
  const diffX = head.x - tail.x;
  const diffY = head.y - tail.y;
  const distance = Math.sqrt(diffX * diffX + diffY * diffY);
  
  let clipPathValue = '';
  
  if (distance < 10) { // 如果距离很近,显示圆形
    clipPathValue = `circle(200px at ${head.x}px ${head.y}px)`;
  } else {
    // 创建椭圆形的polygon,连接头尾两点
    const angle = Math.atan2(diffY, diffX); // 连接角度
    const points = [];
    
    // 从头部开始,画半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle - Math.PI / 2 + Math.PI * i / 30;
      const x = head.x + 200 * Math.cos(theta);
      const y = head.y + 200 * Math.sin(theta);
      points.push(`${x}px ${y}px`);
    }
    
    // 从尾部开始,画另半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle + Math.PI / 2 + Math.PI * i / 30;
      const x = tail.x + 200 * Math.cos(theta);
      const y = tail.y + 200 * Math.sin(theta);
      points.push(`${x}px ${y}px`);
    }
    
    clipPathValue = `polygon(${points.join(', ')})`;
  }
  
  return clipPathValue;
});

3.1.3 鼠标事件处理

实现了完整的鼠标交互逻辑,包括鼠标进入、离开和移动时的状态管理和动画控制。

事件 处理函数 功能
mouseenter onMouseEnter 激活交互效果,初始化轨迹点
mouseleave onMouseLeave 停用交互效果,重置轨迹点
mousemove onMouseMove 更新目标点位置,驱动动画

4. 技术亮点

4.1 轨迹点系统算法

核心原理:使用6个轨迹点,每个点跟随前一个点移动,并应用不同的阻尼系数,实现平滑的拖尾效果。

技术优势

  • 实现了自然的物理运动效果,比简单的线性跟随更具视觉吸引力
  • 通过阻尼系数的递减,创建出层次感和深度感
  • 算法复杂度低,性能消耗小,适合实时交互场景

4.2 动态 Clip-Path 技术

核心原理:利用CSS clip-path属性的动态特性,结合轨迹点位置计算,实时生成不规则遮罩,替代Canvas/SVG的图形绘制方案,用更轻量化的方式实现复杂视觉效果。

技术优势

  • 无依赖轻量化:无需引入任何图形库,纯CSS+JS即可实现,减少项目依赖体积,降低集成成本
  • 平滑过渡无卡顿:通过数值插值计算,实现圆形与椭圆形遮罩的无缝切换,无帧断裂感,视觉连贯性强
  • 渲染性能优化:配合 will-change: clip-path 提示浏览器,提前分配渲染资源,减少重排重绘,提升动画流畅度

5. 性能优化

  1. 渲染性能

    • 使用 will-change: clip-path 提示浏览器优化渲染
    • 合理使用 Vue 的响应式系统,避免不必要的重计算
  2. 事件处理

    • 仅在鼠标在容器内时更新目标点位置,减少计算量
    • 鼠标离开时停止动画,释放资源
  3. 动画性能

    • 使用 requestAnimationFrame 实现流畅的动画效果
    • 鼠标离开时取消动画帧请求,避免内存泄漏

6. 总结与扩展

本次复刻的小米MiMo透视动画,核心价值在于“用简单技术组合实现高级视觉效果”——无需复杂图形库,仅依托Vue3响应式能力与CSS clip-path属性,就能打造出兼具质感与性能的交互组件。其核心亮点可概括为三点:

  • 交互创新:轨迹点系统与动态clip-path结合,打破传统静态标题的交互边界,带来自然流畅的鼠标跟随体验
  • 视觉精致:双层文字矩阵的分层设计,配合遮罩形变,营造出兼具深度感与品牌性的视觉效果
  • 性能可控:轻量化技术方案+多维度优化策略,在保证视觉效果的同时,兼顾页面性能与可维护性

扩展方向

该组件的实现思路可灵活迁移至其他场景:

  • 弹窗过渡动画:将clip-path遮罩用于弹窗进入/退出效果,实现不规则形状的过渡动画。
  • 滚动动效:结合滚动事件替换鼠标事件,实现页面滚动时的元素透视跟随效果。
  • 移动端适配:增加触摸事件支持,将鼠标交互替换为触摸滑动,适配移动端场景。

完整代码

<template>
  <div class="hero-container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousemove="onMouseMove">
    <div class="z-1">
      <div class="line" v-for="line in 13">
        <span class="line-item" v-for="item in 13">HELLO</span>
      </div>
    </div>
    <h1 class="title-1">你好,世界</h1>

    <!-- 第二个div,鼠标移入后需要显示的内容,通过clip-path:circle(0px at -300px -300px)达到隐藏效果 -->
    <div class="z-2" :style="{ 'clip-path': circleClipPath }">
      <div class="hidden-div">
        <div class="line" v-for="line in 13">
          <span class="line-item" v-for="item in 13">HELLO</span>
        </div>
      </div>
      <h1 class="title-2">HELLO , World</h1>
    </div>
  </div>
</template>

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

const showCircle = ref(false)
const containerRef = ref(null)

const trailSystem = ref({
  targetX: 0,
  targetY: 0,
  trailPoints: Array(6)
    .fill(null)
    .map(() => ({ x: 0, y: 0 })),
  animationId: 0,
  isInside: false,
})

const circleClipPath = computed(() => {
  if (!showCircle.value) {
    return 'circle(0px at -300px -300px)'
  }

  // 复制轨迹系统数据进行计算
  const system = JSON.parse(JSON.stringify(trailSystem.value))

  // 更新轨迹点
  for (let t = 0; t < 6; t++) {
    const prevX = t === 0 ? system.targetX : system.trailPoints[t - 1].x
    const prevY = t === 0 ? system.targetY : system.trailPoints[t - 1].y
    const damping = 0.7 - 0.04 * t // 阻尼系数,后面的点移动更慢

    const deltaX = prevX - system.trailPoints[t].x
    const deltaY = prevY - system.trailPoints[t].y

    // 平滑插值
    system.trailPoints[t].x += deltaX * damping
    system.trailPoints[t].y += deltaY * damping
  }

  // 获取第一个点(头部)和最后一个点(尾部)
  const head = system.trailPoints[0]
  const tail = system.trailPoints[5]

  const diffX = head.x - tail.x
  const diffY = head.y - tail.y
  const distance = Math.sqrt(diffX * diffX + diffY * diffY)

  let clipPathValue = ''

  if (distance < 10) {
    // 如果距离很近,显示圆形
    clipPathValue = `circle(200px at ${head.x}px ${head.y}px)`
  } else {
    // 创建椭圆形的polygon,连接头尾两点
    const angle = Math.atan2(diffY, diffX) // 连接角度
    const points = []

    // 从头部开始,画半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle - Math.PI / 2 + (Math.PI * i) / 30
      const x = head.x + 200 * Math.cos(theta)
      const y = head.y + 200 * Math.sin(theta)
      points.push(`${x}px ${y}px`)
    }

    // 从尾部开始,画另半个椭圆
    for (let i = 0; i <= 30; i++) {
      const theta = angle + Math.PI / 2 + (Math.PI * i) / 30
      const x = tail.x + 200 * Math.cos(theta)
      const y = tail.y + 200 * Math.sin(theta)
      points.push(`${x}px ${y}px`)
    }

    clipPathValue = `polygon(${points.join(', ')})`
  }

  return clipPathValue
})

// 动画循环函数
const animate = () => {
  if (showCircle.value) {
    // 更新轨迹点
    for (let t = 0; t < 6; t++) {
      const prevX = t === 0 ? trailSystem.value.targetX : trailSystem.value.trailPoints[t - 1].x
      const prevY = t === 0 ? trailSystem.value.targetY : trailSystem.value.trailPoints[t - 1].y
      const damping = 0.7 - 0.04 * t // 阻尼系数,后面的点移动更慢

      const deltaX = prevX - trailSystem.value.trailPoints[t].x
      const deltaY = prevY - trailSystem.value.trailPoints[t].y

      // 平滑插值
      trailSystem.value.trailPoints[t].x += deltaX * damping
      trailSystem.value.trailPoints[t].y += deltaY * damping
    }

    // 请求下一帧
    trailSystem.value.animationId = requestAnimationFrame(animate)
  }
}

const onMouseEnter = (event) => {
  const container = event.currentTarget
  const rect = container.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top

  showCircle.value = true

  // 初始化目标位置和轨迹点
  trailSystem.value.targetX = x
  trailSystem.value.targetY = y
  trailSystem.value.isInside = true

  // 初始化所有轨迹点到当前位置
  for (let i = 0; i < 6; i++) {
    trailSystem.value.trailPoints[i] = { x, y }
  }

  // 开始动画
  if (!trailSystem.value.animationId) {
    trailSystem.value.animationId = requestAnimationFrame(animate)
  }
}

const onMouseLeave = (event) => {
  const container = event.currentTarget
  const rect = container.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top

  showCircle.value = false
  trailSystem.value.isInside = false

  // 将目标点移出容器边界,使轨迹点逐渐拉回
  let targetX = x
  let targetY = y

  if (x <= 0) targetX = -400
  else if (x >= rect.width) targetX = rect.width + 400

  if (y <= 0) targetY = -400
  else if (y >= rect.height) targetY = rect.height + 400

  trailSystem.value.targetX = targetX
  trailSystem.value.targetY = targetY

  // 停止动画
  if (trailSystem.value.animationId) {
    cancelAnimationFrame(trailSystem.value.animationId)
    trailSystem.value.animationId = 0
  }
}

const onMouseMove = (event) => {
  if (showCircle.value) {
    const container = event.currentTarget
    const rect = container.getBoundingClientRect()
    const x = event.clientX - rect.left
    const y = event.clientY - rect.top

    trailSystem.value.targetX = x
    trailSystem.value.targetY = y
  }
}
</script>

<style scoped>
.hero-container {
  cursor: crosshair;
  background: #faf7f5;
  border-bottom: 1px solid #000;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 500px;
  display: flex;
  position: relative;
  overflow: hidden;
}

.z-1 {
  pointer-events: auto;
  -webkit-user-select: none;
  user-select: none;
  flex-direction: column;
  justify-content: flex-start;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
}

.z-1 .line {
  display: flex;
  align-items: center;
  white-space: nowrap;
  color: #0000000d;
  letter-spacing: 0.3em;
  flex-wrap: nowrap;
  font-size: 52px;
  font-weight: 700;
  line-height: 1.6;
  display: flex;
}

.z-1 .line-item {
  cursor: default;
  flex-shrink: 0;
  margin-right: 0.6em;
  transition:
    color 0.3s,
    text-shadow 0.3s;
  font-family: inherit !important;
}

.z-1 .line:nth-child(odd) {
  margin-left: -2em;
  background-color: rgb(245, 235, 228);
}

.title-1 {
  z-index: 1;
  color: #000;
  letter-spacing: 0.02em;
  text-align: center;
  margin: 0;
  font-size: 72px;
  font-weight: 700;
}

.z-2 {
  pointer-events: none;
  z-index: 10;
  will-change: clip-path;
  background: #000;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
}

.z-2 .hidden-div {
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  flex-direction: column;
  justify-content: flex-start;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
}

.z-2 .hidden-div .line {
  white-space: nowrap;
  color: #ffffff1f;
  letter-spacing: 0.3em;
  flex-wrap: nowrap;
  font-size: 32px;
  font-weight: 700;
  line-height: 1.6;
  display: flex;
}

.z-2 .hidden-div .line:nth-child(odd) {
  margin-left: -0.5em;
}

.title-2 {
  font-size: 72px;
  color: #fff;
  letter-spacing: 0.02em;
  text-align: center;
  white-space: nowrap;
  margin: 0;
  font-size: 72px;
  font-weight: 700;
}
</style>

小米的前端一直很牛,非常有创意,我也通过F12学习源码体会到了新的思路,希望大家也多多关注小米和小米的技术~

TS异步编程

Gemini生成

第一部分:核心概念 (Why & What)

在编程世界里,代码的执行方式主要分两种:同步 (Synchronous)异步 (Asynchronous)

1. 同步 (Synchronous) —— “死心眼的排队者”

概念: 代码从上到下,一行一行执行。上一行代码没有执行完,下一行代码绝对不会开始。

生活类比: 想象你在银行柜台办理业务。

  1. 你前面有一个人正在办业务(代码行 A)。
  2. 不管他办得有多慢,你(代码行 B)都只能在后面干站着等。
  3. 你不能玩手机,不能去上厕所,只能阻塞 (Block) 在那里,直到他结束。

代码表现:

console.log("1. 开始点餐");
alert("我是同步的弹窗,我不关掉,你什么都做不了!"); // 这里会卡住
console.log("2. 吃饭");

如果不点击弹窗的确定,"2. 吃饭" 永远不会打印出来。这就是“阻塞”。


2. 异步 (Asynchronous) —— “拿着取餐器的食客”

概念: 遇到耗时的任务(比如从网络下载图片、读取文件),程序不会傻等,而是把任务交给“别人”(浏览器或操作系统)去处理,自己继续往下执行后面的代码。等耗时任务做完了,再通知程序回来处理结果。

生活类比: 想象你在奶茶店点单。

  1. 你点了一杯制作很复杂的奶茶(耗时任务)。
  2. 店员没有让你站在柜台前盯着他做,而是给了你一个取餐器(回调/Promise),然后说:“你先去旁边坐着玩手机,好了震动叫你。”
  3. 你找个位置坐下(继续执行后续代码)。
  4. 过了一会儿,奶茶好了,取餐器震动,你去拿奶茶(处理异步结果)。

代码表现:

console.log("1. 点单:我要一杯奶茶");

// 这是一个模拟异步的函数,假设需要 2 秒钟
setTimeout(() => {
    console.log("3. 奶茶好了!(这是异步回来的结果)");
}, 2000);

console.log("2. 找个位置坐下玩手机");

控制台的打印顺序是:

  1. 1. 点单...
  2. 2. 找个位置... (注意:这里直接跳过了等待,先执行了!)
  3. (过了2秒后) 3. 奶茶好了...

3. 为什么 JavaScript/TypeScript 必须要有异步?

你可能会问:“同步多简单啊,逻辑清晰,为什么要搞这么复杂的异步?”

这和 JS 的出身有关:

  1. 单线程 (Single Thread):JavaScript(以及编译后的 TS)是单线程的。也就是说,它只有一个“大脑”,同一时间只能做一件事。它不像 Java 或 C++ 那样可以开启多条线程同时工作。
  2. 浏览器的体验
    • 假设你打开一个网页,它需要去服务器请求“用户列表”。
    • 如果使用同步:在数据请求回来的这 1-2 秒内,网页会完全卡死。你点击按钮没反应,滚动条滚不动,甚至无法关闭网页。这对用户体验是灾难性的。
    • 如果使用异步:请求发出去后,浏览器继续响应你的鼠标点击和滚动,等数据回来了,再悄悄把列表渲染到屏幕上。

总结第一部分:

  • 同步 = 顺序执行,会卡住(阻塞)。
  • 异步 = 不等待,继续往下走,回头再处理结果。
  • TS/JS 的特性 = 单线程,为了不让网页/程序卡死,必须大量使用异步。

第二部分:异步的演进史 (History)

JavaScript/TypeScript 的异步演进史,其实就是一部与“代码可读性”抗争的历史。我们的目标始终未变:让异步代码看起来像同步代码一样简单易懂。

我们分三个阶段来讲:


1. 第一阶段:上古时代 —— 回调函数 (Callback)

在 Promise 出现之前(大约是 2015 年 ES6 标准发布前),我们处理异步只有一种办法:回调函数

什么是回调? 简单来说,就是你定义一个函数,但你自己不调用它,而是把它作为参数传给另一个函数(比如网络请求函数)。你告诉对方:“等你做完你的事,回头(Call back)调用一下我这个函数,把结果传给我。”

场景模拟: 我们要去数据库获取用户信息。

// 定义一个回调函数的类型:接收 string 类型的数据,没有返回值
type MyCallback = (data: string) => void;

function getUserData(callback: MyCallback) {
    console.log("1. 开始向服务器请求数据...");
    
    // 模拟耗时 1 秒
    setTimeout(() => {
        console.log("2. 服务器返回数据了");
        const data = "张三";
        // 关键点:任务做完后,手动调用传进来的函数
        callback(data); 
    }, 1000);
}

// 使用
getUserData((name) => {
    console.log(`3. 拿到用户名:${name}`);
});

问题在哪里? 如果是这一层简单的调用,看起来还不错。但现实往往很残酷。


2.第二阶段:黑暗时代 —— 回调地狱 (Callback Hell)

场景升级: 现在的业务逻辑变成了这样,必须严格按顺序执行:

  1. 先获取用户名(比如 "张三")。
  2. 拿到用户名后,去数据库查他的ID
  3. 拿到 ID 后,去查他的订单

代码会变成什么样?

// 伪代码演示,注意看缩进的形状
getUserName((name) => {
    console.log(`拿到名字: ${name}`);
    
    // 在回调里面嵌套第二个请求
    getUserId(name, (id) => {
        console.log(`拿到ID: ${id}`);
        
        // 在回调里面嵌套第三个请求
        getUserOrders(id, (orders) => {
            console.log(`拿到订单: ${orders}`);
            
            // 如果还有第四步... 屏幕就要炸了
            getOrderDetails(orders[0], (detail) => {
                // ...
            });
        });
    });
});

这就是著名的“回调地狱”(也就是“厄运金字塔”):

  1. 代码横向发展:缩进越来越深,阅读极其困难。
  2. 错误处理灾难:你需要在每一层回调里单独写 if (error) ...,极其容易漏掉。
  3. 维护困难:想调整一下顺序?你要小心翼翼地拆括号,很容易改崩。

3.第三阶段:曙光初现 —— Promise (承诺)

为了解决“回调地狱”,社区提出了一种新的规范,后来被纳入了 ES6 标准,这就是 Promise

什么是 Promise? 它是一个对象,代表了“一个未来才会知道结果的操作”。 你可以把它想象成一张披萨店的取餐小票。 当你拿到这个 Promise(小票)时,披萨还没好,但它承诺未来会给你两个结果中的一个:

  1. Fulfilled (成功):披萨做好了,给你披萨。
  2. Rejected (失败):烤箱炸了,给你一个错误原因。

Promise 最大的贡献:链式调用 (Chaining) 它把“回调地狱”的横向嵌套,拉直成了纵向的链条。

TypeScript 中的 Promise 写法:

我们看看上面的“回调地狱”用 Promise 改写后是什么样:

// 假设这些函数现在返回的是 Promise,而不是接受回调
// getUserName() -> 返回 Promise<string>

getUserName()
    .then((name) => {
        console.log(`拿到名字: ${name}`);
        // 返回下一个异步任务,继续往下传
        return getUserId(name); 
    })
    .then((id) => {
        console.log(`拿到ID: ${id}`);
        return getUserOrders(id);
    })
    .then((orders) => {
        console.log(`拿到订单: ${orders}`);
    })
    .catch((error) => {
        // 重点:这里一个 catch 可以捕获上面任何一步发生的错误!
        console.error("出错了:", error);
    });

完整代码

// 1. 获取用户名的函数
// 返回值类型:Promise<string> -> 承诺未来会给出一个 string
function getUserName(): Promise<string> {
    return new Promise((resolve, reject) => {
        console.log("--- 1. 开始请求用户名 ---");
        
        // 模拟网络耗时 1秒
        setTimeout(() => {
            const isSuccess = true; // 模拟:假设请求成功

            if (isSuccess) {
                // 成功了!调用 resolve,把数据 "张三" 传出去
                // 这个 "张三" 会传给下一个 .then((name) => ...) 里的 name
                resolve("张三"); 
            } else {
                // 失败了!调用 reject
                // 这会跳过后面的 .then,直接进入最后的 .catch
                reject("获取用户名失败:网络连接断开"); 
            }
        }, 1000);
    });
}

// 2. 获取用户ID的函数
// 接收参数 name,返回 Promise<number>
function getUserId(name: string): Promise<number> {
    return new Promise((resolve, reject) => {
        console.log(`--- 2. 正在查 ${name} 的ID ---`);

        setTimeout(() => {
            // 假设我们查到了 ID 是 10086
            resolve(10086);
        }, 1000);
    });
}

// 3. 获取订单的函数
// 接收参数 id,返回 Promise<string[]> (字符串数组)
function getUserOrders(id: number): Promise<string[]> {
    return new Promise((resolve, reject) => {
        console.log(`--- 3. 正在查 ID:${id} 的订单 ---`);

        setTimeout(() => {
            // 返回订单列表
            resolve(["奶茶", "炸鸡", "Switch游戏机"]);
        }, 1000);
    });
}

// --- 实际调用部分(就是你刚才看到的那段代码) ---

console.log("程序启动...");

getUserName()
    .then((name) => {
        // 这里接收到的 name 就是 resolve("张三") 里的 "张三"
        console.log(`✅ 拿到名字: ${name}`);
        
        // 关键点:这里 return 了下一个 Promise 函数的调用
        // 这样下一个 .then 才会等到 getUserId 完成后才执行
        return getUserId(name); 
    })
    .then((id) => {
        // 这里接收到的 id 就是 resolve(10086) 里的 10086
        console.log(`✅ 拿到ID: ${id}`);
        return getUserOrders(id);
    })
    .then((orders) => {
        // 这里接收到的 orders 就是那个数组
        console.log(`✅ 拿到订单: ${orders}`);
    })
    .catch((error) => {
        console.error(`❌ 流程中断: ${error}`);
    })
    .finally(() => {
        // (可选) finally 不管成功失败都会执行
        console.log("--- 流程结束 ---");
    });

Promise 的核心状态(面试常考): 一个 Promise 一定处于以下三种状态之一:

  1. Pending (进行中):刚初始化,还没结果。
  2. Fulfilled / Resolved (已成功):操作成功,调用了 .then
  3. Rejected (已失败):操作失败,调用了 .catch

TS 类型小贴士: 在 TypeScript 中,Promise 是有泛型的。 如果一个异步函数最终返回一个字符串,它的类型是 Promise<string>。 如果返回一个数字,类型是 Promise<number>

// 这是一个返回 Promise 的函数定义示例
function wait(ms: number): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("时间到!");
        }, ms);
    });
}

总结第二部分:

  • 回调函数:最原始,容易导致嵌套过深(回调地狱)。
  • Promise:通过 .then() 链式调用,把代码拉直了,解决了缩进问题,并且统一了错误处理(.catch())。

但是……你有没有发现,Promise 虽然比回调好,但还是有很多 .then()?代码里充斥着很多小括号和箭头函数,看起来依然不像我们要的“同步代码”。

这就是为什么我们需要第三部分:Async/Await(终极解决方案)。


第三部分:现代标准写法 (Async/Await)

好的,来到最激动人心的部分了!Async/Await 是现代 JavaScript/TypeScript 开发的标配

学会了这个,你就不再需要写那些繁琐的 .then 链条了。代码会变得像写同步代码(比如 Java 或 Python)一样直观。


1. 什么是 Async/Await?

  • Async/Await 是在 ES2017 (ES8) 引入的新语法。
  • 它本质上是 Promise 的语法糖
    • 也就是说,底层依然在跑 Promise,只是写法变了,机器执行的逻辑没变。
  • async:放在函数定义前,表示“这个函数内部有异步操作”。
  • await:放在 Promise 前面,表示“等一下,直到这个 Promise 出结果(resolve)了,再往下走”。

2. 代码对比:从 Promise 到 Async/Await

让我们用刚才定义的三个函数(getUserName, getUserId, getUserOrders)来演示。它们本身的定义不需要改,只需要改调用的方式

旧写法 (Promise 链式调用)

哪怕逻辑再清晰,依然有很多回调函数嵌套。

function runOldWay() {
    getUserName()
        .then(name => getUserId(name))
        .then(id => getUserOrders(id))
        .then(orders => console.log(orders))
        .catch(err => console.error(err));
}

新写法 (Async/Await)

看!没有回调函数了!全是赋值语句!

// 1. 必须在函数前加 async 关键字
async function runNewWay() {
    try {
        console.log("开始任务...");

        // 2. 使用 await 等待结果,直接赋值给变量
        // JS 引擎运行到这里会暂停,直到 getUserName 里的 resolve 被调用
        const name = await getUserName(); 
        console.log(`拿到名字: ${name}`);

        // 上一行没拿到结果前,这一行绝不会执行
        const id = await getUserId(name);
        console.log(`拿到ID: ${id}`);

        const orders = await getUserOrders(id);
        console.log(`拿到订单: ${orders}`);

    } catch (error) {
        // 3. 错误处理回归原始的 try...catch
        // 只要上面任何一个 await 的 Promise 被 reject,就会跳到这里
        console.error("出错了:", error);
    }
}

// 调用这个异步函数
runNewWay();

3. 深度解析:await 到底做了什么?

当你写下 const name = await getUserName(); 时,发生了什么?

  1. 暂停执行:函数 runNewWay 的执行被暂停在这一行。
  2. 让出线程:虽然 runNewWay 停了,但主线程没有卡死(没有阻塞)。浏览器可以去处理点击事件、渲染动画,或者执行 runNewWay 外面的其他代码。
  3. 等待结果getUserName 在后台跑(比如等待网络请求)。
  4. 恢复执行:一旦 getUserName 完成并 resolve 了结果,runNewWay 会被“唤醒”。结果被赋值给 name,然后继续执行下一行代码。

注意: await 只能用在 async 函数内部。(虽然最新的 TS/JS 支持 Top-level await,但在普通函数里还是不行的)。


4. TypeScript 里的 Async 函数类型

在 TypeScript 中,async 函数的返回值类型永远是 Promise

就算你 return 的是一个普通数字,TS 也会自动帮你不装成 Promise。

// 普通函数
function add(a: number, b: number): number {
    return a + b;
}

// Async 函数
// 虽然看起来 return 3,但 TS 推断出的返回类型是 Promise<number>
async function addAsync(a: number, b: number): Promise<number> {
    return a + b; 
}

// 调用时必须处理 Promise
const result = addAsync(1, 2); // result 是 Promise<number>
// 正确用法:
// await addAsync(1, 2) 或 addAsync(1, 2).then(...)

代码分析

const result = addAsync(1, 2);: 无论有没有加await, async函数都是返回Promise<>对象. 如果没有添加await, 依然会执行该异步函数, 但是不会在这里等待, 会立刻执行下面的函数, 这个addAsync函数就在后台默默执行. 有时候会故意不写await, 比如下面这个场景:

async function initPage() {
    // 发送两个请求,但我不想串行等待(不希望 A 完了才做 B)
    const taskA = getUserInfo(); // 没写 await,请求发出去了
    const taskB = getBanners();  // 没写 await,请求也发出去了
    
    console.log('两个请求都已经发出去了,正在后台跑...');

    // 稍后我再一起等它们的结果
    const user = await taskA;
    const banner = await taskB;
}

总结第三部分

  1. 写法更像同步:用 try...catch 替代 .catch,用赋值替代 .then
  2. 可读性飞跃:代码逻辑从上到下,符合人类阅读习惯。
  3. 调试方便:在 await 这一行打断点,你可以清楚地看到之前的变量状态,这在 .then 链条里是很难做到的。

现在,你已经掌握了最主流的异步写法。

接下来,我们要进入第四部分:TypeScript 里的异步类型。这部分会教你如何在真实的工作中(比如调用后端 API)定义那些复杂的数据接口。这一步是 TS 开发者的日常。


第四部分:TypeScript 里的异步类型 (TS Specifics)

前面的内容其实大都也是 JavaScript 的知识(除了简单的类型标注)。到了这里,我们要讲只有 TypeScript 才能提供的强大功能:如何在异步操作中获得完美的类型提示和安全保障。

这一部分对于前端开发(尤其是对接后端 API)至关重要。


1. Promise 的泛型:Promise<T>

我们在前面的例子里稍微提到了这个。Promise 是一个泛型类。 这就好比 Array<number> 表示“装数字的数组”,Promise<User> 表示“承诺未来给你一个 User 对象”。

基本语法:

// 函数返回值类型
function fetchData(): Promise<TypeOfTheResult> { ... }

实战场景:定义 API 响应结构

假设后端给你这样一个 JSON 数据结构:

// 后端返回的用户数据
{
    "id": 1,
    "username": "admin",
    "isActive": true
}

步骤一:定义 Interface (接口) 我们要先告诉 TS,这个数据长什么样。

interface User {
    id: number;
    username: string;
    isActive: boolean;
    // 甚至可以有可选属性
    avatarUrl?: string; 
}

步骤二:在异步函数中使用

// 这里的返回值类型 Promise<User> 非常重要!
async function fetchCurrentUser(): Promise<User> {
    const response = await fetch('/api/user');
    // 解析 JSON
    const data = await response.json();
    
    // 这里其实有一个类型断言的过程,告诉 TS 这个 data 就是 User
    // 在实际项目中,通常 fetch 封装库(如 axios)会帮我们做泛型传递
    return data as User; 
}

步骤三:享受类型提示

当你调用这个函数时,神奇的事情发生了:

async function main() {
    const user = await fetchCurrentUser();
    
    // 当你敲下 user. 的时候,VS Code 会自动弹窗提示:
    // - id
    // - username
    // - isActive
    // - avatarUrl
    console.log(user.username); 
    
    // 如果你拼写错误,立刻报错!
    console.log(user.usrname); // ❌ 报错:User 类型上不存在 usrname
}

2. 实战技巧:配合 Axios (最常用的请求库)

在真实工作中,我们通常使用 axios 库来发请求。axios 的类型定义非常完善,支持传入泛型。

import axios from 'axios';

// 1. 定义接口
interface Article {
    title: string;
    content: string;
    views: number;
}

// 2. 发送请求
async function getArticle(id: number) {
    // axios.get 是个泛型函数:axios.get<T>(url)
    // 我们传入 <Article>,告诉 axios 返回的数据体 data 是 Article 类型
    const response = await axios.get<Article>(`/api/articles/${id}`);
    
    // response.data 现在的类型就是 Article
    return response.data;
}

// 3. 调用
async function showArticle() {
    const article = await getArticle(101);
    // 此时 article 就是 Article 类型
    console.log(article.title); // ✅ 安全
}

3. 处理“可能是多种类型”的情况

有时候异步操作可能会返回不同的结果,或者可能失败。

场景: 搜索用户,可能找到,也可能没找到(null)。

interface UserInfo {
    name: string;
    age: number;
}

// 返回值类型是 UserInfo 或者 null
async function findUser(name: string): Promise<UserInfo | null> {
    if (name === 'Ghost') {
        return null;
    }
    return { name: 'RealUser', age: 18 };
}

async function check() {
    const user = await findUser('Ghost');
    
    // 这里 user 可能是 null,TS 会强迫你做检查
    // console.log(user.name); // ❌ 报错:user 可能为 null

    if (user) {
        console.log(user.name); // ✅ 现在安全了
    }
}

总结第四部分

  1. 核心思维:写异步函数时,第一件事不是写逻辑,而是先想好返回值类型Promise<T>)。
  2. 接口先行:把后端返回的 JSON 数据结构定义为 interface
  3. 工具库配合:使用 Axios 等支持泛型的库,把 interface 传进去,这样从请求结果里拿到的数据就会自带类型提示。

这一步做好了,你的代码健壮性会提升一个档次,再也不用担心拼错字段名或者不知道后端返回了啥。

准备好进入最后一部分了吗?我们将讨论实战中的错误处理并行技巧(比如怎么让两个请求同时发,而不是一个等一个)。


第五部分:实战与错误处理 (Best Practices)

好,我们进入最后一部分:实战与错误处理 (Best Practices)

这部分是区分“新手”和“熟练工”的分水岭。新手写的异步代码往往在网络正常时能跑,一旦网络抖动或者需要优化性能时就崩了。


1. 优雅的错误处理 (try...catch)

在 Async/Await 模式下,我们使用传统的 try...catch 来捕获异步错误。

基本套路:

async function safeGetData() {
    try {
        // 可能会炸的代码放在 try 里
        const data = await fetchData();
        console.log("成功:", data);
    } catch (error) {
        // 1. 网络断了
        // 2. 服务器 500 了
        // 3. JSON 解析失败了
        // 所有错误都会汇聚到这里
        console.error("出大问题了:", error);
        
        // TS 小坑:catch(error) 这里的 error 默认类型是 unknown 或 any
        // 如果要访问 error.message,最好断言一下
        if (error instanceof Error) {
            console.log("错误信息:", error.message);
        }
    } finally {
        // (可选) 无论成功失败都会执行,适合关闭 loading 动画
        console.log("关闭 Loading 转圈圈");
    }
}

为什么这很重要? 如果不写 try...catch,一旦 await 的 Promise 失败(Rejected),整个函数会抛出异常。如果上层也没人捕获,你的程序可能会崩溃(在 Node.js 中可能会导致进程退出,在前端会导致控制台报红且后续逻辑中断)。


2. 并行处理 (Promise.all) —— 性能优化神器

这是面试和实战中极高频的考点。

场景: 你需要在一个页面同时展示“用户信息”和“最近订单”。这俩接口互不相关。

新手写法 (串行 - 慢): 就像排队,先买奶茶,买完再排队买炸鸡。

async function loadPageSerial() {
    console.time("串行耗时");
    
    const user = await getUser();       // 假设耗时 1s
    const orders = await getOrders();   // 假设耗时 1s
    
    // 总耗时:1s + 1s = 2s
    console.timeEnd("串行耗时");
}

高手写法 (并行 - 快): 我和朋友分头行动,我买奶茶,他买炸鸡,最后一起吃。

async function loadPageParallel() {
    console.time("并行耗时");
    
    // 技巧:Promise.all 接收一个 Promise 数组
    // 它会同时启动数组里的所有任务
    const [user, orders] = await Promise.all([
        getUser(),      // 任务 A
        getOrders()     // 任务 B
    ]);
    
    // 总耗时:max(1s, 1s) = 1s
    // 只有当两个都完成了,await 才会继续往下走
    console.timeEnd("并行耗时");
    
    console.log(user, orders);
}

Promise.all 的特点:

  1. 全成则成:只有数组里所有 Promise 都成功了,它才成功。
  2. 一败则败:只要有一个失败了,整个 Promise.all 直接抛出错误(进入 catch),其他的成功了也没用。

进阶:Promise.allSettled (ES2020) 如果你不希望“一败则败”(比如用户信息挂了,但我还是想展示订单),可以使用 Promise.allSettled。它会等待所有任务结束,不管成功还是失败,并返回每个任务的状态。


3. 一个常见的循环陷阱

需求: 有一个用户 ID 列表 [1, 2, 3],要依次获取他们的详细信息。

错误写法 (forEach):

async function wrongLoop() {
    const ids = [1, 2, 3];
    
    // ❌ 这种写法 await 不生效!forEach 不支持 async 回调
    ids.forEach(async (id) => {
        const user = await getUser(id);
        console.log(user);
    });
    
    console.log("结束了?"); 
    // 实际结果:先打印 "结束了?",然后那 3 个请求才在后台慢慢跑。
}

正确写法 1 (for...of) —— 串行(一个接一个):

async function serialLoop() {
    const ids = [1, 2, 3];
    
    for (const id of ids) {
        // ✅ 能够正确暂停,拿完 id:1 再拿 id:2
        const user = await getUser(id);
        console.log(user);
    }
    console.log("真·结束了");
}

正确写法 2 (map + Promise.all) —— 并行(同时跑):

async function parallelLoop() {
    const ids = [1, 2, 3];
    
    // 1. 先把 ID 数组映射成 Promise 数组
    const promises = ids.map(id => getUser(id));
    
    // 2. 再用 Promise.all 等待它们全部完成
    const users = await Promise.all(promises);
    
    console.log("所有用户都拿到:", users);
}

全文大总结

恭喜你,你已经走完了 TypeScript 异步编程的完整路径!

  1. 核心概念:JS 是单线程的,为了不阻塞,必须用异步。
  2. 演进史:Callback (回调地狱) -> Promise (链式) -> Async/Await (最终形态)
  3. TS 类型:使用 Promise<T> 和接口 (Interface) 来约束异步数据的形状,获得极致的代码提示。
  4. 实战技巧
    • try...catch 兜底错误。
    • Promise.all 做并发优化。
    • 千万别在 forEach 里用 await

\

Dialog组件状态建模规则

本文所说的组件状态建模规则,特别适用于:Dialog 生命周期长、渲染早于数据的组件

核心设计目标

UI 状态建模(template)的第一目标不是语义最精确,而是结构稳定、可渲染、可推导

简单说,template绑定的变量初始值不能为undefined或者null,最好是预定义的空模板。

二、基础概念划分(这是地基)

区分三种“状态层级”

层级 例子 规则
UI 结构状态 表单字段、列表项、dialog 内容 必须结构稳定
UI 行为状态 visible / loading / disabled 可 boolean / enum
业务数据状态 接口返回对象 可 null / undefined

template建模只会和UI结构状态和行为状态有关,和业务数据状态无关。

三、最重要的规则(90% 的坑在这里)

规则 1:**template 绑定的数据,禁止null,推荐属性确定的空数据结构

不推荐

const element = ref(null)
{{ element.id }}

推荐

const element = ref({
  id: '',
  name: '',
})

理由不是“防报错”这么简单,而是:

render / computed / watch(immediate)
会在“业务数据尚未准备好”之前运行

规则 2:null 表示“概念不存在”,而 UI 中很少真的“不存在”

状态 推荐建模
UI状态尚未准备好 空的属性确定的数据结构
业务对象不存在 null
接口失败 error state

四、关于 computed / watch 的建模规则

规则 3:template绑定的computed = 一开始就要有稳定的数据结构

computed从undefined或者null变化为{id:'xxx'},这就称作不稳定

// 不稳定
const id = computed(() => props.element.id)

稳定方案一(首选)

props.element = { id: '' }

稳定方案二(兜底)

const id = computed(() => props.element?.id ?? '')

方案二是 防御,不是建模优雅

规则 4:watch(immediate) 必须当作“setup 同步代码”对待

watch(
  () => props.element,
  (el) => {
    // 这里 ≈ setup 中直接访问
  },
  { immediate: true }
)

所以规则是:

凡是会被 watch(immediate) 读取的数据
都必须在 setup 结束前是安全的

安全的意思是watch的回调函数中需要用guard子句排除到props.element是undefined或者null这种情况。不然会报错。

规则 5:composable 永远假设“调用方是不可靠的”

useSomething(element)

composable 内部必须:

  • guard 参数
  • 不假设结构存在
  • 不信任生命周期顺序
if (!element || !element.id) return

这是 composable 的防御职责 如果你在组件内部写满 if (!xxx) return
那说明状态模型有问题

规则 6:弹框类组件 = 提前存在,延后可见

visible = false // 控制显示
element = {id:"", ...}    // 内容占位

不要用 visible = false 的同时element=ref(null)

这里又一次说明null和空数据结构的区别:null表示不存在,空数据结构表示存在,但内容未准备好。不存在的就不能正常渲染,空的数据结构是可以正常渲染的。

相关知识

vue组件首次渲染执行任务顺序 vue列表渲染设计决策

小程序跳转H5页面实现指定页面回跳小程序 - Uniapp项目解决方案

小程序跳转H5页面实现指定页面回跳小程序 - Uniapp项目解决方案

⚡ 快速开始

如果你只想快速实现功能,只需三步:

  1. 确保已安装依赖npm install weixin-js-sdk
  2. 确保 main.js 已引入Vue.prototype.wxdk = wxdk(项目已配置)
  3. 在页面中引入 mixin
import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

export default {
  mixins: [miniProgramBackMixin],
  // ... 其他代码
}

就这么简单!mixin 会自动处理所有逻辑。


📋 问题背景

在微信小程序开发中,我们经常会遇到这样的场景:小程序通过 web-view 组件跳转到 H5 页面,用户完成 H5 操作后,希望点击浏览器返回键能够返回到小程序。然而,默认情况下,H5 页面的返回操作只会触发浏览器历史记录的回退,无法返回到小程序,这与我们的期望效果不符。

根据微信官方文档,我们可以使用 wx.miniProgram.navigateBack() 接口来实现从 H5 页面返回到小程序的功能。

🎯 解决方案

通过监听 H5 页面的 popstate 事件(浏览器返回操作),在检测到返回行为时,调用微信小程序的 navigateBack 接口,实现返回到小程序的效果。

核心思路

  1. 环境检测:使用 wx.miniProgram.getEnv() 检测当前是否在小程序 WebView 环境中
  2. 历史记录注入:在页面加载时注入一个历史记录,用于拦截返回操作
  3. 返回拦截:监听 popstate 事件,当触发返回时调用小程序返回接口
  4. 层级管理:记录页面入口层级,避免子页面返回误触发小程序返回

📦 实现步骤

步骤一:安装依赖

npm install weixin-js-sdk

步骤二:在 main.js 中引入并挂载

// main.js
import wx from 'weixin-js-sdk'

// Vue 2.x
Vue.prototype.wx = wx

// Vue 3.x (Composition API)
app.config.globalProperties.wx = wx

步骤三:在 H5 页面中实现返回拦截

在需要实现返回小程序功能的 Vue 页面中添加以下代码:

<template>
  <!-- 你的页面内容 -->
</template>

<script>
export default {
  name: 'YourPage',
  data() {
    return {
      // 小程序返回拦截相关状态
      miniProgramBackHandler: null,      // popstate 事件处理器
      miniProgramBackHooked: false,      // 是否已注册拦截
      miniProgramBackDepth: 0            // 记录进入页面时的 history depth
    }
  },
  onLoad() {
    // #ifdef H5
    // 页面加载时初始化拦截,避免首次返回未被接管
    this.setupMiniProgramWebviewBack()
    // #endif
  },
  onUnload() {
    // 页面卸载时清理事件监听器
    if (this.miniProgramBackHandler && typeof window !== 'undefined') {
      window.removeEventListener('popstate', this.miniProgramBackHandler)
      this.miniProgramBackHandler = null
      this.miniProgramBackHooked = false
    }
  },
  methods: {
    /**
     * 小程序webview场景:拦截H5返回行为,返回小程序
     * @description 通过监听 popstate 事件,在用户点击返回时调用小程序返回接口
     */
    setupMiniProgramWebviewBack() {
      // 防止重复注册
      if (this.miniProgramBackHooked || typeof window === 'undefined') {
        return
      }

      // 获取微信小程序桥接对象
      const bridge = this.wx?.miniProgram
      if (!bridge?.getEnv) {
        console.warn('[小程序返回拦截] wxdk.miniProgram.getEnv 不存在,无法拦截返回')
        return
      }

      // 检测当前环境
      bridge.getEnv((res = {}) => {
        console.log('[小程序返回拦截] getEnv 返回:', res)
      
        // 非小程序 WebView 环境,跳过拦截
        if (!res.miniprogram) {
          console.warn('[小程序返回拦截] 当前非小程序 WebView,跳过拦截')
          return
        }

        // 注入历史记录,用于拦截返回操作
        window.history.pushState({ miniProgramBack: true }, '')
      
        // 记录当前层级,只有回退到该层级及以下才触发返回小程序
        this.miniProgramBackDepth = window.history.length
        console.log('[小程序返回拦截] 已注入拦截,入口层级:', this.miniProgramBackDepth)

        // 创建返回拦截处理器
        this.miniProgramBackHandler = () => {
          // 获取当前历史栈深度
          const currentDepth = window.history.length
        
          // 若当前历史栈仍高于入口层级,说明只是从子页面返回,不拦截
          if (currentDepth > this.miniProgramBackDepth) {
            console.log('[小程序返回拦截] 子页回退,不拦截', { 
              currentDepth, 
              base: this.miniProgramBackDepth 
            })
            return
          }

          // 定义降级方案
          const fallback = () => {
            if (this.wx?.closeWindow) {
              console.log('[小程序返回拦截] 调用 closeWindow 作为回退')
              this.wx.closeWindow()
            } else {
              console.log('[小程序返回拦截] 使用 history.go(-1) 作为回退')
              window.history.go(-1)
            }
          }

          // 尝试调用小程序返回接口
          try {
            bridge.navigateBack({
              delta: 1,  // 返回的页面数,默认为1
              success: () => {
                console.log('[小程序返回拦截] navigateBack 成功触发返回小程序')
              },
              fail: (err) => {
                console.warn('[小程序返回拦截] navigateBack 失败,尝试 fallback', err)
                fallback()
              }
            })
          } catch (err) {
            console.error('[小程序返回拦截] navigateBack 异常,fallback', err)
            fallback()
          }
        }

        // 注册 popstate 事件监听器
        window.addEventListener('popstate', this.miniProgramBackHandler)
        this.miniProgramBackHooked = true
        console.log('[小程序返回拦截] popstate 监听已注册')
      })
    }
  }
}
</script>

🔍 代码说明

关键点解析

  1. 环境检测:使用 wx.miniProgram.getEnv() 检测是否在小程序 WebView 中,避免在普通浏览器中执行无效操作。
  2. 历史记录注入:通过 window.history.pushState() 注入一个历史记录,这样当用户点击返回时,会先触发 popstate 事件,而不是直接返回。
  3. 层级管理:记录页面加载时的历史栈深度(miniProgramBackDepth),当用户从子页面返回时,历史栈深度会大于入口层级,此时不触发小程序返回,避免误操作。
  4. 降级方案:如果 navigateBack 调用失败,提供 closeWindowhistory.go(-1) 作为降级方案,确保用户体验。
  5. 内存清理:在 onUnload 生命周期中移除事件监听器,防止内存泄漏。

⚠️ 注意事项

1. 条件编译

使用 #ifdef H5#endif 确保代码只在 H5 平台编译,避免在小程序端执行。

2. 页面跳转

如果页面内部有路由跳转(如使用 router.push),需要注意:

  • 子页面跳转会增加历史栈深度
  • 从子页面返回时不会触发小程序返回(通过层级判断)
  • 只有在入口页面点击返回才会触发小程序返回

3. 兼容性

  • 确保 weixin-js-sdk 版本 >= 1.6.0
  • 微信小程序基础库版本 >= 1.6.0(支持 navigateBack 接口)

4. 调试建议

  • 在开发环境中添加详细的 console 日志,便于排查问题
  • 使用微信开发者工具的真机调试功能测试
  • 注意区分小程序 WebView 和普通浏览器环境

🚀 优化建议

1. 封装为 Mixin(推荐)

如果多个页面都需要此功能,可以封装为 mixin。项目已提供 src/common/mixins/miniProgramBack.js

export default {
  data() {
    return {
      // 小程序返回拦截相关状态
      miniProgramBackHandler: null,      // popstate 事件处理器
      miniProgramBackHooked: false,      // 是否已注册拦截
      miniProgramBackDepth: 0             // 记录进入页面时的 history depth,防止子页返回误触发
    }
  },
  onLoad() {
    // #ifdef H5
    // 直接在页面加载时初始化拦截,避免首次返回未被接管
    this.setupMiniProgramWebviewBack()
    // #endif
  },
  onUnload() {
    // 页面卸载时清理事件监听器,防止内存泄漏
    if (this.miniProgramBackHandler && typeof window !== 'undefined') {
      window.removeEventListener('popstate', this.miniProgramBackHandler)
      this.miniProgramBackHandler = null
      this.miniProgramBackHooked = false
    }
  },
  methods: {
    /**
     * 小程序webview场景:拦截H5返回行为,返回小程序
     * @description 通过监听 popstate 事件,在用户点击返回时调用小程序返回接口
     * @param {Object} options - 配置选项
     * @param {Boolean} options.enable - 是否启用拦截,默认 true
     * @param {Number} options.delta - 返回的页面数,默认 1
     * @param {Function} options.onBeforeBack - 返回前的回调函数
     * @param {Function} options.onBackSuccess - 返回成功的回调函数
     * @param {Function} options.onBackFail - 返回失败的回调函数
     */
    setupMiniProgramWebviewBack(options = {}) {
      const {
        enable = true,
        delta = 1,
        onBeforeBack = null,
        onBackSuccess = null,
        onBackFail = null
      } = options

      // 如果已注册或禁用,直接返回
      if (!enable || this.miniProgramBackHooked || typeof window === 'undefined') {
        return
      }

      // 获取微信小程序桥接对象
      const bridge = this.wx?.miniProgram
      if (!bridge?.getEnv) {
        console.warn('[小程序返回拦截] wxdk.miniProgram.getEnv 不存在,无法拦截返回')
        return
      }

      // 检测当前环境
      bridge.getEnv((res = {}) => {
        console.log('[小程序返回拦截] getEnv 返回:', res)
        
        // 非小程序 WebView 环境,跳过拦截
        if (!res.miniprogram) {
          console.warn('[小程序返回拦截] 当前非小程序 WebView,跳过拦截')
          return
        }

        // 注入历史记录,用于拦截返回操作
        window.history.pushState({ miniProgramBack: true }, '')
        
        // 记录当前层级,只有回退到该层级及以下才触发返回小程序
        this.miniProgramBackDepth = window.history.length
        console.log('[小程序返回拦截] 已注入拦截,入口层级:', this.miniProgramBackDepth)

        // 定义降级方案
        const fallback = () => {
          if (this.wx?.closeWindow) {
            console.log('[小程序返回拦截] 调用 closeWindow 作为回退')
            this.wx.closeWindow()
          } else {
            console.log('[小程序返回拦截] 使用 history.go(-1) 作为回退')
            window.history.go(-1)
          }
        }

        // 创建返回拦截处理器
        this.miniProgramBackHandler = () => {
          // 获取当前历史栈深度
          const currentDepth = window.history.length
          
          // 若当前历史栈仍高于入口层级,说明只是从子页面返回,不拦截
          if (currentDepth > this.miniProgramBackDepth) {
            console.log('[小程序返回拦截] 子页回退,不拦截', { 
              currentDepth, 
              base: this.miniProgramBackDepth 
            })
            return
          }

          // 执行返回前的回调
          if (onBeforeBack && typeof onBeforeBack === 'function') {
            const shouldContinue = onBeforeBack()
            if (shouldContinue === false) {
              console.log('[小程序返回拦截] onBeforeBack 返回 false,取消返回')
              return
            }
          }

          // 尝试调用小程序返回接口
          try {
            bridge.navigateBack({
              delta,
              success: () => {
                console.log('[小程序返回拦截] navigateBack 成功触发返回小程序')
                if (onBackSuccess && typeof onBackSuccess === 'function') {
                  onBackSuccess()
                }
              },
              fail: (err) => {
                console.warn('[小程序返回拦截] navigateBack 失败,尝试 fallback', err)
                if (onBackFail && typeof onBackFail === 'function') {
                  onBackFail(err)
                }
                fallback()
              }
            })
          } catch (err) {
            console.error('[小程序返回拦截] navigateBack 异常,fallback', err)
            if (onBackFail && typeof onBackFail === 'function') {
              onBackFail(err)
            }
            fallback()
          }
        }

        // 注册 popstate 事件监听器
        window.addEventListener('popstate', this.miniProgramBackHandler)
        this.miniProgramBackHooked = true
        console.log('[小程序返回拦截] popstate 监听已注册')
      })
    }
  }
}

可直接使用:

使用方式:

import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

export default {
  mixins: [miniProgramBackMixin],
  // ... 其他代码
}

自定义配置:

export default {
  mixins: [miniProgramBackMixin],
  onLoad() {
    // 自定义配置
    this.setupMiniProgramWebviewBack({
      enable: true,              // 是否启用
      delta: 1,                  // 返回的页面数
      onBeforeBack: () => {
        // 返回前的回调,返回 false 可取消返回
        console.log('即将返回小程序')
        // return false  // 取消返回
      },
      onBackSuccess: () => {
        console.log('成功返回小程序')
      },
      onBackFail: (err) => {
        console.error('返回失败', err)
      }
    })
  }
}

2. 在现有页面中应用

如果页面已经实现了相关逻辑,可以替换为使用 mixin:

替换前:

// 页面中已有相关代码
data() {
  return {
    miniProgramBackHandler: null,
    miniProgramBackHooked: false,
    miniProgramBackDepth: 0
  }
},
onLoad() {
  this.setupMiniProgramWebviewBack()
},
onUnload() {
  // 清理代码...
},
methods: {
  setupMiniProgramWebviewBack() {
    // 实现代码...
  }
}

替换后:

import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

export default {
  mixins: [miniProgramBackMixin],
  // 移除 data 中的相关字段
  // 移除 onLoad 中的调用(mixin 会自动调用)
  // 移除 onUnload 中的清理(mixin 会自动清理)
  // 移除 methods 中的 setupMiniProgramWebviewBack 方法
}

3. 全局注册 Mixin(可选)

如果希望所有 H5 页面都自动启用此功能,可以在 main.js 中全局注册:

// main.js
import miniProgramBackMixin from '@/common/mixins/miniProgramBack'

// Vue 2.x
Vue.mixin(miniProgramBackMixin)

// 注意:全局注册后,如果某个页面不需要此功能,可以在 onLoad 中禁用:
// this.setupMiniProgramWebviewBack({ enable: false })

4. 错误处理优化(高级用法)

如果需要更完善的错误处理和重试机制,可以扩展 mixin:

// 在页面中扩展方法
export default {
  mixins: [miniProgramBackMixin],
  methods: {
    setupMiniProgramWebviewBackWithRetry() {
      const MAX_RETRY = 3
      let retryCount = 0
    
      const tryNavigateBack = () => {
        if (retryCount >= MAX_RETRY) {
          // 使用 mixin 的降级方案
          return
        }
      
        const bridge = this.wx?.miniProgram
        bridge.navigateBack({
          delta: 1,
          success: () => {
            retryCount = 0
            console.log('[小程序返回拦截] navigateBack 成功')
          },
          fail: (err) => {
            retryCount++
            console.warn(`[小程序返回拦截] navigateBack 失败,重试 ${retryCount}/${MAX_RETRY}`, err)
            setTimeout(tryNavigateBack, 100)
          }
        })
      }
    
      // 调用 mixin 的方法,但使用自定义的返回逻辑
      this.setupMiniProgramWebviewBack({
        onBeforeBack: () => {
          tryNavigateBack()
          return false  // 阻止默认行为
        }
      })
    }
  }
}

📚 相关文档

🔗 项目文件

  • Mixin 文件src/common/mixins/miniProgramBack.js

✅ 总结

通过以上方案,我们可以实现小程序跳转 H5 页面后,用户点击返回键能够返回到小程序的功能。关键点在于:

  1. ✅ 正确检测小程序 WebView 环境
  2. ✅ 合理使用历史记录 API 拦截返回操作
  3. ✅ 通过层级管理避免误触发
  4. ✅ 提供降级方案保证兼容性
  5. ✅ 及时清理事件监听器防止内存泄漏

希望本文能帮助你在 Uniapp 项目中实现小程序与 H5 页面的无缝跳转体验!

shell 短信接口开发对接技巧:Shell 环境下短信发送功能集成详解

在 Shell 运维自动化、批量脚本执行场景中,短信通知是告警、状态反馈的核心手段,但多数开发者在对接 shell 短信接口时,常因参数拼接格式错误、HTTP 请求语法不规范、异常处理缺失导致短信发送功能失效,甚至因频率控制不当触发接口限流。本文聚焦 shell 短信接口的开发对接技巧,从底层 HTTP 通信逻辑拆解到完整对接实现,再到优化与排错,解决参数配置、请求方式选择、异常处理等核心痛点,帮助你在 Shell 环境下快速集成稳定、高效的短信发送功能。

一、理解 Shell 对接 shell 短信接口的核心逻辑

1.1 短信接口的 HTTP 通信本质

shell 短信接口的核心是通过 Shell 自带的 curl 工具发起 HTTP 请求,主流服务商(如互亿无线)的 shell 短信接口均支持 POST/GET 双请求方式,字符编码固定为 utf-8。完整的对接流程包含三个核心环节:

  1. 参数构造:按服务商规范拼接 account(APIID)、password(APIKEY)、mobile(手机号)、content(短信内容)等核心参数;
  2. 请求发送:通过 curl 设置请求头(Content-Type: application/x-www-form-urlencoded)和请求方式;
  3. 响应解析:提取返回的 code 和 msg 字段,判断发送结果,核心成功标识为 code=2。

1.2 核心参数与响应码解读

1.2.1 必选参数规范

shell 短信接口对接的核心参数直接决定请求成败,需重点关注:

  • account:APIID,需从服务商后台获取;
  • password:APIKEY 或动态密码(动态密码需搭配 Unix 时间戳);
  • mobile:接收手机号,格式为 11 位数字(如 138****9999),需提前校验;
  • content/templateid:短信内容,支持完整内容(无模板时)或模板变量(需指定 templateid)两种方式。

1.2.2 关键响应码解析

响应中的 code 字段是结果判断的核心,高频码解读:

  • code=2:提交成功,返回 smsid(流水号);
  • code=405:API ID/KEY 错误(最常见);
  • code=4052:访问 IP 与备案 IP 不符;
  • code=4085:同一手机号验证码发送超限(10 条 / 天)。

二、shell 短信接口基础对接实现

2.1 环境准备与工具校验

Shell 对接 shell 短信接口仅依赖系统自带的 curl 工具(Linux/macOS 默认安装),无需额外依赖,先验证 curl 可用性:

bash

# 验证curl是否安装并查看版本
curl --version

若提示 “command not found”,需通过对应系统包管理器安装:

bash

# CentOS/RHEL系统安装curl
yum install curl -y
# Ubuntu/Debian系统安装curl
apt-get install curl -y

2.2 GET 请求对接示例(调试场景)

GET 请求参数直接拼接在 URL 中,适合开发 / 测试阶段快速验证接口连通性,以下是完整可复用示例:

bash

#!/bin/bash
# shell短信接口对接示例(GET请求,调试专用)
# 注:account和password需从服务商注册获取,注册入口:http://user.ihuyi.com/?udcpF6

# 接口基础配置
API_URL="https://api.ihuyi.com/sms/Submit.json"
ACCOUNT="your_api_id"  # 替换为实际APIID
PASSWORD="your_api_key"  # 替换为实际APIKEY
MOBILE="139****8888"     # 接收手机号(脱敏格式)
CONTENT="您的验证码是:6789。请不要把验证码泄露给其他人。"  # 完整短信内容

# 对短信内容进行URL编码,避免特殊字符导致请求失败
ENCODED_CONTENT=$(curl -s -o /dev/null -w %{url_effective} --get --data-urlencode "content=${CONTENT}" "" | cut -d '?' -f2 | cut -d '=' -f2)

# 发起GET请求
RESPONSE=$(curl -s "${API_URL}?account=${ACCOUNT}&password=${PASSWORD}&mobile=${MOBILE}&content=${ENCODED_CONTENT}")

# 解析响应结果
CODE=$(echo ${RESPONSE} | grep -o '"code":[0-9]*' | cut -d ':' -f2)
MSG=$(echo ${RESPONSE} | grep -o '"msg":"[^"]*"' | cut -d ':' -f2 | sed 's/["]//g')

# 输出结果判断
if [ ${CODE} -eq 2 ]; then
    echo "短信发送成功,响应详情:${RESPONSE}"
else
    echo "短信发送失败,错误码:${CODE},错误信息:${MSG}"
fi

demo-shell.png

2.3 POST 请求对接示例(生产场景)

POST 请求参数放在请求体中,安全性更高,适合生产环境,尤其适配长短信、敏感内容场景,以下是模板变量方式示例:

bash

#!/bin/bash
# shell短信接口对接示例(POST请求,生产环境专用)
# 适配场景:运维告警、批量订单通知

# 核心配置项
API_URL="https://api.ihuyi.com/sms/Submit.json"
ACCOUNT="your_api_id"
PASSWORD="your_api_key"
MOBILE="137****6666"
VERIFY_CODE="8899"  # 模板变量内容(匹配templateid=1)
TEMPLATE_ID="1"     # 系统默认验证码模板ID
TIME=$(date +%s)    # 获取Unix时间戳(动态密码方式必填)

# 构造POST请求参数
POST_DATA="account=${ACCOUNT}&password=${PASSWORD}&mobile=${MOBILE}&content=${VERIFY_CODE}&templateid=${TEMPLATE_ID}&time=${TIME}"

# 发起POST请求(设置10秒超时,避免脚本阻塞)
RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "${POST_DATA}" --max-time 10 "${API_URL}")

# 兼容解析(适配jq未安装的服务器环境)
CODE=$(echo ${RESPONSE} | jq -r .code 2>/dev/null || echo "0")
if [ "${CODE}" = "0" ] || [ "${CODE}" = "null" ]; then
    CODE=$(echo ${RESPONSE} | grep -o '"code":[0-9]*' | cut -d ':' -f2)
fi
MSG=$(echo ${RESPONSE} | grep -o '"msg":"[^"]*"' | cut -d ':' -f2 | sed 's/["]//g')

# 异常处理与结果输出
if [ $? -ne 0 ]; then
    echo "接口请求异常:curl命令执行失败"
    exit 1
fi

if [ ${CODE} -eq 2 ]; then
    SMSID=$(echo ${RESPONSE} | grep -o '"smsid":"[^"]*"' | cut -d ':' -f2 | sed 's/["]//g')
    echo "短信发送成功,流水号:${SMSID}"
else
    echo "发送失败:错误码=${CODE},错误信息=${MSG}"
    exit 1
fi

三、shell 短信接口对接优化技巧

3.1 GET/POST 请求方式对比分析

请求方式 核心优势 主要劣势 适配场景
GET 代码简洁、调试便捷(可直接浏览器访问)、无需复杂参数处理 参数暴露在 URL,存在安全风险;内容长度受限(约 2048 字符) 开发 / 测试阶段接口连通性验证
POST 参数安全、支持 500 字长短信、符合生产规范 需处理请求体拼接,代码稍复杂 生产环境(运维告警、批量通知)

核心结论:生产环境对接 shell 短信接口时,优先选择 POST 请求方式,避免敏感参数泄露。

3.2 核心优化策略(清单形式)

  1. 参数脱敏与日志规范:避免日志泄露敏感信息,对手机号脱敏、密码隐藏:

bash

# 手机号脱敏函数(通用复用)
desensitize_mobile() {
    local mobile=$1
    echo ${mobile} | sed 's/(^\d{3})\d{4}(\d{4})/\1****\2/'
}

# 结构化日志记录(仅保留脱敏信息)
SAFE_MOBILE=$(desensitize_mobile ${MOBILE})
LOG_TIME=$(date +'%Y-%m-%d %H:%M:%S')
echo "${LOG_TIME} | 手机号:${SAFE_MOBILE} | 发送结果码:${CODE}" >> /var/log/sms_send.log

2. 自动重试机制:应对网络波动导致的请求失败,设置 3 次以内重试:

bash

# 带重试的短信发送函数
send_sms_with_retry() {
    local retry_times=3
    local count=0
    while [ ${count} -lt ${retry_times} ]; do
        RESPONSE=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "${POST_DATA}" --max-time 10 "${API_URL}")
        CODE=$(echo ${RESPONSE} | grep -o '"code":[0-9]*' | cut -d ':' -f2)
        if [ ${CODE} -eq 2 ]; then
            return 0  # 发送成功,退出重试
        fi
        count=$((count+1))
        sleep 1  # 间隔1秒重试,避免高频请求触发限流
    done
    return 1  # 重试失败
}

3. 频率限制控制:避免触发 4085 错误(同一手机号 10 条 / 天),提前校验发送次数:

bash

# 校验手机号当日发送频率
check_send_frequency() {
    local mobile=$1
    local log_file="/var/log/sms_send.log"
    # 统计当日该手机号发送次数
    send_count=$(grep "$(date +'%Y-%m-%d') | 手机号:$(desensitize_mobile ${mobile})" ${log_file} | wc -l)
    if [ ${send_count} -ge 10 ]; then
        echo "错误:同一手机号当日发送次数已达上限(10次),拒绝发送"
        exit 1
    fi
}

api.png

四、shell 短信接口常见问题排查与调试方法

4.1 高频错误码及解决方案

  1. code=405:API ID/KEY 不正确 → 核对服务商后台的 account(APIID)和 password(APIKEY),确认未混淆两者;
  2. code=4052:访问 IP 与备案 IP 不符 → 在服务商后台添加服务器公网 IP 到白名单,确保请求 IP 与备案一致;
  3. code=407:短信内容含敏感字符 → 提前过滤敏感词库,或使用服务商审核通过的模板(指定 templateid);
  4. code=4085:同一手机号验证码发送超限 → 集成 3.2 中的频率限制函数,避免短时间内重复发送。

4.2 高效调试技巧

  1. 开启 curl 调试模式:打印完整请求 / 响应详情,定位参数或请求头错误:

bash

# 调试POST请求(添加-v参数输出详细日志)
curl -v -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "${POST_DATA}" "${API_URL}"

2. 验证参数编码:确保 content 参数 URL 编码正确,避免中文 / 特殊字符导致请求失败:

bash

# 验证短信内容的URL编码结果
echo -n "您的验证码是:6789。请不要把验证码泄露给其他人。" | xxd -p

3. 离线校验参数格式:提前校验手机号、时间戳等参数格式,减少接口请求失败:

bash

# 手机号格式校验函数
check_mobile_format() {
    local mobile=$1
    if ! [[ ${mobile} =~ ^1[3-9][0-9]{9}$ ]]; then
        echo "错误:手机号格式不正确(正确示例:139****8888)"
        exit 1
    fi
}

总结

  1. shell 短信接口对接的核心是通过 curl 工具规范发起 HTTP 请求,生产环境优先选择 POST 方式,开发测试阶段可使用 GET 方式快速验证;
  2. 基础对接需重点关注参数 URL 编码、响应解析兼容、超时设置,优化策略需覆盖脱敏日志、自动重试、频率限制三大核心;
  3. 排查问题时优先核对 405(鉴权)、4052(IP 白名单)、4085(频率)等高频错误码,通过 curl 调试模式可快速定位根因。

本文提供的 shell 短信接口对接方案覆盖调试、生产、优化全场景,代码可直接复用,适配各类 Shell 运维场景,帮助开发者避开对接陷阱,快速落地稳定的短信发送功能。

❌