阅读视图

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

JavaScript 词法作用域与闭包:从底层原理到实战理解

JS运行机制

词法作用域

“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关”

换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。

你可以这样理解:

代码怎么写的,它就怎么执行——这非常符合我们的直觉。

比如,letconst 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。

所以,“词法”本质上就是:看代码结构,而不是看运行过程

看一段关于词法作用域的代码

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar()// 运行时
}
var myName = '极客时间'
foo();

这里输出的是

极客时间 为什么输出的不是 "极客邦"

因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域

JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用

bar 内部访问变量(比如 testmyName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。

因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。

image.png

总结:

JavaScript 使用 词法作用域(Lexical Scoping) ,也就是说,函数在定义时就决定了它能访问哪些变量,而不是在调用时

词法作用域链:变量查找的路径

当 JavaScript 引擎执行代码时,会为每一段可执行代码创建一个 执行上下文(Execution Context)
每个执行上下文都包含一个 词法环境(Lexical Environment) ,它不仅保存了当前作用域中声明的变量,还持有一个指向外层词法环境的引用。这些嵌套的词法环境连接起来,就形成了 作用域链(Scope Chain)

  • 全局执行上下文位于调用栈的底部,是程序启动时创建的。
  • 每当调用一个函数,就会创建一个新的函数执行上下文,并将其压入调用栈。
  • 当需要查找某个变量时,JavaScript 会从当前作用域开始,沿着作用域链由内向外逐层查找,直到找到该变量,或最终到达全局作用域为止。

这种机制确保了变量访问遵循词法作用域规则——即“在哪里定义,就看哪里的变量”,而不是“在哪里调用”。

看看这段关于作用域链和块级作用域的代码:

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"  // 1.先在词法环境查找一下
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

这段代码的执行结果不会报错,而是会正常输出:

1

原因正是基于 JavaScript 的 词法作用域(Lexical Scoping) 机制。

虽然 bar() 是在 foo() 内部被调用的,但它的声明位置在全局作用域。因此,当 bar 内部引用变量 test 时,JavaScript 引擎会从 bar 自身的作用域开始查找;找不到时,就沿着词法作用域链向外层查找——也就是直接跳到全局作用域,而不会进入 foo 的作用域。

由于全局作用域中存在 let test = 1;,所以 console.log(test) 最终输出的是 1

image.png

换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链

在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境

当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain)

正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。

image.png

这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。

闭包 ——前面内容的优雅升华

闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。


一、什么是闭包?

闭包 = 一个函数 + 它定义时所处的词法环境。

换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。

这并不是魔法,而是 JavaScript 词法作用域机制的自然结果


二、闭包形成的两个必要条件(缺一不可)

  1. 函数嵌套:内部函数引用了外部函数的变量;
  2. 内部函数被暴露到外部:比如通过 return 返回、赋值给全局变量、作为回调传递等,并在外部被调用。

只有同时满足这两点,闭包才会真正“生效”。


三、经典示例:直观感受闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2; // 注意:test2 未被内部函数使用

  var innerBar = {
    getName: function () {
      console.log(test1); // 引用了外部变量 test1
      return myName;      // 引用了外部变量 myName
    },
    setName: function (newName) {
      myName = newName;   // 修改外部变量 myName
    }
  };

  return innerBar; // 将内部对象返回,使内部函数可在外部调用
}

// 执行 foo,获取返回的对象
var bar = foo(); // 此时 foo 已执行完毕,上下文出栈

// 在外部调用内部函数 —— 闭包开始工作!
bar.setName("极客邦");
bar.getName();               // 输出:1
console.log(bar.getName());  // 输出:1 和 "极客邦"

输出结果:

1
1
极客邦

四、关键问题:为什么 foo 的变量没被垃圾回收?

  • 通常情况下,函数执行结束后,其局部变量会被垃圾回收。

  • 但在闭包场景中,只要内部函数仍被外部引用,JavaScript 引擎就会保留该函数所依赖的外部变量

  • 在本例中:

    • getName 和 setName 引用了 myName 和 test1 → 这两个变量被“捕获”并保留在内存中;
    • test2 没有被任何函数使用 → 被正常回收。

📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量
这既保证了功能,又避免了内存浪费。

image.png


五、闭包的本质与词法作用域的关系

1. 闭包的本质

闭包不是某种特殊语法,而是一种运行时行为

函数 + 它出生时的词法环境 = 闭包

你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。

2. 与词法作用域的关联

  • 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
  • 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。

✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。


💡 记住一句话
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。

理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

开场白:一个变量的"无法无天"与它的"寻亲之路"


📖 第一章:黑马喽的嚣张岁月

话说在前端江湖的ES5时代,有个叫var的黑马喽,这家伙简直无法无天!它想来就来,想走就走,完全不顾什么块级作用域的规矩。

// 你们看看这黑马喽的德行
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 你猜输出啥?3,3,3!
    }, 100);
}
// 循环结束了,i还在外面晃荡
console.log(i); // 3,瞧瞧,跑出来了吧!

但今天咱们不仅要扒一扒var的底裤,还要讲讲变量们是怎么"寻亲"的——这就是作用域作用域链的故事。


🔧 第二章:编译器的三把斧——代码的"梳妆打扮"

要说清楚作用域,得先从JavaScript的编译说起。别看JS是解释型语言,它在执行前也要经历一番"梳妆打扮"。

2.1 词法分析:拆解字符串的魔术

想象一下,编译器就像个认真的语文老师,把代码这个长句子拆成一个个有意义的词语:

var a = 1vara=1

注意:空格要不要拆开,得看它有没有用。就像读书时要不要停顿,得看语气!

2.2 语法分析:构建家谱树

拆完词之后,编译器开始理清关系——谁声明了谁,谁赋值给谁,最后生成一棵抽象语法树(AST)

这就像把一堆零散的家庭成员信息,整理成清晰的家谱。

2.3 代码生成:准备执行

最后,编译器把家谱树转换成机器能懂的指令,准备执行。

关键点:JS的编译发生在代码执行前的一瞬间,快到你几乎感觉不到!


💕 第三章:变量赋值的三角恋

var a = 1这么简单的一行代码,背后居然上演着一场"三角恋":

  • 🎯 编译器:干脏活累活的媒人,负责解析和牵线
  • JS引擎:执行具体动作的新郎
  • 🏠 作用域:管理宾客名单的管家

3.1 订婚仪式(编译阶段)

// 当看到 var a = 1;
编译器:管家,咱们这有叫a的变量吗?
作用域:回大人,还没有。
编译器:那就在当前场合声明一个a!

3.2 结婚典礼(执行阶段)

JS引擎:管家,我要找a这个人赋值!
作用域:大人请,a就在这儿。
JS引擎:好,把1赋给a!

这里涉及到两种查找方式:

LHS查询:找容器(找新娘)

var a = 1; // 找到a这个容器装1

RHS查询:找源头(找新娘的娘家)

console.log(a); // 找到a的值
foo();         // 找到foo函数本身

编译过程示意图


🐒 第四章:黑马喽的罪证展示

在ES5时代,var这家伙真是目中无人:

4.1 无视块级作用域

{
    var rogue = "我是黑马喽,我想去哪就去哪";
}
console.log(rogue); // 照样能访问!

4.2 变量提升的诡计

console.log(naughty); // undefined,而不是报错!
var naughty = "我提升了";

这货相当于:

var naughty;          // 声明提升到顶部
console.log(naughty); // undefined
naughty = "我提升了"; // 赋值留在原地

🙏 第五章:如来佛祖的五指山——let和const

ES6时代,如来佛祖(TC39委员会)看不下去了,派出了letconst两位大神:

5.1 块级作用域的紧箍咒

{
    let disciplined = "我在块里面很老实";
    const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不来咯

5.2 暂时性死区的降妖阵

console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?没门!";

真相let/const其实也会提升,但是被关进了"暂时性死区"这个五指山里,在声明前谁都别想访问!


🧩 第六章:黑马喽的迷惑行为——词法作用域的真相

6.1 一个让黑马喽困惑的例子

function bar(){
    console.log( myName);  // 黑马喽:这里该输出啥?
}

function foo(){
    var myName = "白吗喽";
    bar()
    console.log("1:", myName)   // 这个我懂,输出"白吗喽"
}

var myName = "黑吗喽";
foo()  // 输出:"黑吗喽","白吗喽"

黑马喽挠着头想:"不对啊!bar()foo()里面调用,不是应该找到foo()里的myName = "白吗喽"吗?怎么会是黑吗喽呢?"

6.2 outer指针:函数的"身份证"

原来,在编译阶段,每个函数就已经确定了自己的"娘家"(词法作用域):

// 编译阶段发生的事情:
// 1. bar函数出生,它的outer指向全局作用域(它声明在全局)
// 2. foo函数出生,它的outer也指向全局作用域(它声明在全局)
// 3. 变量myName声明提升:var myName = "黑吗喽"

// 执行阶段:
var myName = "黑吗喽";  // 全局myName赋值为"黑吗喽"
foo();                 // 调用foo函数

黑马喽的错误理解

bar() → foo() → 全局

实际的作用域查找(根据outer指针):

bar() → 全局

如图

C9AE3D8E-F1DA-4767-AE87-AF4B1AF8B94D.png

6.3 词法作用域 vs 动态作用域

词法作用域(JavaScript):看出生地

var hero = "全局英雄";

function createWarrior() {
    var hero = "部落勇士";
    
    function fight() {
        console.log(hero); // 永远输出"部落勇士"
    }
    
    return fight;
}

const warrior = createWarrior();
warrior(); // "部落勇士" - 记得出生时的环境

动态作用域:看调用地(JavaScript不是这样!)

// 假设JavaScript是动态作用域(实际上不是!)
var hero = "战场英雄";
const warrior = createWarrior();
warrior(); // 如果是动态作用域,会输出"战场英雄"

🗺️ 第七章:作用域链——变量的寻亲路线图

7.1 每个函数都带着"出生证明"

var grandma = "奶奶的糖果";

function mom() {
    var momCookie = "妈妈的饼干";
    
    function me() {
        var myCandy = "我的棒棒糖";
        console.log(myCandy);    // 自己口袋找
        console.log(momCookie);  // outer指向mom
        console.log(grandma);    // outer的outer指向全局
    }
    
    me();
}

mom();

7.2 作用域链的建造过程

// 全局作用域
var city = "北京";

function buildDistrict() {
    var district = "朝阳区";
    
    function buildStreet() {
        var street = "三里屯";
        console.log(street);     // 自己的
        console.log(district);   // outer指向buildDistrict
        console.log(city);       // outer的outer指向全局
    }
    
    return buildStreet;
}

// 编译阶段就确定的关系:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域

如图

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

⚔️ 第八章:作用域链的实战兵法

8.1 兵法一:模块化开发

function createCounter() {
    let count = 0; // 私有变量,外部无法直接访问
    
    return {
        increment: function() {
            count++; // 闭包:outer指向createCounter作用域
            return count;
        },
        getValue: function() {
            return count;
        }
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 报错!count是私有的

8.2 兵法二:解决循环陷阱

黑马喽的坑
for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 3, 3, 3 - 所有函数共享同一个i
    }, 100);
}
作用域链的救赎
// 方法1:使用let创建块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2 - 每个i都有自己的作用域
    }, 100);
}

// 方法2:IIFE创建新作用域
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 - j在IIFE作用域中
        }, 100);
    })(i);
}

8.3 兵法三:正确的函数嵌套

function foo(){
    var myName = "yang";
    
    function bar(){  // 现在bar的outer指向foo了!
        console.log("2:", myName);  // 找到foo的myName
    }
    
    bar()
    console.log("1:", myName)
}

var myName = "yang1";
foo()  // 输出:2: yang, 1: yang

🚀 第九章:现代JavaScript的作用域体系

9.1 块级作用域的精细化管理

function modernScope() {
    var functionScoped = "函数作用域";
    let blockScoped = "块级作用域";
    
    if (true) {
        let innerLet = "内部的let";
        var innerVar = "内部的var"; // 依然提升到函数顶部!
        
        console.log(blockScoped); // ✅ 可以访问外层的let
        console.log(functionScoped); // ✅ 可以访问外层的var
    }
    
    console.log(innerVar); // ✅ 可以访问
    // console.log(innerLet); // ❌ 报错!let是块级作用域
}

9.2 作用域链的新层级

// 全局作用域
const GLOBAL = "地球";

function country() {
    // 函数作用域
    let nationalLaw = "国家法律";
    
    {
        // 块级作用域1
        let provincialLaw = "省法规";
        
        if (true) {
            // 块级作用域2
            let cityRule = "市规定";
            
            console.log(cityRule);     // ✅ 本市有效
            console.log(provincialLaw); // ✅ 本省有效
            console.log(nationalLaw);   // ✅ 全国有效
            console.log(GLOBAL);        // ✅ 全球有效
        }
        
        // console.log(cityRule); // ❌ 跨市无效
    }
}

⚡ 第十章:作用域链的性能与优化

10.1 作用域查找的代价

var globalVar = "我在最外层";

function level3() {
    // 这个查找要经过:自己 → level2 → level1 → 全局
    console.log(globalVar);
}

function level2() {
    level3();
}

function level1() {
    level2();
}

10.2 优化心法

function optimized() {
    const localCopy = globalVar; // 局部缓存,减少查找深度
    
    function inner() {
        console.log(localCopy); // 直接访问,快速!
    }
    
    inner();
}

🏆 大结局:黑马喽的毕业总结

经过这番学习,黑马喽终于明白了作用域的真谛:

🎯 作用域的进化史

  1. ES5的混乱var无视块级作用域,到处捣乱
  2. ES6的秩序let/const引入块级作用域和暂时性死区
  3. outer指针机制:词法作用域在编译时确定,一辈子不变

🧠 作用域链的精髓

  1. outer指针:函数在编译时就确定了自己的"娘家"
  2. 词法作用域:看出生地,不是看调用地
  3. 就近原则:先找自己,再按outer指针找上级
  4. 闭包的力量:函数永远记得自己出生时的环境

💡 最佳实践心法

// 好的作用域设计就像好的家风
function createFamily() {
    // 外层:家族秘密,内部共享
    const familySecret = "传家宝";
    
    function teachChild() {
        // 中层:教育方法
        const education = "严格教育";
        
        return function child() {
            // 内层:个人成长
            const talent = "天赋异禀";
            console.log(`我有${talent},接受${education},知道${familySecret}`);
        };
    }
    
    return teachChild();
}

const familyMember = createFamily();
familyMember(); // 即使独立生活,依然记得家族传承

🌟 终极奥义

黑马喽感慨地总结道:

"原来JavaScript的作用域就像血缘关系:

  • 作用域是家规(在哪里能活动)
  • 作用域链是族谱(怎么找到祖先)
  • outer指针是出生证明(一辈子不变)
  • 词法作用域是家族传承(看出生地,不是看现住地)"

从此,黑马喽明白了:想要在前端江湖混得好,就要遵守作用域的家规,理解作用域链的族谱,尊重outer指针的出生证明!


🐒 黑马喽寄语:记住,函数的作用域是它的"娘家",编译时定亲,一辈子不变!理解了这套规则,你就能驯服任何JavaScript代码!

AI生成CAD图纸(云原生CAD+AI让设计像聊天一样简单)

项目概述

本章节探讨AI技术与在线CAD相结合,能否打造一个能让CAD"听懂人话"的智能助手。

核心价值:告别繁琐的手动绘图,用自然语言就能完成CAD设计。无论是建筑工程师、机械设计师,还是CAD开发者,都能通过AI大幅提升工作效率。

为什么选择MxCAD来做CAD智能系统?

1. 原子化API - AI时代的CAD开发利器

传统CAD软件的问题是:你只能用它给你的功能,比如"画直线"、"画圆"这样的整体功能。但MxCAD的API把所有功能都拆得特别细,就像乐高积木一样:

// 传统方式:只能调用drawCircle()
drawCircle(center, radius);
// MxCAD原子化API:AI可以精确控制每个细节
const center = new McGePoint3d(100, 100, 0);  // 精确控制圆心
const circle = new McDbCircle();              // 创建圆对象
circle.center = center;                       // 设置圆心
circle.radius = 50;                           // 设置半径
circle.trueColor = new McCmColor(255, 0, 0);  // 精确控制颜色
entitys.push(circle);                         // 添加到图纸

这对AI意味着什么?

- AI可以像人类工程师一样思考,理解每个几何元素的含义

- 可以精确控制颜色、图层、线型等所有属性

- 能处理复杂的空间变换和几何计算

- 生成的代码质量更高,更符合工程规范

2. 智能体策略 - 让AI像专业工程师一样思考

我们设计了三种AI智能体,各自负责不同的专业领域:

A.建模智能体(ModelingAgent)

专业领域 :CAD图形创建和迭代修改

工作流程

1. 接收自然语言指令(如"画一个带圆角的矩形,长100宽60,圆角半径5")

2. 分析需求,拆解为几何元素

3. 生成精确的MxCAD代码

4. 在沙箱中预览效果

5. 自动修复可能的错误

6. 最终插入到图纸中

技术亮点

- 支持代码迭代修改:"刚才那个矩形,把圆角改成10"

- 自动管理实体数组,避免重复和遗漏

- 智能错误修复:代码执行失败时自动分析错误并修复

- 最多重试3次,确保成功率  

B.通用智能体(DefaultAgent)

专业领域 :CAD图纸操作和查询

典型任务

- "选中所有长度大于100的直线"

- "把图层"标注"的颜色改成红色"

- "计算这个区域的面积"

- "导出选中的实体为DXF"  

技术亮点

- 理解CAD专业术语和概念

- 能操作图层、线型、标注等CAD特有功能

- 支持复杂的选择条件和过滤

C.意图识别智能体(IntentRecognitionAgent)

角色 :智能调度员

工作原理

1. 关键词匹配:快速识别用户意图(如包含"画"、"创建"等词 → 建模智能体)

2. LLM深度分析:复杂请求调用大语言模型分析

3. 智能路由:自动选择最合适的智能体处理

优势:用户无需手动选择模式,系统智能判断

3.安全沙箱-让AI代码安全运行

为什么需要沙箱?

AI生成的代码可能包含错误、无限循环或恶意代码,直接在主应用中执行可能导致崩溃或数据丢失。

我们的解决方案

用户输入 → AI生成代码 → 沙箱预览 → 错误检测 → 自动修复 → 用户确认 → 插入图纸

技术实现

- 在隔离的iframe中执行代码

- 限制访问DOM和本地存储

- 捕获所有错误信息

- 提供详细的错误堆栈分析

- 支持最多3次自动修复尝试  

用户体验

- 实时预览AI绘图效果

- 确认无误后再插入图纸

- 随时取消或重新生成

- 支持撤销操作

AI功能详解

自然语言到CAD代码转换

示例1:绘制复杂机械零件

用户:画一个法兰盘,外径200,内径1006个直径20的螺栓孔均匀分布
AI生成代码:
// 创建外圆
const outerCircle = new McDbCircle();
outerCircle.center = new McGePoint3d(0, 0, 0);
outerCircle.radius = 100;
entitys.push(outerCircle);
// 创建内圆
const innerCircle = new McDbCircle();
innerCircle.center = new McGePoint3d(0, 0, 0);
innerCircle.radius = 50;
entitys.push(innerCircle);
// 创建6个螺栓孔
for (let i = 0; i < 6; i++) {
  const angle = (i * 60) * Math.PI / 180;
  const hole = new McDbCircle();
  hole.center = new McGePoint3d(75 * Math.cos(angle), 75 * Math.sin(angle), 0);
  hole.radius = 10;
  entitys.push(hole);
}

示例2:建筑平面图

用户:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央
AI自动计算所有尺寸,生成符合建筑规范的墙体和门窗

智能代码优化

自动补全import语句

// AI生成的代码可能缺少import
const line = new McDbLine(); // 错误:McDbLine未定义
// 系统自动补全
import { McDbLine, McGePoint3d } from "mxcad";
const line = new McDbLine(); // 正确

管理实体数组

// AI可能忘记将实体添加到图纸
const circle = new McDbCircle();
// 缺少 entitys.push(circle);
// 系统自动检测并添加
const circle = new McDbCircle();
entitys.push(circle); // 自动添加

智能修复语法错误

// AI可能生成有语法错误的代码
const point = new McGePoint3d(0, 0, 0) // 缺少分号
// 系统自动修复
const point = new McGePoint3d(0, 0, 0); // 自动添加分号

多AI模型支持

支持的AI提供商

- OpenRouter:统一接口,支持DeepSeek、Llama、Gemini等100+模型

- OpenAI:GPT-4、GPT-3.5等官方模型

- iFlow:国产大模型,包括通义千问、Kimi、DeepSeek等

- 自定义:支持任何OpenAI兼容的API

模型选择策略

- 免费模型:适合测试和简单任务

- 付费模型:适合复杂任务和高质量要求

- 国产模型:适合数据安全要求高的场景

实际应用场景

场景一:建筑工程师 - 快速绘制标准户型

传统方式

1. 打开CAD软件

2. 选择画线工具

3. 输入起点坐标(0,0)

4. 输入终点坐标(10000,0)  // 10米墙

5. 重复步骤3-4,画4面墙

6. 选择偏移工具,偏移240mm生成内墙线

7. 选择修剪工具,修剪墙角

8. 插入门、窗图块

9. 添加尺寸标注

10. 整个过程约15-30分钟  

AI方式

输入:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央,窗宽1500mm在左侧墙中央
AI响应:✅ 已生成标准房间平面图
- 外墙:10m×8m,墙厚240mm
- 门:900mm宽,位于右侧墙中央
- 窗:1500mm宽,位于左侧墙中央
- 已添加尺寸标注
用时:10秒

场景二:机械设计师 - 参数化零件设计

传统方式

- 手动计算所有尺寸

- 逐个绘制每个特征

- 容易出错,修改困难

AI方式

输入:生成一个M10螺栓,长度50mm,头部六角对边16mm

AI响应:✅ 已生成M10螺栓模型

  • 螺纹公称直径:10mm
  • 螺栓长度:50mm
  • 六角头对边宽度:16mm
  • 符合GB/T 5782标准 用时:5秒

场景三:图纸修改-智能批量操作

传统方式

- 手动查找需要修改的元素

- 逐个修改,耗时且容易遗漏

AI方式

输入:把所有标注文字的字体改成仿宋,字高改为3.5mm

AI响应:✅ 已修改23个标注对象

  • 字体:仿宋
  • 字高:3.5mm
  • 修改对象:23个尺寸标注 用时:3秒  

技术架构深度解析

代码执行流程

代码执行流程.png

核心模块说明

1. agents/AgentStrategy.ts

- 智能体策略接口定义

- 智能体实例管理

- 智能体选择逻辑

2. agents/ModelingAgent.ts

- CAD建模专用智能体

- 代码生成与修改

- 错误自动修复

3. agents/IntentRecognitionAgent.ts

- 用户意图识别

- 智能体路由调度

- 对话状态管理

4. core/LLMClient.ts

- 多AI提供商支持

- 请求管理与取消

- 错误处理与重试

5. core/codeModificationUtils.ts

- 代码智能修改

- JSON指令解析

- 语法错误修复

6. sandbox.ts

- 沙箱环境初始化

- 代码安全执行

- 错误信息捕获

7. services/openRouterAPI.ts

- AI模型管理

- API配置管理

- 模型缓存机制  

快速体验AI智能体服务

首先打开demo2.mxdraw3d.com:3000/mxcad/, 如下图: 点击使用AI服务.png 打开AI服务会弹出一个胶囊输入框。我们点击设置按钮,如下图: 打开设置按钮.png 我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图: 设置apiKey的弹框.png

具有配置如下:

首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图: iflow找到api管理设置.png

我们把api key填写到MxCAD AI服务中,如下图: iflow复制apikey.png

选择模型商: iFlow

填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。

然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图: 需求:花一朵花.png 等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图: 需求:花一朵花的效果.png 比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图: 需求:花不够精致.png

需求:花不够精致的效果.png 我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。

比如:"选中实体按照原本比例放大10倍,间距还是原本的间距" image.png 我们点击生成的代码点击运行,效果就出来了,如下图: image-1.png 还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。

Konvajs实现虚拟表格

这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中

本文涉及的代码

虚拟表格

虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。

实现原理

一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)

  1. 按需渲染:只创建和渲染用户当前能看到的数据行和列
  2. 滚动监听:监听容器滚动事件,动态计算新的可见范围

代码大纲

基于上述原理,我们可以写出如下代码:

import Konva from "konva";
import { Layer } from "konva/lib/Layer";
import { Stage } from "konva/lib/Stage";

export type Column = {
  title: string;
  width: number;
};

type VirtualTableConfig = {
  container: HTMLDivElement;
  columns: Column[];
  dataSource: Record<string, any>[];
};

type Range = { start: number; end: number };

class VirtualTable {
  // =========== 表格基础属性 ===========
  rows: number = 20;
  cols: number = 20;
  columns: Column[];
  stage: Stage;
  layer: Layer;
  dataSource: TableDataSource;

  // =========== 虚拟表格实现 ===========
  // 滚动相关属性
  scrollTop: number = 0;
  scrollLeft: number = 0;
  maxScrollTop: number = 0;
  maxScrollLeft: number = 0;
  visibleRowCount: number = 0;
  // 可见行列范围
  visibleRows: Range = { start: 0, end: 0 };
  visibleCols: Range = { start: 0, end: 0 };
  // 表格可见宽高
  visibleWidth: number;
  visibleHeight: number;

  constructor(config: VirtualTableConfig) {
    const { container, columns, dataSource } = config;
    this.columns = columns;
    this.dataSource = dataSource;
    this.visibleWidth = container.getBoundingClientRect().width;
    this.visibleHeight = container.getBoundingClientRect().height;
    this.visibleRowCount = Math.ceil(this.visibleHeight / ROW_HEIGHT);
    this.maxScrollTop = Math.max(
      0,
      (this.rows - this.visibleRowCount) * ROW_HEIGHT
    );

    // 计算总列宽
    const totalColWidth = this.columns.reduce((sum, col) => sum + col.width, 0);
    this.maxScrollLeft = Math.max(0, totalColWidth - this.visibleWidth);

    this.stage = new Konva.Stage({
      container,
      height: this.visibleHeight,
      width: this.visibleWidth,
    });
    this.layer = new Konva.Layer();
    this.stage.add(this.layer);

    // 监听滚动事件
    this.bindScrollEvent(container);
    // 初始化调用
    this.updateVisibleRange();
    this.renderCells();
  }

  // 监听滚动事件
  bindScrollEvent() {
    this.updateVisibleRange();
    this.renderCells();
  }

  // 计算可见行列范围
  updateVisibleRange() {}

  // 渲染可见范围内的 cell
  renderCells() {}
}

export default VirtualTable;

计算可见行列范围

updateVisibleRange() {
    // 计算可见行
    const startRow = Math.floor(this.scrollTop / ROW_HEIGHT);
    const endRow = Math.min(
      startRow + this.visibleRowCount,
      this.dataSource.length
    );
    this.visibleRows = { start: startRow, end: endRow };

    // 计算可见列
    let accumulatedWidth = 0;
    let startCol = 0;
    let endCol = 0;

    // 计算开始列
    for (let i = 0; i < this.columns.length; i++) {
      const col = this.columns[i];
      if (accumulatedWidth + col.width >= this.scrollLeft) {
        startCol = i;
        break;
      }
      accumulatedWidth += col.width;
    }

    // 计算结束列
    accumulatedWidth = 0;
    for (let i = startCol; i < this.columns.length; i++) {
      const col = this.columns[i];
      accumulatedWidth += col.width;
      if (accumulatedWidth > this.visibleWidth) {
        endCol = i + 1;
        break;
      }
    }

    this.visibleCols = {
      start: startCol,
      end: Math.min(endCol, this.columns.length),
    };
  }

滚动事件监听


  /**
   * 绑定滚动事件
   */
  bindScrollEvent(container: HTMLDivElement) {
    container.addEventListener("wheel", (e) => {
      e.preventDefault();
      this.handleScroll(e.deltaX, e.deltaY);
    });

    // 支持触摸滚动
    let lastTouchY = 0;
    let lastTouchX = 0;
    container.addEventListener("touchstart", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });

    container.addEventListener("touchmove", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        const deltaY = lastTouchY - touch.clientY;
        const deltaX = lastTouchX - touch.clientX;
        this.handleScroll(deltaX, deltaY);
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });
  }

  /**
   * 处理滚动
   */
  handleScroll(deltaX: number, deltaY: number) {
    // 更新滚动位置
    this.scrollTop = Math.max(
      0,
      Math.min(this.scrollTop + deltaY, this.maxScrollTop)
    );
    this.scrollLeft = Math.max(
      0,
      Math.min(this.scrollLeft + deltaX, this.maxScrollLeft)
    );

    // 更新可见行列范围
    this.updateVisibleRange();

    // 更新单元格渲染
    this.renderCells();
  }

单元格渲染逻辑


  /**
   * 获取指定行的 Y 坐标
   * @param rowIndex - 行索引
   * @returns Y 坐标值
   */
  getRowY(rowIndex: number): number {
    return rowIndex * ROW_HEIGHT;
  }

  /**
   * 获取指定列的 X 坐标
   * @param colIndex - 列索引
   * @returns X 坐标值
   */
  getColX(colIndex: number): number {
    let x = 0;
    for (let i = 0; i < colIndex; i++) {
      const col = this.columns[i];
      if (col) {
        x += col.width;
      }
    }
    return x;
  }

  renderCell(rowIndex: number, colIndex: number) {
    const column = this.columns[colIndex];
    if (!column) return;
    // 计算坐标时考虑滚动偏移
    const x = this.getColX(colIndex) - this.scrollLeft;
    const y = this.getRowY(rowIndex) - this.scrollTop;
    // 创建单元格
    const group = new Konva.Group({
      x,
      y,
    });
    const rect = new Konva.Rect({
      x: 0,
      y: 0,
      width: column.width,
      height: ROW_HEIGHT,
      fill: "#FFF",
      stroke: "#ccc",
      strokeWidth: 1,
    });

    // 创建文本
    const text = new Konva.Text({
      x: 8,
      y: 8,
      width: column.width - 16,
      height: 16,
      text: this.dataSource[rowIndex][colIndex],
      fontSize: 14,
      fill: "#000",
      align: "left",
      verticalAlign: "middle",
      ellipsis: true,
    });
    group.add(rect);
    group.add(text);
    this.layer.add(group);
  }

  /**
   * 渲染可见范围内的所有单元格
   * 首先清除旧单元格,然后按行列重新渲染
   */
  renderCells() {
    this.layer.destroyChildren();
    // 渲染数据行
    for (
      let rowIndex = this.visibleRows.start;
      rowIndex <= this.visibleRows.end;
      rowIndex++
    ) {
      for (
        let colIndex = this.visibleCols.start;
        colIndex <= this.visibleCols.end;
        colIndex++
      ) {
        this.renderCell(rowIndex, colIndex);
      }
    }
  }

本文涉及的代码

Bipes项目二次开发/设置功能-1(五)

Bipes项目二次开发/设置功能-1(五)

设置功能,这一期改动有点多,可能后期也会继续出设置功能-n文章,这一期是编程模式,那做目的有两个: 1,代码设计 现在确定做的模式有三种,硬件编程,离线编程,海龟编程三种。每种模式所涉及的代码不同,所以得划分出来。

2,可配置性 后期可能会出一些定制开发,界面就可以通过配置,进行界面调整。

编程模式

html

页面初始内容

<div class="settings-preview">
    <div id="settings-modal">
      <h3>设置</h3>
      <div class="settings-group">
        <label>模式选择</label>
        <div class="radio-group">
          <div class="radio-option">
            <input type="radio" id="mode-hardware" name="programMode" value="hardware" checked>
            <span>硬件编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-offline" name="programMode" value="offline">
            <span>离线编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-turtle" name="programMode" value="turtle">
            <span>海龟编程</span>
          </div>
        </div>
      </div>
      <div class="modal-actions">
        <button id="cancel-settings" class="btn btn-secondary">取消</button>
        <button id="save-settings" class="btn btn-primary">保存</button>
      </div>
    </div>
  </div>

js

import Common from "./common";

export default class SettingPreview extends Common {
    constructor() {
        super()
        this.state = false // 设置弹窗是否显示
    }
    initEvent() {
        $('#settingsButton').on('click', () => {
            this.changeSetting(!this.state)
        })
        
        // 添加取消按钮事件监听
        $('#cancel-settings').on('click', () => {
            this.changeSetting(false)
        })
        
        // 添加确认按钮事件监听
        $('#save-settings').on('click', this.saveSettings.bind(this))
    }
    changeSetting(state) {
        let status = state ? 'on' : 'off'
        $('.settings-preview').css('visibility', (state ? 'visible' : 'hidden'))
        $('#settingsButton').css('background', `url(../media/new-icon/setting-${status}.png) center / cover`)
        
        if (state) {
            // 显示时可以加载已保存的设置
            this.loadSettings();
        }

        this.state = state
    }
    
    // 保存设置到本地缓存
    saveSettings() {
        // 获取选中的模式
        let selectedMode = 'hardware'; // 默认值
        const selectedRadio = $('input[name="programMode"]:checked');
        if (selectedRadio.length > 0) {
            selectedMode = selectedRadio.val();
        }
        
        // 创建设置对象并保存到本地缓存
        const settings = {
            mode: selectedMode
        };
        
        try {
            localStorage.setItem('settings', JSON.stringify(settings));
            console.log('设置已保存:', settings);
        } catch (error) {
            console.error('保存设置失败:', error);
        }

        this.changeSetting(false)
    }
    
    // 从本地缓存加载设置
    loadSettings() {
        try {
            const savedSettings = localStorage.getItem('settings');
            if (savedSettings) {
                const settings = JSON.parse(savedSettings);
                if (settings.mode) {
                    // 设置选中的单选按钮
                    $(`input[name="programMode"][value="${settings.mode}"]`).prop('checked', true);
                }
            }
        } catch (error) {
            console.error('加载设置失败:', error);
        }
    }
}

界面效果

在这里插入图片描述

总结

出这一期主要针对编程模式,不同模式下做不同功能。 硬件编程:保留原有功能,通过连接板子,与板子通信,在板子上运行编写好的代码,做出不同效果 离线编程:学习,了解编程 海龟编程:学习,了解编程,让编程变得不枯燥。

【Promise.withResolvers】发现这个api还挺有用

Jym好😘,我是珑墨。

在 es 的异步编程世界中,Promise 已经成为处理异步操作的标准方式。然而,在某些场景下,传统的 Promise 构造函数模式显得不够灵活。Promise.withResolvers 是 ES2024(ES14)中引入的一个静态方法,它提供了一种更优雅的方式来创建 Promise,并同时获得其 resolve 和 reject 函数的引用。

look:

什么是 Promise.withResolvers

Promise.withResolvers 是一个静态方法,它返回一个对象,包含三个属性:

  • promise: 一个 Promise 对象
  • resolve: 用于解决(fulfill)该 Promise 的函数
  • reject: 用于拒绝(reject)该 Promise 的函数

基本语法

const { promise, resolve, reject } = Promise.withResolvers();

这个方法的核心优势在于:你可以在 Promise 外部控制其状态,这在许多场景下非常有用。


为什么 Promise.withResolvers挺实用?

先看传统 Promise 的局限性

Promise.withResolvers 出现之前,如果我们想要在 Promise 外部控制其状态,通常需要这样做:

let resolvePromise;
let rejectPromise;

const myPromise = new Promise((resolve, reject) => {
  resolvePromise = resolve;
  rejectPromise = reject;
});

// 现在可以在外部使用 resolvePromise 和 rejectPromise
setTimeout(() => {
  resolvePromise('成功!');
}, 1000);

这种方法虽然可行,但存在以下问题:

  1. 代码冗余:每次都需要创建临时变量,会导致一坨地雷
  2. 作用域污染:需要在外部作用域声明变量
  3. 不够优雅:代码结构不够清晰
  4. 容易出错:如果忘记赋值,会导致运行时错误

Promise.withResolvers解决了啥?

Promise.withResolvers 解决了上述所有问题:

const { promise, resolve, reject } = Promise.withResolvers();

// 简洁、清晰、安全
setTimeout(() => {
  resolve('成功!');
}, 1000);

语法和用法

基本语法

const { promise, resolve, reject } = Promise.withResolvers();

返回值

Promise.withResolvers() 返回一个普通对象,包含:

  • promise: 一个处于 pending 状态的 Promise 对象
  • resolve: 一个函数,调用时会将 promise 变为 fulfilled 状态
  • reject: 一个函数,调用时会将 promise 变为 rejected 状态

基本示例

示例 1:简单的延迟解析

const { promise, resolve } = Promise.withResolvers();

// 1 秒后解析 Promise
setTimeout(() => {
  resolve('数据加载完成');
}, 1000);

promise.then(value => {
  console.log(value); // 1 秒后输出: "数据加载完成"
});

示例 2:处理错误

const { promise, resolve, reject } = Promise.withResolvers();

// 模拟异步操作
setTimeout(() => {
  const success = Math.random() > 0.5;
  if (success) {
    resolve('操作成功');
  } else {
    reject(new Error('操作失败'));
  }
}, 1000);

promise
  .then(value => console.log(value))
  .catch(error => console.error(error));

示例 3:多次调用 resolve/reject 的行为

const { promise, resolve, reject } = Promise.withResolvers();

resolve('第一次');
resolve('第二次'); // 无效,Promise 状态已确定
reject(new Error('错误')); // 无效,Promise 状态已确定

promise.then(value => {
  console.log(value); // 输出: "第一次"
});

重要提示:一旦 Promise 被 resolve 或 reject,其状态就确定了,后续的 resolve 或 reject 调用将被忽略。


与传统方法的对比

场景 1:事件监听器中的 Promise

传统方法

function waitForClick() {
  let resolveClick;
  let rejectClick;
  
  const promise = new Promise((resolve, reject) => {
    resolveClick = resolve;
    rejectClick = reject;
  });
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    rejectClick(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolveClick(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

使用 Promise.withResolvers

function waitForClick() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    reject(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolve(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

优势

  • 代码更简洁
  • 不需要在外部作用域声明变量
  • 结构更清晰

场景 2:流式数据处理

传统方法

function createStreamProcessor() {
  let resolveStream;
  let rejectStream;
  
  const promise = new Promise((resolve, reject) => {
    resolveStream = resolve;
    rejectStream = reject;
  });
  
  // 模拟流式处理
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolveStream(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    rejectStream(error);
  }
  
  return { promise, processChunk, handleError };
}

使用 Promise.withResolvers

function createStreamProcessor() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolve(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    reject(error);
  }
  
  return { promise, processChunk, handleError };
}

实际应用场景

场景 1:用户交互等待

// 等待用户确认操作
function waitForUserConfirmation(message) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const modal = document.createElement('div');
  modal.className = 'confirmation-modal';
  modal.innerHTML = `
    <p>${message}</p>
    <button class="confirm">确认</button>
    <button class="cancel">取消</button>
  `;
  
  modal.querySelector('.confirm').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    resolve(true);
  });
  
  modal.querySelector('.cancel').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    reject(new Error('用户取消'));
  });
  
  document.body.appendChild(modal);
  
  return promise;
}

// 使用
waitForUserConfirmation('确定要删除这个文件吗?')
  .then(() => console.log('用户确认'))
  .catch(() => console.log('用户取消'));

场景 2:WebSocket 消息等待

class WebSocketManager {
  constructor(url) {
    this.ws = new WebSocket(url);
    this.pendingRequests = new Map();
    this.requestId = 0;
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const { requestId, response, error } = data;
      
      const pending = this.pendingRequests.get(requestId);
      if (pending) {
        this.pendingRequests.delete(requestId);
        if (error) {
          pending.reject(new Error(error));
        } else {
          pending.resolve(response);
        }
      }
    };
  }
  
  sendRequest(message) {
    const { promise, resolve, reject } = Promise.withResolvers();
    const requestId = ++this.requestId;
    
    this.pendingRequests.set(requestId, { resolve, reject });
    
    this.ws.send(JSON.stringify({
      requestId,
      message
    }));
    
    // 设置超时
    setTimeout(() => {
      if (this.pendingRequests.has(requestId)) {
        this.pendingRequests.delete(requestId);
        reject(new Error('请求超时'));
      }
    }, 5000);
    
    return promise;
  }
}

// 使用
const wsManager = new WebSocketManager('ws://example.com');
wsManager.sendRequest('获取用户信息')
  .then(data => console.log('收到响应:', data))
  .catch(error => console.error('错误:', error));

场景 3:文件上传进度

function uploadFileWithProgress(file, url) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const xhr = new XMLHttpRequest();
  const formData = new FormData();
  formData.append('file', file);
  
  xhr.upload.addEventListener('progress', (event) => {
    if (event.lengthComputable) {
      const percentComplete = (event.loaded / event.total) * 100;
      console.log(`上传进度: ${percentComplete.toFixed(2)}%`);
    }
  });
  
  xhr.addEventListener('load', () => {
    if (xhr.status === 200) {
      resolve(JSON.parse(xhr.responseText));
    } else {
      reject(new Error(`上传失败: ${xhr.status}`));
    }
  });
  
  xhr.addEventListener('error', () => {
    reject(new Error('网络错误'));
  });
  
  xhr.addEventListener('abort', () => {
    reject(new Error('上传已取消'));
  });
  
  xhr.open('POST', url);
  xhr.send(formData);
  
  // 返回 Promise 和取消函数
  return {
    promise,
    cancel: () => xhr.abort()
  };
}

// 使用
const { promise, cancel } = uploadFileWithProgress(file, '/api/upload');
promise
  .then(result => console.log('上传成功:', result))
  .catch(error => console.error('上传失败:', error));

场景 4:可取消的异步操作

function createCancellableOperation(operation) {
  const { promise, resolve, reject } = Promise.withResolvers();
  let cancelled = false;
  
  operation()
    .then(result => {
      if (!cancelled) {
        resolve(result);
      }
    })
    .catch(error => {
      if (!cancelled) {
        reject(error);
      }
    });
  
  return {
    promise,
    cancel: () => {
      cancelled = true;
      reject(new Error('操作已取消'));
    }
  };
}

// 使用
const { promise, cancel } = createCancellableOperation(
  () => fetch('/api/data').then(r => r.json())
);

// 3 秒后取消
setTimeout(() => cancel(), 3000);

promise
  .then(data => console.log('数据:', data))
  .catch(error => console.error('错误:', error));

场景 5:队列处理

class TaskQueue {
  constructor() {
    this.queue = [];
    this.processing = false;
  }
  
  add(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    
    this.queue.push({
      task,
      resolve,
      reject
    });
    
    this.process();
    
    return promise;
  }
  
  async process() {
    if (this.processing || this.queue.length === 0) {
      return;
    }
    
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      
      try {
        const result = await task();
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
    
    this.processing = false;
  }
}

// 使用
const queue = new TaskQueue();

queue.add(() => fetch('/api/task1').then(r => r.json()))
  .then(result => console.log('任务1完成:', result));

queue.add(() => fetch('/api/task2').then(r => r.json()))
  .then(result => console.log('任务2完成:', result));

深入理解:工作原理

Promise.withResolvers 的实现原理

虽然 Promise.withResolvers 是原生 API,但我们可以通过理解其等价实现来加深理解:

// Promise.withResolvers 的等价实现
function withResolvers() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

内存和性能考虑

Promise.withResolvers 的实现是高度优化的。它:

  1. 避免闭包开销:原生实现避免了额外的闭包创建
  2. 内存效率:直接返回引用,无需额外的变量存储
  3. 性能优化:浏览器引擎级别的优化

与 Promise 构造函数的关系

// 这两种方式是等价的(在功能上)
const { promise, resolve, reject } = Promise.withResolvers();

// 等价于
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

Promise.withResolvers 提供了:

  • 更简洁的语法
  • 更好的可读性
  • 标准化的 API

浏览器兼容性和 Polyfill

浏览器支持

Promise.withResolvers 是 ES2024 的特性,目前(2024年)的支持情况:

  • ✅ Chrome 119+
  • ✅ Firefox 121+
  • ✅ Safari 17.4+
  • ✅ Node.js 22.0.0+
  • ❌ 旧版本浏览器不支持

Polyfill 实现

如果需要在不支持的浏览器中使用,可以使用以下 polyfill:

if (!Promise.withResolvers) {
  Promise.withResolvers = function() {
    let resolve, reject;
    const promise = new Promise((res, rej) => {
      resolve = res;
      reject = rej;
    });
    return { promise, resolve, reject };
  };
}

使用 Polyfill 的完整示例

// 在项目入口文件添加
(function() {
  if (typeof Promise.withResolvers !== 'function') {
    Promise.withResolvers = function() {
      let resolve, reject;
      const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
      });
      return { promise, resolve, reject };
    };
  }
})();

// 现在可以在任何地方使用
const { promise, resolve, reject } = Promise.withResolvers();

使用 core-js

如果你使用 core-js,可以导入相应的 polyfill:

import 'core-js/actual/promise/with-resolvers';

最佳实践和注意

1. 避免重复调用 resolve/reject

const { promise, resolve, reject } = Promise.withResolvers();

resolve('第一次');
resolve('第二次'); // 无效,但不会报错

// 最佳实践:添加状态检查
let isResolved = false;
function safeResolve(value) {
  if (!isResolved) {
    isResolved = true;
    resolve(value);
  }
}

2. 处理错误情况

const { promise, resolve, reject } = Promise.withResolvers();

try {
  // 某些可能抛出错误的操作
  const result = riskyOperation();
  resolve(result);
} catch (error) {
  reject(error);
}

3. 清理资源

function createResourceManager() {
  const { promise, resolve: originalResolve, reject: originalReject } = Promise.withResolvers();
  const resources = [];
  
  function cleanup() {
    resources.forEach(resource => resource.cleanup());
  }
  
  // 创建包装函数,确保在 resolve 或 reject 时清理资源
  const resolve = (value) => {
    cleanup();
    originalResolve(value);
  };
  
  const reject = (error) => {
    cleanup();
    originalReject(error);
  };
  
  return { promise, resolve, reject };
}

4. 类型安全(TypeScript)

在 TypeScript 中,Promise.withResolvers 的类型定义:

interface PromiseWithResolvers<T> {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
}

// 使用
const { promise, resolve, reject }: PromiseWithResolvers<string> = 
  Promise.withResolvers<string>();

5. 避免内存泄漏

// 不好的做法:持有大量未完成的 Promise,没有清理机制
const pendingPromises = new Map();

function createRequest(id) {
  const { promise, resolve } = Promise.withResolvers();
  pendingPromises.set(id, { promise, resolve });
  return promise;
  // 问题:如果 Promise 永远不会 resolve,会一直占用内存
}

// 好的做法:设置超时和清理机制
const pendingPromises = new Map(); // 在实际应用中,这应该是类或模块级别的变量

function createRequestWithTimeout(id, timeout = 5000) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const timeoutId = setTimeout(() => {
    if (pendingPromises.has(id)) {
      pendingPromises.delete(id);
      reject(new Error('请求超时'));
    }
  }, timeout);
  
  pendingPromises.set(id, {
    promise,
    resolve: (value) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      resolve(value);
    },
    reject: (error) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      reject(error);
    }
  });
  
  return promise;
}

6. 与 async/await 结合使用

async function processWithResolvers() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  // 在异步操作中控制 Promise
  setTimeout(() => {
    resolve('完成');
  }, 1000);
  
  try {
    const result = await promise;
    console.log('结果:', result);
  } catch (error) {
    console.error('错误:', error);
  }
}

总结下

Promise.withResolvers 是 es 异步编程的一个重要补充,它解决了在 Promise 外部控制其状态的需求。通过本文的详细讲解,我们了解到:

核心要点

  1. 简洁性:提供了更优雅的 API 来创建可外部控制的 Promise
  2. 实用性:在事件处理、流式处理、WebSocket 等场景中非常有用
  3. 标准化:作为 ES2024 标准的一部分,提供了统一的解决方案

适用场景

  • ✅ 需要在 Promise 外部控制其状态
  • ✅ 事件驱动的异步操作
  • ✅ 流式数据处理
  • ✅ 可取消的异步操作
  • ✅ 队列和任务管理

注意

  • ⚠️ 浏览器兼容性(需要 polyfill 或现代浏览器)
  • ⚠️ 尤其得避免重复调用 resolve/reject
  • ⚠️ 注意资源清理和内存管理

参考资料


原来Webpack在大厂中这样进行性能优化!

性能优化方案

优化分类:

  1. 优化打包后的结果(分包、减小包体积、CDN 服务器) ==> 更重要
  2. 优化打包速度(exclude、cache-loader)

代码分割(Code Splitting)

一、主要目的

  • 减少首屏加载体积:避免一次性加载全部代码
  • 利用浏览器缓存:第三方库(如 React、Lodash)变动少,可单独缓存
  • 按需加载/并行请求:路由、组件、功能模块只在需要时加载(按需加载或者并行加载文件,而不是一次性加载所有代码)

二、三种主要的代码分割方式

1. 入口起点(Entry Points)手动分割

通过配置多个 entry 实现。

// webpack.config.js
module.exports = {
  entry: {
    main: './src/main.js',
    vendor: './src/vendor.js', // 手动引入公共依赖
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
};

缺点:

  • 无法自动提取公共依赖(比如 mainvendor 都用了 Lodash,会重复打包)
  • 维护成本高

上面写的是通用配置,但我们在公司一般会分别配置开发和生产环境的配置。大多数项目中,entry 在 dev 和 prod 基本一致,无需差异化配置。差异主要体现在 output 和其他插件/加载器行为上。

// webpack.config.prod.js
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].[contenthash:8].js', // 生产环境用 [contenthash](而非 [hash] 或 [chunkhash]),确保精准缓存
    chunkFilename: 'js/[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist'), // 必须输出到磁盘用于部署
    publicPath: '/static/', // 用于 CDN 或静态资源服务器
    clean: true, // 清理旧文件
  },
};
// webpack.config.dev.js
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].js',  // 开发环境若加 hash,每次保存都会生成新文件,可能干扰热更新或者devtools混乱
    chunkFilename: 'js/[name].js',
    path: path.resolve(__dirname, 'dist'), // 通常仍写 dist,但实际不写入磁盘(webpack-dev-server 默认内存存储),节省IO,提高编译速度
    publicPath: '/', // 与 devServer 一致
    // clean: false (默认)
  },
};
2. SplitChunksPlugin(推荐!自动代码分割)

自动提取公共模块和第三方库。webpack 已默认安装相关插件。

默认行为(仅在 production 模式生效):

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async', // 默认只分割异步模块
    },
  },
};

常用配置:

// webpack.config.prod.js
optimization: { 
  // 自动分割
  // https://twitter.com/wSokra/status/969633336732905474
  // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366    
  splitChunks: {
    // chunks: async | initial(对通过的代码处理) | all(同步+异步都处理)
    chunks: 'initial',
    minSize: 20000, // 模块大于 20KB 才分割(Webpack 5 默认值)
    maxSize: 244000, // 单个 chunk 最大不超过 244KB(可选)
    cacheGroups: { // 拆分分组规则
      // 提取 node_modules 中的第三方库
      vendor: {
        test: /[\\/]node_modules[\\/]/, // 匹配符合规则的包
        name: 'vendors', // 拆分包的name 属性
        chunks: 'initial',
        priority: 10, // 优先级高于 default
        enforce: true,
      },
      // 提取多个 chunk 公共代码
      default: {
        minChunks: 2, // 至少被 2 个 chunk 引用
        priority: -20,
        reuseExistingChunk: true, // 复用已存在的 chunk
        maxInitialRequests: 5, // 默认限制太小,无法显示效果
        minSize: 0, // 这个示例太小,无法创建公共块
      },
    },
  },
  // runtime相关的代码是否抽取到一个单独的chunk中,比如import动态加载的代码就是通过runtime 代码完成的
  // 抽离出来利于浏览器缓存,比如修改了业务代码,那么runtime加载的chunk无需重新加载
  runtimeChunk: true,
}

在开发环境下 splitChunks: false, 即可。

生产环境:

  • 生成 vendors.xxxx.js(第三方库)
  • 生成 default.xxxx.js(项目公共代码)
  • 主 bundle 体积显著减小
3. 动态导入(Dynamic Imports)—— 按需加载

使用 import() 语法(符合 ES Module 规范),实现懒加载。

Webpack 会为每个 import() 创建一个独立的 chunk,并自动处理加载逻辑。

三、魔法注释(Magic Comments)—— 控制 chunk 名称等行为

// 自定义 chunk 名称(便于调试和长期缓存)
const module = await import(
  /* webpackChunkName: "my-module" */
  './my-module'
);

其他常见注释:

  • /* webpackPrefetch: true */:空闲时预加载(提升后续访问速度)
  • /* webpackPreload: true */:当前导航关键资源预加载(慎用)
// 预加载“下一个可能访问”的页面
import(
  /* webpackChunkName: "login-page" */
  /* webpackPrefetch: true */
  './LoginPage'
);

详细比较:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

CND

内容分发网络(Content Delivery Network 或 Content Distribution Network)

它是指通过相互连接的网络系统,利用最靠近每个用户的服务器;更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户;提供高性能、可扩展性及低成本的网络内容传递。

工作中,我们使用 CDN 的主要方式有两种:

  1. 打包所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CND 服务器加载的
    1. 通过 output.publicPath 改为自己的的 CDN 服务器,打包后就可以从上面获取资源
    2. 如果是自己的话,一般会从阿里、腾讯等买 CDN 服务器。
  2. 一些第三方资源放在 CDN 服务器上
    1. 一些库/框架会将打包后的源码放到一些免费的 CDN 上,比如 JSDeliver、bootcdn 等
    2. 这样的话,打包的时候就不需要对这些库进行打包,直接使用 CDN 服务器中的源码(通过 externals 配置排除某些包)

CSS 提取

将 css 提取到一个独立的 css 文件。

npm install mini-css-extract-plugin -D
// webpack.config.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  module: {
    rules: [
      // 生产环境:使用 MiniCssExtractPlugin.loader
      {
        test: /\.css$/i,
        use: [
          MiniCssExtractPlugin.loader, // 替换 style-loader
          'css-loader',
          'postcss-loader',
        ],
      },
      {
        test: /\.s[ac]ss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
    }),
  ],
};

Terser 代码压缩

Terser 可以帮助我们压缩、丑化(混淆)我们的代码,让我们的 bundle 变得更小。

Terser 是一个单独的工具,拥有非常多的配置,这里我们只讲工作中如何使用,以一个工程的角度学习这个工具。

真实开发中,我们不需要手动的通过 terser 来处理我们的代码。webpack 中 minimizer 属性,在 production 模式下,默认就是使用的 TerserPlugin 来处理我们代码的。我们也可以手动创建 TerserPlugin 实例覆盖默认配置。

// webpack.prod.js 
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true, // 多核 CPU 并行压缩,默认为true,并发数默认为os.cpus().length-1
        terserOptions: {
          compress: { // 压缩配置
            drop_console: true,
            drop_debugger: true, // 删除debugger
            pure_funcs: ['console.info', 'console.debug'], // 只删除特定的函数调用
          },
          mangle: true, // 是否丑化代码(变量)
          toplevel: true, // 顶层变量是否进行转换
          keep_classnames: true, // 是否保留类的名称
          keep_fnames: true, // 是否保留函数的名称
          format: {
            comments: /@license|@preserve/i, // 保留含 license/preserve 的注释(某些开源库要求保留版权注释)
          },
        },
        extractComments: true, // 默认为true会将注释提取到一个单独的文件(这里用于保留版权注释),false表示不希望保留注释
        sourceMap: true,   // 需要 webpack 配置 devtool 生成 source map
      }),
    ],
  },
};

不要在开发环境启动 terser,因为:

  • 压缩会拖慢构建速度
  • 混淆后的代码无法调试
  • hmr 和 source-map 会失效

CSS 压缩

CSS 压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;我们一般使用插件 css-minimizer-webpack-plugin;他的底层是使用 cssnano 工具来优化、压缩 CSS(也可以单独使用)。

使用也是非常简单:

minimizer: [
  new CssMiniMizerPlugin()({
    parallel: true
  })
]

Tree Shaking 摇树

详情见之前文章:《简单聊聊 webpack 摇树的原理》

HTTP 压缩

HTTP 压缩(HTTP Compression)是一种 在服务器和客户端之间传输数据时减小响应体体积 的技术,通过压缩 HTML、CSS、JavaScript、JSON 等文本资源,显著提升网页加载速度、节省带宽。

一、主流压缩算法

算法 兼容性 压缩率 速度 说明
gzip ✅ 几乎所有浏览器(IE6+) 最广泛使用,Web 标准推荐
Brotli (br) ✅ 现代浏览器(Chrome 49+, Firefox 44+, Safari 11+) ⭐ 更高(比 gzip 高 15%~30%) 较慢(压缩),解压快 推荐用于静态资源
deflate ⚠️ 支持不一致(部分浏览器实现有问题) 已基本淘汰,不推荐使用

二、工作原理(协商压缩)

HTTP 压缩基于 请求头 ↔ 响应头协商机制:

  1. 客户端请求(表明支持的压缩格式)
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br // 客户端支持的压缩算法列表
  1. 服务端响应(返回压缩后的内容)
HTTP/1.1 200 OK
Content-Encoding: br  // 服务端使用的压缩算法
Content-Type: application/javascript
Content-Length: 102400  // 注意:这是压缩后的大小!

...(二进制压缩数据)...
  • 浏览器自动解压,开发者无感知

三、如何启用 HTTP 压缩?

我们一般会优先使用 Nginx 配置做压缩(生产环境最常用),这样就无需应用层处理。

除此之外,我们还会进行预压缩 + 静态文件服务,这主要就是 webpack 要做的工作。

在构建阶段(Webpack/Vite)就生成 .gz.br 文件,部署到 CDN 或静态服务器。

// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [
    // 生成 .gz 文件
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 8192, // 大于 8KB 才压缩
      minRatio: 0.8,  // 至少的压缩比例
    }),
    // 生成 .br 文件(需额外安装)
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 }, // 最高压缩率
    }),
  ],
};

Nginx 配合预压缩文件:

gzip_static on;    # 优先返回 .gz 文件
brotli_static on;  # 优先返回 .br 文件

打包分析

打包时间分析

我们需要借助一个插件 speed-measure-webpack-plugin,即可看到每个 loader、每个 plugin 消耗的打包时间。

// webpack.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const smp = new SpeedMeasurePlugin();

const config = {
  // 你的正常 Webpack 配置
  entry: './src/index.js',
  module: { /* ... */ },
  plugins: [ /* ... */ ],
};

// 仅当环境变量 ANALYZE_SPEED=1 时包裹配置
module.exports = process.env.ANALYZE_SPEED ? smp.wrap(config) : config;

打包文件分析

方法一、生成 stats.json 文件
"build:stats": "w--config ./config/webpack.common.js --env production --profile --json=stats.json",

运行 npm run build:stats,可以获取到一个 stats.json 文件,然后放到到 webpack.github.com/analyse 进行分析。

方法二、webpack-bundle-analyzer

更常用的方式是使用 webpack-bundle-analyzer 插件分析。

// webpack.prod.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'production',
  plugins: [
    // 其他插件...
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态 HTML 报告(默认)
      openAnalyzer: false,    // 不自动打开浏览器
      reportFilename: 'bundle-report.html',
      generateStatsFile: true, // 可选:同时生成 stats.json
      statsFilename: 'stats.json',
    }),
  ],
};

MCP理论和实战,然后做个MCP脚手架吧

引言: 本文介绍了目前MCP Server的开发方式和原理,包括streamable HTTP和STDIO两种。并提供了一个npm脚手架工具帮你创建项目,每个模板项目都是可运行的。

streamable HTTP

原理分析

抓包「握手」

MCP Client总共发了三次请求,MCP Server响应2次。实际的握手流程是4次握手,第5次请求是为了通知后续的信息(比如进度,日志等。 目前规范实现来看,第5次握手不影响正常功能)

使用wiresshark抓包结果如下:

image.png

image.png

从官网的「initialization」流程来看,也就是4次(第5次未来应该会被普遍实现)

image.png

第1次 Post请求,initialize 方法

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "sampling": {},
      "elicitation": {},
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "inspector-client",
      "version": "0.17.2"
    }
  }
}

第2次 :200 OK,响应体如下

{
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "weather",
      "version": "0.0.1"
    }
  },
  "jsonrpc": "2.0",
  "id": 0
}

第3次 :Post请求,notifications/initialized方法

{"jsonrpc":"2.0","method":"notifications/initialized"}

第4次 :202 Accepted,无响应体

第5次 :Get请求,此时要求服务端一定是SSE传输了-accept: text/event-stream

GET /mcp HTTP/1.1
accept: text/event-stream

总结「握手」流程

  1. POST /mcp (initialize)

    • 客户端:你好,我是 Inspector Client,我想初始化。
    • 服务器:收到,这是我的能力列表(200 OK)。
    • 状态:JSON-RPC 会话开始。
  2. POST /mcp (notifications/initialized)

    • 客户端:我已经收到你的能力了,初始化完成。
    • 服务器:收到 (202 Accepted)。
    • 状态:逻辑握手完成。
  3. GET /mcp (Header: accept: text/event-stream)

    • 目的:客户端现在试图建立长连接通道,以便在未来能收到服务器发来的通知(比如 notifications/message 或 roots/listChanged)。如果没有这个通道,服务器就变成了“哑巴”,无法主动联系客户端。

后续通信

tools/list (列出工具)

client->server 请求

请求头:

POST /mcp HTTP/1.1
accept: application/json, text/event-stream
accept-encoding: gzip, deflate, br
content-length: 85
content-type: application/json
mcp-protocol-version: 2025-06-18
user-agent: node-fetch
Host: localhost:3000
Connection: keep-alive

请求数据:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "progressToken": 1
    }
  }
}

P.S. params中的progressToken是可以用于后续的进度通知的(通过SSE)

server->client 响应

响应头:

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json
Date: Thu, 27 Nov 2025 11:52:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

响应体:

{
  "result": {
    "tools": [
      {
        "name": "get_weather_now",
        "title": "Get Weather Now",
        "description": "Get current weather for a location (city name)",
        "inputSchema": {
          "$schema": "http://json-schema.org/draft-07/schema#",
          "type": "object",
          "properties": {
            "location": {
              "description": "Location name or city (e.g. beijing, shanghai, new york, tokyo)",
              "type": "string"
            }
          },
          "required": [
            "location"
          ]
        }
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 1
}

这里列出一个工具:

  • get_weather_now,我们自己定义/注册的工具。我们可以拿到它的titledescriptioninputSchema,这些语义信息可以帮助LLM理解这个工具。
tools/call (调用tool)

这里通过 mcp inspector 工具调用了get_weather_now,请求体如下:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "_meta": {
      "progressToken": 2
    },
    "name": "get_weather_now",
    "arguments": {
      "location": "北京"
    }
  }
}

响应体:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Weather for 北京, CN:\nCondition: 晴\nTemperature: 3°C\nLast Update: 2025-11-27T19:50:14+08:00"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}
方法小总结

上面我们列出了两种常见的方法

  • tools/list。MCP Client在向LLM发请求携带列出的tool,LLM会告诉客户端调用的tool name,然后由MCP client来触发tool调用。
  • tools/call。MCP Client告诉MCP Server 调用哪个tool。

可以结合官网的这张示意图,调用tool就是一次request/response。如果是长任务,可以通过_meta.progressToken作为关联,通过SSE持续通知进度(还记得「握手」流程的第5次握手吗)

image.png

代码实战 - 天气工具

准备天气API

这里我使用了心知天气的API,然后自己封装一个node API。 src/core/seniverse.ts

import * as crypto from 'node:crypto';
import * as querystring from 'node:querystring';
/**
 * 查询天气接口
 */
const API_URL = 'https://api.seniverse.com/v3/';
export class SeniverseApi {
    publicKey;
    secretKey;
    constructor(publicKey, secretKey) {
        this.publicKey = publicKey;
        this.secretKey = secretKey;
    }
    async getWeatherNow(location) {
        const params = {
            ts: Math.floor(Date.now() / 1000), // Current timestamp (seconds)
            ttl: 300, // Expiration time
            public_key: this.publicKey,
            location: location
        };
        // Step 2: Sort keys and construct the string for signature
        // "key=value" joined by "&", sorted by key
        const sortedKeys = Object.keys(params).sort();
        const str = sortedKeys.map(key => `${key}=${params[key]}`).join('&');
        // Step 3: HMAC-SHA1 signature
        const signature = crypto
            .createHmac('sha1', this.secretKey)
            .update(str)
            .digest('base64');
        // Step 4 & 5: Add sig to params and encode for URL
        // querystring.encode will handle URL encoding of the signature and other params
        params.sig = signature;
        const queryString = querystring.encode(params);
        const url = `${API_URL}weather/now.json?${queryString}`;
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.json();
        }
        catch (error) {
            console.error("Error making Seniverse request:", error);
            return null;
        }
    }
}

src/core/index.ts

import { SeniverseApi } from './seniverse.js';

export const seniverseApi = new SeniverseApi(
  process.env.SENIVERSE_PUBLIC_KEY || '',
  process.env.SENIVERSE_SECRET_KEY || '',
);

搭建streamable HTTP类型的MCP

1.使用express提供后端服务,然后设置/mcp endpoint(一般来说MCP client默认就是访问这个endpoint). 2.在MCP协议中,握手/工具调用等都是通过这个一个endpoint来完成的。

3.封装逻辑 封装了一个MyServer

  • run方法启动HTTP服务
  • init方法注册工具

4.核心是McpServerStreamableHTTPServerTransport两个API

  • McpServer: 负责注册tool.
  • StreamableHTTPServerTransport: 接管了/mcp endpoint的通信逻辑
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
import "dotenv/config";
import { seniverseApi } from "./core/index.js";

export class MyServer {
  private mcpServer: McpServer;
  private app: express.Express
  constructor() {
    this.mcpServer = new McpServer({
      name: "weather",
      version: "0.0.1",
    });

    // Set up Express and HTTP transport
    this.app = express();
    this.app.use(express.json());

    this.app.use('/mcp', async (req: express.Request, res: express.Response) => {
        // Create a new transport for each request to prevent request ID collisions
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined,
            enableJsonResponse: true
        });

        res.on('close', () => {
            transport.close();
        });

        await this.mcpServer.connect(transport);
        await transport.handleRequest(req, res, req.body);
    });

  }

  /**
   * 在端口运行Server, 通过HTTP stream传输数据
   */
  async run(): Promise<void> {
    const port = parseInt(process.env.PORT || '3000');
    this.app.listen(port, () => {
        console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
    }).on('error', error => {
        console.error('Server error:', error);
        process.exit(1);
    });
    
  }

  /**
   * 初始化,注册工具
   */
  async init(): Promise<void> {
    // Register weather tool
    this.mcpServer.registerTool(
      "get_weather_now",
      {
        title: "Get Weather Now",
        description: "Get current weather for a location (city name)",
        inputSchema: {
          location: z.string().describe("Location name or city (e.g. beijing, shanghai, new york, tokyo)")
        }
      },
      async ({ location }) => {
        
        const weatherData = await seniverseApi.getWeatherNow(location);
        if (!weatherData || !weatherData.results || weatherData.results.length === 0) {
          return {
            content: [
              {
                type: "text",
                text: `Failed to retrieve weather data for location: ${location}. Please check the location name and try again.`,
              },
            ],
          };
        }

        const result = weatherData.results[0];
        const weatherText = `Weather for ${result.location.name}, ${result.location.country}:\n` +
                            `Condition: ${result.now.text}\n` +
                            `Temperature: ${result.now.temperature}°C\n` +
                            `Last Update: ${result.last_update}`;
        return {
          content: [
            {
              type: "text",
              text: weatherText,
            },
          ],
        };
      },
    );
  }
}

效果如下:

image.png

注意左侧侧边栏:

  • Transport Type选择Streamable HTTP
  • URL 填写你的express 服务地址和endpoint。

stdio

原理分析

我在项目中,通过监听process.stdin,查看通信Message

// 监听 stdin 输入,可以在inspector面板的"notifications/message"中看到(作为debug用)
    process.stdin.on("data", async (data) => {
      const input = data.toString().trim();
      console.error(input);
    });

通过mcp-inspector工具就可以观察到通信信息了,往下看👁

tools/list

image.png

tools/call

image.png

结合官网的stdio通信原理图

image.png

可以总结如下:

  • 连接一个stdio MCP服务,不同于streamable HTTP MCP服务需要进行「握手」,只要开启一个子进程(subprocess),就表示连接成功。
  • 后续的通信的信息格式遵循json-rpc:2.0,通过读写process.stdinprocess.stdout完成通信。

代码实战 - 统计文件数

比较简单,可以参考我的这篇博客 Node写MCP入门教程,基于StdioServerTransport实现的统计目录下文件夹的MCP Server,并且介绍了mcp inspector的调试和Trae安装使用。

创建MCP项目的脚手架

每次写个新MCP Server都要搭建项目模板,这种重复的工作当然该做成工具辣! 我自己写了一个create-mcp脚手架 Githubcreate-mcp cli工具已经发布在npm上了,可以npm安装使用。

cli 原理

1.脚手架原理,首先准备两个模板项目

  • template-stdio 模板
  • template-streamable 模板

2.然后用Node写一个cli工具,使用了以下依赖,通过命令行交互的方式创建项目

pnpm i minimist prompts fs-extra chalk

3.根据你选择的项目名称和模板,帮你拷贝模板,修改为你的「项目名称」

觉得这个cli项目不错的话,给个免费的star吧~ 👉 Github

使用 cli

使用@caikengren-cli/create-mcp创建项目

npx @caikengren-cli/create-mcp

image.png

然后依次分别运行下面两个命令

# 编译ts/运行node
pnpm dev

# 打开 mcp-inspector工具调试
pnpm inspect

参考

mcp官网
mcp中文网
mcp: typescript-sdk

搭建简易版monorepo + turborepo

背景

  • 项目结构:pnpm Monorepo
    • packages/ui:React 组件库(使用 Vite + TS 打包)
    • apps/react-demo:React 应用,依赖 @my-org/ui
  • 目标:
    • ✅ 开发环境:修改 ui 源码 → 自动热更新到 react-demo
    • ✅ 生产构建:react-demo 能正确打包 @my-org/ui

遇到的问题 & 解决方案

❌ 问题 1:生产构建时报错 —— @my-org/ui 无法解析

// 错误信息
[vite]: Rolldown failed to resolve import "@my-org/ui" ...

🔎 根本原因:

  • react-demonode_modules/@my-org/ui/没有 ****dist/ ****目录
  • 导致 Vite 找不到 JS 入口文件(如 ui.js

✅ 解决步骤:

  1. 确认 ****packages/ui/package.json ****包含 ****"files": ["dist"]
    → 否则 pnpm 不会把 dist 链接到 consumer 的 node_modules
  2. 先构建 UI 库

pnpm --filter @my-org/ui build
  1. 确保 ****react-demo ****声明了依赖

pnpm add @my-org/ui@workspace:* --filter react-demo
  1. 强制刷新链接

pnpm install --force

💡 关键认知:pnpm workspace 链接 ≠ 实时目录映射,它只链接 package.json 中声明的文件(通过 files),且需在 dist 存在后执行 install

❌ 问题 2:package.json 入口文件名与实际输出不一致

  • 配置写的是:

"main": "./dist/index.js"
  • 但 Vite 默认输出:

dist/ui.js
dist/ui.mjs

🔎 后果:

  • 即使 dist 被链接,Vite 仍尝试加载不存在的 index.js → 模块解析失败

✅ 解决方案(二选一):

方案 操作
A(推荐) 修改 package.json指向真实文件: "main": "./dist/ui.js"
B 修改 vite.config.ts强制输出 index.jsfileName: (format) => index.${format === 'es' ? 'mjs' : 'js'}``

✅ 最终选择 方案 A,避免改构建配置,更简单直接。

❌ 问题 3:即使 files 正确,dist 仍不出现

运行 pnpm install --force 后,node_modules/@my-org/ui/dist 依然不存在。

🔎 深层原因:

  • react-demo/package.json 未声明对 ****@my-org/ui ****的依赖
  • pnpm 不会自动链接未声明的 workspace 包

✅ 解决:


pnpm add @my-org/ui@workspace:* --filter react-demo

→ 显式建立依赖关系,pnpm 才会创建 symlink 并包含 dist/

📌 这是 Monorepo 的核心规则: “未声明 = 不存在”

✅ 问题 4:开发环境如何实现热更新?

生产构建成功后,需支持开发时实时编辑 ui 组件。

✅ 解决方案:

  1. ****react-demo/vite.config.ts ****中添加 alias

resolve: {
  alias: {
    '@my-org/ui': path.resolve(__dirname, '../../packages/ui/src')
  }
}
  1. 启动开发服务器

pnpm --filter react-demo dev

💡 原理:Vite 直接编译 ui/src 源码(而非 dist),天然支持 HMR 和 TSX。


🧪 验证清单(最终状态)

检查项 命令 预期结果
UI 库已构建 ls packages/ui/dist ui.js, ui.mjs, *.d.ts
依赖已声明 grep "@my-org/ui" apps/react-demo/package.json "@my-org/ui": "workspace:*"
链接已同步 ls apps/react-demo/node_modules/@my-org/ui/dist 文件存在
生产构建成功 pnpm --filter react-demo build 无报错,生成 dist/
开发热更新 修改 ui/src/Button.tsx→ 浏览器自动刷新 ✅ 实时生效

📚 经验总结

场景 关键配置
生产构建 package.jsonmain/module必须匹配真实文件名 + "files": ["dist"]
依赖链接 Consumer 必须在 package.json中显式声明 workspace:*依赖
开发体验 通过 Vite alias指向 src,绕过 dist,实现 HMR
构建顺序 build ui→ 再 pnpm install→ 最后 build app

🔄记住这张图,脑子跟着浏览器的事件循环(Event Loop)转起来了

一、前言

下面按照我的理解,纯手工画了一张在浏览器执行JavaScript代码的Event Loop(事件循环) 流程图。

后文会演示几个例子,把示例代码放到这个流程图演示其执行流程。

当然,这只是简单的事件循环流程,不过,却能让我们快速掌握其原理。

Event Loop.png

二、概念

事件循环JavaScript为了处理单线程执行代码时,能异步地处理用户交互、网络请求等任务 (异步Web API),而设计的一套任务调度机制。它就像一个永不停止的循环,不断地检查(结合上图就是不断检查Task QueueMicrotask Queue这两个队列)并需要运行的代码。


三、为什么需要事件循环

JavaScript是单线程的,这意味着它只有一个主线程来执行代码。如果所有任务(比如一个耗时的计算、一个网络请求)都同步执行,那么浏览器就会被卡住,无法响应用户的点击、输入,直到这个任务完成。这会造成极差的用户体验。

事件循环就是为了解决这个问题而生的:它让耗时的操作(如网络请求、文件读取)在后台异步执行,等这些操作完成后,再通过回调的方式来执行相应的代码,从而不阻塞主线程

四、事件循环流程图用法演示

演示一:小菜一碟

先来一个都是同步代码的小菜,先了解一下前面画的流程图是怎样在调用栈当中执行JavaScript代码的。

console.log(1)

function funcOne() {
  console.log(2)
}

function funcTwo() {
  funcOne()
  console.log(3)
}

funcTwo()

console.log(4)

控制台输出:

1 2 3 4

下图为调用栈执行流程

演示01.png

每执行完一个同步任务会把该任务进行出栈。在这个例子当中每次在控制台输出一次,则进行一次出栈处理,直至全部代码执行完成。

演示二:小试牛刀

setTimeout+Promise组合拳,了解异步代码是如何进入任务队列等待执行的。

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 2)
}, 0)

const promise = new Promise((resolve, reject) => {
  console.log('promise', 3)
  resolve(4)
})

setTimeout(() => {
  console.log('setTimeout', 5)
}, 10)

promise.then(res => {
  console.log('then', res)
})

console.log(6)

控制台输出:

1 promise 3 6 then 4 setTimeout 2 setTimeout 5

流程图执行-步骤一:

先执行同步代码,如遇到异步代码,则把异步回调事件放到后台监听对应的任务队列

image.png

  1. 执行console.log(1),控制台输出1

  2. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间到了,把回调函数() => {console.log('setTimeout', 2)},放到宏任务队列等待。

  3. 执行创建Promise实例,并执行其中同步代码:执行console.log('promise', 3),控制台输出promise 3;执行resolve(4),此时Promise已经确定为完成fulfilled状态,把promise.then()的回调函数响应值设为4

  4. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间未到,把回调函数() => { console.log('setTimeout', 5) }放到后台监听。

  5. 执行promise.then(res => { console.log('then', res) }),出栈走异步代码,把回调函数4 => { console.log('then', 4) }放入微任务队列等待。

流程图执行-步骤二:

上面已经把同步代码执行完成,并且把对应异步回调事件放到了指定任务队列,接下来开始事件循环

image.png

  1. 扫描微任务队列,执行4 => { console.log('then', 4) }回调函数,控制台输出then 4

  2. 微任务队列为空,扫描宏任务队列,执行() => {console.log('setTimeout', 2)}回调函数,控制台输出setTimeout 2

  3. 每执行完一个宏任务,需要再次扫描微任务队列是否存在可执行任务(假设此时后台定时到了,则会把() => { console.log('setTimeout', 5) }加入到了宏任务队列末尾)。

  4. 微任务队列为空,扫描宏任务队列,执行() => { console.log('setTimeout', 5) },控制台输出setTimeout 5

演示三:稍有难度

setTimeout+Promise组合拳+多层嵌套Promise

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 10)
}, 0)

new Promise((resolve, reject) => {
  console.log(2)
  resolve(7)

  new Promise((resolve, reject) => {
    resolve(5)
  }).then(res => {
    console.log(res)

    new Promise((resolve, reject) => {
      resolve('嵌套第三层 Promise')
    }).then(res => {
      console.log(res)
    })
  })

  Promise.resolve(6).then(res => {
    console.log(res)
  })

}).then(res => {
  console.log(res)
})

new Promise((resolve, reject) => {
  console.log(3)

  Promise.resolve(8).then(res => {
    console.log(res)
  })

  resolve(9)
}).then(res => {
  console.log(res)
})

console.log(4)

上一个演示说明了流程图执行的详细步骤,下面就不多加赘叙了,直接看图!

talk is cheap, show me the chart

image.png

上图,调用栈同步代码执行完成,开始事件循环,先看微任务队列,发现不为空,按顺序执行微任务事件:

嵌套02.png

上图,已经把刚才排队的微任务队列全部清空了。但是在执行第一个微任务时,发现还有嵌套微任务,则把该任务放到微任务队列末尾,然后接着一起执行完所有新增任务

嵌套03.png

最后微任务清空后,接着执行宏任务。到此全部事件已执行完毕!

控制台完整输出顺序:

1 2 3 4 5 6 7 8 9 10

演示四:setTimeout伪定时

setTimeout并不是设置的定时到了就马上执行,而是把定时回调放在task queue任务队列当中进行等待,待主线程调用栈中的同步任务执行完成后空闲时才会执行。

const startTime = Date.now()
setTimeout(() => {
  const endTime = Date.now()
  console.log('setTimeout cost time', endTime - startTime)
  // setTimeout cost time 2314
}, 100)

for (let i = 0; i < 300000; i++) {
  // 模拟执行耗时同步任务
  console.log(i)
}

控制台输出:

1 2 3 ··· 300000 setTimeout cost time 2314

下图演示了其执行流程:

setTimeout假延时.png

演示五:fetch网络请求和setTimeout

获取网络数据,fetch回调函数属于微任务,优于setTimeout先执行。

setTimeout(() => {
  console.log('setTimeout', 2)
}, 510)

const startTime = Date.now()
fetch('http://localhost:3000/test').then(res => {
  const endTime = Date.now()
  console.log('fetch cost time', endTime - startTime)
  return res.json()
}).then(data => {
  console.log('data', data)
})

下图当前Call Stack执行栈执行完同步代码后,由于fetchsetTimeout都是宏任务,所以走宏任务Web API流程后注册这两个事件回调,等待定时到后了,由于定时回调是个普通的同步函数,所以放到宏任务队列;等待fetch拿到服务器响应数据后,由于fetch回调为一个Promise对象,所以放到微任务队列。

fetch.png

经过多番刷新网页测试,下图控制台打印展示了setTimeout延时为510msfetch请求响应同样是510ms的情况下,.then(data => { console.log('data', data) })先执行了,也是由于fetch基于Promise实现,所以其回调为微任务。

b475cbb38b0161d3e7f5f97b45824b31.png

五、结语

这可能只是简单的JavaScript代码执行事件循环流程,目的也是让大家更直观理解其中原理。实际执行过程可能还会读取堆内存获取引用类型数据、操作dom的方法,可能还会触发页面的重排、重绘等过程、异步文件读取和写入操作、fetch发起网络请求,与服务器建立连接获取网络数据等情况。

但是,它们异步执行的回调函数都会经过图中的这个事件循环过程,从而构成完整的浏览器事件循环。

Mac 端企业微信调试工具开启指南:解决页面兼容性问题必备

前言:

本文主要分享以下两点:

  1. 如何打开 Mac 版企业微信中的调试控制台:由于 Mac 版企微调试工具的开启方式和 Windows 不一样,网上教程零散,所以整理了详细步骤,帮前端同学快速上手~

  2. 我遇到个坑:页面在 Windows 端企微正常,Mac 端打开时字体却闪一下变大,需要用企微调试工具定位问题,附解决方法。

一、背景介绍

随着公司企业微信的全员推广,内部业务页面逐步迁移至企微环境运行,既提升了协作效率,也对页面兼容性提出了更高要求。近期开发的页面在 Windows 端企微中表现正常,但 Mac 端企微打开时出现字体闪烁变大的异常,需通过 企微内置调试工具 定位问题。由于 Mac 版企微调试工具的开启路径与 Windows 端存在差异,特此整理详细操作流程,为同类场景提供参考。

二、打开步骤

  1. 首先 打开 debug模式:

    方法:同时按下快捷键 command + shift + control + D,会有debug模式开启的提示。

image.png

再按一次就是关闭提示:

image.png

  1. 然后点击左上方的“调试”菜单,即【调试】——>【浏览器、webView相关】——>【开启webView元素审查】。具体见下面截图:

1fe54945-47c8-44ca-bdce-052ac089e475.png

image.png

结束这个步骤之后,再次打开调试查看时,【开启webView元素审查】会变成【关闭webView元素审查】,这样就说明开启成功,即:

11b04d5a-014d-402e-860d-f8edc98253b6.png

  1. 最后 关闭 应用 重新打开 即可。这一步非常重要!如果不重新打开的话,右键时,也不会出来“检查因素”,即下面第四步就不会生效。

  2. 右键,出现 “检查因素”,打开就是调试控制台了:

4b1fab6a-d2f3-42a0-a125-f5d542413232.png

image.png

三、Mac的企业微信的网页,会默认给body加一个zoom属性

这一隐形的设置可能会成为你项目中bug的原因。就比如我项目中的问题,在 Windows 中,页面的字体没有问题,在 Mac 的企微中打开,页面中的字体会缩放一下,就是由于这个默认属性导致的。我需要对此单独处理一下,就能完美解决问题,解决方案如下图:

image.png

四、总结

这就是 Mac 端企业微信调试工具的完整开启步骤啦~ 按照流程操作后,就能像在浏览器控制台一样,调试企微内的页面样式、接口请求等,轻松定位字体异常、布局错乱、交互失效等问题。

如果你的工作中也需要在企微生态开发页面,本文的调试控制台开启方法能直接参考。若有其他企微调试的小技巧,也欢迎在评论区留言交流,一起避坑提效!

以上,希望对你有帮助!

CLI 工具开发的常用包对比和介绍

04-CLI 工具开发

CLI 工具开发涉及命令行交互、终端美化、文件操作和模板生成等核心功能。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
命令行解析 commander 功能丰富、API 友好 yargs(灵活)、meow(轻量)
交互式输入 inquirer 功能全面、生态丰富 prompts(轻量)、enquirer
终端美化 chalk 功能丰富、API 友好 picocolors(极轻量)、kleur
加载动画 ora 简单易用 -
进度条 cli-progress 文件上传/下载进度 -
文件操作 fs-extra Promise API、功能增强 -
文件匹配 glob 通配符匹配 -
模板引擎 handlebars 轻量、逻辑少 ejs、mustache

快速开始

# 1. 安装核心工具
pnpm add commander inquirer chalk ora fs-extra glob handlebars

# 2. 创建 CLI 入口文件
# src/cli.ts

# 3. 配置 package.json bin 字段

命令行交互

commander(推荐)

commander 是一款 Node.js 命令行解析工具,核心用途是解析命令行参数,让 CLI 工具的命令行交互更友好、专业。

优势

  • ✅ API 友好,链式调用
  • ✅ 功能丰富,支持子命令、选项、帮助信息
  • ✅ 生态完善,文档详细
  • ✅ 自动生成帮助信息

劣势

  • ❌ 体积较大(相比 meow)
安装
pnpm add commander
pnpm add @types/commander -D
基础用法
// src/cli.ts
import { program } from 'commander';

program
  .version('1.0.0', '-v, --version')
  .description('一个基于 commander + inquirer 的 CLI 工具示例');

// 定义无参数命令
program
  .command('init')
  .description('初始化项目')
  .action(() => {
    console.log('开始初始化项目...');
  });

// 定义带选项的命令
program
  .command('build')
  .description('打包项目')
  .option('-e, --env <env>', '打包环境', 'development')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action((options) => {
    console.log('开始打包...');
    console.log('打包环境:', options.env);
    console.log('输出目录:', options.outDir);
  });

program.parse(process.argv);
选项配置
选项格式 说明 示例
.option('-s, --single') 布尔型选项(无参数,存在即 true) your-cli --single{ single: true }
.option('-n, --name <name>') 必填参数选项 your-cli --name test{ name: 'test' }
.option('-a, --age [age]') 可选参数选项 your-cli --age 25{ age: 25 };不传则为 undefined
.option('--env <env>', '描述', 'dev') 带默认值的选项 不传 --env 时,默认 { env: 'dev' }
高级用法
// 子命令
program
  .command('create <name>')
  .description('创建新项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .action((name, options) => {
    console.log(`创建项目 ${name},使用模板 ${options.template}`);
  });

// 必需选项
program.requiredOption('-c, --config <path>', '配置文件路径').parse();

// 自定义帮助信息
program.addHelpText('after', '\n示例:\n  $ my-cli init\n  $ my-cli build --env production');

yargs

yargs 是功能强大的命令行解析工具,支持位置参数、命令补全等高级功能。

优势

  • ✅ 功能强大,支持位置参数
  • ✅ 灵活的配置方式
  • ✅ 支持命令补全

劣势

  • ❌ API 相对复杂
  • ❌ 学习曲线较陡
安装
pnpm add yargs
pnpm add @types/yargs -D
基础用法
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

const argv = yargs(hideBin(process.argv))
  .option('name', {
    alias: 'n',
    type: 'string',
    description: '项目名称',
    demandOption: true,
  })
  .option('template', {
    alias: 't',
    type: 'string',
    default: 'default',
    description: '模板类型',
  })
  .command('init <name>', '初始化项目', (yargs) => {
    return yargs.positional('name', {
      describe: '项目名称',
      type: 'string',
    });
  })
  .parseSync();

console.log(argv);

meow

meow 是轻量级的命令行解析工具,适合简单场景。

优势

  • ✅ 轻量级,体积小
  • ✅ 配置简单
  • ✅ 自动处理帮助信息

劣势

  • ❌ 功能相对简单
  • ❌ 不支持复杂命令结构
安装
pnpm add meow
基础用法
import meow from 'meow';

const cli = meow(
  `
  用法
    $ my-cli <input>

  选项
    --name, -n  项目名称
    --template, -t  模板类型

  示例
    $ my-cli init --name my-project
`,
  {
    importMeta: import.meta,
    flags: {
      name: {
        type: 'string',
        alias: 'n',
      },
      template: {
        type: 'string',
        alias: 't',
        default: 'default',
      },
    },
  },
);

console.log(cli.input[0]); // 命令参数
console.log(cli.flags); // 选项

命令行解析工具对比

工具 体积 配置复杂度 功能丰富度 适用场景
commander 较大 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能丰富的 CLI 工具
yargs 较大 ⭐⭐ ⭐⭐⭐⭐⭐ 需要位置参数的场景
meow 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 简单 CLI 工具

选型建议

  • 功能丰富的 CLI 工具:commander(推荐)
  • 需要位置参数:yargs
  • 简单工具:meow

交互式输入

inquirer(推荐)

当命令行参数无法满足需求(如让用户选择框架、输入密码、确认操作)时,inquirer 提供「交互式输入」。

优势

  • ✅ 功能全面,支持多种交互类型
  • ✅ 生态丰富,插件多
  • ✅ 文档完善

劣势

  • ❌ 体积较大
  • ❌ 配置相对复杂
安装
pnpm add inquirer
pnpm add @types/inquirer -D
基础用法
import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';

program
  .command('init')
  .description('初始化项目')
  .action(async () => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称:',
        default: 'my-project',
        validate: (value) => {
          if (!value.trim()) return '项目名称不能为空!';
          return true;
        },
      },
      {
        type: 'list',
        name: 'framework',
        message: '请选择项目框架:',
        choices: [
          { name: 'React + TypeScript', value: 'react-ts' },
          { name: 'Vue + TypeScript', value: 'vue-ts' },
          { name: 'Vanilla JS', value: 'vanilla' },
        ],
        default: 'react-ts',
      },
      {
        type: 'checkbox',
        name: 'modules',
        message: '请选择需要的功能模块:',
        choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
        default: ['路由', 'ESLint/Prettier'],
      },
      {
        type: 'confirm',
        name: 'initGit',
        message: '是否初始化 Git 仓库?',
        default: true,
      },
    ]);

    console.log(chalk.green('\n✅ 项目配置如下:'));
    console.log('项目名称:', answers.projectName);
    console.log('框架:', answers.framework);
    console.log('功能模块:', answers.modules.join(', '));
    console.log('初始化 Git:', answers.initGit ? '是' : '否');
  });

program.parse(process.argv);
核心交互类型
类型 用途 关键配置
input 普通文本输入(如项目名称、邮箱) message、default、validate
password 密码输入(输入内容隐藏) 同 input,自动隐藏输入
list 单选(如框架选择、环境选择) choices(选项数组)、default
checkbox 多选(如功能模块、依赖选择) choices、default(默认选中项)
confirm 二选一确认(是 / 否) message、default(true/false)
rawlist 带编号的单选(按数字选择) 同 list,选项前显示编号
autocomplete 带自动补全的输入(如文件路径) 需配合 inquirer-autocomplete-prompt 插件

prompts

prompts 是轻量级的交互式输入工具,API 简洁。

优势

  • ✅ 轻量级,体积小
  • ✅ API 简洁
  • ✅ 支持取消操作(Ctrl+C)

劣势

  • ❌ 功能相对简单
  • ❌ 生态较小
安装
pnpm add prompts
基础用法
import prompts from 'prompts';

const response = await prompts([
  {
    type: 'text',
    name: 'projectName',
    message: '项目名称',
    initial: 'my-project',
    validate: (value) => (value.trim() ? true : '项目名称不能为空'),
  },
  {
    type: 'select',
    name: 'framework',
    message: '选择框架',
    choices: [
      { title: 'React', value: 'react' },
      { title: 'Vue', value: 'vue' },
    ],
  },
]);

console.log(response);

enquirer

enquirer 是现代化的交互式输入工具,支持自定义提示符。

优势

  • ✅ 现代化设计
  • ✅ 支持自定义提示符
  • ✅ API 灵活

劣势

  • ❌ 文档相对较少
  • ❌ 生态较小
安装
pnpm add enquirer
基础用法
import { prompt } from 'enquirer';

const response = await prompt({
  type: 'input',
  name: 'projectName',
  message: '项目名称',
  initial: 'my-project',
});

console.log(response);

交互式输入工具对比

工具 体积 配置复杂度 功能丰富度 生态 适用场景
inquirer 较大 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能全面的 CLI 工具
prompts 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 简单交互场景
enquirer 中等 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 需要自定义提示符

选型建议

  • 功能全面的 CLI 工具:inquirer(推荐)
  • 简单交互场景:prompts
  • 需要自定义提示符:enquirer

终端美化

chalk(推荐)

chalk 是 Node.js 终端日志彩色打印工具,核心用途是给终端输出的文字添加颜色、背景色、加粗 / 下划线等样式。

优势

  • ✅ 功能丰富,API 友好
  • ✅ 支持链式调用
  • ✅ 生态完善

劣势

  • ❌ 体积较大(相比 picocolors)
安装
pnpm add chalk
封装日志函数
// src/utils/logger.ts
import chalk from 'chalk';

export enum LogType {
  SUCCESS = 'success',
  ERROR = 'error',
  WARN = 'warn',
  INFO = 'info',
}

export const log = (message: string, type: LogType = LogType.INFO) => {
  const prefixMap = {
    [LogType.SUCCESS]: chalk.green('✅'),
    [LogType.ERROR]: chalk.bold.red('❌'),
    [LogType.WARN]: chalk.yellow('⚠️'),
    [LogType.INFO]: chalk.blue('ℹ️'),
  };

  const colorMap = {
    [LogType.SUCCESS]: chalk.green,
    [LogType.ERROR]: chalk.red,
    [LogType.WARN]: chalk.yellow,
    [LogType.INFO]: chalk.blue,
  };

  const prefix = prefixMap[type];
  const color = colorMap[type];
  console.log(`${prefix} ${color(message)}`);
};

export const logSuccess = (message: string) => log(message, LogType.SUCCESS);
export const logError = (message: string) => log(message, LogType.ERROR);
export const logWarn = (message: string) => log(message, LogType.WARN);
export const logInfo = (message: string) => log(message, LogType.INFO);
使用
import { logSuccess, logError, logWarn, logInfo } from './utils/logger';

logInfo('正在初始化项目...');
logWarn('当前 Node 版本低于推荐版本');
logSuccess('项目初始化完成!');
logError('配置文件缺失,请检查 config.json');

picocolors

picocolors 是极轻量的终端颜色库,体积仅 0.5KB。

优势

  • ✅ 极轻量(0.5KB)
  • ✅ API 简洁
  • ✅ 性能好

劣势

  • ❌ 功能相对简单
  • ❌ 不支持链式调用
安装
pnpm add picocolors
基础用法
import pc from 'picocolors';

console.log(pc.green('成功'));
console.log(pc.red('错误'));
console.log(pc.bold(pc.blue('加粗蓝色')));

kleur

kleur 是轻量级的终端颜色库,API 类似 chalk。

优势

  • ✅ 轻量级(体积小)
  • ✅ API 类似 chalk,迁移成本低
  • ✅ 支持链式调用

劣势

  • ❌ 功能相对简单
安装
pnpm add kleur
基础用法
import kleur from 'kleur';

console.log(kleur.green('成功'));
console.log(kleur.red('错误'));
console.log(kleur.bold().blue('加粗蓝色'));

ora(展示加载动画)

ora 是一款 Node.js 终端加载动画工具,核心用途是在耗时操作时显示「加载中」动画 + 提示文字。

安装
pnpm add ora
封装加载动画函数
// src/utils/loader.ts
import ora from 'ora';
import chalk from 'chalk';

export const withLoader = async <T>(message: string, asyncFn: () => Promise<T>): Promise<T> => {
  const spinner = ora(chalk.bold.blue(message)).start();
  try {
    const result = await asyncFn();
    spinner.succeed(chalk.green('✅ 操作完成!'));
    return result;
  } catch (error) {
    spinner.fail(chalk.bold.red(`❌ 操作失败:${(error as Error).message}`));
    throw error;
  }
};
使用示例
import { withLoader } from './utils/loader';

await withLoader('正在请求接口数据...', async () => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
});
高级用法
import ora from 'ora';

const spinner = ora('加载中...').start();

// 更新文本
spinner.text = '处理中...';

// 成功
spinner.succeed('完成!');

// 失败
spinner.fail('失败!');

// 警告
spinner.warn('警告!');

// 信息
spinner.info('信息');

cli-progress(进度条)

cli-progress 用于显示文件上传/下载、批量处理等操作的进度。

安装
pnpm add cli-progress
基础用法
import cliProgress from 'cli-progress';

const bar = new cliProgress.SingleBar({
  format: '进度 |{bar}| {percentage}% | {value}/{total}',
  barCompleteChar: '\u2588',
  barIncompleteChar: '\u2591',
  hideCursor: true,
});

bar.start(100, 0);

// 模拟进度
for (let i = 0; i <= 100; i++) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  bar.update(i);
}

bar.stop();

boxen(边框框)

boxen 用于在终端中创建带边框的文本框,适合显示重要信息。

安装
pnpm add boxen
基础用法
import boxen from 'boxen';
import chalk from 'chalk';

const message = boxen(chalk.green('✅ 项目初始化完成!'), {
  padding: 1,
  margin: 1,
  borderStyle: 'round',
  borderColor: 'green',
});

console.log(message);

cli-table3(表格展示)

cli-table3 用于在终端中展示表格数据,适合显示配置信息、对比数据等。

安装
pnpm add cli-table3
pnpm add @types/cli-table3 -D
基础用法
import Table from 'cli-table3';

const table = new Table({
  head: ['工具', '用途', '推荐度'],
  colWidths: [20, 30, 10],
});

table.push(
  ['commander', '命令行解析', '⭐⭐⭐⭐⭐'],
  ['inquirer', '交互式输入', '⭐⭐⭐⭐⭐'],
  ['chalk', '终端美化', '⭐⭐⭐⭐⭐'],
);

console.log(table.toString());

终端美化工具对比

工具 体积 功能 适用场景
chalk 较大 颜色、样式 功能丰富的 CLI 工具
picocolors 极轻量 基础颜色 对体积敏感的项目
kleur 轻量级 颜色、样式 轻量级 CLI 工具
ora 中等 加载动画 耗时操作提示
cli-progress 中等 进度条 文件处理进度
boxen 轻量级 边框框 重要信息展示
cli-table3 中等 表格 数据展示

选型建议

  • 功能丰富的 CLI 工具:chalk(推荐)
  • 对体积敏感:picocolors
  • 需要进度条:cli-progress
  • 需要表格展示:cli-table3

文件操作

fs-extra(操作文件系统)

fs-extra 在 Node.js 原生 fs 模块基础上做了增强,核心优势:

  • 完全兼容原生 fs 模块(可直接替换 fs 使用)
  • 所有 API 支持 Promise(无需手动封装 util.promisify)
  • 新增高频实用功能(递归创建目录、递归删除目录、复制文件 / 目录等)
安装
pnpm add fs-extra
pnpm add @types/fs-extra -D
核心用法
import fs from 'fs-extra';
import path from 'path';

// 递归创建目录
await fs.ensureDir(path.resolve(__dirname, 'a/b/c'));

// 递归删除目录
await fs.remove(path.resolve(__dirname, 'a'));

// 复制文件/目录
await fs.copy(src, dest);

// 写入 JSON 文件
await fs.writeJson(jsonPath, { name: 'test', version: '1.0.0' }, { spaces: 2 });

// 读取 JSON 文件
const config = await fs.readJson(jsonPath);

// 判断文件/目录是否存在
const exists = await fs.pathExists(path);
常用 API
API 名称 核心用途 优势对比(vs 原生 fs)
fs.ensureDir(path) 递归创建目录(不存在则创建,存在则忽略) 原生需手动递归,fs-extra 一键实现
fs.remove(path) 递归删除文件 / 目录(支持任意层级) 原生需先遍历目录,fs-extra 一键删除
fs.copy(src, dest) 复制文件 / 目录(自动创建目标目录) 原生需区分文件 / 目录,fs-extra 自动适配
fs.writeJson(path, data) 写入 JSON 文件(自动 stringify) 原生需手动 JSON.stringify,fs-extra 简化步骤
fs.readJson(path) 读取 JSON 文件(自动 parse) 原生需手动 JSON.parse,fs-extra 简化步骤
fs.pathExists(path) 判断文件 / 目录是否存在(返回 boolean) 原生需用 fs.access 捕获错误,fs-extra 直接返回

glob(匹配文件)

glob 解决「按规则批量查找文件」的需求,支持用通配符(如 ***?)匹配文件路径。

安装
pnpm add glob
pnpm add @types/glob -D
核心用法
import glob from 'glob';
import path from 'path';

// 同步匹配
const files = glob.sync('src/**/*.ts', {
  cwd: process.cwd(),
  ignore: ['src/test/**/*'],
});

// 异步匹配(推荐)
const files = await glob.promise('src/**/*.{ts,js}', {
  cwd: process.cwd(),
  dot: true,
});

// 流式匹配(适合大量文件)
const stream = glob.stream('src/**/*.ts');
stream.on('data', (filePath) => {
  console.log('匹配到文件:', filePath);
});
常见通配符规则
通配符 含义 示例 匹配结果
* 匹配当前目录下的任意字符(不含子目录) src/*.ts src/index.tssrc/utils.ts
** 匹配任意层级的目录(递归) src/**/*.ts src/index.tssrc/a/b/utils.ts
? 匹配单个字符 src/file?.ts src/file1.tssrc/file2.ts
[] 匹配括号内的任意一个字符 src/[ab].ts src/a.tssrc/b.ts
! 排除匹配的文件 src/**/*.ts + !src/test.ts 所有 .ts 文件,排除 src/test.ts

模板生成

handlebars(生成模板文件)

handlebars 是一款逻辑少、轻量型的模板引擎,核心用途是「将数据与模板结合,动态生成文本内容」。

安装
pnpm add handlebars
pnpm add @types/handlebars -D
基础用法
import handlebars from 'handlebars';

// 1. 定义模板
const template = `{
  "name": "{{ projectName }}",
  "version": "{{ version }}",
  "description": "{{ description }}"
}`;

// 2. 编译模板
const compiledTemplate = handlebars.compile(template);

// 3. 传入数据渲染
const data = {
  projectName: 'my-ts-pkg',
  version: '1.0.0',
  description: '基于 TS + 双模式的 npm 包',
};

const result = compiledTemplate(data);
console.log(result);
核心语法
  • 变量占位符{{ 变量名 }}(支持嵌套对象)
  • 条件判断{{#if 条件}}...{{else}}...{{/if}}
  • 循环遍历{{#each 数组}}...{{/each}}
  • 注释{{! 注释内容 }}
高级用法
// 注册辅助函数
handlebars.registerHelper('uppercase', (str) => {
  return str.toUpperCase();
});

// 使用辅助函数
const template = `{{ uppercase name }}`;

完整示例

结合所有工具,实现一个完整的 CLI 工具:

import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import glob from 'glob';
import handlebars from 'handlebars';
import path from 'path';
import boxen from 'boxen';

program.version('1.0.0', '-v, --version').description('CLI 工具示例');

program
  .command('init [projectName]')
  .description('初始化项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .option('-y, --yes', '跳过交互式询问', false)
  .action(async (projectName, options) => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    let answers: any = {};

    // 如果提供了项目名称且使用了 --yes,跳过交互
    if (projectName && options.yes) {
      answers = {
        projectName,
        framework: 'react-ts',
        modules: ['路由', 'ESLint/Prettier'],
        initGit: true,
      };
    } else {
      // 交互式询问
      answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: '项目名称',
          default: projectName || 'my-project',
          validate: (value) => {
            if (!value.trim()) return '项目名称不能为空!';
            if (!/^[a-z0-9-]+$/.test(value)) {
              return '项目名称只能包含小写字母、数字和连字符!';
            }
            return true;
          },
        },
        {
          type: 'list',
          name: 'framework',
          message: '选择框架',
          choices: [
            { name: 'React + TypeScript', value: 'react-ts' },
            { name: 'Vue + TypeScript', value: 'vue-ts' },
            { name: 'Vanilla JS', value: 'vanilla' },
          ],
          default: 'react-ts',
        },
        {
          type: 'checkbox',
          name: 'modules',
          message: '选择功能模块',
          choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
          default: ['路由', 'ESLint/Prettier'],
        },
        {
          type: 'confirm',
          name: 'initGit',
          message: '是否初始化 Git 仓库?',
          default: true,
        },
      ]);
    }

    const spinner = ora(chalk.blue('正在生成项目文件...')).start();

    try {
      // 1. 检查目录是否存在
      const targetDir = path.resolve(process.cwd(), answers.projectName);
      if (await fs.pathExists(targetDir)) {
        spinner.fail(chalk.red(`目录 ${answers.projectName} 已存在!`));
        process.exit(1);
      }

      // 2. 创建项目目录
      await fs.ensureDir(targetDir);

      // 3. 读取模板文件
      const templateDir = path.resolve(__dirname, '../templates', options.template);
      const templateFiles = await glob.promise('**/*.hbs', {
        cwd: templateDir,
        dot: true,
      });

      // 4. 渲染模板并写入文件
      for (const templateFile of templateFiles) {
        const templatePath = path.resolve(templateDir, templateFile);
        const templateContent = await fs.readFile(templatePath, 'utf8');

        const compiled = handlebars.compile(templateContent);
        const renderedContent = compiled(answers);

        const targetFile = templateFile.replace(/\.hbs$/, '');
        const targetPath = path.resolve(targetDir, targetFile);

        await fs.ensureDir(path.dirname(targetPath));
        await fs.writeFile(targetPath, renderedContent, 'utf8');
      }

      // 5. 初始化 Git(如果选择)
      if (answers.initGit) {
        spinner.text = '正在初始化 Git 仓库...';
        // 这里可以调用 git 命令
      }

      spinner.succeed(chalk.green('项目初始化完成!'));

      // 6. 显示成功信息
      const successMessage = boxen(
        chalk.green(`✅ 项目 ${answers.projectName} 创建成功!\n\n`) +
          chalk.cyan(`cd ${answers.projectName}\n`) +
          chalk.cyan('npm install\n') +
          chalk.cyan('npm run dev'),
        {
          padding: 1,
          margin: 1,
          borderStyle: 'round',
          borderColor: 'green',
        },
      );

      console.log(successMessage);
    } catch (error) {
      spinner.fail(chalk.red(`初始化失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program
  .command('build')
  .description('构建项目')
  .option('-e, --env <env>', '构建环境', 'production')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action(async (options) => {
    const spinner = ora(chalk.blue('正在构建项目...')).start();

    try {
      // 模拟构建过程
      await new Promise((resolve) => setTimeout(resolve, 2000));

      spinner.succeed(chalk.green(`构建完成!输出目录:${options.outDir}`));
    } catch (error) {
      spinner.fail(chalk.red(`构建失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program.parse(process.argv);

最佳实践

  1. 错误处理:使用 try-catch 捕获错误,提供友好的错误提示
  2. 用户体验:使用 ora 显示加载状态,使用 chalk 美化输出
  3. 参数验证:在 inquirer 中使用 validate 验证用户输入
  4. 文件操作:使用 fs-extra 的 Promise API,避免回调地狱
  5. 模板管理:将模板文件放在独立目录,使用 glob 批量处理
  6. 命令结构:使用 commander 组织命令,保持清晰的命令层次
  7. 帮助信息:为每个命令添加清晰的描述和示例

常见问题

命令行解析相关问题

Q: commander 和 yargs 如何选择?

A:

  • commander:API 友好,适合大多数场景(推荐)
  • yargs:需要位置参数或复杂参数解析时使用

Q: 如何获取未定义的选项?

A:

// commander
program.parse();
const unknownOptions = program.opts();

// yargs
const argv = yargs.parse();
const unknown = argv._; // 未定义的参数

交互式输入相关问题

Q: inquirer 和 prompts 如何选择?

A:

  • inquirer:功能全面,生态丰富(推荐)
  • prompts:轻量级,简单场景使用

Q: 如何中断交互式输入?

A:

// inquirer 会自动处理 Ctrl+C
// prompts 需要手动处理
const response = await prompts({
  type: 'text',
  name: 'value',
  message: '输入值',
  onCancel: () => {
    console.log('已取消');
    process.exit(0);
  },
});

终端美化相关问题

Q: chalk 和 picocolors 如何选择?

A:

  • chalk:功能丰富,适合大多数场景(推荐)
  • picocolors:对体积敏感的项目使用

Q: 如何检测终端是否支持颜色?

A:

import chalk from 'chalk';

// chalk 会自动检测,不支持时自动禁用颜色
// 手动检测
const supportsColor = chalk.supportsColor;

文件操作相关问题

Q: fs-extra 和原生 fs 的区别?

A:

  • fs-extra:Promise API、递归操作、JSON 操作更便捷
  • 原生 fs:需要手动封装 Promise、手动递归

Q: glob 如何排除多个文件?

A:

const files = await glob.promise('src/**/*.ts', {
  ignore: ['src/test/**/*', 'src/**/*.test.ts'],
});

模板生成相关问题

Q: handlebars 和其他模板引擎的区别?

A:

  • handlebars:逻辑少、轻量级(推荐)
  • ejs:支持 JavaScript 代码,功能强大但体积大
  • mustache:无逻辑模板,但功能较少

Q: 如何在模板中使用辅助函数?

A:

handlebars.registerHelper('eq', (a, b) => a === b);

// 模板中使用
// {{#if (eq value "test")}}...{{/if}}

参考资源

一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战

author: 大布布将军

前言:万恶之源

本故事有虚构成分。但来源于现实开发场景。 事情是这样的。

某个周五下午 4 点,产品经理(PM)迈着六亲不认的步伐向我们前端走来。他满面春风地说:“哎,那个列表页,用户反馈说找不到想要的数据,加个实时搜索功能吧?要那种一边打字一边过滤的,丝般顺滑的感觉,懂我意思吧?”

前端心想:这还不简单?input 绑定 onChange,拿到 value 往列表里一 filter,完事儿。半小时搞定,还能赶上 6 点的地铁。

于是,前端写下了那段让我后来后悔不已的代码。


第一阶段:由于过度自信导致的翻车

为了还原案发现场,前端写了一个简化版的 Demo。假设我们有一个包含 10,000 条数据的列表(别问为什么前端要处理一万条数据,问就是后端甩锅)。

也就是这几行“天真”的代码:


// 假装这里有一万个商品
const generateProducts = () => {
  return Array.from({ length: 10000 }, (_, i) => `超级无敌好用的商品 #${i}`);
};

const dummyProducts = generateProducts();

export default function SearchList() {
  const [query, setQuery] = useState('');

  // 🔴 罪魁祸首在这里:每次 render 都要遍历一万次
  const filteredProducts = dummyProducts.filter(p => 
    p.toLowerCase().includes(query.toLowerCase())
  );

  const handleChange = (e) => {
    setQuery(e.target.value); // 这一步更新 state,触发重渲染
  };

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} 
        onChange={handleChange} 
        placeholder="搜索..." 
        className="border p-2 w-full"
      />
      <ul className="mt-4">
        {filteredProducts.map((p, index) => (
          <li key={index}>{p}</li>
        ))}
      </ul>
    </div>
  );
}

结果如何?

在前端的 ThinkBook 上跑了一下,输入的时候感觉像是在 PPT 里打字。每一个字符敲下去,都要顿个几百毫秒才会显示在输入框里。

为什么? 这是 React 的基本原理:

  1. 用户输入 'a' -> 触发 setQuery
  2. React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的 render 函数。
  3. render 函数里有一个极其昂贵的 filter 操作(遍历 10k 次)。
  4. 即使 filter 完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。
  5. JS 线程被堵死,UI 渲染被阻塞,用户看到的就是:卡顿

第二阶段:万金油防抖 (Debounce) —— 治标不治本

作为老油条,第一反应当然是:“切,防抖一下不就行了?”

只要让用户打字的时候不触发计算,停下来再计算,不就完了?

import { debounce } from 'lodash';

// ... 省略部分代码

const handleChange = debounce((e) => {
    setQuery(e.target.value);
}, 300);

效果: 输入框确实不卡了,打字很流畅。但是,当你停止打字 300ms 后,页面会突然“冻结”一下,然后列表瞬间刷新。

痛点: 这种体验就像是便秘。虽然没有一直在用力,但最后那一下还是很痛苦。而且,UI 的响应滞后感很强,依然没有达到 PM 要求的“丝般顺滑”。

第三阶段:祭出神器 useDeferredValue

React 18 发布这么久了,是时候让它出来干点活了。

这时候作为前端的我们需要引入一个概念:并发模式 (Concurrent Features)

简单来说,就是把更新任务分为“大哥”和“小弟”。

  • 大哥(紧急更新) :用户的打字输入、点击反馈。这玩意儿必须马上响应,不然用户会以为死机了。
  • 小弟(非紧急更新) :列表的过滤渲染。晚个几百毫秒没人在意。

React 18 给了我们一个 Hook 叫 useDeferredValue,专门用来处理这种场景。

改造后的代码:


export default function OptimizedSearchList() {
  const [query, setQuery] = useState('');
  
  //  魔法在这里:创建一个“滞后”的副本
  // React 会在空闲的时候更新这个值
  const deferredQuery = useDeferredValue(query);

  const handleChange = (e) => {
    // 这里依然是紧急更新,保证 input 框打字流畅
    setQuery(e.target.value); 
  };

  // 只有当 deferredQuery 变了,才去跑这个昂贵的 filter
  // 注意:这里要配合 useMemo,不然也没用
  const filteredProducts = useMemo(() => {
    return dummyProducts.filter(p => 
      p.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} // 绑定实时的 query
        onChange={handleChange} 
        className="border p-2 w-full"
      />
      
      {/* 甚至可以加个 loading 状态,判断 query 和 deferredQuery 是否同步 */}
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        <ul className="mt-4">
          {filteredProducts.map((p, index) => (
            <li key={index}>{p}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

这一波操作到底发生了什么?

  1. 输入 'a' :React 此时收到了两个任务。

    • 任务 A(高优先级):更新 query,让 input 框显示 'a'。
    • 任务 B(低优先级):更新 deferredQuery,并重新计算列表。
  2. React 的调度

    • React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
    • React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
  3. 结果

    • JS 线程没有被长列表渲染锁死。
    • 输入框始终保持 60fps 的响应速度。
    • 列表会在资源空闲时“不知不觉”地更新完成。

深度解析:React 原理是咋搞的?

为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。

1. 以前的 React (Stack Reconciler)

想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。

2. 现在的 React (Fiber & Concurrency)

现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。

  • 切两刀,抬头看看:“老板,有急事吗?”
  • 老板:“没事,你继续。” -> 继续切。
  • 切两刀,抬头:“老板?”
  • 老板:“有!客人要喝水(用户输入了)!”
  • React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。

useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”

总结 & 避坑指南

这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”

但是,兄弟们,请注意以下几点(防杠声明):

  1. 不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用 useDeferredValue 纯属脱裤子放屁,反而更慢。
  2. 配合 useMemo:就像代码里写的,过滤逻辑必须包裹在 useMemo 里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue 就白用了。
  3. 性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上 react-windowreact-virtualized 吧,DOM 节点的数量才是真正的瓶颈。

好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。


下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用 useDeferredValue 包一下,说不定有奇效。

HTML iframe 标签

一、什么是 <iframe> ?

<iframe> 是一个内联框架,用于在当前HTML文档中嵌入另一个独立的HTML页面。它就像一个“窗口”,通过它可以加载并显示另一个网页的内容,且该内容拥有独立的 DOMCSSJavaScript环境。

基本语法如下:

    <iframe src="https://example.com" width="600" height="400" frameborder="0"></iframe>

二、核心属性

  • src:嵌入页面的 URL
  • width / height:尺寸(可设为 100%)
  • title:描述 iframe内容
  • loading="lazy":懒加载
  • sandbox:启用安全沙箱
  • allowfullscreen:允许嵌入的网页全屏显示,需要全屏API的支持。
  • frameborder:是否绘制边框,0为不绘制,1为绘制(默认值)。建议尽量少用这个属性,而是在CSS里面设置样式。

三、sandbox 沙箱机制(安全核心!)

这是<iframe>最重要的安全特性。启用后,默认禁止几乎所有危险操作,除非显式授权。

常用sandbox指令:

  • (空值):最严格:禁止脚本、表单提交、弹窗、同源访问等。
  • allow-scripts:允许执行JavaScript
  • allow-same-origin:允许被视为同源(谨慎!若同时允许脚本,可能绕过沙箱)
  • allow-forms:允许提交表单
  • allow-popups:允许 window.open()
  • allow-top-navigation:允许跳转顶层页面(危险!)

四、跨域通信:postMessage API

由于同源策略,父页面与iframe不能直接访问对方DOMJS变量。但可通过postMessage安全通信。

父页面 -> iframe

// 父页面
const iframe = document.getElementById('my-iframe')
iframe.contentWindow.postMessage(
    { type: 'AUTH', token: 'xxx' },
    'https://trusted-oframe.com' //指定目标 origin,防泄漏
)

iframe -> 父页面

// iframe 内部
window.parent.postMessage(
    { type: 'RESIZE', height: 800 },
    '*' // 或指定父页面 origin
)

监听消息(双方都要)

window.addEventListener('message', (event) => {
    if (event.origin !== 'https://expected-parent.com') return
    if (event.data.type === 'AUTH') {
        // 处理token
    }
})

五、性能优化建议

  • 懒加载:<iframe loading="lazy"> 减少首屏压力
  • 按需加载:用户点击“展开”后再设置 src
  • 避免深层嵌套:iframe 嵌套 iframe 会导致性能雪崩

如何自己构建一个Markdown增量渲染器

揭秘 Vue3 增量 Markdown 解析组件的神奇魔法

先上效果 演示demo

背景

相信很多大模型前端开发的小伙伴都已经处理过markdown实时解析翻译成html了,传统的方式类似使用Marked、markdown-it等组件全量渲染。但是全量渲染及其消耗性能,会造成大量的重排、重绘,导致页面抖动。

各位前端小伙伴们,今天我要给大家分享一个我最近开发的「Vue3 增量 Markdown 解析组件」。这个组件就像是一个「超级翻译官」,能把枯燥的 Markdown 文本瞬间变成生动的 HTML 页面,而且还支持数学公式、代码高亮等高级功能!废话不多说,让我们一起深入这个组件的「内部世界」吧!

开箱即用模式

# 安装命令
npm install v3-markdown-stream
# 或
yarn add v3-markdown-stream

组件使用示例

<template>
  <div>
    <MarkdownRender :markInfo="markdownContent" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { MarkdownRender } from 'v3-markdown-stream';
import 'v3-markdown-stream/dist/v3-markdown-stream.css';

// 静态内容
const markdownContent = ref('# Hello World\n\nThis is a simple markdown example.')
</script>

组件概览

首先,让我们来看看这个组件的「身份证」(都是站在各位巨人的肩膀上

import { h, defineComponent, computed } from "vue";
import { Fragment, jsxs, jsx } from "vue/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import remarkParse from "remark-parse";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import 'highlight.js/styles/github-dark.css';
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeRaw from 'rehype-raw';
import rehypeHighlight from 'rehype-highlight'
import remarkFlexibleContainers from 'remark-flexible-containers'
import remarkGfm from "remark-gfm";
import { VFile } from "vfile";
import { unified } from "unified";

// 定义组件
export default defineComponent({
    name: 'VueMarkdownStreamRender',
    props: {
        markstr: {
            type: String,
            required: true,
            default: ''
        }
    },
    // 其他实现...
})

这个组件就像是一个「瑞士军刀」,集成了多种功能,让 Markdown 解析变得异常强大!

核心功能包解析 - 武林高手们的各司其职

1. Vue 核心团队

  • vue : 提供 h , defineComponent , computed 等核心 API,是整个组件的「骨架」
  • vue/jsx-runtime : 提供 Fragment , jsxs , jsx ,让我们可以在 Vue 中优雅地使用 JSX 语法,相当于给 Vue 「装上了 React 的小翅膀」

2. Unified 解析系统 - 解析界的「中央司令部」

  • unified : 这是整个解析系统的「大脑」,负责协调各个插件的工作。想象一下,它就像是一个「指挥官」,指挥着一群「小兵」(插件)协同作战
  • vfile : 提供文件处理功能,把 Markdown 字符串转换成统一的文件格式,相当于给文本「穿上了标准化的衣服」

3. Remark 家族 - Markdown 的「魔法师」

  • remark-parse : 将 Markdown 文本解析成抽象语法树(AST),就像是「翻译官」把中文翻译成一种中间语言
  • remark-math : 处理数学公式,让你的文档可以「高大上」地展示复杂数学表达式
  • remark-rehype : 将 Markdown AST 转换成 HTML AST,相当于「转换器」把中间语言翻译成另一种中间语言
  • remark-gfm : 支持 GitHub 风格的 Markdown 扩展功能,比如表格、任务列表等,让你的 Markdown 「与时俱进」
  • remark-flexible-containers : 提供灵活的容器功能,让你的内容布局更加多样化,就像是给内容「准备了各种形状的容器」

4. Rehype 家族 - HTML 的「美容师」

  • rehype-raw : 保留原始 HTML,让你的 Markdown 中混合的 HTML 代码也能正常工作,相当于「允许特殊人才保留自己的特色」
  • rehype-katex : 将数学公式渲染成漂亮的 HTML,让数学表达式「穿上漂亮的衣服」
  • rehype-highlight : 为代码块提供语法高亮,让你的代码「光彩照人」

5. 样式支持 - 颜值担当

  • katex.min.css : 数学公式的「时尚服饰」
  • github-dark.css : 代码高亮的「炫酷皮肤」

实现原理大揭秘 - 从文本到页面的神奇旅程

1. 组件结构设计

组件使用 Vue3 的 defineComponent 定义,接收一个必须的 markstr 属性,这是要解析的 Markdown 字符串。整个组件的设计非常简洁,就像一个「专注的翻译官」,只做一件事,但要做到极致!

2. 解析器链的构建

let unifiedProcessor = computed(() => {
    const processor = unified()
        .use(remarkParse, { allowDangerousHtml: true})
        .use(remarkFlexibleContainers)
        .use(remarkRehype, { allowDangerousHtml: true})
        .use(rehypeRaw)
        .use(remarkGfm)
        .use(rehypeKatex)
        .use(remarkMath)
        .use(rehypeHighlight);

    return processor;
});

这部分代码构建了一个「解析流水线」,就像工厂里的生产线一样,Markdown 文本会依次经过各个「加工环节」。这里使用 computed 确保解析器只在必要时重新创建,提高了性能。

3. 文件转换与 AST 处理

const createFile = (markstr) => {
    const file = new VFile();
    file.value = markstr;
    return file;
};

const generateVueNode = (tree) => {
    const vueVnode = toJsxRuntime(tree, {
        Fragment,
        jsx: jsx,
        jsxs: jsxs,
        passNode: true,
    });
    return vueVnode;
};

这两个函数分别负责:

  • createFile : 将 Markdown 字符串包装成 VFile 对象,就像是给文本「准备好行李,准备出发」
  • generateVueNode : 将解析后的 AST 树转换成 Vue 的虚拟 DOM 节点,相当于「将中间语言翻译成最终的目标语言」

4. 响应式渲染

const computedVNode = computed(() => {
    const processor = unifiedProcessor.value;
    const file = createFile(props.markstr);
    let result = generateVueNode(processor.runSync(processor.parse(file), file));
    return result;
});

return () => {
    return h(computedVNode.value);
};

这里是整个组件的「核心驱动」:

  • 使用 computed 响应式地计算虚拟 DOM,当 markstr 变化时,会自动重新解析并渲染
  • processor.parse(file) 将文件解析成 AST
  • processor.runSync(...) 运行所有插件处理 AST
  • 最后通过 h() 函数将生成的虚拟 DOM 渲染到页面上

技术亮点与设计精髓

  1. 响应式设计 : 利用 Vue3 的 computed ,实现了 Markdown 字符串变化时的自动重新解析和渲染
  2. 模块化插件链 : 采用统一的插件系统,各功能模块解耦,可以灵活地添加或移除功能
  3. 高性能优化 : 通过 computed 缓存解析器和虚拟 DOM,避免不必要的重复计算
  4. 丰富的功能支持 : 支持数学公式、代码高亮、GitHub 风格扩展等高级功能
  5. 错误处理机制 : 提供了 errorCaptured 钩子,捕获并记录解析过程中的错误

代码优化建议

虽然这个组件已经相当优秀,但还有一些小地方可以进一步完善:

  1. 插件顺序优化 : 目前的插件顺序可能不是最优的,建议调整为更合理的顺序:
const processor = unified()
    .use(remarkParse, { allowDangerousHtml: true})
    .use(remarkGfm) // GFM 应该在 early 阶段
    .use(remarkMath) // 数学支持也应该 early
    .use(remarkFlexibleContainers)
    .use(remarkRehype, { allowDangerousHtml: true})
    .use(rehypeRaw)
    .use(rehypeHighlight) // 代码高亮应该在 katex 之前
    .use(rehypeKatex); // 数学渲染作为最后一步
  1. 异步解析支持 : 考虑添加异步解析模式,对于大型文档可以提高性能和用户体验
  2. 缓存机制 : 可以添加基于内容哈希的缓存,避免相同内容的重复解析
  3. 错误边界 : 增强错误处理,提供更友好的错误提示给用户

总结

这个 Vue3 Markdown 解析组件就像是一个「智能翻译官 + 高级排版师」,它不仅能准确地将 Markdown 转换成 HTML,还能让最终的展示效果既美观又功能丰富。通过巧妙地组合各种开源工具,它实现了一个功能完备、性能优良的 Markdown 解析渲染系统。

无论是构建博客、文档系统还是知识库,这个组件都能为你的项目增添强大的内容展示能力。希望这篇文章能帮助你理解这个组件的实现原理,也欢迎大家提出宝贵的改进建议!

最后,如果你觉得这个组件对你有帮助,不妨点个赞并分享给更多的开发者朋友,让我们一起让 Markdown 解析变得更简单、更强大!

GitHub源码仓库地址 如果觉得好用,欢迎给个Star ⭐️ 支持一下!

JavaScript 词法作用域、作用域链与闭包:从代码看机制

在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为“变量在哪调用,就在哪找”,但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。


一、什么是执行上下文?调用栈是如何工作的?

在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:

  • 变量环境(Variable Environment) :存储用 var 声明的变量和函数声明。
  • 词法环境(Lexical Environment) :存储用 letconst 声明的块级作用域变量。

此外,每个执行上下文的词法环境中还有一个特殊的属性:outer,它指向该函数定义时所在的作用域的词法环境。

✅ 简单说:outer 指针决定了作用域查找路径,即“作用域链”

我们来看第一个例子(1.js):

function bar(){
  console.log(myName)
}

function foo(){
  var myName = '极客邦'
  bar()
}

var myName = '极客时间'
foo() // 输出: 极客时间

🤔 为什么输出的是 “极客时间” 而不是 “极客邦”?

很多人会误以为:bar() 是在 foo() 内部调用的,那它应该能访问到 foo() 里的 myName。但实际上,bar() 是在全局定义的,所以它的 outer 指向的是全局的词法环境。

bar() 执行时,它先在自己的词法环境中找 myName,没有;然后顺着 outer 指针去全局词法环境查找,找到了 var myName = '极客时间',于是打印出来。

👉 结论:作用域是由函数定义的位置决定的,而不是调用位置。这就是 词法作用域 的核心思想。


二、作用域链:查找变量的“路径”

作用域链就是由一个个执行上下文的 outer 指针串联而成的链条。我们可以通过以下代码进一步理解(2.js):

function bar(){
  var myName = '极客世界'
  let test1 = 100
  if(1){
    let myName = 'Chrome 浏览器'
    console.log(test) // ❌ 报错:test is not defined
  }
}

function foo(){
  var myName = '极客邦'
  let test = 2
  {
    let test = 3
    bar()
  }
}

var myName = '极客时间'

let myAge = 10

let test = 1

foo()

这段代码中,bar() 函数内部试图打印 test,但它找不到。

🔍 查找过程如下:

  1. bar() 的词法环境中找 test → 没有;
  2. bar()outer 指向的词法环境(全局)找 → 全局有 let test = 1,但注意!bar() 是在全局定义的,所以它只能访问全局的 test
  3. 但是 bar() 执行时,test 被重新赋值了吗?没有,因为 bar() 并不在 foo() 内部定义,所以它不会继承 foo() 的作用域。

因此,console.log(test) 实际上是在全局查找 test,结果是 1

⚠️ 注意:bar() 无法访问 foo() 中的 test,即使它是从 foo() 中调用的。这再次证明了:JS 是词法作用域,不是动态作用域

我们可以结合下面这张图来理解调用栈和作用域链的关系:

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar() 的执行上下文在栈顶;
  • 它的 outer 指向全局;
  • 因此查找 test 时,直接跳到了全局词法环境。

三、闭包:函数的“专属背包”

接下来是最有意思的——闭包(Closure)。

闭包的本质是:一个函数能够访问并记住其外部函数的变量,即使外部函数已经执行完毕

我们来看第三个例子(3.js):

function foo(){
  var myName = '极客时间'
  let test1 = 1
  const test2 = 2
  var innerBar = {
    getName: function(){
      console.log(test1)
      return myName
    },
    setName: function(newName){
      myName = newName
    }
  }
  return innerBar
}

var bar = foo() // foo 执行完毕,出栈
//它已经出栈了 那bar里面的变量应该回收吧?
//代码的执行证明 它不会回收
//foo函数确实是出栈了 但是getName/setName还需要foo()函数里面的变量 所以它会'打个包' (如果一个变量被引用的话 那么它们就不能顺利的进行垃圾回收)
bar.setName('极客邦')
bar.getName() // 输出: 极客邦

🤯 为什么 foo() 已经出栈了,还能修改和读取里面的变量?

因为在 foo() 返回 innerBar 对象时,getNamesetName 这两个方法都引用了 foo() 内部的变量 myNametest1。V8 引擎发现这些变量被“外部引用”了,就不会回收它们。

于是,foo() 的执行上下文虽然出栈了,但它的 词法环境被保留了下来,形成了一个“闭包”。

💡 这个被保留下来的词法环境,就是闭包本身。而其中被引用的变量,叫做 自由变量

我们再看一张图:

536a315a83aa48b870d03dd921b6c02a.png

  • setName 执行时,它的 outer 指向 foo() 的词法环境;
  • 即使 foo() 已经执行结束,这个环境依然存在;
  • 所以 myName 可以被修改为 '极客邦'
  • 后续调用 getName() 时,依然能拿到更新后的值。

✅ 闭包的形成条件:

  1. 函数嵌套函数;
  2. 内部函数被返回或暴露到外部;
  3. 内部函数引用了外部函数的变量。

四、闭包的生命周期:什么时候释放?

闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。

比如:

var bar = foo()
bar = null // 此时,bar 不再引用 innerBar,闭包可以被垃圾回收

一旦 bar 被置为 nullgetNamesetName 就不再被引用,V8 引擎就会回收 foo() 的词法环境,释放内存。

🔒 闭包是一种“记忆”机制,但也会带来内存泄漏的风险。使用完后记得释放引用!


五、总结:词法作用域 vs 动态作用域

特性 词法作用域(JavaScript) 动态作用域
查找依据 函数定义的位置 函数调用的位置
是否依赖调用栈顺序
示例语言 JavaScript、Python、C++ Bash、一些脚本语言

JavaScript 是典型的词法作用域语言,这意味着:

  • 函数的 outer 指针在编译阶段就确定;
  • 不管你在哪调用,只要函数定义在全局,它的 outer 就指向全局;
  • 闭包的存在正是基于这种静态作用域的特性。

六、常见误区澄清

❌ 误区一:“在哪个函数里调用,就查哪个函数的作用域”

这是动态作用域的思维。JavaScript 不是这样工作的。

✅ 正确做法:看函数定义在哪outer 指向哪里,就从哪里开始查。

❌ 误区二:“函数执行完,里面的变量就没了”

不一定!如果函数返回了一个引用了内部变量的函数,那么这些变量会被保留,形成闭包。

❌ 误区三:“闭包就是匿名函数”

不对。闭包是一种现象,不一定是匿名函数。只要满足条件,任何函数都可以形成闭包。


七、图解回顾

我们再来快速回顾一下几张关键图:

图1:bar() 调用时的作用域链

lQLPKHIJLY1ZLgPNAnrNBHawi45ov3eSr18JAvLiVN8GAA_1142_634.png

  • bar()outer 指向全局;
  • 查找 test 时,从全局找到 test = 1

图2:foo() 执行时的执行上下文

d70143c661ed9c209cdc5991f27fcab9.png

  • foo() 的词法环境包含 test1, test2
  • 变量环境包含 myName, innerBar

图3:闭包生效时的状态

lQLPJwCC0KWlAbPNA03NBHawINj2y-qMdT0JAv8hJbJKAA_1142_845.png

  • setName 执行时,outer 指向 foo() 的词法环境;
  • 即使 foo() 已出栈,数据依然可访问。

八、写在最后

JavaScript 的作用域机制看似复杂,但只要抓住一个核心:词法作用域 + outer 指针 + 闭包,就能轻松应对大多数场景。

记住一句话:

函数的作用域由它定义的位置决定,而不是调用的位置。

当你看到一个函数在别处被调用时,不要慌,先问一句:“它是在哪定义的?” 然后顺着 outer 指针去找,一切就清晰了。

闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。

希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!


📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!

❌