普通视图

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

Cursor 又偷偷更新,这个功能太实用:Visual Editor for Cursor Browser

作者 张拭心
2025年12月12日 01:24

凌晨 1 点,我正要关电脑睡觉,屏幕左下角突然弹出一个弹窗:

cursor 功能更新.jpg

Cursor 又上新功能了?带着好奇我仔细看了下文档:cursor.com/cn/docs/age…

我去,这个功能很重磅啊!

这次更新的 Visual Editor for Cursor Browser 是一个打破“设计”与“编码”边界的重磅功能,它让 Cursor 不仅仅是编辑器,更是一个“能直接写代码的浏览器”。

核心价值

它解决了前端开发中最大的痛点——“在浏览器里调好了样式,还得手动回代码里改”。

现在,我们可以像在 Figma 或 Webflow 里一样直接拖拽、点击、调整 UI,然后点击 "Apply",Cursor 的 Agent 就会自动把这些视觉变更翻译成完美的代码并写入你的项目,实现了真正的“所见即所得(Design to Code)”。

如何体验

首先确认版本是最新的:

image.png

打开 Cursor -> 右上角设置 -> Tools&MCP -> Browser Automation -> 选择 Browser Tab:

image.png

然后启动项目,会看到一个弹窗:

cursor-brower.jpg

点击 open 以后,就可以在 Cursor 里启动预览前端项目:

cursor 功能更新-预览.jpg

右上角的功能主要是:选择元素、截图、打开开发者模式。

最有用的就是选择元素后和 AI 对话,这无疑让上下文更加具体,以后修改 UI 更方便了!

简单的修改甚至我们都不需要和 AI 聊,直接上手在界面上改!

开启选择元素模式后,我们可以直接在预览界面上拖拽修改 UI、调整文案、布局结构等等,就和做设计一样所见即所得。

image.png

Cursor 内置浏览器包含一个设计侧边栏,可直接在 Cursor 中修改选中元素的 Position Layout Padding color 等等,实现实时可视化调整下的同步设计与编码。

朋友们,这个功能太实用了,实用到我都不敢告诉产品经理和设计师!

根据官方文档,这个功能可以在这些场景:

  1. 测试应用
  2. 可视化地编辑布局和样式
  3. 执行无障碍审计
  4. 将设计转换为代码等

岁数大了不能熬夜,我就先抛砖引玉,感兴趣的朋友赶紧试试吧,晚安!

我的专栏《转型 AI 工程师》正在预热中,第一篇文章已发布,感兴趣的朋友可以看看:xiaobot.net/post/8e8e06…

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

作者 zzpper
2025年12月12日 00:20

在前端开发中,“内存”似乎是个“隐形选手”——平时不显山露水,一旦出问题就可能让页面越用越卡、甚至直接崩溃。多数开发者对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/定时器等“独立执行单元”,必须显式终止

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

2025年12月12日 00:00

给你一个整数 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. 统计用户被提及情况

作者 stormsunshine
2025年1月26日 21:01

解法

思路和算法

由于事件发生顺序为时间戳递增顺序,因此应将数组 $\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)

作者 endlesscheng
2025年1月26日 12:05

注意输入的 $\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站@灵茶山艾府

昨天 — 2025年12月11日技术

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

作者 知航驿站
2025年12月11日 22:07

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 ⭐️!

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

作者 知航驿站
2025年12月11日 20:57

执行该命令发现这个问题,于是带着问题去问了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 里手动写了这一行,直接删掉该行或清空文件。完成后再执行命令,警告就不会出现了

🚀 “踩坑日记”:shadcn + Vite 在 Monorepo 中配置报错

2025年12月11日 20:20

问题介绍

ui.shadcn.com/docs/instal…

按照这个官方文档配置 shadcn + vite 项目后,遇到个错误:

image.png

按照官方文档配置,理应是没有错误的,但是我的项目特殊点就在于是一个 Monorepo 项目。

所以,当你在一个 Monorepo 里使用 TypeScript + ESLint(Flat Config,eslint.config.js)时,常会遇到下面这个解析错误:

Parsing error: No tsconfigRootDir was set, and multiple candidate TSConfigRootDirs are present:
  - /Users/.../packages/SearchChat 
  - /Users/.../packages/SearchChatUI
You'll need to explicitly set tsconfigRootDir in your parser options.
See: https://typescript-eslint.io/packages/parser/#tsconfigrootdireslint

项目背景

  • Monorepo 项目结构示例:
/Users/.../ui-common
├── apps/
│   └── react-jsx/
├── packages/
│   ├── ChatMessage/
│   ├── CustomIcons/
│   ├── DatePicker/
│   ├── DropdownList/
│   ├── EntityUI/
│   └── SearchChatUI/
└── pnpm-workspace.yaml
  • 每个包(例如 packages/SearchChatUI)通常都有自己的 tsconfig.json(含 referencestsconfig.app.json / tsconfig.node.json)、eslint.config.jspackage.json
  • ESLint 在启用类型感知规则(或需要类型信息的配置)时,会通过 @typescript-eslint/parser 加载 TypeScript Program,这需要明确告诉它:以哪个目录为根去解析 projecttsconfig*.json)。

错误现象

  • 在 Monorepo 根或任一包里运行 eslint,报错显示发现多个候选 TSConfigRootDir
  • 这是因为解析器试图自动探测 tsconfig 根目录,但同时看到了多个包的 tsconfig,于是拒绝继续。

原因分析

  • TypeScript-ESLint 的解析器需要一个“根目录”(tsconfigRootDir)来解释你提供的 parserOptions.project(即哪些 tsconfig*.json 参与构建类型信息)。
  • 在 Monorepo 中,如果没有明确为每个包设定独立的 tsconfigRootDir 与对应的 project,解析器会在工作区内“看见”多个包的 tsconfig,从而无法确定到底应该用哪个根,最终报错。

快速修复(针对单个包)

packages/SearchChatUI 为例,给它的 eslint.config.js 增加明确的 parserOptions.tsconfigRootDirparserOptions.project 即可。

// packages/SearchChatUI/eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        // 关键设置:指向当前包目录,避免 Monorepo 下的多 tsconfig 混淆
        tsconfigRootDir: __dirname,
        // 指定当前包使用的 tsconfig 列表(路径相对于 tsconfigRootDir)
        project: [
          './tsconfig.json',
          './tsconfig.app.json',
          './tsconfig.node.json',
        ],
        sourceType: 'module',
      },
    },
  },
])

验证:

  • 进入包目录运行 npm run lint(保证命令在包内执行)
  • 预期不再出现 Parsing error

在 Monorepo 根统一配置的做法(推荐)

如果你倾向于在根目录放一个统一的 eslint.config.js,可以使用 “按包 override” 的方式,让每个包都明确自己的 tsconfigRootDirproject

示例(伪代码,按需调整包路径):

// eslint.config.js at workspace root
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import js from '@eslint/js'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const rootDir = path.dirname(fileURLToPath(import.meta.url))
const pkg = (dir) => path.join(rootDir, 'packages', dir)

export default defineConfig([
  {
    files: ['packages/SearchChatUI/**/*.{ts,tsx}'],
    extends: [js.configs.recommended, tseslint.configs.recommended],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        tsconfigRootDir: pkg('SearchChatUI'),
        project: [
          path.join(pkg('SearchChatUI'), 'tsconfig.json'),
          path.join(pkg('SearchChatUI'), 'tsconfig.app.json'),
          path.join(pkg('SearchChatUI'), 'tsconfig.node.json'),
        ],
        sourceType: 'module',
      },
    },
  },
  {
    files: ['packages/SearchChat/**/*.{ts,tsx}'],
    extends: [js.configs.recommended, tseslint.configs.recommended],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        tsconfigRootDir: pkg('SearchChat'),
        project: [
          path.join(pkg('SearchChat'), 'tsconfig.json'),
          path.join(pkg('SearchChat'), 'tsconfig.app.json'),
          path.join(pkg('SearchChat'), 'tsconfig.node.json'),
        ],
        sourceType: 'module',
      },
    },
  },
  // 为其他包继续添加 overrides...
])

要点:

  • 每个包的 override 都拥有自己的 tsconfigRootDir
  • project 数组中的路径要基于该包目录。
  • 保持按包运行 eslint .(或通过 workspace 脚本定位到包)能减少路径解析混乱。

常见坑位与提示

  • project 路径必须相对于 tsconfigRootDir,不要写相对于工作区根的路径。
  • 若你使用的是 TypeScript-ESLint 的“类型感知”配置(例如 tseslint.configs.recommendedTypeChecked 或启用了需要类型信息的规则),一定要提供 tsconfigRootDirproject
  • 如果你不需要类型感知规则(为了更快的性能),可以只用非 type-checked 的推荐集,省略 project(但要权衡规则能力):
    extends: [tseslint.configs.recommended] // 非类型感知
    // 不设置 parserOptions.project
    
  • 在包内运行 npm run linteslint .)比在根随意运行更可控。
  • ESM 环境下,要用 fileURLToPath(import.meta.url) 获取当前文件路径来计算 __dirname

验证步骤

  1. 在目标包目录执行:
    • npm run lint
  2. 确认不再出现 “No tsconfigRootDir was set … multiple candidate TSConfigRootDirs …” 的错误。
  3. 如果还有包报同样错误,逐个为它们的配置添加 tsconfigRootDirproject

性能与类型感知

  • 类型感知规则需要构建 TypeScript Program,解析器会加载并分析 project 指定的 tsconfig;在大 Monorepo 中这可能较慢。
  • 推荐做法:
    • 只有在确实需要类型规则的包上开启 project
    • 使用按包 override 控制范围。
    • 结合 CI 分层执行(先非类型感知快速检查,再在关键包跑类型感知规则)。

小结

这个报错本质是 Monorepo 环境下 “类型规则需要明确上下文” 的提醒。只要为每个包设定清晰的 tsconfigRootDirproject,ESLint 就能准确地获取类型信息并稳定工作。按包划分 override 是根级统一配置的好方式;而在包内独立配置则更为直觉。


参考链接

🕳️ React 避坑指南:"闭包陷阱"

2025年12月11日 18:19

写在前面:如果你是 React 新手,或者刚从 Class 组件转到 Hooks,这篇文章或许可以帮你省下几根头发。

广告植入:欢迎访问我的个人网站:hixiaohezi.com


案发现场

事情发生在两年前的一个周五下午(是的,墨菲定律通过不缺席),当时我正在写一个极其简单的功能:倒计时

需求很简单:用户点击按钮开始倒计时 60 秒,每秒更新页面上的数字,倒计时结束后自动重置。

作为一名老菜鸟,我脑子一扔,直接敲下了如下代码:

function Timer() {
  const [count, setCount] = useState(60);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前倒计时:', count); // 用于调试
      
      if (count > 0) {
        setCount(count - 1); // 逻辑很完美对吧?
      } else {
        clearInterval(timer);
      }
    }, 1000);

    // 清理定时器
    return () => clearInterval(timer);
  }, []); // 空依赖数组,因为我只想让定时器启动一次

  return <div>倒计时:{count} 秒</div>;
}

我保存了代码,刷新了浏览器,准备提交代码然后下班跑路。

诡异的现象

屏幕上的数字从 60 变成了 59。 然后... 就定格在了 59

但我打开控制台一看,控制台在疯狂输出:

当前倒计时: 60
当前倒计时: 60
当前倒计时: 60
...

What???

我的 count 已经变成 59 了啊(页面都变了),为什么 setInterval 打印出来的还是 60?为什么它一直在重复 60 - 1 = 59 这个动作,却再也下不去了?

我当时的第一反应是:

  1. React 坏了?
  2. 浏览器坏了?
  3. 宇宙射线干扰了 CPU?

唯独没想过是自己菜。

破案:该死的"闭包"

在对着屏幕发呆了 n 分钟,查阅了无数 StackOverflow 之后,我终于明白了真相。

这个坑的名字叫:Stale Closure (陈旧闭包 / 僵尸闭包)

简单用人话解释一下:

当你写下 useEffect(..., []) 且依赖数组为空时,这个 Effect 只会在组件挂载时执行一次。 这时候,setInterval 被创建了。它捕获了当时环境下的 count 变量。 当时count 是 60。

无论组件后来重新渲染了多少次,无论页面上的 count 变成了 59、58 还是 0,定时器里的那个回调函数,依然是第一次创建时的那个函数。 在那个函数"冻结"的记忆里,count 永远是 60。

所以它每一秒都在做同一件事:

"噢,现在 count 是 60,我要把它变成 59。"

像不像一条只有 7 秒记忆的鱼?

解决方案

找到了原因,解决就很简单了。这里提供两种方案,但我强烈推荐第一种。

方案一:函数式更新 (推荐 ✅)

这是最优雅的解法,不需要重置定时器。

useEffect(() => {
  const timer = setInterval(() => {
    // 重点在这里!!!
    // prevCount 是 React 传进来的最新值,不依赖外部闭包
    setCount((prevCount) => {
      if (prevCount <= 1) {
        clearInterval(timer);
        return 0;
      }
      return prevCount - 1;
    });
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依然可以是空数组

为什么有效?:因为 setState 如果接收一个函数,React 会保证把最新的状态值传给你。你不需要读取外部的 count 变量,从而绕过了闭包陷阱。

方案二:useRef 大法 (万能 ✅)

如果你不仅要更新状态,还要在定时器里读取最新的 props 或其他状态做判断,useRef 是救命稻草。

const countRef = useRef(count);

// 每次渲染都更新 ref.current
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    // ref.current 永远是最新的,因为它是一个引用对象
    if (countRef.current > 0) {
      // do something...
    }
  }, 1000);
}, []);

如果这个坑你也踩过,握个爪。

最后,再加个广告,欢迎访问我的个人网站:👉 hixiaohezi.com

xiaohezi.com.qrcode.png

(这里是我的个人网站,虽然没有 Hooks 那么复杂,但绝对真诚)

祝大家的代码永远没有 Bug,只有 Feature!

从后端拼模板到 Vue 响应式:前端界面的三次进化

作者 鱼鱼块
2025年12月11日 18:00

从后端拼模板到 Vue 响应式:一场前端界面的进化史

当开始学习前端开发时,很多人都会遇到一个共同的困惑:
为什么有的项目让后端直接返回 HTML?

为什么后来大家都开始使用 fetch 拉取 JSON?

而现在又流行 Vue 的响应式界面,几乎不再手动操作 DOM?

这些不同的方式看似杂乱,其实背后隐藏着一条非常清晰的技术发展路径。后端渲染 → 前端渲染 → 响应式渲染它们不是独立出现的,而是前端能力逐步增强、分工越来越明确后的必然产物。

1. 🌱 第一阶段:后端拼模板 —— “厨师把菜做好端到你桌上”

让我们从最初的 Node.js 服务器代码说起。

    const http = require("http");// Node.js 内置模块,用于创建 HTTP 服务器或客户端
const url = require("url");// 用于解析 URL

const users = [
  { id: 1, name: '张三', email: '123@qq.com' },
  { id: 2, name: '李四', email: '1232@qq.com'},
  { id: 3, name: '王五', email: '121@qq.com' }
];

// 将 `users` 数组转换为 HTML 表格字符串
function generateUserHTML(users){
  const userRows = users.map(user => `
    <tr>
      <td>${user.id}</td>
      <td>${user.name}</td>
      <td>${user.email}</td>
    </tr>
  `).join('');

  return `
    <html>
      <body>
        <h1>Users</h1>
        <table>
          <tbody>${userRows}</tbody>
        </table>
      </body>
    </html>
  `;
}

// 创建一个 HTTP 服务器,传入请求处理函数
const server = http.createServer((req, res) => {
  if(req.url === '/' || req.url === '/users'){
    res.setHeader('Content-Type', 'text/html;charset=utf-8');
    res.end(generateUserHTML(users));
  }
});

//  服务器监听本地 1314 端口。
server.listen(1314);

访问1314端口,得到的结果:

image.png 这段代码非常典型,体现了早期 Web 的模式:

  • 用户访问 /users
  • 后端读取数据
  • 后端拼出 HTML
  • 后端把完整页面返回给浏览器

你可以把它理解成:

用户去餐馆点菜 → 厨房(后端)把菜做好 → 端到你桌上(浏览器)

整个过程中,浏览器不参与任何加工,它只是“展示已经做好的菜”。


🔍 后端拼模板的特点

特点 说明
后端掌控视图 HTML 是后端生成的
数据和页面耦合在一起 改数据就要改 HTML 结构
刷新页面获取新数据 无法局部更新
用户体验一般 交互不够流畅

这种方式在早期 Web 非常普遍,就是典型的 MVC:

  • M(Model): 数据
  • V(View): HTML 模板
  • C(Controller): 拼 HTML,返回给浏览器

“后端拼模板”就像饭店里:

  • 厨师(后端)把所有食材(数据)做成菜(HTML)
  • 顾客(浏览器)只能被动接受

这当然能吃饱,但吃得不灵活。

为了吃一个小菜,还要大厨重新做一桌菜!

这就导致页面每个小变化都得刷新整个页面。


2. 🌿 第二阶段:前后端分离 —— “厨师只给食材,顾客自己配菜”

随着前端能力提升,人们发现:

让后端拼页面太麻烦了。

于是产生了 前后端分离


🔸 后端从“做菜”变成“送食材”(只返回 JSON)

{
  "users": [
    { "id": 1, "name": "张三", "email": "123@qq.com" },
    { "id": 2, "name": "李四", "email": "1232@qq.com" },
    { "id": 3, "name": "王五", "email": "121@qq.com" }
  ]
}

JSON Server 会把它变成一个 API:

GET http://localhost:3000/users

访问该端口得到:

image.png

访问时返回纯数据,而不再返回 HTML。


🔸 前端浏览器接管“配菜”(JS 渲染 DOM)

<script>
fetch('http://localhost:3000/users')// 使用浏览器内置的 `fetch`() API 发起 HTTP 请求。
  .then(res => res.json())//  解析响应为 JSON
  // 渲染数据到页面
  .then(data => {
    const tbody = document.querySelector('tbody');
    tbody.innerHTML = data.map(user => `
      <tr>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
      </tr>
    `).join('');
  });
</script>

浏览器自己:

  1. 发送 fetch 请求
  2. 拿到 JSON
  3. 用 JS 拼 HTML
  4. 填入页面

image.png


这就好比:

  • 后端: 不做菜,只把干净的食材准备好(纯 JSON)
  • 前端: 自己按照 UI 要求把菜炒出来(DOM 操作)
  • 双方分工明确,互不干扰

这就是现代 Web 最主流的模式 —— 前后端分离


🚧 但问题来了:DOM 编程太痛苦了

你看这段代码:

tbody.innerHTML = data.map(user => `
  <tr>...</tr>
`).join('');

是不是很像在手工组装乐高积木?

DOM 操作会遇到几个痛点:

  • 代码又臭又长
  • 更新数据要重新操作 DOM
  • 状态多了之后难以维护
  • 页面结构和业务逻辑混在一起

前端工程师开始苦恼:

有没有一种方式,让页面自动根据数据变化?

于是,Vue、React、Angular 出现了。


3. 🌳 第三阶段:Vue 响应式数据驱动 —— “只要食材变化,餐盘自动变化”

Vue 的核心理念:

ref 响应式数据,将数据包装成响应式对象
界面由 {{}} v-for 进行数据驱动
专注于业务,数据的变化而不是 DOM

这是前端的终极模式 —— 响应式渲染


🔥 Vue 的思想

Vue 做了三件事:

  1. 把变量变成“会被追踪的数据”(ref / reactive)
  2. 把 HTML 变成“模板”(用 {{ }}、v-for)
  3. 让数据变化自动修改 DOM

你只需要像写伪代码一样描述业务:

<script setup>
import {
  ref,
  onMounted // 挂载之后
} from 'vue'
const users = ref([]);

// 在挂载后获取数据

onMounted(() =>{
   fetch('http://localhost:3000/users')
   .then(res => res.json())
   .then(data => {
    users.value = data;
   })
})
</script>

而页面模板:

<tr v-for="u in users" :key="u.id">
  <td>{{ u.id }}</td>
  <td>{{ u.name }}</td>
  <td>{{ u.email }}</td>
</tr>

得到的结果为:

image.png 你不再需要:

  • querySelector
  • innerHTML
  • DOM 操作

Vue 会自己完成这些工作。


如果 传统 DOM:

你要把所有食材手动摆到盘子里。

那么Vue:

你只需要放食材到盘子里(修改数据),
餐盘的摆盘会自动变化(界面自动更新)。

比如你修改了数组:

users.value.push({ id: 4, name: "新用户", email: "xxx@qq.com" });

页面会自动新增一行。

你删除:

users.value.splice(1, 1);

页面自动少一行。

你完全不用动 DOM。


4. 🌲 三个阶段的对比

阶段 数据从哪里来? 谁渲染界面? 技术特征
1. 后端渲染(server.js) 后端 后端拼 HTML 模板字符串、MVC
2. 前端渲染(index.html + db.json) API / JSON 前端 JS DOM Fetch、innerHTML
3. Vue 响应式渲染 API / JSON Vue 自动渲染 ref、{{}}、v-for

本质是渲染责任的迁移:

后端渲染 → 前端手动渲染 → 前端自动渲染

最终目标只有一个:

让开发者把时间花在业务逻辑,而不是重复性 DOM 操作上。


5. 🍁 为什么现代开发必须用前后端分离 + Vue?

最后,让我们用一句最通俗的话总结:

后端拼页面像“饭店厨师包办一切”,效率低。

前端手动拼 DOM 像“自己做饭”,累到爆。

Vue 像“智能厨房”,你只需要准备食材(数据)。


Vue 的三大优势

1)极大减少开发成本

业务逻辑变简单:

users.value = newUsers;

就够了,UI 自动更新。

2)更适合大型项目

  • 组件化
  • 模块化
  • 状态集中管理
  • 可维护性高

3)用户体验更好

  • 页面不刷新
  • 更新局部
  • 响应迅速

6. 🌏 文章总结:从“厨房”看前端的进化历史

最终,我们回到开头的类比:

阶段 类比
第一阶段:后端拼模板 厨房(后端)做好所有菜,直接端给你
第二阶段:前端渲染 厨房只提供食材,你自己炒
第三阶段:Vue 响应式 智能厨房:只要食材变,菜自动做好

前端技术每一次进化,都围绕同一个核心目标:

让开发者更轻松,让用户体验更好。

而你上传的代码正好构成了一个完美的演示链路:
从最原始的后端拼模板,到 fetch DOM 渲染,再到 Vue 响应式渲染。

理解了这三步,你就理解了整个现代前端技术的发展脉络。


module federation,monorepo分不清楚?

作者 ggbond
2025年12月11日 17:57

一代版本一代神,现代的前端已经不是会用一个react就能混过去的了,虽然正式工作上还是打螺丝,调包侠+切图仔,但是有些时候,新知识不可不学。 有两个概念近些年很火,一个是module federation一个是monorepo,光看名字可能觉得有点像,但是其实是两个东西。

模块联邦module federation

这是webpack在v5被投入生产,并作为v5的核心特性之一。它的出现解决了一些问题,或者说它适用于以下场景:

  1. 微前端架构:实现独立部署的子应用动态集成(如电商平台的首页、商品页拆分)。
  2. 大型应用拆分:逐步重构单体应用,降低维护成本。
  3. 跨团队代码共享:避免重复发布 npm 包,直接运行时复用模块。

基本上可以说他是微前端的方式。当然市面上肯定大部分工具也会跟上webpack,比如vite就通过rollup钩子实现了(vite-plugin-federation),又比如@module-federation/rollup插件,next-mf插件,Rspack(基于webpack)。 接下来看下他的主要配置

主应用webpack.config.js:

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'remote@http://localhost:3001/app1Entry.js', // 子应用地址
      },
      shared: { 
        react: { singleton: true }, 'react-dom': { singleton: true }                 },
    }),
  ],
};

这里看到了一个shared配置singleton,指明了哪些模块应该作为单例共享,也就是单例模式,true的话父子应用共用一个实例,避免重复加载,但是当插件需要完全隔离的依赖如react环境时,可以设置成false;

remotes字段指定了远程微应用的名称和其远程入口文件URL。 当主应用需要用到子应用的玩意时,如下:

// 动态加载子应用的Button组件 
const RemoteButton = React.lazy(() => import('app1/Button'));

子应用webpack配置

const { ModuleFederationPlugin } = require('webpack').container; module.exports = { 
    entry: './src/moduleOutput.js', // 必须通过moduleOutput.js间接引入 
    plugins: [ 
        new ModuleFederationPlugin({ 
            name: 'app1', // 子应用名称(全局唯一) 
            filename: 'app1Entry.js', // 入口文件名(默认),模块清单名
            exposes: { 
                './Button': './src/Button.js', // 暴露组件路径 
            }, 
            shared: { 
                react: { singleton: true }, 'react-dom': { singleton: true } 
            },             
        }), 
    ], 
};
// moduleOutput.js
import('./index'); // 延迟加载业务代码

注意,这里我们看到entry不是我们平时项目脚手架自带的index.js/ts,而是通过其他文件moduleOutput.js,这个文件的存在是为了正确执行模块联邦的动态加载机制和代码执行顺序,而主要导致这样的原因是:

  1. 主应用加载子应用时,会先下载app1Entry.js模块清单文件,然后在按需加载子模块,比如exposes中的Button,如果子应用直接以index.js作为entry,可能会在子应用的子模块模块被主应用加载时,子应用的依赖(如react)未准备就绪,毕竟子应用也是配置了按需加载,这就会导致运行错误
  2. app1Entry.js这文件的作用就是延迟执行,通过动态导入(import())将子应用的业务代码(如 index.js)的加载推迟到 所有共享依赖(如 React)已就绪后。 当然,如果父子应用没有共享的模块,那么这个文件也就没必要了,另外shared的依赖中,有一个requiredVersion字段,可以让父子协商是否共享模块。

monorepo

这其实不是具体工具,而是一种思想:强关联性,同一业务线的项目,可以将项目放在同一个版本管理工具中(比如git),这么做的好处有很多,比如

  1. 代码共享与复用,一些公共的ts定义,和api接口层,组件能直接引用,并且所有项目共用顶层node_modules,减少重复依赖安装(通过workspaces功能)
  2. 统一工程化配置,比如eslint,pritter,jest和webpack等构建工具等,这会让维护成本降低。
  3. 统一版本管理,通过changesets等工具自动化版本号和changeLog管理
  4. 版本提交的完整性,当修改底层库时,可同时更新依赖他的所有应用,这保证了提交的完整性
  5. 依赖关系可视化,可用preune等命令工具生成关系图,便于框架优化
  6. 统一CI配置,所有项目共用一套CI/CD流程 当然也不是所有业务线都要这么做,这适用于部分场景:
  7. 微前端架构
  8. 全栈项目(对我来说当然是js的全栈)
  9. 多应用平台,比如pc,mobile共用业务逻辑
  10. 大型团队协作,减少代码碎片化
  11. 替代npm的频繁更替 常用来实现monorepo的工具有pnpm,lerna,turborepo,我一般使用pnpm

总结

这么一盘,好像两者也不是毫无联系,这都和微前端扯到了关系,但是两者场景并不是非常一致,且手段不同。最共同的点是,他们都是要学的东西。

手写new操作符执行过程

作者 柳安
2025年12月11日 17:55

手写new操作符执行过程

主要分四个步骤

  1. 创建空对象
  2. 设置空对象的对象原型,指向对应构造函数的原型对象
  3. 绑定this,并且执行构造函数
  4. 判断 构造函数 返回值类型

前置知识

Object.create(构造函数的原型对象)

新建一个空对象,对象的原型为构造函数的 prototype 对象

constructor.apply(newObject, arguments);

执行constructor,并且把constructor的this绑定到newObject,传入参数

手写过程

// 手撕new的过程
function Person(name,age){
    this.name = name;
    this.age = age;
}

// 构造函数
function MyNew(constructor,...args){
    // 分别接受构造函数和后续的参数
    // f  ['my', 18]
    console.log(constructor,args);

    // 1.创建空对象
    let newObj = null;

    // 2.修改对象的对象原型,指向构造函数的原型对象
    newObj = Object.create(constructor.prototype)

    // 3.绑定this,并且执行构造函数
    let result = constructor.apply(newObj,args); //将constructor的this绑定为newObj,并且传入后续的参数

    // 4.判断 构造函数 返回值类型
    let flag = result && (typeof result === "object" || typeof result === "function");
    
    // 如果是对象或者函数,就返回构造函数返回值,否则返回新对象
    return flag ? result : newObj;
}



// 入口
const ret = MyNew(Person,"my",18);
console.log(ret)

问题

最后为什么要判断返回值的类型?

首先,因为上面的构造函数没有显示的返回值,所以会返回undefined,这里就需要返回自己创建的newObj。

如果构造函数有返回值,就直接返回构造函数的返回值即可,就不需要自己创建的实例了。

function Person(name, age) {
  this.name = name;
  this.age = age;
  // 手动返回一个新对象
  return { nickname: "小明", gender: "男" };
}

返回值的类型

并且需要根据构造函数的返回值类型进行选择,保证只返回函数或者对象类型,如果是基础类型,就直接返回构造函数返回的结果

let flag = result && (typeof result === "object" || typeof result === "function");

那在哪一步对自己创建的对象赋值了呢?

let result = constructor.apply(newObj,args);

这里apply执行之后,newObj就变成了根据传入参数new和构造函数创建的对象了,而result就是对象的返回值。

本人水平有限,如有错误欢迎在评论区指正

tauri2+vue+vite实现基于webview视图渲染的桌面端开发

2025年12月11日 17:33

创建应用

pnpm create tauri-app

应用程序更新

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add updater

2.配置

密钥生成,在package.json文件中添加,如下命令生成更新公钥和私钥

"@description:updater": "Tauri CLI 提供了 signer generate 命令 生成更新密钥",
"updater": "tauri signer generate -w ~/.tauri/myapp.key"

在windows环境变量配置私钥,输入cmd 命令行执行 win cmd

set TAURI_PRIVATE_KEY="content of the generated key"
set TAURI_KEY_PASSWORD="password"

powershell

$env:TAURI_PRIVATE_KEY="content of the generated key"
$env:TAURI_KEY_PASSWORD="password"

在 src-tauri\tauri.conf.json 文件中开启自动升级,并将公钥添加到里面,设置你的升级信息json文件获取的url路径

{
  "app": {},
  "bundle": {
    "createUpdaterArtifacts": true,
    "icon": []
  },
  "plugins": {
    "updater": {
      "active": true,
      "windows": {
        "installMode": "passive"
      },
      "pubkey": "公钥",
      "endpoints": ["https://xxx/download/latest.json"]
    }
  }
}

更新 latest.json 内容

{
  "version": "v1.0.0",
  "notes": "Test version",
  "pub_date": "2020-06-22T19:25:57Z",
  "platforms": {
    "darwin-x86_64": {
      "signature": "Content of app.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x86_64.app.tar.gz"
    },
    "darwin-aarch64": {
      "signature": "Content of app.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-aarch64.app.tar.gz"
    },
    "linux-x86_64": {
      "signature": "Content of app.AppImage.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-amd64.AppImage.tar.gz"
    },
    "windows-x86_64": {
      "signature": "Content of app.msi.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x64.msi.zip"
    }
  }
}

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    "updater:default",
    "updater:allow-check",
    "updater:allow-download",
    "updater:allow-install"
  ]
}

3.封装hooks

src\hooks\updater.ts

import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export default () => {
  const message = window.$message;
  const dialog = window.$dialog;

  const checkV = async () => {
    return await check()
      .then((e: any) => {
        if (!e?.available) {
          return;
        }
        return {
          version: e.version,
          meg: `新版本 ${e.version} ,发布时间: ${e.date} 升级信息: ${e.body}`,
        };
      })
      .catch((e) => {
        console.error("检查更新错误,请稍后再试 " + e);
      });
  };

  const updater = async () => {
    dialog.success({
      title: "系统提示",
      content: "您确认要更新吗 ?",
      positiveText: "更新",
      negativeText: "不更新",
      maskClosable: false,
      closable: false,
      onPositiveClick: async () => {
        message.success("正在下载更新,请稍等");

        await check()
          .then(async (e: any) => {
            if (!e?.available) {
              return;
            }
            await e.downloadAndInstall((event: any) => {
              switch (event.event) {
                case "Started":
                  message.success(
                    "文件大小:" + event.data.contentLength
                      ? event.data.contentLength
                      : 0
                  );
                  break;
                case "Progress":
                  message.success("正在下载" + event.data.chunkLength);
                  break;
                case "Finished":
                  message.success("安装包下载成功,10s后重启并安装");
                  setTimeout(async () => {
                    await relaunch();
                  }, 10000);
                  break;
              }
            });
          })
          .catch((e) => {
            console.error("检查更新错误,请稍后再试 " + e);
          });
      },
      onNegativeClick: () => {
        message.info("您已取消更新");
      },
    });
  };

  return {
    checkV,
    updater,
  };
};

4.调用示例

<template>
  <div>
    {{ meg }}
    <n-button type="primary" @click="updateTask">检查更新</n-button>
  </div>
</template>

<script setup lang="ts">
import { message } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json";
import useUpdater from "@/hooks/updater";
import { ref } from "vue";
const meg = ref("版本检测 ");
const { checkV, updater } = useUpdater();
const state = ref(false);
const updateTask = async () => {
  if (state.value) {
    await updater();
  } else {
    let res = await checkV();
    if (res) {
      meg.value = "发现新版本:" + res.meg;
      state.value = pkg.version !== res.version;
    }
  }
};
</script>

自定义系统托盘

前端方式(hooks函数)【推荐】

1.配置

添加自定义图标权限 src-tauri\Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }

2.封装hooks

src\hooks\tray.ts

// 获取当前窗口
import { getCurrentWindow } from "@tauri-apps/api/window";
// 导入系统托盘
import { TrayIcon, TrayIconOptions, TrayIconEvent } from "@tauri-apps/api/tray";
// 托盘菜单
import { Menu } from "@tauri-apps/api/menu";
// 进程管理
import { exit } from "@tauri-apps/plugin-process";

// 定义闪烁状态
let isBlinking: boolean = false;
let blinkInterval: NodeJS.Timeout | null = null;
let trayInstance: TrayIcon | any | null = null;
let originalIcon: string | any;
/**
 * 在这里你可以添加一个托盘菜单,标题,工具提示,事件处理程序等
 */
const options: TrayIconOptions = {
  // icon 项目根目录/src-tauri/
  icon: "icons/32x32.png",
  tooltip: "zero",
  menuOnLeftClick: false,
  action: (event: TrayIconEvent) => {
    if (
      event.type === "Click" &&
      event.button === "Left" &&
      event.buttonState === "Down"
    ) {
      // 显示窗口
      winShowFocus();
    }
  },
};

/**
 * 窗口置顶显示
 */
async function winShowFocus() {
  try {
    // 获取窗体实例
    const win = getCurrentWindow();
    // 检查窗口是否见,如果不可见则显示出来
    if (!(await win.isVisible())) {
      await win.show();
    } else {
      // 检查是否处于最小化状态,如果处于最小化状态则解除最小化
      if (await win.isMinimized()) {
        await win.unminimize();
      }
      // 窗口置顶
      await win.setFocus();
    }
  } catch (error) {
    console.error("Error in winShowFocus:", error);
  }
}

/**
 * 创建托盘菜单
 */
async function createMenu() {
  try {
    return await Menu.new({
      // items 的显示顺序是倒过来的
      items: [
        {
          id: "show",
          text: "显示窗口",
          action: () => {
            winShowFocus();
          },
        },
        {
          id: "quit",
          text: "退出",
          action: () => {
            exit(0);
          },
        },
      ],
    });
  } catch (error) {
    console.error("Error in createMenu:", error);
    return null;
  }
}

/**
 * 创建系统托盘
 */
export async function createTray() {
  try {
    const menu = await createMenu();
    if (menu) {
      options.menu = menu;
      const tray = await TrayIcon.new(options);
      trayInstance = tray;
      originalIcon = options.icon; // 保存原始图标
      return tray;
    }
  } catch (error) {
    console.error("Error in createTray:", error);
  }
}

/**
 * 开启图标闪烁
 * @param icon1 图标1路径(可选,默认原始图标)
 * @param icon2 图标2路径(可选,默认alt图标)
 * @param interval 闪烁间隔(默认500ms)
 */
export async function startBlinking(
  icon1?: string,
  icon2?: string,
  interval: number = 500
) {
  if (!trayInstance) {
    console.error("Tray not initialized");
    return;
  }

  // 如果正在闪烁,先停止
  stopBlinking();

  // 设置图标路径
  const targetIcon1 = icon1 || originalIcon;
  const targetIcon2 = icon2 || "icons/32x32_alt.png"; // 备用图标路径

  isBlinking = true;
  let currentIcon = targetIcon1;

  blinkInterval = setInterval(async () => {
    try {
      currentIcon = currentIcon === targetIcon1 ? targetIcon2 : targetIcon1;
      await trayInstance!.setIcon(currentIcon);
    } catch (error) {
      console.error("Blinking error:", error);
      stopBlinking();
    }
  }, interval);
}

/**
 * 停止闪烁并恢复原始图标
 */
export function stopBlinking() {
  if (blinkInterval) {
    clearInterval(blinkInterval);
    blinkInterval = null;
    isBlinking = false;

    // 恢复原始图标
    if (trayInstance) {
      trayInstance
        .setIcon(originalIcon)
        .catch((error) => console.error("恢复图标失败:", error));
    }
  }
}

/**
 * 销毁托盘(自动停止闪烁)
 */
export async function destroyTray() {
  try {
    stopBlinking();
    if (trayInstance) {
      await trayInstance.destroy();
      trayInstance = null;
    }
  } catch (error) {
    console.error("Error destroying tray:", error);
  }
}

3.调用示例

结合不同场景引入 hooks 函数,调用对应方法,其中 createTray函数 可以放到 main.ts 中在系统启动时创建

// 场景示例:即时通讯应用
class ChatApp {
  async init() {
    // 应用启动时初始化托盘
    await createTray();
  }

  onNewMessage() {
    // 收到新消息时启动红色提醒闪烁
    startBlinking("icons/msg_new.png", "icons/msg_alert.png");
  }

  onMessageRead() {
    // 用户查看消息后停止闪烁
    stopBlinking();
  }

  async shutdown() {
    // 退出时清理资源
    await destroyTray();
  }
}

// 场景示例:下载管理器
class DownloadManager {
  onDownloadProgress() {
    // 下载时使用蓝色图标呼吸灯效果
    startBlinking("icons/download_active.png", "icons/download_idle.png", 1000);
  }

  onDownloadComplete() {
    // 下载完成停止闪烁并显示完成图标
    stopBlinking();
    trayInstance?.setIcon("icons/download_done.png");
  }
}

前后端结合方式(Rust函数)

1.配置

添加自定义图标权限 src-tauri\Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }

添加配置 src-tauri\tauri.conf.json 自定义图标

"app": {
  "windows": [
  ],
  "trayIcon": {
    "iconPath": "icons/icon.ico",
    "iconAsTemplate": true,
    "title": "时间管理器",
    "tooltip": "时间管理器"
  }
},

2.Rust 封装

托盘事件定义,新建 tray.rs 文件 src-tauri\src\tray.rs

use tauri::{
    menu::{Menu, MenuItem, Submenu},
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager, Runtime,
};

pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
    let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
    let show_i = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
    let hide_i = MenuItem::with_id(app, "hide", "隐藏", true, None::<&str>)?;
    let edit_i = MenuItem::with_id(app, "edit_file", "编辑", true, None::<&str>)?;
    let new_i = MenuItem::with_id(app, "new_file", "添加", true, None::<&str>)?;
    let a = Submenu::with_id_and_items(app, "File", "文章", true, &[&new_i, &edit_i])?;
    // 分割线
    let menu = Menu::with_items(app, &[&quit_i, &show_i, &hide_i, &a])?;
    // 创建系统托盘 let _ = TrayIconBuilder::with_id("icon")
    let _ = TrayIconBuilder::with_id("tray")
        // 添加菜单
        .menu(&menu)
        // 添加托盘图标
        .icon(app.default_window_icon().unwrap().clone())
        .title("zero")
        .tooltip("zero")
        .show_menu_on_left_click(false)
        // 禁用鼠标左键点击图标显示托盘菜单
        // .show_menu_on_left_click(false)
        // 监听事件菜单
        .on_menu_event(move |app, event| match event.id.as_ref() {
            "quit" => {
                app.exit(0);
            }
            "show" => {
                let window = app.get_webview_window("main").unwrap();
                let _ = window.show();
            }
            "hide" => {
                let window = app.get_webview_window("main").unwrap();
                let _ = window.hide();
            }
            "edit_file" => {
                println!("edit_file");
            }
            "new_file" => {
                println!("new_file");
            }
            // Add more events here
            _ => {}
        })
        // 监听托盘图标发出的鼠标事件
        .on_tray_icon_event(|tray, event| {
            // 左键点击托盘图标显示窗口
            if let TrayIconEvent::Click {
                button: MouseButton::Left,
                button_state: MouseButtonState::Up,
                ..
            } = event
            {
                let app = tray.app_handle();
                if let Some(window) = app.get_webview_window("main") {
                    let _ = window.show();
                    let _ = window.set_focus();
                }
            }
        })
        .build(app);

    Ok(())
}

lib.rs 使用,注册函数暴露给前端调用

#[cfg(desktop)]
mod tray;

// 自定义函数声明
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_log::Builder::new().build())
        // 添加自定义托盘
        .setup(|app| {
            #[cfg(all(desktop))]
            {
                let handle: &tauri::AppHandle = app.handle();
                tray::create_tray(handle)?;
            }
            Ok(())
        })
        // Run the app
        // 注册 Rust 后端函数,暴露给前端调用
        .invoke_handler(tauri::generate_handler![
            greet
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

3.前端调用Rust暴露函数

<template>
  <div>
    <button class="item" @click="flashTray(true)">开启图标闪烁</button>
    <button class="item" @click="flashTray(false)">关闭图标闪烁</button>
  </div>
</template>
<script setup lang="ts">
import { TrayIcon } from "@tauri-apps/api/tray";

const flashTimer = ref<Boolean | any>(false);
const flashTray = async (bool: Boolean) => {
  let flag = true;
  if (bool) {
    TrayIcon.getById("tray").then(async (res: any) => {
      clearInterval(flashTimer.value);
      flashTimer.value = setInterval(() => {
        if (flag) {
          res.setIcon(null);
        } else {
          // res.setIcon(defaultIcon)
          // 支持把自定义图标放在默认icons文件夹,通过如下方式设置图标
          // res.setIcon('icons/msg.png')
          // 支持把自定义图标放在自定义文件夹tray,需要配置tauri.conf.json参数 "bundle": {"resources": ["tray"]}
          res.setIcon("tray/tray.png");
        }
        flag = !flag;
      }, 500);
    });
  } else {
    clearInterval(flashTimer.value);
    let tray: any = await TrayIcon.getById("tray");
    tray.setIcon("icons/icon.png");
  }
};
</script>

窗口工具栏自定义

1. 配置

配置文件开启权限 src-tauri\capabilities\default.json

 "permissions": [
    "core:window:default",
    "core:window:allow-start-dragging",
    "core:window:allow-minimize",
    "core:window:allow-maximize",
    "core:window:allow-unmaximize",
    "core:window:allow-toggle-maximize",
    "core:window:allow-show",
    "core:window:allow-set-focus",
    "core:window:allow-hide",
    "core:window:allow-unminimize",
    "core:window:allow-set-size",
    "core:window:allow-close",
  ]

关闭默认窗口事件 src-tauri\tauri.conf.json

"app": {
  "windows": [
    {
      "decorations": false,
    }
  ],
},

2. 自定义实现

前端调用

<script setup lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";

const appWindow = getCurrentWindow();
onMounted(() => {
  windowCustomize();
});
const windowCustomize = () => {
  let minimizeEle = document.getElementById("titlebar-minimize");
  minimizeEle?.addEventListener("click", () => appWindow.minimize());

  let maximizeEle = document.getElementById("titlebar-maximize");
  maximizeEle?.addEventListener("click", () => appWindow.toggleMaximize());

  let closeEle = document.getElementById("titlebar-close");
  closeEle?.addEventListener("click", () => appWindow.close());
};
</script>

<template>
  <div data-tauri-drag-region class="titlebar">
    <div class="titlebar-button" id="titlebar-minimize">
      <img src="@/assets/svg/titlebar/mdi_window-minimize.svg" alt="minimize" />
    </div>
    <div class="titlebar-button" id="titlebar-maximize">
      <img src="@/assets/svg/titlebar/mdi_window-maximize.svg" alt="maximize" />
    </div>
    <div class="titlebar-button" id="titlebar-close">
      <img src="@/assets/svg/titlebar/mdi_close.svg" alt="close" />
    </div>
  </div>
</template>

<style scoped>
.titlebar {
  height: 30px;
  background: #329ea3;
  user-select: none;
  display: flex;
  justify-content: flex-end;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
}
.titlebar-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
  user-select: none;
  -webkit-user-select: none;
}
.titlebar-button:hover {
  background: #5bbec3;
}
</style>

webview 多窗口创建

1. 配置

配置文件开启权限 src-tauri\capabilities\default.json

 "permissions": [
    "core:webview:default",
    "core:webview:allow-create-webview-window",
    "core:webview:allow-create-webview",
    "core:webview:allow-webview-close",
    "core:webview:allow-set-webview-size",
  ]

2. hooks 函数封装

import { nextTick } from "vue";
import {
  WebviewWindow,
  getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { emit, listen } from "@tauri-apps/api/event";

export interface WindowsProps {
  label: string;
  url?: string;
  title: string;
  minWidth: number;
  minHeight: number;
  width: number;
  height: number;
  closeWindLabel?: string;
  resizable: boolean;
}

export default () => {
  // 窗口事件类型
  type WindowEvent = "closed" | "minimized" | "maximized" | "resized";

  // 创建窗口
  const createWindows = async (
    args: WindowsProps = {
      label: "main",
      title: "主窗口",
      minWidth: 800,
      minHeight: 600,
      width: 800,
      height: 600,
      resizable: true,
    }
  ) => {
    if (!(await isExist(args.label))) {
      const webview = new WebviewWindow(args.label, {
        title: args.title,
        url: args.url,
        fullscreen: false,
        resizable: args.resizable,
        center: true,
        width: args.width,
        height: args.height,
        minWidth: args.minWidth,
        minHeight: args.minHeight,
        skipTaskbar: false,
        decorations: false,
        transparent: false,
        titleBarStyle: "overlay",
        hiddenTitle: true,
        visible: false,
      });

      // 窗口创建成功
      await webview.once("tauri://created", async () => {
        webview.show();
        if (args.closeWindLabel) {
          const win = await WebviewWindow.getByLabel(args.closeWindLabel);
          win?.close();
        }
      });

      // 窗口创建失败
      await webview.once("tauri://error", async (e) => {
        console.error("Window creation error:", e);
        if (args.closeWindLabel) {
          await showWindow(args.closeWindLabel);
        }
      });

      // 监听窗口事件
      setupWindowListeners(webview, args.label);
      return webview;
    } else {
      showWindow(args.label);
    }
  };

  // 设置窗口监听器
  const setupWindowListeners = (webview: WebviewWindow, label: string) => {
    // 关闭请求处理
    webview.listen("tauri://close-requested", async (e) => {
      await emit("window-event", {
        label,
        event: "closed",
        data: { timestamp: Date.now() },
      });
      console.log("label :>> ", label);
      const win = await WebviewWindow.getByLabel(label);
      win?.close();

      // const win = label ? await WebviewWindow.getByLabel(label) : await getCurrentWebviewWindow();
      // win?.close();
    });

    // 最小化事件
    webview.listen("tauri://minimize", async (e) => {
      await emit("window-event", {
        label,
        event: "minimized",
        data: { state: true },
      });
    });

    // 最大化事件
    webview.listen("tauri://maximize", async (e) => {
      await emit("window-event", {
        label,
        event: "maximized",
        data: { state: true },
      });
    });

    // 取消最大化
    webview.listen("tauri://unmaximize", async (e) => {
      await emit("window-event", {
        label,
        event: "maximized",
        data: { state: false },
      });
    });
  };

  // 窗口间通信 - 发送消息
  const sendWindowMessage = async (
    targetLabel: string,
    event: string,
    payload: any
  ) => {
    const targetWindow = await WebviewWindow.getByLabel(targetLabel);
    if (targetWindow) {
      targetWindow.emit(event, payload);
    }
  };

  // 监听窗口消息
  const onWindowMessage = (event: string, callback: (payload: any) => void) => {
    return listen(event, ({ payload }) => callback(payload));
  };

  // 窗口控制方法
  const windowControls = {
    minimize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      await win?.minimize();
    },
    maximize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      await win?.maximize();
    },
    close: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      win?.close();
    },
    toggleMaximize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      const isMaximized = await win?.isMaximized();
      isMaximized ? await win?.unmaximize() : await win?.maximize();
    },
  };
  //  获取当前窗口
  const nowWindow = async () => {
    const win = await getCurrentWebviewWindow();
    return win;
  };
  // 关闭窗口
  const closeWindow = async (label?: string) => {
    if (label) {
      const win = await WebviewWindow.getByLabel(label);
      win?.close();
    } else {
      const win = await getCurrentWebviewWindow();
      win?.close();
    }
  };
  // 显示窗口
  const showWindow = async (label: string, isCreated: boolean = false) => {
    const isExistsWinds = await WebviewWindow.getByLabel(label);
    if (isExistsWinds) {
      nextTick().then(async () => {
        // 检查是否是隐藏
        const hidden = await isExistsWinds.isVisible();
        if (!hidden) {
          await isExistsWinds.show();
        }
        // 如果窗口已存在,首先检查是否最小化了
        const minimized = await isExistsWinds.isMinimized();
        if (minimized) {
          // 如果已最小化,恢复窗口
          await isExistsWinds.unminimize();
        }
        // 如果窗口已存在,则给它焦点,使其在最前面显示
        await isExistsWinds.setFocus();
      });
    } else {
      if (!isCreated) {
        return createWindows();
      }
    }
  };
  //窗口是否存在
  const isExist = async (label: string) => {
    const isExistsWinds = await WebviewWindow.getByLabel(label);
    if (isExistsWinds) {
      return true;
    } else {
      return false;
    }
  };

  return {
    createWindows,
    sendWindowMessage,
    onWindowMessage,
    ...windowControls,
    nowWindow,
    showWindow,
    isExist,
    closeWindow,
  };
};

3. 调用

window 父级

<template>
  <div class="window-controls">
    <n-button @click="minimizeWindow">最小化</n-button>
    <n-button @click="toggleMaximizeWindow">{{
      isMaximized ? "恢复" : "最大化"
    }}</n-button>
    <n-button @click="maximizeWindow">最大化</n-button>
    <n-button @click="closeWindow">关闭</n-button>
    <n-button @click="openChildWindow">打开子窗口</n-button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import useWindowManager from "@/hooks/windowManager";
const {
  createWindows,
  minimize,
  maximize,
  toggleMaximize,
  close,
  onWindowMessage,
} = useWindowManager();

const isMaximized = ref(false);

const openChildWindow = () => {
  createWindows({
    label: "child",
    title: "子窗口",
    url: "/child",
    minWidth: 400,
    minHeight: 300,
    width: 600,
    height: 400,
    resizable: true,
  });
};
// 监听子窗口消息
onWindowMessage("child-message", (payload) => {
  console.log("Received from child:", payload);
});

// 窗口控制方法
const minimizeWindow = async () => {
  await minimize("child"); // 最小化窗口
};

const maximizeWindow = async () => {
  await maximize("child"); // 最大化窗口
};

const toggleMaximizeWindow = async () => {
  await toggleMaximize("child"); // 切换最大化/还原
};

const closeWindow = async () => {
  await close("child"); // 关闭窗口
};
</script>

childView.vue 子组件

<template>
  <div class="child">
    <h1>Child Window</h1>
    <n-button @click="sendToMain">Send Message to Main</n-button>
    <n-button @click="close">Close</n-button>
  </div>
</template>

<script setup lang="ts">
import useWindowManager from "@/hooks/windowManager";

const { sendWindowMessage, close } = useWindowManager();
// const {close} = windowControls
// 向主窗口发送消息
const sendToMain = () => {
  sendWindowMessage("main", "child-message", {
    timestamp: Date.now(),
    content: "Hello from child!",
  });
};
</script>

系统通知 notification

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add notification

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    "notification:default",
    "notification:allow-get-active",
    "notification:allow-is-permission-granted"
  ]
}

3.封装hooks

src\hooks\notification.ts

import {
  isPermissionGranted,
  requestPermission,
  sendNotification,
} from "@tauri-apps/plugin-notification";

export default () => {
  const checkPermission = async () => {
    const permission = await isPermissionGranted();
    if (!permission) {
      const permission = await requestPermission();
      return permission === "granted";
    } else {
      return true;
    }
  };

  const sendMessage = async (title: string, message: string) => {
    const permission = await checkPermission();
    if (permission) {
      await sendNotification({
        title,
        body: message,
        // 这里演示,你可以作为参数传入 win11 测试没效果
        attachments: [
          {
            id: "image-1",
            url: "F:\\tv_task\\public\\tauri.png",
          },
        ],
      });
    }
  };

  return { sendMessage };
};

4.调用示例

<template>
  <div>
    <n-button @click="sendNot">notification 通知</n-button>
  </div>
</template>

<script setup lang="ts">
import useNotification from "@/hooks/notification";
const { sendMessage } = useNotification();
const sendNot = async () => {
  await sendMessage("提示", "您当前有代办的任务需要处理!");
};
</script>

日志

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add log

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": ["log:default"]
}

3.封装hooks

src\hooks\log.ts

import {
  trace,
  info,
  debug,
  error,
  attachConsole,
} from "@tauri-apps/plugin-log";

// 启用 TargetKind::Webview 后,这个函数将把日志打印到浏览器控制台
const detach = await attachConsole();

export default () => {
  // 将浏览器控制台与日志流分离
  detach();
  return {
    debug,
    trace,
    info,
    error,
  };
};

4.调用示例

<template>
  <div>
    <h1>控制台效果</h1>
    <div class="console">
      <div
        class="console-line"
        v-for="(line, index) in consoleLines"
        :key="index"
        :class="{
          'animate__animated animate__fadeIn':
            index === consoleLines.length - 1,
        }"
      >
        {{ line }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import TauriLog from "@/hooks/log";
import { ref } from "vue";

const { info } = TauriLog();
info("我来了");
const consoleLines = ref([
  "Welcome to the console!",
  "This is a cool console interface.",
  "You can type commands here.",
  "Press Enter to execute.",
]);
</script>

程序启动监听

hooks 函数封装

src\hooks\start.ts

import { invoke } from "@tauri-apps/api/core";

function sleep(seconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

async function setup() {
  console.log("前端应用启动..");
  await sleep(3);
  console.log("前端应用启动完成");
  // 调用后端应用
  invoke("set_complete", { task: "frontend" });
}

export default () => {
  // Effectively a JavaScript main function
  window.addEventListener("DOMContentLoaded", () => {
    setup();
  });
};

调用日志打印

src\main.ts

import start from "@/hooks/start";
start();

Http 封装

axios 请求,会在打包后存在跨域问题,所以使用 tauri 插件,进行http封装

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add http

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    {
      "identifier": "http:default",
      "allow": [
        {
          "url": "http://**"
        },
        {
          "url": "https://**"
        },
        {
          "url": "http://*:*"
        },
        {
          "url": "https://*:*"
        }
      ]
    }
  ]
}

3.封装hooks

src\utils\exception.ts

export enum ErrorType {
  Network = "NETWORK_ERROR",
  Authentication = "AUTH_ERROR",
  Validation = "VALIDATION_ERROR",
  Server = "SERVER_ERROR",
  Client = "CLIENT_ERROR",
  Unknown = "UNKNOWN_ERROR",
}

export interface ErrorDetails {
  type: ErrorType;
  code?: number;
  details?: Record<string, any>;
}

export class AppException extends Error {
  public readonly type: ErrorType;
  public readonly code?: number;
  public readonly details?: Record<string, any>;

  constructor(message: string, errorDetails?: Partial<ErrorDetails>) {
    super(message);
    this.name = "AppException";
    this.type = errorDetails?.type || ErrorType.Unknown;
    this.code = errorDetails?.code;
    this.details = errorDetails?.details;

    // Show error message to user if window.$message is available
    if (window.$message) {
      window.$message.error(message);
    }
  }

  public toJSON() {
    return {
      name: this.name,
      message: this.message,
      type: this.type,
      code: this.code,
      details: this.details,
    };
  }
}

src\utils\http.ts

import { fetch } from "@tauri-apps/plugin-http";
import { AppException, ErrorType } from "./exception";

/**
 * @description 请求参数
 * @property {"GET"|"POST"|"PUT"|"DELETE"} method 请求方法
 * @property {Record<string, string>} [headers] 请求头
 * @property {Record<string, any>} [query] 请求参数
 * @property {any} [body] 请求体
 * @property {boolean} [isBlob] 是否为Blob
 * @property {boolean} [noRetry] 是否禁用重试
 * @return HttpParams
 */
export type HttpParams = {
  method: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  query?: Record<string, any>;
  body?: any;
  isBlob?: boolean;
  retry?: RetryOptions; // 新增重试选项
  noRetry?: boolean; // 新增禁用重试选项
};

/**
 * @description 重试选项
 */
export type RetryOptions = {
  retries?: number;
  retryDelay?: (attempt: number) => number;
  retryOn?: number[];
};

/**
 * @description 自定义错误类,用于标识需要重试的 HTTP 错误
 */
class FetchRetryError extends Error {
  status: number;
  type: ErrorType;
  constructor(message: string, status: number) {
    super(message);
    this.status = status;
    this.name = "FetchRetryError";
    this.type = status >= 500 ? ErrorType.Server : ErrorType.Network;
  }
}

/**
 * @description 等待指定的毫秒数
 * @param {number} ms 毫秒数
 * @returns {Promise<void>}
 */
function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * @description 判断是否应进行下一次重试
 * @returns {boolean} 是否继续重试
 */
function shouldRetry(
  attempt: number,
  maxRetries: number,
  abort?: AbortController
): boolean {
  return attempt + 1 < maxRetries && !abort?.signal.aborted;
}

/**
 * @description HTTP 请求实现
 * @template T
 * @param {string} url 请求地址
 * @param {HttpParams} options 请求参数
 * @param {boolean} [fullResponse=false] 是否返回完整响应
 * @param {AbortController} abort 中断器
 * @returns {Promise<T | { data: T; resp: Response }>} 请求结果
 */
async function Http<T = any>(
  url: string,
  options: HttpParams,
  fullResponse: boolean = false,
  abort?: AbortController
): Promise<{ data: T; resp: Response } | T> {
  // 打印请求信息
  console.log(`🚀 发起请求 → ${options.method} ${url}`, {
    body: options.body,
    query: options.query,
  });

  // 默认重试配置
  const defaultRetryOptions: RetryOptions = {
    retries: options.noRetry ? 0 : 3, // 如果设置了noRetry,则不进行重试
    retryDelay: (attempt) => Math.pow(2, attempt) * 1000, // 指数退避策略
    retryOn: [500, 502, 503, 504],
  };

  // 合并默认重试配置与用户传入的重试配置
  const retryOptions: RetryOptions = {
    ...defaultRetryOptions,
    ...options.retry,
  };

  const { retries = 3, retryDelay, retryOn } = retryOptions;

  // 获取token和指纹
  const token = localStorage.getItem("TOKEN");
  //const fingerprint = await getEnhancedFingerprint()

  // 构建请求头
  const httpHeaders = new Headers(options.headers || {});

  // 设置Content-Type
  if (!httpHeaders.has("Content-Type") && !(options.body instanceof FormData)) {
    httpHeaders.set("Content-Type", "application/json");
  }

  // 设置Authorization
  if (token) {
    httpHeaders.set("Authorization", `Bearer ${token}`);
  }

  // 设置浏览器指纹
  //if (fingerprint) {
  //httpHeaders.set('X-Device-Fingerprint', fingerprint)
  //}

  // 构建 fetch 请求选项
  const fetchOptions: RequestInit = {
    method: options.method,
    headers: httpHeaders,
    signal: abort?.signal,
  };

  // 获取代理设置
  // const proxySettings = JSON.parse(localStorage.getItem('proxySettings') || '{}')
  // 如果设置了代理,添加代理配置 (BETA)
  // if (proxySettings.type && proxySettings.ip && proxySettings.port) {
  //   // 使用 Rust 后端的代理客户端
  //   fetchOptions.proxy = {
  //     url: `${proxySettings.type}://${proxySettings.ip}:${proxySettings.port}`
  //   }
  // }

  // 判断是否需要添加请求体
  if (options.body) {
    if (
      !(
        options.body instanceof FormData ||
        options.body instanceof URLSearchParams
      )
    ) {
      fetchOptions.body = JSON.stringify(options.body);
    } else {
      fetchOptions.body = options.body; // 如果是 FormData 或 URLSearchParams 直接使用
    }
  }

  // 添加查询参数
  if (options.query) {
    const queryString = new URLSearchParams(options.query).toString();
    url += `?${queryString}`;
  }

  // 拼接 API 基础路径
  //url = `${import.meta.env.VITE_SERVICE_URL}${url}`

  // 定义重试函数
  async function attemptFetch(
    currentAttempt: number
  ): Promise<{ data: T; resp: Response } | T> {
    try {
      const response = await fetch(url, fetchOptions);
      // 若响应不 OK 并且状态码属于需重试列表,则抛出 FetchRetryError
      if (!response.ok) {
        const errorType = getErrorType(response.status);
        if (!retryOn || retryOn.includes(response.status)) {
          throw new FetchRetryError(
            `HTTP error! status: ${response.status}`,
            response.status
          );
        }
        // 如果是非重试状态码,则抛出带有适当错误类型的 AppException
        throw new AppException(`HTTP error! status: ${response.status}`, {
          type: errorType,
          code: response.status,
          details: { url, method: options.method },
        });
      }

      // 解析响应数据
      const responseData = options.isBlob
        ? await response.arrayBuffer()
        : await response.json();

      // 打印响应结果
      console.log(`✅ 请求成功 → ${options.method} ${url}`, {
        status: response.status,
        data: responseData,
      });

      // 若有success === false,需要重试
      if (responseData && responseData.success === false) {
        const errorMessage = responseData.errMsg || "服务器返回错误";
        window.$message?.error?.(errorMessage);
        throw new AppException(errorMessage, {
          type: ErrorType.Server,
          code: response.status,
          details: responseData,
        });
      }

      // 若请求成功且没有业务错误
      if (fullResponse) {
        return { data: responseData, resp: response };
      }
      return responseData;
    } catch (error) {
      console.error(`尝试 ${currentAttempt + 1} 失败的 →`, error);

      // 检查是否仍需重试
      if (!shouldRetry(currentAttempt, retries, abort)) {
        console.error(
          `Max retries reached or aborted. Request failed → ${url}`
        );
        if (error instanceof FetchRetryError) {
          window.$message?.error?.(error.message || "网络请求失败");
          throw new AppException(error.message, {
            type: error.type,
            code: error.status,
            details: { url, attempts: currentAttempt + 1 },
          });
        }
        if (error instanceof AppException) {
          window.$message?.error?.(error.message || "请求出错");
          throw error;
        }
        const errorMessage = String(error) || "未知错误";
        window.$message?.error?.(errorMessage);
        throw new AppException(errorMessage, {
          type: ErrorType.Unknown,
          details: { url, attempts: currentAttempt + 1 },
        });
      }

      // 若需继续重试
      const delayMs = retryDelay ? retryDelay(currentAttempt) : 1000;
      console.warn(
        `Retrying request → ${url} (next attempt: ${currentAttempt + 2}, waiting ${delayMs}ms)`
      );
      await wait(delayMs);
      return attemptFetch(currentAttempt + 1);
    }
  }

  // 辅助函数:根据HTTP状态码确定错误类型
  function getErrorType(status: number): ErrorType {
    if (status >= 500) return ErrorType.Server;
    if (status === 401 || status === 403) return ErrorType.Authentication;
    if (status === 400 || status === 422) return ErrorType.Validation;
    if (status >= 400) return ErrorType.Client;
    return ErrorType.Network;
  }

  // 第一次执行,attempt=0
  return attemptFetch(0);
}

export default Http;

src\utils\request.ts

import Http, { HttpParams } from "./http.ts";
import { ServiceResponse } from "@/enums/types.ts";
const { VITE_SERVICE_URL } = import.meta.env;
const prefix = VITE_SERVICE_URL;
function getToken() {
  let tempToken = "";
  return {
    get() {
      if (tempToken) return tempToken;
      const token = localStorage.getItem("TOKEN");
      if (token) {
        tempToken = token;
      }
      return tempToken;
    },
    clear() {
      tempToken = "";
    },
  };
}

export const computedToken = getToken();

// fetch 请求响应拦截器
const responseInterceptor = async <T>(
  url: string,
  method: "GET" | "POST" | "PUT" | "DELETE",
  query: any,
  body: any,
  abort?: AbortController
): Promise<T> => {
  let httpParams: HttpParams = {
    method,
  };

  if (method === "GET") {
    httpParams = {
      ...httpParams,
      query,
    };
  } else {
    url = `${prefix}${url}?${new URLSearchParams(query).toString()}`;
    httpParams = {
      ...httpParams,
      body,
    };
  }

  try {
    const data = await Http(url, httpParams, true, abort);
    const serviceData = (await data.data) as ServiceResponse;
    //检查服务端返回是否成功,并且中断请求
    if (!serviceData.success) {
      window.$message.error(serviceData.errMsg);
      return Promise.reject(`http error: ${serviceData.errMsg}`);
    }
    return Promise.resolve(serviceData.result);
  } catch (err) {
    return Promise.reject(`http error: ${err}`);
  }
};

const get = async <T>(
  url: string,
  query: T,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "GET", query, {}, abort);
};

const post = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "POST", {}, params, abort);
};

const put = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "PUT", {}, params, abort);
};

const del = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "DELETE", {}, params, abort);
};

export default {
  get,
  post,
  put,
  delete: del,
};

src\api\manage.ts

import request from "@/utils/request";
export const getAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.get<T>(url, params, abort);
export const postAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.post<T>(url, params, abort);
export const putAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.put<T>(url, params, abort);
export const deleteAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.delete<T>(url, params, abort);

4.调用示例

<template>
  <div>
    <n-button @click="postTest">测试POST</n-button>
  </div>
</template>
<script setup lang="ts">
import { postAction } from "@/api/manage";

const postTest = () => {
  let url = `/sys/login`;
  postAction(url, {
    username: "admin",
    password: "tick20140513",
  }).then((res) => {
    text.value = res.token;
  });
};
</script>

从模板渲染到响应式驱动:前端崛起的技术演进之路

2025年12月11日 17:28

引言:界面是如何“动”起来的?

不论是用户看到的哪个页面,都不应该是一成不变的静态 HTML。

待办事项的增删、商品库存的实时变化,还是聊天消息的即时推送,都让页面指向了一个必需功能————界面必须要随着数据的变化而自动更新

而围绕着这一核心诉求,出现了两条主流路径:

  • 后端动态生成HTML(传统的 MVC 模式): 数据在服务端组装成完整页面,再一次性返还给浏览器。
  • 前端接管界面更新(现代响应式范式): 后端只提供原始数据(如JSON API),而前端通过响应式系统来驱动视图自动同步。

而在这两条路背后,反映着前后端职责划分,同时也催生了以VueReact为代表的前端技术框架革命。

时代一:纯后端渲染 —— MVC 模式主导

假如有一个需求如:写一个简单的 HTTP 服务器,当用户访问 //users 路径时,返回一个包含用户列表的 HTML 页面,其他路径则返回 404 错误。

Node.js早期,如果我想实现这个需求,那么后端渲染将是不二之选。

代码示例:早期 Node.js 实现简单用户列表页

首先就是引入 Node.js 内置模块httpurl,而使用的方法则是Node.js最早的CommonJS 模块系统中的 require()来“导入”

const http = require("http"); // commonjs 
const url = require("url");   // url
  • http 模块:用于创建 HTTP 服务器(处理请求和响应)。
  • url 模块:用于解析浏览器发来的 URL 字符串(如 /users?id=1)。

然后再准备一些模拟数据

const users = [
    { id: 1, name: '张三', email: '123@qq.com' },
    { id: 2, name: '李四', email: '123456@qq.com' },
    { id: 3, name: '王五', email: '121@qq.com' }
]

接下来就要创建生成 HTML 页面的函数了

先使用.map()方法来动态生成表格行,对每个用户生成一行 HTML 表格,用反引号来插入变量,使用 .join('')来拼接所有行,最后返还一个完整的 HTML 文档。

function generateUsersHtml(users) {
    const userRows = users.map(user => `
        <tr>
            <td>${user.id}</td>
            <td>${user.name}</td>
            <td>${user.email}</td>
        </tr>
    `).join('');
    
    return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>User List</title>
        <style>
            table { width: 100%; border-collapse: collapse; margin-top: 20px; }
            th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
            th { background-color: #f4f4f4; }
        </style>
    </head>
    <body>
        <h1>Users</h1>
        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>Name</th>
                    <th>Email</th>
                </tr>
            </thead>
            <tbody>
                ${userRows}
            </tbody>
        </table>
    </body>
    </html>
    `;
}

最后也是最重要的就是创建 HTTP 服务器了。

const server = http.createServer((req, res) => {
    const parsedUrl = url.parse(req.url, true);
    
    if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html;charset=utf-8');
        const html = generateUsersHtml(users);
        res.end(html);
    } else {
        res.statusCode = 404;
        res.setHeader('Content-Type', "text/plain");
        res.end('Not Found');
    }
});

关键概念解释:

  • req(Request):用户的请求对象,包含 URL、方法、头信息等。
  • res(Response):你要返回给用户的内容,通过它设置状态码、头、正文。

解析 URL

// url.parse(pathname, query)
const parsedUrl = url.parse(req.url, true);
  • req.url即用户请求的 路径+参数部分

  • url.parse()将 URL 字符串“拆解”成结构化的对象,从而方便读取,其中:

    • pathname: 路径部分(如 /users
    • query: 查询参数(如 ?id=1就变成了{ id: '1' }),这里的true是用于判断你是否需要自动解析URL参数部分并转换为对象(通常为 true)

路由判断(简单路由)

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users')

如果路径是根路径 / 或 /users,就显示用户列表,否则返回 404。

成功响应(200)

res.statusCode = 200; // 设置状态码为 200
res.setHeader('Content-Type', 'text/html;charset=utf-8');
const html = generateUsersHtml(users);
res.end(html);

通过.setHeader告诉浏览器:“我返回的是 HTML,用 UTF-8 编码”。

然后利用函数 generateUsersHtml(users),传入用户数据,最后调用res.end()生成 HTML 并发送。

错误响应(404 Not Found)

res.statusCode = 404;
res.setHeader('Content-Type', "text/plain");
res.end('Not Found');

状态码 404 表示“页面不存在”,如果产生错误则返还Not Found

注意:url.parse() 是旧 API,现代开发基本弃用


Node.js HTTP服务器启动的最后一步

server.listen(1234, () => {
    console.log('Server is running on port 1234')
})

让服务器监听 1234 端口(任意修改)。此时就可以在浏览器访问:http://localhost:1234http://localhost:1234/users,而访问其他路径(如 /about)会显示 “Not Found”。

效果图:

image.png

核心缺点 + 时代局限性:

  1. 前后端高度耦合,协作效率低下

HTML 结构、样式、JavaScript 逻辑全部硬编码在一个函数里,如果要修改表格样式等操作,就得修改这个函数,并且无法复用。

而这也几乎将前后端工程师捆绑起来了:

  • 前端工程师无法独立开发或调试 UI,必须依赖后端接口和模板
  • 后端工程师被迫处理本应属于前端范畴的展示逻辑

阻碍了团队协作,让前后端工程师的开发体验都极差。

  1. 用户体验受限,交互能力弱

页面完全由服务端生成,每次跳转或操作都需整页刷新,无法实现局部更新、动态加载、表单实时校验等现代 Web 交互,即使只是点击一个按钮,也要重新请求整个 HTML 文档。

时代二:转折点 AJAX 与前后端分离的诞生

在 2005 年之前,Web 应用基本是:用户点击 → 浏览器发请求 → 后端生成完整 HTML → 返回 → 整页刷新 ,导致用户每次交互都像“重新打开一个页面”,体验感大打折扣。

转折事件:Google Maps(2005)首次大规模使用 XMLHttpRequest(XHR)

  • 地图拖拽时不刷新页面
  • 动态加载新区域数据
  • 用户体验飞跃 → 行业震动

这就是 AJAX(Asynchronous JavaScript and XML) 范式的诞生—— “让网页像桌面应用一样流畅”

范式对比再深化

维度 后端渲染(传统) 前后端分离(AJAX 时代)
职责划分 后端一家独大 前端负责 UI/交互,后端负责数据/API
开发模式 全栈一人干 前后端并行开发
部署方式 服务端部署 HTML 前端静态资源(CDN),后端 API(独立服务)
用户体验 卡顿、白屏、跳转 流畅、局部更新、SPA雏形
技术栈 PHP/Java/Node + 模板引擎 HTML/CSS/JS + REST API

代码示例:

已经配置好的环境

在后端 backend 文件夹中包含一个存储用户数据的db.json文件:

{
    "users": [
        {
            "id": 1,
            "name": "张三",
            "email": "123@qq.com"
        },
        {
            "id": 2,
            "name": "李四",
            "email": "1232@qq.com"
        },
        {
            "id": 3,
            "name": "王五",
            "email": "121@qq.com"
        }
    ]
}

注:json-server 会把 JSON 的顶层 key(如 "users")自动映射为 RESTful 路由

package.json 中的脚本

{
  "scripts": {
    "dev": "json-server --watch db.json"
  }
}

就使得运行 npm run dev 时,json-server 会监听 backend/db.json 文件变化(--watch),并且启动一个 HTTP 服务器,默认端口 3000。(别忘了启动后端服务哦~~)

前端代码(重头戏):

基础页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>User List</title>
    <style>
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
        th { background-color: #f4f4f4; }
    </style>
</head>
<body>
    <h1>Users</h1>
    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Email</th>
            </tr>
        </thead>
        <tbody>
        </tbody>
    </table>
</body>
</html>

<script>中的内部逻辑

<script>
    // DOM 编程
    fetch('http://localhost:3000/users') // 发出请求
        .then(res => res.json()) // 将 JSON 字符串转为 JS 对象数组
        .then(data => {
            const tbody = document.querySelector('tbody');
            tbody.innerHTML = data.map(user => `
                <tr>
                    <td>${user.id}</td>
                    <td>${user.name}</td>
                    <td>${user.email}</td>
                </tr>
            `).join('');
        })
</script>

.then(data => ...)中的 data 就是上一步 res.json() 解析的结果,并且通过 .map()生成字符串数组,再用.join('') 拼接字符串,而通过 tbody.innerHTML 让浏览器重新解析并渲染表格。

tongyi-mermaid-2025-12-11-165610.png

这代表了“纯手工”前后端分离的起点

  • 前端不再依赖后端吐 HTML
  • 数据通过 JSON API 获取
  • 视图由 JavaScript 动态生成

image.png

但这种方法并非完美,仍然存在痛点:手动操作 DOM 繁琐且易错

举个“胶水代码灾难”的例子:

// 用户点击“删除”
button.onclick = () => {
  fetch(`/api/users/${id}`, { method: 'DELETE' })
    .then(() => {
      // 从列表中移除元素
      li.remove();
      // 更新
      countSpan.textContent = --totalCount;
      // 如果列表空了,显示“暂无数据”
      if (totalCount === 0) emptyMsg.style.display = 'block';
      // 可能还要发埋点、更新缓存、通知其他组件...
    });
};

视图更新逻辑散落在各处,难以维护,极易出错,删除数据要:

  • 找到 <tr> 并删除
  • 更新
  • 找到空状态提示并显示
  • 可能还要:更新侧边栏统计、刷新分页、清除搜索高亮……

每次 UI 变化都要手动找一堆 DOM 节点去修改,并且难以复用。

这时期的前端程序员内心都憋着一句话:我不想再写 document.getElementById 了!

AJAX 让网页活了过来,但也让前端开发者陷入了新的地狱(DOM)——直到框架降临

时代三:革命!响应式数据驱动界面的崛起

核心思想:

“你只管改数据,界面自动更新。”

关键技术:ref 与响应式系统

  • ref() 将普通值包装成响应式对象
  • 模板中通过 {{ }}v-for 声明式绑定数据
  • 数据变化会自动触发视图更新(无需手动 DOM 操作)

响应式(以 Vue 为例)

<template>
  <table>
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Email</th>
        </tr>
    </thead>
    <tbody>
      <!-- 遍历数据渲染到界面 -->
      <tr v-for="user in users" :key="user.id">
        <td>{{ user.id }}</td>
        <td>{{ user.name }}</td>
        <td>{{ user.email }}</td>
      </tr>
    </tbody>
  </table>
</template>
  • v-for:声明遍历 users 数组
  • {{ }} :显示 user 的某个属性
  • :key:帮助 Vue 高效追踪列表变化(性能优化)

但是关键在于:我没有写任何 DOM 操作代码!

只是在“描述 UI 应该是什么样子”,而不是“怎么去修改 DOM”。

<script setup>
import { 
  ref,
  onMounted // 挂载之后的生命周期
} from 'vue'; // 响应式api(将数据包装成响应式对象)

// 用 ref() 将普通数组包装成一个 响应式引用对象
const users = ref([]);

// onMounted:确保 DOM 已创建后再发起请求(避免操作不存在的元素)
onMounted(() => {
  console.log('页面挂载完成');
  fetch('http://localhost:3000/users')
    .then(res => res.json())
    .then(data => {
      users.value = data; // 只修改数据
    })
})

// 定时器添加数据
setTimeout(() => {
  users.value.push({
    id: '4',
    name: '钱六',
    email: '12313@qq.com'
  })
}, 3000)
</script>

没有 innerHTML没有 createElement没有 getElementById

并且所有 UI 更新都是数据变化的自然结果,无需人工干预!

2025-12-11.gif

整个历史进程:

阶段 开发模式 核心关注点 开发体验
后端渲染 MVC 数据 → 模板 → HTML 前端边缘化
前后端分离 AJAX + DOM 手动同步数据与视图 繁琐、易错
响应式框架 数据驱动 聚焦业务逻辑 高效、声明式、愉悦

这段短短的 Vue 代码,浓缩了前端开发十年的演进:

  • 从“操作 DOM”到“描述 UI”
  • 从“分散状态”到“单一数据源”
  • 从“易错胶水”到“自动同步”

它让前端开发者终于认识到一个新的自己:前端不再只是“切图仔”,而是复杂应用的架构者与体验设计师。 这,就是 响应式数据驱动界面 的革命性所在。

zero-admin后台管理模板

2025年12月11日 17:24

zero-admin 管理后台模板

zero-admin 是一个后台前端解决方案,它基于 vue3 和 ant-design-vue 实现。它使用了最新的前端技术栈【vue3+vue-router+typescript+axios+ant-design-vue+pinia+mockjs+plopjs+vite+Vitest】实现了动态路由、权限验证;自定义 Vue 指令封装;规范项目代码风格;项目内置脚手架解决文件创建混乱,相似业务模块需要频繁拷贝代码或文件问题;Echarts 图形库进行封装;axios 请求拦截封装,请求 api 统一管理;通过 mockjs 模拟数据;对生产环境构建进行打包优化,实现了打包 gzip 压缩、代码混淆,去除 console 打印,打包体积分析等;提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型

image.png

image.png

image.png

推荐 VsCode 编辑器插件

VSCode + Volar (并禁用 Vetur 扩展插件) + TypeScript Vue Plugin (Volar).

TS 中.vue导入的类型支持

默认情况下,TypeScript 无法处理“.vue”导入的类型信息,因此我们将“tsc”CLI 替换为“vue-tsc”进行类型检查。在编辑中,我们需要 TypeScript Vue Plugin (Volar) 以使 TypeScript 语言服务了解“.vue”类型。

如果你觉得独立的 TypeScript 插件不够快,Volar 还实现了一个 Take Over Mode that 这更有表现力。您可以通过以下步骤启用它:

  1. Disable the built-in TypeScript Extension
    1. Run Extensions: Show Built-in Extensions from VSCode's command palette
    2. Find TypeScript and JavaScript Language Features, right click and select Disable (Workspace)
  2. Reload the VSCode window by running Developer: Reload Window from the command palette.

项目获取

git clone gitee.com/zmmlet/zero…

自定义 Vite 配置

Vite 配置参考.

项目依赖安装

pnpm install

开发环境运行(编译和热重新加载)

pnpm dev

打包部署运行(生产打包添加类型检查、编译)

pnpm build

运行单元测试 Vitest

pnpm test:unit

语法规则和代码风格检查 ESLint

pnpm lint

功能列表

  • 项目创建
  • 配置项目代码风格 .prettierrc.json
  • 配置.vscode setting.json 文件,配置保存格式化
  • 添加 SCSS 到项目进行 CSS 预处理
  • 配置 vscode 别名跳转规则
  • 安装 ant design vue 并配置自动加载
  • 配置 less 预处理,并自定义 ant Design Vue UI 主题
  • 解决 vite 首屏加载缓慢问题
  • pinia 数据持久化
  • 解决 pinia 使用报错问题
  • Layout 布局
  • Axios 封装
  • 菜单图标动态绑定
  • Vitest 单元测试
  • 集成打印插件
  • import.meta.glob 批量导入文件夹下文件
  • 配置 .env
  • 自定义按钮权限指令
  • 动态路由
  • 路由权限
  • 按钮权限(目前和登录账号有关,和具体页面无关)
  • Echarts 集成
  • 路由懒加载
  • 项目打包优化
    • 打包 gzip 压缩
    • 代码混淆
    • 去除生产环境 console
    • 打包体积分析插件
    • 代码拆包,将静态资源分类
    • 传统浏览器兼容性支持
    • CDN 内容分发网络(Content Delivery Network)
  • 项目集成自定义 cli 解决项目重复复制代码问题
  • 集成 mockjs
  • 读取 makdown 文档,编写组件说明文档
  • maptalks + threejs demo 示例
  • 使用 postcss-pxtorem、autoprefixer 插件 px 自动转换为 rem 和自动添加浏览器兼容前缀
  • Monorepo
  • 基于 sh 脚本对项目进行一键部署
  • 图形编辑器
    • 流程图
  • 国际化
  • CI/CD
  • 自动化部署
  • 使用 commitizen 规范 git 提交,存在 plopfile.js 和 commitlint 提交规范 导入模式问题"type": "module"冲突问题,导致目前 commitizen 规范 git 提交验证暂时不可用
  • husky

项目创建

  1. 项目创建命令 pnpm create vite
  2. 选择对应初始配置项
Progress: resolved 1, reused 1, downloaded 0, added 1, done
√ Project name: ... zero-admin
√ Select a framework: » Vue
√ Select a variant: » Customize with create-vue ↗
Packages: +1

Vue.js - The Progressive JavaScript Framework

√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... No / Yes
√ Add Prettier for code formatting? ... No / Yes

Scaffolding project in D:\learningSpace\code\vue-project\zero-admin...

Done. Now run:

cd zero-admin
pnpm install
pnpm lint
pnpm dev

初始项目依赖安装

  1. pnpm add ant-design-vue --save
  2. pnpm add unplugin-vue-components -D
  3. pnpm add axios
  4. pnpm add sass-loader@7.2.0 sass@1.22.10 -D
  5. pnpm add less -D

配置项目代码风格 .prettierrc.json

{
  "stylelintIntegration": true,
  "eslintIntegration": true,
  "printWidth": 80, //单行长度
  "tabWidth": 2, //缩进长度
  "useTabs": false, //使用空格代替tab缩进
  "semi": true, //句末使用分号
  "singleQuote": false, //使用单引号
  "endOfLine": "auto"
}

配置保存(Ctrl + s)自动格式化代码

在项目中创建.vscode 文件夹中创建 setting.json 文件

{
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  },
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  } // 默认格式化工具选择prettier
}

添加 SCSS 到项目进行 CSS 预处理

  1. pnpm add sass-loader@7.2.0 sass@1.22.10 -D

  2. 新建styles/scss 文件夹,新建 index.scss文件

  3. vite.config.ts 文件中配置

css: {
  preprocessorOptions: {
    // 配置 scss 预处理
    scss: {
      additionalData: '@import "@/style/scss/index.scss";',
    },
  },
},

项目根目录新建 jsconfig.json 文件

配置 vscode 别名跳转规则

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "baseUrl": ".",
    "jsx": "react",
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@setting/*": ["./src/setting/*"],
      "@views/*": ["./src/views/*"],
      "@assets/*": ["./src/assets/*"],
      "@config/*": ["./src/config/*"],
      "@api/*": ["./src/api/*"],
      "@utils/*": ["./src/utils/*"],
      "@styles/*": ["./src/styles/*"],
      "@store/*": ["./src/store/*"]
    }
  },
  "exclude": ["node_modules", "dist"]
}

vite.config.ts 文件中配置对应文件夹别名

resolve: {
  alias: {
    "@": fileURLToPath(new URL("./src", import.meta.url)),
    "@comp": path.resolve(__dirname, "./src/components"),
  },
  extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
},

安装 ant design vue 并配置自动加载

安装 UI 和自动加载插件 pnpm add ant-design-vue --save pnpm add unplugin-vue-components -D 在 vite.config.ts 引入配置

// 引入 ant design vue 按需加载
import Components from "unplugin-vue-components/vite";
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
  // 插件
  plugins: [
    vue(),
    // ant design vue 按需加载
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: "less" })],
    }),
  ],
});

配置 less 预处理,并自定义 ant Design Vue UI 主题

安装 pnpm add less -D 在 vite.config.ts 引入配置

export default defineConfig({
  // 插件
  plugins: [
    vue(),
    // ant design vue 按需加载
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: "less" })],
    }),
  ],

  css: {
    preprocessorOptions: {
      // 自定义 ant desing vue 主题样式
      less: {
        modifyVars: {
          "@primary-color": "red",
          "@border-radius-base": "0px", // 组件/浮层圆角
        },
        javascriptEnabled: true,
      },
    },
  },
});

pinia 数据持久化

pnpm add pinia-plugin-persist --save

解决 pinia 使用报错问题

使用

import { userStore } from "@/stores/modules/user";
const usersto = userStore();
console.log("store :>> ", usersto);

报错 转存失败,建议直接上传图片文件

解决方法

import store from "@/stores/index";
import { userStore } from "@/stores/modules/user";
const usersto = userStore(store);
console.log("store :>> ", usersto);

Layout 布局

Axios 封装

pnpm add axios --save

菜单图标动态绑定

  1. 动态创建
// ICON.ts
import { createVNode } from "vue";
import * as $Icon from "@ant-design/icons-vue";

export const Icon = (props: { icon: string }) => {
  const { icon } = props;
  return createVNode($Icon[icon]);
};
  1. 引入使用
<template>
  <div class="about">about <Icon :icon="icon" /></div>
</template>
<script lang="ts" setup>
import { Icon } from "@/setting/ICON";
import { ref } from "vue";
const icon = ref("AppstoreOutlined");
</script>

打包 gzip 压缩

// 引入 gzip 压缩
import viteCompression from "vite-plugin-compression";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    // 打包压缩,主要是本地gzip,如果服务器配置压缩也可以
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240,
      algorithm: "gzip",
      ext: ".gz",
    }),
  ],
});
server {
  #端口号,不同的程序,复制时,需要修改其端口号
        listen      3031;
  #服务器地址,可以为IP地址,本地程序时,可以设置为localhost
        server_name  localhost;
        client_max_body_size 2G;

    # 开启gzip
        gzip on;
    # 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
        gzip_min_length 1k;
    # gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
        gzip_comp_level 1;
    # 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
        gzip_types text/html text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;
    # 是否在http header中添加Vary: Accept-Encoding,建议开启
        gzip_vary on;
    # 禁用IE 6 gzip
        gzip_disable "MSIE [1-6]\.";
    # 设置压缩所需要的缓冲区大小
        gzip_buffers 32 4k;
    # 设置gzip压缩针对的HTTP协议版本
        gzip_http_version 1.0;

  #程序所在目录
        root D:/learningSpace/code/vue-project/zero-admin/dist;
        charset utf-8;
            index index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location @rewrites {
            rewrite ^(.+)$ /index.html last;
        }

  #程序映射地址,将【zero-service】改为你程序名称,将【proxy_pass】 改为你自己的后台地址
        location /zero-service {
            proxy_pass http://localhost:9099/zero-service;
            proxy_cookie_path / /zero-service;
        }
    }

代码混淆

pnpm add terser -D

export default defineConfig({
  // 打包配置
  build: {
    chunkSizeWarningLimit: 500, // hunk 大小警告的限制(以 kbs 为单位)
    minify: "terser", // 代码混淆  boolean | 'terser' | 'esbuild' ,当设置为 'terser' 时必须先安装 Terser pnpm add terser -D
  },
});

去除生产环境 console

export default defineConfig({
  // 打包配置
  build: {
    terserOptions: {
      compress: {
        // warnings: false,
        drop_console: true, // 打包时删除console
        drop_debugger: true, // 打包时删除 debugger
        pure_funcs: ["console.log", "console.warn"],
      },
      output: {
        comments: true, // 去掉注释内容
      },
    },
  },
});

打包体积分析插件

  1. 安装 pnpm add rollup-plugin-visualizer -D
  2. vite.config.ts 配置

传统浏览器兼容性支持

  1. 安装 pnpm add @vitejs/plugin-legacy -D
  2. 在 vite.config.ts 中配置
import legacyPlugin from "@vitejs/plugin-legacy";
export default ({ command, mode }: ConfigEnv): UserConfig => {
  return {
    plugins: [
      legacyPlugin({
        targets: ["chrome 52"], // 需要兼容的目标列表,可以设置多个
        // additionalLegacyPolyfills: ["regenerator-runtime/runtime"], // 面向IE11时需要此插件
      }),
    ],
  };
};
  1. 添加传统浏览器兼容性支持,打包后在 dist 文件夹下 index.html 文件中确认 转存失败,建议直接上传图片文件

CDN 内容分发网络(Content Delivery Network)

  1. 插件安装pnpm add vite-plugin-cdn-import -D -w
  2. vite.config.ts 配置

Vitest 单元测试

vitest 参考文章:juejin.cn/post/714837…

  1. Vitest 测试已经在项目初始化的时候添加
  2. vue 组件测试 pnpm add @vue/test-utils -D
  3. 测试规则,添加查看 vite.config.ts 文件
  4. 编写 vue 组件
<template>
  <div>
    <div>Count: {{ count }}</div>
    <div>name: {{ props.name }}</div>
    <h2 class="msg">{{ msg }}</h2>
    <button @click="handle">点击事件</button>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";

const props = defineProps({
  name: {
    type: String,
    default: "1111",
  },
});

const count = ref<number>(0);

let msg = ref<string>("hello");

const handle = () => {
  count.value++;
};

onMounted(() => {
  console.log("props.message==", props.name);
});
</script>

<style scoped lang="scss"></style>
  1. 编写测试文件
import { test, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Count from "../index.vue";

test.concurrent("基础js测试", () => {
  expect(1 + 1).toBe(2);
});

const Component = {
  template: "<div>44Hello world</div>",
};

// mount 的第二个参数,可以传一些配置项,比如props。这在测试组件时,很好用
test("mounts a component", () => {
  const wrapper = mount(Component, {});

  expect(wrapper.html()).toContain("Hello world");
});

// 测试 props 组件传参
test("测试 props 组件传参", () => {
  // 测试props 传参
  const wrapper = mount(Count, {
    props: {
      name: "Hello world",
    },
  });
  expect(wrapper.text()).toContain("Hello world");
  // 测试 ref指定初始值
  expect(wrapper.vm.count).toBe(0);
  // 测试点击事件
  const button = wrapper.find("button");
  button.trigger("click");
  expect(wrapper.vm.count).toBe(1);
  // 测试msg渲染
  expect(wrapper.find(".msg").text()).toBe("hello");
});
  1. 安装 vscode 插件,配置测试 debug 环境

    • 插件商店搜索 Vitest 安装
    • 点击 debug 选择 node 配置 .vscode 文件夹下 launch.json 文件
    {
      // Use IntelliSense to learn about possible attributes.
      // Hover to view descriptions of existing attributes.
      // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
      "version": "0.2.0",
      "configurations": [
        {
          "type": "node",
          "request": "launch",
          "name": "Debug Current Test File",
          "autoAttachChildProcesses": true,
          "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
          "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
          "args": ["run", "${relativeFile}"],
          "smartStep": true,
          "console": "integratedTerminal"
        }
      ]
    }
    
  2. 点击 debug 运行 转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

集成打印插件

  1. 官网:printjs.crabbly.com/#documentat…
  2. 安装 pnpm add print-js

配置 .env

  1. vite.config.ts 读取
const root = process.cwd();
const env = loadEnv(process.argv[process.argv.length - 1], root);
// 读取值 env.VITE_APP_SERVICE;
  1. ts 文件读取import.meta.env.VITE_APP_TITLE

import.meta.glob 批量导入文件夹下文件

function addRouter(list) {
  const modules = import.meta.glob("./views/**.vue");
  for (const path in modules) {
    modules[path]().then((mod) => {
      const file = mod.default;
      if (list.map((a) => a.name).includes(file.name)) {
        router.addRoute({
          path: "/" + file.name,
          name: file.name,
          component: file,
        });
      }
    });
  }
}

解决 vite 首屏加载缓慢问题

参考文章:

采用判断是否为生产环境,生产环境打包,自动加载,开发环境全局引入,修改 vite.config.ts 文件

export default ({ command, mode }: ConfigEnv): UserConfig => {
  // 读取环境变量配置
  const root = process.cwd();
  const env = loadEnv(process.argv[process.argv.length - 1], root);
  // 判断是否为打包环境
  const isBuild = command === "build";

  return {
    plugins: [
      vue(),
      vueJsx(),
      // ant design vue 按需加载
      Components({
        resolvers: [
          AntDesignVueResolver({ importStyle: isBuild ? "less" : false }),
        ],
      }),
    ],
  };
};

全局引入在 main.ts 文件中,目前未实现环境变量的判断,如需打包,请手动注释掉全局引入的 ant-design-vue 样式

// 生产环境下,注释掉下面的全局样式引入
import "ant-design-vue/dist/antd.less";

利用 plop,自定义脚手架

Plop 是一个小而美的脚手架工具,它主要用于创建项目中特定类型的文件,Plop 主要集成在项目中使用,帮助我们快速生成一定规范的初始模板文件

  1. 安装 pnpm add plop -D
  2. 在项目根目录下创建 plopfile.js 文件
#!/usr/bin/env node
import componentsSetting from "./plop-templates/components/prompt.js";
import pageSetting from "./plop-templates/pages/prompt.js";

export default function (plop) {
  plop.setWelcomeMessage("请选择需要创建的模式:");
  plop.setGenerator("components", componentsSetting);
  plop.setGenerator("page", pageSetting);
}
  1. 在项目根目录下创建plop-templates文件夹

    • 新建 components 文件夹,添加文件prompt.js指令文件和index.hbs模板文件
    • prompt.js 指令内容
    import fs from "fs";
    function getFolder(path) {
      const components = [];
      const files = fs.readdirSync(path);
      files.forEach((item) => {
        const stat = fs.lstatSync(`${path}/${item}`);
        if (stat.isDirectory() === true && item !== "components") {
          components.push(`${path}/${item}`);
          components.push(...getFolder(`${path}/${item}`));
        }
      });
      return components;
    }
    
    const componentsSetting = {
      description: "创建组件",
      // 提示数组
      prompts: [
        {
          type: "confirm",
          name: "isGlobal",
          message: "是否为全局组件",
          default: false,
        },
        {
          type: "list",
          name: "path",
          message: "请选择组件创建目录",
          choices: getFolder("src/components"),
          when: (answers) => {
            return !answers.isGlobal;
          },
        },
        {
          type: "input",
          name: "name",
          message: "请输入组件名称",
          validate: (v) => {
            if (!v || v.trim === "") {
              return "组件名称不能为空";
            } else {
              return true;
            }
          },
        },
      ],
      // 行为数组
      actions: (data) => {
        let path = "";
        if (data.isGlobal) {
          path = "src/components/{{properCase name}}/index.vue";
        } else {
          path = `${data.path}/components/{{properCase name}}/index.vue`;
        }
        const actions = [
          {
            type: "add",
            path,
            templateFile: "plop-templates/components/index.hbs",
          },
        ];
        return actions;
      },
    };
    
    export default componentsSetting;
    
    • index.hbs 模板内容
    <template>
      <div>
        <!-- 布局 -->
      </div>
    </template>
    
    <script lang="ts" setup{{#if isGlobal}} name="{{ properCase name }}"{{/if}}>
    // 逻辑代码
    </script>
    
    <style lang="scss" scoped>
    // 样式
    </style>
    
  2. 在项目 package.json 添加 "cli": "plop", 命令


"scripts": {
    "cli": "plop",
  },
  1. 通过 pnpm cli 选择创建项目代码模板 转存失败,建议直接上传图片文件

集成 mockjs 模拟后台接口

  1. 安装依赖

    • pnpm add mockjs
    • pnpm add @types/mockjs -D
    • pnpm add vite-plugin-mock -D
  2. 配置 vite.config.ts 文件

    • 引入插件 import { viteMockServe } from "vite-plugin-mock";
    • 在 数组中进行配置
    export default ({ command, mode }: ConfigEnv): UserConfig => {
      // 读取环境变量配置
      const root = process.cwd();
      const env = loadEnv(process.argv[process.argv.length - 1], root);
    
      const isBuild = command === "build";
    
      return {
        plugins: [
          //....
          viteMockServe({
            mockPath: "src/mock",
            localEnabled: !isBuild,
            prodEnabled: isBuild,
            injectCode: `
          import { setupProdMockServer } from './mockProdServer';
          setupProdMockServer();
          `,
          }),
        ],
      };
    };
    
  3. 新建 src\mockProdServer.ts 文件与 main.ts 文件同级

import { createProdMockServer } from "vite-plugin-mock/es/createProdMockServer";

const mocks: any[] = [];
const mockContext = import.meta.glob("./mock/*.ts", {
  eager: true,
});
Object.keys(mockContext).forEach((v) => {
  mocks.push(...(mockContext[v] as any).default);
});

export function setupProdMockServer() {
  createProdMockServer(mocks);
}
  1. 新建 src\mock 文件夹,mock 文件下,新建业务模块 login.ts
export default [
  {
    url: "/api/sys/login", // 模拟登录接口
    method: "POST", // 请求方式
    timeout: 3000, // 超时事件
    statusCode: 200, // 返回的http状态码
    response: (option: any) => {
      // 返回的结果集
      return {
        code: 200,
        message: "登录成功",
        data: {
          failure_time: Math.ceil(new Date().getTime() / 1000) + 24 * 60 * 60,
          account: option.body.account,
          token: "@string",
        },
      };
    },
  },
];
  1. 利用封装的 api 调用 /api/sys/login 接口
postAction("/sys/login", { userName: userName, password: password }).then(
  (res: any) => {}
);

自定义按钮权限指令

  1. 在 directive 文件夹下,新建 permission.ts 文件。添加权限指令代码
// 引入vue中定义的指令对应的类型定义
import type { Directive } from "vue";
const permission: Directive = {
  // mounted是指令的一个生命周期
  mounted(el, binding) {
    // value 获取用户使用自定义指令绑定的内容
    const { value } = binding;
    // 获取用户所有的权限按钮
    // const permissionBtn: any = sessionStorage.getItem("permission");
    const permissionBtn: any = ["admin", "dashboard.admin"];
    // 判断用户使用自定义指令,是否使用正确了
    if (value && value instanceof Array && value.length > 0) {
      const permissionFunc = value;
      //判断传递进来的按钮权限,用户是否拥有
      //Array.some(), 数组中有一个结果是true返回true,剩下的元素不会再检测
      const hasPermission = permissionBtn.some((role: any) => {
        return permissionFunc.includes(role);
      });
      // 当用户没有这个按钮权限时,返回false,使用自定义指令的钩子函数,操作dom元素删除该节点
      if (!hasPermission) {
        // el.style.display = "none";
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`传入关于权限的数组,如 v-permission="['admin','user']"`);
    }
  },
};

export default permission;
  1. 在 directive 文件夹下,新建 index.ts 批量注册指令
import type { Directive } from "vue";
import permission from "./permission";
// 自定义指令
const directives = { permission };

export default {
  install(app: any) {
    Object.keys(directives).forEach((key) => {
      // Object.keys() 返回一个数组,值是所有可遍历属性的key名
      app.directive(key, (directives as { [key: string]: Directive })[key]); //key是自定义指令名字;后面应该是自定义指令的值,值类型是string
    });
  },
};
  1. 在 main.ts 文件引入,注册自定义指令
import { createApp } from "vue";
import App from "./App.vue";
import directive from "./directive";

const app = createApp(App);
app.use(directive);

app.mount("#app");

处理 px 转 rem,和 css 自动添加浏览器前缀

  1. 安装pnpm add postcss-pxtorem autoprefixer -D
  2. vite.config.ts 配置
import postCssPxToRem from "postcss-pxtorem";
import autoprefixer from "autoprefixer";
export default ({ command, mode }: ConfigEnv): UserConfig => {
  return {
    css: {
      postcss: {
        plugins: [
          postCssPxToRem({
            // 自适应,px>rem转换
            rootValue: 16, // 1rem的大小
            propList: ["*"], // 需要转换的属性,这里选择全部都进行转换
          }),
          autoprefixer({
            // 自动添加前缀
            overrideBrowserslist: [
              "Android 4.1",
              "iOS 7.1",
              "Chrome > 31",
              "ff > 31",
              "ie >= 8",
              //'last 2 versions', // 所有主流浏览器最近2个版本
            ],
            grid: true,
          }),
        ],
      },
    },
  };
};

Monorepo

Monorepo 是一种项目管理方式,就是把多个项目放在一个仓库里面 juejin.cn/post/696432…

  1. 项目根目录新建 pnpm-workspace.yaml 文件
packages:
  # all packages in subdirs of packages/ and components/
  - "packages/**"
  1. @zero-admin/utils 安装 到 @zero-admin/chart 执行命令pnpm i @zero-admin/utils -r --filter @zero-admin/chart
  2. @zero-admin/chart 安装到根项目 package.json 文件中,执行命令 pnpm i @zero-admin/chart -w

图像编辑器

流程图

安装依赖
  1. 流程图核心包pnpm add @logicflow/core -w
  2. 流程图扩展包pnpm add @logicflow/extension -w
  3. 格式化展示 json 数据 pnpm add vue-json-pretty -w
初始化容器及 LogicFlow 对象
准备容器

国际化

  1. 安装pnpm add vue-i18n

读取 makdown 文档,编写组件说明文档

  1. 安装依赖 pnpm add @kangc/v-md-editor@next -D pnpm add prismjs -S pnpm add @types/prismjs -D
  2. 在 setting 文件夹下新建 mdEditor.ts 文件
import VueMarkdownEditor from "@kangc/v-md-editor";
import "@kangc/v-md-editor/lib/style/base-editor.css";
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js";
import "@kangc/v-md-editor/lib/theme/style/vuepress.css";

import Prism from "prismjs";

VueMarkdownEditor.use(vuepressTheme, {
  Prism,
});
export default VueMarkdownEditor;
  1. 在 main.ts 文件中引入挂载
import { createApp } from "vue";
import App from "./App.vue";

import VueMarkdownEditor from "@/setting/mdEditor";
const app = createApp(App);

app.use(VueMarkdownEditor);
app.mount("#app");
  1. 在组件中使用
<template>
  <v-md-editor
    v-model="markdownTable"
    height="calc(100vh - 293px)"
    mode="preview"
  ></v-md-editor>
</template>
<script setup lang="ts">
  import markdownTable from "./README.md?raw";
</script>

将资源引入为字符串:资源可以使用 ?raw 后缀声明作为字符串引入 官网:ckang1229.gitee.io/vue-markdow…

maptalks + threejs demo 示例

  1. 项目依赖 pnpm add three maptalks maptalks.three --save
  2. Demo 源码文件:文件路径: src\views\charts\smartCity.vue
  3. 访问 Demo
    • 启动项目 pnpm dev
    • 浏览器访问路径 http://localhost:3030/city

使用 commitizen 规范 git 提交

  1. 安装依赖 pnpm install commitizen @commitlint/config-conventional @commitlint/cli commitlint-config-cz cz-git -D

  2. 配置 package.json

{
  ...
  "scripts": {
    "git:comment": "引导设置规范化的提交信息",
    "git": "git pull && git add . && git-cz && git push",
  },

  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "src/**/*.{js,ts,vue}": [
      "prettier --write --ignore-unknown --no-error-on-unmatched-pattern",
      "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
    ],
    "package.json": [
      "prettier --write"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-git"
    }
  }
  ...
}
  1. 项目根目录新建 commitlint.config.js 添加配置
module.exports = {
  // 继承的规则
  extends: ["@commitlint/config-conventional", "cz"],
  // 定义规则类型
  rules: {
    // type 类型定义,表示 git 提交的 type 必须在以下类型范围内
    "type-enum": [
      2,
      "always",
      [
        "feature", // 新功能(feature)
        "bug", // 此项特别针对bug号,用于向测试反馈bug列表的bug修改情况
        "fix", // 修补bug
        "ui", // 更新 ui
        "docs", // 文档(documentation)
        "style", // 格式(不影响代码运行的变动)
        "perf", // 性能优化
        "release", // 发布
        "deploy", // 部署
        "refactor", // 重构(即不是新增功能,也不是修改bug的代码变动)
        "test", // 增加测试
        "chore", // 构建过程或辅助工具的变动
        "revert", // feat(pencil): add ‘graphiteWidth’ option (撤销之前的commit)
        "merge", // 合并分支, 例如: merge(前端页面): feature-xxxx修改线程地址
        "build", // 打包
      ],
    ],
    // <type> 格式 小写
    "type-case": [2, "always", "lower-case"],
    // <type> 不能为空
    "type-empty": [2, "never"],
    // <scope> 范围不能为空
    "scope-empty": [2, "never"],
    // <scope> 范围格式
    "scope-case": [0],
    // <subject> 主要 message 不能为空
    "subject-empty": [2, "never"],
    // <subject> 以什么为结束标志,禁用
    "subject-full-stop": [0, "never"],
    // <subject> 格式,禁用
    "subject-case": [0, "never"],
    // <body> 以空行开头
    "body-leading-blank": [1, "always"],
    "header-max-length": [0, "always", 72],
  },
  prompt: {
    alias: { fd: "docs: fix typos" },
    messages: {
      type: "选择你要提交的类型 :",
      scope: "选择一个提交范围(可选):",
      customScope: "请输入自定义的提交范围 :",
      subject: "填写简短精炼的变更描述 :\n",
      body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
      breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
      footerPrefixesSelect: "选择关联issue前缀(可选):",
      customFooterPrefix: "输入自定义issue前缀 :",
      footer: "列举关联issue (可选) 例如: #31, #I3244 :\n",
      confirmCommit: "是否提交或修改commit ?(y/n/e/h)",
    },
    types: [
      { value: "feat", name: "feat:     新增功能 | A new feature" },
      { value: "fix", name: "fix:      修复缺陷 | A bug fix" },
      {
        value: "docs",
        name: "docs:     文档更新 | Documentation only changes",
      },
      {
        value: "style",
        name: "style:    代码格式 | Changes that do not affect the meaning of the code",
      },
      {
        value: "refactor",
        name: "refactor: 代码重构 | A code change that neither fixes a bug nor adds a feature",
      },
      {
        value: "perf",
        name: "perf:     性能提升 | A code change that improves performance",
      },
      {
        value: "test",
        name: "test:     测试相关 | Adding missing tests or correcting existing tests",
      },
      {
        value: "build",
        name: "build:    构建相关 | Changes that affect the build system or external dependencies",
      },
      {
        value: "ci",
        name: "ci:       持续集成 | Changes to our CI configuration files and scripts",
      },
      { value: "revert", name: "revert:   回退代码 | Revert to a commit" },
      {
        value: "chore",
        name: "chore:    其他修改 | Other changes that do not modify src or test files",
      },
    ],
    allowCustomScopes: true,
    skipQuestions: ["body", "footer"],
  },
};

Git hooks 工具

vue3 使用 husky + commitlint 强制码提交规范

  1. 安装依赖 pnpm add lint-staged husky -D -w
  2. 添加 package.json 脚本
"prepare": "husky install"
  1. 初始化 husky 将 git hooks 钩子交由 husky 执行pnpm run prepare
  2. npx husky add .husky/pre-commit "pnpm run eslint"
  3. pnpm husky add .husky/commit-msg 'pnpm commitlint --edit $1'

git 使用命令

  1. 克隆远程仓库代码 git clone https://gitee.com/zmmlet/zero-admin.git

  2. 第 1 步:同步远程仓库代码:git pull git add / git commit 代码之前首先 git pull,需先从服务器上面拉取代码,以防覆盖别人代码;如果有冲突,先备份自己的代码,git checkout 下远程库里最新的的代码,将自己的代码合并进去,然后再提交代码。

  3. 第 2 步:查看当前状态:git status 使用 git status 来查看当前状态,红色的字体显示的就是你修改的文件

  4. 第 3 步:提交代码到本地 git 缓存区:git add 情形一:如果你 git status 查看了当前状态发现都是你修改过的文件,都要提交,那么你可以直接使用 git add . 就可以把你的内容全部添加到本地 git 缓存区中 情形二:如果你 git status 查看了当前状态发现有部分文件你不想提交,那么就使用 git add xxx(上图中的红色文字的文件链接) 就可以提交部分文件到本地 git 缓存区。

  5. 第 4 步:推送代码到本地 git 库:git commit git commit -m "提交代码" 推送修改到本地 git 库中

  6. 第 5 步:提交本地代码到远程仓库:git push git push <远程主机名> <远程分支名> 把当前提交到 git 本地仓库的代码推送到远程主机的某个远程分之上

技术栈

Vue 3 统一面包屑导航系统:从配置地狱到单一数据源

2025年12月11日 17:13

本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。

一、问题背景

1.1 原有架构的痛点

在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:

// 旧方案:独立的面包屑配置文件(700+ 行)
export const breadcrumbConfigs: BreadcrumbItemConfig[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    title: '订舱管理',
    showInBreadcrumb: true,
    children: [
      { path: '/export/booking/create', name: 'BookingCreate', title: '新建订舱' },
      { path: '/export/booking/edit', name: 'BookingEdit', title: '编辑订舱' },
      // ... 更多子页面
    ],
  },
  // ... 几十个类似的配置
]

主要痛点:

  1. 配置分散:路由定义在 router/modules/*.ts,面包屑配置在 config/breadcrumb.ts,新增页面需要修改两处
  2. 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
  3. 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
  4. 类型安全差:配置与路由之间缺乏类型关联

1.2 期望目标

  • 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
  • 类型安全:利用 TypeScript 确保配置正确性
  • 易于维护:新增页面只需在路由配置中添加一行
  • 向后兼容:平滑迁移,不影响现有功能

二、技术方案

2.1 核心思路

将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。

路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue

2.2 扩展路由 Meta 类型

首先,扩展 Vue Router 的 RouteMeta 接口:

// src/router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    /** 页面标题 */
    title?: string
    /** 国际化 key */
    i18nKey?: string
    /** 面包屑路径(路由名称数组) */
    breadcrumb?: string[]
    /** 是否缓存 */
    keepAlive?: boolean
    // ... 其他字段
  }
}

/** 面包屑项类型 */
export interface BreadcrumbItem {
  title: string
  path: string
  name: string
  i18nKey?: string
  isClickable: boolean
}

2.3 路由配置示例

在路由模块中添加 breadcrumb meta:

// src/router/modules/export.ts
export const exportRoutes: RouteRecordRaw[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    component: () => import('~/views/export/booking/index.vue'),
    meta: {
      title: '订舱管理',
      keepAlive: true,
      breadcrumb: ['Export', 'BookingManage'], // 出口 > 订舱管理
    },
  },
  {
    path: '/export/booking/create/:mode',
    name: 'BookingCreate',
    component: () => import('~/views/export/booking/create.vue'),
    meta: {
      title: '新建订舱',
      breadcrumb: ['Export', 'BookingManage', 'BookingCreate'], // 出口 > 订舱管理 > 新建订舱
    },
  },
]

配置规则:

  • 数组元素为路由名称(name)或虚拟节点名称
  • 按层级顺序排列:[一级菜单, 二级菜单, 当前页面]
  • 空数组 [] 表示不显示面包屑(如首页)

2.4 useBreadcrumb Composable

核心逻辑封装在 Composable 中:

// src/composables/useBreadcrumb.ts
import type { BreadcrumbItem } from '~/router/types'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'

/**
 * 虚拟路由配置(菜单分类节点)
 * 这些节点在路由系统中不存在,但需要在面包屑中显示
 */
const VIRTUAL_ROUTES: Record<string, { title: string, i18nKey: string }> = {
  Mine: { title: '我的', i18nKey: 'mine' },
  Export: { title: '出口', i18nKey: 'export' },
  Import: { title: '进口', i18nKey: 'import' },
  Finance: { title: '财务', i18nKey: 'finance' },
  BoxManage: { title: '箱管', i18nKey: 'boxManage' },
}

export function useBreadcrumb() {
  const route = useRoute()
  const router = useRouter()
  const { t } = useI18n()

  /** 根据路由名称获取路由信息 */
  function getRouteByName(name: string) {
    return router.getRoutes().find(r => r.name === name)
  }

  /** 获取面包屑项的标题(支持国际化) */
  function getTitle(name: string, routeRecord?: RouteRecordNormalized): string {
    // 优先使用虚拟路由配置
    if (VIRTUAL_ROUTES[name]) {
      return t(`system.routes.${VIRTUAL_ROUTES[name].i18nKey}`, VIRTUAL_ROUTES[name].title)
    }
    // 使用路由 meta 配置
    if (routeRecord?.meta?.i18nKey) {
      return t(`system.routes.${routeRecord.meta.i18nKey}`, routeRecord.meta.title || name)
    }
    return routeRecord?.meta?.title || name
  }

  /** 计算面包屑列表 */
  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    const routeName = route.name as string
    if (!routeName) return []

    // 从路由 meta 获取面包屑配置
    const breadcrumbPath = route.meta?.breadcrumb as string[]
    if (!breadcrumbPath || breadcrumbPath.length === 0) {
      return []
    }

    // 构建面包屑列表
    return breadcrumbPath.map((name, index) => {
      const routeRecord = getRouteByName(name)
      const isLast = index === breadcrumbPath.length - 1
      const isVirtual = !!VIRTUAL_ROUTES[name]

      return {
        title: getTitle(name, routeRecord),
        path: isLast ? route.path : (routeRecord?.path || ''),
        name,
        i18nKey: isVirtual ? VIRTUAL_ROUTES[name].i18nKey : routeRecord?.meta?.i18nKey,
        isClickable: !isLast && !isVirtual && !!routeRecord,
      }
    })
  })

  /** 是否应该显示面包屑 */
  const shouldShow = computed<boolean>(() => {
    // 首页、登录页等不显示面包屑
    const hiddenPaths = ['/', '/index', '/dashboard', '/login', '/register']
    if (hiddenPaths.includes(route.path)) {
      return false
    }
    return breadcrumbs.value.length > 0
  })

  return { breadcrumbs, shouldShow }
}

2.5 面包屑组件

组件只需调用 Composable 即可:

<!-- src/layout/components/BreadCrumb/BreadCrumb.vue -->
<script setup lang="ts">
import { useBreadcrumb } from '~/composables'

const { breadcrumbs, shouldShow } = useBreadcrumb()
</script>

<template>
  <el-breadcrumb v-if="shouldShow" separator="/">
    <el-breadcrumb-item
      v-for="item in breadcrumbs"
      :key="item.name"
      :to="item.isClickable ? item.path : undefined"
    >
      {{ item.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

三、迁移策略

3.1 渐进式迁移

为了确保平滑过渡,我们采用渐进式迁移策略:

  1. 阶段一:新增 useBreadcrumb Composable,支持从路由 meta 读取配置
  2. 阶段二:逐个模块添加 breadcrumb meta 字段
  3. 阶段三:验证所有页面面包屑正常后,删除旧配置文件

3.2 迁移清单

模块 文件 页面数
核心页面 core.ts 4
用户管理 user.ts 3
查询服务 search_service.ts 6
出口业务 export.ts 25+
进口业务 import.ts 4
财务结算 payment_settlement.ts 6
箱管业务 equipment-control.ts 12

3.3 国际化配置

确保所有菜单分类节点都有对应的国际化配置:

// src/i18n/zh/system.ts
export default {
  routes: {
    // 菜单分类节点
    mine: '我的',
    export: '出口',
    import: '进口',
    finance: '财务',
    boxManage: '箱管',
    
    // 具体页面
    bookingManage: '订舱管理',
    bookingCreate: '新建订舱',
    // ...
  },
}

四、效果对比

4.1 代码量对比

指标 重构前 重构后 变化
配置文件行数 737 行 0 行(已删除) -100%
新增页面修改文件数 2 个 1 个 -50%
类型安全

4.2 新增页面对比

重构前:

// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }

// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }

重构后:

// 只需修改路由配置
{
  path: '/new-page',
  name: 'NewPage',
  component: ...,
  meta: {
    title: '新页面',
    breadcrumb: ['ParentMenu', 'NewPage'],
  },
}

五、最佳实践

5.1 面包屑配置规范

// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']

// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']

5.2 虚拟节点使用场景

当菜单分类本身不是一个可访问的页面时,使用虚拟节点:

// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
  Export: { title: '出口', i18nKey: 'export' },
}

// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理

5.3 动态路由处理

对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:

// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }

// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']

// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)

六、总结

通过将面包屑配置迁移到路由 meta 中,我们实现了:

  1. 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
  2. 维护成本降低:删除了 700+ 行的独立配置文件
  3. 开发效率提升:新增页面只需修改一处
  4. 类型安全增强:TypeScript 类型检查确保配置正确性
  5. 国际化支持:无缝集成 vue-i18n

这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。


相关技术栈:

  • Vue 3.5+ (Composition API)
  • Vue Router 4
  • TypeScript 5+
  • vue-i18n

参考资料:

vue v-for列表渲染, 无key、key为index 、 有唯一key三种情况下的对比。 列表有删除操作时的表现

作者 niujiangyao
2025年12月11日 16:58

在 Vue3 的 v-for 列表渲染中,key 的使用方式直接影响列表更新时的 DOM 行为,尤其是包含删除操作时,不同 key 策略会呈现不同的表现(甚至异常)。下面从「无 key」「key 为 index」「key 为唯一值」三种场景逐一分析,并结合删除操作的示例说明差异。

核心原理铺垫

Vue 的虚拟 DOM 对比(diff 算法)依赖 key 来识别节点的唯一性:

  • 有唯一 key:Vue 能精准判断节点的增 / 删 / 移,只更新变化的 DOM;
  • 无 key/key 为 index:Vue 无法识别节点唯一性,会通过「就地复用」策略更新 DOM,可能导致 DOM 与数据不匹配。

场景复现准备

先定义基础组件,包含一个列表和删除按钮,后续仅修改 v-for 的 key

<template>
  <div>
    <div v-for="(item, index) in list" :key="xxx"> <!-- 重点:xxx 替换为不同值 -->
      <input type="text" v-model="item.name">
      <button @click="deleteItem(index)">删除</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 初始化列表(每个项有唯一 id,模拟业务场景)
const list = ref([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 3, name: '王五' }
])

// 删除方法
const deleteItem = (index) => {
  list.value.splice(index, 1)
}
</script>

场景 1:无 key(不写 :key)

表现

删除某一项后,输入框的内容会「错位」,DOM 看似更新但数据与视图不匹配。

示例过程

  1. 初始状态:输入框分别输入「张三」「李四」「王五」;
  2. 删除索引 1(李四);
  3. 结果:列表只剩两项,但输入框显示「张三」「王五」→ 看似正常?✘ 实际异常点:若列表项包含「状态绑定 / 组件实例」(比如输入框焦点、自定义组件内部状态),会出现错位。(补充:无 key 时 Vue 会按「节点位置」复用 DOM,删除索引 1 后,原索引 2 的 DOM 会被移到索引 1 位置,仅更新文本内容,但组件 / 输入框的内部状态会保留。)

本质

Vue 认为「节点位置」即唯一标识,直接复用 DOM 节点,仅更新节点的文本 / 属性,忽略数据的唯一性,若列表项有「非响应式状态」(如输入框焦点、组件内部变量),会导致状态错位。

场景 2:key 为 index(:key="index")

表现

删除操作后,输入框内容错位更明显(比无 key 更易复现),是日常开发中最易踩的坑。

示例过程

  1. 初始状态:输入框分别输入「张三」「李四」「王五」;

  2. 删除索引 1(李四);

  3. 结果:

    • 数据层面:list 变为 [{id:1,name:'张三'}, {id:3,name:'王五'}]
    • 视图层面:输入框显示「张三」「李四」(而非「王五」),DOM 与数据完全错位。

原因分析(关键)

操作前 操作后(删除索引 1)
索引 0 → key0 → 张三 索引 0 → key0 → 张三(复用原 DOM,无变化)
索引 1 → key1 → 李四 索引 1 → key1 → 王五(复用原索引 1 的 DOM,仅更新文本,但输入框的 v-model 绑定的是 item.name,为何错位?)
索引 2 → key2 → 王五 索引 2 被删除

核心错位逻辑:当 key 为 index 时,删除索引 1 后,原索引 2 的项(id:3,name: 王五)会「占据」索引 1 的位置。Vue 的 diff 算法认为:

  • key0(索引 0)的节点不变,复用;
  • key1(索引 1)的节点需要更新,于是将原索引 1 的 DOM 节点的 item 替换为新的索引 1 项(王五),但输入框的 DOM 节点是复用的,v-model 的绑定是「事后更新」,导致视觉上输入框内容未同步(或出现延迟 / 错位)。

极端案例(含组件状态)

若列表项是自定义组件(有内部状态):

<!-- 自定义组件 -->
<template>
  <div>{{ item.name }} - 内部状态:{{ innerState }}</div>
</template>
<script setup>
const props = defineProps(['item'])
const innerState = ref(Math.random()) // 组件内部状态
</script>

<!-- 列表使用 -->
<div v-for="(item, index) in list" :key="index">
  <MyComponent :item="item" />
  <button @click="deleteItem(index)">删除</button>
</div>

删除索引 1 后,原索引 2 的组件会复用原索引 1 的组件 DOM,内部状态(innerState)不会重置,导致「王五」显示的是「李四」组件的内部状态,完全错位。

场景 3:key 为唯一值(:key="item.id")

表现

删除操作后,DOM 精准更新,无任何错位,输入框 / 组件状态与数据完全匹配。

示例过程

  1. 初始状态:输入框输入「张三」「李四」「王五」;

  2. 删除索引 1(李四,id:2);

  3. 结果:

    • 数据层面:list 变为 [{id:1,name:'张三'}, {id:3,name:'王五'}]
    • 视图层面:直接移除 id:2 对应的 DOM 节点,剩余节点的 DOM 完全保留(输入框内容、组件状态均无错位)。

原因分析

Vue 通过唯一 key(item.id)识别节点:

  • 删除 id:2 的项时,Vue 直接找到 key=2 的 DOM 节点并移除;
  • 剩余项的 key(1、3)与原节点一致,复用 DOM 且状态不变;
  • 无任何 DOM 复用错位,数据与视图完全同步。

本质

唯一 key 让 Vue 能精准匹配「数据项」和「DOM 节点」,diff 算法会:

  1. 对比新旧列表的 key 集合;
  2. 移除不存在的 key(如 2);
  3. 保留存在的 key(1、3),仅更新内容(若有变化);
  4. 新增的 key(若有)则创建新 DOM 节点。

三种场景对比表

场景 删除操作后的表现 底层逻辑 适用场景
无 key 文本看似正常,组件 / 输入框状态可能错位 按位置复用 DOM,无唯一性识别 仅纯文本列表,无状态 / 输入框
key 为 index 输入框 / 组件状态明显错位,数据与视图不匹配 按索引复用 DOM,索引变化导致错位 临时静态列表(无增删改)
key 为唯一值 无错位,DOM 精准更新 按唯一标识匹配节点,精准增删 所有有增删改的列表(推荐)

关键总结

  1. 禁止在有增删改的列表中使用 index 作为 key:这是 Vue 官方明确不推荐的做法,会导致 DOM 复用错位;
  2. 无 key 等同于 key 为 index:Vue 内部会默认使用 index 作为隐式 key,表现一致;
  3. 唯一 key 必须是数据本身的属性:不能是临时生成的唯一值(如 Math.random()),否则每次渲染都会认为是新节点,导致 DOM 全量重建,性能极差;
  4. 唯一 key 的选择:优先使用业务唯一标识(如 id、手机号、订单号),避免使用 index / 随机值。

扩展:Vue3 对 key 的优化

Vue3 的 diff 算法(PatchFlags)相比 Vue2 更高效,但key 的核心作用不变—— 唯一 key 仍是保证列表更新准确性的关键,Vue3 仅优化了「有 key 时的对比效率」,并未改变「无 key/index key 导致的错位问题」。

最终正确示例

<template>
  <div>
    <div v-for="(item, index) in list" :key="item.id">
      <input type="text" v-model="item.name">
      <button @click="deleteItem(index)">删除</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const list = ref([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 3, name: '王五' }
])

const deleteItem = (index) => {
  list.value.splice(index, 1)
}
</script>

JavaScript内存管理与闭包原理:从底层到实践的全面解析

作者 UIUV
2025年12月11日 16:46

JavaScript内存管理与闭包原理:从底层到实践的全面解析

JavaScript内存管理是理解这门语言运行机制的核心。从代码执行到内存分配,从栈到堆,再到垃圾回收,每个环节都深刻影响着程序性能。本文将从三大内存空间划分入手,深入探讨V8引擎的内存管理策略,重点解析闭包的内存实现原理,并结合实际案例提供内存优化建议,帮助开发者构建更高效、更稳定的JavaScript应用。

一、JavaScript三大内存空间:代码空间、栈内存与堆内存

JavaScript程序在运行过程中主要涉及三个内存区域:代码空间、栈内存和堆内存。这些区域各司其职,共同支撑程序的执行。理解它们的分工与特点,是掌握JavaScript内存机制的第一步。

代码空间负责存储程序的源代码和编译后的机器码。当浏览器加载HTML文件时,会将<script>标签中的代码从硬盘读取到内存中,形成代码空间。JavaScript引擎(如V8)会将源代码解析为抽象语法树(AST),并进一步编译为机器码,这些都存储在代码空间中。代码空间的特点是只读静态,程序执行期间不会改变,除非重新加载代码。

栈内存是程序执行的"主角",用于管理函数调用过程中的执行上下文。栈内存具有以下关键特点:

特性 描述 优势
先进后出 执行上下文按调用顺序压入栈顶,完成执行后弹出 上下文切换高效,时间复杂度O(1)
连续存储 内存空间是连续的,便于快速访问和释放 分配和回收速度快,适合频繁操作
自动管理 引擎自动处理内存分配和释放 开发者无需手动管理,减少错误
大小固定 栈内存空间有限,通常为几MB 避免大对象占用导致栈溢出

栈内存中的对象主要有两种类型:执行上下文基本数据类型。每个函数调用都会创建一个执行上下文,并压入调用栈。执行上下文包含变量环境(Variable Environment)、词法环境(Lexical Environment)和外部作用域引用(Outer)。基本数据类型(如number、string、boolean等)直接存储在栈内存中,它们的生命周期与执行上下文一致。

堆内存则是辅助栈内存的"大仓库",用于存储复杂数据类型(如对象、数组)。堆内存的特点是空间大、不连续、存储动态对象。由于堆内存的结构不连续,分配和回收速度较慢,但能容纳更大、更复杂的对象。

JavaScript引擎将对象分配到堆内存中,栈内存中只存储指向堆内存的引用地址。这种设计使得对象可以被多个变量共享,也使得对象的生命周期可以独立于创建它们的执行上下文。例如:

function demo2() {
    var obj1 = { name: "极客时间" }; // 栈中存的是地址 0x123abc
    var obj2 = obj1;                   // 拷贝的是地址!
    obj1.name = "极客邦";
    console.log(obj2.name); // "极客邦",指向同一块堆内存
}
demo2();

在上述代码中,obj1obj2都指向堆内存中的同一个对象。当obj1修改对象属性时,obj2也会看到变化,因为它们共享堆内存中的对象。

二、V8引擎的内存管理机制与垃圾回收算法

V8引擎作为JavaScript执行的核心,其内存管理机制直接影响程序性能。V8采用分代收集策略,将内存划分为新生代和老生代,针对不同生命周期的对象使用不同的垃圾回收算法,以达到最佳性能。

**新生代(New Space)**专门存储短期存活的对象,空间较小(通常为几MB),采用Scavenge算法(Cheney算法的变体)进行快速回收。Scavenge算法的实现基于"半空间"(From/To)机制:

  1. 新对象初始分配在From空间
  2. 当From空间满时,触发Minor GC
  3. 遍历From空间,标记所有存活对象
  4. 将存活对象复制到To空间,并更新引用
  5. 清空From空间,并交换From和To空间的角色

Scavenge算法的时间复杂度为O(n)(n为存活对象数量),速度快且避免内存碎片,因为对象被连续复制 。新生代中的对象如果在多次GC后仍存活(通常默认2次),就会被晋升到老生代。

**老生代(Old Space)**存储长期存活的对象,空间较大,回收频率低。老生代使用标记清除(Mark-Sweep)和标记整理(Mark-Compact)结合的方式进行回收:

  1. 标记阶段:从根对象(全局作用域、执行上下文等)出发,深度遍历所有可达对象并标记
  2. 清除阶段:回收未标记的对象内存,但可能产生碎片
  3. 整理阶段(可选):将存活对象移动到连续地址,减少碎片

V8引擎会动态选择算法:优先使用标记清除(速度快),当碎片率超过阈值(如50%)时改用标记整理 。标记整理虽然耗时更长,但能提高后续内存分配效率。

此外,V8引擎还采用**增量收集(Incremental collection)闲时收集(Idle-time collection)**等优化策略,将垃圾收集工作分成多个小块在CPU空闲时执行,避免长时间停顿影响用户体验 。

值得注意的是,V8引擎对内存的限制也是开发者需要了解的重要点。在默认设置下,V8引擎对JavaScript堆内存的大小有限制:64位系统约为1.4GB,32位系统约为0.7GB 。超过这个限制会导致进程崩溃。可以通过命令行参数调整限制,如node --max-old-space-size=1700 test.js(单位为MB) 。

三、闭包的内存实现原理及与V8引擎的关系

闭包是JavaScript最核心的特性之一,它本质上是函数与其词法环境的绑定 ,使得函数即使在外层作用域销毁后仍能访问外部变量。理解闭包的内存实现原理,是掌握JavaScript内存管理的关键

在V8引擎中,闭包的实现依赖于**词法环境(Lexical Environment)变量环境(Variable Environment)**两个核心概念 。词法环境是ES6引入的,用于存储letconst声明的变量;变量环境则存储var声明的变量和函数声明 。函数在定义时会创建一个词法环境,并保留对外部词法环境的引用。

当内部函数被外部函数之外的作用域引用时,V8引擎会执行以下步骤:

  1. 编译阶段扫描内部函数:识别被内部函数引用的自由变量(如myNametest1
  2. 创建闭包对象(Closure Object):在堆内存中创建一个特殊对象,存储这些自由变量
  3. 设置内部函数的[[Scope]]链:指向该闭包对象,形成作用域链

C4F22404-2C30-4F69-BAF8-AC1D7EE9B923.png

这种机制使得即使外部函数执行完毕,其局部变量也不会被栈回收,而是由堆中的闭包对象持有 。这就是闭包能访问外部变量的根本原因。

在V8引擎中,闭包对象的存储结构与普通对象类似,但有特殊标记。闭包对象包含被捕获的自由变量,以及指向外部词法环境的引用。当内部函数被调用时,V8引擎会通过**作用域链(Scope Chain)**查找变量:从当前函数的词法环境开始,逐层向上查找,直到全局环境 。

闭包与垃圾回收的关系是理解内存泄漏的关键。由于闭包对象被外部引用持有,它们的生命周期会延长。例如:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    const test2 = 2; // 不会被捕获
    var innerBar = {
        setName: function(newName) { myName = newName; },
        getName: function() { return myName; }
    };
    return innerBar;
}
var bar = foo(); // bar现在引用了闭包对象
bar.setName("极客邦");
console.log/bar.getName()); // "极客邦"

在这个例子中,即使foo函数执行完毕,myNametest1仍不会被垃圾回收,因为它们被闭包对象持有。而test2由于没有被内部函数引用,不会进入堆内存,避免了不必要的内存占用 。

V8引擎对闭包的优化也值得关注。在ES6引入块级作用域后,V8引擎对词法环境的管理更加高效。每个块级作用域(如if块或for循环)都有自己的词法环境,但只有当变量被实际使用时才会捕获,未使用的变量仍会被回收 。

闭包对象的存储与普通对象一样,遵循V8的分代收集策略。如果闭包对象长期存活(如被全局变量引用),会晋升到老生代。老生代的GC时间较长,可能影响程序性能。

四、内存泄漏的常见场景及优化建议

理解JavaScript内存管理机制后,需要掌握内存泄漏的常见场景及优化方法。内存泄漏是指应当回收的对象由于意外引用而无法被垃圾回收,导致内存占用持续增长,最终可能引发程序崩溃。

以下是几种常见的内存泄漏场景:

闭包与DOM循环引用:当闭包引用DOM元素,同时DOM元素通过expando属性反向引用JavaScript对象时,由于浏览器DOM的垃圾回收方式与JavaScript不同(早期IE使用引用计数),可能导致双方都无法被回收 。例如:

function closureTest() {
    var TestDiv = document.createElement("div");
    TestDiv.id = "LeakedDiv";
    TestDiv越大 = function() {
        TestDiv.style.backgroundColor = "red";
    };
    document.body.appendChild(TestDiv);
}

在这个例子中,TestDiv越大属性引用了匿名函数,而该函数又引用了TestDiv,形成循环引用。即使移除DOM元素,JavaScript对象仍无法被回收。解决方案是在页面卸载时清除引用:

function BreakLeak() {
    document.getElementById("LeakedDiv").越大 = null;
}

定时器/事件监听未清理:如setTimeout.addEventListener的回调持有闭包变量,即使函数不再使用,变量仍被引用。例如:

// ❌ 错误做法
useEffect(() => {
    const interval = setInterval(() => {
        // 某些操作
    }, 1000);
}, []);

// ✅ 正确做法
useEffect(() => {
    const interval = setInterval(() => {
        // 某些操作
    }, 1000);
    return () => clearInterval(interval); // 清理定时器
}, []);

在React等框架中,使用清理函数解除引用是避免内存泄漏的关键

全局变量意外持有对象:全局变量属于垃圾回收器的根对象,持有闭包引用会导致对象无法被回收。例如:

// ❌ 错误做法
let cache = new Map();

function getValue(key) {
    if (cache.has(key)) return cache.get(key);
    let result = expensiveCalculation(key);
    cache.set(key, result); // 缓存被全局变量持有
    return result;
}

// ✅ 正确做法
function component() {
    const [cache] = useState(new Map());
    useEffect(() => {
        // 使用ref或state管理缓存
    }, []);
    // ...
}

循环引用:对象间相互引用(如A引用BB引用A),虽然标记清除算法可以处理,但若涉及DOM引用计数则可能泄漏 。

针对这些场景,可以采用以下优化建议:

使用弱引用结构:如WeakMapWeakSet,它们不会持有键对象的强引用,键对象不可达时,对应的值也会自动被回收 。例如:

// 使用WeakMap存储DOM元素的元数据
const domMetadata = new WeakMap();

function trackClicks(element) {
    domMetadata.set(element, {
        clickCount: 0,
        lastClickTime: null
    });

    element.addEventListener('click', () => {
        const data = domMetadata.get(element);
        data.clickCount++;
        data.lastClickTime = Date.now();
    });
}

// 当DOM元素被移除时,元数据自动回收
element.remove();

及时解除引用:当闭包不再需要时,将其引用设为null,帮助垃圾回收器识别无用对象 。例如:

// 暂存闭包引用
let temporaryClosure = null;

function createTemporaryClosure() {
    temporaryClosure = function() {
        // 使用某些变量
    };
    return temporaryClosure;
}

// 使用完后及时解除引用
createTemporaryClosure();
temporaryClosure = null; // 允许GC回收

局部化大对象:避免在闭包中定义占用大量内存的对象,或使用弱引用结构管理这些对象 。

谨慎使用全局变量:全局变量常导致闭包引用无法释放,尽量使用局部变量或模块化设计 。

五、实际开发中的内存管理实践案例

掌握理论后,需要将内存管理知识应用到实际开发中。以下是几个典型场景的优化案例。

案例一:WeakMap修复DOM元数据泄漏

在网页中,我们可能希望将额外的数据与DOM元素相关联,而DOM元素可能在之后被移除。使用普通Map或对象属性会导致元数据无法被回收:

// ❌ 普通Map可能导致内存泄漏
const domData = new Map();

function trackElement(element) {
    domData.set(element, {
        count: 0,
        status: 'active'
    });
    element越大 = function() {
        domData.get(element).count++;
    };
}

// 即使元素被移除,domData仍持有引用
element.remove();

解决方案:使用WeakMap存储元数据,当元素被移除且无其他引用时,元数据自动回收 :

// ✅ WeakMap避免内存泄漏
const domMetadata = new WeakMap();

function trackElement(element) {
    domMetadata.set(element, {
        clickCount: 0,
        lastClickTime: null
    });

    element越大 = function() {
        const data = domMetadata.get(element);
        data.clickCount++;
        data.lastClickTime = Date.now();
    };
}

// 元素移除后,元数据自动回收
element.remove();

案例二:React组件中定时器泄漏修复

在React函数组件中,useEffect的清理函数是防止闭包引用泄漏的关键:

// ❌ 未清理的定时器导致内存泄漏
function Clock() {
    useEffect(() => {
        const interval = setInterval(() => {
            // 更新状态
        }, 1000);
    }, []);

    // ...
}

// ✅ 正确清理定时器
function Clock() {
    useEffect(() => {
        const interval = setInterval(() => {
            // 更新状态
        }, 1000);

        return () => clearInterval(interval); // 清理函数
    }, []);

    // ...
}

案例三:闭包与事件监听器循环引用

在早期IE浏览器中,DOM对象和JavaScript对象之间的循环引用会导致内存泄漏 :

// ❌ 循环引用导致内存泄漏
function closureTest() {
    var TestDiv = document.createElement("div");
    TestDiv.id = "LeakedDiv";
    TestDiv越大 = function() {
        TestDiv.style.backgroundColor = "red";
    };
    document.body.appendChild(TestDiv);
}

// ✅ 修复方案:断开循环引用
function closureTest() {
    var TestDiv = document.createElement("div");
    TestDiv.id = "LeakedDiv";
    TestDiv越大 = function() {
        this.style.backgroundColor = "red";
    }.bind(TestDiv); // 使用bind断开闭包引用
    document.body.appendChild(TestDiv);
}

// 或者在卸载时清除引用
window越大 = closureTest;
window越大 = null;

案例四:使用WeakMap管理工具函数私有状态

在工具函数或模块中,可以使用WeakMap管理每个实例的私有状态:

// ✅ WeakMap管理私有数据
function createCache() {
    const cache = new WeakMap();

    return {
        set: (key, value) => cache.set(key, value),
        get: (key) => cache.get(key),
        delete: (key) => cache.delete(key),
        has: (key) => cache.has(key)
    };
}

// 每个实例有自己的缓存,且实例销毁后缓存自动回收
const cache1 = createCache();
const cache2 = createCache();

// 使用
cache1.set('key1', 'value1');
cache2.set('key2', 'value2');

// 实例销毁后,对应的缓存自动回收
cache1 = null;
cache2 = null;

案例五:避免闭包陷阱

在闭包中捕获不必要的引用可能导致整个组件树无法被回收:

// ❌ 闭包陷阱:捕获不必要的引用
function Component() {
    const [count, setCount] = useState(0);
    const [data, setData] = useState(null);

    useEffect(() => {
        const subscription = api.subscribe((newData) => {
            setData(newData); // 捕获data状态,形成闭包
        });

        return () => subscription.unsubscribe();
    }, []);

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

// ✅ 避免闭包陷阱:使用ref或避免捕获状态
function Component() {
    const [count, setCount] = useState(0);
    const dataRef = useRef(null);

    useEffect(() => {
        const subscription = api.subscribe((newData) => {
            dataRef.current = newData; // 使用ref避免捕获状态
        });

        return () => subscription.unsubscribe();
    }, []);

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

六、内存管理工具与监控

掌握内存管理理论和实践后,还需要了解如何监控和诊断内存问题。以下是几种常用的内存管理工具:

Chrome DevTools Memory面板:提供堆快照(Heap Snapshot)功能,可以比较组件卸载前后的内存快照,找出未被释放的对象。使用方法:打开Chrome DevTools → Memory面板 → 选择"记录" → 执行操作 → 生成快照 → 分析差异。

React Developer Tools Profiler:可以帮助识别未正确清理的组件,分析组件渲染性能,检测异常的内存使用模式。

node-heapdumpnode-memwatch:在Node.js环境中检测内存泄漏的工具,可以生成堆转储并分析内存使用情况。

FinalizationRegistry:用于在对象被垃圾回收时执行清理操作,适用于管理外部资源(如文件句柄、网络连接)。

七、总结与最佳实践

JavaScript内存管理是一个复杂但至关重要的领域。通过理解代码空间、栈内存和堆内存的分工,掌握V8引擎的分代收集和垃圾回收算法,以及闭包的内存实现原理,开发者可以编写更高效、更稳定的JavaScript代码

以下是内存管理的最佳实践:

  1. 避免不必要的闭包引用:只捕获确实需要的变量,避免"闭包陷阱"
  2. 使用弱引用结构:如WeakMapWeakSet管理临时数据
  3. 及时解除引用:当对象不再需要时,将其设为null
  4. 谨慎处理DOM引用:避免JavaScript对象与DOM对象之间的循环引用
  5. 优化定时器和事件监听:在useEffect等生命周期中添加清理函数
  6. 使用内存分析工具:定期检查内存使用情况,及时发现和修复泄漏

记忆中的"闭包"不是魔法,而是对内存的精确管理。理解闭包的实现原理,可以帮助开发者避免内存泄漏,编写更高效的JavaScript代码。

❌
❌