阅读视图
深入理解 JavaScript 词法作用域链:从代码到底层实现机制
ehcarts 实现 饼图扇区间隙+透明外描边
JavaScript 词法作用域与闭包:从底层原理到实战理解
JS运行机制
词法作用域
“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关” 。
换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。
你可以这样理解:
代码怎么写的,它就怎么执行——这非常符合我们的直觉。
比如,let 和 const 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。
所以,“词法”本质上就是:看代码结构,而不是看运行过程。
看一段关于词法作用域的代码
function bar() {
console.log(myName);
}
function foo() {
var myName = '极客邦'
bar()// 运行时
}
var myName = '极客时间'
foo();
这里输出的是
极客时间
为什么输出的不是 "极客邦"?
因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域。
JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用。
当 bar 内部访问变量(比如 test 或 myName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。
因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。
总结:
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。
换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链。
在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境。
当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain) 。
正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。
这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。
闭包 ——前面内容的优雅升华
闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。
一、什么是闭包?
闭包 = 一个函数 + 它定义时所处的词法环境。
换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。
这并不是魔法,而是 JavaScript 词法作用域机制的自然结果。
二、闭包形成的两个必要条件(缺一不可)
- 函数嵌套:内部函数引用了外部函数的变量;
-
内部函数被暴露到外部:比如通过
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没有被任何函数使用 → 被正常回收。
-
📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量。
这既保证了功能,又避免了内存浪费。
五、闭包的本质与词法作用域的关系
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 = 1 → var、a、=、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委员会)看不下去了,派出了let和const两位大神:
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() → 全局
如图
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 = 全局作用域
如图
⚔️ 第八章:作用域链的实战兵法
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();
}
🏆 大结局:黑马喽的毕业总结
经过这番学习,黑马喽终于明白了作用域的真谛:
🎯 作用域的进化史
-
ES5的混乱:
var无视块级作用域,到处捣乱 -
ES6的秩序:
let/const引入块级作用域和暂时性死区 - outer指针机制:词法作用域在编译时确定,一辈子不变
🧠 作用域链的精髓
- outer指针:函数在编译时就确定了自己的"娘家"
- 词法作用域:看出生地,不是看调用地
- 就近原则:先找自己,再按outer指针找上级
- 闭包的力量:函数永远记得自己出生时的环境
💡 最佳实践心法
// 好的作用域设计就像好的家风
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,内径100,6个直径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秒
技术架构深度解析
代码执行流程
核心模块说明
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服务会弹出一个胶囊输入框。我们点击设置按钮,如下图:
我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图:
具有配置如下:
首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图:
我们把api key填写到MxCAD AI服务中,如下图:
选择模型商: iFlow
填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。
然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图:
等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图:
比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图:
我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。
比如:"选中实体按照原本比例放大10倍,间距还是原本的间距"
我们点击生成的代码点击运行,效果就出来了,如下图:
还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。
Konvajs实现虚拟表格
这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中
虚拟表格
虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。
实现原理
一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)
- 按需渲染:只创建和渲染用户当前能看到的数据行和列
- 滚动监听:监听容器滚动事件,动态计算新的可见范围
代码大纲
基于上述原理,我们可以写出如下代码:
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);
这种方法虽然可行,但存在以下问题:
- 代码冗余:每次都需要创建临时变量,会导致一坨地雷
- 作用域污染:需要在外部作用域声明变量
- 不够优雅:代码结构不够清晰
- 容易出错:如果忘记赋值,会导致运行时错误
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 的实现是高度优化的。它:
- 避免闭包开销:原生实现避免了额外的闭包创建
- 内存效率:直接返回引用,无需额外的变量存储
- 性能优化:浏览器引擎级别的优化
与 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 外部控制其状态的需求。通过本文的详细讲解,我们了解到:
核心要点
- 简洁性:提供了更优雅的 API 来创建可外部控制的 Promise
- 实用性:在事件处理、流式处理、WebSocket 等场景中非常有用
- 标准化:作为 ES2024 标准的一部分,提供了统一的解决方案
适用场景
- ✅ 需要在 Promise 外部控制其状态
- ✅ 事件驱动的异步操作
- ✅ 流式数据处理
- ✅ 可取消的异步操作
- ✅ 队列和任务管理
注意
- ⚠️ 浏览器兼容性(需要 polyfill 或现代浏览器)
- ⚠️ 尤其得避免重复调用 resolve/reject
- ⚠️ 注意资源清理和内存管理
参考资料
原来Webpack在大厂中这样进行性能优化!
性能优化方案
优化分类:
- 优化打包后的结果(分包、减小包体积、CDN 服务器) ==> 更重要
- 优化打包速度(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'),
},
};
缺点:
- 无法自动提取公共依赖(比如
main和vendor都用了 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 的主要方式有两种:
- 打包所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CND 服务器加载的
- 通过
output.publicPath改为自己的的 CDN 服务器,打包后就可以从上面获取资源 - 如果是自己的话,一般会从阿里、腾讯等买 CDN 服务器。
- 通过
- 一些第三方资源放在 CDN 服务器上
- 一些库/框架会将打包后的源码放到一些免费的 CDN 上,比如 JSDeliver、bootcdn 等
- 这样的话,打包的时候就不需要对这些库进行打包,直接使用 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 压缩基于 请求头 ↔ 响应头协商机制:
- 客户端请求(表明支持的压缩格式)
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br // 客户端支持的压缩算法列表
- 服务端响应(返回压缩后的内容)
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抓包结果如下:
从官网的「initialization」流程来看,也就是4次(第5次未来应该会被普遍实现)
第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
总结「握手」流程
-
POST /mcp (initialize)
- 客户端:你好,我是 Inspector Client,我想初始化。
- 服务器:收到,这是我的能力列表(200 OK)。
- 状态:JSON-RPC 会话开始。
-
POST /mcp (notifications/initialized)
- 客户端:我已经收到你的能力了,初始化完成。
- 服务器:收到 (202 Accepted)。
- 状态:逻辑握手完成。
-
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,我们自己定义/注册的工具。我们可以拿到它的title,description和inputSchema,这些语义信息可以帮助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次握手吗)
代码实战 - 天气工具
准备天气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.核心是McpServer和StreamableHTTPServerTransport两个API
-
McpServer: 负责注册tool. -
StreamableHTTPServerTransport: 接管了/mcpendpoint的通信逻辑
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,
},
],
};
},
);
}
}
效果如下:
注意左侧侧边栏:
- 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
tools/call
结合官网的stdio通信原理图
可以总结如下:
- 连接一个stdio MCP服务,不同于streamable HTTP MCP服务需要进行「握手」,只要开启一个子进程(subprocess),就表示连接成功。
- 后续的通信的信息格式遵循
json-rpc:2.0,通过读写process.stdin和process.stdout完成通信。
代码实战 - 统计文件数
比较简单,可以参考我的这篇博客 Node写MCP入门教程,基于StdioServerTransport实现的统计目录下文件夹的MCP Server,并且介绍了mcp inspector的调试和Trae安装使用。
创建MCP项目的脚手架
每次写个新MCP Server都要搭建项目模板,这种重复的工作当然该做成工具辣!
我自己写了一个create-mcp脚手架 Github。create-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
然后依次分别运行下面两个命令
# 编译ts/运行node
pnpm dev
# 打开 mcp-inspector工具调试
pnpm inspect
参考
搭建简易版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-demo的node_modules/@my-org/ui/中 没有 ****dist/****目录 - 导致 Vite 找不到 JS 入口文件(如
ui.js)
✅ 解决步骤:
-
确认 ****
packages/ui/package.json****包含 ****"files": ["dist"]
→ 否则 pnpm 不会把dist链接到 consumer 的node_modules - 先构建 UI 库
pnpm --filter @my-org/ui build
-
确保 ****
react-demo****声明了依赖
pnpm add @my-org/ui@workspace:* --filter react-demo
- 强制刷新链接
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.js: fileName: (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 组件。
✅ 解决方案:
-
在 ****
react-demo/vite.config.ts****中添加 alias:
resolve: {
alias: {
'@my-org/ui': path.resolve(__dirname, '../../packages/ui/src')
}
}
- 启动开发服务器:
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.json的 main/module必须匹配真实文件名 + "files": ["dist"]
|
| 依赖链接 | Consumer 必须在 package.json中显式声明 workspace:*依赖 |
| 开发体验 | 通过 Vite alias指向 src,绕过 dist,实现 HMR |
| 构建顺序 | 先 build ui→ 再 pnpm install→ 最后 build app
|
🔄记住这张图,脑子跟着浏览器的事件循环(Event Loop)转起来了
一、前言
下面按照我的理解,纯手工画了一张在浏览器执行JavaScript代码的Event Loop(事件循环) 流程图。
后文会演示几个例子,把示例代码放到这个流程图演示其执行流程。
当然,这只是简单的事件循环流程,不过,却能让我们快速掌握其原理。
二、概念
事件循环是JavaScript为了处理单线程执行代码时,能异步地处理用户交互、网络请求等任务 (异步Web API),而设计的一套任务调度机制。它就像一个永不停止的循环,不断地检查(结合上图就是不断检查Task Queue和Microtask 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
下图为调用栈执行流程
每执行完一个同步任务会把该任务进行出栈。在这个例子当中每次在控制台输出一次,则进行一次出栈处理,直至全部代码执行完成。
演示二:小试牛刀
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
流程图执行-步骤一:
先执行同步代码,如遇到异步代码,则把异步回调事件放到后台监听或对应的任务队列。
-
执行
console.log(1),控制台输出1。 -
执行定时器,遇到异步代码,后台注册定时器回调事件,时间到了,把回调函数
() => {console.log('setTimeout', 2)},放到宏任务队列等待。 -
执行创建
Promise实例,并执行其中同步代码:执行console.log('promise', 3),控制台输出promise 3;执行resolve(4),此时Promise已经确定为完成fulfilled状态,把promise.then()的回调函数响应值设为4。 -
执行定时器,遇到异步代码,后台注册定时器回调事件,时间未到,把回调函数
() => { console.log('setTimeout', 5) }放到后台监听。 -
执行
promise.then(res => { console.log('then', res) }),出栈走异步代码,把回调函数4 => { console.log('then', 4) }放入微任务队列等待。
流程图执行-步骤二:
上面已经把同步代码执行完成,并且把对应异步回调事件放到了指定任务队列,接下来开始事件循环。
-
扫描微任务队列,执行
4 => { console.log('then', 4) }回调函数,控制台输出then 4。 -
微任务队列为空,扫描宏任务队列,执行
() => {console.log('setTimeout', 2)}回调函数,控制台输出setTimeout 2。 -
每执行完一个宏任务,需要再次扫描微任务队列是否存在可执行任务(假设此时后台定时到了,则会把
() => { console.log('setTimeout', 5) }加入到了宏任务队列末尾)。 -
微任务队列为空,扫描宏任务队列,执行
() => { 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
上图,调用栈同步代码执行完成,开始事件循环,先看微任务队列,发现不为空,按顺序执行微任务事件:
上图,已经把刚才排队的微任务队列全部清空了。但是在执行第一个微任务时,发现还有嵌套微任务,则把该任务放到微任务队列末尾,然后接着一起执行完所有新增任务。
最后微任务清空后,接着执行宏任务。到此全部事件已执行完毕!
控制台完整输出顺序:
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
下图演示了其执行流程:
演示五: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执行栈执行完同步代码后,由于fetch和setTimeout都是宏任务,所以走宏任务Web API流程后注册这两个事件回调,等待定时到后了,由于定时回调是个普通的同步函数,所以放到宏任务队列;等待fetch拿到服务器响应数据后,由于fetch回调为一个Promise对象,所以放到微任务队列。
经过多番刷新网页测试,下图控制台打印展示了setTimeout延时为510ms,fetch请求响应同样是510ms的情况下,.then(data => { console.log('data', data) })先执行了,也是由于fetch基于Promise实现,所以其回调为微任务。
五、结语
这可能只是简单的JavaScript代码执行事件循环流程,目的也是让大家更直观理解其中原理。实际执行过程可能还会读取堆内存获取引用类型数据、操作dom的方法,可能还会触发页面的重排、重绘等过程、异步文件读取和写入操作、fetch发起网络请求,与服务器建立连接获取网络数据等情况。
但是,它们异步执行的回调函数都会经过图中的这个事件循环过程,从而构成完整的浏览器事件循环。
Mac 端企业微信调试工具开启指南:解决页面兼容性问题必备
前言:
本文主要分享以下两点:
如何打开 Mac 版企业微信中的调试控制台:由于 Mac 版企微调试工具的开启方式和 Windows 不一样,网上教程零散,所以整理了详细步骤,帮前端同学快速上手~
我遇到个坑:页面在 Windows 端企微正常,Mac 端打开时字体却闪一下变大,需要用企微调试工具定位问题,附解决方法。
一、背景介绍
随着公司企业微信的全员推广,内部业务页面逐步迁移至企微环境运行,既提升了协作效率,也对页面兼容性提出了更高要求。近期开发的页面在 Windows 端企微中表现正常,但 Mac 端企微打开时出现字体闪烁变大的异常,需通过 企微内置调试工具 定位问题。由于 Mac 版企微调试工具的开启路径与 Windows 端存在差异,特此整理详细操作流程,为同类场景提供参考。
二、打开步骤
-
首先 打开
debug模式:方法:同时按下快捷键
command + shift + control + D,会有debug模式开启的提示。
再按一次就是关闭提示:
- 然后点击左上方的“调试”菜单,即【调试】——>【浏览器、webView相关】——>【开启webView元素审查】。具体见下面截图:
结束这个步骤之后,再次打开调试查看时,【开启webView元素审查】会变成【关闭webView元素审查】,这样就说明开启成功,即:
-
最后 关闭 应用 重新打开 即可。这一步非常重要!如果不重新打开的话,右键时,也不会出来“检查因素”,即下面第四步就不会生效。
-
右键,出现 “检查因素”,打开就是调试控制台了:
三、Mac的企业微信的网页,会默认给body加一个zoom属性
这一隐形的设置可能会成为你项目中bug的原因。就比如我项目中的问题,在 Windows 中,页面的字体没有问题,在 Mac 的企微中打开,页面中的字体会缩放一下,就是由于这个默认属性导致的。我需要对此单独处理一下,就能完美解决问题,解决方案如下图:
四、总结
这就是 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.ts、src/utils.ts
|
** |
匹配任意层级的目录(递归) | src/**/*.ts |
src/index.ts、src/a/b/utils.ts
|
? |
匹配单个字符 | src/file?.ts |
src/file1.ts、src/file2.ts
|
[] |
匹配括号内的任意一个字符 | src/[ab].ts |
src/a.ts、src/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);
最佳实践
- 错误处理:使用 try-catch 捕获错误,提供友好的错误提示
- 用户体验:使用 ora 显示加载状态,使用 chalk 美化输出
- 参数验证:在 inquirer 中使用 validate 验证用户输入
- 文件操作:使用 fs-extra 的 Promise API,避免回调地狱
- 模板管理:将模板文件放在独立目录,使用 glob 批量处理
- 命令结构:使用 commander 组织命令,保持清晰的命令层次
- 帮助信息:为每个命令添加清晰的描述和示例
常见问题
命令行解析相关问题
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 的基本原理:
- 用户输入 'a' -> 触发
setQuery。 - React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的
render函数。 -
render函数里有一个极其昂贵的filter操作(遍历 10k 次)。 - 即使
filter完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。 - 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>
);
}
这一波操作到底发生了什么?
-
输入 'a' :React 此时收到了两个任务。
- 任务 A(高优先级):更新
query,让 input 框显示 'a'。 - 任务 B(低优先级):更新
deferredQuery,并重新计算列表。
- 任务 A(高优先级):更新
-
React 的调度:
- React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
- React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
-
结果:
- JS 线程没有被长列表渲染锁死。
- 输入框始终保持 60fps 的响应速度。
- 列表会在资源空闲时“不知不觉”地更新完成。
深度解析:React 原理是咋搞的?
为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。
1. 以前的 React (Stack Reconciler)
想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。
2. 现在的 React (Fiber & Concurrency)
现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。
- 切两刀,抬头看看:“老板,有急事吗?”
- 老板:“没事,你继续。” -> 继续切。
- 切两刀,抬头:“老板?”
- 老板:“有!客人要喝水(用户输入了)!”
- React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。
useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”
总结 & 避坑指南
这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”
但是,兄弟们,请注意以下几点(防杠声明):
-
不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用
useDeferredValue纯属脱裤子放屁,反而更慢。 -
配合 useMemo:就像代码里写的,过滤逻辑必须包裹在
useMemo里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue就白用了。 -
性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上
react-window或react-virtualized吧,DOM 节点的数量才是真正的瓶颈。
好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。
下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用
useDeferredValue包一下,说不定有奇效。
HTML iframe 标签
一、什么是 <iframe> ?
<iframe> 是一个内联框架,用于在当前HTML文档中嵌入另一个独立的HTML页面。它就像一个“窗口”,通过它可以加载并显示另一个网页的内容,且该内容拥有独立的 DOM、CSS、JavaScript环境。
基本语法如下:
<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不能直接访问对方DOM或JS变量。但可通过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 渲染到页面上
技术亮点与设计精髓
- 响应式设计 : 利用 Vue3 的 computed ,实现了 Markdown 字符串变化时的自动重新解析和渲染
- 模块化插件链 : 采用统一的插件系统,各功能模块解耦,可以灵活地添加或移除功能
- 高性能优化 : 通过 computed 缓存解析器和虚拟 DOM,避免不必要的重复计算
- 丰富的功能支持 : 支持数学公式、代码高亮、GitHub 风格扩展等高级功能
- 错误处理机制 : 提供了 errorCaptured 钩子,捕获并记录解析过程中的错误
代码优化建议
虽然这个组件已经相当优秀,但还有一些小地方可以进一步完善:
- 插件顺序优化 : 目前的插件顺序可能不是最优的,建议调整为更合理的顺序:
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); // 数学渲染作为最后一步
- 异步解析支持 : 考虑添加异步解析模式,对于大型文档可以提高性能和用户体验
- 缓存机制 : 可以添加基于内容哈希的缓存,避免相同内容的重复解析
- 错误边界 : 增强错误处理,提供更友好的错误提示给用户
总结
这个 Vue3 Markdown 解析组件就像是一个「智能翻译官 + 高级排版师」,它不仅能准确地将 Markdown 转换成 HTML,还能让最终的展示效果既美观又功能丰富。通过巧妙地组合各种开源工具,它实现了一个功能完备、性能优良的 Markdown 解析渲染系统。
无论是构建博客、文档系统还是知识库,这个组件都能为你的项目增添强大的内容展示能力。希望这篇文章能帮助你理解这个组件的实现原理,也欢迎大家提出宝贵的改进建议!
最后,如果你觉得这个组件对你有帮助,不妨点个赞并分享给更多的开发者朋友,让我们一起让 Markdown 解析变得更简单、更强大!
GitHub源码仓库地址 如果觉得好用,欢迎给个Star ⭐️ 支持一下!
JavaScript 词法作用域、作用域链与闭包:从代码看机制
在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为“变量在哪调用,就在哪找”,但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。
一、什么是执行上下文?调用栈是如何工作的?
在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:
-
变量环境(Variable Environment) :存储用
var声明的变量和函数声明。 -
词法环境(Lexical Environment) :存储用
let、const声明的块级作用域变量。
此外,每个执行上下文的词法环境中还有一个特殊的属性: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,但它找不到。
🔍 查找过程如下:
- 在
bar()的词法环境中找test→ 没有; - 到
bar()的outer指向的词法环境(全局)找 → 全局有let test = 1,但注意!bar()是在全局定义的,所以它只能访问全局的test; - 但是
bar()执行时,test被重新赋值了吗?没有,因为bar()并不在foo()内部定义,所以它不会继承foo()的作用域。
因此,console.log(test) 实际上是在全局查找 test,结果是 1。
⚠️ 注意:
bar()无法访问foo()中的test,即使它是从foo()中调用的。这再次证明了:JS 是词法作用域,不是动态作用域。
我们可以结合下面这张图来理解调用栈和作用域链的关系:
-
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 对象时,getName 和 setName 这两个方法都引用了 foo() 内部的变量 myName 和 test1。V8 引擎发现这些变量被“外部引用”了,就不会回收它们。
于是,foo() 的执行上下文虽然出栈了,但它的 词法环境被保留了下来,形成了一个“闭包”。
💡 这个被保留下来的词法环境,就是闭包本身。而其中被引用的变量,叫做 自由变量。
我们再看一张图:
-
setName执行时,它的outer指向foo()的词法环境; - 即使
foo()已经执行结束,这个环境依然存在; - 所以
myName可以被修改为'极客邦'; - 后续调用
getName()时,依然能拿到更新后的值。
✅ 闭包的形成条件:
- 函数嵌套函数;
- 内部函数被返回或暴露到外部;
- 内部函数引用了外部函数的变量。
四、闭包的生命周期:什么时候释放?
闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。
比如:
var bar = foo()
bar = null // 此时,bar 不再引用 innerBar,闭包可以被垃圾回收
一旦 bar 被置为 null,getName 和 setName 就不再被引用,V8 引擎就会回收 foo() 的词法环境,释放内存。
🔒 闭包是一种“记忆”机制,但也会带来内存泄漏的风险。使用完后记得释放引用!
五、总结:词法作用域 vs 动态作用域
| 特性 | 词法作用域(JavaScript) | 动态作用域 |
|---|---|---|
| 查找依据 | 函数定义的位置 | 函数调用的位置 |
| 是否依赖调用栈顺序 | 否 | 是 |
| 示例语言 | JavaScript、Python、C++ | Bash、一些脚本语言 |
JavaScript 是典型的词法作用域语言,这意味着:
- 函数的
outer指针在编译阶段就确定; - 不管你在哪调用,只要函数定义在全局,它的
outer就指向全局; - 闭包的存在正是基于这种静态作用域的特性。
六、常见误区澄清
❌ 误区一:“在哪个函数里调用,就查哪个函数的作用域”
这是动态作用域的思维。JavaScript 不是这样工作的。
✅ 正确做法:看函数定义在哪,outer 指向哪里,就从哪里开始查。
❌ 误区二:“函数执行完,里面的变量就没了”
不一定!如果函数返回了一个引用了内部变量的函数,那么这些变量会被保留,形成闭包。
❌ 误区三:“闭包就是匿名函数”
不对。闭包是一种现象,不一定是匿名函数。只要满足条件,任何函数都可以形成闭包。
七、图解回顾
我们再来快速回顾一下几张关键图:
图1:bar() 调用时的作用域链
-
bar()的outer指向全局; - 查找
test时,从全局找到test = 1。
图2:foo() 执行时的执行上下文
-
foo()的词法环境包含test1,test2; - 变量环境包含
myName,innerBar。
图3:闭包生效时的状态
-
setName执行时,outer指向foo()的词法环境; - 即使
foo()已出栈,数据依然可访问。
八、写在最后
JavaScript 的作用域机制看似复杂,但只要抓住一个核心:词法作用域 + outer 指针 + 闭包,就能轻松应对大多数场景。
记住一句话:
函数的作用域由它定义的位置决定,而不是调用的位置。
当你看到一个函数在别处被调用时,不要慌,先问一句:“它是在哪定义的?” 然后顺着 outer 指针去找,一切就清晰了。
闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。
希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!
📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!