阅读视图

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

一次 React 项目 lock 文件冲突修复:从 Hook 报错到 Vite 配置优化

一次 React 项目 lock 文件冲突修复:从 Hook 报错到 Vite 配置优化

在日常开发中,分支合并是高频操作,但稍有不慎就可能引发依赖相关的“连锁反应”。本文记录了一次 rebase main 后因 lock 文件冲突,导致 React Hook 报错的完整排查与解决过程,希望能为遇到类似问题的开发者提供参考。

一、背景:rebase main 引发的“意外”

最近在开发一个基于 React + Vite + Mobx 的项目,为了同步主分支的最新代码,我执行了 git rebase main 操作。过程中遇到了 package-lock.json 冲突,由于当时急于推进开发,我直接手动编辑了冲突文件,保留了双方的依赖配置后提交了代码。

本以为只是简单的文件合并,没想到启动项目后,浏览器控制台直接抛出了一连串报错:

img_v3_02sr_d84a55a3-57fb-4edb-b768-04c950fdd4hu.jpg

报错堆栈指向 mobx-react-lite 中的 useObserver 方法,提示 useRef 无法读取 null 属性。更奇怪的是,这些代码在 rebase 前完全正常,没有任何语法或逻辑修改。

二、问题分析:锁定核心矛盾

1. 排除代码逻辑问题

首先排查业务代码:近期未修改 Hook 调用逻辑,所有 useRefuseState 等 Hooks 均符合“顶层调用”规则,且未在条件、循环或事件处理函数中调用。排除代码本身的问题后,将目光聚焦到依赖和构建配置上。

2. 定位依赖层面问题

根据 React 官方文档提示,Hook 调用异常的三大常见原因:

  1. 违反 Hooks 使用规则(已排除);
  2. React 与渲染器(如 React DOM)版本不匹配;
  3. 项目中存在多个 React 实例。

结合“仅 lock 文件冲突后出现问题”的场景,重点排查后两点:

  • 执行 npm ls react react-dom 查看依赖树,
    • 发现输出中,Terminal#1-14 显示面板同时存在两版 mobx-react-lite :直接依赖 4.1.0 ,通过 mobx-react@9.2.1 间接带入 4.1.1 。这会让它们各自沿着不同的依赖解析路径去找 react ,在多入口/预打包的情况下,很容易把两份 React 打到同一页面。
  • 进一步验证:在打包文件中搜索package.json中的react版本号18.3.1,或者搜索react源码中的ReactCurrentDispatch。可以发现合了代码之后,构建产物两个chunk中都有react。
image.pngimg_v3_02sr_9d69706b-b957-48ec-8144-06036dc021hu.jpg

代码修改前的打包资源

img_v3_02sr_8a12fa19-d116-403a-9e4d-74a9914ce9hu.jpg

img_v3_02sr_9d2d7185-de0b-474a-8b81-b6169247b3hu.jpg

代码修改后的打包资源

3. 追溯问题根源

lock 文件的核心作用是锁定依赖的安装路径和版本。手动合并冲突时,错误保留了不同分支的依赖配置,导致 npm install 时出现依赖嵌套安装:

  • 项目和项目依赖的包都依赖了mobx-react-lite并且版本不同。
  • 打包产物中,两个chunk中各自有一个react
  • 运行时,就产生了两个react实例

React Hooks 的运行依赖单一的调度器实例,当 mobx-react-lite 中的 useObserver 调用嵌套依赖的 React 实例时,会因调度器不匹配导致 Hook 调用失效,进而抛出 useRef 读取 null 的错误。

三、尝试修改:从依赖到配置的逐步排查

1. 重置依赖(首次尝试失败)

首先想到的是修复依赖树,执行以下操作:

# 清除本地依赖和缓存
rm -rf node_modules package-lock.json
npm cache clean --force
# 重新安装依赖
npm install

但重新安装后,npm ls react 仍显示存在嵌套版本。推测是 mobx-react-lite 的依赖声明中未将 React 设为 peerDependency,导致 npm 自动安装兼容版本的嵌套依赖。

2. 强制统一依赖版本(部分缓解)

通过 npm install react@18.2.0 react-dom@18.2.0 --force 强制指定 React 版本,重新安装后嵌套依赖消失。但启动项目后,仍偶尔出现 Hook 报错,排查发现是 Vite 开发环境预构建时未正确识别依赖,导致部分代码仍引用旧版本缓存。

3. 优化 Vite 配置(最终突破)

结合之前对 Vite dedupeoptimizeDeps 的了解,意识到需要从构建层面确保依赖的唯一性和预构建的完整性:

  • resolve.dedupe:强制 Vite 将所有 React 相关依赖解析为根目录版本,杜绝多实例;
  • optimizeDeps.include:强制预构建核心依赖,避免预构建漏检导致的缓存问题。

四、解决问题:最终生效的配置方案

1. 固化 Vite 配置

修改 vite.config.js,添加依赖去重和预构建配置:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    // 去重 React 相关核心依赖,确保单一实例
    dedupe: ['react', 'react-dom', 'mobx-react-lite'],
  },
  optimizeDeps: {
    // 强制预构建核心依赖,避免漏检
    include: ['react', 'react-dom', 'mobx-react-lite'],
    // 预构建阶段再次去重,双重保障
    dedupe: ['react', 'react-dom'],
  },
})

2. 清理缓存并验证

执行 vite --force 强制清除预构建缓存,重新启动项目后:

  • 浏览器控制台无任何 Hook 相关报错;
  • 执行 npm ls react react-dom 仅显示根目录单一版本;
  • 打印 React 实例对比结果为 true,确认多实例问题彻底解决。

五、总结与反思

这次问题的核心是“lock 文件冲突处理不当”,但背后暴露了对依赖管理和构建工具配置的认知缺口。总结几点关键经验:

  1. lock 文件冲突切勿手动修改:遇到 lock 文件冲突时,优先执行 git checkout -- package-lock.json 回滚,再通过 rm -rf node_modules && npm install 重新安装,避免依赖树混乱;
  2. 依赖声明需规范:第三方库应将 React 等核心依赖设为 peerDependency,而非直接依赖,避免嵌套安装;
  3. Vite 配置的“防护作用” :对于 React、Vue 等核心依赖,建议在 Vite 配置中提前设置 dedupeoptimizeDeps.include,从构建层面规避多实例和预构建问题;
  4. 报错排查要结合官方文档:React 官方明确列出了 Hook 调用异常的三大原因,排查时应先对照文档缩小范围,避免盲目尝试。

此次排查过程虽曲折,但也加深了对依赖管理、Vite 构建原理和 React Hooks 运行机制的理解。希望这篇记录能帮助大家在遇到类似问题时少走弯路~

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

大家好,今天用一个最经典的 Todos 应用,来带大家彻底搞清楚:

「为什么我们不再手动操作 DOM?Vue 到底替我们做了什么?」

很多初学者看完 Vue 文档后,会觉得「好像很简单啊」,但真正自己写的时候,又会不自觉地回到原来的命令式写法:

document.getElementById('app').innerHTML = xxx

这篇文章将通过一个逐步演进的过程,让你从「机械式 DOM 操作」进化到「数据驱动」的现代 Vue3 开发思维,彻底领悟响应式编程的魅力。

一、原生 JS 写 Todos:痛并痛苦着

先来看看传统写法(很多人还在这么写):

<h2 id="app"></h2>
<input type="text" id="todo-input">

<script>
  const app = document.getElementById('app');
  const todoInput = document.getElementById('todo-input');
  
  todoInput.addEventListener('change', function(event) {
    const todo = event.target.value.trim();
    if (!todo) return;
    app.innerHTML = todo; // 只能显示最后一个!
  })
</script>

这代码能跑,但问题一大堆:

  • 只能显示一条任务(innerHTML 被覆盖)
  • 要实现多条任务、删除、完成状态……需要写几百行 DOM 操作
  • 一旦需求变动,改起来就是灾难

这就是典型的命令式编程:我们的大脑一直在想「我要先找到哪个元素,然后怎么改它」。

而 Vue 的核心思想是:别管 DOM,你只管数据就行。

二、Vue3 + Composition API 完整实现

03998dfb2be956b19c909a672ec27e78.jpg

<!-- App.vue -->
<script setup>
import { ref, computed } from 'vue'

// 1. 响应式数据(重点!)
const title = ref('') // 输入框内容
const todos = ref([
  { id: 1, title: '吃饭', done: false },
  { id: 2, title: '睡觉', done: true }
])

// 2. 计算属性:统计未完成任务数量(带缓存!)
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 3. 添加任务
const addTodo = () => {
  if (!title.value.trim()) return
  
  todos.value.push({
    id: Date.now(), // 推荐用时间戳,比 Math.random() 更可靠
    title: title.value.trim(),
    done: false
  })
  title.value = '' // 清空输入框
}

// 4. 高级技巧:全选/全不选(computed 的 getter + setter)
const allDone = computed({
  get() {
    if (todos.value.length === 0) return false
    return todos.value.every(todo => todo.done)
  },
  set(value) {
    todos.value.forEach(todo => {
      todo.done = value
    })
  }
})
</script>

<template>
  <div class="todos">
    <h2>我的任务清单</h2>
    
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="今天要做什么?按回车添加"
      class="input"
    />

    <!-- 任务列表 -->
    <ul v-if="todos.length" class="todo-list">
      <li v-for="todo in todos" :key="todo.id" class="todo-item">
        <input type="checkbox" v-model="todo.done">
        <span :class="{ done: todo.done }">{{ todo.title }}</span>
      </li>
    </ul>
    
    <div v-else class="empty">
      🎉 暂无任务,休息一下吧~
    </div>

    <!-- 统计 + 全选 -->
    <div class="footer">
      <label>
        <input type="checkbox" v-model="allDone">
        全选
      </label>
      <span>未完成:{{ active }} / 总数:{{ todos.length }}</span>
    </div>
  </div>
</template>

<style scoped>
.done{
  color: gray;
  text-decoration: line-through;
}
</style>

三、核心知识点深度拆解(建议反复看)

1. ref() 是如何做到响应式的?

const title = ref('')

这句话背后发生了什么?

  • Vue 在内部为 title 创建了一个响应式对象
  • 真正的数据存在 title.value 中
  • 当你读取 title.value 时,Vue 会记录「当前组件依赖了这个数据」
  • 当你修改 title.value 时,Vue 知道「哪些组件需要重新渲染」,自动更新 DOM

这就叫「依赖收集 + 自动更新」,你完全不用管 DOM!

2. 为什么 computed 比普通函数香?

// 普通函数写法(每次都会计算!)
const activeCount = () => todos.value.filter(...).length

// computed 写法(只有依赖变化才重新计算)
const active = computed(() => todos.value.filter(...).length)

性能差异巨大!当你有 1000 条任务时,普通函数会在每次渲染都执行 1000 次过滤,而 computed 可能只执行一次。

3. computed 的 getter + setter 神技(90%的人不知道)

const allDone = computed({
  get() {
    // 如果todos为空,返回false
    if (todos.value.length === 0) return false;
    // 如果所有todo都完成,返回true
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    // 设置所有todo的done状态
    todos.value.forEach(todo => {
      todo.done = value;
    });
  }
})

这才是真正的「双向计算属性」!点击全选框时,v-model 会自动调用 setter,把所有任务的 done 状态同步修改。

4. v-for 一定要写 :key!不然会出大问题

<li v-for="todo in todos" :key="todo.id">

不写 key 的后果:

  • Vue 无法准确判断哪条数据变了,会导致整张列表重绘
  • 输入框焦点丢失、动画错乱、状态错位

推荐 key 使用:

id: Date.now() + Math.random() // 更稳妥
// 或使用 uuid 库

5. v-model 本质是 :value + @input 的语法糖

Vue 的双向绑定(v-model) = 数据 → 视图 的绑定 + 视图 → 数据的绑定

它让「数据」和「表单元素的值」始终保持同步,你改数据,界面自动更新;你改输入框,数据也自动更新。

<input v-model="title">
<!-- 等价于 -->
<input :value="title" @input="title = $event.target.value">

拆解一下:

方向 对应指令 作用
数据 → 视图 :value="msg" 把 msg 的值渲染到 input 上
视图 → 数据 @input="msg = $event.target.value" 用户输入时,把值重新赋值给 msg

而 @keydown.enter 是 Vue 提供的键位修饰符,超级好用:

@keydown.enter="addTodo"
@keydown.ctrl.enter="addTodo"
@click.prevent="submit" <!-- 阻止默认行为 -->

四、常见坑位避雷指南(血泪经验)

场景 错误写法 正确写法 说明
添加任务后输入框不清空 没重置 title.value title.value = '' v-model 是双向绑定,必须手动清空
全选状态不同步 用普通变量控制 用 computed({get,set}) 普通变量无法响应所有任务的变化
key 使用 index :key="index" :key="todo.id" index 会导致状态错乱
id 使用 Math.random() id: Math.random() id: Date.now() 可能重复,尤其快速添加时
computed 忘记 .value return todos.filter(...) return todos.value.filter(...) script setup 中 ref 要加 .value

五、细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

computed 是如何做到「又快又省」的?

一句话结论:
computed 只有在它的「依赖」真正发生变化时,才会重新计算一次,其他所有时间直接返回缓存结果。

这才是它比普通方法快 10~100 倍的根本原因!

一、最直观的对比实验
<script setup>
import { ref, computed } from 'vue'

const a = ref(1)
const b = ref(10)

// 场景1:普通方法(每次渲染都重新算)
const sum1 = () => {
  console.log('普通方法被调用了') 
  return a.value + b.value
}

// 场景2:computed(只有依赖变了才算)
const sum2 = computed(() => {
  console.log('computed 被调用了')
  return a.value + b.value
})
</script>

<template>
  <p>普通方法:{{ sum1() }}</p>
  <p>computed:{{ sum2 }}</p>
  <button @click="a++">a + 1</button>
  <button @click="b++">b + 1</button>
</template>

你会看到:

操作 普通方法打印几次 computed 打印几次
页面首次渲染 1 次 1 次
点击 a++ 再次打印 再次打印
点击 b++ 再次打印 再次打印
页面任意地方触发渲染(比如父组件更新) 又打印! 不打印!(直接用缓存)

这就是「缓存」带来的性能飞跃!

Vue 内部到底是怎么实现这个缓存的?(底层逻辑)

Vue 用了一个经典的「脏检查 + 依赖收集」机制(Vue3 用 Proxy 更优雅,但原理一致):

步骤 发生了什么
1. 创建 computed Vue 创建一个「计算属性对象」,里面有个 value(缓存值)和 dirty(是否脏)标志」
2. 第一次读取 computed 执行计算函数 → 同时收集所有用到的响应式数据(a、b、todos.length 等)作为依赖
3. 把依赖和这个 computed 关联起来 a.effect.deps.push(computed)
4. 依赖变化时 Vue 把这个 computed 的 dirty 标志设为 true(表示缓存失效了)
5. 下一次读取时 发现 dirty = true → 重新执行计算函数 → 更新缓存 → dirty = false
6. 之后再读取 dirty = false → 直接返回缓存值,不执行函数

图解:

首次读取 computed
     ↓
执行计算函数 → 依赖收集(记录依赖了 a 和 b)
     ↓
把结果缓存起来,dirty = false

a.value = 999(依赖变化)
     ↓
Vue 自动把所有依赖了 a 的 computed 的 dirty 设为 true

下次读取 computed
     ↓
发现 dirty = true → 重新计算 → 更新缓存 → dirty = false
哪些情况会打破缓存?(常见坑)
情况 是否重新计算 说明
依赖的 ref/reactive 变了 正常触发
依赖的普通变量(let num = 1) 不是响应式的!永远只算一次(大坑!)
依赖了 props props 也是响应式的
依赖了 store.state(Pinia/Vuex) store 是响应式的
依赖了 route.params $route 是响应式的(Vue Router 注入)
依赖了 window.innerWidth 不是响应式!要配合 watchEffectScope 手动处理
实战避雷清单
错误写法 正确写法 后果
computed(() => Date.now()) 改成普通方法或用 ref(new Date()) + watch 每一次读取都重新计算,缓存失效
computed(() => Math.random()) 同上 永远不缓存,性能灾难
computed(() => props.list.length) 完全正确 推荐写法
computed(() => JSON.parse(JSON.stringify(todos.value))) 不要这么做,深拷贝太重 浪费性能
六、一句话记住

computed 的高性能秘诀只有 8 个字:
「依赖不变,绝不重新计算」

现在你再也不用担心「用 computed 会不会影响性能」了,反而应该大胆用!
因为它比你手写任何缓存逻辑都要聪明、都要快!

六、总结:从「操作 DOM」到「操作数据」的思维跃迁

传统 JS 思维 Vue 响应式思维
先找元素 → 再改 innerHTML 只改数据 → Vue 自动更新 DOM
手动 addEventListener 用 v-model / @event 声明式绑定
手动计算未完成数量 用 computed 自动计算 + 缓存
全选要遍历 DOM 用 computed setter 一行搞定

当你真正理解了「数据驱动视图」后,你会发现:

写 Vue 代码不再是「怎么操作页面」,而是「数据怎么变化。

这才是现代前端开发的正确姿势!

👊👊👊领导让我从vue转到react,我敲泥*

领导让我从vue转到react,怎么办尼,那就先看关键hooks,useRffect的使用吧🍭🍭🍭

掘金上关于 useEffect 的文章不少,
但真正把它 讲清楚、讲透、讲到你能写出可维护代码 的,其实不多😉😉😉。

今天哥们直接用几个你能马上复制运行的 Demo,
从最基础的依赖数组、清理副作用、到自定义 Hook,
再扩展到 TanStack Query 为什么几乎取代 useEffect

一句话:
看完这篇,你对所有 “useEffect 什么时候写 / 写什么 / 不写会怎样” 都有清晰答案。


① useEffect 的本质:依赖数组才是核心

先来看最经典的例子:

useEffect(() => {
  console.log("Effect 执行,依赖 count =", count);
}, [count]);

只要你理解下面这句话,你就能掌握 useEffect:

依赖变 → 执行 effect → 执行 cleanup清理函数(如果有)

来看看 Demo:

import { useEffect, useState } from "react";

export default function DemoUseEffect() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  useEffect(() => {
    console.log("Effect 执行,依赖 count =", count);
  }, [count]);

  return (
    <div className="p-6">
      <button onClick={() => setCount(count + 1)}>count + 1</button>

      <input value={text} onChange={(e) => setText(e.target.value)} />
    </div>
  );
}

重点:

  • 点按钮 → 触发 effect
  • 输入框 → 不会触发 effect(因为 text 没写进依赖数组)

那如果依赖数组省略?useEffect(()=>{},无依赖项)

不写依赖数组,等于依赖所有 state、props → 每次渲染都会执行。

所以这是 React 社区默认“不要做”的事 ——
性能差,还容易写出无限循环。

那依赖数组写成 [] 呢?

只执行一次,之后再也不会执行。
常用于初始化逻辑。

那为什么我在控制台看到 effect 执行两次?

因为从React18+之后, React.StrictMode(开发环境)会主动触发 “mount → unmount → mount” 两次
帮你提前暴露副作用 bug。

不是你写错,是 React 故意的。

开发两次 → 线上一次

//main.tsx
 <StrictMode>
    <APP>
  </StrictMode>

② 自定义 Hook 其实就是“把 useEffect 封装起来”

你写过下面这种做“localStorage 持久化”的逻辑吗?

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState(
    () => JSON.parse(localStorage.getItem(key) || "{}") ?? initialValue
  );

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [value]);

  return [value, setValue];
}

页面使用:

const [name, setName] = useLocalStorage("demo-name", "sss");

本质是什么?
就是:

自定义 Hook = 包装 useState / useEffect,让你的页面更干净、逻辑复用。

你返回什么,外面就用什么。
完全等于“把 effect 写内部,外面只管拿结果”。

这也是为什么:
自定义 Hook 一般都带 use 开头,并且内部可能有多个 useEffect。

自定义hooks能不能不用use开头?
任何函数内部如果调用了 useState/useEffect,就必须以 use 开头,不然就是破坏 React 的 Hook 机制。其实我自己试了,不用use开头似乎也能正常运行(😕😕😕)


③ 为什么必须写 cleanup?(比如定时器)

下面这个 Demo 很多人面试必被问:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("Interval,count=", count);
  }, 1000);

  return () => {
    console.log("清理旧定时器");
    clearInterval(timer);
  };
}, [count]);

流程是这样的:

  • count 第一次是 0 → 开启一个定时器
  • 点按钮 count = 1 → 清理旧定时器 → 开一个新的
  • 再点 → 一样清理 → 重建

如果你没有写 cleanup 会怎样?

  • 每次点都会创建一个新的定时器。
  • console 会变成“机关枪模式”。
  • 性能暴涨,浏览器发热,CPU 起飞 🔥
  • React 官方把这叫做 内存泄漏(memory leak)

所以:

任何产生订阅、定时器、事件监听、外部资源的 effect,都必须写 cleanup。

这是 useEffect 的最重要规则之一。


④ 防抖 / 节流输入框:useEffect 的神级用法

防抖逻辑:

function useDebounce(value: string, delay = 800) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

使用:

const debounced = useDebounce(text, 1000);

useEffect(() => {
  if (debounced) {
    console.log("防抖触发 →", debounced);
  }
}, [debounced]);

输入快,不触发;
停止 1s,才触发。

这是 effect 的另一个核心用法:
根据业务去做一些自定义的联动工具hooks


⑤ 重点扩展:为什么 TanStack Query (tanstack.com/query/lates…) 让你越来越少写 useEffect?

React 社区现在有一句话:

“越写越多 useEffect,代码越乱。useEffect在一个页面使用太多,太多的副作用域导致代码逻辑极度混乱,数据层形成干扰”

然后大家发现了更现代的做法:
用 TanStack Query(React Query),几乎不需要手写 useEffect 来请求数据了。

传统写法:

useEffect(() => {
  axios.get("/api/user").then(res => setUser(res.data));
}, []);

有:

  • loading 状态
  • error 状态
  • 缓存策略
  • 重试逻辑
  • refetch 机制

写成地狱。

而 React Query:

const { data, isLoading, error } = useQuery({
  queryKey: ["user"],
  queryFn: fetchUser,
});

🎉 不需要 useEffect:

  • 自动请求
  • 自动缓存
  • 自动失败重试
  • 自动并发控制
  • 自动后台刷新(Stale-While-Revalidate)
  • 自动依赖感知更新

这就是为什么:

React Query 正在让 useEffect 专注于“副作用”,不再用于“业务逻辑”。

这一点对项目复杂度提升巨大。


⑥ useEffect 的黄金法则(你能把这段贴到团队规范里)

1. useEffect 只处理副作用,不要处理业务

比如请求数据 → 用 React Query
比如格式化数据 → 用 useMemo
比如事件 → 封装成自定义 Hook

2. 依赖数组永远要写全(让 ESLint 帮你)

不写 / 漏写依赖 = bug 温床。

3. 如非必要,不要写空依赖数组 []

会导致数据永不更新。

4. cleanup 是必须的(定时器、事件、订阅)

否则内存泄漏 + 性能炸裂。

5. 自定义 Hook = 复用 useEffect 的最佳方式


总结

useEffect 已经不是你的业务逻辑中心,
而是“失控副作用的收容所”。

你应该:

- 把数据请求交给 React Query (非必要)

  • 把状态交给 state 管理库(Zustand / Jotai / Redux Toolkit)
  • 只在 effect 里写副作用(定时器、事件绑定、订阅等)
  • 用自定义 Hook 封装逻辑

写干净的组件,做干净react developers

一次 npm 更新强制2FA导致的发布失败的排查:403、2FA、Recovery Code、Granular Token 的混乱体验

最近在更新发布自己的一个 npm 包(branch-commit-compare)时踩了一堆坑。问题本身不复杂,但 npm 新旧验证体系在 UI 和流程上混杂一起,导致排查成本远高于必要水平。这里记录一下整个过程,避免别人重复踩坑。


背景

我本地执行:

npm publish

结果直接报:

npm ERR! 403 Forbidden - PUT https://registry.npmjs.org/xxx
Two-factor authentication or granular access token with bypass 2fa enabled is required to publish packages.

很明确:npm 要求 2FA 或具有 bypass-2FA 权限的 token 才允许发布。问题在于 —— 我当时的 2FA 状态看起来“不完整”。


一、UI 误导:npm 只给我一个「Security Key」选项

进入 npm 账户的 Security 页面 → Enable 2FA,看到的 2FA 方式只有:

Security Key(USB/NFC、生物识别等)

正常情况下,npm 应该同时提供:

  • Authenticator App(TOTP)
  • Security key(FIDO U2F)
  • Recovery codes

但在我这里,点击 Enable 2FA 直接跳到 Security Key,没有 TOTP 选项

这会带来一个明显问题:

npm CLI 的 --otp=xxxxxx 只能输入 TOTP 码,不能输入恢复码、不能输入 security-key 相关内容。

也就是说:只启用 Security Key = 无法在 CLI 完成 publish


二、我以为自己拿到的是「秘钥」,实际上那是 Recovery Codes

e496c18fe4b39b0e2e1d2d4c7958c561fd3a2526f31ce7aea8394413b1826576
061baf4f296ea7e94c416eb9be80ecd47843ae7b957916f7d788d7310cefc053
4c7feaaca38231ca76222bab37eea9ff1dc69ccf90398900025c4dd03226002b
1886537d1acf213f1ea27329bd7ecdc6e6934397a55a1b1d5882698617f738ed
8b6abe5645ffb5e09d6b6aad2955d8ac9c64bf971751845cbf2afd453476473d

UI 只显示一串普通文本,把它描述成“安全密钥”。但实际那是 Recovery Codes(一次性备份码),不是 TOTP Secret,也不是 token。

把换行删掉、合并成一行都没用,它压根不是 TOTP。

这导致我误以为 CLI 能用它 publish → 当然失败。


三、npm profile get 暴露核心真相

执行:

npm profile get

返回:

two-factor auth │ auth-and-writes

含义:

  • 你已经启用了 2FA,但只启用了 写操作保护
  • npm 要求:发布时必须提供 TOTP
  • Recovery Code 和 Security Key 都不能替代 TOTP

结论:你的 2FA 配置不完整,CLI 肯定 403


四、可行的解决路径:创建 Granular Token 并开启 bypass 2FA

既然 npm CLI 强制需要 TOTP,而 UI 又不给你配置 TOTP,那就只能走另一条路:

1. 创建 Granular Access Token

进入 npm → 头像 → Access Tokens → Create New Token

选择:

  • 类型:Granular / Automation Token
  • 权限:选择你要 publish 的包
  • 关键:勾选 “Bypass two-factor authentication”

创建后会出现真正的一次性 token(长字符串),这才是发布用的。

2. 写入本地 ~/.npmrc

echo "//registry.npmjs.org/:_authToken=你的token" >> ~/.npmrc

3. 再次发布

npm publish

→ 一次通过。


五、根因:npm 的验证系统正在剧烈切换

npm 现在要求:

  • 经典 token 全部废弃
  • granular token 成为唯一可用的 token
  • granular token 默认需要 2FA
  • 而 2FA 又分 TOTP / security key
  • 但 UI 渐进式上线,部分用户只能看到 security key
  • CLI 又要求 TOTP

这就造成一个现象:

你启用了“半套”2FA,UI 显示正常,但 CLI 无法完成发布。


六、最终解决方案总结

  1. npm profile get 显示 auth-and-writes → CLI 必须 OTP
  2. UI 只给你 Security Key,没有 TOTP → CLI 永远无法输入正确 OTP
  3. Recovery Code 不是 OTP → 无效
  4. 唯一可行方法 → 创建带 bypass-2FA 的 granular token
  5. 写入本地 .npmrc 后发布即可

七、给后来者的建议

如果你正在启用 npm 的 2FA:

  • 一定要启用 TOTP(Authenticator App)
    而不是只启用 Security Key (但是官方只给了这种方式)
  • Granular Token 是目前唯一不会踩坑的方式(特别是 CI/CD)
  • 不要被恢复码误导,它不是 OTP
  • 25年12月9号强制必须使用2FA或者绕过2FA的token 我今天12月12太多人在操作了,官网巨卡无比

结语

npm 的验证体系正在重构,UI 不统一、流程半旧半新,是这次踩坑的根本原因。如果你遇到类似的 403 + 要求 2FA,又找不到 TOTP,多半和我一样卡在了“只有 Security Key”这个界面。

解决方法就是:绕开 2FA,使用带 bypass 权限的 granular token 发布

从零开始理解 JavaScript Promise:彻底搞懂异步编程

🎯 从零开始理解 JavaScript Promise:彻底搞懂异步编程

🔗 原文链接:Promises From The Ground Up
👨‍💻 原作者:Josh W. Comeau
📅 发布时间:2024年6月3日
🕐 最后更新:2025年3月18日

⚠️ 关于本译文

本文基于 Josh W. Comeau 的原文进行忠实翻译,力求准确传达原作者的技术观点和逻辑结构。

🎨 特色亮点:

  • 保持原文的完整性和技术准确性
  • 采用自然流畅的中文表达,避免翻译腔
  • 添加画外音板块,提供译者的补充解读和实践心得
  • 使用生动比喻帮助理解复杂概念

💡 画外音说明: 文中标注为画外音的部分是译者基于实际开发经验添加的拓展解释,旨在帮助读者更好地理解和应用这些概念,不代表原作者观点。


在学习 JavaScript 的路上,有很多坎儿要过。其中最大、最让人头疼的,就是 Promise(承诺)

要想真正理解 Promise,咱们得对 JavaScript 的工作原理和它的局限性有相当深入的了解。没有这些背景知识,Promise 就像天书一样难懂。

这事儿确实挺让人抓狂的,因为 Promise API 在现代 JavaScript 开发中实在太重要了。它已经成为处理异步代码的标准方式。现代的 Web API 都是建立在 Promise 之上的。没办法绕过去:如果想用 JavaScript 高效工作,真的很有必要搞懂 Promise。

所以在这篇教程里,咱们要学习 Promise,但会从最基础的地方开始。我会分享那些花了我好几年才搞明白的关键知识点。希望读到最后,你能对 Promise 是什么、怎么有效使用它们有更深的理解。✨

💡 画外音:我刚开始学 Promise 的时候,总是记不住 .then().catch() 到底该怎么用,感觉像是硬记 API。后来才明白,一旦理解了 JavaScript 单线程的本质和异步编程的必要性,这些 API 设计就显得非常自然了。所以这篇文章真的是从根源讲起,强烈推荐耐心读完。

适合谁看

这篇文章适合初级到中级的 JavaScript 开发者。你需要懂一些基本的 JavaScript 语法。


🤔 为啥要这么设计?

假设咱们想做一个"新年倒计时",像这样的效果:

钉钉录屏_2025-12-11 195030.gif 如果 JavaScript 和大多数其他编程语言一样,咱们可以这样解决问题:

function newYearsCountdown() {
  print("3");
  sleep(1000);

  print("2");
  sleep(1000);

  print("1");
  sleep(1000);

  print("Happy New Year! 🎉");
}

在这段假想的代码里,程序会在遇到 sleep() 调用时暂停,等指定的时间过去后再继续执行。

可惜的是,JavaScript 里没有 sleep 函数,因为它是一门单线程语言。*

💡 画外音:这里的"线程"(thread)指的是执行代码的长时间运行进程。JavaScript 只有一个线程,所以它一次只能做一件事,不能同时处理多个任务。这是个问题,因为如果咱们唯一的 JavaScript 线程忙着管理倒计时,它就干不了别的事儿了。

*技术上讲,现代 JavaScript 可以通过 Web Workers 访问多线程,但这些额外的线程无法访问 DOM,所以在大多数场景下用不上。

当我刚学这些东西的时候,不太明白为什么这是个问题。如果倒计时是现在唯一发生的事情,那 JS 线程在这段时间被完全占用不是挺正常的吗?

嗯,虽然 JavaScript 没有 sleep 函数,但它确实有一些其他函数会长时间占用主线程。咱们可以用这些方法来体验一下,如果 JavaScript 真有 sleep 函数会是什么样子。

比如说,window.prompt()。这个函数用来从用户那里收集信息,它会暂停代码执行,就像咱们假想的 sleep() 函数一样。

点击下面这个示例中的按钮,然后在提示框打开时试着和页面交互

image.png

💡 提示:这里只放了截图。如果想亲自体验这个效果(强烈推荐!),可以去原文页面试试,点击按钮后你会发现整个页面真的卡住了,完全动不了。

注意到了吗?当提示框打开的时候,整个页面完全没反应!你没法滚动、点击任何链接,也没法选择任何文本!JavaScript 线程正忙着等咱们输入值,好让它能继续运行代码。在等待的过程中,它干不了别的任何事,所以浏览器就把整个 UI 都锁住了。

其他语言有多个线程,所以其中一个被占用一会儿也没啥大不了的。但在 JavaScript 里,咱们就这一个线程,而且它要用来干所有事情:处理事件、管理网络请求、更新 UI 等等。

如果想做一个倒计时,咱们得找个不阻塞线程的方法。

💡 画外音:这就是为什么你有时会看到有人说"不要在主线程做耗时操作"。比如复杂的计算、大数据处理,如果放在主线程,用户就会感觉页面卡死了。这也是为什么后来出现了 Web Workers,专门用来处理这类重活儿。

为什么整个 UI 都冻结了?

在上面 window.prompt() 的例子中,浏览器等待咱们输入值的时候,整个 UI 都变得没反应了。

这有点奇怪……浏览器滚动页面或选择文本又不依赖 JavaScript。那为什么这些操作也做不了呢?

我觉得浏览器这么做是为了防止 bug。比如滚动页面会触发 "scroll" 事件,这些事件可以被 JavaScript 捕获和处理。如果 JS 线程忙着的时候滚动事件发生了,那段代码就永远不会运行,如果开发者假设滚动事件总是会被处理,就可能导致 bug。

这也可能是出于用户体验的考虑;也许浏览器禁用 UI 是为了让用户不能忽略提示框。不管怎样,我估计原生的 sleep 函数也得这么工作才能防止 bug。


📞 回调函数(Callbacks)

咱们工具箱里解决这类问题的主要工具是 setTimeoutsetTimeout 是一个接受两个参数的函数:

  1. 未来某个时刻要做的一块工作
  2. 要等待的时间

来看个例子:

console.log('Start');

setTimeout(
  () => {
    console.log('After one second');
  },
  1000
);

这块工作通过一个函数传进去。这种模式叫做回调(callback)

前面假想的 sleep() 函数就像给公司打电话,然后一直等着接通下一个客服。而 setTimeout() 就像按 1 让他们在客服有空的时候给你回电。你可以挂掉电话,该干嘛干嘛。

setTimeout() 被称为异步函数。这意味着它不会阻塞线程。相比之下,window.prompt()同步的,因为 JavaScript 线程在等待的时候干不了别的。

异步代码的一个大坑是,它意味着咱们的代码不会总是按线性顺序运行。看看下面这个例子:

console.log('1. Before setTimeout');

setTimeout(() => {
  console.log('2. Inside setTimeout');
}, 500);

console.log('3. After setTimeout');

你可能期望这些日志按从上到下的顺序触发:1 > 2 > 3但记住,回调的核心思想就是"留个号,一会儿回你。 JavaScript 线程不会干坐着等,它会继续运行。

想象一下,如果咱们给 JavaScript 线程一本日记,让它记录运行这段代码时做的所有事情。运行完之后,日记会是这样:

  • 00:000:打印 "1. Before setTimeout"
  • 00:001:注册一个定时器
  • 00:002:打印 "3. After setTimeout"
  • 00:501:打印 "2. Inside setTimeout"

setTimeout() 注册了回调,就像在日历上安排一个会议。注册回调只需要极短的时间,一旦完成,它就继续往下走,执行程序的其余部分。

💡 画外音:这个"日记"的比喻特别好,帮我彻底理解了事件循环。很多新手(包括当年的我)觉得 setTimeout(fn, 0) 很神奇——明明延迟是 0,为什么还是异步的?就是因为它会被"注册"到日历上,即使时间到了,也得等当前同步代码都跑完才轮到它。

回调在 JavaScript 里到处都是,不只是用于定时器。比如,咱们这样监听指针事件(pointer events):

钉钉录屏_2025-12-11 201119.gif

💡 画外音:"pointer"(指针)是个统称,涵盖了所有涉及"指向"的 UI 输入方式,包括鼠标、手指在触摸屏上的点击、触控笔等。所以 pointer events 比 mouse events 的概念更广。

window.addEventListener() 注册了一个回调,每当检测到特定事件时就会被调用。在这个例子中,咱们监听鼠标移动。每当用户移动鼠标或在触摸屏上拖动手指,咱们就会运行一块代码作为响应。

就像 setTimeout 一样,JavaScript 线程不会专注于监视和等待这些事件。它告诉浏览器"嘿,用户移动指针的时候告诉我一声"。当事件触发时,JS 线程会回过头来运行咱们的回调。

好吧,咱们已经跑得有点远了。回到最初的问题:如果想做一个 3 秒倒计时,该怎么做?

在过去,最常见的解决方案是设置嵌套的回调,像这样:

console.log("3…");

setTimeout(() => {
  console.log("2…");

  setTimeout(() => {
    console.log("1…");

    setTimeout(() => {
      console.log("Happy New Year!!");
    }, 1000);
  }, 1000);
}, 1000);

这太疯狂了,对吧?咱们的 setTimeout 回调里又创建了新的 setTimeout 回调!

当我在 2000 年代早期开始折腾 JavaScript 的时候,这种模式挺常见的,虽然大家都觉得不太理想。咱们把这种模式叫做回调地狱(Callback Hell)

Promise 就是为了解决回调地狱的一些问题而开发的。

💡 画外音:回调地狱不仅仅是代码难看的问题。真正的痛点是:错误处理变得超级复杂,每层嵌套都要处理错误;代码的可读性和维护性极差,嵌套超过 3 层基本就看不懂了。我曾经维护过一个 7 层嵌套的回调,那酸爽,现在想起来还头疼。

等等,定时器怎么知道什么时候触发?

setTimeout API 接收一个回调函数和一个持续时间。过了指定时间后,回调函数就会被调用。

但怎么做到的?如果 JavaScript 线程没有看着定时器,像老鹰盯小鸡一样盯着它,它怎么知道该调用回调了?

这超出了本教程的范围,但 JavaScript 有个东西叫做事件循环(event loop)。当咱们调用 setTimeout 时,一条小消息会被添加到队列里。每当 JS 线程不在执行代码时,它就在监视事件循环,检查消息。

定时器到期时,事件循环里就会亮起一个提示灯,就像有新留言的答录机。如果 JS 线程当时没在忙,它会立刻跳过去执行传给 setTimeout() 的回调。

这确实意味着定时器不是 100% 精确的。JavaScript 只有一个线程,它可能正忙着干别的事儿,比如处理滚动事件或等待 window.prompt()。如果咱们指定了 1000ms 的定时器,可以确信至少过了 1000 毫秒,但可能会稍微长一点。

你可以在 MDN 上了解更多关于事件循环的内容。


🎁 Promise 登场

前面说过,咱们不能让 JavaScript 傻等着再执行下一行代码,因为那会把线程堵死。得想办法把工作拆成一块块异步执行。

不过嵌套太难看了,能不能换个思路?要是能把这些操作像串珠子一样连起来就好了——先做这个,做完了做那个,再做下一个。

就当好玩儿,咱们假设有根魔法棒,可以随意改变 setTimeout 函数的工作方式。如果咱们这样做会怎样:

console.log('3');

setTimeout(1000)
  .then(() => {
    console.log('2');

    return setTimeout(1000);
  })
  .then(() => {
    console.log('1');

    return setTimeout(1000);
  })
  .then(() => {
    console.log('Happy New Year!!');
  });

不直接把回调传给 setTimeout(那会导致嵌套和回调地狱),而是用一个特殊的 .then() 方法把它们串起来,是不是好多了?

这就是 Promise 的核心思想。Promise 是 JavaScript 在 2015 年一次大更新中加入的特殊结构。

可惜 setTimeout 还是老样子,用的是回调风格。因为 setTimeout 在 Promise 出现之前就已经存在很久了,要是改了它的工作方式,会导致很多老网站挂掉。向后兼容是好事,但也意味着有些东西没法那么优雅。

不过现代的 Web API 都是基于 Promise 构建的。咱们来看个例子。


🔧 使用 Promise

fetch() 函数允许咱们发起网络请求,通常是从服务器获取一些数据。

看看这段代码:

const fetchValue = fetch('/api/get-data');

console.log(fetchValue);
// -> Promise {<pending>}

当咱们调用 fetch() 时,它启动网络请求。这是一个异步操作,所以 JavaScript 线程不会停下来等待。代码继续运行。

fetch() 函数到底返回了啥?肯定不是服务器返回的真实数据,因为咱们才刚发起请求,数据还在路上呢。它返回的其实是一张"欠条"(IOU),就像浏览器给你打的一张白条,上面写着:"嘿,数据我还没拿到,但我保证马上就给你!"

💡 画外音:IOU 是 "I Owe You"(我欠你)的缩写,读音就像说"I Owe You"。它是一种表示欠债的凭据。用这个比喻特别贴切——Promise 就像浏览器给你打的一张欠条:"数据我现在还没拿到,但我欠你的,到时候一定给你"。

具体来说,Promise 就是个 JavaScript 对象。它内部永远只会处于三种状态之一:

  • pending(待定) — 工作正在进行中,还没完成
  • fulfilled(已完成) — 工作已成功完成
  • rejected(已拒绝) — 出了点问题,Promise 无法完成

只要 Promise 还在 pending 状态,就说它是未解决的(unresolved)。一旦工作完成了,它就变成已解决(resolved)。这里要注意:不管最后是成功(fulfilled)还是失败(rejected),都算是"解决了"。

💡 画外音:Promise 的这三种状态一开始可能有点绕。我喜欢这样理解:pending 就像快递在路上,fulfilled 就像快递送到了,rejected 就像快递丢了或地址错了。一旦快递状态确定(送到或丢失),就不会再变了。

一般来说,咱们会希望在 Promise 完成后做点什么。这时候就用 .then() 方法:

fetch('/api/get-data')
  .then((response) => {
    console.log(response);
    // Response { type: 'basic', status: 200, ...}
  });

fetch() 返回一个 Promise,咱们用 .then() 挂上一个回调函数。等浏览器收到响应了,这个回调就会被执行,响应对象也会作为参数传进来。

等待 JSON?

如果你用过 Fetch API,可能注意到需要第二步才能真正拿到咱们需要的 JSON 数据:

fetch('/api/get-data')
  .then((response) => {
    return response.json();
})
 .then((json) => {
   console.log(json);
   // { data: { ... } }
 });

response.json() 会返回一个全新的 Promise,等响应数据完全转成 JSON 格式后,这个 Promise 才算完成。

但等等,为啥 response.json() 还是异步的?咱们不是已经拿到响应了吗,数据不应该早就是 JSON 了吗?

还真不一定。Web 的一个核心特性是,服务器可以流式传输数据,一点点分批发送。这在传视频(比如 YouTube)的时候很常见,对于大一点的 JSON 数据也可以这么干。

fetch() 返回的 Promise,在浏览器收到第一个字节数据时就算完成了。而 response.json() 的 Promise,要等到收到最后一个字节才算完成。

实际上,JSON 数据很少分批发送,所以这两个 Promise 大多数时候会同时完成。但 Fetch API 在设计时就考虑到了流式响应的场景,所以才需要这么绕一下。

💡 画外音:新手常犯的一个错误是:拿到 response 后直接用,忘了调用 .json()。记住,fetch() 返回的第一个 Promise 只是给你一个"响应对象",里面的数据还是原始格式,需要再调用 .json() 才能解析成 JavaScript 对象。这也是为什么你经常看到两个 .then() 的原因。


🛠️ 创建自己的 Promise

用 Fetch API 的时候,Promise 是 fetch() 函数在背后帮咱们创建的。但要是咱们用的 API 不支持 Promise 呢?

比如 setTimeout,它是在 Promise 出现之前就有了。要想用定时器又不掉进回调地狱,就得自己动手包装一个 Promise。

语法是这样的:

const demoPromise = new Promise((resolve) => {
  // 做一些异步工作,然后
  // 调用 `resolve()` 来完成 Promise
});

demoPromise.then(() => {
  // 当 Promise 完成时,
  // 这个回调会被调用!
})

Promise 其实是个通用容器,它本身不干活儿。当咱们用 new Promise() 创建 Promise 时,得同时告诉它"你要干啥活儿"——通过传入一个函数来指定具体的异步任务。这个任务可以是任何东西:发网络请求、等个定时器、读个文件,啥都行。

等这个活儿干完了,咱们就调用 resolve(),告诉 Promise:"搞定了,一切顺利!"这样 Promise 就变成已解决状态了。

回到咱们一开始的问题——做个倒计时。在这个场景里,异步任务就是"等 setTimeout 跑完"。

那咱们可以自己动手,写一个基于 Promise 的小工具函数,把 setTimeout 包装一下:

function wait(duration) {
  return new Promise((resolve) => {
    setTimeout(resolve, duration);
  });
}

const timeoutPromise = wait(1000);

timeoutPromise.then(() => {
  console.log('1 second later!')
});

这段代码看起来超级吓人。咱们试着分解一下:

  • 咱们写了个新的工具函数 wait,它接收一个参数 duration(持续时间)。目标是把它当成 sleep 函数用,但是异步的、不阻塞线程的那种。
  • wait 函数里创建并返回了一个新的 Promise。Promise 自己啥也不干,得靠咱们在异步工作完成时调用 resolve
  • Promise 内部,咱们用 setTimeout 启动了一个定时器。把 Promise 给的 resolve 函数和用户传进来的 duration 都给它。
  • 定时器时间到了,就会执行回调。这就形成了连锁反应:setTimeout 执行了 resolveresolve 告诉 Promise "搞定了",然后 .then() 里的回调也跟着被触发。

这段代码要是还让你头疼,别担心😅。这里确实揉了好多高级概念在一起!能理解大概思路就行,细节慢慢消化。

有个点可能会帮你理清楚:上面代码里,咱们把 resolve 函数直接扔给了 setTimeout。其实也可以这样写,创建一个箭头函数来调用 resolve

function wait(duration) {
  return new Promise((resolve) => {
    setTimeout(
      () => resolve(),
      duration
    );
  });
}

JavaScript 里函数是"一等公民",意思是函数可以像字符串、数字那样随便传来传去。这特性挺厉害,但新手可能需要点时间才能习惯。上面这种写法不那么直接,但效果完全一样,哪种看着舒服就用哪种!

💡 画外音:这个 wait 函数是我在实际项目中常用的一个工具。很多人会把它加到工具函数库里。甚至有些库(比如 p-timeout)专门提供这类 Promise 工具。学会包装旧的回调式 API 成 Promise,这个技能超级有用,因为还有很多老代码和库用的是回调。


⛓️ 链式调用 Promise

关于 Promise,有一点很重要要理解:它们只能被解决一次。一旦 Promise 被完成或拒绝,它就永远保持那个状态了。

这意味着 Promise 并不真正适合某些场景。比如事件监听器:

window.addEventListener('mousemove', (event) => {
  console.log(event.clientX);
})

这个回调会在用户每次移动鼠标时触发,可能成百上千次。Promise 干不了这活儿。

那咱们的倒计时怎么办?虽然不能重复用同一个 wait Promise,但可以把多个 Promise 串成一条链:

wait(1000)
  .then(() => {
    console.log('2');
    return wait(1000);
  })
  .then(() => {
    console.log('1');
    return wait(1000);
  })
  .then(() => {
    console.log('Happy New Year!!');
  });

第一个 Promise 完成了,.then() 回调就被执行。这个回调又创建并返回一个新的 Promise,就这样一个接一个地串下去。

💡 画外音:Promise 链是个很强大的模式。关键点在于每个 .then() 都会返回一个新的 Promise,这样就能一直链下去。不过要注意:如果忘记 return,链就断了,后面的 .then() 不会等前面的异步操作完成。这是新手常犯的错误,我也踩过好几次坑。


📦 传递数据

前面的例子里,咱们调用 resolve 时都没传参数,只是用它来标记"活儿干完了"。但有时候,咱们还得把结果数据传出来!

来看个例子,假设有个用回调的数据库库:

function getUser(userId) {
  return new Promise((resolve) => {
    // 在这个例子中,异步工作是
    // 根据 ID 查找用户
    db.get({ id: userId }, (user) => {
      // 现在咱们有了完整的 user 对象,
      // 可以在这里传进去...
      resolve(user);
    });
  });
}

getUser('abc123').then((user) => {
  // ...然后在这里取出来!
  console.log(user);
  // { name: 'Josh', ... }
})

传给 resolve 的参数,会原封不动地传到 .then() 的回调函数里。这样就能把异步操作的结果一路传出去了。


❌ 被拒绝的 Promise

可惜,JavaScript 的世界里,Promise 不是总能兑现。有时候也会黄了。

比如用 Fetch API 发网络请求,不一定能成功啊!可能网络不稳定,也可能服务器挂了。这些情况下,Promise 就会被拒绝(rejected),而不是正常完成。

咱们可以用 .catch() 方法来处理:

fetch('/api/get-data')
  .then((response) => {
    // ...
  })
  .catch((error) => {
    console.error(error);
  });

Promise 成功了,就走 .then() 这条路。失败了,就走 .catch()。可以理解为两条岔路,看 Promise 最后是啥状态。

💡 画外音:错误处理是 Promise 相比回调的一大优势。在回调地狱里,每层嵌套都要单独处理错误。但用 Promise,你可以在链的末尾加一个 .catch(),它能捕获整个链中任何地方的错误。这大大简化了错误处理逻辑。

Fetch 的坑

假设服务器返回了个错误,比如 404 Not Found 或者 500 Internal Server Error。这应该会触发 Promise 被拒绝,对不对?

意外的是,并不会!这种情况下,Promise 还是会正常完成,只不过 Response 对象里会带着错误信息:

Response {
  ok: false,
  status: 404,
  statusText: 'Not Found',
}

这看着有点奇怪,但仔细想想也说得通:咱们的 Promise 确实完成了,也从服务器拿到响应了!虽然不是咱们想要的那种响应,但确实有响应。

至少按"许三个愿望的精灵"的逻辑,这没毛病。

自己写 Promise 的时候,可以用第二个参数 reject 来标记拒绝:

new Promise((resolve, reject) => {
  someAsynchronousWork((result, error) => {
    if (error) {
      reject(error);
      return;
    }

    resolve(result);
  });
});

Promise 里面要是出了问题,就调用 reject() 来标记失败。传给 reject() 的参数(通常是个错误对象)会被传到 .catch() 回调里。

令人困惑的名字

前面说过,Promise 有三种状态:pending(进行中)、fulfilled(成功)和 rejected(失败)。那为啥参数不叫 "fulfill" 和 "reject",而是叫 "resolve" 和 "reject" 呢?

原因是这样的:resolve() 大多数情况下确实会让 Promise 变成 fulfilled 状态。但有个特殊情况——如果你在 resolve() 里传入的不是普通值,而是另一个 Promise,事情就不一样了。

举个例子:

const promise1 = new Promise((resolve) => {
  const promise2 = fetch('/api/data');
  resolve(promise2); // 传入了另一个 Promise!
});

这时候,promise1 会"挂靠"到 promise2 上,等 promise2 的结果。虽然 promise1 技术上还在 pending 状态,但它已经算是 "resolved"(已交接)了——因为它已经把自己的命运交给 promise2 了,JavaScript 线程也已经去忙 promise2 的事儿了。

所以 "resolved" 不等于 "fulfilled",它更像是"已经有着落了"(不管最后成功还是失败)。

这个细节我也是发完博文后读者告诉我才知道的(感谢大家!)。老实说,99% 的开发者都不会碰到这种情况,不用纠结。如果你真的想深入研究,可以看这个文档:States and Fates

💡 画外音:说实话,这个"resolved vs fulfilled"的区别在日常开发中真的不太需要纠结,记住 resolve() 表示成功、reject() 表示失败就够了。不过如果你在面试或者读规范文档的时候碰到,至少知道是咋回事。


🎭 Async / Await

现代 JavaScript 最牛的一点就是 async / await 语法。用了这个语法,咱们终于能写出接近理想状态的倒计时代码了:

async function countdown() {
  console.log("5…");
  await wait(1000);

  console.log("4…");
  await wait(1000);

  console.log("3…");
  await wait(1000);

  console.log("2…");
  await wait(1000);

  console.log("1…");
  await wait(1000);

  console.log("Happy New Year!");
}

等等,这不是不可能吗! 函数执行到一半不能暂停啊,那会把线程堵死的!

其实这个新语法底层还是 Promise。咱们来扒开看看它是怎么运作的:

async function addNums(a, b) {
  return a + b;
}

const result = addNums(1, 1);

console.log(result);
// -> Promise {<fulfilled>: 2}

本以为返回值应该是数字 2,结果却是个 Promise,里面包着数字 2。只要给函数加上 async 关键字,它就一定会返回 Promise,哪怕函数里压根没干异步的活儿。

上面的代码其实是这样的语法糖:

function addNums(a, b) {
  return new Promise((resolve) => {
    resolve(a + b);
  });
}

同样的,await 关键字也是 .then() 回调的语法糖:

// 这段代码...
async function pingEndpoint(endpoint) {
  const response = await fetch(endpoint);
  return response.status;
}

// ...等价于这个:
function pingEndpoint(endpoint) {
  return fetch(endpoint)
    .then((response) => {
      return response.status;
    });
}

Promise 给 JavaScript 打好了底层基础,让咱们能写出看着像同步、实际是异步的代码。

这设计,真的绝了。

💡 画外音async/await 是我最喜欢的 JavaScript 特性之一。它让异步代码读起来就像同步代码一样自然。不过有个常见误区:很多人以为 async/await 是一种新的异步机制,其实它只是 Promise 的语法糖。理解这一点很重要,因为有时候你还是需要直接用 Promise(比如 Promise.all() 并发请求)。另外,别忘了用 try/catch 包裹 await,不然错误可能会悄悄溜走!


🚀 更多内容即将推出!

过去几年,我全职都在做教育内容,制作和分享像这篇博文这样的资源。我已经做了 CSS 课程和 React 课程。

学生们问得最多的就是:"能不能做个原生 JavaScript 的课程?"这事儿我一直在想。接下来几个月,应该会发更多关于原生 JavaScript 的文章。

想在我发布新内容时第一时间知道的话,最好是订阅我的邮件列表。有新博文或者课程更新,我都会发邮件通知你。❤️


📝 译者总结

💡 核心要点回顾

概念 关键理解
单线程本质 JavaScript 只有一个线程,不能像其他语言那样"停下来等"
回调地狱 嵌套回调难以维护,错误处理复杂,这是 Promise 要解决的核心问题
Promise 状态 pending(进行中)→ fulfilled(成功)或 rejected(失败)
链式调用 .then() 返回新 Promise,可以一直链下去,避免嵌套
async/await Promise 的语法糖,让异步代码看起来像同步,但本质还是 Promise

🎯 实用建议

  1. 包装旧 API:很多老 API 还在用回调,学会用 Promise 包装它们(像文中的 wait 函数)
  2. 错误处理:养成在 Promise 链末尾加 .catch() 的习惯,或者用 try/catch 包裹 await
  3. 别忘了 return:Promise 链中如果需要传递数据或继续链式调用,一定要 return
  4. 并发请求:需要同时发起多个请求时,用 Promise.all() 而不是多个 await
  5. Fetch 陷阱:记住 HTTP 错误状态码(404、500等)不会触发 .catch(),要检查 response.ok

Electron学习

Electron学习

一. Electron安装

自己从零开始,方便理解Electron的内部机制

// 项目初始化
npm init -y
//  下载Electron  不用 Electron Forge
npm  install  --save-dev  electron@latest

二. 创建一个窗口

添加 index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>hello world</h1>
</body>
</html>
添加 main.js
// 导入 Electron 的核心模块
// app: 控制应用程序的生命周期事件
// BrowserWindow: 创建和控制浏览器窗口
const { app, BrowserWindow } = require('electron')
// 当 Electron 完成初始化并准备创建浏览器窗口时触发此事件
app.on('ready', () => {
     // 创建一个新的浏览器窗口实例
  const mainWindow = new BrowserWindow({
    width: 800, // 设置窗口宽度为 800 像素
    height: 600, // 设置窗口高度为 600 像素
    // 配置网页功能选项
    webPreferences: {
      nodeIntegration: true  // 允许在渲染进程中使用 Node.js 功能
    }
  })
 // 加载应用程序的主页面
  // 这里加载当前目录下的 index.html 文件
  mainWindow.loadFile('index.html')
})
在package.json文件下scripts 添加运行命令

{
  "name": "lectron",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "dependencies": {
    "electron": "^39.2.6"
  },
  "devDependencies": {},
  "scripts": {
    "start": "electron ."
  },
 
  "keywords": [],
  "author": "",
  "license": "ISC"
}

注意: package.json文件下 需要修改为 "main": "main.js", 要不然会报错。

在这里插入图片描述

运行命令

npm run  start

在这里插入图片描述

三. 打开调试者工具

mainWindow.webContents.openDevTools() // 打开开发者工具

const { app, BrowserWindow } = require('electron')
// 当 Electron 完成初始化并准备创建浏览器窗口时触发此事件
app.on('ready', () => {
  const mainWindow = new BrowserWindow({
    width: 800, // 设置窗口宽度为 800 像素
    height: 600, // 设置窗口高度为 600 像素
    // 配置网页功能选项
    webPreferences: {
      nodeIntegration: true  // 允许在渲染进程中使用 Node.js 功能
    }
  })

  mainWindow.webContents.openDevTools() // 打开开发者工具
  // 这里加载当前目录下的 index.html 文件
  mainWindow.loadFile('index.html')
})

四.热更新mian.js文件

因为每次修改mian.js文件 。需要关闭程序,重新运行命令。mian修改过的内容才生效。所以我们需要热更。不需要关闭程序,重新运行命令

nodemon文件监视工具,检测文件变化并自动重启进程

安装 nodemon

npm install  nodemon --save-dev
// --watch main.js
监视 main.js文件的变化(Electron 主进程入口文件)
// --exec "electron ."
检测到变化时执行的命令:  electron .启动当前目录的 Electron 应用
"scripts": {
    "start": "electron .",
    "start:wacth": "nodemon --watch main.js --exec \"electron .\""
  },

五. Electron进程和线程

主进程Main Process

在 Electron应用都有一个单一的主进程,作为应用程序入口点。主进程在Node.js 运行。它具备所以Node.jsAPI的能力。

  • 窗口管理
  • 应用程序生命周期
  • 原生API
渲染进程Renderer Process

每个Electron应用会为每个打开的 BrowserWindow 生成一个单独的渲染进程。

渲染器无权直接访问require或其他Node.js API

进程间通信(IPC)

IPC通道名称

  • ipcMain
  • ipcRenderer

预加载脚本

为了将electron的不同类型的进程接在一起,我们需要使用被称为预加载preload的特殊脚本。

1.单向通信 - 从渲染器进程到主进程

使用ipcRenderer.send API发送消息,然后使用ipcMain.onAPI接收。

实现通过输入框修改应用标题

在这里插入图片描述

一. 添加输入框

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>hello world</h1>
    Title: <input id="title" />
    <button id="btn" type="button">Set</button>

    <h1 id="info"></h1>
  </body>
  <script src="./renderer.js"></script>
</html>

二. 添加renderer.js 编写 点击按钮事件和渲染Node版本号逻辑

// 获取页面上 ID 为 'info' 的元素
const info = document.getElementById('info')
console.log(window.versions,'window.versions')
// 设置该元素的内容为版本信息
info.innerHTML = `Chrome (v${window.versions.chrome}), Node.js (v${window.versions.node})`
// 获取页面上 ID 为 'btn' 的按钮元素
const btn = document.getElementById('btn')
// 获取页面上 ID 为 'title' 的输入框元素
const titleInput = document.getElementById('title')
// 给按钮添加点击事件监听器
btn.addEventListener('click', () => {
     // 从输入框中获取用户输入的值
    const title = titleInput.value
     // 调用预加载脚本暴露的 window.electron.setTitle 方法
    // 该方法会通过 IPC 通知主进程设置窗口标题
    window.electron.setTitle(title)
    
   
  })

三. 预加载脚本(preload.js)

它使用contextBridge来安全地暴露一些Node.js和Electron的功能给渲染进程(即网页)。

代码中使用了三个exposeInMainWorld调用,分别暴露了'versions'、'electron'和'require'三个全局变量。

// contextBridge: 安全地向渲染进程暴露 API 的桥梁
// ipcRenderer: 渲染进程与主进程通信的模块
const { contextBridge, ipcRenderer } = require("electron");
/**
 * 通过 contextBridge 向渲染进程暴露 Node.js 和 Chromium 版本信息
 * 安全地将只读数据暴露给 window.versions 对象
 */
contextBridge.exposeInMainWorld('versions', {
    node: process.versions.node, // Node.js 运行时版本号
    chrome: process.versions.chrome // Chromium 引擎版本号
  })
/**
 * 向渲染进程暴露自定义的 electron API
 * 创建一个安全的 window.electron 对象,包含 setTitle 方法
 * @param {string} title - 要设置的窗口标题
 */
contextBridge.exposeInMainWorld("electron", {
  setTitle: (title) => ipcRenderer.send("set-title", title),
});
/**
 * 危险操作!暴露 Node.js 的 require 函数到渲染进程
 * ⚠️ 严重安全警告:这会导致 XSS 漏洞和系统级攻击风险
 * 绝对不要在真实项目中这样做!
 */
contextBridge.exposeInMainWorld("require", require);

四.修改main.js

preload: path.join(__dirname, 'preload.js') // 预加载脚本路径 ipcMain.on('set-title',handlsSetTitle) // 注册 IPC 监听器:监听渲染进程发送的 'set-title' 事件 handlsSetTitle 修改应用标题

const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')

const createWindow = () => {
     // 创建一个新的浏览器窗口实例
   const mainWindow = new BrowserWindow({
    width: 800, // 设置窗口宽度为 800 像素
    height: 700, // 设置窗口高度为 600 像素
    // 配置网页功能选项
    webPreferences: {
      nodeIntegration: true , // 允许在渲染进程中使用 Node.js 功能
      preload:path.join(__dirname, 'preload.js') // 预加载脚本
    }
  })
  mainWindow.webContents.openDevTools() // 打开开发者工具
  //  加载应用程序的主页面  这里加载当前目录下的 index.html 文件
  mainWindow.loadFile('index.html')
}
/**
 * 处理设置窗口标题的 IPC 消息
 * @param {Electron.IpcMainEvent} event - IPC 事件对象
 * @param {string} title - 要设置的新窗口标题
 */

const handlsSetTitle = (event, title) => {
    console.log('title', event, title)
     // 获取发送消息的 WebContents 对象
    const webContents = event.sender
     // 通过 WebContents 找到对应的 BrowserWindow 实例
    const win = BrowserWindow.fromWebContents(webContents)
    // 设置窗口标题
    win.setTitle(title)
    
}

// 当 Electron 完成初始化并准备创建浏览器窗口时触发此事件
app.on('ready', () => {
    // 注册 IPC 监听器:监听渲染进程发送的 'set-title' 事件
    ipcMain.on('set-title',handlsSetTitle)
    // 创建主窗口
    createWindow()
})
2.双向通信 - 从渲染器进程到主进程,再从主进程到渲染器进程
  • 使用ipcRender.invoke 进行发送
  • 使用ipcMain.handle 来进行响应
  • 它的第二个函数被用一个回调。然后返回值将作为Promise返回到最初的invoker调用
实现本地保存输入框内容,并且页面显示文件大小

在这里插入图片描述

一. 添加输入框

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>hello world</h1>
    <!-- 标题输入框 -->
    Title: <input id="title" />
    <button id="btn" type="button">Set</button>
    <h1 id="info"></h1>
    <!-- 文本输入 -->
    Content: <input id="content"/>
    <button id="btn2" type="button">Write</button>
    <h1 id="counter"></h1>
  </body>
  <script src="./renderer.js"></script>
</html>

二.在preload.js,添加点击事件

const btn2 = document.getElementById('btn2')
const contentInput = document.getElementById('content')
const counter = document.getElementById('counter')
btn2.addEventListener('click', async () => {
    const content = contentInput.value
    const len = await window.electron.writeFile(content)
    console.log(len)
    counter.innerHTML = `文件大小: ${len}`
    const c = await fs.promises.readFile('test.txt', { encoding: 'utf-8' })
    counter.innerHTML += `文件内容: ${c}`
  })

三.在预设脚本preload.js ,使用ipcRender.invoke 进行发送

contextBridge.exposeInMainWorld("electron", {
  setTitle: (title) => ipcRenderer.send("set-title", title),
  // 调用主进程的 writeFile 方法,并将内容作为参数传递
  writeFile: (content) => ipcRenderer.invoke('write-file', content), 
});

四.修改mian.js

pcMain.handle('write-file', handleWriteFile) // 注册 IPC 处理程序:处理渲染进程发送的 'write-file' 请求

handleWriteFile //

// 导入 Electron 的核心模块
// app: 控制应用程序的生命周期事件
// BrowserWindow: 创建和控制浏览器窗口
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const createWindow = () => {
     // 创建一个新的浏览器窗口实例
   const mainWindow = new BrowserWindow({
    width: 800, // 设置窗口宽度为 800 像素
    height: 700, // 设置窗口高度为 600 像素
    // 配置网页功能选项
    webPreferences: {
      nodeIntegration: true , // 允许在渲染进程中使用 Node.js 功能
      preload:path.join(__dirname, 'preload.js') // 预加载脚本
    }
  })
  mainWindow.webContents.openDevTools() // 打开开发者工具
  //  加载应用程序的主页面  这里加载当前目录下的 index.html 文件
  mainWindow.loadFile('index.html')
}
/**
 * 处理设置窗口标题的 IPC 消息
 * @param {Electron.IpcMainEvent} event - IPC 事件对象
 * @param {string} title - 要设置的新窗口标题
 */

const handlsSetTitle = (event, title) => {
    console.log('title', event, title)
     // 获取发送消息的 WebContents 对象
    const webContents = event.sender
     // 通过 WebContents 找到对应的 BrowserWindow 实例
    const win = BrowserWindow.fromWebContents(webContents)
    // 设置窗口标题
    win.setTitle(title)
    
}

/**
 * 
 * @param {*} event 
 * @param {*} content 
 * @returns 
 */
async function handleWriteFile(event, content) {
    console.log('the content', content)
    await fs.promises.writeFile('test.txt', content)
    const stats = await fs.promises.stat('test.txt')
    return stats.size
  }
// 当 Electron 完成初始化并准备创建浏览器窗口时触发此事件
app.on('ready', () => {
    // 注册 IPC 监听器:监听渲染进程发送的 'set-title' 事件
    ipcMain.on('set-title',handlsSetTitle)
// 注册 IPC 处理程序:处理渲染进程发送的 'write-file' 请求
    ipcMain.handle('write-file', handleWriteFile) 
    // 创建主窗口
    createWindow()
})
3.单向通信- 从主进程到渲染进程
  • 使用win.webContets.send进行发送
  • 使用ipcRenderer.on接收

进入页面,页面开始每3秒加3

在这里插入图片描述

1.修改mian.js

const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const createWindow = () => {
     // 创建一个新的浏览器窗口实例
   const mainWindow = new BrowserWindow({
    width: 800, // 设置窗口宽度为 800 像素
    height: 700, // 设置窗口高度为 600 像素
    // 配置网页功能选项
    webPreferences: {
      nodeIntegration: true , // 允许在渲染进程中使用 Node.js 功能
      preload:path.join(__dirname, 'preload.js') // 预加载脚本
    }
  })
  mainWindow.webContents.openDevTools() // 打开开发者工具
  //  加载应用程序的主页面  这里加载当前目录下的 index.html 文件
  mainWindow.loadFile('index.html')

  return mainWindow
}
/**
 * 处理设置窗口标题的 IPC 消息
 * @param {Electron.IpcMainEvent} event - IPC 事件对象
 * @param {string} title - 要设置的新窗口标题
 */

const handlsSetTitle = (event, title) => {
    console.log('title', event, title)
     // 获取发送消息的 WebContents 对象
    const webContents = event.sender
     // 通过 WebContents 找到对应的 BrowserWindow 实例
    const win = BrowserWindow.fromWebContents(webContents)
    // 设置窗口标题
    win.setTitle(title)
    
}

/**
 * 
 * @param {*} event 
 * @param {*} content 
 * @returns 
 */
async function handleWriteFile(event, content) {
    console.log('the content', content)
    await fs.promises.writeFile('test.txt', content)
    const stats = await fs.promises.stat('test.txt')
    return stats.size
  }
// 当 Electron 完成初始化并准备创建浏览器窗口时触发此事件
app.on('ready', () => {
    // 注册 IPC 监听器:监听渲染进程发送的 'set-title' 事件
    ipcMain.on('set-title',handlsSetTitle)
// 注册 IPC 处理程序:处理渲染进程发送的 'write-file' 请求
    ipcMain.handle('write-file', handleWriteFile) 
    // 创建主窗口
    createWindow()



    let counter = 1
    const win = createWindow()
    // remote.enable(win.webContents)
    win.webContents.send('update-counter', counter)
    setInterval(() => {
      counter += 3
      win.webContents.send('update-counter', counter)
    }, 3000)
})
  1. index.html 添加显示数字元素

     <!-- 显示数字元素 -->
    
        <h1 id="time"></h1>
    
  2. renderer.js

    const time = document.getElementById('time')
    // 
      window.electron.onUpdateCounter((value) => {
        time.innerText = value.toString()
      })
    
  3. preload.js添加 onUpdateCounter

     */
    contextBridge.exposeInMainWorld("electron", {
      setTitle: (title) => ipcRenderer.send("set-title", title),
      // 调用主进程的 writeFile 方法,并将内容作为参数传递
      writeFile: (content) => ipcRenderer.invoke('write-file', content), 
      // 调用主进程的 readFile 方法,并将回调函数作为参数传递
      onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
    });
    

六. Electron Forge

Electron Forge 是一个用于打包和分发 Electron 应用程序的工具。 它将 Electron 的构建工具生态系统统一到一个可扩展的界面中,这样每个人都可以直接上手制作 Electron 应用。

它的亮点:

  • 📦 应用打包和代码签名
  • 🚚 Windows、macOS 和 Linux 上的可定制安装程序(DMG、deb、MSI、PKG、AppX 等)
  • ☁️ 云提供商(GitHub、S3、Bitbucket 等)的自动化发布流程
  • ⚡️ 易于使用的 Webpack 和 TypeScript 样板模板
  • ⚙️ 原生 Node.js 模块支持
  • 🔌 可扩展的 JavaScript 插件 API
初始化一个新的 Forge 项目
# my-app-electron 项目名称
#  --template=vite-typescript 模本用vite+typescript
npm init create-electron-app@latest my-app-electron1 -- --template=vite-typescript
# 进入项目
cd my-app-electron
#运行项目
npm start
结合vue3
# 安装vue
npm install vue
#安装vite识别vue插件
npm install --save-dev @vitejs/plugin-vue

七. 应用打包

macOS打包

需要安装 需通过Electron Forge@electron-forge/maker-dmg插件实现

npm install @electron-forge/maker-dmg

操作系统:仅能在macOS上打包DMG(因DMG是macOS专属格式)

forge.config.ts打包文件配置

import type { ForgeConfig } from '@electron-forge/shared-types';
// 每一种打包类型都设计一个单独的npm
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
// macOS
import { MakerDMG } from '@electron-forge/maker-dmg';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';

const config: ForgeConfig = {
    // 基础打包配置
  packagerConfig: {
    asar: true,// 启用 ASAR 归档格式(将应用程序打包为单个文件)
  },
  rebuildConfig: {},
    // 制作包的工具
  makers: [
      // windows 安装包
    new MakerSquirrel({}),
         new MakerDMG({
      name: 'YourAppName', // DMG文件名(默认与packagerConfig.name一致)
      background: 'assets/dmg-background.png', // DMG窗口背景图(推荐1440x900像素)
      icon: 'assets/icon.icns', // DMG窗口中的应用图标(与packagerConfig.icon一致)
      iconSize: 80, // 图标大小(像素)
       format:'ULFO', // 使用ULFO格式,兼容性更好
      }
    })
    new MakerZIP({}, ['darwin']), // ZIP 压缩包生成器(排除 macOS 平台)
    new MakerRpm({}),// RPM 包生成器 (RedHat/CentOS/Fedora)
    new MakerDeb({}),// DEB 包生成器 (Debian/Ubuntu)
  ],
  plugins: [
    new VitePlugin({
      build: [
        {
          entry: 'src/main.ts', // 主进程入口文件
          config: 'vite.main.config.ts',// 主进程专用的 Vite 配置文件
          target: 'main',// 指定目标为 Electron 主进程
        },
        {
          entry: 'src/preload.ts', // 预加载脚本入口文件
          config: 'vite.preload.config.ts',
          target: 'preload',
        },
      ],
      renderer: [
        {
          name: 'main_window',
          config: 'vite.renderer.config.ts',
        },
      ],
    }),
    // Fuses are used to enable/disable various Electron functionality
    // at package time, before code signing the application
    new FusesPlugin({
      version: FuseVersion.V1,
      [FuseV1Options.RunAsNode]: false,
      [FuseV1Options.EnableCookieEncryption]: true,
      [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
      [FuseV1Options.EnableNodeCliInspectArguments]: false,
      [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
      [FuseV1Options.OnlyLoadAppFromAsar]: true,
    }),
  ],
};

export default config;

打包命令

# 1. package 命令
npm  run  package 
# 效果:生成可执行程序,但还不是安装包,直接双击就可以用
#2. make 命令
npm run make
# 效果: 生成完整的安装包,需要安装完毕以后使用

可执行程序和安装包区别

维度 可执行程序(package) 安装包(make)
运行方式 直接双击运行(无需安装) 需运行安装向导,将文件部署到系统目录
系统影响 不修改系统目录(或仅临时缓存) 写入系统目录、注册元数据、创建快捷方式
分发形式 单文件/文件夹(如 .exe.app 安装包(如 .msi.pkg.dmg
用户体验 适合快速测试或便携使用 适合正式发布,提供标准化安装/卸载流程

package打包文件结构

📂 输出目录结构(以 Windows 为例)

out/
└── your-app-name-win32-x64/
    ├── your-app-name.exe          # 主可执行文件(Electron 封装)
    ├── resources/
    │   ├── app.asar               # 应用代码(ASAR 归档格式)
    │   ├── electron.asar          # Electron 运行时(可选)
    │   └── ...                    # 其他资源文件
    ├── node_modules/              # 生产依赖(仅限被引用的模块)
    ├── locales/                   # 多语言文件(如 zh-CN.pak)
    ├── swiftshader/               # GPU 渲染备用库(无显卡驱动时)
    └── ...                        # 其他平台相关文件

什么ASAR

一、ASAR 的本质:为什么用它?

传统 Electron 应用打包时,会将代码直接放在 resources/app目录下(明文可见),存在两个问题:

  1. 源码暴露风险:用户可直接查看/修改 JS/CSS 代码;
  2. 文件系统性能差:大量小文件读取效率低(尤其 Windows)。

ASAR 解决了这些问题:

  • 归档整合:将分散的文件打包成单个 .asar文件,类似 ZIP 但更高效(无需解压即可随机访问);
  • 保护源码:虽然非加密(可用工具解压),但增加了直接阅读的门槛;
  • 提升性能:减少文件系统调用次数,加快应用启动速度。

二、ASAR 文件结构(以 app.asar为例)

ASAR 文件内部是一个虚拟文件系统,结构与你的项目目录一致(但排除了 devDependencies和无关文件)。例如,若你的项目结构如下:

your-project/
├── package.json       # 生产依赖声明(仅保留 dependencies)
├── src/
│   ├── main.js        # 主进程代码
│   └── renderer/      # 渲染进程代码
│       ├── index.html
│       └── app.js
├── static/            # 静态资源(图片、字体等)
│   └── logo.png
└── node_modules/      # 仅包含被引用的生产依赖(精简后)

app.asar解压后的虚拟结构完全一致(但实际存储为二进制归档):

app.asar (虚拟目录)
├── package.json
├── src/
│   ├── main.js
│   └── renderer/
│       ├── index.html
│       └── app.js
├── static/
│   └── logo.png
└── node_modules/      # 精简后的生产依赖

三、ASAR 的核心操作(开发必备)

  1. 解压 ASAR 文件(查看/修改源码)

若需调试或修改打包后的代码,可先将 app.asar解压为明文目录:

# 安装 asar 工具(Electron 内置,也可单独安装)
npm install -g @electron/asar

# 解压 app.asar 到 unpacked 目录
asar extract out/your-app-win32-x64/resources/app.asar unpacked/

解压后,unpacked/目录即为明文的项目结构,可直接编辑代码。

  1. 重新打包为 ASAR

修改完成后,将明文目录重新打包为 app.asar

asar pack unpacked/ new-app.asar  # 将 unpacked/ 打包为 new-app.asar
# 替换原文件:cp new-app.asar out/your-app-win32-x64/resources/app.asar

3. 禁用 ASAR(明文目录模式)

若需完全明文(如开发阶段调试),可在 forge.config.js中关闭 ASAR:

// forge.config.js
module.exports = {
  packagerConfig: {
    asar: false,  // 禁用 ASAR,生成明文 app 目录(而非 app.asar)
  },
};

此时,package命令会在 resources/下生成 app/明文目录(而非 app.asar)。

浅记录一下专家体系

在扩展MCP的时候就遇到了上下文不够用的问题,目前最佳还是用RAG,这种方式抓取关键信息,节省字段。期待一手专家强化的表现

Expert RAG System 架构设计

版本: v2.0 日期: 2025-12-12


一、系统全景图

┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                                      │
│                              🧠 Expert RAG Workflow System                                           │
│                                                                                                      │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                      │
│   ┌─────────────────────────────────────────────────────────────────────────────────────────────┐   │
│   │                                    User Layer (用户层)                                       │   │
│   │                                                                                              │   │
│   │    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐                │   │
│   │    │   Cursor    │    │    CLI      │    │    Web      │    │    MCP      │                │   │
│   │    │    IDE      │    │   Tools     │    │  Dashboard  │    │   Client    │                │   │
│   │    └──────┬──────┘    └──────┬──────┘    └──────┬──────┘    └──────┬──────┘                │   │
│   │           │                  │                  │                  │                        │   │
│   └───────────┼──────────────────┼──────────────────┼──────────────────┼────────────────────────┘   │
│               │                  │                  │                  │                            │
│               ▼                  ▼                  ▼                  ▼                            │
│   ┌─────────────────────────────────────────────────────────────────────────────────────────────┐   │
│   │                                   MCP Layer (MCP 服务层)                                     │   │
│   │                                                                                              │   │
│   │    ┌───────────────────────────────────────────────────────────────────────────────────┐    │   │
│   │    │                           cursor-workflow MCP Server                               │    │   │
│   │    │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐               │    │   │
│   │    │  │  Workflow   │  │   Expert    │  │    RAG      │  │   Status    │               │    │   │
│   │    │  │  Commands   │  │  Commands   │  │  Commands   │  │  Commands   │               │    │   │
│   │    │  │ /start      │  │ /activate   │  │ rag_query   │  │ rag_status  │               │    │   │
│   │    │  │ /iterate    │  │ /reinforce  │  │ rag_index   │  │ rag_inspect │               │    │   │
│   │    │  │ /confirm    │  │ /iterate    │  │             │  │             │               │    │   │
│   │    │  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘               │    │   │
│   │    └───────────────────────────────────────────────────────────────────────────────────┘    │   │
│   │                                              │                                               │   │
│   └──────────────────────────────────────────────┼───────────────────────────────────────────────┘   │
│                                                  │                                                   │
│                                                  ▼                                                   │
│   ┌─────────────────────────────────────────────────────────────────────────────────────────────┐   │
│   │                                  Core Layer (核心层)                                         │   │
│   │                                                                                              │   │
│   │    ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐                        │   │
│   │    │  Expert Manager │    │   RAG Engine    │    │ Session Manager │                        │   │
│   │    │                 │    │                 │    │                 │                        │   │
│   │    │ • 专家注册      │    │ • 文档分块      │    │ • 上下文缓存    │                        │   │
│   │    │ • 专家激活      │    │ • 向量化       │    │ • Session 管理  │                        │   │
│   │    │ • 强化记录      │    │ • 语义检索     │    │ • TTL 控制      │                        │   │
│   │    │ • 迭代升级      │    │ • 结果排序     │    │ • 缓存释放      │                        │   │
│   │    └────────┬────────┘    └────────┬────────┘    └────────┬────────┘                        │   │
│   │             │                      │                      │                                 │   │
│   │             ▼                      ▼                      ▼                                 │   │
│   │    ┌─────────────────────────────────────────────────────────────────┐                      │   │
│   │    │                     Retrieval Chain (检索链)                    │                      │   │
│   │    │                                                                 │                      │   │
│   │    │    RAG 检索 ───► 关键词匹配 ───► 文件加载 ───► 空响应          │                      │   │
│   │    │       │              │              │              │            │                      │   │
│   │    │       ▼              ▼              ▼              ▼            │                      │   │
│   │    │   [成功返回]    [成功返回]    [成功返回]    [无知识提示]        │                      │   │
│   │    └─────────────────────────────────────────────────────────────────┘                      │   │
│   │                                                                                              │   │
│   └──────────────────────────────────────────────────────────────────────────────────────────────┘   │
│                                                  │                                                   │
│                                                  ▼                                                   │
│   ┌─────────────────────────────────────────────────────────────────────────────────────────────┐   │
│   │                                 Storage Layer (存储层)                                       │   │
│   │                                                                                              │   │
│   │    ┌─────────────────────────────┐         ┌─────────────────────────────┐                  │   │
│   │    │      Global Storage         │         │      Project Storage        │                  │   │
│   │    │   ~/.cursor-workflow/       │         │   {project}/.cursor/        │                  │   │
│   │    │                             │         │                             │                  │   │
│   │    │  ├─ models/                 │         │  ├─ skills/                 │                  │   │
│   │    │  │  └─ all-MiniLM-L6-v2/   │         │  │  ├─ vue3-expert/         │                  │   │
│   │    │  │                          │         │  │  │  ├─ SKILL.md         │                  │   │
│   │    │  ├─ skills/                 │         │  │  │  ├─ LOCAL.md         │                  │   │
│   │    │  │  └─ (模板库)             │         │  │  │  └─ reinforcements/  │                  │   │
│   │    │  │                          │         │  │  └─ react-expert/       │                  │   │
│   │    │  ├─ vectors/                │         │  │                          │                  │   │
│   │    │  │  └─ global-index.json   │◄────────│  ├─ vectors/                │                  │   │
│   │    │  │                          │  引用   │  │  ├─ index.json          │                  │   │
│   │    │  └─ registry.json           │         │  │  └─ metadata.json       │                  │   │
│   │    │                             │         │  │                          │                  │   │
│   │    └─────────────────────────────┘         │  └─ workflow-state.json    │                  │   │
│   │                                             └─────────────────────────────┘                  │   │
│   │                                                                                              │   │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────────┐  │   │
│   │    │                              Embedding Layer                                         │  │   │
│   │    │                                                                                      │  │   │
│   │    │    ┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐      │  │   │
│   │    │    │  Transformers.js │         │     Orama      │         │  JSON Storage   │      │  │   │
│   │    │    │  (Embedding)    │────────►│  (Vector DB)   │────────►│  (Persistence)  │      │  │   │
│   │    │    │  384 维向量     │         │  向量检索      │         │  文件存储       │      │  │   │
│   │    │    └─────────────────┘         └─────────────────┘         └─────────────────┘      │  │   │
│   │    └─────────────────────────────────────────────────────────────────────────────────────┘  │   │
│   │                                                                                              │   │
│   └──────────────────────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                                      │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘

二、专家生命周期

┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                    Expert Lifecycle (专家生命周期)                               │
├─────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                  │
│   ┌──────────────────────────────────────────────────────────────────────────────────────────┐  │
│   │                                    1. 模板创建阶段                                        │  │
│   │                                                                                           │  │
│   │      开发者定义专家                                                                       │  │
│   │           │                                                                               │  │
│   │           ▼                                                                               │  │
│   │    ┌─────────────┐         ┌─────────────┐         ┌─────────────┐                       │  │
│   │    │  SKILL.md   │         │  commands/  │         │  resources/ │                       │  │
│   │    │  核心技能   │         │  命令定义   │         │  模板资源   │                       │  │
│   │    └─────────────┘         └─────────────┘         └─────────────┘                       │  │
│   │           │                       │                       │                               │  │
│   │           └───────────────────────┼───────────────────────┘                               │  │
│   │                                   ▼                                                       │  │
│   │                    ┌─────────────────────────────┐                                        │  │
│   │                    │   全局模板库                 │                                        │  │
│   │                    │   ~/.cursor-workflow/skills │                                        │  │
│   │                    └─────────────────────────────┘                                        │  │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                           │                                                      │
│                                           ▼                                                      │
│   ┌──────────────────────────────────────────────────────────────────────────────────────────┐  │
│   │                                    2. 项目初始化阶段                                      │  │
│   │                                                                                           │  │
│   │    $ cursor-workflow init --project=/path/to/project --experts=vue3,react,architecture   │  │
│   │                                           │                                               │  │
│   │                                           ▼                                               │  │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────┐   │  │
│   │    │                                                                                  │   │  │
│   │    │   1. 复制专家模板到项目                                                          │   │  │
│   │    │      ~/.cursor-workflow/skills/vue3-expert → .cursor/skills/vue3-expert         │   │  │
│   │    │                                                                                  │   │  │
│   │    │   2. 创建本地化配置文件                                                          │   │  │
│   │    │      .cursor/skills/vue3-expert/LOCAL.md (空模板)                               │   │  │
│   │    │                                                                                  │   │  │
│   │    │   3. 初始化向量索引                                                              │   │  │
│   │    │      引用全局索引 + 创建项目索引空间                                             │   │  │
│   │    │                                                                                  │   │  │
│   │    │   4. 创建工作流状态文件                                                          │   │  │
│   │    │      .cursor/workflow-state.json                                                │   │  │
│   │    │                                                                                  │   │  │
│   │    └─────────────────────────────────────────────────────────────────────────────────┘   │  │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                           │                                                      │
│                                           ▼                                                      │
│   ┌──────────────────────────────────────────────────────────────────────────────────────────┐  │
│   │                                    3. 本地化阶段                                          │  │
│   │                                                                                           │  │
│   │    用户: "请根据这个 API 文档本地化 vue3 专家"                                           │  │
│   │           │                                                                               │  │
│   │           ▼                                                                               │  │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────┐   │  │
│   │    │  workflow_localize(expertId, materials)                                          │   │  │
│   │    │                                                                                  │   │  │
│   │    │   1. 读取物料文件 (API.md, 规范文档等)                                           │   │  │
│   │    │   2. AI 总结提取知识点                                                           │   │  │
│   │    │   3. 更新 LOCAL.md                                                              │   │  │
│   │    │   4. 增量索引到向量库                                                            │   │  │
│   │    │                                                                                  │   │  │
│   │    └─────────────────────────────────────────────────────────────────────────────────┘   │  │
│   │                                                                                           │  │
│   │    结果:                                                                                  │  │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────┐   │  │
│   │    │  LOCAL.md                                                                        │   │  │
│   │    │  ─────────────────────────────────────────────────────────────────               │   │  │
│   │    │  # vue3-expert 项目本地化配置                                                    │   │  │
│   │    │                                                                                  │   │  │
│   │    │  ## 项目 API 规范                                                                │   │  │
│   │    │  - 使用 /api/v1 作为 API 前缀                                                   │   │  │
│   │    │  - 响应格式: { code, data, message }                                            │   │  │
│   │    │                                                                                  │   │  │
│   │    │  ## 组件规范                                                                     │   │  │
│   │    │  - 强制使用 <script setup> 语法                                                 │   │  │
│   │    │  - 样式使用 SCSS + BEM 命名                                                     │   │  │
│   │    └─────────────────────────────────────────────────────────────────────────────────┘   │  │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                           │                                                      │
│                                           ▼                                                      │
│   ┌──────────────────────────────────────────────────────────────────────────────────────────┐  │
│   │                                    4. 使用阶段                                            │  │
│   │                                                                                           │  │
│   │    用户: "/vue3-expert 帮我创建一个用户管理组件"                                         │  │
│   │           │                                                                               │  │
│   │           ▼                                                                               │  │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────┐   │  │
│   │    │  workflow_activate(vue3-expert)                                                  │   │  │
│   │    │                                                                                  │   │  │
│   │    │   1. 检查向量索引状态                                                            │   │  │
│   │    │   2. RAG 检索相关知识                                                            │   │  │
│   │    │      - SKILL: 组件创建规范                                                       │   │  │
│   │    │      - LOCAL: 项目组件规范                                                       │   │  │
│   │    │      - REINFORCE: 历史踩坑经验                                                   │   │  │
│   │    │   3. 注入上下文,开始工作                                                        │   │  │
│   │    │                                                                                  │   │  │
│   │    └─────────────────────────────────────────────────────────────────────────────────┘   │  │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                           │                                                      │
│                                           ▼                                                      │
│   ┌──────────────────────────────────────────────────────────────────────────────────────────┐  │
│   │                                    5. 强化阶段                                            │  │
│   │                                                                                           │  │
│   │    方案评审 < 80 分,或者用户指出问题                                                    │  │
│   │           │                                                                               │  │
│   │           ▼                                                                               │  │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────┐   │  │
│   │    │  expert_reinforce(expertId, problem, solution)                                   │   │  │
│   │    │                                                                                  │   │  │
│   │    │   1. 记录问题和解决方案到 HISTORY.md                                             │   │  │
│   │    │   2. 增量索引强化内容                                                            │   │  │
│   │    │   3. 检查强化次数                                                                │   │  │
│   │    │      - < 5: 继续累积                                                             │   │  │
│   │    │      - >= 5: 触发专家迭代                                                        │   │  │
│   │    │                                                                                  │   │  │
│   │    └─────────────────────────────────────────────────────────────────────────────────┘   │  │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                           │                                                      │
│                                           ▼                                                      │
│   ┌──────────────────────────────────────────────────────────────────────────────────────────┐  │
│   │                                    6. 迭代阶段                                            │  │
│   │                                                                                           │  │
│   │    强化次数 >= 5                                                                         │  │
│   │           │                                                                               │  │
│   │           ▼                                                                               │  │
│   │    ┌─────────────────────────────────────────────────────────────────────────────────┐   │  │
│   │    │  expert_iterate(expertId)                                                        │   │  │
│   │    │                                                                                  │   │  │
│   │    │   1. 读取所有强化记录                                                            │   │  │
│   │    │   2. AI 分析共性问题                                                             │   │  │
│   │    │   3. 更新 SKILL.md (融入强化经验)                                               │   │  │
│   │    │   4. 清空 HISTORY.md                                                            │   │  │
│   │    │   5. 重建向量索引                                                                │   │  │
│   │    │   6. 版本号 +1                                                                   │   │  │
│   │    │                                                                                  │   │  │
│   │    └─────────────────────────────────────────────────────────────────────────────────┘   │  │
│   │                                                                                           │  │
│   │    结果: 专家从 v1.0.0 升级到 v1.1.0,融合了 5 次强化经验                               │  │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘

三、工作流状态机

┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                    Workflow State Machine (工作流状态机)                         │
├─────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                  │
│                                         /start <任务>                                            │
│                                              │                                                   │
│                                              ▼                                                   │
│   ┌─────────────────────────────────────────────────────────────────────────────────────────┐   │
│   │                                                                                          │   │
│   │                                       ┌────────┐                                         │   │
│   │                                       │  INIT  │                                         │   │
│   │                                       └────┬───┘                                         │   │
│   │                                            │                                             │   │
│   │                            激活需求专家,开始分析需求                                    │   │
│   │                                            │                                             │   │
│   │                                            ▼                                             │   │
│   │                              ┌───────────────────────────┐                               │   │
│   │                              │       REQUIREMENT         │                               │   │
│   │                              │                           │                               │   │
│   │                              │  ┌─────────────────────┐  │                               │   │
│   │                              │  │ 迭代 1: 理解核心需求 │  │ ◄─── /iterate                │   │
│   │                              │  └──────────┬──────────┘  │                               │   │
│   │                              │             │              │                               │   │
│   │                              │  ┌──────────▼──────────┐  │                               │   │
│   │                              │  │ 迭代 2: 补充边界条件 │  │ ◄─── /iterate                │   │
│   │                              │  └──────────┬──────────┘  │                               │   │
│   │                              │             │              │                               │   │
│   │                              │  ┌──────────▼──────────┐  │                               │   │
│   │                              │  │ 迭代 3: 最终审查    │  │ ◄─── /iterate                │   │
│   │                              │  └─────────────────────┘  │                               │   │
│   │                              │                           │                               │   │
│   │                              └─────────────┬─────────────┘                               │   │
│   │                                            │                                             │   │
│   │                                      /confirm                                            │   │
│   │                                            │                                             │   │
│   │                                            ▼                                             │   │
│   │                              ┌───────────────────────────┐                               │   │
│   │                              │        SOLUTION           │                               │   │
│   │                              │                           │                               │   │
│   │                              │   ┌─────────────────────┐ │                               │   │
│   │                              │   │   并行生成方案      │ │                               │   │
│   │                              │   │                     │ │                               │   │
│   │                              │   │  ┌───────┐ ┌───────┐│ │                               │   │
│   │                              │   │  │Expert1│ │Expert2││ │                               │   │
│   │                              │   │  │方案 A │ │方案 B ││ │                               │   │
│   │                              │   │  └───────┘ └───────┘│ │                               │   │
│   │                              │   └─────────────────────┘ │                               │   │
│   │                              │                           │                               │   │
│   │                              └─────────────┬─────────────┘                               │   │
│   │                                            │                                             │   │
│   │                                       /review                                            │   │
│   │                                            │                                             │   │
│   │                                            ▼                                             │   │
│   │                              ┌───────────────────────────┐                               │   │
│   │                              │         REVIEW            │                               │   │
│   │                              │                           │                               │   │
│   │                              │   专家互评打分            │                               │   │
│   │                              │                           │                               │   │
│   │                              │   ┌─────────────────────┐ │                               │   │
│   │                              │   │ 评分维度 (各25分)   │ │                               │   │
│   │                              │   │ • 完整性            │ │                               │   │
│   │                              │   │ • 可行性            │ │                               │   │
│   │                              │   │ • 可维护性          │ │                               │   │
│   │                              │   │ • 性能考虑          │ │                               │   │
│   │                              │   └─────────────────────┘ │                               │   │
│   │                              │                           │                               │   │
│   │                              └─────────────┬─────────────┘                               │   │
│   │                                            │                                             │   │
│   │                          ┌─────────────────┼─────────────────┐                           │   │
│   │                          │                 │                 │                           │   │
│   │                     < 80 分           >= 80 分          最高分方案                       │   │
│   │                          │                 │                 │                           │   │
│   │                          ▼                 │                 │                           │   │
│   │            ┌─────────────────────┐         │                 │                           │   │
│   │            │    强化记录         │         │                 │                           │   │
│   │            │    记录不足点       │         │                 │                           │   │
│   │            │    重新生成方案     │         │                 │                           │   │
│   │            └──────────┬──────────┘         │                 │                           │   │
│   │                       │                    │                 │                           │   │
│   │                       └────────────────────┼─────────────────┘                           │   │
│   │                                            │                                             │   │
│   │                                            ▼                                             │   │
│   │                              ┌───────────────────────────┐                               │   │
│   │                              │       EXECUTION           │                               │   │
│   │                              │                           │                               │   │
│   │                              │   ┌─────────────────────┐ │                               │   │
│   │                              │   │ Task 1: xxx         │ │ ◄─── /next                   │   │
│   │                              │   │ [✓] 完成            │ │                               │   │
│   │                              │   ├─────────────────────┤ │                               │   │
│   │                              │   │ Task 2: xxx         │ │ ◄─── /next                   │   │
│   │                              │   │ [✓] 完成            │ │                               │   │
│   │                              │   ├─────────────────────┤ │                               │   │
│   │                              │   │ Task 3: xxx         │ │ ◄─── /next                   │   │
│   │                              │   │ [ ] 进行中          │ │                               │   │
│   │                              │   └─────────────────────┘ │                               │   │
│   │                              │                           │                               │   │
│   │                              └─────────────┬─────────────┘                               │   │
│   │                                            │                                             │   │
│   │                                        /done                                             │   │
│   │                                            │                                             │   │
│   │                                            ▼                                             │   │
│   │                                       ┌────────┐                                         │   │
│   │                                       │  DONE  │                                         │   │
│   │                                       └────────┘                                         │   │
│   │                                                                                          │   │
│   └──────────────────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘

四、RAG 检索流程

┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                    RAG Retrieval Flow (RAG 检索流程)                             │
├─────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                  │
│    用户输入: "帮我创建一个带有表单验证的用户编辑组件"                                           │
│         │                                                                                        │
│         ▼                                                                                        │
│    ┌─────────────────────────────────────────────────────────────────────────────────────────┐  │
│    │                                   1. Query Processing                                    │  │
│    │                                                                                          │  │
│    │    ┌──────────────────┐         ┌──────────────────┐         ┌──────────────────┐       │  │
│    │    │   Query Parser   │────────►│   Transformers.js│────────►│  Query Vector    │       │  │
│    │    │   提取关键意图   │         │   文本向量化     │         │  [0.12, 0.34...] │       │  │
│    │    └──────────────────┘         └──────────────────┘         └──────────────────┘       │  │
│    │                                                                       │                  │  │
│    └───────────────────────────────────────────────────────────────────────┼──────────────────┘  │
│                                                                            │                     │
│                                                                            ▼                     │
│    ┌─────────────────────────────────────────────────────────────────────────────────────────┐  │
│    │                                   2. Session Check                                       │  │
│    │                                                                                          │  │
│    │    ┌──────────────────────────────────────────────────────────────────┐                 │  │
│    │    │  Context Budget Manager                                          │                 │  │
│    │    │                                                                   │                 │  │
│    │    │  ┌────────────────────┐    ┌────────────────────┐                │                 │  │
│    │    │  │ 首次激活?          │    │ 缓存命中?          │                │                 │  │
│    │    │  │                    │    │                    │                │                 │  │
│    │    │  │  是 → 完整检索     │    │  是 → 增量检索     │                │                 │  │
│    │    │  │  否 → 检查缓存     │    │  否 → 完整检索     │                │                 │  │
│    │    │  └────────────────────┘    └────────────────────┘                │                 │  │
│    │    └──────────────────────────────────────────────────────────────────┘                 │  │
│    │                                              │                                           │  │
│    └──────────────────────────────────────────────┼───────────────────────────────────────────┘  │
│                                                   │                                              │
│                                                   ▼                                              │
│    ┌─────────────────────────────────────────────────────────────────────────────────────────┐  │
│    │                                   3. Retrieval Chain                                     │  │
│    │                                                                                          │  │
│    │    ┌─────────────────────────────────────────────────────────────────────────────────┐  │  │
│    │    │                                                                                  │  │  │
│    │    │   ┌──────────────┐      ┌──────────────┐      ┌──────────────┐      ┌────────┐  │  │  │
│    │    │   │  RAG 检索    │─────►│  关键词匹配  │─────►│  文件加载    │─────►│  空    │  │  │  │
│    │    │   │  (Orama)     │ 失败 │  (BM25)      │ 失败 │  (降级)      │ 失败 │  响应  │  │  │  │
│    │    │   └──────┬───────┘      └──────┬───────┘      └──────┬───────┘      └────────┘  │  │  │
│    │    │          │ 成功                │ 成功                │ 成功                     │  │  │
│    │    │          ▼                     ▼                     ▼                          │  │  │
│    │    │   ┌──────────────────────────────────────────────────────┐                      │  │  │
│    │    │   │                    Results                           │                      │  │  │
│    │    │   └──────────────────────────────────────────────────────┘                      │  │  │
│    │    │                                                                                  │  │  │
│    │    └─────────────────────────────────────────────────────────────────────────────────┘  │  │
│    │                                              │                                           │  │
│    └──────────────────────────────────────────────┼───────────────────────────────────────────┘  │
│                                                   │                                              │
│                                                   ▼                                              │
│    ┌─────────────────────────────────────────────────────────────────────────────────────────┐  │
│    │                                   4. Priority & Merge                                    │  │
│    │                                                                                          │  │
│    │    ┌─────────────────────────────────────────────────────────────────────────────────┐  │  │
│    │    │                     知识优先级排序                                               │  │  │
│    │    │                                                                                  │  │  │
│    │    │   ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐                        │  │  │
│    │    │   │  LOCAL   │  │ REINFORCE│  │  SKILL   │  │  SHARED  │                        │  │  │
│    │    │   │ 优先级100│  │ 优先级80 │  │ 优先级60 │  │ 优先级40 │                        │  │  │
│    │    │   └──────────┘  └──────────┘  └──────────┘  └──────────┘                        │  │  │
│    │    │        │              │              │              │                            │  │  │
│    │    │        └──────────────┼──────────────┼──────────────┘                            │  │  │
│    │    │                       │              │                                           │  │  │
│    │    │                       ▼              ▼                                           │  │  │
│    │    │              ┌─────────────────────────────────┐                                 │  │  │
│    │    │              │   Conflict Resolver             │                                 │  │  │
│    │    │              │   相同主题取高优先级            │                                 │  │  │
│    │    │              └─────────────────────────────────┘                                 │  │  │
│    │    │                                                                                  │  │  │
│    │    └─────────────────────────────────────────────────────────────────────────────────┘  │  │
│    │                                              │                                           │  │
│    └──────────────────────────────────────────────┼───────────────────────────────────────────┘  │
│                                                   │                                              │
│                                                   ▼                                              │
│    ┌─────────────────────────────────────────────────────────────────────────────────────────┐  │
│    │                                   5. Context Injection                                   │  │
│    │                                                                                          │  │
│    │    ┌─────────────────────────────────────────────────────────────────────────────────┐  │  │
│    │    │                                                                                  │  │  │
│    │    │   ## 📚 相关知识 (置信度: 92%)                                                   │  │  │
│    │    │                                                                                  │  │  │
│    │    │   ### 来源: 项目规范 (LOCAL) [优先级: 100]                                       │  │  │
│    │    │   > 本项目使用 Element Plus 表单组件                                             │  │  │
│    │    │   > 表单验证使用 async-validator                                                 │  │  │
│    │    │                                                                                  │  │  │
│    │    │   ### 来源: 强化经验 (REINFORCE) [优先级: 80]                                    │  │  │
│    │    │   > 问题: 表单重置时验证状态未清除                                               │  │  │
│    │    │   > 解决: 调用 formRef.resetFields() 而非手动清空                                │  │  │
│    │    │                                                                                  │  │  │
│    │    │   ### 来源: 基础技能 (SKILL) [优先级: 60]                                        │  │  │
│    │    │   > Vue3 表单组件应使用 v-model 双向绑定                                         │  │  │
│    │    │   > 验证规则定义在 rules 对象中                                                  │  │  │
│    │    │                                                                                  │  │  │
│    │    │   ---                                                                            │  │  │
│    │    │   ℹ️ 已消耗 450 tokens,剩余预算 2550 tokens                                     │  │  │
│    │    │                                                                                  │  │  │
│    │    └─────────────────────────────────────────────────────────────────────────────────┘  │  │
│    │                                                                                          │  │
│    └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘

五、可视化管理

┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                    Visualization & Management (可视化与管理)                     │
├─────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                  │
│    ┌──────────────────────────────────────────────────────────────────────────────────────────┐ │
│    │                              1. CLI Tools (命令行工具)                                    │ │
│    │                                                                                           │ │
│    │   $ cursor-rag status                          # 查看全局状态                            │ │
│    │   $ cursor-rag status --project=./             # 查看项目状态                            │ │
│    │   $ cursor-rag inspect vue3-expert             # 查看专家知识                            │ │
│    │   $ cursor-rag search "组件创建"               # 搜索知识库                              │ │
│    │   $ cursor-rag index --rebuild                 # 重建索引                                │ │
│    │   $ cursor-rag export vue3-expert              # 导出专家                                │ │
│    │                                                                                           │ │
│    └──────────────────────────────────────────────────────────────────────────────────────────┘ │
│                                                                                                  │
│    ┌──────────────────────────────────────────────────────────────────────────────────────────┐ │
│    │                              2. MCP Tools (Cursor 内查询)                                 │ │
│    │                                                                                           │ │
│    │   用户: "查看当前知识库状态"                                                             │ │
│    │                                                                                           │ │
│    │   AI 调用: rag_status({ projectPath: "E:/vite7-vue-project" })                          │ │
│    │                                                                                           │ │
│    │   返回:                                                                                   │ │
│    │   ┌─────────────────────────────────────────────────────────────────────────────────┐    │ │
│    │   │  📊 RAG 知识库状态                                                               │    │ │
│    │   │                                                                                  │    │ │
│    │   │  项目: vite7-vue-project                                                        │    │ │
│    │   │  专家: 9| 知识块: 150| 总 Tokens: 22.0K                                 │    │ │
│    │   │                                                                                  │    │ │
│    │   │  专家详情:                                                                       │    │ │
│    │   │  ├─ vue3-expert    [25 块] SKILL:12 LOCAL:8 REINFORCE:5                        │    │ │
│    │   │  ├─ react-expert   [18 块] SKILL:10 LOCAL:6 REINFORCE:2                        │    │ │
│    │   │  └─ arch-expert    [28 块] SKILL:15 LOCAL:10 REINFORCE:3                       │    │ │
│    │   │                                                                                  │    │ │
│    │   │  Session: 活跃 (30分钟前激活 vue3-expert)                                       │    │ │
│    │   │  缓存: 命中率 80% | 节省 8000 tokens                                            │    │ │
│    │   └─────────────────────────────────────────────────────────────────────────────────┘    │ │
│    │                                                                                           │ │
│    └──────────────────────────────────────────────────────────────────────────────────────────┘ │
│                                                                                                  │
│    ┌──────────────────────────────────────────────────────────────────────────────────────────┐ │
│    │                              3. Web Dashboard (可视化界面)                                │ │
│    │                                                                                           │ │
│    │   $ cursor-rag dashboard                       # 启动 Dashboard                         │ │
│    │   🌐 Dashboard 启动于 http://localhost:3721                                              │ │
│    │                                                                                           │ │
│    │   ┌─────────────────────────────────────────────────────────────────────────────────┐    │ │
│    │   │  ┌───────────────────────────────────────────────────────────────────────────┐  │    │ │
│    │   │  │  🧠 Expert RAG Dashboard                               [vite7-vue-project] │  │    │ │
│    │   │  ├───────────────────────────────────────────────────────────────────────────┤  │    │ │
│    │   │  │                                                                            │  │    │ │
│    │   │  │   ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │  │    │ │
│    │   │  │   │ Projects │  │ Experts  │  │  Chunks  │  │  Tokens  │                  │  │    │ │
│    │   │  │   │    3     │  │    17    │  │   275    │  │  40.7K   │                  │  │    │ │
│    │   │  │   └──────────┘  └──────────┘  └──────────┘  └──────────┘                  │  │    │ │
│    │   │  │                                                                            │  │    │ │
│    │   │  │   ┌────────────────────────────────────────────────────────────────────┐  │  │    │ │
│    │   │  │   │  📈 知识分布                                                        │  │  │    │ │
│    │   │  │   │                                                                     │  │  │    │ │
│    │   │  │   │      SKILL ████████████████░░░░░░ 48%                              │  │  │    │ │
│    │   │  │   │      LOCAL ████████████░░░░░░░░░░ 32%                              │  │  │    │ │
│    │   │  │   │  REINFORCE ████████░░░░░░░░░░░░░░ 20%                              │  │  │    │ │
│    │   │  │   │                                                                     │  │  │    │ │
│    │   │  │   └────────────────────────────────────────────────────────────────────┘  │  │    │ │
│    │   │  │                                                                            │  │    │ │
│    │   │  │   ┌────────────────────────────────────────────────────────────────────┐  │  │    │ │
│    │   │  │   │  🔍 知识搜索                                       [ 搜索 ]        │  │  │    │ │
│    │   │  │   │  ┌──────────────────────────────────────────────────────────────┐  │  │  │    │ │
│    │   │  │   │  │ 表单验证                                                      │  │  │  │    │ │
│    │   │  │   │  └──────────────────────────────────────────────────────────────┘  │  │  │    │ │
│    │   │  │   │                                                                     │  │  │    │ │
│    │   │  │   │  Results: 5 matches                                                │  │  │    │ │
│    │   │  │   │  ┌────────────────────────────────────────────────────────────┐    │  │  │    │ │
│    │   │  │   │  │ 1. [LOCAL] 项目表单规范 (0.95)                             │    │  │  │    │ │
│    │   │  │   │  │ 2. [REINFORCE] 表单重置问题 (0.88)                         │    │  │  │    │ │
│    │   │  │   │  │ 3. [SKILL] Vue3 表单组件 (0.82)                            │    │  │  │    │ │
│    │   │  │   │  └────────────────────────────────────────────────────────────┘    │  │  │    │ │
│    │   │  │   └────────────────────────────────────────────────────────────────────┘  │  │    │ │
│    │   │  │                                                                            │  │    │ │
│    │   │  └───────────────────────────────────────────────────────────────────────────┘  │    │ │
│    │   └─────────────────────────────────────────────────────────────────────────────────┘    │ │
│    │                                                                                           │ │
│    └──────────────────────────────────────────────────────────────────────────────────────────┘ │
│                                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘

六、数据流图

┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                    Data Flow (数据流)                                            │
├─────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                  │
│                                                                                                  │
│    ┌─────────────┐                                                      ┌─────────────┐         │
│    │   SKILL.md  │                                                      │   LOCAL.md  │         │
│    │   基础技能  │                                                      │  项目本地化 │         │
│    └──────┬──────┘                                                      └──────┬──────┘         │
│           │                                                                    │                │
│           │                        ┌─────────────┐                             │                │
│           │                        │ HISTORY.md  │                             │                │
│           │                        │  强化记录   │                             │                │
│           │                        └──────┬──────┘                             │                │
│           │                               │                                    │                │
│           ▼                               ▼                                    ▼                │
│    ┌─────────────────────────────────────────────────────────────────────────────────────┐     │
│    │                                Document Chunker                                      │     │
│    │                                   文档分块器                                         │     │
│    │                                                                                      │     │
│    │   输入: Markdown 文件                                                                │     │
│    │   输出: 知识块 (chunk_size=500, overlap=50)                                          │     │
│    │                                                                                      │     │
│    └────────────────────────────────────────┬────────────────────────────────────────────┘     │
│                                             │                                                   │
│                                             ▼                                                   │
│    ┌─────────────────────────────────────────────────────────────────────────────────────┐     │
│    │                              Transformers.js Embedding                               │     │
│    │                                    向量化引擎                                        │     │
│    │                                                                                      │     │
│    │   模型: all-MiniLM-L6-v2                                                             │     │
│    │   维度: 384                                                                          │     │
│    │   输出: [0.12, -0.34, 0.56, ...]                                                     │     │
│    │                                                                                      │     │
│    └────────────────────────────────────────┬────────────────────────────────────────────┘     │
│                                             │                                                   │
│                                             ▼                                                   │
│    ┌─────────────────────────────────────────────────────────────────────────────────────┐     │
│    │                                   Orama Database                                     │     │
│    │                                    向量数据库                                        │     │
│    │                                                                                      │     │
│    │   ┌─────────────────────────────────────────────────────────────────────────────┐   │     │
│    │   │  index.json                                                                  │   │     │
│    │   │  ─────────────────────────────────────────────────────────────────           │   │     │
│    │   │  {                                                                           │   │     │
│    │   │    "chunks": [                                                               │   │     │
│    │   │      {                                                                       │   │     │
│    │   │        "id": "vue3-expert:skill:001",                                        │   │     │
│    │   │        "expertId": "vue3-expert",                                            │   │     │
│    │   │        "docType": "skill",                                                   │   │     │
│    │   │        "priority": 60,                                                       │   │     │
│    │   │        "content": "Vue3 组件应使用 Composition API...",                      │   │     │
│    │   │        "embedding": [0.12, -0.34, 0.56, ...]                                 │   │     │
│    │   │      },                                                                      │   │     │
│    │   │      ...                                                                     │   │     │
│    │   │    ]                                                                         │   │     │
│    │   │  }                                                                           │   │     │
│    │   └─────────────────────────────────────────────────────────────────────────────┘   │     │
│    │                                                                                      │     │
│    │   ┌─────────────────────────────────────────────────────────────────────────────┐   │     │
│    │   │  metadata.json                                                               │   │     │
│    │   │  ─────────────────────────────────────────────────────────────────           │   │     │
│    │   │  {                                                                           │   │     │
│    │   │    "projectName": "vite7-vue-project",                                       │   │     │
│    │   │    "experts": {                                                              │   │     │
│    │   │      "vue3-expert": { "chunks": 25, "tokens": 3500, ... }                    │   │     │
│    │   │    },                                                                        │   │     │
│    │   │    "stats": { "totalChunks": 150, "totalTokens": 22000 }                     │   │     │
│    │   │  }                                                                           │   │     │
│    │   └─────────────────────────────────────────────────────────────────────────────┘   │     │
│    │                                                                                      │     │
│    └────────────────────────────────────────┬────────────────────────────────────────────┘     │
│                                             │                                                   │
│                                             ▼                                                   │
│    ┌─────────────────────────────────────────────────────────────────────────────────────┐     │
│    │                                workflow-state.json                                   │     │
│    │                                   工作流状态                                         │     │
│    │                                                                                      │     │
│    │   {                                                                                  │     │
│    │     "meta": { ... },                                                                 │     │
│    │     "state": { "current": "EXECUTION", ... },                                        │     │
│    │     "rag": {                                                                         │     │
│    │       "session": { "id": "...", "lastActiveAt": "..." },                             │     │
│    │       "activeExpert": { "id": "vue3-expert", "contextLoaded": true },                │     │
│    │       "contextCache": { ... },                                                       │     │
│    │       "stats": { "cacheHits": 12, "tokensSaved": 8000 }                              │     │
│    │     }                                                                                │     │
│    │   }                                                                                  │     │
│    │                                                                                      │     │
│    └─────────────────────────────────────────────────────────────────────────────────────┘     │
│                                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘

七、实施计划(更新版)

┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                    Implementation Plan (实施计划)                                │
├─────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                  │
│   Phase 1: 基础设施 (3天)                                                                        │
│   ├─ 1.1 Orama 向量数据库集成                                    [2天]                          │
│   └─ 1.2 Transformers.js Embedding 集成                          [1天]                          │
│                                                                                                  │
│   Phase 2: 核心功能 (4天)                                                                        │
│   ├─ 2.1 Document Chunker 文档分块器                             [1天]                          │
│   ├─ 2.2 Session Manager 会话管理器                              [1天]                          │
│   ├─ 2.3 Retrieval Chain 检索链                                  [1天]                          │
│   └─ 2.4 Context Budget Manager 上下文预算                       [1天]                          │
│                                                                                                  │
│   Phase 3: MCP 集成 (3天)                                                                        │
│   ├─ 3.1 扩展 workflow_activate (内置 RAG)                       [1天]                          │
│   ├─ 3.2 新增 rag_status / rag_inspect 工具                      [1天]                          │
│   └─ 3.3 扩展 expert_reinforce (自动索引)                        [1天]                          │
│                                                                                                  │
│   Phase 4: 初始化与索引 (2天)                                                                    │
│   ├─ 4.1 冷启动处理                                               [1天]                          │
│   └─ 4.2 增量索引机制                                             [1天]                          │
│                                                                                                  │
│   Phase 5: 可视化 (3天)                                                                          │
│   ├─ 5.1 metadata.json 自动生成                                   [0.5天]                        │
│   ├─ 5.2 CLI 工具 (cursor-rag status/inspect)                    [1天]                          │
│   └─ 5.3 Web Dashboard 基础版                                     [1.5天]                        │
│                                                                                                  │
│   Phase 6: 测试与优化 (2天)                                                                      │
│   ├─ 6.1 集成测试                                                 [1天]                          │
│   └─ 6.2 性能优化                                                 [1天]                          │
│                                                                                                  │
│   ───────────────────────────────────────────────────────────────────────────                   │
│   总计: 17 个工作日                                                                              │
│                                                                                                  │
│   里程碑:                                                                                        │
│   ├─ M1 (Phase 1-2): 核心 RAG 引擎可用                           [7天]                          │
│   ├─ M2 (Phase 3-4): MCP 集成完成,可在 Cursor 中使用             [12天]                         │
│   └─ M3 (Phase 5-6): 可视化和优化完成                             [17天]                         │
│                                                                                                  │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘

八、总结

本架构设计了一个完整的 Expert RAG Workflow System,包含:

模块 功能 技术选型
存储层 向量存储 + 持久化 Orama + JSON
Embedding 层 文本向量化 Transformers.js
核心层 专家管理 + RAG 检索 + Session 管理 TypeScript
MCP 层 Cursor 集成 MCP SDK
可视化层 CLI + Web Dashboard Node.js + Vue3

核心特点:

  • 零原生依赖 - 跨平台无编译问题
  • 上下文最优 - Session 缓存 + 增量检索
  • 知识优先级 - LOCAL > REINFORCE > SKILL
  • Fallback 机制 - RAG → 关键词 → 文件 → 空
  • 可视化管理 - CLI + Web Dashboard

npm发布报错急救手册:快速解决2FA与令牌问题

npm发布报错急救手册:快速解决2FA与令牌问题

刚刚执行 npm publish 却看到 "Two-factor authentication or granular access token with bypass 2fa enabled is required" 的红色报错?别担心,5分钟内让你恢复发布能力。

快速诊断:为什么突然不能发布了?

如果你最近两天突然遇到这个报错,根本原因是:npm 在2025年12月9日永久撤销了所有“经典令牌”

以前能用的令牌现在全部失效,必须使用新的自动化令牌粒度访问令牌来发布包。


🚀 5分钟紧急解决方案

方案A:个人开发者快速恢复发布(推荐)

如果你在个人电脑上发布自己的包,这是最快的方法:

  1. 创建自动化令牌

    # 登录 npm 网站,进入令牌管理
    # 访问:https://www.npmjs.com/settings/你的用户名/tokens
    
    # 或使用 CLI(需要 npm v10+)
    npm token create --type automation --read-only false
    
  2. 复制生成的令牌字符串(形如 npm_xxxxxx

  3. 配置到本地环境

    # 一次性使用(仅本次终端会话有效)
    npm config set //registry.npmjs.org/:_authToken=你的令牌字符串
    
    # 或永久保存到项目(推荐)
    echo "//registry.npmjs.org/:_authToken=你的令牌字符串" >> .npmrc
    
  4. 立即测试发布

    npm publish --dry-run  # 先试运行
    npm publish            # 实际发布
    

方案B:团队项目配置(包管理员操作)

如果你管理一个团队包,需要为成员配置权限:

  1. 创建粒度访问令牌

    • 访问 npm 网站令牌页面
    • 点击 "Generate New Token" → 选择 "Granular Access Token"
    • 配置权限时必须勾选:"Bypass 2FA"
  2. 分配精确权限

    # 示例:创建仅能发布特定包的令牌
    npm token create --granular \
      --package "团队包名" \
      --permissions publish \
      --bypass-2fa \
      --expiry 30d  # 30天有效期
    
  3. 安全分享给团队成员

    • 通过密码管理器分享
    • 或配置到团队 CI/CD 环境变量

方案C:GitHub Actions 自动发布修复

如果你的 CI/CD 突然失败,更新工作流配置:

# .github/workflows/publish.yml
name: Publish to npm
on:
  push:
    branches: [main]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org/'
          
      - run: npm ci
      
      - name: Publish Package
        run: npm publish
        env:
          # 关键:使用新的自动化令牌
          NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}

在 GitHub 仓库设置中:

  1. 进入 Settings → Secrets and variables → Actions
  2. 更新 NPM_AUTOMATION_TOKEN 值为新创建的自动化令牌

🔍 故障排查对照表

症状 可能原因 立即解决
E401/E403 错误 令牌过期或无效 创建全新的自动化令牌
EPUBLISHFORBIDDEN 令牌权限不足 检查令牌是否有写权限
仍要求2FA验证 令牌未配置 bypass-2fa 确保使用自动化令牌或勾选 bypass-2fa
仅 CI/CD 失败 环境变量未更新 更新 CI 的 NODE_AUTH_TOKEN 密钥
所有命令都失败 npm 登录会话过期 运行 npm login 重新认证

快速检查命令:

# 1. 检查当前认证状态
npm whoami

# 2. 验证令牌权限
npm access ls-collaborators 你的包名

# 3. 检查本地配置
npm config get //registry.npmjs.org/:_authToken

# 4. 查看令牌类型(前4字符)
# npm_ = 自动化/粒度令牌 | npm_org = 组织令牌 | 其他 = 已失效

📚 理解新令牌体系(原理简介)

为什么必须改变?

旧的“经典令牌”一旦泄露就永久有效,安全隐患极大。新的令牌体系提供:

  1. 自动化令牌:CI/CD 专用,自动绕过 2FA,最长 90 天
  2. 粒度访问令牌:精细权限控制,可选 bypass-2fa
  3. 会话令牌npm login 生成,仅 2 小时有效

令牌类型对比

场景 推荐令牌类型 有效期 关键优势
本地开发 自动化令牌 90天 自动绕过2FA,一次配置长期使用
CI/CD 流水线 自动化令牌 90天 专为自动化优化,无需交互
团队协作 粒度访问令牌 自定义 精确权限控制,可 bypass-2fa
临时发布 npm login 会话 2小时 无需配置令牌,简单快捷

🛡️ 长期最佳实践

1. 令牌安全管理

# 定期列出所有令牌
npm token list

# 撤销不再需要的令牌
npm token revoke 令牌ID前10字符

# 设置日历提醒,每80天轮换令牌

2. 项目标准化配置

# 项目 .npmrc 示例
@scope:registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
always-auth=true

# .env 文件(不要提交到Git!)
NPM_TOKEN=npm_你的自动化令牌

3. 探索未来方案:可信发布

对于开源项目,考虑迁移到更安全的可信发布

  • 完全无需长期令牌
  • 通过 GitHub Actions OIDC 自动获取临时凭证
  • npm 官方推荐的新标准

总结与下一步

立即行动清单:

  1. ✅ 创建新的自动化令牌(个人)或粒度令牌(团队)
  2. ✅ 更新本地 .npmrc 或 CI/CD 环境变量
  3. ✅ 测试 npm publish --dry-run
  4. 🔄 设置令牌到期提醒(80天后)

长期策略:

  • 个人项目:使用自动化令牌 + 定期轮换
  • 团队项目:粒度访问令牌 + 权限审计
  • 开源项目:探索可信发布(Trusted Publishing)

这次变更虽然带来了短期的适配成本,但显著提升了整个 npm 生态系统的安全性。正确配置后,你的发布流程将更加健壮和安全。

本文基于 npm 2025年12月安全更新,适用于 npm v10+。如遇特殊问题,可查阅 npm 状态页面 获取最新信息。

【已解决】uni-textarea 无法绑定 v-model / 数据不回显?换原生 textarea 一招搞定!

微信图片_20251212103855_623_10.jpg 在 uni-app 开发中,很多同学会遇到 <uni-textarea> 组件的坑:明明数据有值、v-model 绑定正确,但输入框就是不回显内容;甚至部分场景下 v-model 完全失效,只能输入却无法双向绑定。

试过重置样式、延迟渲染、更新 uni-ui 版本都没用?别折腾了!直接改用 uni-app 原生 <textarea> 组件,兼容性拉满,绑定和样式都能完全自定义。

问题复现

使用 <uni-textarea> 时,明明 value 有值、页面能打印出绑定的变量,但输入框内就是空白

<!-- 失效的 uni-textarea 写法 --> 
<uni-textarea v-model="content" placeholder="需求说明"></uni-textarea>
<text>数据存在:{{ content }}</text> <!-- 能打印,但输入框无内容 -->

解决方案:改用原生 textarea

直接替换为 uni-app 原生 <textarea>,样式完全自定义,v-model 绑定 100% 生效:

<template>
  <view class="textarea-container">
    <!-- 原生 textarea 替代 uni-textarea -->
    <textarea v-model="content" placeholder="请输入内容" class="native-textarea"
   adjust-position="true" maxlength="-1"></textarea>
    <!-- 验证数据绑定 -->
    <text class="verify-text">当前值:{{ content }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      // 支持大文本、换行、特殊字符,绑定即回显
      content: `需求文本`
    };
  }
};
</script>

<style scoped>
/* 容器:确保输入框有足够空间 */
.textarea-container {
  padding: 20rpx;
  box-sizing: border-box;
}

/* 原生 textarea 样式自定义 */
.native-textarea {
width: 70%;
height: 118rpx !important;
font-size: 20rpx;
border-radius: 8rpx;
resize: none;
box-sizing: border-box;
white-space: pre-wrap;
pointer-events: auto;
user-select: text;
position: relative;
z-index: 1;
}

/* 验证文本样式(可选) */
.verify-text {
  display: block;
  margin-top: 20rpx;
  font-size: 24rpx;
  color: #666;
}
</style>

核心优势

  1. 绑定 100% 生效:原生 <textarea> 完全兼容 uni-app 的 v-model 双向绑定,数据一改输入框立刻回显;
  2. 样式完全可控:摆脱 uni-textarea 内置样式的束缚,宽高、行高、边框等想怎么改就怎么改;
  3. 多端适配:兼容微信小程序、H5、App 等所有 uni-app 支持的端,无兼容性问题;
  4. 支持特殊格式white-space: pre-wrap 保留文本原有换行,大文本、特殊字符都能正常显示。

补充说明

1. 为什么 uni-textarea 会失效?

  • uni-ui 封装的 <uni-textarea> 底层用了绝对定位、多层嵌套,容易导致样式覆盖或渲染延迟;
  • 部分版本的 uni-ui 存在 v-model 语法糖兼容问题,即使数据绑定成功也无法渲染。

2. 原生 textarea 核心属性说明(解决赋值后无法编辑)

属性 作用
adjust-position 仅小程序端生效,软键盘弹出时自动调整输入框位置,修复赋值后聚焦失效、无法编辑的问题;
maxlength="-1" 解除小程序端默认 140 字长度限制,避免赋值内容超限时触发绑定校验异常,导致无法编辑;
pointer-events: auto 确保输入框可点击,避免全局样式污染导致的 “假不可编辑”;
user-select: text 允许文本选择,避免全局样式禁止选中文本导致的无法编辑;

3. 原生 textarea 进阶用法

(1)自定义 placeholder 样式

/* 自定义 placeholder 颜色 */
.native-textarea::placeholder {
  color: #999;
  font-size: 26rpx;
}

(2)限制输入长度

<textarea
  v-model="content"
  placeholder="需求说明"
  class="native-textarea"
  maxlength="2000" <!-- 限制最大输入长度,替换 -1 即可 -->
></textarea>

(3)Vue2 动态新增属性赋值(避免响应式丢失)

若绑定变量是对象属性(如 obj.content),新增属性时需用 Vue.set

import Vue from 'vue';
methods: {
  setObjContent() {
    // 给对象动态新增属性,保留响应式
    Vue.set(this.obj, 'content', '赋值内容');
  }
}

总结

如果你的 <uni-textarea> 遇到 v-model 绑定失效、数据不回显、样式无法修改,或改用原生 <textarea> 后赋值无法编辑等问题,按本文方案:

  1. 替换为原生 <textarea> 并添加 adjust-position="true" 和 maxlength="-1"
  2. 确保绑定变量提前初始化、赋值为字符串类型;
  3. 避免覆盖响应式对象、禁用全局样式干扰。

无需纠结 uni-ui 组件的兼容问题,简单直接又稳定!

vite+ts+monorepo从0搭建vue3组件库(五):vite打包组件库

打包配置

vite 专门提供了库模式的打包方式,配置其实非常简单,首先全局安装 vite 以及@vitejs/plugin-vue

   pnpm add vite @vitejs/plugin-vue -D -w

在components下新建vite.config.ts。我们需要让打包后的结构和我们开发的结构一致,如下配置我们将打包后的文件放入dlx-ui 目录下,因为后续发布组件库的名字就是 dlx-ui,当然这个命名大家可以随意.具体代码在下方

然后在 components/package.json 添加打包命令scripts

 "scripts": {
    "build": "vite build"
  },

声明文件

到这里其实打包的组件库只能给 js 项目使用,在 ts 项目下运行会出现一些错误,而且使用的时候还会失去代码提示功能,这样的话我们就失去了用 ts 开发组件库的意义了。所以我们需要在打包的库里加入声明文件(.d.ts)。

全局安装vite-plugin-dts

pnpm add vite-plugin-dts -D -w

在vite.config.ts中引入,完整的配置文件如下:

// components/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
  plugins: [
    vue(),
    dts({
      entryRoot: './src',
      outDir: ['../dlx-ui/es/src', '../dlx-ui/lib/src'],
      //指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json
      tsconfigPath: '../../tsconfig.json',
    }),
  ],
  build: {
    //打包文件目录
    outDir: 'es',
    emptyOutDir: true,
    //压缩
    //minify: false,
    rollupOptions: {
      //忽略打包vue文件
      external: ['vue'],
      input: ['index.ts'],
      output: [
        {
          //打包格式
          format: 'es',
          //打包后文件名
          entryFileNames: '[name].mjs',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/es',
        },
        {
          //打包格式
          format: 'cjs',
          //打包后文件名
          entryFileNames: '[name].js',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/lib',
        },
      ],
    },
    lib: {
      entry: './index.ts',
    },
  },
})

执行pnpm run build打包,出现了我们需要的声明的文件

image.png

可以看到打包时打包了2种模式,一种是es模式,一种是cjs模式,当用户引入组件库时使用哪种呢?我们可以修改/components/package.json的代码:

  • main: 指向 lib/index.js,这是 CommonJS 模块的入口文件。Node.js 环境和不支持 ES 模块的工具会使用这个文件。
  • module: 指向 es/index.mjs,这是 ES 模块的入口文件。现代前端工具(如 Vite)会优先使用这个文件。
  "main": "lib/index.js", // CommonJS 入口文件
  "module": "es/index.mjs", // ES 模块入口文件

但是此时的所有样式文件还是会统一打包到 style.css 中,还是不能进行样式的按需加载,所以接下来我们将让 vite 不打包样式文件,样式文件后续单独进行打包。后面我们要做的则是让样式文件也支持按需引入,敬请期待。

vite+ts+monorepo从0搭建vue3组件库(四):button组件开发

组件属性

button组件接收以下属性

  • type 类型
  • size 尺寸
  • plain 朴素按钮
  • round 圆角按钮
  • circle 圆形按钮
  • loading 加载
  • disabled禁用
  • text 文字

button组件全部代码如下:

// button.vue
<template>
  <button
    class="dlx-button"
    :class="[
      buttonSize ? `dlx-button--${buttonSize}` : '',
      buttonType ? `dlx-button--${buttonType}` : '',
      {
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle,
        'is-disabled': disabled,
        'is-loading': loading,
        'is-text': text,
        'is-link': link,
      },
    ]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <span v-if="loading" class="dlx-button__loading">
      <span class="dlx-button__loading-spinner"></span>
    </span>
    <span class="dlx-button__content">
      <slot></slot>
    </span>
  </button>
</template>

<script lang="ts" setup>
import { computed } from 'vue'

defineOptions({
  name: 'DlxButton',
})

const props = defineProps({
  // 按钮类型
  type: {
    type: String,
    values: ['primary', 'success', 'warning', 'danger', 'info'],
    default: '',
  },
  // 按钮尺寸
  size: {
    type: String,
    values: ['large', 'small'],
    default: '',
  },
  // 是否为朴素按钮
  plain: {
    type: Boolean,
    default: false,
  },
  // 是否为圆角按钮
  round: {
    type: Boolean,
    default: false,
  },
  // 是否为圆形按钮
  circle: {
    type: Boolean,
    default: false,
  },
  // 是否为加载中状态
  loading: {
    type: Boolean,
    default: false,
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false,
  },
  // 是否为文字按钮
  text: {
    type: Boolean,
    default: false,
  },
  // 是否为链接按钮
  link: {
    type: Boolean,
    default: false,
  },
})

const buttonSize = computed(() => props.size)
const buttonType = computed(() => props.type)

const handleClick = (evt: MouseEvent) => {
  if (props.disabled || props.loading) return
  emit('click', evt)
}

const emit = defineEmits(['click'])
</script>

<style lang="less" scoped>
.dlx-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  line-height: 1;
  height: 32px;
  white-space: nowrap;
  cursor: pointer;
  color: #606266;
  text-align: center;
  box-sizing: border-box;
  outline: none;
  transition: 0.1s;
  font-weight: 500;
  padding: 8px 15px;
  font-size: 14px;
  border-radius: 4px;
  background-color: #fff;
  border: 1px solid #dcdfe6;

  &:hover,
  &:focus {
    color: #409eff;
    border-color: #c6e2ff;
    background-color: #ecf5ff;
  }

  &:active {
    color: #3a8ee6;
    border-color: #3a8ee6;
    outline: none;
  }

  // 主要按钮
  &--primary {
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;

    &:hover,
    &:focus {
      background: #66b1ff;
      border-color: #66b1ff;
      color: #fff;
    }

    &:active {
      background: #3a8ee6;
      border-color: #3a8ee6;
      color: #fff;
    }
  }

  // 成功按钮
  &--success {
    color: #fff;
    background-color: #67c23a;
    border-color: #67c23a;

    &:hover,
    &:focus {
      background: #85ce61;
      border-color: #85ce61;
      color: #fff;
    }

    &:active {
      background: #5daf34;
      border-color: #5daf34;
      color: #fff;
    }
  }

  // 警告按钮
  &--warning {
    color: #fff;
    background-color: #e6a23c;
    border-color: #e6a23c;

    &:hover,
    &:focus {
      background: #ebb563;
      border-color: #ebb563;
      color: #fff;
    }

    &:active {
      background: #cf9236;
      border-color: #cf9236;
      color: #fff;
    }
  }

  // 危险按钮
  &--danger {
    color: #fff;
    background-color: #f56c6c;
    border-color: #f56c6c;

    &:hover,
    &:focus {
      background: #f78989;
      border-color: #f78989;
      color: #fff;
    }

    &:active {
      background: #dd6161;
      border-color: #dd6161;
      color: #fff;
    }
  }

  // 信息按钮
  &--info {
    color: #fff;
    background-color: #909399;
    border-color: #909399;

    &:hover,
    &:focus {
      background: #a6a9ad;
      border-color: #a6a9ad;
      color: #fff;
    }

    &:active {
      background: #82848a;
      border-color: #82848a;
      color: #fff;
    }
  }

  // 大尺寸
  &--large {
    height: 40px;
    padding: 12px 19px;
    font-size: 14px;
    border-radius: 4px;
  }

  // 小尺寸
  &--small {
    height: 24px;
    padding: 5px 11px;
    font-size: 12px;
    border-radius: 3px;
  }

  // 朴素按钮
  &.is-plain {
    background: #fff;

    // 不同类型按钮的默认状态
    &.dlx-button--primary {
      color: #409eff;
      border-color: #409eff;
    }

    &.dlx-button--success {
      color: #67c23a;
      border-color: #67c23a;
    }

    &.dlx-button--warning {
      color: #e6a23c;
      border-color: #e6a23c;
    }

    &.dlx-button--danger {
      color: #f56c6c;
      border-color: #f56c6c;
    }

    &.dlx-button--info {
      color: #909399;
      border-color: #909399;
    }

    &:hover,
    &:focus {
      background: #ecf5ff;
      border-color: #409eff;
      color: #409eff;
    }

    &:active {
      background: #ecf5ff;
      border-color: #3a8ee6;
      color: #3a8ee6;
    }

    // 为不同类型的朴素按钮添加对应的悬浮状态
    &.dlx-button--primary {
      &:hover,
      &:focus {
        background: #ecf5ff;
        border-color: #409eff;
        color: #409eff;
      }
      &:active {
        border-color: #3a8ee6;
        color: #3a8ee6;
      }
    }

    &.dlx-button--success {
      &:hover,
      &:focus {
        background: #f0f9eb;
        border-color: #67c23a;
        color: #67c23a;
      }
      &:active {
        border-color: #5daf34;
        color: #5daf34;
      }
    }

    &.dlx-button--warning {
      &:hover,
      &:focus {
        background: #fdf6ec;
        border-color: #e6a23c;
        color: #e6a23c;
      }
      &:active {
        border-color: #cf9236;
        color: #cf9236;
      }
    }

    &.dlx-button--danger {
      &:hover,
      &:focus {
        background: #fef0f0;
        border-color: #f56c6c;
        color: #f56c6c;
      }
      &:active {
        border-color: #dd6161;
        color: #dd6161;
      }
    }

    &.dlx-button--info {
      &:hover,
      &:focus {
        background: #f4f4f5;
        border-color: #909399;
        color: #909399;
      }
      &:active {
        border-color: #82848a;
        color: #82848a;
      }
    }
  }

  // 圆角按钮
  &.is-round {
    border-radius: 20px;
  }

  // 圆形按钮
  &.is-circle {
    border-radius: 50%;
    padding: 8px;
  }

  // 文字按钮
  &.is-text {
    border-color: transparent;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:not(.is-disabled) {
      // 默认文字按钮
      color: #409eff;

      &:hover,
      &:focus {
        color: #66b1ff;
        background-color: transparent;
        border-color: transparent;
      }

      &:active {
        color: #3a8ee6;
      }

      // 不同类型的文字按钮颜色
      &.dlx-button--primary {
        color: #409eff;
        &:hover,
        &:focus {
          color: #66b1ff;
        }
        &:active {
          color: #3a8ee6;
        }
      }

      &.dlx-button--success {
        color: #67c23a;
        &:hover,
        &:focus {
          color: #85ce61;
        }
        &:active {
          color: #5daf34;
        }
      }

      &.dlx-button--warning {
        color: #e6a23c;
        &:hover,
        &:focus {
          color: #ebb563;
        }
        &:active {
          color: #cf9236;
        }
      }

      &.dlx-button--danger {
        color: #f56c6c;
        &:hover,
        &:focus {
          color: #f78989;
        }
        &:active {
          color: #dd6161;
        }
      }

      &.dlx-button--info {
        color: #909399;
        &:hover,
        &:focus {
          color: #a6a9ad;
        }
        &:active {
          color: #82848a;
        }
      }
    }

    // 文字按钮的禁用状态
    &.is-disabled {
      color: #c0c4cc;
    }
  }

  // 链接按钮
  &.is-link {
    border-color: transparent;
    color: #409eff;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:hover,
    &:focus {
      color: #66b1ff;
    }

    &:active {
      color: #3a8ee6;
    }
  }

  // 禁用状态
  &.is-disabled {
    &,
    &:hover,
    &:focus,
    &:active {
      cursor: not-allowed;

      // 普通按钮的禁用样式
      &:not(.is-text):not(.is-link) {
        background-color: #fff;
        border-color: #dcdfe6;
        color: #c0c4cc;

        // 有颜色的按钮的禁用样式
        &.dlx-button--primary {
          background-color: #a0cfff;
          border-color: #a0cfff;
          color: #fff;
        }

        &.dlx-button--success {
          background-color: #b3e19d;
          border-color: #b3e19d;
          color: #fff;
        }

        &.dlx-button--warning {
          background-color: #f3d19e;
          border-color: #f3d19e;
          color: #fff;
        }

        &.dlx-button--danger {
          background-color: #fab6b6;
          border-color: #fab6b6;
          color: #fff;
        }

        &.dlx-button--info {
          background-color: #c8c9cc;
          border-color: #c8c9cc;
          color: #fff;
        }
      }
    }
  }

  // 有颜色的按钮禁用状态 - 直接选择器
  &.is-disabled.dlx-button--primary {
    background-color: #a0cfff;
    border-color: #a0cfff;
    color: #fff;
  }

  &.is-disabled.dlx-button--success {
    background-color: #b3e19d;
    border-color: #b3e19d;
    color: #fff;
  }

  &.is-disabled.dlx-button--warning {
    background-color: #f3d19e;
    border-color: #f3d19e;
    color: #fff;
  }

  &.is-disabled.dlx-button--danger {
    background-color: #fab6b6;
    border-color: #fab6b6;
    color: #fff;
  }

  &.is-disabled.dlx-button--info {
    background-color: #c8c9cc;
    border-color: #c8c9cc;
    color: #fff;
  }

  // 文字按钮禁用状态
  &.is-disabled.is-text {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 链接按钮禁用状态
  &.is-disabled.is-link {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 加载状态
  &.is-loading {
    position: relative;
    pointer-events: none;

    &:before {
      pointer-events: none;
      content: '';
      position: absolute;
      left: -1px;
      top: -1px;
      right: -1px;
      bottom: -1px;
      border-radius: inherit;
      background-color: rgba(255, 255, 255, 0.35);
    }
  }

  .dlx-button__loading {
    display: inline-flex;
    align-items: center;
    margin-right: 4px;
  }

  .dlx-button__loading-spinner {
    display: inline-block;
    width: 14px;
    height: 14px;
    border: 2px solid #fff;
    border-radius: 50%;
    border-top-color: transparent;
    animation: button-loading 1s infinite linear;
  }
}

@keyframes button-loading {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

引用

在play的src下新建example,存放各个组件的代码,先在play下安装vue-router

pnpm i vue-router

目录结构如下

image.png

app.vue如下:

<template>
  <div class="app-container">
    <div class="sidebar">
      <h2 class="sidebar-title">组件列表</h2>
      <ul class="menu-list">
        <li
          v-for="item in menuItems"
          :key="item.path"
          :class="{ active: currentPath === item.path }"
          @click="handleMenuClick(item.path)"
        >
          {{ item.name }}
        </li>
      </ul>
    </div>
    <div class="content">
      <router-view></router-view>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const currentPath = ref('/button')

const menuItems = [
  { name: 'Button 按钮', path: '/button' },
  // 后续添加其他组件...
]

const handleMenuClick = (path: string) => {
  currentPath.value = path
  router.push(path)
}
</script>

<style scoped>
.app-container {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 240px;
  background-color: #f5f7fa;
  border-right: 1px solid #e4e7ed;
  padding: 20px 0;
}

.sidebar-title {
  padding: 0 20px;
  margin: 0 0 20px;
  font-size: 18px;
  color: #303133;
}

.menu-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.menu-list li {
  padding: 12px 20px;
  cursor: pointer;
  color: #303133;
  font-size: 14px;
  transition: all 0.3s;
}

.menu-list li:hover {
  color: #409eff;
  background-color: #ecf5ff;
}

.menu-list li.active {
  color: #409eff;
  background-color: #ecf5ff;
}

.content {
  flex: 1;
  padding: 20px;
}
</style>

router/index.ts如下:

import { createRouter, createWebHistory } from 'vue-router'
import ButtonExample from '../example/button.vue'

const routes = [
  {
    path: '/',
    redirect: '/button',
  },
  {
    path: '/button',
    component: ButtonExample,
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

play下执行pnpm run dev

运行效果:

image.png

vite+ts+monorepo从0搭建vue3组件库(三):开发一个组件

1.在packages下新建components和utils文件夹,分别执行pnpm init,并将他们的包名改为@dlx-ui/components@dlx-ui/utils,目录结构如下:

组件目录

image.png

组件编写

button.vue

<!-- button组件 -->

<template>
  <button class="button" :class="typeClass" @click="handleClick">
    测试按钮
    <slot></slot>
  </button>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  type: {
    type: String,
    default: 'default',
  },
})

const typeClass = ref('')

const handleClick = () => {
  console.log('click')
}
</script>

<style lang="less" scoped>
.button {
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  background: #fff;
  border: 1px solid #dcdfe6;
}
</style>

然后在button/index.ts将其导出

import Button from './button'

export { Button }

export default Button

因为我们后面会有很多组件的,比如 Icon,Upload,Select 等,所以我们需要在components/src/index.ts集中导出所有组件

// components/src/index.ts
export * from './button'

最后在components下的index.ts中,导出所有组件,供其他页面使用

export * from './src/index'

局部引用组件

在play项目中,安装@dlx-ui/components,并且在app.vue中使用

在play目录下执行pnpm add @dlx-ui/components

然后在app.vue中引入button

<template>
  <Button>按钮</Button>
</template>

<script setup lang="ts">
import { Button } from '@dlx-ui/components'
</script>

<style scoped>

</style>

image.png

全局挂载组件

有的时候我们使用组件的时候想要直直接使用 app.use()挂载整个组件库,其实使用 app.use()的时候它会调用传入参数的 install 方法,因此首先我们给每个组件添加一个 install 方法,然后再导出整个组件库,我们将 button/index.ts 改为

import _Button from './button.vue'

import type { App, Plugin } from "vue";
type SFCWithInstall<T> = T & Plugin;
const withInstall = <T>(comp: T) => {
  (comp as SFCWithInstall<T>).install = (app: App) => {
    const name = (comp as any).name;
    //注册组件
    app.component(name, comp as SFCWithInstall<T>);
  };
  return comp as SFCWithInstall<T>;
};
export const Button = withInstall(_Button);
export default Button;


components/index.ts修改为

import * as components from "./src/index";
export * from "./src/index";
import { App } from "vue";

export default {
  install: (app: App) => {
    for (let c in components) {
      app.use(components[c]);
    }
  },
};

组件命名

此时我们需要给button.vue一个name:dlx-button好在全局挂载的时候作为组件名使用 在setup语法糖中使用defineOptions

defineOptions({
  name: 'dlx-button',
})

main.ts全局挂载组件库

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import dlxui from '@dlx-ui/components'
const app = createApp(App)
app.use(dlxui)

createApp(App).mount('#app')

在app.vue中引入

<template>
  <dlx-button>全局挂载的按钮</dlx-button>
</template>

<script setup lang="ts"></script>

image.png

vite+ts+monorepo从0搭建vue3组件库(二):项目搭建

安装依赖

在根目录下安装vue和ts 和 less

pnpm的-w 表示在根目录下安装

  pnpm add vue@next typescript less -D -w

初始化ts

跟目录执行 npx tsc --init,生成tsconfig.json,对其做一个更改如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "jsx": "preserve",
    "strict": true,
    "target": "ES2015",
    "module": "ESNext",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "lib": ["esnext", "dom"],
    "types": ["vite/client"]
  }
}

搭建一个基于 vite 的 vue3 项目

创建一个vue3项目,在跟目录下执行以下命令:就创建了play文件夹,一个基于vue+ts+vite的vue3项目

pnpm create vite play --template vue-ts

因为 play 项目需要测试本地的组件库,所以也需要将 play 和我们的组件库关联在一起。修改一下pnpm-workspace.yaml文件

packages:
  - "packages/**"
  - "play"

此时 play 项目便可以安装本地 packages 下的包了

在play下执行pnpm run dev 就能运行play项目了,运行结果:

image.png

我们在根目录运行 play项目里面的dev 脚本

可以使用pnpm -F play dev 指定运行子目录里面的script中的脚本

这个是 pnpm 的能力。

按钮太多了?基于ResizeObserver优雅显示

引言:列表操作按钮的困境

在日常开发中,我们经常遇到这样的场景:数据表格的每一行都有多个操作按钮,如编辑、删除、查看、审核等。随着业务复杂度增加,按钮数量可能达到5个、8个甚至更多。这带来两个核心问题:

  1. 空间冲突:在小屏幕或内容密集的列中,按钮会溢出容器边界
  2. 视觉混乱:过多的按钮挤在一起,用户难以快速定位所需操作

传统解决方案通常是固定显示几个核心按钮,其余收起到下拉菜单。但这种方式不够智能:无论容器多宽,都显示固定数量的按钮,无法充分利用可用空间。

本文将介绍一种基于ResizeObserver的动态按钮显示方案,它能根据容器宽度智能决定显示哪些按钮,既优雅又高效。

核心思路:响应式按钮布局

我们的目标是创建一个自适应组件,它能够:

  1. 实时监测容器宽度变化
  2. 根据可用空间动态计算可以显示多少个按钮
  3. 将超出空间的按钮自动收起到"更多"下拉菜单
  4. 在空间充足时,尽可能多地显示按钮

关键技术:ResizeObserver API

ResizeObserver是现代浏览器提供的API,用于监听元素尺寸变化。相比传统的window.resize事件,它有两大优势:

javascript

// 传统方式:监听整个窗口
window.addEventListener('resize', () => {
  // 性能较差,触发频繁
});

// ResizeObserver:监听特定元素
const observer = new ResizeObserver((entries) => {
  for (let entry of entries) {
    const { width, height } = entry.contentRect;
    // 精确控制,性能更优
  }
});
observer.observe(element);

ResizeObserver只会监听我们关注的元素,并且提供了更精确的尺寸信息,是响应式布局的理想工具。

组件实现详解

1. 组件接口设计

首先定义清晰的组件接口,这是良好组件的基石:

typescript

interface Button {
  // 按钮文本,支持动态函数
  label: string | ((row: any) => string)
  // 动态属性,如type、size等
  otherProps?: any
  // 图标,支持动态函数
  icon?: string | ((row: any) => string)
  // 额外CSS类
  class?: string
  // 可见性判断函数
  visibleHandler?: (row: any, index?: number) => boolean
  // 禁用状态判断函数
  disabledHandler?: (row: any, index?: number) => boolean
  // 点击处理函数
  handler: (row: any, index?: number) => void
}

这种设计支持高度动态化的按钮配置,每个按钮都可以根据行数据决定其显示、禁用状态和属性。

2. 智能宽度计算

核心逻辑在于如何准确估算每个按钮的宽度:

typescript

const estimateButtonWidth = (button) => {
  // 图标按钮:图标宽度 + 内边距
  if (button.icon) return 16 + 8
  
  // 文本按钮:字符数 × 单个字符宽度 + 内边距
  const text = typeof button.label === 'function' 
    ? button.label(props.data) 
    : button.label
  return text.length * 14 + 12
}

这个估算公式是关键,需要根据实际设计调整乘数和常数。更精确的方法是使用Canvas的measureText方法,但会增加复杂度。

3. 动态布局算法

typescript

const handleResize = () => {
  const containerWidth = containerRef.value?.clientWidth
  const buttonWidths = buttons.value.map(estimateButtonWidth)
  let totalWidth = 0
  let maxVisibleButtons = 0

  // 计算最多能显示多少个按钮
  for (let i = 0; i < buttonWidths.length; i++) {
    totalWidth += buttonWidths[i]
    
    // 考虑按钮间距(如果有的话)
    const spacing = i === buttonWidths.length - 1 ? 0 : 30
    
    if (totalWidth + spacing <= containerWidth) {
      maxVisibleButtons = i + 1
    } else {
      break
    }
  }

  // 优化:如果隐藏按钮只有一个,且空间几乎足够,就显示它
  if (
    maxVisibleButtons < buttons.value.length &&
    state.hiddenButtons.length === 1 &&
    totalWidth - 8 <= containerWidth
  ) {
    maxVisibleButtons += 1
  }

  // 更新按钮分组
  state.visibleButtons = buttons.value.slice(0, maxVisibleButtons)
  state.hiddenButtons = buttons.value.slice(maxVisibleButtons)
}

算法考虑了按钮间距,并包含了一个优化逻辑:当只有一个按钮被隐藏且空间接近足够时,稍微调整布局以显示它。

4. 生命周期管理

typescript

let resizeObserver

onMounted(() => {
  handleResize() // 初始计算
  resizeObserver = new ResizeObserver(handleResize)
  resizeObserver.observe(containerRef.value)
})

onBeforeUnmount(() => {
  resizeObserver?.unobserve(containerRef.value)
})

确保在组件销毁时清理观察器,避免内存泄漏。

使用示例

vue

<template>
  <a-table :data-source="data">
    <a-table-column title="操作" width="200">
      <template #default="{ record, index }">
        <MoreButton 
          :data="record" 
          :data-index="index"
          :button-list="getButtons(record)"
        />
      </template>
    </a-table-column>
  </a-table>
</template>

<script setup>
import MoreButton from './MoreButton.vue'

const getButtons = (record) => [
  {
    label: '编辑',
    handler: () => editItem(record),
    visibleHandler: () => record.status !== 'deleted'
  },
  {
    label: '删除',
    handler: () => deleteItem(record),
    disabledHandler: () => record.status === 'processing'
  },
  {
    label: '查看详情',
    icon: 'icp-chakanxiangqing',
    handler: () => viewDetail(record)
  },
  {
    label: '审核',
    otherProps: (row) => ({
      type: row.needAudit ? 'primary' : 'default'
    }),
    handler: () => auditItem(record)
  },
  // 更多按钮...
]
</script>

性能优化建议

1. 防抖处理

typescript

import { debounce } from 'lodash-es'

const handleResize = debounce(() => {
  // 计算逻辑
}, 100)

2. 缓存计算结果

typescript

const widthCache = new Map()

const estimateButtonWidth = (button) => {
  const cacheKey = JSON.stringify(button)
  if (widthCache.has(cacheKey)) {
    return widthCache.get(cacheKey)
  }
  
  // 计算宽度
  const width = button.icon ? 24 : getlabel(button.label).length * 14 + 12
  widthCache.set(cacheKey, width)
  return width
}

3. 虚拟化支持

对于超长列表,可以结合表格虚拟化技术,只渲染可视区域内的行。

对比传统方案

方案 优点 缺点
固定数量 实现简单 无法自适应,空间浪费
CSS媒体查询 响应式 断点固定,不够灵活
ResizeObserver方案 完全自适应,精准控制 实现较复杂,需要考虑性能

扩展思考

1. 支持优先级

可以为按钮添加优先级属性,确保重要操作始终可见:

typescript

interface Button {
  priority?: number // 1-10,数值越小优先级越高
}

2. 动画过渡

添加按钮显示/隐藏的过渡动画,提升用户体验:

css

.dynamic-button {
  transition: opacity 0.3s ease, transform 0.3s ease;
}

3. 移动端适配

在移动端可以切换到垂直布局或操作面板:

javascript

const isMobile = computed(() => window.innerWidth < 768)

总结

基于ResizeObserver的动态按钮显示方案,通过智能的空间计算和响应式布局,解决了按钮过多时的显示问题。这种方案的优势在于:

  1. 完全自适应:根据实际可用空间动态调整
  2. 用户友好:尽可能多地显示直接操作,减少点击次数
  3. 维护性好:组件化设计,便于复用和扩展
  4. 性能可控:精确监听,避免不必要的重排重绘

在实际项目中,这种组件的应用可以显著提升表格操作区的用户体验,特别是在复杂的管理后台系统中。它体现了现代前端开发的核心思想:以用户为中心,通过技术手段创造更智能、更优雅的交互体验。

最佳实践提示:对于数据量大的表格,建议结合虚拟滚动技术使用,避免ResizeObserver观察过多元素导致的性能问题。

Vue中级冒险:3-4周成为组件沟通大师 🚀

欢迎使用我的小程序👇👇👇👇

small.png


欢迎回到你的Vue学习之旅!如果你已经跨过了基础门槛,那么接下来的3-4周将带你进入一个全新的世界——在这里,组件不再孤立,数据流动如交响乐般和谐,代码组织变得优雅而强大。

📅 第一周:与“时间魔法师”——生命周期成为好友

想象一下,每个Vue组件都像一个小生命,有自己的出生、成长和告别时刻。生命周期钩子就是这些关键时刻的提醒铃铛🔔。

// 以前你可能只认识created和mounted
// 现在来认识整个生命周期家族吧!
export default {
  beforeCreate() { console.log('我即将诞生!') },
  created() { console.log('我出生了!可以访问数据啦') },
  beforeMount() { console.log('准备挂载到DOM树上...') },
  mounted() { console.log('成功安家!现在可以操作DOM了') },
  beforeUpdate() { console.log('数据变了,我要准备更新啦') },
  updated() { console.log('更新完成!界面焕然一新') },
  beforeUnmount() { console.log('我即将离开这个世界...') },
  unmounted() { console.log('再见!清理工作完成') }
}

本周小挑战:写一个组件,在它生命的每个阶段都在控制台留下足迹👣,观察数据变化和DOM更新时的触发顺序。

📅 第二周:掌握组件间的“悄悄话”艺术

组件不会读心术,但它们有6种方式可以交流!让我们把它们想象成住在不同房间的室友:

1. Props:妈妈喊你吃饭式(父→子)

// 爸爸组件大喊:
<ChildComponent :dinner="'红烧肉'" />

// 孩子组件乖乖接收:
props: ['dinner']

2. $emit:孩子有事报告式(子→父)

// 孩子在房间里喊:
this.$emit('hungry', '想吃零食')

// 爸爸在外面监听:
<ChildComponent @hungry="handleHungry" />

3. Refs:直接敲门式

// 获取组件实例,直接调用方法
<ChildComponent ref="child" />
this.$refs.child.doSomething()

4. Event Bus:小区广播式(任意组件间)

// 创建一个中央事件总线
// 组件A:广播消息
eventBus.emit('news', '今天小区停水')

// 组件B:收听广播
eventBus.on('news', (msg) => {
  console.log(msg) // 今天小区停水
})

5. Provide/Inject:家族秘密传承式(跨层级)

// 爷爷辈组件:
provide() {
  return { familySecret: '传家宝的位置' }
}

// 孙子辈组件(跳过爸爸直接获取):
inject: ['familySecret']

6. Vuex/Pinia:社区公告栏式(全局状态)

// 任何组件都可以:
store.commit('setMessage', '社区通知:明天停电')

// 任何组件也都能看到:
store.state.message

本周实践:创建一个“家庭聊天室”应用,使用至少4种通信方式让祖孙三代的组件互相传递消息!

📅 第三、四周:解锁组合式API的“积木魔法”

还记得小时候搭积木的乐趣吗?组合式API让你重新体验这种快乐!

选项式API vs 组合式API

// 以前(选项式) - 像整理抽屉
export default {
  data() { return { count: 0 } },
  methods: { increment() { this.count++ } },
  computed: { doubleCount() { return this.count * 2 } }
}

// 现在(组合式) - 像搭积木
import { ref, computed } from 'vue'

export default {
  setup() {
    // 逻辑1:计数器
    const count = ref(0)
    const increment = () => { count.value++ }
    const doubleCount = computed(() => count.value * 2)
    
    // 逻辑2:用户信息
    const user = ref(null)
    const fetchUser = async () => { /* ... */ }
    
    // 像搭积木一样组合功能
    return { count, increment, doubleCount, user, fetchUser }
  }
}

超能力:自定义组合函数

// 创建一个可复用的“鼠标跟踪器”积木
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  const update = (event) => {
    x.value = event.pageX
    y.value = event.pageY
  }
  
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
  
  return { x, y }
}

// 在任何组件中轻松使用:
const { x, y } = useMouse()
// 看!鼠标坐标自动跟踪!

响应式进阶:reactive vs ref

// ref - 给单个值加响应式外衣
const count = ref(0) // 访问时:count.value

// reactive - 给对象加响应式外衣
const state = reactive({ 
  name: 'Vue', 
  version: 3 
}) // 访问时:state.name

// 小贴士:简单值用ref,复杂对象用reactive

终极挑战:用组合式API重构你之前的一个项目,把相关逻辑抽成自定义组合函数,体验“代码乐高”的快乐!

🎉 庆祝时刻:你已经成为Vue中级开发者!

经过这3-4周的冒险,你已经掌握了:

  • 生命周期管理:像时间旅行者一样掌控组件的一生
  • 6种组件通信:让组件间的对话流畅自然
  • 组合式API:用乐高式思维构建可维护的代码

现在你的Vue技能树已经枝繁叶茂🌳!这些技能不仅在面试中闪闪发光,更能让你在实际项目中游刃有余。

下一步冒险预告:高级路由管理、性能优化、服务端渲染... 但先给自己放个小假,用新技能做个有趣的小项目吧!


分享你的学习成果或遇到的问题,在评论区一起交流成长!你的3周挑战故事是什么? 💬

#Vue #前端开发 #编程学习 #JavaScript #组合式API

Vue3 组件入门:像搭乐高一样玩转前端!

欢迎使用我的小程序👇👇👇👇

small.png


你好呀!如果你刚开始学习 Vue3 组件开发,那你来对地方了!想象一下,组件就像是前端世界的乐高积木——小巧、独立、可重复使用,还能组合成酷炫的东西。让我们花 1-2 周时间,轻松掌握组件开发的三大基石!

🎯 第一周:认识你的“乐高积木”

组件基本结构:Vue 的“基因代码”

每个 Vue 组件都像一个独立的小程序,有自己的模板、逻辑和样式:

<template>
  <!-- 这里是组件的“外貌” -->
  <div class="my-component">
    <h1>{{ title }}</h1>
    <button @click="handleClick">点我!</button>
  </div>
</template>

<script setup>
// 这里是组件的“大脑”
import { ref } from 'vue'

const title = ref('你好,我是组件!')

const handleClick = () => {
  console.log('按钮被点击啦!')
}
</script>

<style scoped>
/* 这里是组件的“穿搭” */
.my-component {
  border: 2px solid #42b983;
  padding: 20px;
  border-radius: 10px;
}
</style>

💡 小贴士<script setup> 是 Vue3 的语法糖,让代码更简洁!scoped 样式确保穿搭只影响自己,不会“撞衫”。

🔄 第二周:让积木“活”起来

Props:组件的“个性定制”

就像给乐高人仔换装一样,Props 让组件可以接收外部数据:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ name }}</h2>
    <p>年龄:{{ age }}</p>
    <p v-if="isVip">⭐ VIP会员</p>
  </div>
</template>

<script setup>
// 定义组件可以接收哪些“定制参数”
const props = defineProps({
  name: {
    type: String,
    required: true  // 这个必须传!
  },
  age: {
    type: Number,
    default: 18     // 不传的话默认18岁
  },
  isVip: Boolean    // 简写形式
})
</script>

使用这个组件时:

<template>
  <UserCard name="小明" :age="20" :is-vip="true" />
  <UserCard name="小红" /> <!-- 小红自动18岁,不是VIP -->
</template>

🎭 有趣比喻:Props 就像点奶茶时的选项——甜度、冰度、加料,同一个奶茶组件,能调出千变万化的味道!

Events:组件的“悄悄话机制”

组件不能总是被动接收,有时也需要主动“说话”:

<!-- Counter.vue -->
<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="reset">归零</button>
  </div>
</template>

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

const emit = defineEmits(['count-change', 'reset-done'])

const count = ref(0)

const increment = () => {
  count.value++
  // 对外“喊话”:计数变化啦!
  emit('count-change', count.value)
}

const reset = () => {
  count.value = 0
  // 喊另一句话:重置完成啦!
  emit('reset-done')
}
</script>

父组件接收“悄悄话”:

<template>
  <Counter 
    @count-change="onCountChange"
    @reset-done="showAlert('已归零!')"
  />
</template>

<script setup>
const onCountChange = (newCount) => {
  console.log(`计数器变成${newCount}了!`)
}

const showAlert = (msg) => {
  alert(msg)
}
</script>

🔊 生动解释:Events 就像组件之间的“对讲机”。子组件按下按钮,父组件就能听到:“嘿!我这里发生事情了!”

插槽:组件的“留白艺术”

有时候,我们想在组件里留一块空地,让使用它的人自由发挥:

<!-- FancyBox.vue -->
<template>
  <div class="fancy-box">
    <div class="header">
      <slot name="header">默认标题</slot>
    </div>
    
    <div class="content">
      <!-- 匿名插槽:不写name的那个 -->
      <slot>默认内容</slot>
    </div>
    
    <div class="footer">
      <slot name="footer"></slot>
      <!-- 如果没提供footer,这里什么都不显示 -->
    </div>
  </div>
</template>

尽情发挥创意:

<template>
  <FancyBox>
    <!-- #header 是 v-slot:header 的简写 -->
    <template #header>
      <h1>🎉 我的个性化标题!</h1>
    </template>
    
    <!-- 这里是匿名插槽的内容 -->
    <p>这是放在主区域的内容...</p>
    <img src="/my-image.jpg" alt="我的图片">
    
    <template #footer>
      <button>确定</button>
      <button>取消</button>
    </template>
  </FancyBox>
</template>

🎨 精妙比喻:插槽就像相框——相框组件提供结构和样式(边框、材质),但你可以在里面放任何照片!

🚀 两周学习路线图

第一周:打好地基

  • 第1-2天:创建你的第一个组件,理解“单文件组件”概念
  • 第3-4天:玩转 Props,尝试各种类型验证
  • 第5-7天:组件通信初体验,父子组件互相“对话”

第二周:进阶组合

  • 第8-10天:掌握具名插槽和作用域插槽
  • 第11-12天:构建一个小项目(如用户卡片集)
  • 第13-14天:重构重复代码为可复用组件

💪 动手挑战!

试着创建一个 MessageBubble 组件:

  1. 通过 type prop 控制样式(成功、警告、错误)
  2. 点击气泡时发射 close 事件
  3. 使用插槽让内容可以包含任何 HTML
  4. 添加一个 icon 插槽,允许自定义图标

🌟 总结

Vue3 组件开发其实就像玩乐高:

  • 基本结构 = 积木的基础形状
  • Props = 给积木涂上不同颜色
  • Events = 积木之间的连接卡扣
  • 插槽 = 预留的特殊接口

记住,最好的学习方式就是动手去做!从今天起,试着把页面上的每个部分都想象成可复用的组件。两周后,你会惊讶地发现,自己已经能用“乐高思维”构建整个应用了!

有什么问题或有趣的组件创意吗?欢迎在评论区分享!一起在 Vue3 的世界里搭出炫酷的作品吧!✨


📅 学习进度提醒:标记你的日历,两周后回来看看自己构建了多少个酷炫组件!

从“拼字符串”到“魔法响应”:一场数据驱动页面的奇幻进化之旅

引言

你有没有想过,为什么今天的网页能像变魔术一样——点一下按钮,列表自动刷新;输个名字,头像立刻出现?而十几年前,想换个内容却要整个页面“唰”地重载?

这一切的背后,是一场关于数据如何驱动页面的技术革命。今天,我们就穿越三段代码时空,跟随一份用户列表的命运,看看 Web 开发是如何从“手搓 HTML”一步步进化到“声明即渲染”的魔法世界的。


第一章:远古时代 —— 服务端“手工编织”页面(server.js

源代码链接:[vue/ref/demo/server.js · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo/server.js · Zou/lesson_zp - 码云 - 开源中国")

时间回到 Web 初期,那时没有 Vue,没有 React,甚至连 AJAX 都还没普及。开发者们用最原始的方式:在服务器上把数据和 HTML 混在一起,直接“烤”成完整网页,再扔给浏览器

打开 server.js,你会看到这样一段代码:

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

这是一份硬编码的用户名单,就像藏在厨房抽屉里的老式通讯录。

接着,一个叫 generateUsersHtml 的函数登场了:

function generateUsersHtml(users){
    const userRows = users.map(user => `
| ${user.id}|${user.name}|${user.email}|
| ---|---|---|
`).join('');
    return `
Users
| ID|Name|Email|
| ---|---|---|
${userRows}
            
    `
}

注意!这里用的不是标准 HTML 表格,而是 Markdown 表格语法(可能是为了简化演示)。但原理惊人地朴素:用 JavaScript 字符串拼接,把数据“缝”进模板里

当用户访问 /users 时:

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html;charset=utf-8'); 
    const html = generateUsersHtml(users);
    res.end(html);
}

服务器把拼好的“成品网页”通过 res.end() 发出去。浏览器收到后,直接显示——没有请求、没有等待、没有交互,一切都在服务端完成

这就像一位老裁缝,根据你的身材(数据)现场量体裁衣(生成 HTML),然后把做好的衣服(完整页面)递给你。但如果你胖了两斤,他得重新做一件——整个页面刷新!

优点:简单、快速、SEO 友好。
缺点:交互差、前后端耦合、改一点就要重刷。


第二章:工业革命 —— 前后端“分家”,API 登场(index.html + db.json + package.json

随着网站越来越复杂,开发者发现:“让前端和后端各干各的,效率更高!”于是,前后端分离成为新潮流。

完整项目结构及链接:[vue/ref/demo2 · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo2 · Zou/lesson_zp - 码云 - 开源中国")

![](<> "点击并拖拽以移动")

项目准备

前后端分离需要进行项目初始化

第一步:对负责后端的文件夹 backend 进行初始化,在终端打开该文件夹,执行以下命令

# 初始化项目,生成 package.json(只需执行一次)
npm init -y
 
# 安装 json-server,用于基于 JSON 文件快速创建本地 REST API 服务。
npm i json-server

第二步:在前端文件夹 frontend 中添加文件 index.html ,在后端文件夹 backend 中添加 db.json 文件

第三步:修改 package.json 文件内容

将文件内的JavaScript语句修改为如下内容

  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;json-server --watch db.json&#34;
  },

这段代码定义了一个名为 dev 的 npm 脚本,作用是使用 json-server 工具监听(--watch)项目根目录下的 db.json 文件。当运行 npm run dev 时,会启动一个本地 RESTful API 服务器,自动将 db.json 中的数据暴露为 API 接口,并在文件内容变化时实时更新接口数据,常用于前端开发中的模拟后端服务。

静态骨架:index.html 的“空舞台”

看一眼 index.html

User List
Users
| ID|Name|Email|
| ---|---|---|

这简直是个“幽灵页面”——有标题、有表头,但没有一行真实数据!它就像一个空荡荡的剧院舞台,只搭好了布景,就等演员(数据)登场。

数据仓库:db.json 的“假数据库”

真正的数据藏在这里:

{
    &#34;users&#34;: [
        {
            &#34;id&#34;: 1,
            &#34;name&#34;: &#34;张三&#34;,
            &#34;email&#34;: &#34;zhangsan@qq.com&#34;
        },
        {
            &#34;id&#34;: 2,
            &#34;name&#34;: &#34;李四&#34;,
            &#34;email&#34;: &#34;lisi@qq.com&#34;
        }
        ,
        {
            &#34;id&#34;: 3,
            &#34;name&#34;: &#34;王五&#34;,
            &#34;email&#34;: &#34;wangwu@qq.com&#34;    
        }
    ]
}

这是一个纯 JSON 文件,结构清晰,但本身不会动。它需要一个“翻译官”把它变成 API。

自动化 API 工厂:json-server(来自 package.json

package.json

{
  &#34;scripts&#34;: {
    &#34;dev&#34;: &#34;json-server --watch db.json&#34;
  },
  &#34;dependencies&#34;: {
    &#34;json-server&#34;: &#34;^1.0.0-beta.3&#34;
  }
}

运行 npm run dev,神奇的事情发生了:json-server 自动监听 db.json,并启动一个本地服务器(默认 http://localhost:3000),将 users 数组暴露为 RESTful 接口:

  • GET /users → 返回全部用户
  • GET /users/1 → 返回 ID 为 1 的用户

现在,前端只需一句 fetch('/users'),就能拿到 JSON 数据!

舞台(index.html)不再自己造演员,而是打电话给经纪公司(API):“请派三位演员上台!” 演员来了,前台 JS 再手动把他们安排到座位上(操作 DOM)。

优点:前后端解耦、接口复用、便于测试。
痛点:前端仍需手动更新 DOM,代码冗长易错——“找到表格 → 清空 → 循环创建行 → 插入单元格……”

于是,人们渴望一种更智能的方式……


第三章:魔法纪元 —— Vue 的“响应式咒语”(App.vue

如果说前后端分离是“分工”,那么 Vue 就是“自动化”。它引入了一个颠覆性理念:你只管描述“页面应该长什么样”,数据变了,页面自动跟着变

项目准备

创建Vue3项目,在终端打开创建项目的文件夹,运行以下命令

npm init vite

回车后输入项目名称 ref-demo,选择 Vue + JavaScript 即可

![](<> "点击并拖拽以移动")

完整项目结构及 App.vue 文件链接:[vue/ref/demo3/ref-demo/src/App.vue · Zou/lesson_zp - 码云 - 开源中国](gitee.com/giaoZou/les… "vue/ref/demo3/ref-demo/src/App.vue · Zou/lesson_zp - 码云 - 开源中国")

![](<> "点击并拖拽以移动")

响应式数据:会“思考”的变量 —— Vue 的魔法心脏

App.vue 中,我们看到这样两行代码:

import { ref } from 'vue';
const users = ref([]);

别小看这短短两行——它们开启了一个自动同步数据与视图的魔法世界

ref([]) 不是普通数组,而是一个“活”的数据容器

乍一看,users 似乎只是个空数组。但其实,ref() 把它包装成了一个响应式引用对象(reactive reference) 。你可以把它想象成一个装着数据的“智能玻璃瓶”:

  • 瓶子里装的是你的真实数据(比如用户列表);
  • 瓶子本身会“监听”:只要有人往里放新东西(修改 .value),它就会立刻广播:“注意!内容变了!”
  • 所有“订阅”了这个瓶子的 UI 元素(比如模板中的 {{user.name}}),都会自动更新自己。

换句话说,users 不再是死气沉沉的变量,而是一个会呼吸、会通知、会联动的活体数据

技术小贴士:在 Vue 3 的 Composition API 中,ref 内部使用了 JavaScript 的 Proxygetter/setter 机制,实现对 .value 访问和赋值的拦截,从而建立“依赖追踪”系统。


生命周期钩子:组件的“出生仪式”

接下来这段代码,是组件的“成人礼”:

onMounted(() => {
  console.log('组件挂载完成,开始从后端获取数据');
  fetch('/users')
    .then(res => res.json())
    .then(data => {
      users.value = data;
      console.log('从后端获取到的数据:', users.value);
    })
})
什么是 onMounted

Vue 组件有自己的“生命周期”:创建 → 挂载 → 更新 → 卸载。
onMounted 就是那个关键的“我已上线”时刻——当组件的 DOM 已经被渲染到页面上,Vue 就会执行这个回调函数。

这就像一个新生儿睁开眼睛的第一秒,立刻说:“世界你好!我要开始干活了!”

为什么在这里发请求?

因为:

  • 页面结构已经存在(模板已解析);
  • 此时发起 fetch('/users'),既能拿到数据,又能确保有地方展示它;
  • 如果在组件还没挂载前就操作 DOM 或赋值,可能会失败或造成内存泄漏。

于是,组件一“睁眼”,就向后端(由 json-server 提供的 /users 接口)发出请求,拿到 JSON 数据后:

users.value = data;

Boom!魔法触发!


重点:这一行代码,如何让页面“活”起来?

当你写下 users.value = data,看似只是赋值,实则引爆了一连串精妙的连锁反应:

  1. Vue 的响应式系统检测到 users 的值发生了变化
    → 因为 users 是用 ref 创建的,任何对 .value 的写入都会被拦截。
  2. 系统立刻找出所有“依赖”这个数据的地方
    → 在编译阶段,Vue 已经悄悄记录下:模板中用了 users 的地方(比如 v-for=&#34;user in users&#34;)都需要被通知。
  3. 重新执行相关的渲染逻辑
    → Vue 并不会重绘整个页面,而是只重新计算“受影响的部分”——也就是用户列表区域。
  4. 生成新的虚拟 DOM(Virtual DOM)
    → Vue 先在内存中构建一个轻量级的 DOM 树副本。
  5. 与旧虚拟 DOM 对比(diff 算法)
    → 找出最小差异:比如新增了三行、删除了零行。
  6. 精准更新真实 DOM
    → 只修改浏览器中真正需要变动的节点,避免不必要的重排重绘。

 整个过程毫秒级完成,用户毫无感知,却看到了最新数据!


声明式模板:所想即所得的 UI 编程 

现在,看看 `` 部分的神奇之处:

<table>
  <tr>
    <th>id</th>
    <th>name</th>
    <th>email</th>
  </tr>
<tbody>
  <tr>
    <td>{{user.id}}</td>
    <td>{{user.name}}</td>
    <td>{{user.email}}</td>
  </tr>
</tbody>
</table>

这里藏着 Vue 两大核心法宝:

{{ }}:插值表达式 —— 数据的“透明窗口”
  • {{user.name}} 不是一段字符串,而是一个动态绑定
  • 它告诉 Vue:“请在这里显示 user.name 的当前值,并且当它变时,自动刷新。”
  • 你不需要手动拼接 HTML,也不用担心 XSS(Vue 默认会转义内容,安全可靠)。
 v-for:列表渲染指令 —— 自动化的“克隆工厂”
  • v-for=&#34;user in users&#34; 是 Vue 的循环指令。
  • 它会遍历 users 数组,为每个 user 自动生成一行 <tr>
  • :key=&#34;user.id&#34; 提供唯一标识,帮助 Vue 高效追踪每个节点的身份(比如移动、删除时保持状态)。

在传统开发中,你要写:

// 找到表格
const tbody = document.querySelector('#user-table tbody');
// 清空
tbody.innerHTML = '';
// 循环创建行
data.forEach(user => {
  const tr = document.createElement('tr');
  tr.innerHTML = `<td>${user.id}</td><td>${user.name}</td>...`;
  tbody.appendChild(tr);
});

而在 Vue 中,你只需说:

“我想显示一个用户列表,每一行包含 id、name 和 email。”

然后 Vue 就默默完成了剩下的所有工作。

这就是声明式编程的魅力:你描述“是什么”,框架负责“怎么做”。

从此,开发者从 DOM 操作的泥潭中解放出来,专注于业务逻辑与用户体验——而这,正是现代前端框架最伟大的馈赠。


终章:三次飞跃,一条主线

时代 核心思想 数据流向 开发体验 用户体验
服务端渲染 “我给你完整的饭” 数据 → 服务端 → 完整 HTML → 浏览器 简单但笨重 刷新卡顿,交互弱
前后端分离 “我给你食材,你自己做” 浏览器 → 请求 API → 获取 JSON → 手动更新 DOM 灵活但繁琐 局部更新,但依赖手动编码
响应式框架 “你告诉我菜谱,我自动做饭” 数据变化 → 自动驱动视图更新 声明式、高效、可维护 流畅、实时、无感

结语:技术的本质是“解放人”

server.js 的字符串拼接,到 App.vue 的响应式绑定,表面看是代码风格的变迁,实则是开发范式的跃迁

从“命令式”(怎么做)走向“声明式”(做什么)

今天的前端开发者,不再需要关心“如何插入一行表格”,而是专注“用户列表应该展示哪些字段”。这种抽象,让我们能更专注于业务逻辑与用户体验。

而这,正是技术进步最美的样子——让复杂消失,让创造浮现

🌟 下次当你看到一个动态刷新的列表时,不妨微笑一下:那背后,是一场跨越二十年的工程智慧结晶。

一键部署!一款开源自托管的照片画廊神器!

大家好,我是 Java陈序员

在这个数字时代,我们的手机和相机里存满了无数珍贵的照片 —— 家人的笑脸、旅行的风景、生活的点滴瞬间。但这些回忆往往被淹没在杂乱的相册里,要么受制于云存储的隐私风险,要么因格式兼容问题难以完整呈现。

这时候,我们可以搭建一个完全属于自己、能按时间和地点梳理回忆的照片画廊。

今天,给大家推荐一款专注于流畅体验的自托管个人画廊神器,支持一键部署!

项目介绍

chronoframe —— 一个丝滑的照片展示和管理应用,支持多种图片格式和大尺寸图片渲染。

功能特色

  • 强大的照片管理:支持通过网页界面轻松管理和浏览照片,并在地图上查看照片拍摄地点
  • 轻量部署体验:基于 Docker 一键部署,无需额外配置数据库(内置 SQLite),几分钟内即可完成私有化部署
  • 多存储后端适配:支持本地文件系统、S3 兼容存储多种存储方式,满足不同场景需求
  • 地图可视化浏览:自动提取照片 GPS 信息,使用 Mapbox 进行地理编码,在地图上展示照片拍摄位置
  • 响应式设计:完美适配桌面端和移动端,支持触摸操作和手势控制,提供原生应用般的体验
  • Live/Motion Photo 支持:完整支持 Apple LivePhoto 格式和 Google 标准的 Motion Photo,自动检测和处理 MOV 视频文件,保留动态照片效果

技术栈:Nuxt4 + TypeScript + TailwindCSS + Drizzle ORM

快速上手

配置信息

创建 .env 文件,下面是使用本地存储的最小示例。

# 管理员邮箱(必须)
CFRAME_ADMIN_EMAIL=
# 管理员用户名(可选,默认 ChronoFrame)
CFRAME_ADMIN_NAME=
# 管理员密码(可选,默认 CF1234@!)
CFRAME_ADMIN_PASSWORD=

# 站点信息(均可选)
NUXT_PUBLIC_APP_TITLE=
NUXT_PUBLIC_APP_SLOGAN=
NUXT_PUBLIC_APP_AUTHOR=
NUXT_PUBLIC_APP_AVATAR_URL=

# 地图提供器 (maplibre/mapbox)
NUXT_PUBLIC_MAP_PROVIDER=maplibre
# 使用 MapLibre 需要 MapTiler 访问令牌
NUXT_PUBLIC_MAP_MAPLIBRE_TOKEN=
# 使用 Mapbox 需要 Mapbox 访问令牌
NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN=

# 存储提供者(local 或 s3 或 openlist)
NUXT_STORAGE_PROVIDER=local
NUXT_PROVIDER_LOCAL_PATH=/app/data/storage

# 会话密码(必须,32 位随机字符串)
NUXT_SESSION_PASSWORD=

# 是否开启允许 IP 直接访问
NUXT_ALLOW_INSECURE_COOKIE=true

如选择使用 S3 存储,请将存储部分的配置替换为:

NUXT_STORAGE_PROVIDER=s3
NUXT_PROVIDER_S3_ENDPOINT=
NUXT_PROVIDER_S3_BUCKET=chronoframe
NUXT_PROVIDER_S3_REGION=auto
NUXT_PROVIDER_S3_ACCESS_KEY_ID=
NUXT_PROVIDER_S3_SECRET_ACCESS_KEY=
NUXT_PROVIDER_S3_PREFIX=photos/
NUXT_PROVIDER_S3_CDN_URL=

若选择使用 OPENLIST,请将存储部分的配置替换为:

NUXT_STORAGE_PROVIDER=openlist
NUXT_PROVIDER_OPENLIST_BASE_URL=https://openlist.example.com
NUXT_PROVIDER_OPENLIST_ROOT_PATH=/115pan/chronoframe
NUXT_PROVIDER_OPENLIST_TOKEN=your-static-token

如果需要集成 Github 登录,需配置 GitHub OAuth 变量:

NUXT_OAUTH_GITHUB_CLIENT_ID=
NUXT_OAUTH_GITHUB_CLIENT_SECRET=

Docker 部署

1、拉取镜像

docker pull ghcr.io/hoshinosuzumi/chronoframe:latest

2、创建挂载目录和配置文件

mkdir -p /data/software/chronoframe/data

cd /data/software/chronoframe

# 配置文件参考前文的配置文件信息
vim .env

3、运行容器

docker run -d \
--name chronoframe \
  -p 3000:3000 \
  -v /data/software/chronoframe/data:/app/data \
  --env-file .env \
  ghcr.io/hoshinosuzumi/chronoframe:latest

Docker Compose 部署

1、创建 docker-compose.yml 文件

services:
  chronoframe:
    image: ghcr.io/hoshinosuzumi/chronoframe:latest
    container_name: chronoframe
    restart: unless-stopped
    ports:
      - '3000:3000'
    volumes:
      - ./data:/app/data
    env_file:
      - .env

2、启动服务

# 启动服务
docker compose up -d

# 查看日志
docker compose logs -f chronoframe

# 停止服务
docker compose down

# 更新到最新版本
docker compose pull
docker compose up -d

反向代理

如需要使用反向代理服务器(如 Nginx 或 Caddy)来处理 HTTPS 和域名解析,可参考如下配置。

server {
    listen 80;
    server_name your-domain.com;
    
    # HTTPS 重定向
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your-domain.com;
    
    # SSL 证书配置
    ssl_certificate /path/to/your/certificate.crt;
    ssl_certificate_key /path/to/your/private.key;
    
    # SSL 安全配置
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    
    # 上传大小限制
    client_max_body_size 100M;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        
        # WebSocket 支持
        proxy_set_header Connection "upgrade";
        proxy_set_header Upgrade $http_upgrade;
        
        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
    
    # 静态资源缓存
    location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|ico|woff|woff2|ttf|eot)$ {
        proxy_pass http://localhost:3000;
        expires 1y;
        add_header Cache-Control "public, immutable";
        proxy_set_header Host $host;
    }
}

功能体验

首页

  • 明亮模式

  • 暗黑模式

  • 照片查看

  • 地球仪

  • 相簿

  • 筛选照片

控制台

  • 仪表盘

  • 照片库

  • 上传照片

  • 相簿

  • 队列管理

  • 系统日志

无论是摄影爱好者整理作品,还是个人珍藏生活片段,chronoframe 都能通过简单的部署方式,为你打造一个流畅、安全且充满温度的私人图片画廊。快去部署体验吧~

项目地址:https://github.com/HoshinoSuzumi/chronoframe

最后

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

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

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

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

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


Node.js 事件循环(Event Loop)

什么是事件循环

事件循环(Event Loop) 是 Node.js 的核心机制,它使得 Node.js 能够在单线程环境下高效地处理大量并发操作。尽管 JavaScript 是单线程的,但通过事件循环和非阻塞 I/O,Node.js 可以同时处理成千上万的连接。

为什么需要事件循环?

在传统的多线程服务器模型中,每个请求都会创建一个新线程,这会导致:

  • 资源消耗大:每个线程都需要内存和 CPU 资源
  • 上下文切换开销:线程间的切换会消耗 CPU 时间
  • 并发限制:受限于系统能创建的线程数量

Node.js 通过事件循环解决了这些问题:

  • 单线程执行:JavaScript 代码在单一主线程中运行
  • 非阻塞 I/O:I/O 操作在后台线程池中执行,不阻塞主线程
  • 事件驱动:通过回调函数处理异步操作的结果

事件循环的工作原理

事件循环是一个持续运行的循环,它不断地检查是否有待处理的任务,并按照特定的顺序执行它们。

基本执行流程

┌───────────────────────────┐
│   Node.js 启动            │
└───────────┬───────────────┘
            │
            ▼
┌───────────────────────────┐
│   执行同步代码            │
│   (主脚本)                │
└───────────┬───────────────┘
            │
            ▼
┌───────────────────────────┐
│   进入事件循环            │
│   ┌─────────────────────┐ │
│   │ 1. Timers           │ │
│   │ 2. Pending Callbacks│ │
│   │ 3. Idle, Prepare    │ │
│   │ 4. Poll             │ │
│   │ 5. Check            │ │
│   │ 6. Close Callbacks  │ │
│   └─────────────────────┘ │
└───────────┬───────────────┘
            │
            ▼
┌───────────────────────────┐
│   检查是否有待处理任务    │
└───────────┬───────────────┘
            │
      ┌─────┴─────┐
      │           │
     是           否
      │           │
      ▼           ▼
  继续循环    退出程序

关键概念

  1. 调用栈(Call Stack):执行同步代码的地方
  2. 任务队列(Task Queue):存储待执行的异步回调
  3. 微任务队列(Microtask Queue):存储 Promise 回调(Promise.then/catch/finally
  4. nextTick 队列(NextTick Queue):存储 process.nextTick 回调(独立于微任务队列,优先级更高)
  5. 事件循环:协调调用栈和任务队列的机制

事件循环的六个阶段

根据 Node.js 官方文档,事件循环包含以下六个阶段:

1. Timers(定时器阶段)

执行由 setTimeout()setInterval() 设置的回调函数。

setTimeout(() => {
  console.log('Timer 1');
}, 0);

setInterval(() => {
  console.log('Interval');
}, 1000);

特点

  • 只执行已经到期的定时器回调
  • 定时器的延迟时间是最小延迟,不是精确延迟
  • 如果事件循环被阻塞,定时器可能会延迟执行

2. Pending Callbacks(待处理的回调阶段)

执行延迟到下一个循环迭代的 I/O 回调。这些回调通常来自系统操作,主要是某些系统级别的错误回调(如 TCP 连接错误)。

const net = require('net');

// TCP 连接错误回调可能在这个阶段执行
const socket = net.createConnection(80, 'invalid-host');
socket.on('error', (err) => {
  // 某些系统错误回调(如 ECONNREFUSED)会在这个阶段执行
  console.error('Connection error:', err);
});

注意:大部分 I/O 回调(包括 fs.readFile 的成功和错误回调)都在 Poll 阶段执行,而不是这个阶段。Pending Callbacks 阶段主要处理系统级别的错误回调。

3. Idle, Prepare(空闲、准备阶段)

仅供 Node.js 内部使用,通常不涉及用户代码。

4. Poll(轮询阶段)

这是事件循环的核心阶段,负责:

  • 检索新的 I/O 事件
  • 执行与 I/O 相关的回调(除了关闭回调、定时器回调和 setImmediate 回调)
const fs = require('fs');

fs.readFile('data.txt', 'utf8', (err, data) => {
  // 这个回调在 Poll 阶段执行
  console.log('File content:', data);
});

Poll 阶段的工作机制

  1. 如果 Poll 队列不为空,同步执行队列中的回调,直到队列为空或达到系统限制
  2. 如果 Poll 队列为空:
    • 如果有 setImmediate() 回调,进入 Check 阶段
    • 如果没有,等待新的 I/O 事件到达

5. Check(检查阶段)

执行由 setImmediate() 设置的回调函数。

setImmediate(() => {
  console.log('setImmediate callback');
});

setImmediate vs setTimeout

  • setImmediate() 在当前事件循环的 Check 阶段执行
  • setTimeout(fn, 0) 在下一个事件循环的 Timers 阶段执行
  • 在 I/O 回调中,setImmediate 总是先于 setTimeout 执行

6. Close Callbacks(关闭回调阶段)

执行关闭事件的回调,如 socket.on('close', ...)

const server = require('http').createServer();

server.on('close', () => {
  console.log('Server closed');
});

server.close();

事件队列

事件队列是事件循环的核心数据结构,用于存储待执行的异步回调。事件循环通过管理 nextTick 队列、微任务队列和宏任务队列来协调异步操作的执行顺序。详细的队列类型和执行顺序说明请参考下面的"宏任务与微任务"章节。


宏任务与微任务

理解宏任务和微任务的区别对于掌握事件循环至关重要。

宏任务(Macrotasks)

宏任务包括:

  • setTimeout()
  • setInterval()
  • setImmediate()
  • I/O 操作回调

特点

  • 在每个事件循环阶段之间,会先清空 nextTick 队列,然后清空微任务队列
  • 执行完所有微任务后,才进入下一个事件循环阶段

微任务(Microtasks)

微任务包括:

  • Promise.then() / Promise.catch() / Promise.finally()
  • queueMicrotask()

特点

  • 优先级高于宏任务
  • 在每个事件循环阶段结束后执行
  • 会阻塞后续阶段的执行,直到微任务队列清空

注意process.nextTick() 虽然行为类似微任务,但它有自己独立的队列,优先级甚至高于 Promise 微任务。在每个阶段结束后,会先执行所有 process.nextTick 回调,然后才执行 Promise 等微任务。

执行示例

console.log('开始');

// 宏任务
setTimeout(() => {
  console.log('setTimeout 1');
  
  // 微任务(在宏任务内部)
  Promise.resolve().then(() => {
    console.log('Promise 2');
  });
  
  process.nextTick(() => {
    console.log('nextTick 2');
  });
}, 0);

// 微任务
Promise.resolve().then(() => {
  console.log('Promise 1');
});

process.nextTick(() => {
  console.log('nextTick 1');
});

// 宏任务
setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

console.log('结束');

// 输出顺序:
// 开始
// 结束
// nextTick 1
// Promise 1
// setTimeout 1
// nextTick 2
// Promise 2
// setTimeout 2

注意process.nextTick 的优先级甚至高于 Promise,过度使用可能导致事件循环阻塞。详细说明请参考下面的"process.nextTick 与 setImmediate"章节。


process.nextTick 与 setImmediate

这两个 API 经常被混淆,但它们有本质区别:

process.nextTick

  • 执行时机:在当前操作完成后、进入下一个事件循环阶段之前立即执行(不是事件循环的一部分)
  • 优先级:最高,甚至高于 Promise 微任务
  • 用途:确保回调在当前操作完成后立即执行,用于保证 API 的异步性
console.log('开始');

setImmediate(() => {
  console.log('setImmediate');
});

process.nextTick(() => {
  console.log('nextTick');
});

console.log('结束');

// 输出:
// 开始
// 结束
// nextTick
// setImmediate

setImmediate

  • 执行时机:在当前事件循环的 Check 阶段执行
  • 优先级:低于 process.nextTick 和 Promise
  • 用途:在当前事件循环结束后执行回调

何时使用哪个?

// 使用 process.nextTick:需要立即执行,不阻塞 I/O
function asyncOperation(data, callback) {
  if (data) {
    process.nextTick(() => {
      callback(null, data);
    });
  } else {
    process.nextTick(() => {
      callback(new Error('No data'));
    });
  }
}

// 使用 setImmediate:在 I/O 操作后执行
const fs = require('fs');

fs.readFile('file.txt', () => {
  setImmediate(() => {
    console.log('在 I/O 回调后执行');
  });
  
  setTimeout(() => {
    console.log('在下一个事件循环执行');
  }, 0);
});

事件循环优化策略

优化事件循环是提升 Node.js 应用性能的关键。

1. 避免阻塞主线程

问题代码

// ❌ 阻塞事件循环
// 假设已有 Express 应用:const app = require('express')();

function heavyComputation() {
  let result = 0;
  for (let i = 0; i < 10000000000; i++) {
    result += i;
  }
  return result;
}

app.get('/compute', (req, res) => {
  const result = heavyComputation(); // 阻塞所有请求
  res.json({ result });
});

优化方案

// ✅ 使用 Worker Threads
const { Worker } = require('worker_threads');
const { join } = require('path');

function heavyComputationAsync() {
  return new Promise((resolve, reject) => {
    // 使用独立的 worker 文件(推荐方式)
    const worker = new Worker(join(__dirname, 'worker.js'));
    
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

app.get('/compute', async (req, res) => {
  const result = await heavyComputationAsync();
  res.json({ result });
});

worker.js

const { parentPort } = require('worker_threads');

let result = 0;
for (let i = 0; i < 10000000000; i++) {
  result += i;
}

parentPort.postMessage(result);

2. 合理使用异步 API

问题代码

// ❌ 同步 I/O 阻塞事件循环
// 假设已有 Express 应用:const app = require('express')();
const fs = require('fs');

app.get('/file', (req, res) => {
  const data = fs.readFileSync('large-file.txt'); // 阻塞
  res.send(data);
});

优化方案

// ✅ 使用异步 I/O
const fs = require('fs').promises;

app.get('/file', async (req, res) => {
  const data = await fs.readFile('large-file.txt');
  res.send(data);
});

3. 控制微任务数量

问题代码

// ❌ 创建大量微任务
function recursivePromise(count) {
  if (count <= 0) return Promise.resolve();
  
  return Promise.resolve().then(() => {
    return recursivePromise(count - 1); // 可能阻塞事件循环
  });
}

优化方案

// ✅ 使用 setImmediate 拆分任务
function recursivePromise(count) {
  if (count <= 0) return Promise.resolve();
  
  return new Promise((resolve) => {
    setImmediate(() => {
      recursivePromise(count - 1).then(resolve);
    });
  });
}

4. 避免过度使用 process.nextTick

问题代码

// ❌ 递归调用 process.nextTick
function recursiveNextTick(count) {
  if (count <= 0) return;
  
  process.nextTick(() => {
    recursiveNextTick(count - 1); // 可能导致事件循环阻塞
  });
}

优化方案

// ✅ 使用 setImmediate 或拆分任务
function recursiveNextTick(count) {
  if (count <= 0) return;
  
  setImmediate(() => {
    recursiveNextTick(count - 1);
  });
}

5. 使用流处理大文件

问题代码

// ❌ 一次性读取大文件到内存
// 假设已有 Express 应用:const app = require('express')();
const fs = require('fs');

app.get('/large-file', (req, res) => {
  const data = fs.readFileSync('huge-file.txt'); // 可能耗尽内存
  res.send(data);
});

优化方案

// ✅ 使用流处理
const fs = require('fs');

app.get('/large-file', (req, res) => {
  const stream = fs.createReadStream('huge-file.txt');
  stream.pipe(res); // 流式传输,不占用大量内存
});

6. 监控事件循环延迟

// 监控事件循环延迟
const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((items) => {
  const entry = items.getEntries()[0];
  console.log(`Event Loop Delay: ${entry.duration}ms`);
  
  // 如果延迟超过阈值,发出警告
  if (entry.duration > 100) {
    console.warn('Event loop is blocked!');
  }
});

obs.observe({ entryTypes: ['measure'] });

// 初始化 start mark
performance.mark('start');

// 定期测量事件循环延迟
setInterval(() => {
  setImmediate(() => {
    performance.mark('end');
    performance.measure('event-loop-delay', 'start', 'end');
    performance.mark('start'); // 为下一次测量做准备
  });
}, 1000);

7. 使用集群模式处理 CPU 密集型任务

const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

// Node.js 16+ 使用 cluster.isPrimary,旧版本使用 cluster.isMaster
if (cluster.isPrimary) {
  // 主进程:创建工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  
  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} died`);
    cluster.fork(); // 重启工作进程
  });
} else {
  // 工作进程:运行应用
  require('./app');
}

实际案例分析

案例 1:I/O 回调中的执行顺序

const fs = require('fs');

fs.readFile(__filename, () => {
  console.log('1. I/O 回调');
  
  setTimeout(() => {
    console.log('2. setTimeout');
  }, 0);
  
  setImmediate(() => {
    console.log('3. setImmediate');
  });
  
  Promise.resolve().then(() => {
    console.log('4. Promise');
  });
  
  process.nextTick(() => {
    console.log('5. nextTick');
  });
});

console.log('6. 同步代码');

// 输出顺序:
// 6. 同步代码
// 1. I/O 回调
// 5. nextTick
// 4. Promise
// 3. setImmediate
// 2. setTimeout

注意:在 I/O 回调中,setImmediate 总是先于 setTimeout 执行。这是因为 I/O 回调在 Poll 阶段执行,执行完毕后会进入 Check 阶段(执行 setImmediate),然后才进入下一个循环的 Timers 阶段(执行 setTimeout)。


总结

核心要点

  1. 事件循环是 Node.js 的核心:它使得单线程能够高效处理并发操作

  2. 六个阶段的执行顺序

    • Timers → Pending Callbacks → Idle/Prepare → Poll → Check → Close Callbacks
  3. 任务优先级(从高到低):

    • nextTick 队列(独立队列,不是事件循环的一部分):
      1. process.nextTick() - 优先级最高,在当前操作完成后、进入下一个事件循环阶段之前立即执行
    • 微任务(Microtasks): 2. Promise.then() / Promise.catch() / Promise.finally() - Promise 回调 3. queueMicrotask() - 标准微任务 API
    • 宏任务(Macrotasks): 4. setImmediate() - 在当前事件循环的 Check 阶段执行 5. setTimeout() / setInterval() - 在 Timers 阶段执行 6. I/O 操作回调(文件系统、网络操作等)- 在 Poll 阶段执行 7. Close Callbacks(关闭回调,如 socket.on('close'))- 在 Close Callbacks 阶段执行
  4. 优化原则

    • 永远不要阻塞事件循环
    • 优先使用异步 API
    • 合理控制微任务数量
    • 使用流处理大文件
    • 监控事件循环延迟

最佳实践

  1. 使用异步 I/O:避免同步文件操作和网络请求
  2. 拆分大任务:将 CPU 密集型任务拆分为小块
  3. 使用 Worker Threads:处理 CPU 密集型任务
  4. 使用流:处理大文件和数据流
  5. 监控性能:定期检查事件循环延迟
  6. 避免阻塞操作:不要在回调中执行耗时计算
  7. 避免过度使用 process.nextTick:可能导致事件循环阻塞
  8. 避免同步 API:除非绝对必要

参考资源

❌