Jack N @ GitHub

Full stack AI Engineer, focus on: React, Next.js, node.js and .Net

一、Promise 基础概念

Promise 是 JavaScript 中处理异步操作的核心对象,代表一个异步操作的最终完成或失败及其结果值。它解决了传统回调函数(Callback)带来的”回调地狱”问题,使异步代码更加可读、可维护。

三种状态

状态 说明
pending 初始状态,既未成功也未失败
fulfilled 操作成功完成
rejected 操作失败

状态一旦从 pending 转变为 fulfilled 或 rejected,就不可逆转。

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const promise = new Promise((resolve, reject) => {
// 异步操作
setTimeout(() => {
const success = true;
if (success) {
resolve('操作成功');
} else {
reject(new Error('操作失败'));
}
}, 1000);
});

promise
.then(result => console.log(result))
.catch(err => console.error(err.message));

二、Promise 核心方法

1. .then() 链式调用

1
2
3
4
fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => fetchOrderDetail(orders[0].id))
.then(detail => console.log(detail));

每个 .then() 返回一个新的 Promise,支持链式调用。

2. .catch() 错误处理

1
2
3
4
5
6
7
8
asyncOperation()
.then(result => {
// 处理结果
})
.catch(error => {
// 捕获上面任意环节抛出的错误
console.error('出错了:', error);
});

3. .finally() 最终执行

1
2
3
4
showLoading()
.then(data => render(data))
.catch(err => showError(err))
.finally(() => hideLoading()); // 无论成功失败都执行

三、Promise 静态方法

Promise.all — 全部成功才算成功

1
2
3
4
5
6
const [user, orders, products] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchProducts(),
]);
// 任一 Promise reject,整体立即 reject

Promise.allSettled — 等待所有结果(ES2020)

1
2
3
4
5
const results = await Promise.allSettled([
fetchWithRetry(),
fetchBackup(),
]);
// results: [{status:'fulfilled', value:...}, {status:'rejected', reason:...}]

Promise.race — 取第一个完成的结果

1
2
3
4
5
6
const winner = await Promise.race([
fetchFromCDN(),
fetchFromOrigin(),
timeout(5000),
]);
// 第一个 resolve 或 reject 的结果

Promise.any — 取第一个成功的结果(ES2021)

1
2
3
4
5
6
7
const first = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);
// 忽略所有失败,只要有一个成功就 resolve
// 全部失败则 reject AggregateError

四、实用技巧

1. 串行执行数组中的异步任务

1
2
3
4
5
6
7
async function sequentialTasks(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task());
}
return results;
}

2. 并发限制(控制同时执行的 Promise 数量)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function promisePool(tasks, concurrency) {
const results = new Array(tasks.length);
let index = 0;

async function worker() {
while (index < tasks.length) {
const i = index++;
results[i] = await tasks[i]();
}
}

const workers = Array.from({ length: concurrency }, worker);
await Promise.all(workers);
return results;
}

// 使用:最多同时执行 3 个请求
const files = list.map(file => () => uploadFile(file));
await promisePool(files, 3);

3. 带超时的 Promise

1
2
3
4
5
6
7
8
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}

const data = await withTimeout(fetchUser(1), 3000);

4. 重试机制

1
2
3
4
5
6
7
8
9
10
11
12
async function retry(fn, times = 3, delay = 1000) {
for (let i = 0; i < times; i++) {
try {
return await fn();
} catch (err) {
if (i === times - 1) throw err;
await new Promise(r => setTimeout(r, delay * (i + 1)));
}
}
}

const result = await retry(() => unstableAPI(), 3, 500);

5. Promise 化回调函数(promisify)

1
2
3
4
5
const { promisify } = require('util');
const fs = require('fs');

const readFileAsync = promisify(fs.readFile);
const content = await readFileAsync('./data.txt', 'utf-8');

五、async/await 最佳实践

1. 错误处理:try/catch

1
2
3
4
5
6
7
8
9
async function getUser() {
try {
const res = await fetch('/api/user');
const data = await res.json();
return data;
} catch (err) {
console.error('获取用户失败:', err);
}
}

2. 避免不必要的串行等待

1
2
3
4
5
6
7
8
9
// ❌ 慢:串行执行
const user = await fetchUser();
const posts = await fetchPosts();

// ✅ 快:并行执行
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(),
]);

3. 顶层 await(Node.js 14.8+ ESM)

1
2
3
// module.mjs
const config = await loadConfig();
export default config;

六、面试常见问题

Q1:Promise 和 Callback 有什么区别?

对比项 Callback Promise
可读性 嵌套深,回调地狱 链式调用,扁平化
错误处理 每个回调单独处理 .catch() 统一捕获
组合能力 难以组合多个异步操作 all/race/any 等原生支持
状态管理 无法保证只调用一次 settle 后不可变

Q2:.then() 和 .catch() 的执行顺序是怎样的?

1
2
3
4
Promise.reject('err')
.then(() => console.log('A')) // 跳过
.catch(() => console.log('B')) // 输出 B
.then(() => console.log('C')); // 输出 C

.catch() 相当于 .then(null, onRejected),捕获前面的错误后,后续 .then() 正常执行。

Q3:如何手动实现一个 Promise?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];

const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
}
};

const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
}
};

try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}

then(onFulfilled, onRejected) {
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

const promise2 = new MyPromise((resolve, reject) => {
if (this.state === 'fulfilled') {
setTimeout(() => {
try {
const x = onFulfilled(this.value);
resolvePromise(promise2, x, resolve, reject);
} catch (e) { reject(e); }
});
} else if (this.state === 'rejected') {
setTimeout(() => {
try {
const x = onRejected(this.reason);
resolvePromise(promise2, x, resolve, reject);
} catch (e) { reject(e); }
});
} else {
this.onFulfilledCallbacks.push(() => { /* 同上 */ });
this.onRejectedCallbacks.push(() => { /* 同上 */ });
}
});

return promise2;
}
}

function resolvePromise(promise2, x, resolve, reject) {
if (x === promise2) return reject(new TypeError('Chaining cycle detected'));
if (x && (typeof x === 'object' || typeof x === 'function')) {
let called = false;
try {
const then = x.then;
if (typeof then === 'function') {
then.call(x, y => { if (!called) { called = true; resolve(y); } },
r => { if (!called) { called = true; reject(r); } });
} else { resolve(x); }
} catch (e) { if (!called) reject(e); }
} else { resolve(x); }
}

Q4:Promise.all 和 Promise.allSettled 的区别?

  • Promise.all:快速失败,任意一个 reject 则整体立即 reject。
  • Promise.allSettled:全部等待,不论成功失败都返回完整结果数组。

适用场景:all 用于必须全部成功的并行任务;allSettled 用于希望获取所有结果的场景(如批量上报)。

Q5:async 函数返回值是什么?

async 函数始终返回一个 Promise。

  • 如果函数 return value,返回的 Promise 以 value resolve。
  • 如果函数 throw error,返回的 Promise 以 error reject。
  • 如果 return 一个 Promise,会等待该 Promise settle。
1
2
async function foo() { return 42; }
foo().then(v => console.log(v)); // 42

Q6:如何捕获 async 函数中未处理的异常?

1
2
3
4
5
6
7
8
// 方式一:try/catch
// 方式二:.catch()
asyncFn().catch(err => log(err));

// 方式三:全局兜底
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});

Q7:Promise 的微任务(Microtask)机制

1
2
3
4
5
6
7
8
9
console.log('1');

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

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 → 4 → 3 → 2

规则:同步代码 > 微任务(Promise.then) > 宏任务(setTimeout)。


七、常见陷阱

1. 循环中使用 await

1
2
3
4
5
6
7
// ❌ 串行,慢
for (const url of urls) {
const res = await fetch(url);
}

// ✅ 并行
const results = await Promise.all(urls.map(url => fetch(url)));

2. 忘记 return 或 await

1
2
3
4
5
6
7
8
9
// ❌ 错误:.then 链中断
function getData() {
fetch('/api').then(res => res.json()); // 没有 return
}

// ✅ 正确
function getData() {
return fetch('/api').then(res => res.json());
}

3. resolve 一个 Promise

1
2
3
4
// resolve 另一个 Promise 时,会等待它 settle,状态透传
new Promise(resolve => {
resolve(Promise.reject('err')); // 外层变成 rejected
});

总结

场景 推荐方案
单个异步操作 async/await + try/catch
多个并行独立操作 Promise.all
需要全部结果(含失败) Promise.allSettled
只要最快成功 Promise.any
超时控制 Promise.race + timeout
并发限制 自实现 promisePool

掌握 Promise 是 Node.js 异步编程的基石,无论是日常开发还是面试,理解其底层机制(状态机、微任务队列、链式调用原理)都能让你游刃有余。

1. 题目

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

示例:

1
2
3
4
5
6
7
输入: [1,2,3,1]
输出: 4
解释: 偷 1 号(=1) 和 3 号(=3) 号房屋,1 + 3 = 4。

输入: [2,7,9,3,1]
输出: 12
解释: 偷 1、3、5 号房屋,2 + 9 + 1 = 12。

2. 解题思路

经典「选/不选」线性 DP。

2.1 状态定义

dp[i] = 考虑到第 i 间房时的最高金额。对第 i 间房有两种选择:

  • 不偷 i:dp[i] = dp[i-1](沿用前一间的最优)。
  • 偷 i:则不能偷 i-1,dp[i] = dp[i-2] + nums[i]。

转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i])。

  • 时间复杂度:O(n),空间复杂度:O(n) → 优化到 O(1)。

2.2 滚动变量

只用两个变量 prev(dp[i-2]) 与 curr(dp[i-1]) 交替更新,无需数组:每步 ([prev, curr] = [curr, max(curr, prev + num)])。

3. TypeScript 实现

3.1 O(1) 空间(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function rob(nums: number[]): number {
let prev = 0; // dp[i-2]
let curr = 0; // dp[i-1]

for (const num of nums) {
const next = Math.max(curr, prev + num);
prev = curr;
curr = next;
}

return curr;
}

console.log(rob([2, 7, 9, 3, 1])); // 12

3.2 显式 DP 数组(便于理解)

1
2
3
4
5
6
7
8
9
10
function robDP(nums: number[]): number {
const n = nums.length;
if (n === 0) return 0;
const dp = new Array(n).fill(0);
dp[0] = nums[0];
for (let i = 1; i < n; i++) {
dp[i] = Math.max(dp[i - 1], (dp[i - 2] ?? 0) + nums[i]);
}
return dp[n - 1];
}

4. 面试延伸

  • 进阶 II:环形街道(House Robber II)——首尾相邻不能同时偷,拆成「不偷首」和「不偷尾」两个线性子问题取 max,max(rob(nums[0..n-2]), rob(nums[1..n-1]))。
  • 进阶 III:树形 DP(House Robber III)——在二叉树上做「偷当前根 vs 偷左右子」的决策,后序 DFS 返回 [不偷, 偷] 一对值,是树形 DP 模板。
  • 本题是「最大子序列和 / 股票买卖」等同族「每步二选一」DP 的原型,掌握状态定义 + 滚动优化即可通吃。

难度:中等 | LeetCode 198 | 线性 DP

做网站这件事,很多人的认知还停留在十年前。

要么花几百块用凡科、建站之星套模板,页面千篇一律,一眼就是廉价流水线作品;要么找人定制开发,前端后端从头写起,改个新闻都要找程序员改代码,费时又费钱。

尤其是设计公司、品牌工作室这类靠颜值吃饭的行业,模板网站的土味质感,完全撑不起自身的高端定位。

今天给大家聊的 Strapi,就是目前开发者圈公认的「现代建站神器」。它完美解决了传统建站的所有痛点,也是现在高端定制企业官网的主流后台方案。

很多人听不懂「无头CMS」「前后端解耦」这些专业词,没关系,我用大白话带你彻底搞懂它。

Strapi 到底是什么?

简单说,Strapi 就是一个免费开源、自己掌控、零代码搭后台的内容管理系统。

区别于凡科、有赞这类租即用的SaaS建站平台,Strapi 不是成品网站,而是一个「万能后台容器」。你可以把它理解成一个全自动的后端工人:不用手写代码,就能帮你建数据库、存内容、管权限、出接口。

它最核心的特点就是无头(Headless)。通俗解释就是:它只负责管好所有数据内容,不捆绑任何前端页面样式。

传统CMS是“捆绑一体机”,后台数据和前台页面焊死,想改设计、换风格处处受限。而Strapi 只做后端本职工作,前端页面你想用Next.js、Vue随便选,自由度直接拉满。

不用写代码,普通人也能搭专业后台

这是Strapi 最圈粉的优势,也是它吊打传统建站工具的关键。

以往做企业官网后台,想要实现「项目案例、新闻动态、公司介绍、留言表单」的管理功能,后端开发者要手动建数据表、写增删改查接口、配置权限,至少大半天起步。

但用Strapi,全程可视化拖拽操作。

你在后台点点鼠标,就能创建各类内容模型:单页的官网配置、多条目的项目案例、可双语切换的新闻资讯、存储留言的表单库,全部一键生成。

同时自带完整的权限体系,你可以设置管理员、普通编辑账号,区分谁能改内容、谁只能查看,完美适配企业运营场景。

更贴心的是原生支持中英双语,不用自己折腾代码适配,做外贸、高端双语官网简直是量身定制。

专属现代架构,速度和颜值双在线

现在的高端企业官网,都追求静态渲染、CDN加速、极速打开,这也是模板网站做不到的。

Strapi 搭配Next.js 是目前行业顶配组合。前端用Next.js 写出高颜值、自适应的静态页面,通过CDN全网加速,打开速度飞快,SEO表现也远超模板站。

后端交给Strapi 全权托管,所有项目、新闻、文案、图片都存在自己的PostgreSQL数据库里。

这里必须提一句核心优势:数据完全归自己。

凡科、建站之星这类SaaS平台,数据都存在厂商服务器里,年费一停,网站和数据直接清零。而Strapi 是自托管部署,服务器、数据库、所有内容全权掌控,安全感拉满。

轻量二次开发,按需定制不臃肿

很多人担心,零代码工具会功能受限,满足不了定制需求。Strapi 完美避开了这个坑。

日常90%的建站需求,纯后台配置就能搞定,完全不用写代码。

遇到特殊需求也不怕,它支持轻量化二次开发。比如表单提交后自动发邮件通知、留言数据统计、自动导出表格、IP防刷限流,简单写几行代码就能实现。

而且扩展开发不会改动系统内核,后续升级版本、维护迭代都很方便,不会出现越改越乱的情况。

适配中小企业,性价比拉满

论成本,Strapi 优势也十分明显。

开源免费无版权费用,只需要一台便宜的轻量服务器部署即可,不用每年花几千块续费SaaS模板。一次搭建,长期使用,自由迭代改版。

不管是设计公司、小微企业、个人工作室,想要一个高颜值、可自主管控、支持定制的官网,这套架构都是最优解。

不用将就千篇一律的模板,不用承担SaaS平台的数据绑架,用最低的成本,拿下高端定制网站的所有能力。

在前端/全栈算法面试中,「多数元素」是一道非常经典的简单题。最容易想到的解法是用哈希表统计每个数字出现次数,遍历后找到大于 n/2 的元素。哈希表思路直观,但需要 O(n) 的额空间。

面试官经常会追加一个进阶问题:能不能在 O(n) 时间复杂度,O(1) 空间复杂度完成?

这时候,摩尔投票法(Boyer-Moore Majority Vote Algorithm)就是最优解。

题目描述

给定一个大小为 n 的数组 nums,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。
题目保证数组非空,且一定存在多数元素。

示例:
输入:[2,2,1,1,1,2,2]
输出:2

解释:数组长度为7,多数元素出现次数需要大于3.5,也就是至少4次。数字2一共出现4次,满足条件。

摩尔投票法核心思想:相互抵消

摩尔投票的核心逻辑可以用一句话概括:不同元素相互抵消,相同元素累加。

我们可以把数组里每一个数字想象成不同阵营的士兵:

  1. 遇到和当前候选相同的士兵,己方兵力+1;
  2. 遇到敌方士兵,双方同归于尽,己方兵力-1;
  3. 如果己方兵力归零,代表当前阵营全部阵亡,更换新的候选士兵。

本题有一个关键前提:多数元素出现次数 > 数组长度的一半。
也就是说,多数元素的总数量,大于剩下所有其他元素数量之和。
无论怎么两两抵消,最后剩下存活的候选,一定是这个多数元素。

⚠️ 重要提醒:如果题目不保证一定存在大于 n/2 的众数,摩尔投票得到的候选不能直接作为答案,必须再遍历一次数组,校验该候选的实际出现次数。

算法执行流程演示

以数组 [2,2,1,1,1,2,2] 举例:

  • 初始候选:2,计数 count = 1
  • i=1,元素2:相同,count=2
  • i=2,元素1:不同,count=1
  • i=3,元素1:不同,count=0,兵力耗尽
  • i=4,元素1:count=0,更换候选为1,count=1
  • i=5,元素2:不同,count=0,兵力耗尽
  • i=6,元素2:count=0,更换候选为2,count=1

遍历结束,候选值为2,就是数组的多数元素。

复杂度分析

  • 时间复杂度:O(n),仅遍历数组一次
  • 空间复杂度:O(1),只使用两个变量,不需要额外开辟存储空间

对比哈希表解法:哈希表同样O(n)时间,但需要O(n)空间存储每个数字的计数。大数据场景下摩尔投票优势明显。

TypeScript 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 摩尔投票法求解多数元素
* @param nums 数字数组,题目保证存在出现次数大于 n/2 的元素
* @returns 多数元素
*/
function majorityElement(nums: number[]): number {
// 当前候选元素
let candidate = nums[0];
// 候选计数
let count = 1;

// 从第二个元素开始遍历
for (let i = 1; i < nums.length; i++) {
if (count === 0) {
// 兵力归零,更换候选
candidate = nums[i];
count = 1;
} else if (nums[i] === candidate) {
// 相同元素,计数累加
count++;
} else {
// 不同元素,相互抵消
count--;
}
}
return candidate;
}

// 测试用例
console.log(majorityElement([2,2,1,1,1,2,2])); // 2
console.log(majorityElement([3,2,3])); // 3

拓展版本(无“一定存在多数元素”的前提,增加校验)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function majorityElementCheck(nums: number[]): number | null {
let candidate = nums[0];
let count = 1;
for (let i = 1; i < nums.length; i++) {
if (count === 0) {
candidate = nums[i];
count = 1;
} else if (nums[i] === candidate) {
count++;
} else {
count--;
}
}
// 二次遍历校验候选是否满足条件
let total = 0;
for (const num of nums) {
if (num === candidate) total++;
}
return total > nums.length / 2 ? candidate : null;
}

面试小结

  1. 摩尔投票适合:寻找出现次数大于数组一半的元素;
  2. 核心是抵消思想,O(1)空间是面试加分项;
  3. 一定要记住边界:题目如果不保证存在众数,必须额外校验。

在 Qoder、TRAE、CodeBuddy 之间共享全局 Skill:多个 IDE,一套能力

现在做 AI 编程,很多人已经不是只用一个 IDE 了。 写代码时可能打开 Qoder,做一些 Agent 相关的工作;遇到自己更顺手的场景,又切到 TRAE;国内网络环境或者团队协作场景下,CodeBuddy 也可能成为主力。

多个 IDE 并不是什么问题,真正麻烦的是:每个 IDE 都有自己的一套 Skill 配置。

你花时间写了一套代码规范、Git 提交规范、Code Review 规则,或者整理了一套“如何分析项目、如何写测试、如何生成 API 文档”的 Skill,结果换一个 IDE 就得重新配置一遍。

更麻烦的是,Skill 一旦修改,就要同步到多个地方。

有没有办法做到:

Skill 只维护一份,Qoder、TRAE、CodeBuddy 都能使用?

答案是可以。

核心思路其实很简单:把 Skill 从 IDE 的配置目录中抽离出来,放到一个独立的全局目录,然后让不同 IDE 的 Skill 目录指向同一个位置。

这样,我们就可以实现“一份 Skill,多处使用”。

一、为什么要在多个 IDE 之间共享 Skill?

首先,是因为多个 IDE 可以一起薅羊毛。

不同 AI IDE 往往有不同的优势。

有的 IDE 更适合日常 Coding,有的 Agent 能力比较强,有的在代码补全、项目理解、Terminal 操作或者国内服务接入方面体验更好。

既然不同 IDE 各有所长,没有必要强迫自己只使用一个。

真正应该统一的,是自己的“工作方法”。

例如,我可能有这样几个 Skill:

1
2
3
4
5
6
code-review
git-commit
write-test
project-analysis
api-design
documentation

这些东西描述的是我希望 AI 如何帮我工作,而不是某个 IDE 专属的能力。

所以它们天然应该独立于 IDE 存在。


二、把 Skill 从 IDE 中“解耦”出来

假设我们建立一个统一的 Skill 仓库:

1
2
3
4
5
6
7
8
9
10
11
~/shared-skills/
├── code-review/
│ └── SKILL.md
├── git-commit/
│ └── SKILL.md
├── write-test/
│ └── SKILL.md
├── project-analysis/
│ └── SKILL.md
└── api-design/
└── SKILL.md

以后不管使用 Qoder、TRAE 还是 CodeBuddy,都不直接维护自己的 Skill 内容。

而是让它们共同使用:

1
~/shared-skills/

从逻辑上看,就变成了:

1
2
3
4
5
             ┌── Qoder
│
shared-skills├── TRAE
│
└── CodeBuddy

三个 IDE 是不同的入口,但背后使用的是同一套 Skill。

这样做最大的好处就是:

Skill 和 IDE 解耦。

IDE 可以换,Skill 不需要跟着换。


三、关键技巧:用软链接共享 Skill

如果不同 IDE 都支持从固定目录加载 Skill,那么最简单的方法就是使用软链接。

例如,我们把真正的 Skill 放在:

1
~/shared-skills/

然后让各个 IDE 的 Skill 目录指向它。

Linux/macOS 下可以使用:

1
2
3
ln -s ~/shared-skills ~/.qoder/skills
ln -s ~/shared-skills ~/.trae/skills
ln -s ~/shared-skills ~/.codebuddy/skills

具体目录需要根据各个 IDE 当前版本的 Skill 目录规则进行调整。

最终效果类似:

1
2
3
4
5
6
7
8
9
10
11
Qoder Skill
↓
~/shared-skills/

TRAE Skill
↓
~/shared-skills/

CodeBuddy Skill
↓
~/shared-skills/

于是你只需要修改:

1
~/shared-skills/code-review/SKILL.md

三个 IDE 使用的就是同一份内容。

这就解决了一个非常痛苦的问题:

一处修改,所有 IDE 生效。


四、Skill 不只是共享,还可以用 Git 管起来

如果只是把 Skill 放到一个公共目录,其实还不够。

因为 Skill 本身也是代码和知识资产。

既然是自己的工作流,为什么不把它放进 Git?

例如:

1
2
3
4
5
cd ~/shared-skills

git init
git add .
git commit -m "init skills"

之后修改 Skill:

1
2
git add .
git commit -m "improve code review skill"

甚至可以直接把它放到 GitHub、GitLab 或公司的 Git 服务中。

这样就有了一个独立的:

个人 AI Skill 仓库。

例如:

1
2
3
4
5
6
7
8
9
10
11
my-ai-skills/
├── README.md
├── coding/
│ ├── code-review/
│ └── write-test/
├── git/
│ └── git-commit/
├── architecture/
│ └── project-analysis/
└── documentation/
└── api-doc/

换电脑的时候,只需要:

1
git clone <your-skill-repository>

然后重新建立 IDE 的软链接即可。


五、这套方案真正有价值的地方

很多人一开始会觉得:

“几个 SKILL.md 文件而已,有必要这么折腾吗?”

真正用一段时间之后,就会发现价值非常大。

因为 Skill 会不断迭代。

比如最开始的 code-review 只有几十行,后来你发现 AI 经常漏掉异常处理,于是增加:

1
2
3
4
检查异常处理
检查边界条件
检查资源释放
检查并发安全

再后来,又加入公司的编码规范。

如果每个 IDE 都维护一份,那么修改一次就要同步三次。

但共享 Skill 后:

1
2
3
4
5
修改一次
↓
Git commit
↓
所有 IDE 自动使用最新版

这时候 Skill 才真正变成了自己的可积累资产。


六、不同 IDE 擅长不同事情,但 Skill 可以保持一致

这也是我比较推荐这种方案的另一个原因。

我们没有必要因为使用多个 IDE,就让工作流也碎片化。

可以让:

  • Qoder 负责它擅长的 Agent / Coding 场景
  • TRAE 负责适合它的开发任务
  • CodeBuddy 处理另外一些项目或工作流

但是,无论 AI 来自哪个 IDE,都遵循同一套:

1
2
3
4
5
6
7
8
9
10
11
代码规范
↓
项目分析方法
↓
测试规范
↓
Git 规范
↓
Code Review 规范
↓
文档规范

也就是说:

IDE 可以不同,AI 的“工作方法”可以统一。

这其实比单纯共享几个 Prompt 更重要。


七、进一步:把 Skill 当成自己的“AI 配置仓库”

如果再往前走一步,可以把 Skill 当成 dotfiles 一样管理。

例如:

1
2
~/dotfiles/
~/shared-skills/

里面保存的不是某个 IDE 的配置,而是自己长期积累下来的 AI 工作流。

最终形成:

1
2
3
4
5
6
7
8
                    ┌── Qoder
│
├── TRAE
shared-skills ──────┤
└── CodeBuddy
│
↓
Git Repository

以后无论换电脑、换 IDE,甚至换团队,只要新的工具支持类似的 Skill 机制,就可以继续复用。

工具会变化,但自己的工作流可以沉淀下来。


写在最后

AI IDE 的竞争会越来越激烈,我们没有必要在不同工具之间“二选一”。

哪个 IDE 在什么事情上好用,就用哪个。

真正值得沉淀的,不应该是某一个 IDE 的配置,而是我们自己积累出来的 Skill。

把 Skill 独立出来,再通过软链接让 Qoder、TRAE、CodeBuddy 共享,最后交给 Git 管理,就可以形成一套非常简单但实用的工作方式:

多个 IDE,一起薅羊毛;不同 IDE,各司其职;Skill 一份维护,到处生效;Git 管理,持续沉淀。

最终,你使用的不再只是几个 AI IDE,而是一套属于自己的、可以不断迭代的 AI Coding 工作流。

1. 题目

给定一个包含非负整数的 m × n 网格 grid,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

示例:

1
2
3
4
5
6
7
输入: grid = [
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 路径 1→3→1→1→1 总和最小 = 7。

2. 解题思路

只能向右或向下 ⇒ 到达 (i, j) 的前驱只有 (i-1, j) 和 (i, j-1),是最经典的 DP。

2.1 状态定义

dp[i][j] = 从左上角到 (i, j) 的最小路径和。

转移方程:

1
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])

边界:第一行只能从左边来 dp[0][j] = dp[0][j-1] + grid[0][j];第一列只能从上边来 dp[i][0] = dp[i-1][0] + grid[i][0]。

  • 时间复杂度:O(m·n),空间复杂度:O(m·n)。

2.2 空间优化到 O(n)

每行只依赖「上一行」和「本行左边」,可用一维滚动数组:dp[j] = grid[i][j] + min(dp[j](上一行的值), dp[j-1](本行左边的值))。

极限可原地改 grid 到 O(1)(若允许修改输入)。

3. TypeScript 实现

3.1 二维 DP(清晰)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function minPathSum(grid: number[][]): number {
const m = grid.length;
const n = grid[0].length;
const dp = Array.from({ length: m }, () => new Array(n).fill(0));

dp[0][0] = grid[0][0];
for (let j = 1; j < n; j++) dp[0][j] = dp[0][j - 1] + grid[0][j];
for (let i = 1; i < m; i++) dp[i][0] = dp[i - 1][0] + grid[i][0];

for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
}
}

return dp[m - 1][n - 1];
}

console.log(minPathSum([[1, 3, 1], [1, 5, 1], [4, 2, 1]])); // 7

3.2 一维滚动数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function minPathSumOpt(grid: number[][]): number {
const m = grid.length;
const n = grid[0].length;
const dp = new Array(n).fill(0);

for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (i === 0 && j === 0) dp[j] = grid[0][0];
else if (i === 0) dp[j] = dp[j - 1] + grid[i][j]; // 第一行
else if (j === 0) dp[j] = dp[j] + grid[i][j]; // 第一列,dp[j] 仍是上一行值
else dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j];
}
}

return dp[n - 1];
}

4. 面试延伸

  • 变体:不同路径(计数而非求和)把 min 换成 +,是同一网格 DP 骨架。
  • 带障碍 / 三角形最小路径和 / 地下城游戏:注意「地下城游戏」需从右下倒推(HP 约束),说明并非所有网格 DP 都能正向做。
  • 追问:为什么一维数组里 dp[j] 在更新前代表上一行?因为本行还没覆盖它,天然滚动——这是滚动数组的核心技巧。

难度:中等 | LeetCode 64 | 网格 DP 入门

1. 题目

给你一个二叉树的根节点 root,判断其是否是一个有效的二叉搜索树(BST)。

有效 BST 定义:

  • 节点的左子树只包含小于当前节点的数。
  • 节点的右子树只包含大于当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。

示例:

1
2
3
4
5
6
7
8
输入: root = [5,1,4,null,null,3,6]
5
/ \
1 4
/ \
3 6
输出: false
解释: 根节点 5 的右子树里出现 3(< 5),违反 BST 定义。

2. 解题思路

2.1 常见错误:只比较父子

只判断「左子 < 父 < 右子」是不够的——如上图,节点 3 是其父 4 的合法左子,但它位于根 5 的右子树中,必须 > 5。约束是从根一路传递下来的区间。

2.2 上下界递归(正确)

给每个节点维护一个开区间 (min, max):

  • 根的区间是 (-∞, +∞)。

  • 走向左孩子:上界收紧为父节点值 → (min, node.val)。

  • 走向右孩子:下界收紧为父节点值 → (node.val, max)。

  • 节点值必须严格落在自己区间内,否则非法。

  • 时间复杂度:O(n),空间复杂度:O(h)。

2.3 中序遍历递增

BST 的中序遍历结果严格递增。迭代中序遍历,比较当前值与前驱值 prev,若 val <= prev 则非法。用 prev 记录上一个访问值即可,天然利用「左→根→右」顺序。

  • 时间复杂度:O(n),空间复杂度:O(h)。

3. TypeScript 实现

树节点定义:

1
2
3
4
5
6
7
8
9
class TreeNode {
val: number;
left: TreeNode | null;
right: TreeNode | null;
constructor(val = 0) {
this.val = val;
this.left = this.right = null;
}
}

3.1 上下界递归

1
2
3
4
5
6
7
8
9
10
11
12
function isValidBST(root: TreeNode | null): boolean {
const check = (node: TreeNode | null, min: number, max: number): boolean => {
if (!node) return true;
if (node.val <= min || node.val >= max) return false;
return (
check(node.left, min, node.val) &&
check(node.right, node.val, max)
);
};

return check(root, -Infinity, Infinity);
}

3.2 中序遍历(迭代)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function isValidBSTInorder(root: TreeNode | null): boolean {
const stack: TreeNode[] = [];
let cur = root;
let prev = -Infinity;

while (cur || stack.length) {
while (cur) {
stack.push(cur);
cur = cur.left; // 一路向左
}
cur = stack.pop()!;
if (cur.val <= prev) return false; // 非严格递增
prev = cur.val;
cur = cur.right;
}

return true;
}

4. 面试延伸

  • 为什么用严格 <、>(而非 <=):BST 定义里子树值必须严格小于/大于,相等也算非法;用 Infinity 做初始边界可避免「节点值恰好等于 INT_MIN/INT_MAX」的坑(用 null 表示无边界更稳妥)。
  • 传递区间 vs 传递父指针:区间法最直观;中序法把「树问题」转成「有序序列问题」是常用思维。
  • 关联题:BST 第 k 小、把有序数组转 BST、LCA of BST 都依赖「中序有序」或「值域可二分」性质。

难度:中等 | LeetCode 98 | BST 性质 / 遍历

1. 题目

给定一个经过编码的字符串,返回它解码后的字符串。

编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。

你可以认为输入字符串总是有效的,输入中没有额外的空格,且方括号内总是含有小写字母或另外一个方括号。

示例:

1
2
3
输入: s = "3[a]2[bc]"    输出: "aaabcbc"
输入: s = "3[a2[c]]" 输出: "accaccacc"
输入: s = "2[abc]3[cd]ef" 输出: "abcabccdcdcdef"

2. 解题思路

存在嵌套结构 k[...],天然适合栈(或递归 DFS)。

2.1 双栈法(数字栈 + 字符串栈)

用两个栈分别保存括号层的「重复次数」和「进入括号前已拼好的字符串」:

  • curStr 累积当前层字符串,num 累积当前解析的数字。

  • 遇到 [:把当前的 num 压入 numStack、当前 curStr 压入 strStack,然后清空 num 和 curStr,进入内层。

  • 遇到 ]:弹出内层重复次数 k 和「外层字符串」prev,令 curStr = prev + curStr.repeat(k),回到外层。

  • 遇到字母:拼进 curStr;遇到数字:num = num * 10 + d(处理多位数)。

  • 时间复杂度:O(输出长度),空间复杂度:O(嵌套深度)。

3. TypeScript 实现

3.1 双栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function decodeString(s: string): string {
const numStack: number[] = [];
const strStack: string[] = [];
let curStr = "";
let num = 0;

for (const ch of s) {
if (ch >= "0" && ch <= "9") {
num = num * 10 + Number(ch); // 多位数字
} else if (ch === "[") {
numStack.push(num); // 保存本层重复次数
strStack.push(curStr); // 保存进入括号前的字符串
curStr = "";
num = 0;
} else if (ch === "]") {
const k = numStack.pop()!;
const prev = strStack.pop()!;
curStr = prev + curStr.repeat(k); // 内层展开并拼接外层
} else {
curStr += ch; // 普通字母
}
}

return curStr;
}

console.log(decodeString("3[a2[c]]")); // accaccacc

3.2 单栈(把字符逐个压栈)

把 ] 之前的所有字符压栈,遇到 ] 时弹栈收集字母与数字,再展开重新压回。写法统一但弹栈拼接略繁。

4. 面试延伸

  • 递归解法:用全局指针 i,decode() 遇到 [ 递归取内层结果并 repeat,遇到 ] 返回——很多前端候选人觉得递归更直观。
  • 本题是「括号匹配 / 嵌套求值」家族,同族:基本计算器、有效括号、最小栈。看到「嵌套 + 需要回到上一层状态」就应条件反射想到栈/递归。
  • 坑点:数字可能是多位(如 12[a]),必须用 num = num*10 + d 累积,不能 Number(ch) 单独取。

难度:中等 | LeetCode 394 | 栈 / 递归

1. 题目

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

示例:

1
2
3
4
5
输入: height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出: 6

输入: height = [4,2,0,3,2,5]
输出: 9

2. 解题思路

核心视角:按列计算。对每一列 i,它能接的水 = min(左侧最高, 右侧最高) - height[i](若为负则 0)。「木桶效应」取决于两边较矮的那个挡板。

2.1 动态规划预处理左右最大值

开两个数组 leftMax[i]、rightMax[i] 分别预处理「从两端到 i 的最大高度」,再逐列累加。O(n) 时间、O(n) 空间。

2.2 双指针(最优)

左右指针 l、r 向中间逼近,维护 leftMax、rightMax 两个标量:

  • 比较 height[l] 与 height[r],较矮的一侧决定该侧列的接水量:

    • 若 height[l] < height[r]:左列的左挡板是 leftMax,右挡板至少是 height[r](≥ height[l]),所以左侧当前列水位由 leftMax 决定 → 累加 leftMax - height[l],l++。
    • 右侧对称处理。
  • 边走边更新各自的 max。

  • 时间复杂度:O(n),空间复杂度:O(1)。

3. TypeScript 实现

3.1 双指针(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function trap(height: number[]): number {
let l = 0;
let r = height.length - 1;
let leftMax = 0;
let rightMax = 0;
let water = 0;

while (l < r) {
if (height[l] < height[r]) {
if (height[l] >= leftMax) {
leftMax = height[l]; // 新的左挡板
} else {
water += leftMax - height[l]; // 填补凹处
}
l++;
} else {
if (height[r] >= rightMax) {
rightMax = height[r];
} else {
water += rightMax - height[r];
}
r--;
}
}

return water;
}

console.log(trap([0,1,0,2,1,0,1,3,2,1,2,1])); // 6

3.2 DP 预处理左右最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function trapDP(height: number[]): number {
const n = height.length;
if (n === 0) return 0;
const leftMax = new Array(n).fill(0);
const rightMax = new Array(n).fill(0);

leftMax[0] = height[0];
for (let i = 1; i < n; i++) leftMax[i] = Math.max(leftMax[i - 1], height[i]);

rightMax[n - 1] = height[n - 1];
for (let i = n - 2; i >= 0; i--) rightMax[i] = Math.max(rightMax[i + 1], height[i]);

let water = 0;
for (let i = 0; i < n; i++) water += Math.min(leftMax[i], rightMax[i]) - height[i];

return water;
}

4. 面试延伸

  • 栈解法:用单调递减栈按「行」积水(遇到更高柱子就弹出凹槽结算宽度),思路另类,可作加分。
  • 与盛最多水的容器对照:那道是「两条板一次盛水」,本题是「每列独立按左右挡板蓄水」,都是双指针经典,但计算方式不同。
  • 追问:为什么 height[l] < height[r] 时能直接对左列结算?因为右挡板已被 height[r] 保证不矮于当前左侧,水位由 leftMax 唯一确定,无需求真实的右侧最大值。

难度:困难 | LeetCode 42 | 双指针 / 单调栈

1. 题目

设计一个支持 push、pop、top 操作,并能在 O(1) 时间内检索到最小元素的栈。

实现 MinStack 类:

  • push(val) 将元素压入栈
  • pop() 移除栈顶元素
  • top() 返回栈顶元素
  • getMin() 返回栈中的最小元素

示例:

1
2
3
4
5
6
7
8
9
输入:
MinStack s = new MinStack();
s.push(-2);
s.push(0);
s.push(-3);
s.getMin(); → -3
s.pop();
s.top(); → 0
s.getMin(); → -2

2. 解题思路

普通栈取最小值需要 O(n) 遍历。要 O(1),需额外空间换时间。

2.1 辅助栈(同步记录最小值)

维护两个栈:data 存所有元素,minStack 存「对应 data 各状态下的最小值」。

  • push 时:data 正常入栈;minStack 入栈 min(val, 当前 minStack 栈顶)。
  • pop 时:两栈同时弹出。
  • getMin:直接看 minStack 栈顶。

这样 minStack 栈顶始终是 data 当前全部元素的最小值。

  • 时间复杂度:所有操作 O(1);空间复杂度:O(n)。

2.2 优化:最小栈只在「新最小或并列最小」时压入

minStack 只在 val <= 当前最小 时压入,pop 时若 data 弹出值等于栈顶再同步弹出 minStack,可省部分空间(注意 <= 以处理重复最小值)。

3. TypeScript 实现

3.1 双辅助栈(经典)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MinStack {
private data: number[] = [];
private minStack: number[] = [];

push(val: number): void {
this.data.push(val);
const curMin = this.minStack.length
? this.minStack[this.minStack.length - 1]
: val;
this.minStack.push(Math.min(val, curMin));
}

pop(): void {
this.data.pop();
this.minStack.pop();
}

top(): number {
return this.data[this.data.length - 1];
}

getMin(): number {
return this.minStack[this.minStack.length - 1];
}
}

3.2 单栈存差值(进阶,省一个栈)

栈中每个元素存「压入时的值 − 当时的最小值」。用 min 记录当前最小:

  • 压入 val:若 val - min >= 0 直接入栈;否则说明出现更小值,入 val - min(负数)并更新 min = val。
  • pop 时:弹出 d,若 d >= 0 原值为 min + d;若 d < 0 说明弹出的是「旧的最小值」,需还原 min = min - d。

思路巧妙但边界易错,面试能讲清 3.1 已足够,3.2 作为加分。

4. 面试延伸

  • 核心思想:用空间换 O(1) 查询,「辅助栈记录历史状态」是通用技巧。
  • 追问:如果 getMin 之后还要能恢复?——正因为 pop 同步回退 minStack,历史最小值可正确还原,这是辅助栈方法的正确性来源。
  • 类似设计题:LRU 缓存、带最大值的队列(双栈模拟队列 + 单调队列)等,考察数据结构组合能力,可对照本博客 LRU Cache。
  • 语言细节:TS 里 private 字段、返回栈顶用 arr[arr.length-1],注意空栈的防御(LeetCode 保证合法调用)。

难度:中等 | LeetCode 155 | 设计 / 栈

0%