普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月13日首页

React从入门到出门第六章 事件代理机制与原生事件协同

作者 怕浪猫
2026年1月13日 09:14

G9oIeDzXQAIwgJc.jpeg 大家好~ 前面我们陆续掌握了 React 19 的组件、路由、状态管理等核心知识点,今天咱们聚焦一个容易被忽略但至关重要的底层模块——事件系统

用过 React 的同学都知道,我们在组件中写的事件(如 onClick、onChange)和原生 DOM 事件看似相似,却又存在差异:比如 React 事件的 this 指向默认绑定组件实例、事件对象是合成事件(SyntheticEvent)、事件处理函数默认不会冒泡到原生 DOM 层面。这些差异的背后,都源于 React 对原生事件的封装与优化——核心就是事件代理机制

很多开发者在实际开发中会遇到“React 事件与原生事件冲突”“事件冒泡不符合预期”等问题,本质上是没理清 React 事件系统与原生事件的关系。今天这篇文章,我们就从“是什么-为什么-怎么做”三个层面,拆解 React 19 事件系统的核心原理,重点说清 React UI 事件与原生 window 事件的关联,结合代码示例和流程图,让你既能理解底层逻辑,也能解决实际开发中的问题~

一、先抛问题:React 事件和原生事件有啥不一样?

在拆解原理前,我们先通过一个简单案例,直观感受 React 事件与原生事件的差异。先看代码:

import { useEffect, useRef } from 'react';

function EventDemo() {
  const btnRef = useRef(null);

  // React 事件:onClick
  const handleReactClick = () => {
    console.log('React 事件:onClick 触发');
  };

  // 原生事件:addEventListener
  useEffect(() => {
    const btn = btnRef.current;
    const handleNativeClick = () => {
      console.log('原生事件:addEventListener 触发');
    };
    btn.addEventListener('click', handleNativeClick);
    return () => {
      btn.removeEventListener('click', handleNativeClick);
    };
  }, []);

  return (
    <button ref={btnRef} onClick={handleReactClick}>
      点击测试
    </button>
  );
}

点击按钮后,控制台输出顺序是:原生事件:addEventListener 触发React 事件:onClick 触发。这个顺序是不是和你预期的不一样?

再把案例改一下,给按钮的父元素也添加事件:

import { useEffect, useRef } from 'react';

function EventDemo() {
  const btnRef = useRef(null);
  const parentRef = useRef(null);

  // 父元素 React 事件
  const handleParentReactClick = () => {
    console.log('父元素 React 事件:onClick 触发');
  };

  // 子元素 React 事件
  const handleReactClick = () => {
    console.log('子元素 React 事件:onClick 触发');
  };

  // 父元素原生事件
  useEffect(() => {
    const parent = parentRef.current;
    const handleParentNativeClick = () => {
      console.log('父元素原生事件:addEventListener 触发');
    };
    parent.addEventListener('click', handleParentNativeClick);
    return () => {
      parent.removeEventListener('click', handleParentNativeClick);
    };
  }, []);

  // 子元素原生事件
  useEffect(() => {
    const btn = btnRef.current;
    const handleNativeClick = () => {
      console.log('子元素原生事件:addEventListener 触发');
    };
    btn.addEventListener('click', handleNativeClick);
    return () => {
      btn.removeEventListener('click', handleNativeClick);
    };
  }, []);

  return (
    <div ref={parentRef} onClick={handleParentReactClick} style={{ padding: '20px', border: '1px solid #ccc' }}>
      <button ref={btnRef} onClick={handleReactClick}>
        点击测试
      </button>
    </div>
  );
}

点击按钮后,控制台输出顺序是:

  1. 子元素原生事件:addEventListener 触发(原生冒泡阶段先触发子元素)
  2. 父元素原生事件:addEventListener 触发(原生冒泡阶段向上传播)
  3. 子元素 React 事件:onClick 触发
  4. 父元素 React 事件:onClick 触发

这个结果更让人困惑了:为什么原生事件的冒泡顺序和 React 事件的冒泡顺序完全相反?为什么 React 事件总是在原生事件之后触发?要解答这些问题,我们必须先搞懂 React 事件系统的核心——事件代理机制

二、核心原理 1:React 事件代理机制(事件委托)

React 事件系统的核心优化点就是“事件代理”(也叫事件委托)。在原生 DOM 中,我们通常会给每个元素单独绑定事件;而 React 则是将所有 UI 事件(如 onClick、onChange、onMouseMove 等)都委托给了最顶层的 document 节点(React 17 及之后版本改为委托给 root 节点,即 React 挂载的根容器,如 #root,React 19 延续这一设计)。

1. 事件代理的核心逻辑

简单来说,React 事件代理的流程是:

  1. React 组件渲染时,并不会给对应的 DOM 元素直接绑定事件处理函数,而是将事件类型(如 click)、事件处理函数、组件信息等存入一个“事件注册表”;
  2. 在 React 挂载的根容器(如 #root)上,统一绑定原生事件(如 addEventListener('click', 统一处理函数));
  3. 当用户点击元素时,事件会从目标元素原生冒泡到根容器;
  4. 根容器的统一处理函数捕获到事件后,会根据事件目标(target)从“事件注册表”中找到对应的 React 事件处理函数,然后执行。

2. 用图例梳理事件代理流程

dispatch-event.46e8e5ef.png

3. 简化代码模拟 React 事件代理

为了让大家更直观理解,我们用原生 JS 模拟 React 事件代理的核心逻辑:

// 1. 事件注册表:存储 React 组件的事件信息
const eventRegistry = new Map();

// 2. React 根容器(模拟 #root)
const root = document.getElementById('root');

// 3. 统一事件处理函数(根容器绑定的原生事件处理函数)
function handleRootEvent(e) {
  // e.target 是事件的实际目标(如按钮)
  const target = e.target;

  // 从事件注册表中查找当前目标及祖先元素的事件处理函数
  let current = target;
  const handlers = [];

  while (current && current !== root) {
    // 查找当前元素对应的事件处理函数(这里简化为 click 事件)
    const eventKey = `${current.dataset.reactId}-click`;
    if (eventRegistry.has(eventKey)) {
      handlers.push(eventRegistry.get(eventKey));
    }
    // 向上遍历祖先元素(模拟冒泡)
    current = current.parentNode;
  }

  // 执行找到的事件处理函数(顺序:子元素 → 父元素,模拟 React 事件冒泡)
  handlers.forEach(handler => handler(e));
}

// 4. 给根容器绑定原生事件(模拟 React 初始化时的绑定)
root.addEventListener('click', handleRootEvent);

// 5. 模拟 React 组件绑定事件(将事件存入注册表)
function bindReactEvent(reactId, element, eventType, handler) {
  element.dataset.reactId = reactId; // 给元素标记 React ID
  const eventKey = `${reactId}-${eventType}`;
  eventRegistry.set(eventKey, handler);
}

// 6. 测试:创建组件元素并绑定 React 事件
const parentDiv = document.createElement('div');
parentDiv.style.padding = '20px';
parentDiv.style.border = '1px solid #ccc';

const btn = document.createElement('button');
btn.textContent = '点击测试';
parentDiv.appendChild(btn);
root.appendChild(parentDiv);

// 给父元素绑定 React 点击事件
bindReactEvent('parent-1', parentDiv, 'click', () => {
  console.log('父元素 React 事件:onClick 触发');
});

// 给子元素绑定 React 点击事件
bindReactEvent('btn-1', btn, 'click', () => {
  console.log('子元素 React 事件:onClick 触发');
});

// 给子元素绑定原生点击事件
btn.addEventListener('click', () => {
  console.log('子元素原生事件:addEventListener 触发');
});

// 给父元素绑定原生点击事件
parentDiv.addEventListener('click', () => {
  console.log('父元素原生事件:addEventListener 触发');
});

运行这段代码后,点击按钮的输出顺序和我们之前的 React 案例完全一致!这就验证了 React 事件代理的核心逻辑:React 事件是通过根容器的原生事件统一捕获,再通过事件注册表查找并执行对应的处理函数,其“冒泡”是模拟出来的,而非原生 DOM 冒泡

三、核心原理 2:React UI 事件与原生 window 事件的关系

理解了事件代理机制后,我们就能清晰厘清 React UI 事件与原生 window 事件的关系了。首先要明确两个核心概念:

  • React UI 事件:就是我们在组件中写的 onClick、onChange 等事件,是 React 封装后的“合成事件”,依赖事件代理机制执行;
  • 原生 window 事件:就是通过 window.addEventListener 绑定的事件(如 resize、scroll、click 等),是浏览器原生支持的事件,遵循原生 DOM 事件流(捕获→目标→冒泡)。

1. 两者的核心关联:事件流的先后顺序

React UI 事件的执行依赖于根容器的原生事件捕获,而根容器是 window 下的一个 DOM 节点。因此,React UI 事件的执行顺序,必然处于原生事件流的“冒泡阶段”(因为事件要先冒泡到根容器,才能被 React 的统一处理函数捕获)。

我们用“原生事件流+React 事件流”的组合流程图,梳理两者的先后关系:

2. 代码验证:React 事件与 window 事件的顺序

我们用代码验证上述流程,给 window 绑定捕获和冒泡阶段的 click 事件:

import { useEffect, useRef } from 'react';

function EventWithWindowDemo() {
  const btnRef = useRef(null);

  // React 事件
  const handleReactClick = () => {
    console.log('React 事件:onClick 触发');
  };

  // 原生事件(目标元素)
  useEffect(() => {
    const btn = btnRef.current;
    const handleBtnNative = () => {
      console.log('目标元素原生事件:冒泡阶段 触发');
    };
    btn.addEventListener('click', handleBtnNative);
    return () => btn.removeEventListener('click', handleBtnNative);
  }, []);

  // window 原生事件(捕获阶段)
  useEffect(() => {
    const handleWindowCapture = (e) => {
      console.log('window 原生事件:捕获阶段 触发');
    };
    // 第三个参数为 true,表示在捕获阶段执行
    window.addEventListener('click', handleWindowCapture, true);
    return () => window.removeEventListener('click', handleWindowCapture, true);
  }, []);

  // window 原生事件(冒泡阶段)
  useEffect(() => {
    const handleWindowBubble = (e) => {
      console.log('window 原生事件:冒泡阶段 触发');
    };
    // 第三个参数省略或为 false,表示在冒泡阶段执行
    window.addEventListener('click', handleWindowBubble);
    return () => window.removeEventListener('click', handleWindowBubble);
  }, []);

  return (
    <button ref={btnRef} onClick={handleReactClick}>
      点击测试(含 window 事件)
    </button>
  );
}

点击按钮后,控制台输出顺序如下,完全符合我们梳理的流程:

  1. window 原生事件:捕获阶段 触发(原生捕获阶段从 window 开始)
  2. 目标元素原生事件:冒泡阶段 触发(原生目标阶段)
  3. React 事件:onClick 触发(事件冒泡到根容器,被 React 捕获执行)
  4. window 原生事件:冒泡阶段 触发(事件最终冒泡到 window)

3. 关键结论:React 事件是原生事件的“子集”与“延迟执行”

从上述流程和代码可以得出核心结论:

  • React 事件并非脱离原生事件存在,而是基于原生事件实现的封装——React UI 事件的执行,依赖于原生事件冒泡到根容器的过程;
  • React 事件的执行时机晚于目标元素及祖先元素的原生事件(因为要等事件冒泡到根容器),但早于 window 上的原生冒泡事件
  • window 上的原生捕获事件,会在整个事件流的最开始执行,甚至早于目标元素的原生事件。

四、核心原理 3:合成事件(SyntheticEvent)与原生事件对象的关系

除了执行顺序,React 事件对象(SyntheticEvent)与原生事件对象也存在差异。在 React 事件处理函数中,我们拿到的 event 不是原生的 Event 对象,而是 React 封装的 SyntheticEvent 对象。

1. 合成事件的核心作用

React 封装 SyntheticEvent 的核心目的是:

  • 跨浏览器兼容:不同浏览器的原生事件对象存在差异(如 IE 的 event.srcElement vs 标准的 event.target),SyntheticEvent 统一了这些差异,让开发者无需关注浏览器兼容;
  • 事件对象池复用:React 会复用 SyntheticEvent 对象(减少内存开销),事件处理函数执行完后,会清空对象的属性(如 event.target、event.preventDefault() 等);
  • 统一的事件 API:SyntheticEvent 提供了与原生事件对象相似的 API(如 preventDefault、stopPropagation),但行为有细微差异。

2. 合成事件与原生事件对象的关联

SyntheticEvent 对象内部持有原生事件对象的引用,可通过 event.nativeEvent 获取原生事件对象。例如:

const handleReactClick = (event) => {
  console.log(event instanceof SyntheticEvent); // true
  console.log(event.nativeEvent instanceof Event); // true(原生事件对象)
  console.log(event.target === event.nativeEvent.target); // true(统一目标元素)
};

3. 注意点:合成事件的事件阻止

在 React 事件中,调用 event.stopPropagation() 只能阻止 React 事件的“模拟冒泡”(即阻止父组件的 React 事件执行),但无法阻止原生事件的冒泡;如果要阻止原生事件冒泡,需要调用原生事件对象的 stopPropagation()

const handleReactClick = (event) => {
  console.log('子元素 React 事件触发');
  // 阻止 React 事件的模拟冒泡(父组件的 React 事件不会执行)
  event.stopPropagation();
  // 阻止原生事件的冒泡(父元素的原生事件、window 事件不会执行)
  event.nativeEvent.stopPropagation();
};

注意:在 React 17 之前,合成事件的事件池复用机制会导致“异步访问事件属性失效”(如在 setTimeout 中访问 event.target 会是 null),需要用 event.persist() 保留事件属性;React 17 及之后(包括 React 19),移除了事件池复用机制,异步访问事件属性也能正常获取。

五、实战避坑:React 事件与原生事件协同的常见问题

理解了上述原理后,我们就能解决实际开发中 React 事件与原生事件协同的常见问题了。下面列举 3 个高频问题及解决方案:

问题 1:React 事件与原生事件冒泡冲突,导致重复执行

场景:父组件用 React 事件,子组件用原生事件,点击子组件时,父组件的 React 事件和子组件的原生事件都执行,不符合预期。

解决方案:在子组件的原生事件中,调用原生事件对象的 stopPropagation(),阻止事件冒泡到根容器,从而阻止 React 事件执行:

useEffect(() => {
  const btn = btnRef.current;
  const handleNativeClick = (e) => {
    console.log('子元素原生事件触发');
    e.stopPropagation(); // 阻止原生事件冒泡,React 事件不会执行
  };
  btn.addEventListener('click', handleNativeClick);
  return () => btn.removeEventListener('click', handleNativeClick);
}, []);

问题 2:window 事件未移除,导致内存泄漏

场景:在组件中绑定 window 原生事件(如 resize、scroll),组件卸载后,事件未移除,导致内存泄漏。

解决方案:在 useEffect 的清理函数中,移除 window 事件绑定:

useEffect(() => {
  const handleWindowResize = () => {
    console.log('窗口大小变化');
  };
  window.addEventListener('resize', handleWindowResize);
  // 组件卸载时移除事件
  return () => {
    window.removeEventListener('resize', handleWindowResize);
  };
}, []);

问题 3:React 事件中异步访问事件属性失效(React 17 之前)

场景:在 React 17 及之前版本中,在 setTimeout 中访问 event.target 会是 null。

解决方案:调用 event.persist() 保留事件属性,或提前保存需要的属性:

// 方案 1:调用 event.persist()
const handleReactClick = (event) => {
  event.persist(); // 保留事件属性
  setTimeout(() => {
    console.log(event.target); // 正常获取
  }, 1000);
};

// 方案 2:提前保存属性
const handleReactClick = (event) => {
  const target = event.target; // 提前保存
  setTimeout(() => {
    console.log(target); // 正常获取
  }, 1000);
};

注意:React 17 及之后版本(包括 React 19)已移除事件池复用机制,无需调用 event.persist(),异步访问事件属性也能正常获取。

六、核心总结

今天我们从案例出发,拆解了 React 19 事件系统的核心原理,重点厘清了 React UI 事件与原生 window 事件的关系,最后给出了实战避坑方案。核心要点总结如下:

  1. React 事件的核心是事件代理:所有 UI 事件委托给根容器(#root),通过事件注册表查找并执行处理函数,其“冒泡”是模拟的;
  2. React 事件与原生事件的顺序:window 原生捕获事件 → 目标元素/祖先元素原生事件 → React 事件 → window 原生冒泡事件;
  3. 合成事件是原生事件的封装:提供跨浏览器兼容和统一 API,可通过 event.nativeEvent 获取原生事件对象;
  4. 实战避坑关键:阻止原生冒泡需调用 event.nativeEvent.stopPropagation();window 事件需在组件卸载时移除;React 17 之前异步访问事件属性需用 event.persist()。

七、下一步学习方向

掌握了 React 事件系统的核心原理后,下一步可以重点学习:

  • React 19 事件系统的新特性:如对原生事件的进一步优化、与并发渲染的协同等;
  • 事件性能优化:如防抖节流在 React 事件中的应用、避免不必要的事件绑定;
  • 特殊事件场景:如表单事件(onSubmit、onChange)的特殊处理、拖拽事件与 React 事件的协同。

如果这篇文章对你有帮助,欢迎点赞、收藏、转发~ 有任何问题也可以在评论区留言交流~ 我们下期再见!

昨天 — 2026年1月12日首页

👋 手搓 gzip 实现的文件分块压缩上传

作者 源心锁
2026年1月12日 23:13

👋 手搓 GZIP 实现的文件分块压缩上传

1 前言

已经半年多的时间没有闲下来写文章了。一方面是重新迷上了玩游戏,另一方面是 AI 时代的到来,让我对普通技术类文章的阅读频率减少了很多,相应的,自己动笔的动力也减缓了不少。

但经过这段时间的摸索,有一点是可以确定的:具有一定技术深度、带有强烈个人风格或独特创意的文章,在 AI 时代仍具有不可替代的价值。

所以,本篇来了。

在上一篇文章中,我们实现了在浏览器中记录结构化日志,现在,我们需要将这部分日志上传到云端,方便工程师调试。

我们面临的首要问题就是,文件太大了,必须分片上传。

我们将从零构建一套大文件上传系统。和普通的大文件上传系统(如阿里 OSS、七牛云常见的方案)相似,我们具备分片上传、断点续传的基础能力。但不同的是,我们为此引入了两个高阶特性:

  1. AWS S3 预签名直传(Presigned URL) :降低服务端带宽压力。
  2. 独立分片 Gzip 压缩:在客户端对分片进行独立压缩,但最终在服务端合并成一个合法的 Gzip 文件。

阅读本篇,你将收获:

  • Gzip (RFC 1952) 与 Deflate (RFC 1951) 协议的底层实现原理。
  • 基于 AWS S3 实现大文件分片直传的完整架构。
  • 一个生产级前端上传 SDK 的设计思路。

2 基础方案设计

在正式开始设计之前,我们需要先了解以下知识:AWS 提供服务端的大文件上传或下载能力,但不直接提供直传场景(presign url)的大文件分片上传能力。

基于 AWS 实现的常规流程的大文件上传 flow 为:

  • 后端先启用 CreateMultipartUpload,得到 uploadId,返回前端

    • 在启用时,需遵循以下规则:

      • ✅ 分段上传的最大文件大小为 5TB
      • ⚠️ 最大分段数为 10000
      • ⚠️ 分段大小单次限制为 5MB-5GB,最后一段无限制
    • 需提前定义 x-amz-acl

    • 需提前定义使用的校验和算法 x-amz-checksum-algorithm

    • 需提前定义校验和类型 x-amz-checksum-type

  • 在上传时,可以通过 presign url 上传

    • 每一段都必须在 header 中包含 uploadId
    • 每一段都建议计算校验和,并携带到 header 中(声明时如定义了 **x-amz-checksum-algorithm 则必传)**
    • 每一段上传时,都必须携带分段的序号 partNumber
    • 上传后,返回每一段的 ETag 和 PartNumber,如果使用了校验和算法,则也返回;该返回数据需要记录下来
  • 上传完成后,调用 CompleteMultipartUpload

    • 必须包含参数 part,使用类似于:
    • ⚠️ 除了最后一段外,单次最小 5MB,否则 complete 阶段会报错

好在这并不意味着我们要在「直传」和「分片上传」中间二选一。

来看到我们的架构图,我们在 BFF 总共只需要三个接口,分别负责「创建上传任务」「获取分片上传 URL」「完成分片上传」的任务,而实际上传时,调用预授权的 AWS URL。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092658.png

更细节的部分,可以参考这份时序图。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092540.png

2.1 关键接口

📤 创建上传任务

  • 接口地址POST /createSliceUpload

  • 功能

    • 检查文件是否已存在
    • 检查是否存在未完成的上传任务
    • 创建新的分片上传任务
  • 返回示例

    • ✅ 文件已存在:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "url": "https://..."
      }
      
    • 🔄 任务进行中:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": [1, 2, 3]
      }
      
    • 🆕 新建任务:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": []
      }
      

🔗 获取分片上传 URL

  • 接口地址POST /getSlicePresignedUrl

  • 功能:获取指定分片的预签名上传 URL

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "partNumber": 1,
      "uploadId": "abc123"
    }
    
  • 返回示例

    {
      "uploadUrl": "https://..."
    }
    

/getSlicePresignedUrl 接口中,我们通过 AWS SDK 可以预签一个直传 URL

import { UploadPartCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

  const uploadUrl = await getSignedUrl(
    s3client,
    new UploadPartCommand({
      Bucket: AWS_BUCKET,
      Key: fileKey,
      PartNumber: partNumber,
      UploadId: uploadId,
    }),
    { expiresIn: 3600 },
  );

✅ 完成分片上传

  • 接口地址POST /completeSliceUpload

  • 功能:合并所有已上传的分片

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "uploadId": "abc123",
      "parts": [
        { "ETag": "etag1", "PartNumber": 1 },
        { "ETag": "etag2", "PartNumber": 2 }
      ]
    }
    
  • 返回示例

    {
      "id": "xxx",
      "location": "https://..."
    }
    

2.2 前端设计

为了方便使用,我们尝试构建一套方便使用的 SDK,设计的 Options 如下

interface UploadSliceOptions {
  fileName: string;
  id: string;
  getContent: (
    uploadSlice: (params: { content: ArrayBufferLike; partNumber: number; isLast?: boolean }) => Promise<void>,
  ) => Promise<void>;
  acl?: 'public-read' | 'authenticated-read';
  contentType?: string;
  contentEncoding?: 'gzip';
}

这些参数的设计意图是:

  • fileName: 分片最终合并时呈现的名字

  • id :同名文件可能实际并不同,可以使用 hash 值来区分

  • 核心上传逻辑的抽象(getContent 函数):

    • 职责:负责异步地生成或获取每一个文件分片(比如从本地文件中读取一块数据)

    • 不直接接收文件内容,而是接收一个回调函数 uploadSlice 作为参数。

      • uploadSlice 的职责是:负责异步地将这一个分片的数据(content)和它的序号(partNumber)发送到服务器。
  • 可选的文件属性(HTTP 头部相关):

  • contentType?: string: 可选。指定文件的 MIME 类型(例如 'image/jpeg''application/pdf')。这在云存储中很重要,它会影响文件被访问时的 Content-Type 响应头。

  • contentEncoding?: 'gzip': 可选。指明文件内容是否(或如何)被压缩的。在这里,它明确只支持 'gzip',意味着如果提供了这个选项,上传的内容会被进行独立分片压缩

2.2.1 核心功能实现

📤 单个分片上传

uploadSlice 函数实现逻辑如下:

  1. 通过 FileClient 获取预签名 URL
  2. 使用 fetch API 将分片内容上传到该 URL
  3. 获取 ETag,并返回上传结果
export const uploadSlice = async ({ id, fileName, partNumber, content, uploadId }: UploadSliceParams) => {
  const { uploadUrl: presignedUrl } = await FileClient.getSlicePresignedUrl({
    id,
    fileName,
    partNumber,
    uploadId,
  });

  const uploadRes = await fetch(presignedUrl, {
    method: 'PUT',
    body: content,
  });
  const etag = uploadRes.headers.get('etag');
  if (!etag) throw new Error('Upload failed');
  return {
    ETag: etag,
    PartNumber: partNumber,
  };
};

🔁 分片上传流程控制

uploadSliceFile 实现完整上传逻辑:

  1. 创建上传任务,获取 uploadId
  2. 若返回完整 URL(如小文件无需分片),则直接返回
  3. 调用 getContent 回调,获取各分片内容并上传
  4. 对失败的分片进行重试
  5. 所有分片上传完成后,调用接口合并分片
  const uploadTask = await FileClient.createSliceUpload({
    fileName,
    id,
    acl,
    contentEncoding,
    contentType,
  });
  
  if (uploadTask.url) {
    return uploadTask.url; // 代表这个 id 的文件实际上已经上传过了
  }
  
  const { uploadedParts = [] } = uploadTask;
  const uploadId = uploadTask.uploadId as string;

  const parts: { PartNumber: number; ETag: string }[] = [...(uploadedParts as { PartNumber: number; ETag: string }[])];
  
  await getContent(async ({content,isLast})=>{
     ...
     const part = await uploadSlice({
         content: new Blob([content]),
         partNumber: currentPartNumber,
         uploadId,
         id,
         fileName,
     });
     parts.push(part);
  })
  
  
  return FileClient.completeSliceUpload(...)

❗ 错误处理与重试机制

  • 最大重试次数:MAX_RETRY_TIMES = 3
  • 重试延迟时间:RETRY_DELAY = 1000ms
  • 若分片上传失败,则按策略重试
  • 合并上传前需校验所有分片是否上传成功

🔄 分片去重处理

合并前对已上传分片进行去重:

  1. 按分片序号排序
  2. 使用 Set 记录已处理的分片编号
  3. 构建唯一的分片列表

2.2.2 使用示例

虽然咋一看有些奇怪,但这种方式对于流式上传支持度更好,且在普通场景也同样适用。如下边这份代码是普通文件的上传 demo

// 示例:上传一个大文件
const fileId = 'unique-file-id';
const fileName = 'large-file.mp4';
const file = /* 获取文件对象 */;
const chunkSize = 5 * 1024 * 1024; // 每片5MB
const chunks = Math.ceil(file.size / chunkSize);

const fileUrl = await uploadSliceFile({
  fileName,
  id: fileId,
  getContent: async (uploadSlice) => {
    for (let i = 0; i < chunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(file.size, start + chunkSize);
      const chunk = file.slice(start, end);

      await uploadSlice({
        content: chunk,
        partNumber: i + 1, // 分片编号从1开始
      });
    }
  },
});

console.log('文件上传成功,访问地址:', fileUrl);

3 进阶:分块 GZIP 压缩

我们的日志,其以字符串的形式保存,上传时,最终也是要上传成一份文本型文件。

所以,我们可以考虑在上传前进行压缩,以进一步减少上传时的体积——这个过程中,我们可以考虑使用 gzip、brotli、zstd 等算法。

从兼容性考虑 💭,现在 Web 浏览器支持率最高的算法是 gzip 和 brotli 算法。但 brotli 的原理决定了我们可能很难完整发挥出 brotli 算法的效果。

原因有几个。

第一个致命的原因是,brotli(RFC 7932) 是一种 raw stream 格式,它的数据流由一个或多个“元块”(Meta-Block) 组成。流中的最后一个元块会包含一个特殊的 ISLAST 标志位,它相当于一个「文件结束符」

当我们单独压缩每一个分散的文本片段时:

  • 文本A -> 片段A.br (最后一个元块包含 ISLAST=true)
  • 文本B -> 片段B.br (最后一个元块包含 ISLAST=true)
  • 文本C -> 片段C.br (最后一个元块包含 ISLAST=true)
  • ...

当我们把它们合并在一起时(例如通过 cat A.br B.br C.br > final.br),我们得到的文件结构是: [A的数据... ISLAST=true] [B的数据... ISLAST=true] [C的数据... ISLAST=true]

当一个标准的 Brotli 解码器(比如浏览器)读取这个 final.br 文件时:

  1. 解码器开始读取 [A的数据...]
  2. 解码器读取到 A 的最后一个元块,看到了 ISLAST=true 标志。
  3. 解码器立即停止解码,因为它认为流已经结束了。
  4. [B的数据...][C的数据...] 会被完全忽略,当成文件末尾的“垃圾数据”。

最终结果 我们只能成功解压出 文本A,所有后续的文本内容都会丢失。

——即便我们手动将 IS_LAST 修改正确,但「独立压缩」会导致另一个严重问题——压缩率的极大损失。

因为 br 的压缩过程中,需要先建立一个滑动窗口字典。而如果我们对每一个分片都进行压缩,br 实际上需要为每一个分片建立一个字典。

这意味着这个过程中,最核心的字典不断被重置,br 压缩器丢失了用于判断内部重复的关键工具, 进而会导致压缩率极大的下降。

而对于 gzip 来讲,虽然 gzip body 采用的 deflate 算法同样需要字段,但其窗口大小只有 32KB(br 则是 4-16MB),而我们单个分片单最小大小即是 %MB,所以对于 gzip 来说,分成 5MB 再压缩还是 500MB 直接压缩区别并不大。

所以,我们选择 gzip 来做分块压缩。

Gzip 协议是一种文件格式,它充当一个“容器”。这个容器包裹了使用 DEFLATE (RFC 1951) 算法压缩的数据块,并为其添加了元信息和校验和,以确保文件的完整性和可识别性。

一个 gzip 文件由三个核心部分组成:

  1. Header (头部) :识别文件并提供元信息。
  2. Body (主体) :包含 DEFLATE 压缩的数据流。
  3. Footer (尾部) :提供数据完整性校验。

这意味着,我们进行分块压缩时,可以通过手动创建 header + body + footer 的方式进行分块压缩。

3.1 HEADER & FOOTER

头部至少有 10 个字节。

偏移量 (字节) 长度 (字节) 字段名 固定值 / 描述
0 1 ID1 0x1f (或 31)。这是识别 gzip 文件的“魔术数字”第一部分。
1 1 ID2 0x8b (或 139)。“魔术数字”第二部分。
2 1 CM 0x08 (或 8)。表示压缩方法 (Compression Method) 为 DEFLATE
3 1 FLG 标志位 (Flags)。这是一个极其重要的字节,它的每一位都代表一个布尔值,用于控制是否存在“可选头部”。
4 4 MTIME 文件的最后修改时间 (Modification Time),以 4 字节的 Unix 时间戳格式存储。
8 1 XFL 额外标志 (Extra Flags)。通常用于指示 DEFLATE 压缩器使用的压缩级别(例如 0x02 = 最高压缩率,0x04 = 最快压缩率)。
9 1 OS 操作系统 (Operating System)。0x03 = Unix, 0x00 = Windows/FAT, 0xFF = 未知。

其中的核心部分是 FLG,即标志位。这是头部第 4 个字节 (偏移量 3),我们需要按位 (bit) 来解析它:

Bit (位) 掩码 (Hex) 字段名 描述
0 (最低位) 0x01 FTEXT 如果置 1,表示文件可能是 ASCII 文本文件(这只是一个提示)。
1 0x02 FHCRC 如果置 1,表示头部包含一个 2 字节的头部校验和 (CRC-16)
2 0x04 FEXTRA 如果置 1,表示头部包含一个扩展字段 (extra field)
3 0x08 FNAME 如果置 1,表示头部包含原始文件名
4 0x10 FCOMMENT 如果置 1,表示头部包含注释
5 0x20 RESERVED 保留位,必须为 0。
6 0x40 RESERVED 保留位,必须为 0。
7 0x80 RESERVED 保留位,必须为 0。

然后,根据 FLG 标志位的设置,紧跟在 10 字节固定头部后面的,可能会按顺序出现以下字段:

  • FEXTRA (如果 FLG & 0x04 为真):

    • XLEN (2 字节): 扩展字段的总长度 N。
    • EXTRA (N 字节): N 字节的扩展数据。
  • FNAME (如果 FLG & 0x08 为真):

    • 原始文件名,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FCOMMENT (如果 FLG & 0x10 为真):

    • 注释,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FHCRC (如果 FLG & 0x02 为真):

    • 一个 2 字节的 CRC-16 校验和,用于校验整个头部(包括所有可选部分)的完整性。

我们的话,我们需要写入 filename,所以转换成代码,就是如下的实现:

/**
 * 生成标准 GZIP Header(10 字节)
 * 符合 RFC 1952 规范。
 * 可用于拼接 deflate raw 数据生成完整 .gz 文件。
 */
/**
 * 生成包含文件名的标准 GZIP Header
 * @param {string} filename - 要嵌入头部的原始文件名
 */
export function createGzipHeader(filename: string): Uint8Array {
  // 1. 创建基础的10字节头部,并将Flags位设置为8 (FNAME)
  const header = new Uint8Array([
    0x1f,
    0x8b, // ID1 + ID2: magic number
    0x08, // Compression method: deflate (8)
    0x08, // Flags: 设置FNAME位 (bit 3)
    0x00,
    0x00,
    0x00,
    0x00, // MTIME: 0
    0x00, // Extra flags: 0
    0x03, // OS: 3 (Unix)
  ]);

  // 动态设置 MTIME
  const mtime = Math.floor(Date.now() / 1000);
  header[4] = mtime & 0xff;
  header[5] = (mtime >> 8) & 0xff;
  header[6] = (mtime >> 16) & 0xff;
  header[7] = (mtime >> 24) & 0xff;

  // 2. 将文件名字符串编码为字节
  const encoder = new TextEncoder(); // 默认使用 UTF-8
  const filenameBytes = encoder.encode(filename);

  // 3. 拼接最终的头部
  // 最终头部 = 10字节基础头 + 文件名字节 + 1字节的null结束符
  const finalHeader = new Uint8Array(10 + filenameBytes.length + 1);

  finalHeader.set(header, 0);
  finalHeader.set(filenameBytes, 10);
  // 最后一个字节默认为0,作为null结束符

  return finalHeader;
}

footer 则相对简单一些,尾部是固定 8 字节的块,由 CRC32 和 ISIZE 组成:

偏移量 长度 (字节) 字段名 描述
0 4 CRC-32 原始未压缩数据的 CRC-32 校验和。
4 4 ISIZE 原始未压缩数据的大小 (字节数)。由于它只有 4 字节,gzip 文件无法正确表示大于 4GB 的文件(解压后的大小)。

这两个值是 gzip 压缩过程中需要从整个文件角度计算的信息,由于两者均可以增量计算,问题不大。(crc32 本身计算量不大,推荐直接使用 sheetjs 库就行)

这样的话,我们就得到了这样的代码:

export function createGzipFooter(crc32: number, size: number): Uint8Array {
  const footer = new Uint8Array(8);
  const view = new DataView(footer.buffer);
  view.setUint32(0, crc32, true);
  view.setUint32(4, size % 0x100000000, true);
  return footer;
}

3.2 BODY

对我们来说,中间的 raw 流是最麻烦的。

gzip body 中的 DEFLATE 流 (RFC 1951) 并不是一个单一的、连续的东西,它本身就有一套非常重要的“特殊规则”。

DEFLATE 流的真正结构是由一个或多个数据“块” (Block) 拼接而成的。

gzip压缩器在工作时,会根据数据的情况,智能地将原始数据分割成不同类型的“块”来处理。它可能会先用一种块,然后再换另一种,以达到最佳的压缩效果。

DEFLATE 流中的每一个“块”,都必须以一个 3-bit (比特) 的头部开始。这个 3-bit 的头部定义了这个块的所有规则。

这 3 个 bit (比特) 分为两部分:

  1. BFINAL (1-bit): “最后一块”标记

    • 1: 这是整个 DEFLATE 流的最后一个块。解压器在处理完这个块后,就应该停止,并去寻找 gzip 的 Footer (CRC-32 和 ISIZE)。
    • 0: 后面还有更多的块,请继续。
  2. BTYPE (2-bits): “块类型”

    • 这 2 个 bit 决定了紧随其后的整个块的数据要如何被解析。

BTYPE 字段有三种可能的值,每一种都代表一套完全不同的压缩规则:

****规则 1:BTYPE = 00 (无压缩块) 压缩器在分析数据时,如果发现数据是完全随机的(比如已经压缩过的图片、或加密数据),它会发现压缩后的体积反而变大了。

  • 此时,它会切换到 00 模式,意思是:“我放弃压缩,直接原文存储。”

  • 结构:

    1. (BFINAL, 00) 这 3-bit 头部。
    2. 跳到下一个字节边界 (Byte-alignment)。
    3. LEN (2 字节): 声明这个块里有多少字节的未压缩数据(长度 N)。
    4. NLEN (2 字节): LEN 的“反码”(NOT LEN),用于校验 LEN 是否正确。
    5. N 字节的原始数据(原文照搬)。

规则 2:BTYPE = 01 (静态霍夫曼压缩)

  • 这是“标准”规则。 压缩器使用一套固定的、在 RFC-1951 规范中预先定义好的霍夫曼树(Huffman Tree)来进行压缩。

  • 这套“静态树”是基于对大量英语文本统计分析后得出的最佳通用编码表(例如,'e'、'a'、' ' 的编码非常短)。

  • 优点: 压缩器不需要在数据流中包含霍夫曼树本身,解压器直接使用它内置的这套标准树即可。这节省了头部空间。

  • 缺点: 如果你的数据不是英语文本(比如是中文或代码),这套树的效率可能不高。

  • 结构:

    1. (BFINAL, 01) 这 3-bit 头部。
    2. 紧接着就是使用“静态树”编码的 LZ77 + 霍夫曼编码 的数据流。
    3. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(静态树中的 256 号符号)结尾。

规则 3:BTYPE = 10 (动态霍夫曼压缩)

  • 这是“定制”规则,也是压缩率最高的规则。

  • 压缩器会先分析这个块的数据,统计出所有字符的准确频率,然后为这个块“量身定做”一套最优的霍夫曼树。

  • 优点: 压缩率最高,因为它完美贴合了当前数据块的特征(比如在压缩 JS 时,{ } ( ) . 的编码会变得极短)。

  • 缺点: 压缩器必须把这套“定制树”本身也压缩后,放到这个块的开头,以便解压器知道该如何解码。这会占用一些头部空间。

  • 结构:

    1. (BFINAL, 10) 这 3-bit 头部。
    2. 一个“定制霍夫曼树”的描述信息(这部分本身也是被压缩的)。
    3. 紧接着是使用这套“定制树”编码的 LZ77 + 霍夫曼编码 的数据流。
    4. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(定制树中的 256 号符号)结尾。

——不过,于我们而言,我们先通过静态霍夫曼压缩即可。

这个过程中,我们需要借助三方库,目前浏览器虽然支持 CompressionStream API,但并不支持我们进行精确流控制。

import pako from 'pako';

export async function compressBufferRaw(buf: ArrayBufferLike, isLast?: boolean): Promise<ArrayBufferLike> {
  const originalData = new Uint8Array(buf);

  const deflater = new pako.Deflate({ raw: true });
  deflater.push(originalData, isLast ? pako.constants.Z_FINISH : pako.constants.Z_SYNC_FLUSH);
  if (!isLast) {
    deflater.onEnd(pako.constants.Z_OK);
  }
  const compressedData = deflater.result;
  return compressedData.buffer;
}

我们用一个示例来表示一个完整 gzip 文件的话,方便理解。假设我们压缩一个叫 test.txt 的文件,它的 Gzip 文件 test.txt.gz 在十六进制编辑器中可能如下所示:

Offset  Data
------  -------------------------------------------------------------
0000    1F 8B         (ID1, ID2: Gzip 魔术数字)
0002    08            (CM: DEFLATE)
0003    08            (FLG: 0x08 = FNAME 标志位置 1)
0004    XX XX XX XX   (MTIME: 4 字节时间戳)
0008    04            (XFL: 最快压缩)
0009    03            (OS: Unix)

(可选头部开始)
000A    74 65 73 74   (t e s t)
000E    2E 74 78 74   (. t x t)
0012    00            (FNAME: NULL 终结符)

(Body 开始)
0013    ED C0 ...     (DEFLATE 压缩流开始...)
...
...     ...           (...此块数据流的末尾包含一个 EOB 符号...)
                      (... DEFLATE 压缩流结束)

(Footer 开始)
XXXX    YY YY YY YY   (CRC-32: 原始 test.txt 文件的校验和)
XXXX+4  ZZ ZZ ZZ ZZ   (ISIZE: 原始 test.txt 文件的大小)

至此,我们完成了一套社区前列的分片上传方案。S3 将所有上传的部分按序合并后,在S3上形成的文件结构是:[Gzip Header][Deflate_Chunk_1][Deflate_Chunk_2]...[Deflate_Last_Chunk][Gzip Footer] 这个拼接起来的文件是一个完全合法、可流式解压的 .gz 文件。

4 性能 & 对比

为了验证该方案(Smart S3 Gzip)的实际效果,我们构建了一个基准测试环境,将本文方案与「普通直传」及「传统前端压缩上传」进行全方位对比。

4.1 测试环境

  • 测试文件:1GB Nginx Access Log (纯文本)
  • 网络环境:模拟家用宽带上行 50Mbps (约 6.25MB/s)
  • 测试设备:MacBook Pro (M1 Pro), 32GB RAM
  • 浏览器:Chrome 143

4.2 核心指标对比

核心指标 方案 A:普通直传 方案 B:前端整体压缩 方案 C:本文方案 (分片 Gzip 流)
上传总耗时 ~165 秒 ~45 秒 (但等待压缩很久) ~38 秒 (边压边传)
首字节发送时间 0 秒 (立即开始) 30 秒+ (需等待压缩完成) 0.5 秒 (首个分片压缩完即发)
峰值内存占用(计算值) 50MB (流式) 2GB+ (需读入全量文件) 100MB (仅缓存并发分片)
网络流量消耗 1GB ~120MB ~121MB (略多出的 Header 开销可忽略)
客户端 CPU 负载 极低 (<5%) 单核 100% (持续一段时间,可能 OOM) 多核均衡 (并发压缩,利用率高)

4.3 深度解析

🚀 1. 速度提升的秘密:流水线效应

在方案 B(整体压缩)中,用户必须等待整个 1GB 文件在本地压缩完成,才能开始上传第 1 个字节。这是一种「串行阻断」模型。 而本文方案 C 采用了「流水线(Pipeline)」模型:压缩第 N 个分片的同时,正在上传第 N-1 个分片。 对于高压缩率的文本文件(通常压缩比 5:1 到 10:1),网络传输往往比本地 CPU 压缩要慢。这意味着 CPU 的压缩几乎是“免费”的,因为它掩盖在了网络传输的时间里。

💰 2. 成本分析:不仅是快,还省钱

AWS S3 的计费主要包含存储费和流量费。

  • 存储成本:1GB 的日志存入 S3,如果未压缩,每月存储费是压缩后的 5-10 倍。虽然 S3 本身很便宜,但对于 PB 级日志归档,这笔费用惊人。
  • 传输加速成本:如果使用了 S3 Transfer Acceleration,费用是按流量计算的。压缩后上传意味着流量费用直接打一折。

🛡️ 3. 内存安全性

方案 B 是前端的大忌。试图将 1GB 文件读入 ArrayBuffer 进行整体 gzip 压缩,极其容易导致浏览器 Tab 崩溃(OOM)。本文方案将内存控制在 分片大小 * 并发数 (例如 5MB * 5 = 25MB) 的安全范围内,即使上传 100GB 文件也不会爆内存。

4.4 适用场景与局限性

✅ 强烈推荐场景:

  • 日志归档 / 数据备份:CSV, JSON, SQL Dump, Log 文件。压缩率极高,收益巨大。
  • 弱网环境:上传带宽受限时,压缩能显著减少等待时间。

❌ 不推荐场景:

  • 已经压缩的文件:MP4, JPG, ZIP, PNG。再次 Gzip 几乎无压缩效果,反而浪费 CPU。
  • 超低端设备:如果用户的设备是性能极差的老旧手机,CPU 压缩速度可能低于网络上传速度,反而成为瓶颈。建议在 SDK 增加 navigator.hardwareConcurrency 检测,自动降级。

5 结语

通过深入理解 HTTP、AWS S3 协议以及 Gzip 的二进制结构,我们打破了“压缩”与“分片”不可兼得的魔咒。这套系统目前已在我们内部的日志回放平台稳定运行,有效减少文件上传时长。

有时候,技术的突破口往往就藏在那些看似枯燥的 RFC 文档里。希望这篇“硬核”的实战总结,能给你带来一些启发。

JavaScript 中的 sort 排序问题

2026年1月12日 22:12

在 JavaScript 中,以下两种写法是等价的:

写法一:

let fruits = ["banana", "apple", "cherry", "Apple"]  
fruits.sort()  
console.log(fruits) // ["Apple", "apple", "banana", "cherry"]  

写法二:

let fruits = ["banana", "apple", "cherry", "Apple"]
fruits.sort((a, b) => {
  return a > b ? 1 : -1
})
console.log(fruits)

sort 排序基本原理

因为 sort 函数默认是字符的 ASCII 码升序排列的。

比如:

'A'.charCode() // 65
'a'.charCode() // 97
'b'.charCode() // 98

因此如果是10和2排序的话,其实是'10'和'2'排序,'1'.charCode() 为 49,'2'.charCode() 为 50,导致出现 2 比 10 大,出现在 10 后面。

比如下面的代码:

let nums = [3, 10, 2]
nums.sort()
console.log('nums') // [10, 2, 3]

基础

那么问题来了,如果我想实现以下数组按照 appName 字典顺序降序排列怎么办?

let apps = [
  ['chrome', { cpu: 30, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['firefox', { cpu: 80, memory: 90 }],
  ['safari', { cpu: 10, memory: 50 }],
]

注:chrome、edge 这些是 appName

欢迎在评论区解答。

进阶

再扩展一下,给定一个数组 sortRules,这个数组只能取 cpu 和 memory 两个值,可能是 0、1、2 个。

比如 sortRules 可能是:[]['cpu']['memory', 'cpu']['cpu', 'memory'] 等。

请实现先按照给定的 sortRules 的值依次升序排序,再按照 appName 降序排序。

比如 sortRules 是 ['cpu'],则排序结果是:

let apps = [
  ['safari', { cpu: 10, memory: 50 }],
  ['chrome', { cpu: 30, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['firefox', { cpu: 80, memory: 90 }],
]

比如 sortRules 是 ['cpu', 'memory'],则排序结果是:

let apps = [
  ['safari', { cpu: 10, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['chrome', { cpu: 30, memory: 50 }],
  ['firefox', { cpu: 80, memory: 90 }],
]

欢迎在评论区回复~

Fork 主题如何更新?基于 Ink 构建主题更新 CLI 工具

作者 cos
2026年1月12日 20:56

本文地址:blog.cosine.ren/post/intera…

本文图表、伪代码等由 AI 辅助编写

背景

当你 fork 了一个开源项目作为自己的博客主题,如何优雅地从上游仓库同步更新?手动敲一串 Git 命令既繁琐又容易出错;但直接点 Fork 的 Sync 按钮,又可能覆盖你的自定义配置和内容。

很多人因此在「保持更新」和「保留修改」之间左右为难:要么干脆二开后不再同步,要么每次更新都提心吊胆。

这也是为什么不少项目会像 @fumadocs/cli 一样,提供专门的 CLI 来完成更新等相关操作。

本文将介绍如何简单地构建一个交互式 CLI 工具,把 fork 同步的流程自动化起来。

这个工具的核心目标是:

  • 安全:更新前检查工作区状态,必要时可备份
  • 透明:预览所有变更,让用户决定是否更新
  • 友好:出现冲突时给出明确指引

具体的代码可以看这个 PR:

github.com/cosZone/ast…

不过这个 PR 只是最初的版本,后面又缝缝补补了不少东西,整体流程是我研究一个周末后摸索出的,如有不足,那一定是我考虑不周,欢迎指出~

在这个 PR 里,我基于 Ink 构建了一个交互式 TUI 工具,提供了博客内容备份/还原、主题更新、内容生成、备份管理等功能:

pnpm koharu # 交互式主菜单
pnpm koharu backup # 备份博客内容 (--full 完整备份)
pnpm koharu restore # 还原备份 (--latest, --dry-run, --force)
pnpm koharu update # 从上游同步更新 (--check, --skip-backup, --force)
pnpm koharu generate # 生成内容资产 (LQIP, 相似度, AI 摘要)
pnpm koharu clean # 清理旧备份 (--keep N)
pnpm koharu list # 查看所有备份

其中备份功能可以:

  • 基础备份:博客文章、配置、头像、.env
  • 完整备份:包含所有图片和生成的资产文件
  • 自动生成 manifest.json 记录主题版本与备份元信息(时间等)

还原功能可以:

  • 交互式选择备份文件
  • 支持 --dry-run 预览模式
  • 显示备份类型、版本、时间等元信息

主题更新功能可以:

  • 自动配置 upstream remote 指向原始仓库
  • 预览待合并的提交列表(显示 hash、message、时间)
  • 更新前可选备份,支持冲突检测与处理
  • 合并成功后自动安装依赖
  • 支持 --check 仅检查更新、--force 跳过工作区检查

整体架构

infographic sequence-snake-steps-underline-text
data
  title Git Update 命令流程
  desc 从 upstream 同步更新的完整工作流
  items
    - label 检查状态
      desc 验证当前分支和工作区状态
      icon mdi/source-branch-check
    - label 配置远程
      desc 确保 upstream remote 已配置
      icon mdi/source-repository
    - label 获取更新
      desc 从 upstream 拉取最新提交
      icon mdi/cloud-download
    - label 预览变更
      desc 显示待合并的提交列表
      icon mdi/file-find
    - label 确认备份
      desc 可选:备份当前内容
      icon mdi/backup-restore
    - label 执行合并
      desc 合并 upstream 分支到本地
      icon mdi/merge
    - label 处理结果
      desc 成功则安装依赖,冲突则提示解决
      icon mdi/check-circle

更新相关 Git 命令详解

1. 检查当前分支

git rev-parse --abbrev-ref HEAD

作用:获取当前所在分支的名称。

参数解析

  • rev-parse:解析 Git 引用
  • --abbrev-ref:输出简短的引用名称(如 main),而不是完整的 SHA

使用场景:确保用户在正确的分支(如 main)上执行更新,避免在 feature 分支上意外合并上游代码。

const currentBranch = execSync("git rev-parse --abbrev-ref HEAD")
  .toString()
  .trim();
if (currentBranch !== "main") {
  throw new Error(`仅支持在 main 分支执行更新,当前分支: ${currentBranch}`);
}

2. 检查工作区状态

git status --porcelain

作用:以机器可读的格式输出工作区状态。

参数解析

  • --porcelain:输出稳定、易于解析的格式,不受 Git 版本和语言设置影响

输出格式

M  modified-file.ts      # 已暂存的修改
 M unstaged-file.ts      # 未暂存的修改
?? untracked-file.ts     # 未跟踪的文件
A  new-file.ts           # 新添加的文件
D  deleted-file.ts       # 删除的文件

前两个字符分别表示暂存区和工作区的状态。

const statusOutput = execSync("git status --porcelain").toString();
const uncommittedFiles = statusOutput.split("\n").filter((line) => line.trim());
const isClean = uncommittedFiles.length === 0;

3. 管理远程仓库

检查 remote 是否存在

git remote get-url upstream

作用:获取指定 remote 的 URL,如果不存在会报错。

添加 upstream remote

# 将 URL 替换为你的上游仓库地址
git remote add upstream https://github.com/original/repo.git

作用:添加一个名为 upstream 的远程仓库,指向原始项目。

为什么需要 upstream?

当你 fork 一个项目后,你的 origin 指向你自己的 fork,而 upstream 指向原始项目。这样可以:

  • upstream 拉取原项目的更新
  • origin 推送你的修改
// UPSTREAM_URL 需替换为你的上游仓库地址
const UPSTREAM_URL = "https://github.com/original/repo.git";

function ensureUpstreamRemote(): string {
  try {
    return execSync("git remote get-url upstream").toString().trim();
  } catch {
    execSync(`git remote add upstream ${UPSTREAM_URL}`);
    return UPSTREAM_URL;
  }
}

4. 获取远程更新

git fetch upstream

作用:从 upstream 远程仓库下载所有分支的最新提交,但不会自动合并到本地分支。

git pull 的区别

  • fetch 只下载数据,不修改本地代码
  • pull = fetch + merge,会自动合并

使用 fetch 可以让我们先预览变更,再决定是否合并。

5. 计算提交差异

git rev-list --left-right --count HEAD...upstream/main

作用:计算本地分支与 upstream/main 之间的提交差异。

参数解析

  • rev-list:列出提交记录
  • --left-right:区分左侧(本地)和右侧(远程)的提交
  • --count:只输出计数,不列出具体提交
  • HEAD...upstream/main:三个点表示对称差集

输出示例

2    5

表示本地有 2 个提交不在 upstream 上(ahead),upstream 有 5 个提交不在本地(behind)。

const revList = execSync(
  "git rev-list --left-right --count HEAD...upstream/main"
)
  .toString()
  .trim();
const [aheadStr, behindStr] = revList.split("\t");
const aheadCount = parseInt(aheadStr, 10);
const behindCount = parseInt(behindStr, 10);

console.log(`本地领先 ${aheadCount} 个提交,落后 ${behindCount} 个提交`);

6. 查看待合并的提交

git log HEAD..upstream/main --pretty=format:"%h|%s|%ar|%an" --no-merges

作用:列出 upstream/main 上有但本地没有的提交。

参数解析

  • HEAD..upstream/main:两个点表示 A 到 B 的差集(B 有而 A 没有的)
  • --pretty=format:"...":自定义输出格式
    • %h:短 hash
    • %s:提交信息
    • %ar:相对时间(如 "2 days ago")
    • %an:作者名
  • --no-merges:排除 merge commit

输出示例

a1b2c3d|feat: add dark mode|2 days ago|Author Name
e4f5g6h|fix: typo in readme|3 days ago|Author Name
const commitFormat = "%h|%s|%ar|%an";
const output = execSync(
  `git log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
).toString();

const commits = output
  .split("\n")
  .filter(Boolean)
  .map((line) => {
    const [hash, message, date, author] = line.split("|");
    return { hash, message, date, author };
  });

7. 查看远程文件内容

git show upstream/main:package.json

作用:直接查看远程分支上某个文件的内容,无需切换分支或合并。

使用场景:获取上游仓库的版本号,用于显示"将更新到 x.x.x 版本"。

const packageJson = execSync("git show upstream/main:package.json").toString();
const { version } = JSON.parse(packageJson);
console.log(`最新版本: ${version}`);

8. 执行合并

git merge upstream/main --no-edit

作用:将 upstream/main 分支合并到当前分支。

参数解析

  • --no-edit:使用自动生成的合并提交信息,不打开编辑器

合并策略:Git 会自动选择合适的合并策略:

  • Fast-forward:如果本地没有新提交,直接移动指针
  • Three-way merge:如果有分叉,创建一个合并提交

注意:本工具采用 merge 同步上游,保留本地历史。如果你的需求是"强制与上游一致"(丢弃本地修改),需要使用 rebase 或 reset 方案,不在本文讨论范围。

9. 检测合并冲突

git diff --name-only --diff-filter=U

作用:列出所有未解决冲突的文件。

参数解析

  • --name-only:只输出文件名
  • --diff-filter=U:只显示 Unmerged(未合并/冲突)的文件

另一种方式是解析 git status --porcelain 的输出,查找冲突标记:

const statusOutput = execSync("git status --porcelain").toString();
const conflictFiles = statusOutput
  .split("\n")
  .filter((line) => {
    const status = line.slice(0, 2);
    // U = Unmerged, AA = both added, DD = both deleted
    return status.includes("U") || status === "AA" || status === "DD";
  })
  // 注:为简化展示,这里直接截取路径
  // 若需完整兼容重命名/特殊路径,应使用更严格的 porcelain 解析
  .map((line) => line.slice(3).trim());

10. 中止合并

git merge --abort

作用:中止当前的合并操作,恢复到合并前的状态。

使用场景:当用户遇到冲突但不想手动解决时,可以选择中止合并。

function abortMerge(): boolean {
  try {
    execSync("git merge --abort");
    return true;
  } catch {
    return false;
  }
}

状态机设计

如果是简单粗暴的使用 useEffect 的话,会出现很多 useEffect 那自然很不好。

整个更新流程使用简单的 useReducer + Effect Map 模式管理,将状态转换逻辑和副作用处理分离,确保流程清晰可控。

为什么不用 Redux?

在设计 CLI 状态管理时,很自然会想到 Redux,毕竟它是 React 生态中最成熟的状态管理方案,而且还是用着 Ink 来进行开发的。但对于 CLI 工具,useReducer 是更合适的选择,理由如下:

  1. 状态作用域单一:CLI 工具通常是单组件树结构,不存在跨页面、跨路由的状态共享需求,
  2. 无需 Middleware 生态:Redux 的强大之处在于中间件生态(redux-thunk、redux-saga、redux-observable),用于处理复杂的异步流程。但我们的场景不需要那么复杂。
  3. 依赖最小化:CLI 工具应该快速启动、轻量运行useReducer 内置于 React,不会引入额外依赖(当然 React 本身也是依赖,不过我的项目里本来就需要它)

总之,对这个场景来说 Redux 有点"过度设计"。

那咋整?

  • Reducer:集中管理所有状态转换逻辑,纯函数易于测试
  • Effect Map:状态到副作用的映射,统一处理异步操作
  • 单一 Effect:一个 useEffect 驱动整个流程

下面是完整的状态转换流程图,展示了所有可能的状态转换路径和条件分支:

注意:Mermaid stateDiagram 中状态名不能包含连字符 -,这里使用 camelCase 命名。

stateDiagram-v2
    [*] --> checking: 开始更新

    checking --> error: 不在 main 分支
    checking --> dirtyWarning: 工作区不干净 && !force
    checking --> fetching: 工作区干净 || force

    dirtyWarning --> [*]: 用户取消
    dirtyWarning --> fetching: 用户继续

    fetching --> upToDate: behindCount = 0
    fetching --> backupConfirm: behindCount > 0 && !skipBackup
    fetching --> preview: behindCount > 0 && skipBackup

    backupConfirm --> backingUp: 用户确认备份
    backupConfirm --> preview: 用户跳过备份

    backingUp --> preview: 备份完成
    backingUp --> error: 备份失败

    preview --> [*]: checkOnly 模式
    preview --> merging: 用户确认更新
    preview --> [*]: 用户取消

    merging --> conflict: 合并冲突
    merging --> installing: 合并成功

    conflict --> [*]: 用户处理冲突

    installing --> done: 依赖安装成功
    installing --> error: 依赖安装失败

    done --> [*]
    error --> [*]
    upToDate --> [*]

类型定义

// 12 种状态覆盖完整流程
type UpdateStatus =
  | "checking" // 检查 Git 状态
  | "dirty-warning" // 工作区有未提交更改
  | "backup-confirm" // 确认备份
  | "backing-up" // 正在备份
  | "fetching" // 获取更新
  | "preview" // 显示更新预览
  | "merging" // 合并中
  | "installing" // 安装依赖
  | "done" // 完成
  | "conflict" // 有冲突
  | "up-to-date" // 已是最新
  | "error"; // 错误

// Action 驱动状态转换
type UpdateAction =
  | { type: "GIT_CHECKED"; payload: GitStatusInfo }
  | { type: "FETCHED"; payload: UpdateInfo }
  | { type: "BACKUP_CONFIRM" | "BACKUP_SKIP" | "UPDATE_CONFIRM" | "INSTALLED" }
  | { type: "BACKUP_DONE"; backupFile: string }
  | { type: "MERGED"; payload: MergeResult }
  | { type: "ERROR"; error: string };

Reducer 集中状态转换

所有状态转换逻辑集中在 reducer 中,每个 case 只处理当前状态下合法的 action:

function updateReducer(state: UpdateState, action: UpdateAction): UpdateState {
  const { status, options } = state;

  // 通用错误处理:任何状态都可以转到 error
  if (action.type === "ERROR") {
    return { ...state, status: "error", error: action.error };
  }

  switch (status) {
    case "checking": {
      if (action.type !== "GIT_CHECKED") return state;
      const { payload: gitStatus } = action;

      if (gitStatus.currentBranch !== "main") {
        return {
          ...state,
          status: "error",
          error: "仅支持在 main 分支执行更新",
        };
      }
      if (!gitStatus.isClean && !options.force) {
        return { ...state, status: "dirty-warning", gitStatus };
      }
      return { ...state, status: "fetching", gitStatus };
    }

    case "fetching": {
      if (action.type !== "FETCHED") return state;
      const { payload: updateInfo } = action;

      if (updateInfo.behindCount === 0) {
        return { ...state, status: "up-to-date", updateInfo };
      }
      const nextStatus = options.skipBackup ? "preview" : "backup-confirm";
      return { ...state, status: nextStatus, updateInfo };
    }

    // ... 其他状态处理
  }
}

Effect Map:统一副作用处理

每个需要执行副作用的状态对应一个 effect 函数,可返回 cleanup 函数:

type EffectFn = (
  state: UpdateState,
  dispatch: Dispatch<UpdateAction>
) => (() => void) | undefined;

const statusEffects: Partial<Record<UpdateStatus, EffectFn>> = {
  checking: (_state, dispatch) => {
    const gitStatus = checkGitStatus();
    ensureUpstreamRemote();
    dispatch({ type: "GIT_CHECKED", payload: gitStatus });
    return undefined;
  },

  fetching: (_state, dispatch) => {
    fetchUpstream();
    const info = getUpdateInfo();
    dispatch({ type: "FETCHED", payload: info });
    return undefined;
  },

  installing: (_state, dispatch) => {
    let cancelled = false;
    installDeps().then((result) => {
      if (cancelled) return;
      dispatch(
        result.success
          ? { type: "INSTALLED" }
          : { type: "ERROR", error: result.error }
      );
    });
    return () => {
      cancelled = true;
    }; // cleanup
  },
};

组件使用

组件中只需一个核心 useEffect 来驱动整个状态机:

function UpdateApp({ checkOnly, skipBackup, force }) {
  const [state, dispatch] = useReducer(
    updateReducer,
    { checkOnly, skipBackup, force },
    createInitialState
  );

  // 核心:单一 effect 处理所有副作用
  useEffect(() => {
    const effect = statusEffects[state.status];
    if (!effect) return;
    return effect(state, dispatch);
  }, [state.status, state]);

  // UI 渲染基于 state.status
  return <Box>...</Box>;
}

这种模式的优势:

  • 可测试性:Reducer 是纯函数,可以独立测试状态转换
  • 可维护性:状态逻辑集中,不会分散在多个 useEffect
  • 可扩展性:添加新状态只需在 reducer 和 effect map 各加一个 case

用户交互设计

使用 React Ink 构建终端 UI,提供友好的交互体验:

预览更新

发现 5 个新提交:
  a1b2c3d feat: add dark mode (2 days ago)
  e4f5g6h fix: responsive layout (3 days ago)
  i7j8k9l docs: update readme (1 week ago)
  ... 还有 2 个提交

注意: 本地有 1 个未推送的提交

确认更新到最新版本? (Y/n)

处理冲突

发现合并冲突
冲突文件:
  - src/config.ts
  - src/components/Header.tsx

你可以:
  1. 手动解决冲突后运行: git add . && git commit
  2. 中止合并恢复到更新前状态

备份文件: backup-2026-01-10-full.tar.gz

是否中止合并? (Y/n)

完整代码实现

Git 操作封装

import { execSync } from "node:child_process";

function git(args: string): string {
  return execSync(`git ${args}`, {
    encoding: "utf-8",
    stdio: ["pipe", "pipe", "pipe"],
  }).trim();
}

function gitSafe(args: string): string | null {
  try {
    return git(args);
  } catch {
    return null;
  }
}

export function checkGitStatus(): GitStatusInfo {
  const currentBranch = git("rev-parse --abbrev-ref HEAD");
  const statusOutput = gitSafe("status --porcelain") || "";
  const uncommittedFiles = statusOutput
    .split("\n")
    .filter((line) => line.trim());

  return {
    currentBranch,
    isClean: uncommittedFiles.length === 0,
    // 注:简化处理,完整兼容需更严格的 porcelain 解析
    uncommittedFiles: uncommittedFiles.map((line) => line.slice(3).trim()),
  };
}

export function getUpdateInfo(): UpdateInfo {
  const revList =
    gitSafe("rev-list --left-right --count HEAD...upstream/main") || "0\t0";
  const [aheadStr, behindStr] = revList.split("\t");

  const commitFormat = "%h|%s|%ar|%an";
  const commitsOutput =
    gitSafe(
      `log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
    ) || "";

  const commits = commitsOutput
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      const [hash, message, date, author] = line.split("|");
      return { hash, message, date, author };
    });

  return {
    behindCount: parseInt(behindStr, 10),
    aheadCount: parseInt(aheadStr, 10),
    commits,
  };
}

export function mergeUpstream(): MergeResult {
  try {
    git("merge upstream/main --no-edit");
    return { success: true, hasConflict: false, conflictFiles: [] };
  } catch {
    const conflictFiles = getConflictFiles();
    return {
      success: false,
      hasConflict: conflictFiles.length > 0,
      conflictFiles,
    };
  }
}

function getConflictFiles(): string[] {
  const output = gitSafe("diff --name-only --diff-filter=U") || "";
  return output.split("\n").filter(Boolean);
}

Git 命令速查表

命令 作用 场景
git rev-parse --abbrev-ref HEAD 获取当前分支名 验证分支
git status --porcelain 机器可读的状态输出 检查工作区
git remote get-url <name> 获取 remote URL 检查 remote
git remote add <name> <url> 添加 remote 配置 upstream
git fetch <remote> 下载远程更新 获取更新
git rev-list --left-right --count A...B 统计差异提交数 计算 ahead/behind
git log A..B --pretty=format:"..." 列出差异提交 预览更新
git show <ref>:<path> 查看远程文件 获取版本号
git merge <branch> --no-edit 自动合并 执行更新
git diff --name-only --diff-filter=U 列出冲突文件 检测冲突
git merge --abort 中止合并 回滚操作

Git 命令功能分类

为了更好地理解这些命令的用途,下面按功能将它们分类展示:

infographic hierarchy-structure
data
  title Git 命令功能分类
  desc 按操作类型组织的命令清单
  items
    - label 状态检查
      icon mdi/information
      children
        - label git rev-parse
          desc 获取当前分支名
        - label git status --porcelain
          desc 检查工作区状态
    - label 远程管理
      icon mdi/server-network
      children
        - label git remote get-url
          desc 检查 remote 是否存在
        - label git remote add
          desc 添加 upstream remote
        - label git fetch
          desc 下载远程更新
    - label 提交分析
      icon mdi/source-commit
      children
        - label git rev-list
          desc 统计提交差异
        - label git log
          desc 查看提交历史
        - label git show
          desc 查看远程文件内容
    - label 合并操作
      icon mdi/source-merge
      children
        - label git merge
          desc 执行分支合并
        - label git merge --abort
          desc 中止合并恢复状态
    - label 冲突检测
      icon mdi/alert-octagon
      children
        - label git diff --diff-filter=U
          desc 列出未解决冲突文件

备份还原功能实现

除了主题更新,CLI 还提供了完整的备份还原功能,确保用户数据安全。

备份和还原是两个互补的操作,下图展示了它们的完整工作流:

infographic compare-hierarchy-row-letter-card-compact-card
data
  title 备份与还原流程对比
  desc 两个互补操作的完整工作流
  items
    - label 备份流程
      icon mdi/backup-restore
      children
        - label 检查配置
          desc 确定备份类型和范围
        - label 创建临时目录
          desc 准备暂存空间
        - label 复制文件
          desc 按配置复制所需文件
        - label 生成 manifest
          desc 记录版本和元信息
        - label 压缩打包
          desc tar.gz 压缩存档
        - label 清理临时目录
          desc 删除暂存目录
    - label 还原流程
      icon mdi/restore
      children
        - label 选择备份
          desc 读取 manifest 显示备份信息
        - label 解压到临时目录
          desc 提取归档内容(包含 manifest)
        - label 读取 manifest.files
          desc 获取实际备份成功的文件列表
        - label 按映射复制文件
          desc 使用自动生成的 RESTORE_MAP
        - label 清理临时目录
          desc 删除解压的暂存文件

备份项配置

备份系统采用配置驱动的方式,定义需要备份的文件和目录:

export interface BackupItem {
  src: string; // 源路径(相对于项目根目录)
  dest: string; // 备份内目标路径
  label: string; // 显示标签
  required: boolean; // 是否为必需项(basic 模式包含)
}

export const BACKUP_ITEMS: BackupItem[] = [
  // 基础备份项(required: true)
  {
    src: "src/content/blog",
    dest: "content/blog",
    label: "博客文章",
    required: true,
  },
  {
    src: "config/site.yaml",
    dest: "config/site.yaml",
    label: "网站配置",
    required: true,
  },
  {
    src: "src/pages/about.md",
    dest: "pages/about.md",
    label: "关于页面",
    required: true,
  },
  {
    src: "public/img/avatar.webp",
    dest: "img/avatar.webp",
    label: "用户头像",
    required: true,
  },
  { src: ".env", dest: "env", label: "环境变量", required: true },
  // 完整备份额外项目(required: false)
  { src: "public/img", dest: "img", label: "所有图片", required: false },
  {
    src: "src/assets/lqips.json",
    dest: "assets/lqips.json",
    label: "LQIP 数据",
    required: false,
  },
  {
    src: "src/assets/similarities.json",
    dest: "assets/similarities.json",
    label: "相似度数据",
    required: false,
  },
  {
    src: "src/assets/summaries.json",
    dest: "assets/summaries.json",
    label: "AI 摘要数据",
    required: false,
  },
];

备份流程

备份操作使用 tar.gz 格式压缩,并生成 manifest.json 记录元信息:

export function runBackup(
  isFullBackup: boolean,
  onProgress?: (results: BackupResult[]) => void
): BackupOutput {
  // 1. 创建备份目录和临时目录
  fs.mkdirSync(BACKUP_DIR, { recursive: true });
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
  const tempDir = path.join(BACKUP_DIR, `.tmp-backup-${timestamp}`);

  // 2. 过滤备份项目(基础备份只包含 required: true 的项目)
  const itemsToBackup = BACKUP_ITEMS.filter(
    (item) => item.required || isFullBackup
  );

  // 3. 复制文件到临时目录
  const results: BackupResult[] = [];
  for (const item of itemsToBackup) {
    const srcPath = path.join(PROJECT_ROOT, item.src);
    const destPath = path.join(tempDir, item.dest);

    if (fs.existsSync(srcPath)) {
      fs.cpSync(srcPath, destPath, { recursive: true });
      results.push({ item, success: true, skipped: false });
    } else {
      results.push({ item, success: false, skipped: true });
    }
    onProgress?.([...results]); // 进度回调
  }

  // 4. 生成 manifest.json
  const manifest = {
    name: "astro-koharu-backup",
    version: getVersion(),
    type: isFullBackup ? "full" : "basic",
    timestamp,
    created_at: new Date().toISOString(),
    files: Object.fromEntries(results.map((r) => [r.item.dest, r.success])),
  };
  fs.writeFileSync(
    path.join(tempDir, "manifest.json"),
    JSON.stringify(manifest, null, 2)
  );

  // 5. 压缩并清理
  tarCreate(backupFilePath, tempDir);
  fs.rmSync(tempDir, { recursive: true, force: true });

  return { results, backupFile: backupFilePath, fileSize, timestamp };
}

tar 操作封装

使用系统 tar 命令进行压缩和解压,并添加路径遍历安全检查:

// 安全验证:防止路径遍历攻击
function validateTarEntries(entries: string[], archivePath: string): void {
  for (const entry of entries) {
    if (entry.includes("\0")) {
      throw new Error(`tar entry contains null byte`);
    }
    const normalized = path.posix.normalize(entry);
    if (path.posix.isAbsolute(normalized)) {
      throw new Error(`tar entry is absolute path: ${entry}`);
    }
    if (normalized.split("/").includes("..")) {
      throw new Error(`tar entry contains parent traversal: ${entry}`);
    }
  }
}

// 创建压缩包
export function tarCreate(archivePath: string, sourceDir: string): void {
  spawnSync("tar", ["-czf", archivePath, "-C", sourceDir, "."]);
}

// 解压到指定目录
export function tarExtract(archivePath: string, destDir: string): void {
  listTarEntries(archivePath); // 先验证条目安全性
  spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
}

// 读取 manifest(不解压整个文件)
export function tarExtractManifest(archivePath: string): string | null {
  const result = spawnSync("tar", ["-xzf", archivePath, "-O", "manifest.json"]);
  return result.status === 0 ? result.stdout : null;
}

还原流程

还原操作基于 manifest 驱动,确保只还原实际备份成功的文件:

// 路径映射:从备份项配置自动生成,确保一致性
export const RESTORE_MAP: Record<string, string> = Object.fromEntries(
  BACKUP_ITEMS.map((item) => [item.dest, item.src])
);

export function restoreBackup(backupPath: string): RestoreResult {
  // 1. 创建临时目录并解压
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
  tarExtract(backupPath, tempDir);

  // 2. 读取 manifest 获取实际备份的文件列表
  const manifestPath = path.join(tempDir, "manifest.json");
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));

  const restored: string[] = [];
  const skipped: string[] = [];

  // 3. 基于 manifest.files 还原(只还原成功备份的文件)
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    // 跳过备份失败的文件
    if (!success) {
      skipped.push(backupPath);
      continue;
    }

    const projectPath = RESTORE_MAP[backupPath];
    if (!projectPath) {
      console.warn(`未知的备份路径: ${backupPath},跳过`);
      skipped.push(backupPath);
      continue;
    }

    const srcPath = path.join(tempDir, backupPath);
    const destPath = path.join(PROJECT_ROOT, projectPath);

    if (fs.existsSync(srcPath)) {
      fs.mkdirSync(path.dirname(destPath), { recursive: true });
      fs.cpSync(srcPath, destPath, { recursive: true });
      restored.push(projectPath);
    } else {
      skipped.push(backupPath);
    }
  }

  // 4. 清理临时目录
  fs.rmSync(tempDir, { recursive: true, force: true });

  return {
    restored,
    skipped,
    backupType: manifest.type,
    version: manifest.version,
  };
}

Dry-Run 模式详解

Dry-run(预演模式)是 CLI 工具中常见的安全特性,允许用户在实际执行前预览操作结果。本实现采用函数分离 + 条件渲染的模式。

下图展示了预览模式和实际执行模式的核心区别:

infographic compare-binary-horizontal-badge-card-arrow
data
  title Dry-Run 模式与实际执行对比
  desc 预览模式和实际还原的关键区别
  items
    - label 预览模式
      desc 安全的只读预览
      icon mdi/eye
      children
        - label 提取 manifest.json
          desc 调用 tarExtractManifest 不解压整个归档
        - label 读取 manifest.files
          desc 获取实际备份的文件列表
        - label 统计文件数量
          desc 调用 tarList 计算每个路径的文件数
        - label 不修改任何文件
          desc 零副作用,可安全执行
    - label 实际执行
      desc 基于 manifest 的还原
      icon mdi/content-save
      children
        - label 解压整个归档
          desc 调用 tarExtract 提取所有文件
        - label 读取 manifest.files
          desc 获取实际备份成功的文件列表
        - label 按 manifest 复制文件
          desc 只还原 success: true 的文件
        - label 显示跳过的文件
          desc 报告 success: false 的文件

预览函数和执行函数

关键在于提供两个功能相似但副作用不同的函数:

// 预览函数:只读取 manifest,不解压不修改文件
export function getRestorePreview(backupPath: string): RestorePreviewItem[] {
  // 只提取 manifest.json,不解压整个归档
  const manifestContent = tarExtractManifest(backupPath);
  if (!manifestContent) {
    throw new Error("无法读取备份 manifest");
  }

  const manifest = JSON.parse(manifestContent);
  const previewItems: RestorePreviewItem[] = [];

  // 基于 manifest.files 生成预览
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    if (!success) continue; // 跳过备份失败的文件

    const projectPath = RESTORE_MAP[backupPath];
    if (!projectPath) continue;

    // 从归档中统计文件数量(不解压)
    const files = tarList(backupPath);
    const matchingFiles = files.filter(
      (f) => f === backupPath || f.startsWith(`${backupPath}/`)
    );
    const fileCount = matchingFiles.length;

    previewItems.push({
      path: projectPath,
      fileCount: fileCount || 1,
      backupPath,
    });
  }

  return previewItems;
}

// 执行函数:实际解压并复制文件
export function restoreBackup(backupPath: string): RestoreResult {
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
  tarExtract(backupPath, tempDir); // 实际解压

  // 读取 manifest 驱动还原
  const manifest = JSON.parse(
    fs.readFileSync(path.join(tempDir, "manifest.json"), "utf-8")
  );

  const restored: string[] = [];
  for (const [backupPath, success] of Object.entries(manifest.files)) {
    if (!success) continue;
    const projectPath = RESTORE_MAP[backupPath];
    // ... 实际复制文件
    fs.cpSync(srcPath, destPath, { recursive: true });
    restored.push(projectPath);
  }

  return { restored, skipped: [], backupType: manifest.type };
}

两个函数的核心区别:

  • 预览:调用 tarExtractManifest() 只提取 manifest,再用 tarList() 统计文件数量
  • 执行:调用 tarExtract() 解压整个归档,基于 manifest.files 复制文件

组件层:条件分发

在 React 组件中,根据 dryRun 参数决定调用哪个函数:

interface RestoreAppProps {
  dryRun?: boolean; // 是否为预览模式
  force?: boolean; // 是否跳过确认
}

export function RestoreApp({ dryRun = false, force = false }: RestoreAppProps) {
  const [result, setResult] = useState<{
    items: RestorePreviewItem[] | string[];
    backupType?: string;
    skipped?: string[];
  }>();

  // 预览模式:只读取 manifest
  const runDryRun = useCallback(() => {
    const previewItems = getRestorePreview(selectedBackup);
    setResult({ items: previewItems });
    setStatus("done");
  }, [selectedBackup]);

  // 实际还原:基于 manifest 执行还原
  const runRestore = useCallback(() => {
    setStatus("restoring");
    const { restored, skipped, backupType } = restoreBackup(selectedBackup);
    setResult({ items: restored, backupType, skipped });
    setStatus("done");
  }, [selectedBackup]);

  // 确认时根据模式分发
  function handleConfirm() {
    if (dryRun) {
      runDryRun();
    } else {
      runRestore();
    }
  }
}

关键设计:

  • 统一数据结构result 可以容纳预览和执行两种结果
  • 类型区分:预览返回 RestorePreviewItem[](含 fileCount),执行返回 string[]
  • 额外信息:执行模式返回 backupTypeskipped,用于显示完整信息

UI 层:差异化展示

预览模式和实际执行模式在 UI 上有明确区分:

{
  /* 确认提示:显示备份类型和文件数量 */
}
<Text color="yellow">
  {dryRun ? "[预览模式] " : ""}
  确认还原 {result?.backupType} 备份? 此操作将覆盖现有文件
</Text>;

{
  /* 完成状态:根据模式显示不同标题 */
}
<Text bold color="green">
  {dryRun ? "预览模式" : "还原完成"}
</Text>;

{
  /* 结果展示:预览模式显示文件数量统计 */
}
{
  result?.items.map((item) => {
    const isPreviewItem = typeof item !== "string";
    const filePath = isPreviewItem ? item.path : item;
    const fileCount = isPreviewItem ? item.fileCount : 0;
    return (
      <Text key={filePath}>
        <Text color="green">{"  "}+ </Text>
        <Text>{filePath}</Text>
        {/* 预览模式额外显示文件数量 */}
        {isPreviewItem && fileCount > 1 && (
          <Text dimColor> ({fileCount} 文件)</Text>
        )}
      </Text>
    );
  });
}

{
  /* 统计文案:使用 "将" vs "已" 区分 */
}
<Text>
  {dryRun ? "将" : "已"}还原: <Text color="green">{result?.items.length}</Text>{" "}
  项
</Text>;

{
  /* 显示跳过的文件(仅实际执行模式) */
}
{
  !dryRun && result?.skipped && result.skipped.length > 0 && (
    <Box flexDirection="column" marginTop={1}>
      <Text color="yellow">跳过的文件:</Text>
      {result.skipped.map((file) => (
        <Text key={file} dimColor>
          {"  "}- {file}
        </Text>
      ))}
    </Box>
  );
}

{
  /* 预览模式特有提示 */
}
{
  dryRun && <Text color="yellow">这是预览模式,没有文件被修改</Text>;
}

{
  /* 实际执行模式:显示后续步骤 */
}
{
  !dryRun && (
    <Box flexDirection="column" marginTop={1}>
      <Text dimColor>后续步骤:</Text>
      <Text dimColor>{"  "}1. pnpm install # 安装依赖</Text>
      <Text dimColor>{"  "}2. pnpm build # 构建项目</Text>
    </Box>
  );
}

命令行使用

# 预览模式:查看将要还原的内容
pnpm koharu restore --dry-run

# 实际执行
pnpm koharu restore

# 跳过确认直接执行
pnpm koharu restore --force

# 还原最新备份(预览)
pnpm koharu restore --latest --dry-run

输出对比

预览模式输出(Full 备份)

备份文件: backup-2026-01-10-12-30-00-full.tar.gz
备份类型: full
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00

[预览模式] 确认还原 full 备份? 此操作将覆盖现有文件 (Y/n)

预览模式
  + src/content/blog (42 文件)
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img (128 文件)
  + src/assets/lqips.json
  + src/assets/similarities.json
  + src/assets/summaries.json

将还原: 8 项
这是预览模式,没有文件被修改

预览模式输出(Basic 备份)

备份文件: backup-2026-01-10-12-30-00-basic.tar.gz
备份类型: basic
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00

[预览模式] 确认还原 basic 备份? 此操作将覆盖现有文件 (Y/n)

预览模式
  + src/content/blog (42 文件)
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img/avatar.webp

将还原: 5 项
这是预览模式,没有文件被修改

实际执行输出(含跳过的文件)

还原完成
  + src/content/blog
  + config/site.yaml
  + src/pages/about.md
  + .env
  + public/img

跳过的文件:
  - src/assets/lqips.json (备份时不存在)

已还原: 5 项
后续步骤:
  1. pnpm install # 安装依赖
  2. pnpm build # 构建项目
  3. pnpm dev # 启动开发服务器

写在最后

能看到这里,那很厉害了,觉得还挺喜欢的话,欢迎给我一个 star 呢~

github.com/cosZone/ast…

自认为这次实现的这个 CLI 对于我自己的需求来说,相当好用,只恨没有早一些实践,如果你看到这篇文章,可以放心大胆的去构建。

相关链接如下

React Ink

Git 同步 Fork

状态机与 useReducer

我用 Gemini 3 Pro 手搓了一个并发邮件群发神器(附源码)

作者 ErpanOmer
2026年1月12日 12:16
这个周末我失业了🤣。 起因很简单:公司项目原因,我需要给订阅列表里的几千个用户发一封更新通知。 市面上的邮件营销工具(Mailchimp 之类)死贵,还要一个个导入联系人;自己写脚本吧,以前得折腾半天

拿捏年终总结:自动提取GitLab提交记录

2026年1月12日 10:29

一、脚本功能概述

这是一个用于自动提取GitLab提交记录的Node.js脚本,专为年终总结设计。它可以:

  1. 根据指定的时间范围批量获取GitLab提交记录
  2. 过滤掉合并提交,只保留实际代码变更
  3. 按项目分组展示提交记录
  4. 生成Markdown格式的提交汇总报告

二、核心模块解析

1. 环境变量读取模块

javascript

function readEnvFile(envPath) {
  const content = fs.readFileSync(envPath, 'utf8');
  const lines = content.split(/\r?\n/).filter(Boolean);
  const env = {};
  for (const line of lines) {
    if (line.trim().startsWith('#')) continue;
    const idx = line.indexOf('=');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim();
    env[key] = value;
  }
  return env;
}

功能说明:读取.env配置文件,解析为键值对。

配置说明

env

# GitLab服务器地址
GITLAB_URL=https://your.gitlab.server.com

# GitLab访问令牌(从GitLab个人设置中获取)
GITLAB_TOKEN=your_gitlab_access_token

# 可选:作者用户名(用于过滤提交)
GITLAB_AUTHOR_USERNAME=your_username

# 可选:指定项目ID(多个用逗号分隔)
GITLAB_PROJECT_IDS=123,456,789

2. 命令行参数解析模块

javascript

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg.startsWith('--')) {
      const [k, v] = arg.split('=');
      args[k.slice(2)] = v;
    }
  }
  return args;
}

功能说明:解析命令行参数,支持--since--until参数。

3. 时间范围处理模块

javascript

function ensureIsoRange(sinceInput, untilInput) {
  const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
  const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
  if (sinceIsDateOnly && untilIsDateOnly) {
    const { since } = toIsoRangeDayStartEnd(sinceInput);
    const { until } = toIsoRangeDayStartEnd(untilInput);
    return { since, until };
  }
  const since = new Date(sinceInput).toISOString();
  const until = new Date(untilInput).toISOString();
  return { since, until };
}

功能说明:将用户输入的时间范围转换为ISO标准格式,支持日期格式和完整时间格式。

4. API请求模块

javascript

function requestJson(urlStr, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const { protocol, hostname, port, pathname, search } = u;
    const lib = protocol === 'https:' ? https : http;
    const options = {
      hostname,
      port: port || (protocol === 'https:' ? 443 : 80),
      path: `${pathname}${search}`,
      method: 'GET',
      headers,
    };
    const req = lib.request(options, (res) => {
      const { statusCode, headers: resHeaders } = res;
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if (statusCode >= 200 && statusCode < 300) {
          try {
            const json = JSON.parse(body);
            resolve({ json, headers: resHeaders, statusCode });
          } catch (e) {
            reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
          }
        } else {
          reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

功能说明:发送HTTP/HTTPS请求,返回JSON格式的响应。

5. GitLab API调用模块

javascript

async function fetchAllCommits(baseUrl, token, id, since, until, author) {
  const collected = [];
  let page = 1;
  for (;;) {
    const params = { since, until, per_page: 100, page, with_stats: false, author };
    const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
    collected.push(...commits);
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return collected;
}

功能说明:分页获取GitLab提交记录,支持作者过滤。

6. 提交记录过滤模块

javascript

function filterNonMerge(commits) {
  const filtered = [];
  for (const commit of commits) {
    const { parent_ids } = commit;
    const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
    if (nonMerge) filtered.push(commit);
  }
  return filtered;
}

功能说明:过滤掉合并提交,只保留实际代码变更的提交。

7. 报告生成模块

javascript

function buildMarkdown(range, author, grouped) {
  const { since, until } = range;
  const { username, name } = author;
  const lines = [];
  lines.push(`# 提交汇总`);
  lines.push(`- 作者: ${name || username || ''}`);
  lines.push(`- 时间范围: ${since}${until}`);
  for (const project of grouped.projects) {
    const { name: projName } = project.meta;
    lines.push(`\n项目: ${projName}`);
    const commits = project.commits;
    for (const commit of commits) {
      lines.push(formatCommitLine(project.meta, commit));
    }
  }
  return `${lines.join('\n')}\n`;
}

功能说明:生成Markdown格式的提交汇总报告。

三、使用方法

  1. 安装依赖:无需额外依赖,使用Node.js内置模块。

  2. 配置.env文件:根据实际情况修改.env文件中的配置。

  3. 运行脚本

    bash

    node fetch_commits.js --since=2025-01-01 --until=2025-12-31
    node fetch_commits.js --since=2025-06-01 --until=2026-01-11 --author=你的提交用户名
    
  4. 查看报告:脚本会生成commits.md文件,包含指定时间范围内的提交记录。

四、完整代码 同级创建.env即可使用

javascript

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

function readEnvFile(envPath) {
  const content = fs.readFileSync(envPath, 'utf8');
  const lines = content.split(/\r?\n/).filter(Boolean);
  const env = {};
  for (const line of lines) {
    if (line.trim().startsWith('#')) continue;
    const idx = line.indexOf('=');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim();
    env[key] = value;
  }
  return env;
}

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg.startsWith('--')) {
      const [k, v] = arg.split('=');
      args[k.slice(2)] = v;
    }
  }
  return args;
}

function toIsoRangeDayStartEnd(dateStr) {
  const start = new Date(`${dateStr}T00:00:00.000Z`);
  const end = new Date(`${dateStr}T23:59:59.999Z`);
  return { since: start.toISOString(), until: end.toISOString() };
}

function ensureIsoRange(sinceInput, untilInput) {
  const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
  const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
  if (sinceIsDateOnly && untilIsDateOnly) {
    const { since } = toIsoRangeDayStartEnd(sinceInput);
    const { until } = toIsoRangeDayStartEnd(untilInput);
    return { since, until };
  }
  const since = new Date(sinceInput).toISOString();
  const until = new Date(untilInput).toISOString();
  return { since, until };
}

function requestJson(urlStr, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const { protocol, hostname, port, pathname, search } = u;
    const lib = protocol === 'https:' ? https : http;
    const options = {
      hostname,
      port: port || (protocol === 'https:' ? 443 : 80),
      path: `${pathname}${search}`,
      method: 'GET',
      headers,
    };
    const req = lib.request(options, (res) => {
      const { statusCode, headers: resHeaders } = res;
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if (statusCode >= 200 && statusCode < 300) {
          try {
            const json = JSON.parse(body);
            resolve({ json, headers: resHeaders, statusCode });
          } catch (e) {
            reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
          }
        } else {
          reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

function buildApiUrl(base, pathStr, query = {}) {
  const u = new URL(pathStr, base);
  const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
  for (const [k, v] of entries) {
    u.searchParams.set(k, String(v));
  }
  return u.toString();
}

async function fetchProjectMeta(baseUrl, token, id) {
  const url = buildApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(id)}`);
  const headers = { 'PRIVATE-TOKEN': token };
  const { json } = await requestJson(url, headers);
  const { name, path_with_namespace, web_url } = json;
  return { id, name, path_with_namespace, web_url };
}

async function fetchCommitsPage(baseUrl, token, id, params) {
  const url = buildApiUrl(
    baseUrl,
    `/api/v4/projects/${encodeURIComponent(id)}/repository/commits`,
    params
  );
  const headers = { 'PRIVATE-TOKEN': token };
  const { json, headers: resHeaders } = await requestJson(url, headers);
  const { ['x-next-page']: nextPage, ['x-page']: page, ['x-total-pages']: totalPages } = resHeaders;
  return { commits: json, nextPage, page, totalPages };
}

async function fetchAllCommits(baseUrl, token, id, since, until, author) {
  const collected = [];
  let page = 1;
  for (;;) {
    const params = { since, until, per_page: 100, page, with_stats: false, author };
    const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
    collected.push(...commits);
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return collected;
}

function filterNonMerge(commits) {
  const filtered = [];
  for (const commit of commits) {
    const { parent_ids } = commit;
    const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
    if (nonMerge) filtered.push(commit);
  }
  return filtered;
}

function formatCommitLine(project, commit) {
  const { short_id, title, message, committed_date, author_name, author_email } = commit;
  const main = (title || message || '').replace(/\r?\n/g, ' ');
  const ts = formatDateLocal(committed_date);
  return `- ${ts} | ${short_id} | ${main} | ${author_name} <${author_email}>`;
}

function pad2(n) {
  return String(n).padStart(2, '0');
}

function formatDateLocal(iso) {
  const d = new Date(iso);
  const y = d.getFullYear();
  const m = pad2(d.getMonth() + 1);
  const day = pad2(d.getDate());
  const hh = pad2(d.getHours());
  const mm = pad2(d.getMinutes());
  const ss = pad2(d.getSeconds());
  return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}

function buildMarkdown(range, author, grouped) {
  const { since, until } = range;
  const { username, name } = author;
  const lines = [];
  lines.push(`# 提交汇总`);
  lines.push(`- 作者: ${name || username || ''}`);
  lines.push(`- 时间范围: ${since}${until}`);
  for (const project of grouped.projects) {
    const { name: projName } = project.meta;
    lines.push(`\n项目: ${projName}`);
    const commits = project.commits;
    for (const commit of commits) {
      lines.push(formatCommitLine(project.meta, commit));
    }
  }
  return `${lines.join('\n')}\n`;
}

async function fetchMembershipProjects(baseUrl, token) {
  const headers = { 'PRIVATE-TOKEN': token };
  const projects = [];
  let page = 1;
  for (;;) {
    const url = buildApiUrl(baseUrl, '/api/v4/projects', {
      membership: true,
      simple: true,
      per_page: 100,
      page,
      order_by: 'last_activity_at',
    });
    const { json, headers: resHeaders } = await requestJson(url, headers);
    for (const item of json) {
      const { id, name, path_with_namespace, web_url } = item;
      projects.push({ id, name, path_with_namespace, web_url });
    }
    const nextPage = resHeaders['x-next-page'];
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return projects;
}

async function resolveAuthorQuery(baseUrl, token, username, override) {
  if (override) return override;
  if (!username) return null;
  const url = buildApiUrl(baseUrl, '/api/v4/users', { username });
  const headers = { 'PRIVATE-TOKEN': token };
  const { json } = await requestJson(url, headers);
  if (Array.isArray(json) && json.length > 0) {
    const { name } = json[0];
    return name || username;
  }
  return username;
}

function filterByAuthorName(commits, authorName) {
  if (!authorName) return commits;
  const out = [];
  for (const commit of commits) {
    const { author_name } = commit;
    if (author_name === authorName) out.push(commit);
  }
  return out;
}

async function main() {
  const cwd = process.cwd();
  const envPath = path.join(cwd, '.env');
  const env = readEnvFile(envPath);
  const {
    GITLAB_URL,
    GITLAB_TOKEN,
    GITLAB_AUTHOR_USERNAME,
  } = env;
  const args = parseArgs(process.argv);
  const { since: sinceRaw, until: untilRaw, author: authorArg } = args;
  if (!GITLAB_URL || !GITLAB_TOKEN || !sinceRaw || !untilRaw) {
    process.stderr.write(
      '缺少必要配置或参数。需要 GITLAB_URL, GITLAB_TOKEN, --since=YYYY-MM-DD, --until=YYYY-MM-DD\n'
    );
    process.exit(1);
  }
  const { since, until } = ensureIsoRange(sinceRaw, untilRaw);
  const desiredAuthor = authorArg || 'zhouzb';
  const authorQuery = await resolveAuthorQuery(GITLAB_URL, GITLAB_TOKEN, GITLAB_AUTHOR_USERNAME, desiredAuthor);
  const authorInfo = { username: GITLAB_AUTHOR_USERNAME, name: desiredAuthor };
  let metas = [];
  if (env.GITLAB_PROJECT_IDS) {
    const ids = env.GITLAB_PROJECT_IDS.split(',').map((s) => s.trim()).filter(Boolean);
    for (const id of ids) {
      const meta = await fetchProjectMeta(GITLAB_URL, GITLAB_TOKEN, id);
      metas.push(meta);
    }
  } else {
    metas = await fetchMembershipProjects(GITLAB_URL, GITLAB_TOKEN);
  }
  const grouped = { projects: [] };
  for (const meta of metas) {
    const { id } = meta;
    const all = await fetchAllCommits(GITLAB_URL, GITLAB_TOKEN, id, since, until, authorQuery || undefined);
    const filtered = filterByAuthorName(filterNonMerge(all), desiredAuthor);
    if (filtered.length > 0) grouped.projects.push({ meta, commits: filtered });
  }
  const md = buildMarkdown({ since, until }, authorInfo, grouped);
  fs.writeFileSync(path.join(cwd, 'commits.md'), md, 'utf8');
}

main().catch((e) => {
  const { message } = e;
  process.stderr.write(`${message}\n`);
  process.exit(1);
});

在 Vue3 中使用 LogicFlow 更新节点名称

作者 持续前行
2026年1月12日 09:57

在 Vue3 中更新 LogicFlow 节点名称有多种方式,下面我为你详细介绍几种常用方法。

🔧 核心更新方法

1. 使用 updateText方法(推荐)

这是最直接的方式,通过节点 ID 更新文本内容:

<template>
  <div>
    <div ref="container" style="width: 100%; height: 500px;"></div>
    <button @click="updateNodeName">更新节点名称</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import LogicFlow from '@logicflow/core';
import '@logicflow/core/dist/style/index.css';

const container = ref(null);
const lf = ref(null);
const selectedNodeId = ref('');

onMounted(() => {
  lf.value = new LogicFlow({
    container: container.value,
    grid: true,
  });

  // 示例数据
  lf.value.render({
    nodes: [
      {
        id: 'node_1',
        type: 'rect',
        x: 100,
        y: 100,
        text: '原始名称'
      }
    ]
  });

  // 监听节点点击,获取选中节点ID
  lf.value.on('node:click', ({ data }) => {
    selectedNodeId.value = data.id;
  });
});

// 更新节点名称
const updateNodeName = () => {
  if (!selectedNodeId.value) {
    alert('请先点击选择一个节点');
    return;
  }

  const newName = prompt('请输入新的节点名称', '新名称');
  if (newName) {
    // 使用 updateText 方法更新节点文本
    lf.value.updateText(selectedNodeId.value, newName);
  }
};
</script>

2. 通过 setProperties方法更新

这种方法可以同时更新文本和其他属性:

// 更新节点属性,包括名称
const updateNodeWithProperties = () => {
  if (!selectedNodeId.value) return;

  const newNodeName = '更新后的节点名称';
  
  // 获取节点当前属性
  const nodeModel = lf.value.getNodeModelById(selectedNodeId.value);
  const currentProperties = nodeModel.properties || {};
  
  // 更新属性
  lf.value.setProperties(selectedNodeId.value, {
    ...currentProperties,
    nodeName: newNodeName,
    updatedAt: new Date().toISOString()
  });
  
  // 同时更新显示文本
  lf.value.updateText(selectedNodeId.value, newNodeName);
};

🎯 事件监听与交互方式

1. 双击编辑模式

实现双击节点直接进入编辑模式:

// 监听双击事件
lf.value.on('node:dblclick', ({ data }) => {
  const currentNode = lf.value.getNodeModelById(data.id);
  const currentText = currentNode.text?.value || '';
  
  const newText = prompt('编辑节点名称:', currentText);
  if (newText !== null) {
    lf.value.updateText(data.id, newText);
  }
});

2. 右键菜单编辑

结合 Menu 插件实现右键菜单编辑:

import { Menu } from '@logicflow/extension';
import '@logicflow/extension/lib/style/index.css';

// 初始化时注册菜单插件
lf.value = new LogicFlow({
  container: container.value,
  plugins: [Menu],
});

// 配置右键菜单
lf.value.extension.menu.setMenuConfig({
  nodeMenu: [
    {
      text: '编辑名称',
      callback: (node) => {
        const currentText = node.text || '';
        const newText = prompt('编辑节点名称:', currentText);
        if (newText) {
          lf.value.updateText(node.id, newText);
        }
      }
    },
    {
      text: '删除',
      callback: (node) => {
        lf.value.deleteNode(node.id);
      }
    }
  ]
});

💡 自定义节点名称编辑

对于自定义节点,可以重写文本相关方法:

import { RectNode, RectNodeModel } from '@logicflow/core';

class CustomNodeModel extends RectNodeModel {
  // 自定义文本样式
  getTextStyle() {
    const style = super.getTextStyle();
    return {
      ...style,
      fontSize: 14,
      fontWeight: 'bold',
      fill: '#1e40af',
    };
  }
  
  // 初始化节点数据
  initNodeData(data) {
    super.initNodeData(data);
    // 确保文本格式正确
    this.text = {
      x: data.x,
      y: data.y + this.height / 2 + 10,
      value: data.text || '默认节点'
    };
  }
}

// 注册自定义节点
lf.value.register({
  type: 'custom-node',
  view: RectNode,
  model: CustomNodeModel
});

🚀 批量更新与高级功能

1. 批量更新多个节点

// 批量更新所有节点名称
const batchUpdateNodeNames = () => {
  const graphData = lf.value.getGraphData();
  const updatedNodes = graphData.nodes.map(node => ({
    ...node,
    text: `${node.text}(已更新)`
  }));
  
  // 重新渲染
  lf.value.render({
    nodes: updatedNodes,
    edges: graphData.edges
  });
};

// 按条件更新节点
const updateNodesByCondition = () => {
  const graphData = lf.value.getGraphData();
  const updatedNodes = graphData.nodes.map(node => {
    if (node.type === 'rect') {
      return {
        ...node,
        text: `矩形节点-${node.id}`
      };
    }
    return node;
  });
  
  lf.value.render({
    nodes: updatedNodes,
    edges: graphData.edges
  });
};

2. 实时保存与撤销重做

// 监听文本变化并自动保存
lf.value.on('node:text-update', ({ data }) => {
  console.log('节点文本已更新:', data);
  saveToBackend(lf.value.getGraphData());
});

// 实现撤销重做功能
const undo = () => {
  lf.value.undo();
};

const redo = () => {
  lf.value.redo();
};

// 启用历史记录
lf.value = new LogicFlow({
  container: container.value,
  grid: true,
  history: true, // 启用历史记录
  historySize: 100 // 设置历史记录大小
});

⚠️ 注意事项与最佳实践

  1. 文本对象格式:LogicFlow 中文本可以是字符串或对象格式 {value: '文本', x: 100, y: 100}
  2. 更新时机:确保在 lf.render()之后再进行更新操作
  3. 错误处理:更新前检查节点是否存在
  4. 性能优化:批量更新时考虑使用防抖
// 安全的更新函数
const safeUpdateNodeName = (nodeId, newName) => {
  if (!lf.value) {
    console.error('LogicFlow 实例未初始化');
    return false;
  }
  
  const nodeModel = lf.value.getNodeModelById(nodeId);
  if (!nodeModel) {
    console.error(`节点 ${nodeId} 不存在`);
    return false;
  }
  
  try {
    lf.value.updateText(nodeId, newName);
    return true;
  } catch (error) {
    console.error('更新节点名称失败:', error);
    return false;
  }
};

这些方法涵盖了 Vue3 中 LogicFlow 节点名称更新的主要场景,你可以根据具体需求选择合适的方式。

告别手写礼簿!一款开源免费的电子红白喜事礼簿系统!

作者 Java陈序员
2026年1月12日 09:31

大家好,我是 Java陈序员

无论是儿女结婚的喜宴,还是亲友离世的白事,礼金记账都是绕不开的环节。

传统手写礼簿,不仅考验书写速度和细心程度,还面临着“记重了、算错了、丢了账本”的风险,既费人力又不省心。

而市面上的电子记账工具,要么依赖网络,要么数据存在云端,总担心隐私泄露。

今天,给大家推荐一款纯本地运行的电子礼簿系统,不用连网、不用注册、数据加密存储、安全又好用,红白喜事都适配!

项目介绍

gift-book —— 一款纯本地、零后端、完全本地运行的单页 Web 应用,旨在为各类红白喜事提供一个现代化、安全、高效的礼金(份子钱)管理解决方案。

功能特色

  • 无需联网:纯 HTML 单页应用,不依赖服务器,单页 Web 应用拔网线也能正常记账,数据 100% 存储在本地设备
  • 数据金融级加密保护:全量数据采用 AES-256 加密落库,管理密码通过 SHA-256 哈希保护,即使设备丢失、文件被拷贝,数据也无法破解
  • 秒级记账:姓名、金额、渠道(微信/支付宝/现金)全键盘操作,回车即录,支持实时检测重名、重复金额,并提供语音播报核对功能
  • 双色主题:内置 “喜庆红”(喜事)、“肃穆灰”(白事)两套皮肤,完美适配不同场景的氛围需求
  • 双屏互动:支持开启副屏页面,实时投射数据到外接屏幕/电视,副屏自动开启隐私模式,且支持自定义上传展示收款码
  • 专业级报表与归档:内置专业 PDF 引擎,生成的电子礼簿支持自定义字体、封面图、背景纹理,支持导出加密数据文件,跨设备可全量恢复
  • 开箱即用:普通用户免部署,无需安装任何环境,双击即可运行,同时可部署到服务器上,通过浏览器在线访问

快速上手

gift-book 由纯静态文件组成,无需安装任何环境。

1、打开下载地址,下载 Windows 预编译应用(gift-book.exe)

https://github.com/jingguanzhang/gift-book/releases

2、双击运行 gift-book.exe

3、初始化:创建新事项

设置事项名称及管理密码(请务必牢记,丢失无法找回)。

4、记账:录入数据

5、归档:活动结束后,务必导出 Excel 或 PDF 文件到电脑,微信收藏或云盘永久保存

功能体验

  • 礼金录入

  • 副屏

  • 礼簿

  • 礼金统计详情

本地开发

需要依赖代码编辑器(推荐 VS Code)和浏览器(Chrome/Edge)。

1、克隆或下载项目源码

git clone https://github.com/jingguanzhang/gift-book.git

2、在 VS Code 中打开项目代码

3、代码目录结构

gift-book
├── index1.html             # v1.1 专业版主入口(核心代码均内嵌于此,方便单文件分发)
├── index.html              # v1.0 基础版主入口
├── static/                 # 静态资源目录
    ├── tailwindcss.js      # 样式引擎
    ├── xlsx.full.min.js    # Excel 导出库
    ├── pdf-lib.min.js      # PDF 生成引擎
    ├── crypto-js.min.js    # 加密库
    └── fontkit & .ttf      # 字体文件(用于 PDF 生成)
└── guest-screen.html       # 副屏显示页面

4、右键 index.html 并选择 "Open with Live Server" 运行程序

需要在 VS Code 中提前安装插件 Live Server.

5、部署上线:无需编译,直接将所有文件上传至 GitHub Pages、Vercel、Nginx 或任何静态文件服务器即可

可以说,gift-book 这款纯本地电子礼簿,没有复杂的操作门槛,没有数据泄露的顾虑,只用简单的方式把账记准、记清、存好。快去试试吧~

项目地址:https://github.com/jingguanzhang/gift-book

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


LeetCode 274. H 指数:两种高效解法全解析

作者 Wect
2026年1月12日 09:30

在科研成果评价领域,H 指数是一个非常经典的指标,而 LeetCode 274 题正是围绕 H 指数的计算展开。这道题看似简单,但背后藏着两种思路迥异的高效解法。今天我们就来深入剖析这道题,把两种解法的逻辑、实现和优劣讲透。

一、题目回顾与 H 指数定义

首先明确题目要求:给定一个整数数组 citations,其中 citations[i] 表示研究者的第 i 篇论文被引用的次数,计算并返回该研究者的 H 指数。

核心是理解 H 指数的定义(划重点):一名科研人员的 H 指数是指他至少发表了 h 篇论文,并且这 h 篇论文每篇的被引用次数都大于等于 h。如果存在多个可能的 h 值,取最大的那个。

举个例子帮助理解:若 citations = [1,3,1],H 指数是 1。因为研究者有 3 篇论文,其中至少 1 篇被引用 ≥1 次,而要达到 h=2 则需要至少 2 篇论文被引用 ≥2 次(实际只有 1 篇3次,不满足),所以最大的 h 是 1。

二、解法一:计数排序思路(时间 O(n),空间 O(n))

先看第一种解法的代码,这是一种基于计数排序的优化方案,适合对时间效率要求较高的场景。


function hIndex_1(citations: number[]): number {
  const ciLen = citations.length;
  const count = new Array(ciLen + 1).fill(0);
  for (let i = 0; i < ciLen; i++) {
    if (citations[i] > ciLen) {
      count[ciLen]++;
    } else {
      count[citations[i]]++;
    }
  }
  let total = 0;
  for (let i = ciLen; i >= 0; i--) {
    total += count[i];
    if (total >= i) {
      return i;
    }
  }
  return 0;
};

2.1 核心思路

H 指数的最大值不可能超过论文总数 n(因为要至少 h 篇论文,h 最多等于论文数)。所以对于引用次数超过 n 的论文,我们可以统一视为引用次数为 n(不影响 H 指数的计算)。

基于这个特点,我们可以用一个计数数组 count 统计每个引用次数(0 到 n)对应的论文数量,然后从后往前累加计数,找到第一个满足「累加总数 ≥ 当前引用次数」的数值,这个数值就是最大的 H 指数。

2.2 步骤拆解(以 citations = [3,0,6,1,5] 为例)

  1. 初始化变量:论文总数 ciLen = 5,计数数组 count 长度为 ciLen + 1 = 6,初始值全为 0(count = [0,0,0,0,0,0])。

  2. 统计引用次数分布:遍历 citations 数组,将每篇论文的引用次数映射到 count 中:

     最终`count` 含义:引用 0 次的 1 篇、1 次的 1 篇、3 次的 1 篇、5 次及以上的 2 篇。
    
    • 3 ≤ 5 → count[3]++ → count = [0,0,0,1,0,0]

    • 0 ≤ 5 → count[0]++ → count = [1,0,0,1,0,0]

    • 6 > 5 → count[5]++ → count = [1,0,0,1,0,1]

    • 1 ≤ 5 → count[1]++ → count = [1,1,0,1,0,1]

    • 5 ≤ 5 → count[5]++ → count = [1,1,0,1,0,2]

  3. 倒序累加找 H 指数:从最大可能的 h(即 ciLen=5)开始,累加 count[i](表示引用次数 ≥i 的论文总数),直到累加和 ≥i:

    • i=5:total = 0 + 2 = 2 → 2 < 5 → 继续

    • i=4:total = 2 + 0 = 2 → 2 < 4 → 继续

    • i=3:total = 2 + 1 = 3 → 3 ≥ 3 → 满足条件,返回 3

最终结果为 3,符合预期(3 篇论文被引用 ≥3 次:3、6、5)。

2.3 优缺点

优点:时间复杂度 O(n),只需要两次遍历数组,效率极高;空间复杂度 O(n),仅需一个固定长度的计数数组。

缺点:需要额外的空间存储计数数组,对于论文数量极少的场景,空间开销不明显,但思路相对排序法更难理解。

三、解法二:排序思路(时间 O(n log n),空间 O(1))

第二种解法是基于排序的思路,逻辑更直观,容易理解,也是很多人首先会想到的方案。


function hIndex(citations: number[]): number {
  // 思路:逆序排序
  citations.sort((a, b) => b - a);
  let res = 0;
  for (let i = 0; i < citations.length; i++) {
    if (citations[i] >= i + 1) {
      res = i + 1;
    }
  }
  return res;
};

3.1 核心思路

将引用次数数组逆序排序(从大到小),此时排序后的数组第 i 个元素(索引从 0 开始)表示第 i+1 篇论文的引用次数。如果该元素 ≥ i+1,说明前 i+1 篇论文的引用次数都 ≥ i+1,此时 H 指数至少为 i+1。遍历完数组后,最大的这个 i+1 就是最终的 H 指数。

3.2 步骤拆解(同样以 citations = [3,0,6,1,5] 为例)

  1. 逆序排序数组:排序后 citations = [6,5,3,1,0]

  2. 遍历数组找最大 h:初始化 res = 0,依次判断每个元素:

    • i=0:citations[0] = 6 ≥ 0+1=1 → res = 1

    • i=1:citations[1] = 5 ≥ 1+1=2 → res = 2

    • i=2:citations[2] = 3 ≥ 2+1=3 → res = 3

    • i=3:citations[3] = 1 ≥ 3+1=4 → 不满足,res 不变

    • i=4:citations[4] = 0 ≥ 4+1=5 → 不满足,res 不变

  3. 返回结果:最终 res = 3,与解法一结果一致。

3.3 优缺点

优点:逻辑直观,容易理解和实现;空间复杂度低,若允许原地排序(如 JavaScript 的 sort 方法),空间复杂度为 O(log n)(排序的递归栈空间),否则为 O(1)。

缺点:时间复杂度由排序决定,为 O(n log n),对于大规模数据(如论文数量极多),效率不如解法一。

四、两种解法对比与适用场景

解法 时间复杂度 空间复杂度 核心优势 适用场景
计数排序法 O(n) O(n) 时间效率极高,两次线性遍历 大规模数据,对时间要求高
逆序排序法 O(n log n) O(1) 逻辑直观,空间开销小 小规模数据,追求代码简洁易读

五、常见易错点提醒

  1. 混淆 H 指数的定义:容易把「至少 h 篇论文 ≥h 次」写成「h 篇论文 exactly h 次」,导致判断条件错误(如之前有同学把解法一的 total ≥ i 写成 total === i)。

  2. 排序方向错误:解法二必须逆序排序(从大到小),若正序排序会导致逻辑混乱,无法正确统计。

  3. 忽略边界情况:如 citations = [0](H 指数 0)、citations = [100](H 指数 1),需确保两种解法都能覆盖这些场景。

六、总结

LeetCode 274 题的两种解法各有优劣:计数排序法以空间换时间,适合大规模数据;逆序排序法逻辑简洁,适合小规模数据。理解这两种解法的核心在于吃透 H 指数的定义——「至少 h 篇论文 ≥h 次引用」,所有的逻辑都是围绕这个定义展开的。

建议大家在练习时,先尝试自己实现逆序排序法(容易上手),再深入理解计数排序法的优化思路,通过对比两种解法的差异,加深对「时间复杂度」和「空间复杂度」权衡的理解。

Underscore.js 整体设计思路与架构分析

作者 Anita_Sun
2026年1月12日 07:50

源码分析: bgithub.xyz/lessfish/un…

官网中所带注释的源码:

整体分析:underscorejs.org/docs/unders…

模块分析:underscorejs.org/docs/module…

核心架构模式

模块结构

Underscore.js 采用了 立即执行函数表达式 (IIFE) 作为核心模块结构,创建了一个封闭的作用域,避免了全局变量污染:

这种设计方式能够让 Underscore.js :

  • 支持多种模块系统(CommonJS、AMD、全局变量)
  • 提供 noConflict 方法,避免命名冲突
  • 在不同环境中(浏览器、Node.js)正常工作
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? 
  // 场景1:CommonJS 环境(Node.js)
  module.exports = factory() :
  typeof define === 'function' && define.amd ? 
  // 场景2:AMD 环境(如 RequireJS)
  define('underscore', factory) :
  // 场景3:无模块化的浏览器全局环境(兜底)
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, (function () {
    var current = global._; // 保存当前全局的 _ 变量
    var exports = global._ = factory(); // 把 underscore 挂载到全局 _
    // 解决命名冲突的 noConflict 方法
    exports.noConflict = function () { global._ = current; return exports; };
  }()));
}(this, (function () {
  // 核心实现...
})));

双模式 API 设计

Underscore.js 同时支持两种调用方式:

函数式调用

_.map([123], function(num) { return 
num * 2; });

面向对象调用(链式)

_([123]).map(function(num) { return 
num * 2; }).value();

这种设计通过以下核心构造函数实现:

function _$1(obj) {
  if (obj instanceof _$1) return obj;
  if (!(this instanceof _$1)) return new 
  _$1(obj);
  this._wrapped = obj;
}

方法挂载机制

函数定义与收集

Underscore.js 首先将所有功能实现为独立函数,然后通过 allExports 对象统一收集:

var allExports = {
  __proto__: null,
  VERSION: VERSION,
  restArguments: restArguments,
  isObject: isObject,
  // ... 其他函数
};

方法挂载

通过 mixin 方法,将所有函数同时挂载到构造函数和原型链上:

function mixin(obj) {
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name];
    _$1.prototype[name] = function() {
      var args = [this._wrapped];
      push.apply(args, arguments);
      return chainResult(this, func.apply
      (_$1, args));
    };
  });
  return _$1;
}

// 执行挂载
var _ = mixin(allExports);

这种设计使得:

  • 所有函数既可以通过 _.func() 方式调用
  • 也可以通过_().func() 链式调用

数组方法集成

Underscore.js 还集成了原生数组的方法,分为两类:

变更方法(Mutator)

pop/push/reverse/shift/sort/splice/unshift 这些方法的核心是修改原数组(比如 push 往原数组加元素,shift 从原数组删第一个元素),执行后原数组本身变了,方法返回值只是 “操作结果”(比如 pop 返回删除的元素),而非新数组。

// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);

// 调用mutator方法push
arrWrapper.push(4);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组被修改)
each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
  // 从数组原型(ArrayProto是Array.prototype的简写)获取对应的原生方法
  var method = ArrayProto[name];

  // 为包装类原型添加当前遍历的方法(如pop/push等)
  _$1.prototype[name] = function() {
    // 获取包装类实例中包裹的原始数组(_wrapped是包装类存储原始数据的核心属性)
    var obj = this._wrapped;

    // 仅当原始数组不为null/undefined时执行(避免空指针错误)
    if (obj != null) {
      // 调用原生数组方法,将当前方法的参数透传给原生方法,直接修改原数组
      // apply作用:绑定方法执行上下文为原始数组obj,参数以数组形式传递
      method.apply(obj, arguments);

      // 特殊边界处理:修复shift/splice清空数组后可能残留的索引0问题
      // 原因:部分场景下shift/splice把数组清空(length=0)后,obj[0]仍可能残留undefined
      // 删除索引0可保证空数组的结构完全干净,符合原生数组的预期行为
      if ((name === 'shift' || name === 'splice') && obj.length === 0) {
        delete obj[0];
      }
    }

    // 返回链式调用结果:保证调用该方法后仍能继续调用包装类的其他方法
    // chainResult会根据是否开启链式调用,返回包装类实例(this)或修改后的原数组(obj)
    return chainResult(this, obj);
  };
});

function chainResult(instance, obj) {
    return instance._chain ? _$1(obj).chain() : obj;
}

function chain(obj) {
    var instance = _$1(obj);
    instance._chain = true;
    return instance;
}

访问方法(Accessor)

concat/join/slice 这些方法的核心是返回新结果,原数组完全不变(比如 concat 拼接后返回新数组,原数组还是原样;slice 截取后返回新子数组)。

// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);

// 2. 调用accessor方法concat
const newWrapper = arrWrapper.concat([5,6]);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组仍不变)
console.log(newWrapper._wrapped); // [1,2,3,4,5,6](新结果)
// 批量为自定义数组包装类的原型挂载数组非可变方法(不会修改原数组,返回新值)
// 目标:让自定义包装类实例调用这些方法时,获取原生方法的返回结果,并保持链式调用特性
each(['concat', 'join', 'slice'], function(name) {
  // 从数组原型(ArrayProto)中获取对应的原生方法(如Array.prototype.concat)
  // ArrayProto是Array.prototype的简写,常见于Underscore/Lodash等工具库
  var method = ArrayProto[name];

  // 为自定义数组包装类(_$1)的原型挂载当前遍历的方法
  _$1.prototype[name] = function() {
    // 获取包装类实例中包裹的原始数组/值(_wrapped是包装类存储原始数据的核心属性)
    var obj = this._wrapped;

    // 仅当原始数据不为null/undefined时执行原生方法(避免空指针错误)
    if (obj != null) {
      // 调用原生方法,透传参数并接收返回值
      // 核心差异:这类方法不修改原数组,而是返回新值,因此需要用新值覆盖obj
      obj = method.apply(obj, arguments);
    }

    // 返回链式调用结果:将新的返回值(obj)传入chainResult,保证链式调用的正确性
    // 若开启链式则返回包装类实例,未开启则返回新的数组/值
    return chainResult(this, obj);
  };
});

链式调用实现

Underscore.js 的链式调用是其一大特色,通过以下机制实现:

调用 _.chain() 后,所有方法执行完都会通过 chainResult 返回「新的包装对象」(而非原始数据),因此可以继续调用原型上的方法;直到调用 value() 方法(需补充实现),取出 _wrapped 里的原始数据,结束链式。

举个栗子

看下链式调用如何工作:

// 链式调用示例:过滤出大于2的数,再乘以2,最后获取结果
var finalResult = _.chain([1, 2, 3, 4])
  .filter(function(x) { return x > 2; })
  .map(function(x) { return x * 2; })
  .value();

console.log(finalResult); // 输出:[6, 8]

代码实现

下面看下核心代码是怎么实现的吧 ~

// 核心包装类 _$1(对应 Underscore 的 _ 函数)
function _$1(obj) {
  // 如果是 _$1 实例,直接返回
  if (obj instanceof _$1) return obj;
  // 如果不是实例,创建实例并存储原始数据
  if (!(this instanceof _$1)) return new _$1(obj);
  this._wrapped = obj; // 存储被包装的原始数据(数组/对象)
  this._chain = false; // 链式标记,默认关闭
}

// 结束链式:获取最终结果
_$1.prototype.value = function() {
  return this._wrapped; // 取出包装对象里的原始数据
};

function chain(obj) {
  var instance = _$1(obj);
  instance._chain = true; // 开启链式标记
  return instance;
}

function chainResult(instance, obj) {
  // 关键判断:如果开启链式,返回新的包装对象(继续链式);否则返回原始数据
  return instance._chain ? _$1(obj).chain() : obj;
}

// 模拟 Underscore 的 each/functions 工具函数(简化版)
function each(arr, callback) {
  for (var i = 0; i < arr.length; i++) callback(arr[i], i);
}
function functions(obj) {
  return Object.keys(obj).filter(key => typeof obj[key] === 'function');
}

function mixin(obj) {
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name]; // 挂载到 _$1 静态方法
    _$1.prototype[name] = function() {
      // 1. 构造参数:第一个参数是包装的原始数据 this._wrapped,后续是方法入参
      var args = [this._wrapped];
      push.apply(args, arguments);
      // 2. 执行工具函数,得到结果
      var result = func.apply(_$1, args);
      // 3. 调用 chainResult,决定返回包装对象(链式)还是原始数据
      return chainResult(this, result);
    };
  });
  return _$1;
}

// 挂载常用工具方法(模拟 Underscore 的 filter/map)
mixin({
  filter: function(arr, fn) {
    return arr.filter(fn);
  },
  map: function(arr, fn) {
    return arr.map(fn);
  }
});

// 给 _$1 原型挂载 chain 方法(对应用户代码里的 instance.chain())
_$1.prototype.chain = function() {
  return chain(this._wrapped);
};

核心设计特点

函数式编程风格

Underscore.js 采用函数式编程范式,提供了大量高阶函数:

  • 纯函数 :如 map 、 filter 等,不修改原数据,避免污染原数据

  • 函数工厂 :如 tagTester 、 createPredicateIndexFinder 等。会返回一个新函数的函数,用于复用函数逻辑,减少重复代码

    // 函数工厂:生成检测特定类型的函数
    const tagTester = function(tag) {
      // 返回新函数(检测类型)
      return function(obj) {
        return Object.prototype.toString.call(obj) === `[object ${tag}]`;
      };
    };
    
    // 生产具体的检测函数
    _.isArray = tagTester('Array');
    _.isObject = tagTester('Object');
    _.isFunction = tagTester('Function');
    
    // 使用
    console.log(_.isArray([1,2])); // true
    console.log(_.isObject({a:1})); // true
    
  • 函数组合 :如 compose 函数,将多个函数组合成一个新函数,执行顺序为 “从右到左”,前一个函数的输出作为后一个函数的输入

    // 函数组合核心实现
    _.compose = function(...funcs) {
      return function(...args) {
        // 从右到左执行函数
        return funcs.reduceRight((result, func) => [func.apply(this, result)], args)[0];
      };
    };
    
    // 示例:先过滤大于2的数,再乘以2,最后求和
    const filterBig = arr => _.filter(arr, x => x > 2);
    const double = arr => _.map(arr, x => x * 2);
    const sum = arr => _.reduce(arr, (a, b) => a + b, 0);
    
    // 组合函数:sum(double(filterBig(arr)))
    const process = _.compose(sum, double, filterBig);
    
    console.log(process([1,2,3,4])); // (3,4)→[6,8]→14
    
  • 函数柯里化 :如 partial 函数,将多参数函数拆解为单参数函数链,可分步传参,延迟执行。

    // 柯里化核心实现(简化版)
    _.partial = function(func, ...fixedArgs) {
      return function(...remainingArgs) {
        // 合并固定参数和剩余参数,执行原函数
        return func.apply(this, fixedArgs.concat(remainingArgs));
      };
    };
    
    // 示例:固定乘法的第一个参数为2(创建“乘以2”的函数)
    const multiply = (a, b) => a * b;
    const double = _.partial(multiply, 2);
    
    // 分步传参:先传2,后传3/4
    console.log(double(3)); // 6
    console.log(double(4)); // 8
    

跨环境兼容性

Underscore.js 设计了完善的跨环境兼容机制,核心是 先检测、后适配、再降级 的策略:

  • 环境检测 :自动检测运行环境(浏览器、Node.js)
  • 特性检测 :检测原生方法是否存在
  • 优雅降级 :当原生方法不可用时,使用自定义实现
  • IE 兼容性 :特别处理了 IE < 9 的兼容性问题

性能优化

Underscore.js 在设计中融入了多种性能优化策略:

  • 缓存 :如 memoize 函数,缓存计算结果

    // memoize 核心实现(简化版)
    _.memoize = function(func, hashFunction) {
      const cache = {}; // 缓存容器
      hashFunction = hashFunction || function(args) {
        return args[0]; // 默认用第一个参数作为缓存key
      };
    
      return function(...args) {
        const key = hashFunction.apply(this, args);
        // 缓存存在则直接返回,否则执行函数并缓存
        if (!cache.hasOwnProperty(key)) {
          cache[key] = func.apply(this, args);
        }
        return cache[key];
      };
    };
    
    // 示例:缓存斐波那契计算结果(避免重复递归)
    const fib = _.memoize(function(n) {
      return n < 2 ? n : fib(n - 1) + fib(n - 2);
    });
    
    console.log(fib(10)); // 55(首次计算,缓存结果)
    console.log(fib(10)); // 55(直接取缓存,无需计算)
    
  • 延迟执行 :如 debounce 、 throttle 函数

    • debounce(防抖) :延迟执行函数,若短时间内重复触发,重置延迟(如搜索框输入、窗口 resize);
    • throttle(节流) :限制函数在指定时间内仅执行一次(如滚动事件、按钮点击)。
    // 防抖核心实现(简化版)
    _.debounce = function(func, wait) {
      let timeoutId;
      return function(...args) {
        clearTimeout(timeoutId); // 重置延迟
        timeoutId = setTimeout(() => {
          func.apply(this, args);
        }, wait);
      };
    };
    
    // 示例:搜索框输入后500ms执行搜索
    const search = _.debounce(function(keyword) {
      console.log('搜索:', keyword);
    }, 500);
    
    // 快速输入时,仅最后一次输入后500ms执行
    search('a');
    search('ab');
    search('abc'); // 仅执行这一次
    
  • 惰性求值 :通过链式调用实现 链式调用时,并非每一步都立即计算,而是延迟到最后一步 value () 才执行最终计算,减少中间临时数据的生成:

    // 惰性求值示例:链式调用仅在value()时执行最终逻辑
    const result = _.chain([1,2,3,4])
      .filter(x => x > 2) // 暂存逻辑,不立即执行
      .map(x => x * 2)    // 暂存逻辑,不立即执行
      .value();           // 执行所有逻辑,返回结果 [6,8]
    
  • 原生方法优先 :当原生方法可用时,优先使用原生方法,JavaScript 原生方法(如 Array.prototype.mapObject.keys)由引擎底层实现(C++),比纯 JS 实现快得多。

可扩展性

Underscore.js 设计了良好的扩展机制:

  • mixin 方法 :允许用户添加自定义函数,可将自定义函数挂载到 Underscore 原型上,支持链式调用:

    // 示例:自定义一个“求平方和”的方法,通过mixin挂载
    _.mixin({
      sumOfSquares: function(arr) {
        return _.reduce(arr, (sum, x) => sum + x * x, 0);
      }
    });
    
    // 直接调用 + 链式调用都支持
    console.log(_.sumOfSquares([1,2,3])); // 1+4+9=14
    
    const result = _.chain([1,2,3])
      .filter(x => x > 1) // [2,3]
      .sumOfSquares()     // 4+9=13
      .value();
    console.log(result); // 13
    
  • 自定义 iteratee :允许用户自定义迭代器行为

    // 示例:自定义迭代器,处理对象数组的特定属性
    const users = [
      { name: '张三', age: 20 },
      { name: '李四', age: 30 }
    ];
    
    // 自定义迭代器:提取age属性并判断是否大于25
    const ageIterator = user => user.age > 25;
    const result = _.filter(users, ageIterator);
    console.log(result); // [{ name: '李四', age: 30 }]
    
  • 模板系统 :支持自定义分隔符、变量插值规则,适配不同场景

    // 示例:自定义模板分隔符(默认是<% %>,改为{{ }})
    _.templateSettings = {
      evaluate: /{{(.+?)}}/g,    // 执行代码:{{ code }}
      interpolate: /{{=(.+?)}}/g // 插值:{{= value }}
    };
    
    // 使用自定义模板
    const template = _.template('Hello {{= name }}! {{ if (age > 18) { }}成年{{ } else { }}未成年{{ } }}');
    const html = template({ name: '张三', age: 20 });
    console.log(html); // Hello 张三! 成年
    

缺点

性能层面的损耗

链式调用的额外开销

Underscore 链式调用依赖每次方法调用创建新的 _$1 包装对象,且需通过 value() 触发最终计算:

  • 对象创建成本:每一步链式操作(如 map()/filter())都会实例化新的包装对象,频繁操作大型数据集时,内存分配和垃圾回收开销显著;
  • 原型链查找损耗:包装对象的方法挂载在 _$1.prototype 上,每次调用需遍历原型链,效率低于原生方法的直接调用;
  • 对比示例_.chain([1,2,3]).map(x=>x*2).value() 比原生 [1,2,3].map(x=>x*2) 多了「包装对象创建→原型链查找→结果重新包装」三层开销。

具体函数实现的效率瓶颈

  • 类型检测冗余:早期版本未优先使用原生 Array.isArray(),而是通过 Object.prototype.toString.call() 做类型判断,效率比原生 API 低 30% 以上;
  • 遍历策略不优:统一用通用遍历逻辑处理数组 / 对象,未针对数组使用更高效的 for 循环(而非 for...in),对象遍历未优先用 Object.keys() 过滤原型属性;
  • 高阶函数调用栈开销map/filter 等方法的迭代器需通过 optimizeCb 封装闭包,每次迭代都会产生函数调用栈损耗,而原生方法由引擎内联优化,无此开销。

闭包与内存占用

Underscore 基于 IIFE 封装核心逻辑,闭包会长期持有内部变量(如 _ 构造函数、mixin 缓存、工具函数):

  • 即使仅使用 _.isArray() 一个方法,整个闭包内的所有变量也无法被垃圾回收,造成内存冗余;
  • 非模块化环境下,全局挂载的 _ 变量常驻内存,进一步增加无意义的内存占用。

API 设计层面:一致性与易用性缺陷

双模式调用的混淆性

同时支持「函数式调用(_.map())」和「对象链式调用(_().map())」,带来双重问题:

  • 学习成本高:新手需理解两种模式的底层差异(如链式模式依赖 _wrapped 包装数据,函数式模式直接传参);
  • 行为不一致风险:部分方法在两种模式下参数传递有细微差异(如 _.reduce() 链式调用时 this 指向包装对象,函数式调用时需手动传 context)。

参数与行为的不一致性

  • 参数顺序混乱_.reduce(collection, iteratee, [accumulator]) 与原生 Array.prototype.reduce(callback, [initialValue]) 参数顺序相反,用户切换使用时易出错;
  • 边界处理不统一:对 null/undefined/ 空对象的处理逻辑混乱(如 _.map(null) 返回 []_.keys(null) 抛出错误);
  • 可选参数模糊_.defaults()/_.extend() 对默认值、浅拷贝的规则未明确标注,导致相同输入可能产生不同预期结果。

功能覆盖的冗余与缺失

  • 冗余覆盖:部分方法(如 _.each())仅对原生方法做简单封装,无额外价值却增加调用层级;
  • 核心功能缺失:早期版本无原生的深拷贝方法(_.cloneDeep() 为后期补充),需手动嵌套 _.extend() 实现,易用性差。

生态兼容层面:与现代开发体系脱节

模块化适配严重不足

  • 无原生 ES 模块支持:仅通过 IIFE 兼容 CommonJS/AMD,无法直接使用 import { map } from 'underscore' 按需导入;
  • 树摇(Tree-shaking)失效:模块结构设计导致现代打包工具(Webpack/Rollup)无法移除未使用的函数,即使仅用 _.isArray(),也会打包整个库(约 5KB),而原生 Array.isArray() 无体积成本;
  • 对比 Lodash-eslodash-es/map 可按需导入,体积仅几百字节,Underscore 无此能力。

现代语法与工具链适配差

  • 旧语法的陈旧性:依赖构造函数 + 原型链实现包装对象(_$1.prototype[name] = ...),与 ES6 class 语法脱节,现代开发者可读性差;
  • 箭头函数冲突:链式调用依赖 this 指向包装对象,而箭头函数的词法 this 会导致 this._wrapped 报错,增加使用复杂度;
  • 框架集成不契合:在 React/Vue 等现代框架中,其函数式风格与框架响应式系统(如 Vue 的 ref/reactive)适配性差,不如 Lodash/Ramda 灵活。

类型系统支持缺失

  • 无内置 TypeScript 类型:完全依赖第三方 @types/underscore,存在类型覆盖不全(如链式调用返回类型推断错误)、版本不匹配(库更新后类型定义滞后)等问题;
  • 类型安全不足:方法参数 / 返回值无类型约束,运行时易因类型错误导致 bug,而现代库(如 Lodash)原生支持 TS 类型。

扩展性与维护层面:原型链设计的硬伤

扩展机制的局限性

  • 原型污染风险mixin 方法直接挂载函数到 _$1.prototype,若自定义方法名与内置方法冲突(如自定义 map),会覆盖原生逻辑,导致意外行为;
  • 无结构化插件系统:扩展方式仅依赖 mixin,无法像 Vue/React 那样通过插件注册、生命周期管理复杂扩展,生态扩展性差。

代码结构与维护成本

  • 闭包嵌套复杂:核心逻辑通过多层闭包封装,早期版本包含大量 “魔法逻辑”(如 optimizeCb 优化迭代器),代码可读性极低;
  • 测试覆盖不全面:虽有基础测试用例,但跨环境(如旧版 IE)、边界场景(如空值 / 超大数组)的测试覆盖不足,修复 bug 易引入新问题;
  • 兼容负担重:为适配 IE6+ 等老旧环境,保留大量冗余的兼容代码,无法精简核心逻辑。

功能设计层面:能力不足与场景覆盖不全

异步操作支持缺失

  • 无原生支持 Promise/async/await,处理异步数据流(如接口请求→数据处理)时,需手动封装 _.map + Promise.all,代码冗余;
  • 防抖 / 节流函数(debounce/throttle)仅支持同步逻辑,无法处理异步回调的时序问题。

对象操作能力有限

  • 浅拷贝局限_.extend()/_.defaults() 仅支持浅拷贝,深度拷贝需手动实现或依赖第三方扩展,而原生 structuredClone() 或 Lodash _.cloneDeep() 已原生支持;
  • 对象遍历低效:无针对嵌套对象的遍历方法(如 _.deepKeys),处理复杂对象需多层嵌套调用。

函数式特性不完整

  • 柯里化 / 组合能力弱:仅通过 _.partial() 模拟柯里化(无法自动柯里化多参数函数),_.compose() 仅支持同步函数组合,无异步组合能力;
  • 惰性求值不彻底:链式调用虽有惰性特征,但仅在 value() 时执行,无法像 Ramda 那样实现 “按需计算”,处理超大数据集时效率低。

安全性层面:潜在的风险隐患

原型污染风险

早期版本的 _.extend()/_.defaults() 未过滤 __proto__ 属性,若传入包含 __proto__: { evil: true } 的用户输入,会修改 Object.prototype,导致全局原型污染:

// 原型污染示例(旧版本 Underscore)
const obj = {};
_.extend(obj, { __proto__: { test: 123 } });
console.log({}.test); // 123(全局原型被污染)

模板注入隐患

_.template() 方法默认使用 eval 执行模板中的代码,若未过滤用户输入的模板字符串,易引发代码注入攻击:

// 模板注入风险
const userInput = "{{= alert('XSS') }}";
const template = _.template(userInput);
template(); // 执行恶意代码

时代适配层面:原生 ES6+ 的全面替代

核心功能被原生方法覆盖

ES6+ 引入的原生 API 完全覆盖 Underscore 核心能力,且性能更优(引擎级优化):

Underscore 方法 原生替代方案 优势
_.map() Array.prototype.map() 无包装对象开销,引擎内联优化
_.keys() Object.keys() 原生实现,效率更高
_.extend() Object.assign() 原生支持,无需额外依赖
_.debounce() 浏览器原生 requestIdleCallback(或框架内置) 更贴合现代浏览器调度机制

旧语法与现代开发习惯脱节

  • 依赖 arguments 对象处理参数(如 _.partial()),而现代 JS 已支持剩余参数(...args),代码更简洁;
  • 构造函数 + 原型链的实现方式,与现代开发者熟悉的 class 语法相悖,学习和维护成本高。

核心总结

Underscore.js 的所有缺点本质是 “早期设计无法适配现代 JavaScript 生态”

  1. 性能层面:链式调用、闭包、低效实现带来多维度损耗,无法与原生引擎优化的 API 竞争;
  2. 生态层面:模块化、类型系统、现代工具链适配不足,无法满足现代工程化开发需求;
  3. 功能层面:异步、深拷贝、函数式特性的缺失,无法覆盖复杂业务场景;
  4. 安全 / 维护层面:原型污染、代码复杂、测试不足,增加生产环境风险。

深入解析LoRaWAN协议架构与核心组件:为什么NS服务器如此关键?

作者 赵明飞
2026年1月12日 06:17

在物联网(IoT)飞速发展的今天,低功耗、远距离、广连接的通信技术成为实现大规模设备联网的关键。其中,LoRaWAN(Long Range Wide Area Network)凭借其长距离、低功耗、大容量和高安全性等优势,已成为全球主流的低功耗广域网(LPWAN)标准之一,广泛应用于智慧城市、工业物联网、农业监测、环境监控等多个领域。

本文将带你全面了解 LoRaWAN 的协议分层、网络架构、数据传输流程以及其安全机制,并重点解析一个常被忽视却至关重要的核心组件——网络服务器(NS),为何它是整个系统不可或缺的“大脑”。

同时,我们也将介绍如何借助成熟的 LoRaWAN 平台快速搭建自己的物联网系统,助力开发者和企业高效落地项目。


一、LoRaWAN 协议分层:从物理层到应用层

LoRaWAN 是基于 LoRa 调制技术 构建的上层通信协议,由 LoRa 联盟制定并维护,专为物联网场景优化设计。其协议栈分为两层:

1. 物理层(PHY Layer)

  • 使用 LoRa 扩频调制技术,采用线性调频扩频(Chirp Spread Spectrum),具备极强的抗干扰能力和穿透性。
  • 工作频段根据地区不同而异:
    • 欧洲:868MHz
    • 北美:915MHz
    • 中国:CN470–510MHz(主要使用)、CN779–787MHz(功率受限)
  • 在城市环境中通信距离可达 3~5公里,郊区甚至可达 15公里以上,某些理想条件下可突破25公里。

2. 数据链路层(MAC Layer)

  • 遵循 LoRaWAN 协议规范,定义了终端接入、数据传输、安全认证等机制。
  • 采用改进型 ALOHA 协议,终端无需监听信道即可发送数据,简化了通信流程。
  • 支持 ADR(自适应数据速率):网络服务器可根据信号质量动态调整终端的数据速率和发射功率,平衡覆盖范围与电池寿命。

二、LoRaWAN 网络架构:星型拓扑的四层结构

LoRaWAN 采用典型的星型网络结构,主要包括以下四个部分:

1. 终端设备(End Devices)

如温湿度传感器、智能电表、门磁、水浸报警器等,通常由电池供电,具有超低功耗特性。它们通过 LoRa 无线方式将采集的数据发送给网关。

示例:农田中的土壤湿度传感器定时上报数据,用于自动灌溉控制。

2. 网关(Gateway)

又称集中器,是连接终端与后台系统的桥梁。它能同时接收多个终端的数据,并通过以太网或4G/5G回传至网络服务器。

  • 支持多通道并发接收,单个网关可连接数千个终端。
  • 室外网关适合大面积覆盖,室内网关 则适用于楼宇内部部署。

3. 网络服务器(Network Server, NS)

这是整个 LoRaWAN 系统的“中枢神经”,负责处理所有来自网关的数据,进行去重、解码、路由选择、频率管理、速率调控等关键操作。

4. 应用服务器(Application Server)

执行具体业务逻辑,比如数据分析、可视化展示、告警推送、远程控制指令下发等。例如,在智慧园区中,应用服务器可根据温湿度数据自动调节空调系统。


三、各组件之间的协作关系

  1. 终端 ↔ 网关 终端通过 LoRa 发送数据包,网关接收后添加 RSSI(信号强度)、SNR(信噪比)等信息,转发至 NS。
  2. 网关 ↔ 网络服务器 多个网关可能接收到同一终端的数据包,NS 负责判断最优路径、去除重复包,并决定是否需要确认回复。
  3. NS ↔ 应用服务器 NS 将有效数据解密并转发给对应的应用服务器;反之,下行指令也需经 NS 路由至目标终端。

正是因为这种“多对一”的汇聚结构,NS 成为了保障数据完整性、网络效率和安全性的核心节点。


四、LoRaWAN 的加密机制:端到端安全保障

安全性是物联网不可忽视的一环。LoRaWAN 提供双重加密保护:

  • NwkSKey(网络会话密钥):保护网络层数据,防止非法设备接入或篡改控制命令。
  • AppSKey(应用会话密钥):实现终端与应用服务器之间的 端到端加密,即使 NS 被攻击,原始业务数据依然无法被窃取。

密钥在设备入网时通过 OTAA(Over-The-Air Activation)或 ABP(Activation By Personalization)方式生成,确保每次通信的安全性。


五、为什么 NS(网络服务器)必不可少?

很多人误以为只要有了终端和网关就能完成通信,但实际上,没有 NS,LoRaWAN 网络根本无法正常运行。以下是 NS 的三大核心作用:

5.1 数据处理与去重

当一个终端发出的数据被多个网关接收到时,NS 必须从中选出最佳数据包(基于信号质量),剔除冗余信息,避免应用服务器重复处理。

5.2 网络管理与资源调度

  • 动态启用 ADR,优化终端速率与功耗;
  • 管理设备入网、退网状态;
  • 分配频率与信道,防止冲突;
  • 控制 MAC 命令下发(如请求确认、关闭接收窗口等)。

5.3 安全控制中心

  • 执行设备身份验证;
  • 管理会话密钥的生命周期;
  • 解密上行数据,加密下行指令;
  • 防止重放攻击、中间人攻击等安全威胁。

可以说,NS 是 LoRaWAN 网络的大脑与心脏,决定了整个系统的稳定性、扩展性和安全性。


六、LoRaWAN 的核心优势总结

优势 说明
✅ 长距离通信 城市数公里,郊区超10公里
✅ 超低功耗 电池寿命可达5~10年
✅ 大规模连接 单网关支持数千终端
✅ 高安全性 AES-128 加密 + 双重密钥机制
✅ 灵活部署 星型结构易扩展,支持私有部署
✅ 成本低廉 设备与基础设施成本低

七、典型应用场景

  • 🌆 智慧城市:智能路灯、垃圾满溢检测、停车管理、抄表系统
  • 🌾 智慧农业:土壤墒情监测、气象站、灌溉控制
  • 🏭 工业物联网:设备状态监控、资产追踪、预测性维护
  • 🏠 智能家居:门窗传感器、温控系统、安防报警
  • 🌍 环境监测:空气质量、水质、噪声实时采集
  • 🏥 医疗健康:可穿戴设备远程监护、药品冷链追踪

八、如何快速搭建自己的 LoRaWAN 系统?

对于中小企业或开发者而言,自研 NS 不仅成本高、周期长,还需应对复杂的协议兼容问题。更高效的方案是使用成熟的 LoRaWAN 平台。

门思科技(Manthink) 提供了一站式 LoRaWAN 解决方案:

  • DTU产品系列:支持 RS-485/M-Bus/4-20mA 接口,即插即用,无需开发即可接入 LoRaWAN。
  • LoRaWAN 温湿度传感器:IP65防护等级,8年电池寿命,适用于仓储、农业等场景。
  • 室外网关 GDO51:企业级 Ubuntu 系统,支持 ChirpStack、TTN、Basic Station 和 ThinkLink 协议。
  • ThinkLink:门思科技自主研发的 LoRaWAN NS 平台,支持全球标准,提供规则引擎、数据卡片、API 对接等功能。

结语:选择开放平台,加速 IoT 落地

LoRaWAN 技术正在重塑万物互联的方式。而作为网络核心的 NS 服务器,不仅是数据流转的枢纽,更是保障系统稳定与安全的基石。

如果你正计划开展一个小规模物联网项目,不妨试试 门思科技的 ThinkLink 平台 —— 它不仅免费支持 1000个设备接入,还兼容任何品牌的 LoRaWAN 网关与终端,真正实现“开箱即用”。

无需自建服务器,无需复杂配置,注册即用,极大降低初期投入和技术门槛。


🔗 推荐阅读与资源链接:

📌 关注我们,获取更多 LoRa、LoRaWAN、网关、NS 相关的技术干货与行业案例!

#LoRa #LoRaWAN #物联网 #网关 #NS #NetworkServer #智慧城市 #低功耗广域网 #门思科技 #Manthink #ThinkLink #工业物联网 #无线通信 #CSDN #微信公众号 #技术科普

前端存储与离线应用实战:Cookie、LocalStorage、PWA 及 Service Worker 核心知识点

2026年1月12日 10:02

1. 前言

该文章围绕浏览器存储及相关技术展开,核心涵盖Cookie、LocalStorage、SessionStorage、IndexedDB 四种浏览器存储方式(各有存储大小、使用场景等差异),同时介绍了 PWA(渐进式 Web 应用) 的特性与相关工具,以及 Service Worker 的作用、运行机制和调试方式,最终通过案例分析与实战帮助学习者掌握各类技术的概念、使用及选择逻辑。

2.思维导图(mindmap)

image.png

3.浏览器存储方式详情(核心对比)

存储方式 核心定位 存储大小 关键特性 典型用途
Cookie 维持 HTTP 无状态的客户端状态存储 约 4KB 1. 生成方式:HTTP 响应头 set-cookie、JS 的 document.cookie;2. 关联对应域名(存在 CDN 流量损耗);3. 支持 httponly 属性;4. 可设置 expire 过期时间 辨别用户、记录客户基础信息
LocalStorage HTML5 专用浏览器本地存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 持久化存储(除非主动清除) 浏览器本地缓存方案
SessionStorage 会话级浏览器存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 会话结束后数据清除 临时维护表单信息
IndexedDB 客户端大容量结构化数据存储 -(无明确限制,支持大量数据) 1. 低级 API,支持索引;2. 高性能数据搜索;3. 弥补 Web Storage 大容量存储短板 为应用创建离线版本

三、PWA(Progressive Web Apps)相关

  1. 定义:并非单一技术,而是通过一系列 Web 新特性 + 优秀 UI 交互设计,渐进式增强 Web App 用户体验的新模型

  2. 核心特性:

    • 可靠:无网络环境下可提供基本页面访问,避免 “未连接到互联网” 提示

    • 快速:针对网页渲染和网络数据访问做了专项优化

    • 融入:可添加到手机桌面,支持全屏显示、推送等原生应用类似特性

  3. 相关工具:lighthouse(下载地址:lavas.baidu.com/doc-assets/…

四、Service Worker 相关

  1. 定义:独立于当前网页,在浏览器后台运行的脚本,为无页面 / 无用户交互场景的特性提供支持
  2. 核心能力:
    • 首要特性:拦截和处理网络请求,编程式管理缓存响应
    • 未来特性:推送消息、背景同步、地理围栏定位(geofencing)
  3. 生命周期:Installing(安装中)→ Activated(激活)→ Idle(闲置)/ Terminated(终止),过程中可能出现 Error(错误)
  4. 调试地址:
    • chrome://serviceworker-internals/
    • chrome://inspect/#service-workers

4. 关键问题

问题 1:Cookie 与 LocalStorage 作为浏览器存储方式,核心差异体现在哪些方面?

答案:两者核心差异集中在 4 点:1. 存储大小:Cookie 约 4KB,LocalStorage 约 5M;2. 通信特性:Cookie 会随 HTTP 请求发送至服务端(关联域名导致 CDN 流量损耗),LocalStorage 仅在客户端使用,不与服务端通信;3. 核心定位:Cookie 侧重维持 HTTP 无状态的客户端状态,LocalStorage 是 HTML5 设计的专用本地缓存方案;4. 附加特性:Cookie 支持 expire 过期时间和 httponly 属性,LocalStorage 无过期时间(需主动清除)且无 httponly 相关设置。

问题 2:PWA 能提供 “可靠、快速、融入” 的用户体验,其背后依赖的关键技术支撑是什么?

答案:PWA 的核心体验依赖两大关键技术:1. Service Worker:通过后台运行的脚本拦截网络请求、管理缓存响应,实现无网络环境下的基本页面访问(支撑 “可靠” 特性),同时优化网络数据访问效率(辅助 “快速” 特性);2. IndexedDB:提供客户端大容量结构化数据存储能力,为 PWA 离线版本提供数据支撑(强化 “可靠” 特性);此外,Web 新特性与优化的 UI 交互设计共同保障了 “快速” 和 “融入”(如桌面添加、全屏显示)特性的实现。

问题 3:在实际开发中,如何根据需求选择合适的浏览器存储方式?

答案:需结合存储数据量、使用场景、是否与服务端交互等需求判断:1. 若需存储少量用户标识、会话状态(需随请求发送至服务端),选择 Cookie(约 4KB,支持过期时间);2. 若需在客户端持久化存储中等容量数据(不与服务端交互),如本地缓存配置、用户偏好,选择 LocalStorage(约 5M);3. 若需临时存储会话期间的表单数据、页面临时状态(会话结束后无需保留),选择 SessionStorage(约 5M);4. 若需存储大量结构化数据(如离线应用的本地数据库),支撑应用离线使用,选择 IndexedDB(无明确容量限制,支持索引和高性能搜索)。

Vben Admin管理系统集成qiankun微服务(二)

作者 go_caipu
2026年1月11日 23:05

继上篇

上篇Vben Admin管理系统集成qiankun微服务(一)遗留的三个问题:

  1. 子应用鉴权使用主应用鉴权,如果系统鉴权过期要跳转到登录页面。
  2. 主应用和子应用保持主题风格一致,主应用调整子应用同步调整。
  3. 支持多个应用动态加载。

下面分步完成以上相关内容

1. 主应用和子应用主题同步

主应用

主应用和子应用的数据传递主要使用props实现,上篇文章已经实现了部分没有详细解释,本篇补充以上内容。 通过props.userInfo和props.token 传递登录信息和授权信息,

vue-vben-admin/apps/web-antd/src/qiankun/config.ts

/**  本地应用测试微服务架构 */
export default {
  subApps: [
    {
      name: 'basic', // 子应用名称,跟package.json一致
      // entry: import.meta.env.VITE_API_BASE_URL, // 子应用入口,本地环境下指定端口
      entry: 'http://localhost:5667', // 子应用入口,本地前端环境下指定端口'http://localhost:5174',发布可以调整为主系统:/app/workflow-app/= /app/插件名称/
      container: '#sub-container', // 挂载子应用的dom
      activeRule: '/app/basic', // 路由匹配规则
      props: {
        userInfo: [],
        token: '',
      }, // 主应用与子应用通信传值
      sandbox: {
        strictStyleIsolation: true, // 启用严格样式隔离
      },
    },
  ],
};

vue-vben-admin/apps/web-antd/src/qiankun/index.ts文件,实现代码主要是在beforeLoad函数

// 参考项目:https://github.com/wstee/qiankun-web
import { useAccessStore, useUserStore } from '@vben/stores';

import { registerMicroApps } from 'qiankun';

import config from './config';

const { subApps } = config;

export async function registerApps() {
  try {
    // 如果子应用是不定的,可以这里定义接口从后台获取赋值给subApps,动态添加

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
         
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          const container = document.querySelector(app.container);
          if (container) container.innerHTML = '';
        },
      ],
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

子应用调整

修改代码读取主应用传递的参数,调整mount函数 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

   async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);
      // 监听主应用的主题事件
      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    }

如果操作子应用时登录信息失效了呢,要让应用跳转到登录,可以修改setupAccessGuard函数,按照如下修改直接跳转到系统登录页。

caipu-vben-admin/apps/app-antd-child/src/router/guard.ts

 // 没有访问权限,跳转登录页面
      if (to.fullPath !== LOGIN_PATH) {
        // return {
        //   path: LOGIN_PATH,
        //   // 如不需要,直接删除 query
        //   query:
        //     to.fullPath === preferences.app.defaultHomePath
        //       ? {}
        //       : { redirect: encodeURIComponent(to.fullPath) },
        //   // 携带当前跳转的页面,登录后重新跳转该页面
        //   replace: true,
        // };
        window.location = 'http://localhost:5666/#/login';
      }

这样就实现主应用和子应用的信息同步了。

2. 主应用与子应用主题同步

vben主题相关配置是在'@vben/preferences'包中,要调整的动态配置主要是在preferences.theme当中,所以实现主题同步只要把配置信息同步到子应用即可。

未通过props传递原因是加载子应用之后再调整偏好设置和主题 子应用不生效,所以考虑只能通另外一种方式实现,最终选择 window.dispatchEvent事件监听的方式实现。

image.png

主应用调整

调整 vue-vben-admin/apps/web-antd/src/layouts/basic.vue

# 引用包
import { preferences } from '@vben/preferences';

# 合适位置增加主题监听
watch(
  () => ({
    theme: preferences.theme,
  }),
  async ({ theme }) => {
    alert('handler qiankun-theme  start', theme);
    // 子应用会监听这个事件并更新响应式对象
    window.dispatchEvent(
      new CustomEvent('qiankun-theme-update', {
        detail: preferences,
      }),
    );
  },
  {
    immediate: true,
  },
);

子应用调整

如果细心的话,在上述子应用调整的main.ts,mount函数要已有说明,主要是增加事件监听qiankun-theme-update 和监听处理事件handleThemeUpdate,完整代码如下 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

import { initPreferences, updatePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { unmountGlobalLoading } from '@vben/utils';

import {
  qiankunWindow,
  renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';

import { bootstrap } from './bootstrap';
import { overridesPreferences } from './preferences';

let app: any = null;
/**
 * 应用初始化完成之后再进行页面加载渲染
 */
async function initApplication(container: any = null) {
  // name用于指定项目唯一标识
  // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
  const env = import.meta.env.PROD ? 'prod' : 'dev';
  const appVersion = import.meta.env.VITE_APP_VERSION;
  const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;

  // app偏好设置初始化
  await initPreferences({
    namespace,
    overrides: overridesPreferences,
  });
  // 启动应用并挂载
  // vue应用主要逻辑及视图
  app = await bootstrap(namespace, container);
  // 移除并销毁loading
  unmountGlobalLoading();
}

const initQianKun = async () => {
  renderWithQiankun({
    async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);

      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    },
    bootstrap() {
      return new Promise((resolve, reject) => {
        // eslint-disable-next-line no-console
        console.log('[qiankun] app bootstrap');
        resolve();
      });
    },
    update(props: any) {
      // eslint-disable-next-line no-console
      console.log('[子应用]  update');
      const { container } = props;
      initApplication(container);
    },
    unmount(props) {
      // 移除事件监听
      if (handleThemeUpdate) {
        // eslint-disable-next-line no-console
        console.log('remove sub apps theme handle:', app.name);
        window.removeEventListener('qiankun-theme-update', handleThemeUpdate);
      }
      // eslint-disable-next-line no-console
      console.log('[子应用] unmount', props);
      app?.unmount();
      app = null;
    },
  });
};
// 判断是否为乾坤环境,否则会报错iqiankun]: Target container with #subAppContainerVue3 not existed while subAppVue3 mounting!
qiankunWindow.__POWERED_BY_QIANKUN__
  ? await initQianKun()
  : await initApplication();

const handleThemeUpdate = (event: any) => {
  const newTheme = event.detail;
  if (newTheme) {
    // 更新响应式对象,由于是响应式的,Vue 会自动更新视图
    console.log('子应用主题已更新(通过 props + 事件):', newTheme);
    updatePreferences(newTheme);
  }
};

3. 支持多个应用动态加载

子应用如果不是固定subApps,要从后台加载那如何实现呢,比如我的程序实现子应用动态插拔,后台安装子应用之后前台就要支持展示。 代码逻辑是:本地调试从config.ts获取固定配置,发布环境读取后台配置。主要看registerApps()。 核心代码是下面这段:

 if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

完整文件代码是:

import type { MicroApp } from '#/api/apps/model';

import { useAccessStore, useUserStore } from '@vben/stores';

// 参考项目:https://github.com/wstee/qiankun-web
import { registerMicroApps } from 'qiankun';

import { GetMicroApp } from '#/api/apps';

import config from './config';

let { subApps } = config;

export async function registerApps() {
  try {
    // 判断是否是发布环境,发布环境从后台获p取subApps
    if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
          // app.props.publicKey = import.meta.env.VITE_PUBLIC_KEY;
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          // const container = document.querySelector(app.container);
          // if (container) container.innerHTML = '';
          // 仅隐藏容器,不删除 DOM
          if (app.container.style) {
            app.container.style.display = 'none';
          }
        },
      ],
      beforeUnmount: (app) => {
        // 重新显示容器
        if (app.container.style) {
          app.container.style.display = 'none';
        }
      },
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

GetMicroApp()返回数据结构json结果如下,主要是data的内容:

{
    "code": 200,
    "data": [
        {
            "name": "caipu-site",
            "entry": "/app/caipu-site/",
            "activeRule": "/app/caipu-site"
        },
        {
            "name": "email",
            "entry": "/app/email/",
            "activeRule": "/app/email"
        },
        {
            "name": "ip2region",
            "entry": "/app/ip2region/",
            "activeRule": "/app/ip2region"
        },
        {
            "name": "testdata",
            "entry": "/app/testdata/",
            "activeRule": "/app/testdata"
        }
    ],
    "msg": "",
    "success": true,
    "timestamp": 1768140865000
}

最后

  1. 上文有小伙伴回复是否可以支持主应用多页签切换不同子应用的页面状态保持,抱歉多次尝试未在vben实现此功能,作为一名后端人员技术有限如您有实现方案,请不吝指教。

  2. 抽时间也会尝试下wujie微前端方案完善相关功能,基于以上浅显内容,欢迎大积极尝试和分享。 如你有更好的建议内容分享请给评论。

如有幸被转载请注明出处: go-caipu

insertAdjacentHTML踩坑实录:AI没搞定的问题,我给搞定啦

2026年1月11日 20:40

今天开发「Todo-List」应用新特性:任务支持配置标签功能。

在做添加标签的美化效果:点击【+标签】按钮,自动转换为可编辑标签名的输入框。

标签功能示例.gif

用AI编写效果,很快就编码完了。

💡提示词:请优化标签在页面的显示方式,要求: 1.创建任务或更新任务时,可以显示数据库现有标签供用户选择; 2.用户在创建或更新任务时,可以通过选择或取消选择的方式来设置任务关联的标签; 3.用户在创建或更新任务时,可以新增标签,在现有标签列最后是一个+标签,点击该标签即可转换为标签编辑模式进行添加新标签,而后添加完新标签,在新标签后一个又出现新的+标签; 4.创建或更新任务中,除+标签外,数据库已有标签的名字后面可以以数字形式显示当前标签关联任务数据; 5.创建或更新任务时,除+标签外,显示数字是0的标签可以通过标签末端的×进行删除。请实现上述功能!

但是尬尴:重复点击【+标签】按钮,监听事件仅首次生效,后续点击都不生效。而且关键是给AI说明了问题场景后,AI没修复!

💡提示词:当前+标签存在bug,点击首次可以生效转变为编辑模式,使用×关闭后,重新点击+标签,无法再次转换为编辑模式。要求实现可以重复点击和转换

(PS:为了证明AI没修复对,此处附上AI修复方式的截图呢)

靠AI有时候确实也是靠不住,还是得靠自己啊。

一波调试定位,发现这行代码: selector.insertAdjacentHTML('beforeend', inputHtml),前后两次执行效果竟然不一样。

这时候,第二个AI大佬就该登场了。(PS:毕竟我前端代码也不熟,还是得仰仗AI啊)

insertAdjacentHTML() 是 DOM 操作 API,用于在指定位置插入 HTML 字符串。如果元素有id,且id重复插入,那么就会出现问题。

<!-- 第一次插入 -->
<input id="myInput" type="text">

<!-- 第二次插入(相同ID) -->
<input id="myInput" type="text"> <!-- 重复ID,事件可能失效 -->

好吧,检查了下代码,确实是这个原因。

最终修复方式也很简单,id动态化。

generateRandomId() {
  // 时间戳确保唯一性,随机数增加安全性
  return `new-tag-input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

// 显示新增标签输入框
showAddTagInput() {
    const addBtn = document.getElementById('add-tag-btn');
    if (!addBtn) return;

    // 生成当前counter值对应的ID
    const currentId = this.generateRandomId();

    const inputHtml = `
        <span class="tag-input-mode" id="tag-input-mode">
            <input type="text" id="${currentId}" placeholder="标签名" maxlength="20">
            <button class="btn-cancel" id="cancel-add-tag">×</button>
        </span>
    `;

    addBtn.replaceWith(document.createElement('span'));
    const inputContainer = document.getElementById('tag-input-mode');
    if (inputContainer) {
        inputContainer.outerHTML = inputHtml;
    } else {
        const selector = document.getElementById('tags-selector');
        selector.insertAdjacentHTML('beforeend', inputHtml);
    }

    // 绑定事件
    // ...
}

所以,前端的开发同学要留意啦,insertAdjacentHTML 这个API使用的时候可以要注意插入的元素的id或class不能重复哦,不然会出现二次点击事件不生效的呢。

今天分享的内容就到这了,感谢阅读,欢迎三连哦!

❌
❌