阅读视图

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

90%前端都踩过的JS内存黑洞:从《你不知道的JavaScript》解锁底层逻辑与避坑指南

在前端开发中,“内存”似乎是个“隐形选手”——平时不显山露水,一旦出问题就可能让页面越用越卡、甚至直接崩溃。多数开发者对JS内存的理解停留在“栈存基础类型,堆存引用类型”的表层,却忽略了《你不知道的JavaScript》中反复强调的:内存机制的核心不是“存哪里”,而是“如何被管理、何时被回收”

今天这篇文章,我们就从《你不知道的JavaScript》的底层视角,拆解5个最容易被忽略的JS内存关键知识点。每个点都配套真实业务场景的坑位案例、可直接复用的解决方案,帮你从“被动踩坑”变成“主动掌控”内存!

一、内存生命周期的“隐形漏洞”:你以为的“不用了”≠“被回收”

《你不知道的JavaScript》第一卷开篇就强调: “JS的自动垃圾回收不是‘万能兜底’,它只回收‘不可达’的内存” 。很多内存泄漏的根源,就是我们误以为“变量不用了就会被回收”,却忽略了内存生命周期的“主动释放”环节。

🔍 易忽略点:解除引用是回收的前提

JS内存生命周期分三步:分配→使用→回收。其中“回收”的关键是“切断变量的所有可达引用”。但实际开发中,我们常因以下操作留下“隐形引用”:

  • 全局变量未及时清理(最常见!比如未声明的变量自动挂载到window)
  • 闭包长期持有大对象的引用
  • DOM元素被移除后,JS中仍保留其引用

💣 坑位案例:全局变量的“内存寄生”

// 错误示范:无意识创建全局变量
function handleClick() {
  // 忘记声明var/let/const,data自动成为window属性
  data = new Array(1000000).fill(0); // 100万长度数组,约4MB内存
  console.log('处理点击事件');
}

// 多次点击后,window.data持续存在,内存越积越多
document.getElementById('btn').addEventListener('click', handleClick);

✅ 避坑指南:主动解除引用+限制全局变量

// 正确做法1:用let/const声明局部变量,函数执行完自动解除引用
function handleClick() {
  const data = new Array(1000000).fill(0); 
  console.log('处理点击事件');
  // 函数执行完毕,data的引用被销毁,等待GC回收
}

// 正确做法2:若必须用全局变量,使用后主动置空
let globalData = null;
function handleClick() {
  globalData = new Array(1000000).fill(0);
  // 业务逻辑处理完毕后
  globalData = null; // 切断引用,让GC可以回收
}

《你不知道的JavaScript》核心提示:全局变量的生命周期与页面一致,除非主动置空,否则会一直占用内存。开发中应尽量使用局部变量,或用IIFE封装全局逻辑,避免变量“寄生”在window上。

二、V8分代回收与数组的“快慢陷阱”:为什么你的数组越用越卡?

《你不知道的JavaScript》中提到:“JS引擎的内存优化细节,直接决定代码的运行效率”。V8作为主流引擎,对数组的内存管理有个极易被忽略的机制——快慢数组切换,一旦触发切换,内存占用和执行效率会急剧下降。

🔍 易忽略点:数组的“连续内存”幻觉

很多人以为JS数组和其他语言一样,是“连续的内存空间”,但实际V8中数组分两种:

快数组:连续内存空间,类似传统数组,访问速度快(O(1)),新建空数组默认是快数组。

慢数组:用HashTable(键值对)存储,元素分散在内存中,访问速度慢(O(n)),当数组出现“大量空洞”时触发切换。

触发快数组→慢数组的两个关键条件(V8源码逻辑):

  1. 数组新增索引与最大索引差值≥1024(比如数组长度10,直接赋值arr[1034] = 1)
  2. 新容量≥3×扩容后容量×2(内存浪费过多时)

💣 坑位案例:稀疏数组的内存爆炸

// 错误示范:创建稀疏数组,触发快→慢切换
const arr = [1, 2, 3];
// 直接赋值索引1025,制造1022个空洞
arr[1025] = 4; 
console.log(arr.length); // 1026,但中间1022个位置都是empty

// 此时arr已变成慢数组,遍历速度下降50%+,内存占用激增

✅ 避坑指南:避免稀疏数组,用正确方式增删元素

// 正确做法1:避免直接赋值大索引,用push/unshift有序添加
const arr = [1, 2, 3];
for (let i = 4; i ≤ 1025; i++) {
  arr.push(i); // 保持数组连续,维持快数组状态
}

// 正确做法2:若需存储离散数据,用对象替代稀疏数组
const data = {
  0: 1,
  1: 2,
  1025: 4
}; // 明确存储离散键值,比慢数组更高效

三、闭包的内存真相:不是闭包导致泄漏,是你用错了闭包

《你不知道的JavaScript》对闭包的定义是:“函数及其词法环境的组合”。很多开发者谈闭包色变,认为“闭包一定会导致内存泄漏”,但真相是——合理的闭包是正常的内存使用,只有“长期持有不必要的引用”才会泄漏

🔍 易忽略点:闭包的“词法环境残留”

闭包会保留外部函数的词法环境,若外部函数中的大对象被闭包引用,且闭包长期存在(比如挂载到全局),则大对象无法被回收,导致内存泄漏。

💣 坑位案例:长期存在的闭包持有大对象

// 错误示范:闭包长期持有大对象
function createDataProcessor() {
  // 大对象:模拟10MB的业务数据
  const bigBusinessData = new Array(2500000).fill({ name: 'test' });
  
  return function processData(id) {
    // 闭包引用bigBusinessData
    return bigBusinessData.find(item => item.id === id);
  };
}

// processData被挂载到全局,长期存在
window.processData = createDataProcessor();

✅ 避坑指南:用WeakMap拆分闭包引用,或及时解除闭包

// 正确做法1:用WeakMap存储大对象,避免闭包直接持有
const dataCache = new WeakMap();

function createDataProcessor() {
  const bigBusinessData = new Array(2500000).fill({ name: 'test' });
  dataCache.set('businessData', bigBusinessData);
  
  return function processData(id) {
    const data = dataCache.get('businessData');
    return data ? data.find(item => item.id === id) : null;
  };
}

// 不需要时,主动删除缓存,释放大对象
function destroyProcessor() {
  dataCache.delete('businessData');
  window.processData = null; // 解除闭包的全局引用
}

《你不知道的JavaScript》核心提示:闭包的内存管理核心是“控制引用周期”。如果闭包不需要长期存在,要及时切断其全局引用;如果必须长期存在,要避免引用大对象,或用弱引用机制(WeakMap/WeakSet)管理关联数据。

四、WeakMap/WeakSet的“弱引用魔法”:2025年最实用的内存优化工具

《你不知道的JavaScript》中提到的“弱引用”概念,在2025年的前端开发中已成为主流优化手段。很多开发者知道WeakMap,但却用错场景,甚至误以为它是“万能回收器”——这背后的核心逻辑,你可能一直没搞懂。

🔍 易忽略点:弱引用的“自动清理”本质

普通Map/Set是“强引用”:只要Map存在,其键对象即使外部已销毁,也无法被GC回收;而WeakMap/WeakSet是“弱引用”:当键对象的外部强引用消失时,GC会自动回收该对象,并清除其在WeakMap中的关联条目,无需手动清理。

关键限制(必记!):

  • WeakMap的键必须是对象,不能是字符串/数字等基础类型
  • 无法遍历(无keys()、values()、size属性),只能通过get()查询存在的键

💡 2025实战场景:DOM关联数据的内存安全管理

动态DOM增删是内存泄漏重灾区,传统Map存储DOM关联数据会导致泄漏,WeakMap是完美解决方案:

// 正确做法:用WeakMap存储DOM关联数据
const domDataMap = new WeakMap();

// 绑定数据到DOM
function bindDataToDom(dom, data) {
  domDataMap.set(dom, data);
}

// 获取DOM关联数据
function getDataFromDom(dom) {
  return domDataMap.get(dom);
}

// 移除DOM时,无需手动清理数据!
const btn = document.getElementById('btn');
bindDataToDom(btn, { clickCount: 0 });
document.body.removeChild(btn);
btn = null; // 外部强引用消失,GC自动回收btn和domDataMap中的关联数据

✅ 进阶优化:结合FinalizationRegistry监听回收事件

2025年主流浏览器已全面支持FinalizationRegistry,可监听弱引用对象的回收事件,用于释放非内存资源(如文件句柄、网络连接):

// 监听对象回收,释放非内存资源
const resourceRegistry = new FinalizationRegistry((resourceId) => {
  console.log(`资源${resourceId}已回收,关闭网络连接`);
  // 执行非内存资源清理逻辑(如关闭WebSocket)
  closeConnection(resourceId);
});

function createResource(obj, resourceId) {
  domDataMap.set(obj, resourceId);
  resourceRegistry.register(obj, resourceId); // 注册回收监听
}

// 当obj被GC回收时,会触发registry的回调
let obj = {};
createResource(obj, 'conn-123');
obj = null;

五、WebWorker的内存盲区:独立内存空间的“隐形泄漏”

2025年WebWorker在大数据处理、图形渲染等场景中应用越来越广,但很多开发者忽略了:每个Worker都有独立的内存空间,若不手动终止,会一直占用内存,即使页面跳转也不会释放

🔍 易忽略点:Worker的生命周期管理

Worker的内存特点:

  1. 初始化成本高(50-200ms),创建过多Worker会导致内存激增
  2. 与主线程通过结构化克隆传递数据,大数据传输会产生内存副本
  3. 必须显式终止(worker.terminate()),否则持续存在

💣 坑位案例:未终止的Worker导致内存泄漏

// 错误示范:频繁创建Worker且不终止
function processBigData(data) {
  const worker = new Worker('data-processor.js');
  worker.postMessage(data);
  worker.onmessage = (e) => {
    console.log('处理完成', e.data);
    // 忘记终止Worker,内存持续占用
  };
}

// 多次调用后,多个Worker实例残留,内存飙升
for (let i = 0; i < 10; i++) {
  processBigData(new Array(1000000).fill(0));
}

✅ 避坑指南:复用Worker+显式终止

// 正确做法1:复用Worker实例,避免重复创建
let dataWorker = null;

function initWorker() {
  if (!dataWorker) {
    dataWorker = new Worker('data-processor.js');
  }
  return dataWorker;
}

function processBigData(data) {
  const worker = initWorker();
  return new Promise((resolve) => {
    worker.onmessage = (e) => {
      resolve(e.data);
      // 非持续使用时,可终止Worker
      // worker.terminate();
      // dataWorker = null;
    };
    worker.postMessage(data);
  });
}

// 页面卸载时,强制终止所有Worker
window.addEventListener('beforeunload', () => {
  if (dataWorker) {
    dataWorker.terminate();
  }
});

🎯 总结:从《你不知道的JavaScript》到实战的核心心法

JS内存管理的核心,从来不是“记住栈堆区别”,而是理解《你不知道的JavaScript》反复强调的: “内存是有限资源,开发者的责任是让无用的内存‘可达性消失’”

记住这4个核心心法,从此告别内存泄漏:

  1. 全局变量“少而精”,使用后主动置空
  2. 避免稀疏数组,警惕V8快慢数组切换
  3. 闭包不背锅,控制引用周期是关键(弱引用兜底)
  4. Worker/定时器等“独立执行单元”,必须显式终止

每日一题-统计用户被提及情况🟡

给你一个整数 numberOfUsers 表示用户总数,另有一个大小为 n x 3 的数组 events 。

每个 events[i] 都属于下述两种类型之一:

  1. 消息事件(Message Event):["MESSAGE", "timestampi", "mentions_stringi"]
    • 事件表示在 timestampi 时,一组用户被消息提及。
    • mentions_stringi 字符串包含下述标识符之一:
      • id<number>:其中 <number> 是一个区间 [0,numberOfUsers - 1] 内的整数。可以用单个空格分隔 多个 id ,并且 id 可能重复。此外,这种形式可以提及离线用户。
      • ALL:提及 所有 用户。
      • HERE:提及所有 在线 用户。
  2. 离线事件(Offline Event):["OFFLINE", "timestampi", "idi"]
    • 事件表示用户 idi 在 timestampi 时变为离线状态 60 个单位时间。用户会在 timestampi + 60 时自动再次上线。

返回数组 mentions ,其中 mentions[i] 表示  id 为  i 的用户在所有 MESSAGE 事件中被提及的次数。

最初所有用户都处于在线状态,并且如果某个用户离线或者重新上线,其对应的状态变更将会在所有相同时间发生的消息事件之前进行处理和同步。

注意 在单条消息中,同一个用户可能会被提及多次。每次提及都需要被 分别 统计。

 

示例 1:

输入:numberOfUsers = 2, events = [["MESSAGE","10","id1 id0"],["OFFLINE","11","0"],["MESSAGE","71","HERE"]]

输出:[2,2]

解释:

最初,所有用户都在线。

时间戳 10 ,id1 和 id0 被提及,mentions = [1,1]

时间戳 11 ,id0 离线

时间戳 71 ,id0 再次 上线 并且 "HERE" 被提及,mentions = [2,2]

示例 2:

输入:numberOfUsers = 2, events = [["MESSAGE","10","id1 id0"],["OFFLINE","11","0"],["MESSAGE","12","ALL"]]

输出:[2,2]

解释:

最初,所有用户都在线。

时间戳 10 ,id1 和 id0 被提及,mentions = [1,1]

时间戳 11 ,id0 离线

时间戳 12 ,"ALL" 被提及。这种方式将会包括所有离线用户,所以 id0 和 id1 都被提及,mentions = [2,2]

示例 3:

输入:numberOfUsers = 2, events = [["OFFLINE","10","0"],["MESSAGE","12","HERE"]]

输出:[0,1]

解释:

最初,所有用户都在线。

时间戳 10 ,id0 离线 

时间戳 12 ,"HERE" 被提及。由于 id0 仍处于离线状态,其将不会被提及,mentions = [0,1]

 

提示:

  • 1 <= numberOfUsers <= 100
  • 1 <= events.length <= 100
  • events[i].length == 3
  • events[i][0] 的值为 MESSAGE 或 OFFLINE 。
  • 1 <= int(events[i][1]) <= 105
  • 在任意 "MESSAGE" 事件中,以 id<number> 形式提及的用户数目介于 1 和 100 之间。
  • 0 <= <number> <= numberOfUsers - 1
  • 题目保证 OFFLINE 引用的用户 id 在事件发生时处于 在线 状态。

3433. 统计用户被提及情况

解法

思路和算法

由于事件发生顺序为时间戳递增顺序,因此应将数组 $\textit{events}$ 按时间戳升序排序,然后遍历数组 $\textit{events}$,根据每个事件更新用户的相应状态。由于相同时间戳的离线和上线事件在消息事件之前处理,因此当存在多个事件的时间戳相同时,离线事件应在消息事件之前。

由于消息事件的提及用户的情况包括提及给定集合的用户、提及所有用户和提及所有在线用户,因此需要判断消息事件发生时每个用户的在线状态,可以通过维护每个用户的最新在线起始时间判断每个用户的在线状态。创建长度为 $\textit{numberOfUsers}$ 的数组 $\textit{onlineTimes}$,其中 $\textit{onlineTimes}[i]$ 表示编号 $i$ 的用户的最新在线起始时间。由于初始时所有用户都处于在线状态且所有事件的时间戳都是正整数,因此将数组 $\textit{onlineTimes}$ 中的所有元素都初始化为 $0$。

创建长度为 $\textit{numberOfUsers}$ 的结果数组 $\textit{mentions}$。遍历排序后的数组 $\textit{events}$,对于遍历到的每个事件 $[\textit{type}, \textit{timestamp}, \textit{users}]$,判断其类型,执行相应的操作。

  • 如果 $\textit{types} = \text{``MESSAGE"}$,则当前事件是消息事件,根据 $\textit{users}$ 的值决定需要提及的用户,执行提及操作。

    • 当 $\textit{users} = \text{``ALL"}$ 时,提及所有用户,将数组 $\textit{mentions}$ 中的所有元素值都增加 $1$。

    • 当 $\textit{users} = \text{``HERE"}$ 时,提及所有在线用户,遍历 $0 \le i < \textit{numberOfUsers}$ 的每个用户编号 $i$,如果 $\textit{onlineTimes}[i] \le \textit{timestamp}$,则编号 $i$ 的用户被提及,将 $\textit{mentions}[i]$ 的值增加 $1$。

    • 其余情况下,将 $\textit{users}$ 使用空格分隔转换成数组,遍历数组中的每个用户编号,将用户编号对应的数组 $\textit{mentions}$ 中的元素值增加 $1$。

  • 如果 $\textit{types} = \text{``OFFLINE"}$,则当前事件是离线事件,用 $\textit{id}$ 表示 $\textit{users}$ 对应的用户编号,则编号为 $\textit{id}$ 的用户在时间戳 $\textit{timestamp}$ 离线,并将在时间戳 $\textit{timestamp} + 60$ 自动上线,因此将 $\textit{onlineTimes}[\textit{id}]$ 的值更新为 $\textit{timestamp} + 60$。

遍历结束之后,结果数组 $\textit{mentions}$ 即为每个用户被提及的次数。

代码

###Java

class Solution {
    static final String MESSAGE = "MESSAGE", OFFLINE = "OFFLINE", ALL = "ALL", HERE = "HERE";
    static final int OFFLINE_TIME = 60;

    public int[] countMentions(int numberOfUsers, List<List<String>> events) {
        Collections.sort(events, (a, b) -> {
            int aTimestamp = Integer.parseInt(a.get(1)), bTimestamp = Integer.parseInt(b.get(1));
            if (aTimestamp != bTimestamp) {
                return aTimestamp - bTimestamp;
            } else {
                String aType = a.get(0), bType = b.get(0);
                return bType.compareTo(aType);
            }
        });
        int[] mentions = new int[numberOfUsers];
        int[] onlineTimes = new int[numberOfUsers];
        for (List<String> ev : events) {
            String type = ev.get(0);
            int timestamp = Integer.parseInt(ev.get(1));
            String users = ev.get(2);
            if (MESSAGE.equals(type)) {
                if (ALL.equals(users)) {
                    updateMentions(mentions, onlineTimes, Integer.MAX_VALUE);
                } else if (HERE.equals(users)) {
                    updateMentions(mentions, onlineTimes, timestamp);
                } else {
                    String[] ids = users.split(" ");
                    for (String idStr : ids) {
                        int id = Integer.parseInt(idStr.substring(2));
                        mentions[id]++;
                    }
                }
            } else {
                int id = Integer.parseInt(users);
                onlineTimes[id] = timestamp + OFFLINE_TIME;
            }
        }
        return mentions;
    }

    public void updateMentions(int[] mentions, int[] onlineTimes, int timestamp) {
        int numberOfUsers = mentions.length;
        for (int i = 0; i < numberOfUsers; i++) {
            if (onlineTimes[i] <= timestamp) {
                mentions[i]++;
            }
        }
    }
}

###C#

public class Solution {
    const string MESSAGE = "MESSAGE", OFFLINE = "OFFLINE", ALL = "ALL", HERE = "HERE";
    const int OFFLINE_TIME = 60;

    public int[] CountMentions(int numberOfUsers, IList<IList<string>> events) {
        ((List<IList<string>>) events).Sort((a, b) => {
            int aTimestamp = int.Parse(a[1]), bTimestamp = int.Parse(b[1]);
            if (aTimestamp != bTimestamp) {
                return aTimestamp - bTimestamp;
            } else {
                string aType = a[0], bType = b[0];
                return bType.CompareTo(aType);
            }
        });
        int[] mentions = new int[numberOfUsers];
        int[] onlineTimes = new int[numberOfUsers];
        foreach (IList<string> ev in events) {
            string type = ev[0];
            int timestamp = int.Parse(ev[1]);
            string users = ev[2];
            if (MESSAGE.Equals(type)) {
                if (ALL.Equals(users)) {
                    UpdateMentions(mentions, onlineTimes, int.MaxValue);
                } else if (HERE.Equals(users)) {
                    UpdateMentions(mentions, onlineTimes, timestamp);
                } else {
                    string[] ids = users.Split(" ");
                    foreach (string idStr in ids) {
                        int id = int.Parse(idStr.Substring(2));
                        mentions[id]++;
                    }
                }
            } else {
                int id = int.Parse(users);
                onlineTimes[id] = timestamp + OFFLINE_TIME;
            }
        }
        return mentions;
    }

    public void UpdateMentions(int[] mentions, int[] onlineTimes, int timestamp) {
        int numberOfUsers = mentions.Length;
        for (int i = 0; i < numberOfUsers; i++) {
            if (onlineTimes[i] <= timestamp) {
                mentions[i]++;
            }
        }
    }
}

复杂度分析

  • 时间复杂度:$O(n \log n \log t + \textit{numberOfUsers} + L)$,其中 $n$ 是数组 $\textit{events}$ 的长度,$\textit{numberOfUsers}$ 是用户总数,$t$ 是数组 $\textit{events}$ 中的时间戳最大值,$L$ 是数组 $\textit{events}$ 中的所有提及字符串的长度之和。将数组 $\textit{events}$ 排序的时间是 $O(n \log n \log t)$,创建数组 $\textit{mentions}$ 和 $\textit{onlineTimes}$ 的时间是 $O(\textit{numberOfUsers})$,遍历排序后的数组 $\textit{events}$ 计算提及次数的时间是 $O(n + L)$,因此时间复杂度是 $O(n \log n \log t + \textit{numberOfUsers} + L)$。

  • 空间复杂度:$O(n + \textit{numberOfUsers})$,其中 $n$ 是数组 $\textit{events}$ 的长度,$\textit{numberOfUsers}$ 是用户总数。由于待排序的元素是数组,因此排序的空间是 $O(n)$,记录每个用户的最新在线起始时间的空间是 $O(\textit{numberOfUsers})$。

按照时间戳排序 + 模拟(Python/Java/C++/C/Go/JS/Rust)

注意输入的 $\textit{events}$ 不保证是按时间顺序发生的,需要先排序。

按照时间戳 $\textit{timestamp}$ 从小到大排序,时间戳相同的,离线事件排在前面,因为题目要求「状态变更在所有相同时间发生的消息事件之前处理」。

然后模拟:

  • 离线事件:用一个数组 $\textit{onlineT}$ 记录用户下次在线的时间戳($60$ 秒后)。如果 $\textit{onlineT}[i]\le$ 当前时间戳,则表示用户 $i$ 已在线。
  • 消息事件:把相应用户的提及次数加一。

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def countMentions(self, numberOfUsers: int, events: List[List[str]]) -> List[int]:
        # 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
        events.sort(key=lambda e: (int(e[1]), e[0][2]))

        ans = [0] * numberOfUsers
        online_t = [0] * numberOfUsers
        for type_, timestamp, mention in events:
            cur_t = int(timestamp)  # 当前时间
            if type_[0] == 'O':  # 离线
                online_t[int(mention)] = cur_t + 60  # 下次在线时间
            elif mention[0] == 'A':  # @所有人
                for i in range(numberOfUsers):
                    ans[i] += 1
            elif mention[0] == 'H':  # @所有在线用户
                for i, t in enumerate(online_t):
                    if t <= cur_t:  # 在线
                        ans[i] += 1
            else:  # @id
                for s in mention.split():
                    ans[int(s[2:])] += 1
        return ans

###java

class Solution {
    public int[] countMentions(int numberOfUsers, List<List<String>> events) {
        // 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
        events.sort((a, b) -> {
            int ta = Integer.parseInt(a.get(1));
            int tb = Integer.parseInt(b.get(1));
            return ta != tb ? ta - tb : b.get(0).charAt(0) - a.get(0).charAt(0);
        });

        int[] ans = new int[numberOfUsers];
        int[] onlineT = new int[numberOfUsers];
        for (List<String> e : events) {
            int curT = Integer.parseInt(e.get(1)); // 当前时间
            String mention = e.get(2);
            if (e.get(0).charAt(0) == 'O') { // 离线
                onlineT[Integer.parseInt(mention)] = curT + 60; // 下次在线时间
            } else if (mention.charAt(0) == 'A') { // @所有人
                for (int i = 0; i < numberOfUsers; i++) {
                    ans[i]++;
                }
            } else if (mention.charAt(0) == 'H') { // @所有在线用户
                for (int i = 0; i < numberOfUsers; i++) {
                    if (onlineT[i] <= curT) { // 在线
                        ans[i]++;
                    }
                }
            } else { // @id
                for (String s : mention.split(" ")) {
                    int i = Integer.parseInt(s.substring(2));
                    ans[i]++;
                }
            }
        }
        return ans;
    }
}

###cpp

#include<ranges>
class Solution {
public:
    vector<int> countMentions(int numberOfUsers, vector<vector<string>>& events) {
        // 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
        ranges::sort(events, {}, [](auto& e) {
            return pair(stoi(e[1]), e[0][2]);
        });

        vector<int> ans(numberOfUsers);
        vector<int> online_t(numberOfUsers);
        for (auto& e : events) {
            int cur_t = stoi(e[1]); // 当前时间
            string& mention = e[2];
            if (e[0][0] == 'O') { // 离线
                online_t[stoi(mention)] = cur_t + 60; // 下次在线时间
            } else if (mention[0] == 'A') { // @所有人
                for (int& v : ans) {
                    v++;
                }
            } else if (mention[0] == 'H') { // @所有在线用户
                for (int i = 0; i < numberOfUsers; i++) {
                    if (online_t[i] <= cur_t) { // 在线
                        ans[i]++;
                    }
                }
            } else { // @id
                for (const auto& part : mention | ranges::views::split(' ')) {
                    string s(part.begin() + 2, part.end());
                    ans[stoi(s)]++;
                }
            }
        }
        return ans;
    }
};

###c

// 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
int cmp(const void* p, const void* q) {
    char** a = *(char***)p;
    char** b = *(char***)q;
    int ta = atoi(a[1]);
    int tb = atoi(b[1]);
    return ta != tb ? ta - tb : b[0][0] - a[0][0];
}

int* countMentions(int numberOfUsers, char*** events, int eventsSize, int* eventsColSize, int* returnSize) {
    qsort(events, eventsSize, sizeof(char**), cmp);

    *returnSize = numberOfUsers;
    int* ans = calloc(numberOfUsers, sizeof(int));
    int* online_t = calloc(numberOfUsers, sizeof(int));

    for (int i = 0; i < eventsSize; i++) {
        char** e = events[i];
        int cur_t = atoi(e[1]); // 当前时间
        char* mention = e[2];

        if (e[0][0] == 'O') { // 离线
            online_t[atoi(mention)] = cur_t + 60; // 下次在线时间
        } else if (mention[0] == 'A') { // @所有人
            for (int i = 0; i < numberOfUsers; i++) {
                ans[i]++;
            }
        } else if (mention[0] == 'H') { // @所有在线用户
            for (int i = 0; i < numberOfUsers; i++) {
                if (online_t[i] <= cur_t) { // 在线
                    ans[i]++;
                }
            }
        } else { // @id
            // 注:如果不想修改输入的话,可以先复制一份 mention
            for (char* tok = strtok(mention, " "); tok; tok = strtok(NULL, " ")) {
                ans[atoi(tok + 2)]++;
            }
        }
    }

    free(online_t);
    return ans;
}

###go

func countMentions(numberOfUsers int, events [][]string) []int {
// 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
slices.SortFunc(events, func(a, b []string) int {
ta, _ := strconv.Atoi(a[1])
tb, _ := strconv.Atoi(b[1])
return cmp.Or(ta-tb, int(b[0][0])-int(a[0][0]))
})

ans := make([]int, numberOfUsers)
onlineT := make([]int, numberOfUsers)
for _, e := range events {
curT, _ := strconv.Atoi(e[1]) // 当前时间
mention := e[2]
if e[0][0] == 'O' { // 离线
i, _ := strconv.Atoi(mention)
onlineT[i] = curT + 60 // 下次在线时间
} else if mention[0] == 'A' { // @所有人
for i := range ans {
ans[i]++
}
} else if mention[0] == 'H' { // @所有在线用户
for i, t := range onlineT {
if t <= curT { // 在线
ans[i]++
}
}
} else { // @id
for _, s := range strings.Split(mention, " ") {
i, _ := strconv.Atoi(s[2:])
ans[i]++
}
}
}
return ans
}

###js

var countMentions = function(numberOfUsers, events) {
    // 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
    events.sort((a, b) => parseInt(a[1]) - parseInt(b[1]) || b[0][0].charCodeAt(0) - a[0][0].charCodeAt(0));

    const ans = Array(numberOfUsers).fill(0);
    const onlineT = Array(numberOfUsers).fill(0);
    for (const [type, timestamp, mention] of events) {
        const curT = parseInt(timestamp); // 当前时间
        if (type[0] === 'O') { // 离线
            onlineT[parseInt(mention)] = curT + 60; // 下次在线时间
        } else if (mention[0] === 'A') { // @所有人
            for (let i = 0; i < numberOfUsers; i++) {
                ans[i]++;
            }
        } else if (mention[0] === 'H') { // @所有在线用户
            for (let i = 0; i < numberOfUsers; i++) {
                if (onlineT[i] <= curT) { // 在线
                    ans[i]++;
                }
            }
        } else { // @id
            for (const s of mention.split(" ")) {
                ans[parseInt(s.slice(2))] += 1;
            }
        }
    }
    return ans;
};

###rust

impl Solution {
    pub fn count_mentions(number_of_users: i32, mut events: Vec<Vec<String>>) -> Vec<i32> {
        // 按照时间戳从小到大排序,时间戳相同的,离线事件排在前面
        events.sort_unstable_by_key(|e| (e[1].parse::<i32>().unwrap(), e[0].as_bytes()[2]));

        let n = number_of_users as usize;
        let mut ans = vec![0; n];
        let mut online_t = vec![0; n];
        for e in events {
            let cur_t = e[1].parse().unwrap(); // 当前时间
            let mention = &e[2];
            if e[0].as_bytes()[0] == b'O' { // 离线
                online_t[mention.parse::<usize>().unwrap()] = cur_t + 60; // 下次在线时间
            } else if mention.as_bytes()[0] == b'A' { // @所有人
                for cnt in ans.iter_mut() {
                    *cnt += 1;
                }
            } else if mention.as_bytes()[0] == b'H' { // @所有在线用户
                for (&t, cnt) in online_t.iter().zip(ans.iter_mut()) {
                    if t <= cur_t { // 在线
                        *cnt += 1;
                    }
                }
            } else { // @id
                for s in mention.split(' ') {
                    ans[s[2..].parse::<usize>().unwrap()] += 1;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn + m\log m\log U + L)$,其中 $m$ 是 $\textit{events}$ 的长度,$n$ 是 $\textit{numberOfUsers}$,$U\le 10^5$ 是时间戳的最大值,$L$ 是所有 mentions_string 的长度之和。排序需要 $\mathcal{O}(m\log m)$ 次比较,每次比较需要 $\mathcal{O}(\log U)$ 的时间把长为 $\mathcal{O}(\log U)$ 的字符串时间戳转成整数。注:如果预处理这个转换,可以把排序的过程优化至 $\mathcal{O}(m\log m)$。
  • 空间复杂度:$\mathcal{O}(n)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

我封装了一个“瑞士军刀”级插件,并顺手搞定了自动化部署

Nuxt 3 开发提效指南:我封装了一个“瑞士军刀”级插件,并顺手搞定了自动化部署

在 Nuxt 3 的开发过程中,我们经常会遇到一些重复性的工作:封装 Fetch 请求、处理 AES/RSA 加密、配置 SEO Meta、判断设备类型等等。虽然社区有很多优秀的库,但每次新开项目都要重新把这些库集成一遍,还是略显繁琐。

于是,我开发了 nuxt-web-plugin,一个集成了网络请求、安全加密、SEO 优化、设备检测等常用功能的 Nuxt 3 插件,旨在让开发变得更简单、更高效。

GitHub 仓库github.com/hangjob/nux…
在线文档hangjob.github.io/nuxt-web-pl…

🚀 为什么需要这个插件?

在实际业务开发中,我们往往需要解决以下痛点:

  1. API 请求繁琐:原生 useFetch 虽然好用,但缺乏统一的拦截器、错误处理和 Token 自动注入。
  2. 数据安全焦虑:前后端交互敏感数据(如密码、手机号)裸奔,手动引入 crypto-jsjsencrypt 体积大且配置麻烦。
  3. SEO 配置重复:每个页面都要手写 useHead,不仅累还容易漏掉 Open Graph 标签。
  4. 设备适配麻烦:需要手动解析 User-Agent 来判断是移动端还是 PC 端,或者是否在微信环境内。

nuxt-web-plugin 就是为了解决这些问题而生的。它不是一个臃肿的 UI 库,而是一套轻量级的业务逻辑增强套件

✨ 核心功能一览

1. 优雅的网络请求 (useApiClient)

基于 Nuxt useFetch 的深度封装,支持全局拦截器、自动携带 Token、统一错误处理。

const api = useApiClient()

// GET 请求
const { data } = await api.get('/user/profile')

// POST 请求(自动处理 Content-Type)
await api.post('/auth/login', { body: { username, password } })

nuxt.config.ts 中简单配置即可生效:

export default defineNuxtConfig({
  modules: ['nuxt-web-plugin'],
  webPlugin: {
    network: {
      baseURL: 'https://api.example.com',
      timeout: 10000
    }
  }
})

2. 开箱即用的安全加密 (useCrypto, useWebUtils)

内置了 AES 对称加密、RSA 非对称加密和 Hash 哈希计算,无需额外安装依赖。

const { encrypt, decrypt } = useSymmetricCrypto() // AES
const { encrypt: rsaEncrypt } = useAsymmetricCrypto() // RSA
const { hash } = useHash() // MD5, SHA-256

// 示例:登录密码加密
const encryptedPassword = rsaEncrypt(password)
// 示例:本地存储敏感数据
const secureData = encrypt(userData)

3. 懒人版 SEO 优化 (useWebSeo)

一行代码搞定 Title、Description、Keywords 以及 Open Graph 社交分享卡片。

useWebSeo({
  title: '我的文章标题',
  description: '这是一篇关于 Nuxt 3 插件的介绍文章',
  image: '/cover.png' // 自动转换为绝对路径
})

4. 设备与环境检测 (useDevice)

在 SSR 和客户端均可准确识别设备类型。

const { isMobile, isDesktop, isWeChat, isIOS } = useDevice()

if (isMobile) {
  // 加载移动端组件
}

🛠️ 附加技能:如何使用 GitHub Actions 自动部署文档

在这个项目的开发过程中,我使用了 VitePress 编写文档,并利用 GitHub Actions 实现了自动化部署到 GitHub Pages。这里分享一下我的踩坑经验和最终方案。

1. 准备 VitePress

首先,确保你的文档项目(通常在 docs 目录)能正常 build。

docs/.vitepress/config.mts 中,最关键的一步是设置 base 路径,必须与你的 GitHub 仓库名一致:

export default defineConfig({
  // 如果仓库地址是 https://github.com/hangjob/nuxt-web-plugin
  // 那么 base 必须是 /nuxt-web-plugin/
  base: '/nuxt-web-plugin/', 
  // ...
})

2. 配置 GitHub Actions

在项目根目录创建 .github/workflows/docs.yml

遇到的坑

  1. 权限不足:GitHub Actions 默认只有只读权限,无法推送到 gh-pages 分支。需要在仓库 Settings -> Actions -> General -> Workflow permissions 中开启 Read and write permissions
  2. pnpm 找不到:Actions 环境默认没有 pnpm,需要专门安装。
  3. 锁文件问题:如果不想提交 pnpm-lock.yaml,安装依赖时不能用 --frozen-lockfile

最终可用的配置(亲测有效)

name: Deploy Docs

on:
  push:
    branches: [main] # 推送 main 分支时触发

permissions:
  contents: write # 显式赋予写权限

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # 1. 安装 pnpm
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false

      # 2. 设置 Node 环境 (推荐 LTS v20 或 v22)
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm # 如果提交了锁文件,可以用这个加速

      # 3. 安装依赖 (无锁文件模式)
      - name: Install deps
        run: pnpm install --no-frozen-lockfile

      # 4. 解决 Nuxt 特有的构建问题 (生成 .nuxt 目录)
      - name: Prepare Nuxt
        run: pnpm run dev:prepare 

      # 5. 构建文档
      - name: Build docs
        run: pnpm docs:build

      # 6. 发布到 gh-pages 分支
      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: docs/.vitepress/dist

3. 自动化

配置完成后,每次 git push,GitHub Actions 就会自动帮你构建文档并发布。你可以通过 https://<username>.github.io/<repo-name>/ 访问你的文档站点。


💡 结语

nuxt-web-plugin 目前还在持续迭代中,希望能为你的 Nuxt 开发之旅减少一些重复劳动。如果你觉得有用,欢迎来 GitHub 点个 Star ⭐️!

三季度机构持仓曝光,高毅、淡马锡加仓贝壳

近期,贝壳持续获国际及国内大型金融机构增持。披露显示,国内私募基金巨头高毅资产在今年第三季度增持贝壳约66万股美股,占其持仓约2.15%,为其美股十大重仓股之一。首域盈信在第三季度内增持贝壳约156.6万股美股;淡马锡同期也增持了93万股美股。

和媒体共赢 - 读《广告的没落,公关的崛起》

最近读完了《定位》作者艾·里斯的另一本书《广告的没落,公关的崛起》,记录一些心得。

广告的没落

当一个广告让消费者意识到是广告时,广告的效果就会大打折扣。

我记得当年脑白金就把广告做成报纸的新闻报道形式,以此来让大家误以为是报纸在宣传脑白金的功效。但现在广告的监管越来越严,这种擦边的广告越来越难通过审核。

广告追求创意,但消费者购买的是产品。

如果一个产品广告很有创意,但是产品本身很普通。另一个广告很普通,但是产品本身很好。大家还是更可能购买后者。

广告追求创意和讨论,但是真正到了决策环节,影响决策的还是产品本身的心智,而不是广告创意。

产品的创意(创新)比广告的创意更重要。

品牌是潜在顾客心智中的一个认知。

广告很难进入消费者的心智。

相比于广告,公关(具体指通过媒体等第三方途径,间接讲述你的故事)更有可信度,也更有传播性。

消费者在试图评估一个品牌的时候,更倾向从朋友、亲戚,还有权威网站上获得信息,而不是广告。

公关的崛起

因为广告很难进入消费者心智,那么就应该更多通过公关来建立品牌。在通过公关建立品牌后,可以把广告作为维护品牌的工具。

书中结合各种品牌案例,提到了一些技巧。

技巧一:为媒体传播而设计,包括提前透露消息、新的品类/品牌名称、可信度的发言人。书中的案例是 Segway 平衡车。

技巧二:成为争议话题。案例是红牛(某些成份被禁,激发年轻人尝试的好奇心)。

技巧三:创意。为品牌增加一些东西,引起讨论。

技巧四:从小媒体入手。没人比媒体更多地浏览媒体。案例是《定位》一书,该书刚开始只在一个小媒体中被报道,但后来被《华尔街日报》发现,跟进了报道。

我的一些感受

看完本书之后,我刚好刷到一位媒体记者在微博上吐槽小米的公关(如下图)。但是我却从这段话中,看到小米在努力让自己的任何商业行为都成为公关传播的话题。在公关这件事情上,小米做得是非常优秀的。

以上。

鲸鸿动能斩获艾菲大中华区9项大奖

12月10-11日,在2025大中华区艾菲奖颁奖典礼上,鲸鸿动能斩获艾菲大中华区9项大奖。其中,��〈抚痕倡议〉社会共创运动——为4亿中国妈妈推动改变》获公关奖金奖、场景营销奖铜奖及大健康奖铜奖;《Grab×鲸鸿动能:智慧全场景营销,开启不可思议的旅行》获两项场景营销奖银奖;《同程旅行:实现爆量获客》获效果营销奖铜奖。此外,鲸鸿动能还摘得2025艾菲公关奖最具实效品牌No.1。

三星首款三折叠Galaxy Z TriFold国内亮相,Galaxy AI落到10英寸大屏上丨最前线

近日,三星旗下首款三折叠手机 Galaxy Z TriFold 在国内真机亮相,该产品配备 10 英寸可折叠屏幕。

三星 Galaxy Z TriFold 采用向内多重折叠设计,可支持两次展开操作。闭合时,6.5 英寸外屏可以满足日常手机使用;在完全展开状态下,机身厚度约为 3.9 毫米,是目前机身最薄的 Galaxy 机型之一。

在 10 英寸大屏上,Galaxy AI 功能拥有了更大的操作界面。
例如,使用「照片助手」中的「生成式编辑」优化照片细节时,编辑前与编辑后的图片可以同时显示在大屏上,用户可以更直观地对比效果;使用三星浏览器查看网页时,可以通过「浏览助手」在大屏中即时生成网页摘要或翻译,帮助用户快速了解关键信息;需要多模态 AI 助理 Bixby 解答问题时,还可以以不遮挡其他应用的悬浮窗口形式开启,无需频繁切换界面。

图片:三星提供

10 英寸大屏同样有利于多任务处理。
用户可以在屏幕中同时开启三个互不干扰的竖屏应用程序并行使用,也可以通过多窗口视图自由调整应用尺寸和布局,按个人习惯定制交互界面。

对于创作需求较多的用户,展开后的大屏为图像后期处理等工作提供了更充裕的操作空间。如果需要更适合网页浏览和文档阅读的纵向视野,用户可以将机身旋转 90 度,以纵向握持的方式观看内容。在视频编辑、沟通聊天以及出行导航等场景中,大尺寸屏幕也能呈现更多信息,并提升细节操作的精度。

图片:三星提供

硬件方面,三星 Galaxy Z TriFold 搭载骁龙8至尊版移动平台(for Galaxy),为系统日常运行提供性能保障。内屏与外屏均采用第二代动态AMOLED屏幕,支持高分辨率、较高峰值亮度以及120Hz自适应刷新率,可覆盖办公、观影与游戏等多种使用场景。
电池方面,机身内置5600毫安时(典型值)三电芯电池系统,支持45W快速充电,以提升续航表现。

影像系统由2亿像素大底主摄领衔的后置摄像头组构成,可满足用户在日常生活和出行场景中的拍摄需求。在夜景等光线复杂环境下,增强超视觉引擎会根据拍摄场景智能优化画面亮度与色彩。

图片:三星提供

此前需要外接显示设备才能使用的Samsung DeX,如今在三星Galaxy Z TriFold上可以本地独立运行。开启DeX模式后,用户可以创建至多四个独立桌面,每个桌面可同时运行五个应用程序,方便在不同使用场景间快速切换,从而提升多任务处理效率。如果需要两块大屏协同操作,用户也可以连接外部显示设备,通过扩展模式获得接近PC的窗口交互体验。

目前,三星Galaxy Z TriFold已正式开启预订:16GB+512GB版本建议零售价19,999 元,16GB+1TB版本建议零售价 21,999元。

解决npm publish的404/403和配置警告全记录

执行该命令发现这个问题,于是带着问题去问了AI,说是需要去配置npm token

npm publish

404-可能是没有登录

image.png

403-授权问题

image.png

流程-登录 npm 官网 -> Avatar -> Access Tokens -> Generate New Token -> 选 Automation / Granular Access Token,确保有 publish 权限且支持 Bypass 2FA

image.png

设置token,不要放在.npmrc中,可以直接命令行设置

npm config set //registry.npmjs.org/:_authToken=npm_16ZGwGQDSAUJEND3I927ZmAd1PP3IapziOD2jz6tj

接下来报错,is-current,意思说多了这个属于,不允许提交,版本问题

npm warn Unknown user config "is-current". This will stop working in the next major version of npm.

查看当前配置来源:

npm config list -l

image.png

配置还挺多的,大家可自行查看

在输出中找到包含 is-current 的位置(通常是在用户级或项目级 .npmrc)。2) 删除这项配置:

npm config delete is-current

如果在项目根目录的 .npmrc 里手动写了这一行,直接删掉该行或清空文件。完成后再执行命令,警告就不会出现了

良品铺子:“门店动态补货决策数据集”已落地应用,有效缩短采购决策时间

36氪获悉,良品铺子在业绩说明会上表示,公司目前正稳步推进供应链及各项业务的精益管理、降本增效措施,后续将持续加强成本费用管控,推动公司稳健发展。针对区域需求差异下的库存优化,由公司自主研发的“门店动态补货决策数据集”已经落地应用,该系统能有效缩短采购决策时间,优化库存结构。

汇丰预计美联储明后两年都不会降息

汇丰证券预测,美联储明后两年将维持利率稳定在周三所设定的3.5%-3.75%区间。该机构的美国经济学家Ryan Wang在12月10日的报告中指出,“我们认为,FOMC在整个2026年和2027年将维持联邦基金利率目标区间在3.50%-3.75%不变,但随着经济的演变,和过去一样,需始终关注这一展望面临的重要双向风险。”Wang指出,鲍威尔强调了“2026年经济稳健增长的基本观点,部分得益于财政政策支持及人工智能相关支出”,但同时也存在“劳动力市场逐步降温的证据”。(财联社)

零跑汽车:董事长朱江明及傅利泉增持215万股,金额超1亿元港币

36氪获悉,零跑汽车公告,近期本公司股东、董事长兼首席执行官朱江明先生及本公司股东傅利泉先生于市场内合共购入2150600股本公司H股(本次增持),平均价格为每股股份约50.51港币,合共增持金额超1亿元港币。紧随本次增持后,于本公告日期,朱江明先生及傅利泉先生及所属的本公司单一最大股东集团合共持有本公司209100538股H股及128517839股内资股,占本公司已发行总股数的23.75%。

旗滨集团:控股子公司旗滨光能引入战略伙伴东方资产

36氪获悉,旗滨集团公告,公司决定放弃控股子公司湖南旗滨光能科技有限公司13.75%股权的优先购买权,该部分股权拟以4.73亿元转让给中国东方资产管理股份有限公司。此次交易为关联交易,转让方宁海旗滨科源企业管理咨询合伙企业为公司实际控制人控制的企业。公司表示,放弃优先购买权是为了审慎应对行业调整,引入战略伙伴赋能旗滨光能发展,并实现整体价值最大化。交易完成后,公司仍为旗滨光能控股股东,持股比例不变。
❌