阅读视图

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

我如何用 AI 处理历史遗留代码:MiniMax M2.1 升级体验

一、

最近,我写了好几篇 AI 教程,就收到留言,要我谈谈我自己的 AI 编程。

今天就来分享我的 AI 编程,也就是大家说的"氛围编程"(vibe coding)。

声明一下,我只是 AI 初级用户,不是高手。除了不想藏私,更多是为了抛砖引玉,跟大家交流。

二、

平时,我很少用 AI 生成新项目。因为每次看 AI 产出的代码,我总觉得那是别人的代码,不是我的。

如果整个项目都用 AI 生成,潜意识里,我感觉不到那是自己的项目。我的习惯是,更愿意自己写新项目的主体代码。

我主要把 AI 用在别人的项目和历史遗留代码,这可以避免读懂他人代码的巨大时间成本。

就拿历史遗留代码为例,(1)很多时候没有足够的文档,也没有作者的说明,(2)技术栈和工具库都过时了,读懂代码还要翻找以前的标准,(3)最极端的情况下,只有构建产物,没有源代码,根本无法着手。

AI 简直就是这类代码的救星,再古老的代码,它都能读懂和修改,甚至还能对构建产物进行逆向工程。

下面就是我怎么用 AI 处理历史遗留代码,平时我基本就是这样来 AI 编程。

三、

我的 AI 编程工具是 Claude Code。因为命令行对我更方便,也容易跟其他工具集成。

我使用的 AI 模型,大部分时间是国产的 MiniMax M2。我测过它的功能,相当不错,能够满足需要,它的排名也很靠前。

另外,它有包月价(29元人民币),属于最便宜的编程模型之一,可以放心大量使用,反复试错。要是改用大家都趋之若鹜的 Claude 系列模型,20美元的 Pro 套餐不够用,200美元的 Max 套餐又太贵。

MiniMax 接入 Claude Code 的方法,参考我的这篇教程

四、

就在我写这篇文章的时候,MiniMax 本周进行了一次大升级,M2 模型升级到了 M2.1

因为跟自己相关,我特别关注这次升级。

根据官方的发布声明,这次升级特别加强了"多语言编程能力",对于常用编程语言(Rust、Java、Golang、C++、Kotlin、Objective-C、TypeScript、JavaScript 等)有专门强化。

它的 WebDev 与 AppDev 开发能力因此有大幅提升,可以用来开发复杂的 Web 应用和 Android/iOS 的原生 App。

"在软件工程相关场景的核心榜单上,MiniMax M2.1 相比于 M2 有了显著的提升,尤其是在多语言场景上,超过 Claude Sonnet 4.5 和 Gemini 3 Pro,并接近 Claude Opus 4.5。"

根据上面这段介绍,它的编程能力,超出或接近了国外旗舰模型。

这个模型已经上线了,现在就能用。那么,这篇文章正好测一下,官方的介绍是否准确,它的 Web 开发能力到底有没有变强。

至于价格,跟原来一样。但是,官方表示"响应速度显著提升,Token 消耗明显下降",也算变相降价了。

M2.1 接入 Claude Code,我的参数如下。

五、

我这次选择的历史遗留项目是 wechat-format,一个 Web 应用,将 Markdown 文本转为微信公众号的样式。

上图左侧的文本框输入 Markdown 文本,右侧立刻显示自动渲染的结果,可以直接复制到微信公众号的编辑器。

它非常好用,大家可以去试试看。我的公众号现在就用它做排版,效果不错(下图)。

问题是,原作者六年前就放弃了,这个项目不再更新了。我看过源码,它用的是老版本的 Vue.js 和 CodeMirror 编辑器,没有任何文档和说明,还经过了编译工具的处理,注释都删掉了。

如果不熟悉它的技术栈,想要修改这些代码是很困难的,可能要投入大量时间。

那么废话少说,直接让 AI 上场,把这些代码交给 MiniMax M2.1 模型。

六、

接手老项目的第一步,是对项目进行一个总体的了解。

我首先会让 AI 生成项目概述。大家可以跟着一起做,跟我的结果相对照。


# 克隆代码库
$ git clone git@github.com:ruanyf/wechat-format.git

# 进入项目目录
$ cd wechat-format

# 启动 Claude Code
$ claude-minimax

上面的claude-minimax是我的自定义命令,用来在 Claude Code 里面调用 MiniMax 模型(参见教程)。

输入"生成这个仓库的概述"。

AI 很快就给出了详细说明,包括项目的总体介绍、核心功能、技术栈和文件结构(下图)。

有了总体了解以后,我会让 AI 解释主要脚本文件的代码。

【提示词】解释 index.html 文件的代码

它会给出代码结构和页面布局(上图),然后是 JS 脚本加载顺序和 Vue 应用逻辑,甚至包括了流程图(下图),这可是我没想到的。

做完这一步,代码库的大致情况应该就相当了解了,而 AI 花费的时间不到一分钟。

七、

既然这个模型号称有"多语言编程能力",我就让它把项目语言从 JavaScript 改成 TypeScript。

对于很多老项目来说,这也是常见需求,难度不低。

它先制定了迁移计划,然后生成了 tsconfig.json 和 types.d.ts,并逐个将 JS 文件转为对应的 TS 文件(下图)。

修改完成后,它试着运行这个应用,发现有报错(下图),于是又逐个解决错误。

最终,迁移完成,它给出了任务总结(下图)。

我在浏览器运行这个应用,遇到了两个报错:CodeMirror 和 FuriganaMD 未定义。

我把报错信息提交给模型,它很快修改了代码,这次就顺利在浏览器跑起来了。

至此,这个多年前的 JavaScript 应用就成功改成了 TypeScript 应用,并且所有内部对象都有了完整的类型定义。

你还可以接着添加单元测试,这里就省略了。

八、

简单的测试就到此为止,我目前的 AI 编程大概就到这个程度,用 AI 来解释和修改代码。我也建议大家,以后遇到历史遗留代码,一律先交给 AI。

虽然这个测试比较简单,不足以考验 MiniMax M2.1 的能力上限,但如果人工来做上面这些事情,可能一个工作日还搞不定,但是它只需要十几分钟。

总体上,我对它的表现比较满意。大家都看到了,我的提示词很简单,就是一句话,但是它正确理解了意图,如果一次没有成功,最多再修改一两次就正确了。

而且,就像发布说明说的一样,它运行速度很快,思考过程和生成过程最多也就两三分钟,不像有的模型要等很久。

另外,不管什么操作,它都会给出详细的讲解和代码注释。

总之,就我测试的情况来看,这个模型的 Web 开发能力确实很不错,可以用于实际工作。

最后,说一点题外话。著名开发者 Simon Willison 最近说,评测大模型越来越困难,"我识别不出两个模型之间的实质性差异",因为主流的新模型都已经足够强大,足以解决常见任务,只有不断升级评测的难度,才能测出它们的强弱。

这意味着,对于普通程序员的常见编程任务,不同模型不会构成重大差异,没必要迷信国外的旗舰模型,国产模型就很好用。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2025年12月23日

你以为 Props 只是传参? 不,它是 React 组件设计的“灵魂系统”

90% 的 React 初学者,都低估了 Props。
他们以为它只是“从父组件往子组件传点数据”。

但真正写过复杂组件、设计过通用组件的人都知道一句话:

Props 决定了一个组件“好不好用”,而不是“能不能用”。

这篇文章,我们不讲 API 清单、不背概念,
而是围绕 Props 系统的 5 个核心能力,一次性讲透 React 组件化的底层逻辑:

  • Props 传递
  • Props 解构
  • 默认值(defaultProps / 默认参数)
  • 类型校验(PropTypes)
  • children 插槽机制(React 的核武器)

👉 看完你会明白:
React 真正厉害的不是 JSX,而是 Props 设计。


一、Props 的本质:组件的“对外接口”

先抛一个结论:

React 组件 ≈ 一个函数 + 一套 Props 接口

来看一个最简单的组件 👇

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

使用时:

<Greeting name="白兰地" />

很多人到这里就停了,但问题是:

name 到底是什么?

答案是:

name 不是变量,是组件对外暴露的能力。

Props 本质上是:

  • 父组件 👉 子组件的输入
  • 组件作者 👉 使用者的约定

二、Props 解构:不是语法糖,而是“设计声明”

对比两种写法 👇

❌ 不推荐

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

✅ 推荐

function Greeting({ name }) {
  return <h1>Hello, {name}</h1>
}

为什么?

解构不是为了少写字,而是为了表达意图。

当你看到函数签名:

function Greeting({ name, message, showIcon }) {}

你立刻就知道:

  • 这个组件“需要什么”
  • 组件的“输入边界”在哪里

👉 好的组件,从函数签名就能读懂。


三、Props 默认值:组件“健壮性”的第一步

看这个组件 👇

function Greeting({ name, message }) {
  return (
    <div>
      <h1>Hello, {name}</h1>
      <p>{message}</p>
    </div>
  )
}

如果使用者这么写:

<Greeting name="空瓶" />

会发生什么?

message === undefined

这时候就轮到 默认值 出场了。


方式一:defaultProps(经典)

Greeting.defaultProps = {
  message: 'Welcome!'
}

方式二:解构默认值(更推荐)

function Greeting({ name, message = 'Welcome!' }) {}

💡默认值不是兜底,而是组件设计的一部分。

它代表的是:

  • “在你不配置的情况下”
  • “组件应该表现成什么样”

四、Props 类型校验:组件的“自说明文档”

来看一段很多人忽略、但非常值钱的代码 👇

import PropTypes from 'prop-types'

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
  message: PropTypes.string,
  showIcon: PropTypes.bool,
}

很多人会说:

“这不是可有可无吗?”

但在真实项目里,它解决的是:

  • ❌ 参数传错没人发现
  • ❌ 新人不知道组件怎么用
  • ❌ 组件一多,全靠猜

🔍 PropTypes 的真正价值

不是防 bug,而是“降低理解成本”。

当你看到 propTypes,就等于看到一份说明书:

  • 哪些 props 必须传?
  • 哪些是可选?
  • 类型是什么?

👉 一个没有 propTypes 的通用组件,本质上是“黑盒”。


五、children:React Props 系统的“王炸”

如果只能选一个 Props 机制,我会毫不犹豫选:

🧨 children

来看一个 Card 组件 👇

const Card = ({ children, className = '' }) => {
  return (
    <div className={`card ${className}`}>
      {children}
    </div>
  )
}

使用时:

<Card className="user-card">
  <h2>张三</h2>
  <p>高级前端工程师</p>
  <button>查看详情</button>
</Card>

这里发生了一件非常重要的事情:

组件不再关心“内容是什么”。


🧠 children 的设计哲学

组件负责“骨架”,使用者负责“填充”。

  • Card 只负责:边框、阴影、间距
  • children 决定:展示什么内容

这让组件具备了两个特性:

  • ✅ 高度复用
  • ✅ 永不过期

六、children + Props = 通用组件的终极形态

再看一个更高级的例子:Modal 👇

<Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
  <p>这是一个弹窗</p>
  <p>你可以在这里显示任何 JSX</p>
</Modal>

Modal 的实现:

function Modal({ HeaderComponent, FooterComponent, children }) {
  return (
    <div>
      <HeaderComponent />
      {children}
      <FooterComponent />
    </div>
  )
}

这背后是一个非常高级的思想:

Props 不只是数据,也可以是组件。


七、请记住这 5 条 Props 设计铁律

🔥 如果你只能记住一段话,请记住这里

  1. Props 是组件的“对外接口”,不是随便传的变量
  2. 解构 Props,是在声明组件的能力边界
  3. 默认值,决定组件的“基础体验”
  4. 类型校验,让组件自带说明书
  5. children,让组件从“可用”变成“好用”

八、写在最后

当你真正理解 Props 之后,你会发现:

  • React 不只是 UI 库
  • 它在教你如何设计 API
  • 如何让别人“用得爽”

Props 写得好不好,决定了一个人 React 水平的上限。

每日一题-两个最好的不重叠活动🟡

给你一个下标从 0 开始的二维整数数组 events ,其中 events[i] = [startTimei, endTimei, valuei] 。第 i 个活动开始于 startTimei ,结束于 endTimei ,如果你参加这个活动,那么你可以得到价值 valuei 。你 最多 可以参加 两个时间不重叠 活动,使得它们的价值之和 最大 。

请你返回价值之和的 最大值 。

注意,活动的开始时间和结束时间是 包括 在活动时间内的,也就是说,你不能参加两个活动且它们之一的开始时间等于另一个活动的结束时间。更具体的,如果你参加一个活动,且结束时间为 t ,那么下一个活动必须在 t + 1 或之后的时间开始。

 

示例 1:

输入:events = [[1,3,2],[4,5,2],[2,4,3]]
输出:4
解释:选择绿色的活动 0 和 1 ,价值之和为 2 + 2 = 4 。

示例 2:

Example 1 Diagram

输入:events = [[1,3,2],[4,5,2],[1,5,5]]
输出:5
解释:选择活动 2 ,价值和为 5 。

示例 3:

输入:events = [[1,5,3],[1,5,1],[6,6,5]]
输出:8
解释:选择活动 0 和 2 ,价值之和为 3 + 5 = 8 。

 

提示:

  • 2 <= events.length <= 105
  • events[i].length == 3
  • 1 <= startTimei <= endTimei <= 109
  • 1 <= valuei <= 106

两个最好的不重叠活动

方法一:时间戳排序

思路与算法

我们可以将所有活动的左右边界放在一起进行自定义排序。具体地,我们用 $(\textit{ts}, \textit{op}, \textit{val})$ 表示一个「事件」:

  • $\textit{op}$ 表示该事件的类型。如果 $\textit{op} = 0$,说明该事件表示一个活动的开始;如果 $\textit{op} = 1$,说明该事件表示一个活动的结束。

  • $\textit{ts}$ 表示该事件发生的时间,即活动的开始时间或结束时间。

  • $\textit{val}$ 表示该事件的价值,即对应活动的 $\textit{value}$ 值。

我们将所有的时间按照 $\textit{ts}$ 为第一关键字升序排序,这样我们就能按照时间顺序依次处理每一个事件。当 $\textit{ts}$ 相等时,我们按照 $\textit{op}$ 为第二关键字升序排序,这是因为题目中要求了「第一个活动的结束时间不能等于第二个活动的起始时间」,因此当时间相同时,我们先处理开始的事件,再处理结束的事件。

当排序完成后,我们就可以通过对所有的事件进行一次遍历,从而算出最多两个时间不重叠的活动的最大价值:

  • 当我们遍历到一个结束事件时,我们用 $\textit{val}$ 来更新 $\textit{bestFirst}$,其中 $\textit{bestFirst}$ 表示当前已经结束的所有活动的最大价值。这样做的意义在于,所有已经结束的事件都可以当作第一个活动

  • 当我们遍历到一个开始事件时,我们将该活动当作第二个活动,由于第一个活动的最大价值为 $\textit{bestFirst}$,因此我们用 $\textit{val} + \textit{bestFirst}$ 更新答案即可。

代码

###C++

struct Event {
    // 时间戳
    int ts;
    // op = 0 表示左边界,op = 1 表示右边界
    int op;
    int val;
    Event(int _ts, int _op, int _val): ts(_ts), op(_op), val(_val) {}
    bool operator< (const Event& that) const {
        return tie(ts, op) < tie(that.ts, that.op);
    }
};

class Solution {
public:
    int maxTwoEvents(vector<vector<int>>& events) {
        vector<Event> evs;
        for (const auto& event: events) {
            evs.emplace_back(event[0], 0, event[2]);
            evs.emplace_back(event[1], 1, event[2]);
        }
        sort(evs.begin(), evs.end());
        
        int ans = 0, bestFirst = 0;
        for (const auto& [ts, op, val]: evs) {
            if (op == 0) {
                ans = max(ans, val + bestFirst);
            }
            else {
                bestFirst = max(bestFirst, val);
            }
        }
        return ans;
    }
};

###Python

class Event:
    def __init__(self, ts: int, op: int, val: int):
        self.ts = ts
        self.op = op
        self.val = val
    
    def __lt__(self, other: "Event") -> bool:
        return (self.ts, self.op) < (other.ts, other.op)


class Solution:
    def maxTwoEvents(self, events: List[List[int]]) -> int:
        evs = list()
        for event in events:
            evs.append(Event(event[0], 0, event[2]))
            evs.append(Event(event[1], 1, event[2]))
        evs.sort()

        ans = bestFirst = 0
        for ev in evs:
            if ev.op == 0:
                ans = max(ans, ev.val + bestFirst)
            else:
                bestFirst = max(bestFirst, ev.val)
        
        return ans

###Java

class Solution {
    public int maxTwoEvents(int[][] events) {
        List<Event> evs = new ArrayList<>();
        for (int[] event : events) {
            evs.add(new Event(event[0], 0, event[2]));
            evs.add(new Event(event[1], 1, event[2]));
        }
        Collections.sort(evs);
        int ans = 0, bestFirst = 0;
        for (Event event : evs) {
            if (event.op == 0) {
                ans = Math.max(ans, event.val + bestFirst);
            } else {
                bestFirst = Math.max(bestFirst, event.val);
            }
        }
        return ans;
    }
    
    class Event implements Comparable<Event> {
        int ts;
        int op;
        int val;
        
        Event(int ts, int op, int val) {
            this.ts = ts;
            this.op = op;
            this.val = val;
        }
        
        @Override
        public int compareTo(Event other) {
            if (this.ts != other.ts) {
                return Integer.compare(this.ts, other.ts);
            }
            return Integer.compare(this.op, other.op);
        }
    }
}

###C#

public class Solution {
    public int MaxTwoEvents(int[][] events) {
        List<Event> evs = new List<Event>();
        foreach (var eventArr in events) {
            evs.Add(new Event(eventArr[0], 0, eventArr[2]));
            evs.Add(new Event(eventArr[1], 1, eventArr[2]));
        }
        evs.Sort();
        
        int ans = 0, bestFirst = 0;
        foreach (var ev in evs) {
            if (ev.Op == 0) {
                ans = Math.Max(ans, ev.Val + bestFirst);
            } else {
                bestFirst = Math.Max(bestFirst, ev.Val);
            }
        }
        return ans;
    }
    
    class Event : IComparable<Event> {
        public int Ts { get; set; }
        public int Op { get; set; }
        public int Val { get; set; }
        
        public Event(int ts, int op, int val) {
            Ts = ts;
            Op = op;
            Val = val;
        }
        
        public int CompareTo(Event other) {
            if (Ts != other.Ts) {
                return Ts.CompareTo(other.Ts);
            }
            return Op.CompareTo(other.Op);
        }
    }
}

###Go

func maxTwoEvents(events [][]int) int {
    type Event struct {
        ts  int
        op  int
        val int
    }
    
    evs := make([]Event, 0)
    for _, event := range events {
        evs = append(evs, Event{event[0], 0, event[2]})
        evs = append(evs, Event{event[1], 1, event[2]})
    }
    
    sort.Slice(evs, func(i, j int) bool {
        if evs[i].ts != evs[j].ts {
            return evs[i].ts < evs[j].ts
        }
        return evs[i].op < evs[j].op
    })
    
    ans, bestFirst := 0, 0
    for _, ev := range evs {
        if ev.op == 0 {
            if ev.val + bestFirst > ans {
                ans = ev.val + bestFirst
            }
        } else {
            if ev.val > bestFirst {
                bestFirst = ev.val
            }
        }
    }
    return ans
}

###C

typedef struct {
    int ts;
    int op;
    int val;
} Event;

int compareEvents(const void* a, const void* b) {
    Event* e1 = (Event*)a;
    Event* e2 = (Event*)b;
    if (e1->ts != e2->ts) {
        return e1->ts - e2->ts;
    }
    return e1->op - e2->op;
}

int maxTwoEvents(int** events, int eventsSize, int* eventsColSize) {
    Event* evs = (Event*)malloc(2 * eventsSize * sizeof(Event));
    int idx = 0;
    for (int i = 0; i < eventsSize; i++) {
        evs[idx++] = (Event){events[i][0], 0, events[i][2]};
        evs[idx++] = (Event){events[i][1], 1, events[i][2]};
    }
    qsort(evs, 2 * eventsSize, sizeof(Event), compareEvents);

    int ans = 0, bestFirst = 0;
    for (int i = 0; i < 2 * eventsSize; i++) {
        if (evs[i].op == 0) {
            if (evs[i].val + bestFirst > ans) {
                ans = evs[i].val + bestFirst;
            }
        } else {
            if (evs[i].val > bestFirst) {
                bestFirst = evs[i].val;
            }
        }
    }
    
    free(evs);
    return ans;
}

###JavaScript

var maxTwoEvents = function(events) {
    const evs = [];
    for (const event of events) {
        evs.push({ts: event[0], op: 0, val: event[2]});
        evs.push({ts: event[1], op: 1, val: event[2]});
    }
    
    evs.sort((a, b) => {
        if (a.ts !== b.ts) {
            return a.ts - b.ts;
        }
        return a.op - b.op;
    });
    
    let ans = 0, bestFirst = 0;
    for (const ev of evs) {
        if (ev.op === 0) {
            ans = Math.max(ans, ev.val + bestFirst);
        } else {
            bestFirst = Math.max(bestFirst, ev.val);
        }
    }
    return ans;
};

###TypeScript

function maxTwoEvents(events: number[][]): number {
    interface Event {
        ts: number;
        op: number;
        val: number;
    }
    
    const evs: Event[] = [];
    for (const event of events) {
        evs.push({ts: event[0], op: 0, val: event[2]});
        evs.push({ts: event[1], op: 1, val: event[2]});
    }
    
    evs.sort((a, b) => {
        if (a.ts !== b.ts) {
            return a.ts - b.ts;
        }
        return a.op - b.op;
    });
    
    let ans = 0, bestFirst = 0;
    for (const ev of evs) {
        if (ev.op === 0) {
            ans = Math.max(ans, ev.val + bestFirst);
        } else {
            bestFirst = Math.max(bestFirst, ev.val);
        }
    }
    return ans;
}

###Rust

#[derive(Debug)]
struct Event {
    ts: i32,
    op: i32,
    val: i32,
}

impl Solution {
    pub fn max_two_events(events: Vec<Vec<i32>>) -> i32 {
        let mut evs: Vec<Event> = Vec::new();
        for event in events {
            evs.push(Event { ts: event[0], op: 0, val: event[2] });
            evs.push(Event { ts: event[1], op: 1, val: event[2] });
        }
        
        evs.sort_by(|a, b| {
            if a.ts != b.ts {
                a.ts.cmp(&b.ts)
            } else {
                a.op.cmp(&b.op)
            }
        });
        
        let mut ans = 0;
        let mut best_first = 0;
        for ev in evs {
            if ev.op == 0 {
                ans = ans.max(ev.val + best_first);
            } else {
                best_first = best_first.max(ev.val);
            }
        }
        
        ans
    }
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,其中 $n$ 是数组 $\textit{events}$ 的长度。

  • 空间复杂度:$O(n)$。

排序 + 单调栈二分(Python/Java/C++/Go)

设参加的第二个活动的开始时间为 $\textit{startTime}$,那么第一个活动选哪个最好?

在结束时间小于 $\textit{startTime}$ 的活动中,选择价值最大的活动。

为了方便查找,先把 $\textit{events}$ 按照结束时间从小到大排序。

排序后,对比如下两个活动:

  • 活动一:结束于 $3$ 时刻,价值 $999$。
  • 活动二:结束于 $6$ 时刻,价值 $9$。

活动二的结束时间又晚,价值又小,全方面不如活动一,是垃圾数据,直接忽略。

换句话说,在遍历 $\textit{events}$ 的过程中(注意 $\textit{events}$ 已按照结束时间排序),只在遇到更大价值的活动时,才记录该活动。把这些活动记录到一个栈(列表)中,那么从栈底到栈顶,结束时间是递增的,价值也是递增的,非常适合二分查找。关于二分查找的原理,请看 二分查找 红蓝染色法【基础算法精讲 04】

枚举第二个活动,在单调栈中二分查找结束时间严格小于 $\textit{startTime}$ 的最后一个活动,即为价值最大的第一个活动。如果没找到,那么只能选一个活动。

为了简化判断逻辑,可以在栈底加一个结束时间为 $0$,价值也为 $0$ 的哨兵。

写法一

class Solution:
    def maxTwoEvents(self, events: List[List[int]]) -> int:
        # 按照结束时间排序
        events.sort(key=lambda e: e[1])  

        # 从栈底到栈顶,结束时间递增,价值递增
        st = [(0, 0)]  # 栈底哨兵 
        ans = 0
        for start_time, end_time, value in events:
            # 二分查找最后一个结束时间 < start_time 的活动
            i = bisect_left(st, (start_time,)) - 1
            ans = max(ans, st[i][1] + value)
            # 遇到比栈顶更大的价值,入栈
            if value > st[-1][1]:
                st.append((end_time, value))
        return ans
class Solution {
    public int maxTwoEvents(int[][] events) {
        // 按照结束时间排序
        Arrays.sort(events, (a, b) -> a[1] - b[1]);

        // 从栈底到栈顶,结束时间递增,价值递增
        ArrayList<int[]> st = new ArrayList<>(); // (结束时间, 价值)
        st.add(new int[]{0, 0}); // 栈底哨兵

        int ans = 0;
        for (int[] e : events) {
            int i = search(st, e[0]);
            int value = e[2];
            ans = Math.max(ans, st.get(i)[1] + value);
            // 遇到比栈顶更大的价值,入栈
            if (value > st.getLast()[1]) {
                st.add(new int[]{e[1], value});
            }
        }
        return ans;
    }

    // 返回最后一个满足 st[i][0] < target 的 i
    private int search(List<int[]> st, int target) {
        int left = -1, right = st.size();
        while (left + 1 < right) { // 开区间二分
            int mid = left + (right - left) / 2;
            if (st.get(mid)[0] < target) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
class Solution {
public:
    int maxTwoEvents(vector<vector<int>>& events) {
        // 按照结束时间排序
        ranges::sort(events, {}, [](auto& e) { return e[1]; });

        // 从栈底到栈顶,结束时间递增,价值递增
        vector<pair<int, int>> st = {{0, 0}}; // 栈底哨兵
        int ans = 0;
        for (auto& e : events) {
            int start_time = e[0], value = e[2];
            // 二分查找最后一个结束时间 < start_time 的活动
            auto it = --ranges::lower_bound(st, start_time, {}, &pair<int, int>::first);
            ans = max(ans, it->second + value);
            // 遇到比栈顶更大的价值,入栈
            if (value > st.back().second) {
                st.emplace_back(e[1], value);
            }
        }
        return ans;
    }
};
func maxTwoEvents(events [][]int) (ans int) {
// 按照结束时间排序
slices.SortFunc(events, func(a, b []int) int { return a[1] - b[1] })

// 从栈底到栈顶,结束时间递增,价值递增
type pair struct{ endTime, value int }
st := []pair{{}} // 栈底哨兵
for _, e := range events {
startTime, value := e[0], e[2]
// 二分查找最后一个结束时间 < startTime 的活动
i := sort.Search(len(st), func(i int) bool { return st[i].endTime >= startTime }) - 1
ans = max(ans, st[i].value+value)
// 遇到比栈顶更大的价值,入栈
if value > st[len(st)-1].value {
st = append(st, pair{e[1], value})
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{events}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

写法二:原地

也可以把 $\textit{events}$ 当作栈。

缺点是没法用哨兵。

class Solution:
    def maxTwoEvents(self, events: List[List[int]]) -> int:
        events.sort(key=lambda e: e[1])

        ans = size = 0  # 把 events 当作栈
        for start_time, end_time, value in events:
            i = bisect_left(events, (start_time,), hi=size) - 1
            if i >= 0:
                ans = max(ans, value + events[i][1])
            else:
                ans = max(ans, value)

            if size == 0 or value > events[size - 1][1]:
                events[size] = (end_time, value)
                size += 1
        return ans
class Solution {
    public int maxTwoEvents(int[][] events) {
        Arrays.sort(events, (a, b) -> a[1] - b[1]);

        int ans = 0;
        int size = 0; // 把 events 当作栈
        for (int[] e : events) {
            int i = search(events, size, e[0]);
            int value = e[2];
            if (i >= 0) {
                ans = Math.max(ans, value + events[i][2]);
            } else {
                ans = Math.max(ans, value);
            }

            if (size == 0 || value > events[size - 1][2]) {
                events[size++] = e;
            }
        }
        return ans;
    }

    // 返回最后一个满足 st[i][1] < target 的 i
    private int search(int[][] st, int right, int target) {
        int left = -1;
        while (left + 1 < right) { // 开区间二分
            int mid = left + (right - left) / 2;
            if (st[mid][1] < target) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
class Solution {
public:
    int maxTwoEvents(vector<vector<int>>& events) {
        ranges::sort(events, {}, [](auto& e) { return e[1]; });

        int ans = 0, size = 0; // 把 events 当作栈
        for (auto& e : events) {
            int start_time = e[0], value = e[2];
            auto it = ranges::lower_bound(events.begin(), events.begin() + size, start_time, {}, [](auto& e) { return e[1]; });
            if (it != events.begin()) {
                ans = max(ans, value + (*--it)[2]);
            } else {
                ans = max(ans, value);
            }

            if (size == 0 || value > events[size - 1][2]) {
                events[size++] = e;
            }
        }
        return ans;
    }
};
func maxTwoEvents(events [][]int) (ans int) {
slices.SortFunc(events, func(a, b []int) int { return a[1] - b[1] })

st := events[:0] // 把 events 当作栈
for _, e := range events {
startTime, value := e[0], e[2]
i := sort.Search(len(st), func(i int) bool { return st[i][1] >= startTime }) - 1
if i >= 0 {
ans = max(ans, value+events[i][2])
} else {
ans = max(ans, value)
}
if len(st) == 0 || value > st[len(st)-1][2] {
st = append(st, e)
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{events}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序的栈开销。

专题训练

  1. 单调栈题单。
  2. 动态规划题单的「§7.2 不相交区间」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

从微信公众号&小程序的SDK剖析JSBridge

从微信公众号&小程序的SDK剖析JSBridge

引言

在移动互联网时代,Hybrid应用已成为主流开发模式之一。JSBridge作为连接JavaScript与Native的核心桥梁,让Web页面能够调用原生能力,实现了跨平台开发的完美平衡。微信作为国内最大的超级应用,其公众号JSSDK和小程序架构为我们提供了绝佳的JSBridge实践案例。本文将深入剖析这两套SDK的实现原理,帮助读者理解JSBridge的本质与设计思想。

一、JSBridge核心概念

1.1 什么是JSBridge

JSBridge是JavaScript与Native之间的通信桥梁,它建立了双向消息通道,使得:

  • JavaScript调用Native: Web页面可以调用原生能力(相机、地理位置、支付等)
  • Native调用JavaScript: 原生代码可以向Web页面传递数据或触发事件

1.2 JSBridge通信架构

graph TB
    subgraph WebView层
        A[JavaScript代码]
    end

    subgraph JSBridge层
        B[消息队列]
        C[协议解析器]
    end

    subgraph Native层
        D[原生API Handler]
        E[系统能力]
    end

    A -->|发起调用| B
    B -->|解析协议| C
    C -->|转发请求| D
    D -->|调用能力| E
    E -->|返回结果| D
    D -->|回调| C
    C -->|执行callback| A

    style A fill:#e1f5ff
    style E fill:#fff4e1
    style C fill:#f0f0f0

1.3 通信方式对比

JSBridge主要有三种实现方式:

方式 原理 优点 缺点
URL Schema拦截 通过iframe.src触发特定协议 兼容性好,iOS/Android通用 有URL长度限制,不支持同步返回
注入API Native向WebView注入全局对象 调用简单直接 Android 4.2以下有安全风险
MessageHandler WKWebView的postMessage机制 性能好,安全性高 仅iOS可用

二、微信公众号JSSDK实现原理

2.1 JSSDK架构设计

微信公众号的JSSDK基于WeixinJSBridge封装,提供了更安全和易用的接口。

sequenceDiagram
    participant H5 as H5页面
    participant SDK as wx-JSSDK
    participant Bridge as WeixinJSBridge
    participant Native as 微信客户端

    H5->>SDK: 调用wx.config()
    SDK->>Native: 请求签名验证
    Native-->>SDK: 返回验证结果

    H5->>SDK: 调用wx.chooseImage()
    SDK->>Bridge: invoke('chooseImage', params)
    Bridge->>Native: 转发调用请求
    Native->>Native: 打开相册选择
    Native-->>Bridge: 返回图片数据
    Bridge-->>SDK: 触发回调
    SDK-->>H5: success(res)

2.2 JSSDK初始化流程

JSSDK的初始化需要完成配置验证和ready状态准备:

// 步骤1: 引入JSSDK
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

// 步骤2: 配置权限验证
wx.config({
  debug: false,
  appId: 'your-app-id',
  timestamp: 1234567890,
  nonceStr: 'random-string',
  signature: 'sha1-signature',
  jsApiList: ['chooseImage', 'uploadImage', 'getLocation']
});

// 步骤3: 监听ready事件
wx.ready(function() {
  // 配置成功后才能调用API
  console.log('JSSDK初始化完成');
});

wx.error(function(res) {
  console.error('配置失败:', res);
});

配置验证流程说明:

  1. 获取签名: 后端通过jsapi_ticket和当前URL生成SHA1签名
  2. 前端配置: 将签名等参数传入wx.config()
  3. 客户端验证: 微信客户端校验签名的合法性
  4. 授权完成: 验证通过后触发ready事件

2.3 WeixinJSBridge底层机制

WeixinJSBridge是微信内部提供的原生接口,不对外公开但可以直接使用:

// 检测WeixinJSBridge是否ready
function onBridgeReady() {
  WeixinJSBridge.invoke(
    'getBrandWCPayRequest',
    {
      appId: 'wx123456',
      timeStamp: '1234567890',
      nonceStr: 'randomstring',
      package: 'prepay_id=xxx',
      signType: 'MD5',
      paySign: 'signature'
    },
    function(res) {
      if (res.err_msg === 'get_brand_wcpay_request:ok') {
        console.log('支付成功');
      }
    }
  );
}

if (typeof WeixinJSBridge === 'undefined') {
  document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
} else {
  onBridgeReady();
}

WeixinJSBridge与wx JSSDK的关系:

  • WeixinJSBridge: 底层原生接口,直接由微信客户端注入,无需引入外部JS
  • wx JSSDK: 基于WeixinJSBridge的高级封装,提供统一的API规范和安全验证
flowchart LR
    A[H5页面] -->|引入jweixin.js| B[wx JSSDK]
    B -->|封装调用| C[WeixinJSBridge]
    C -->|Native注入| D[微信客户端]
    D -->|系统能力| E[&#34;相机、支付、定位等&#34;]

    style B fill:#07c160
    style C fill:#ff9800
    style D fill:#576b95

2.4 典型API调用示例

以选择图片为例,展示完整的调用链路:

// 封装图片选择功能
function selectImages(count = 9) {
  return new Promise((resolve, reject) => {
    wx.chooseImage({
      count: count,          // 最多选择数量
      sizeType: ['original', 'compressed'],
      sourceType: ['album', 'camera'],
      success: function(res) {
        const localIds = res.localIds; // 返回本地图片ID列表
        resolve(localIds);
      },
      fail: function(err) {
        reject(err);
      }
    });
  });
}

// 使用示例
wx.ready(async function() {
  try {
    const imageIds = await selectImages(5);
    console.log('已选择图片:', imageIds);

    // 继续上传图片
    uploadImages(imageIds);
  } catch (error) {
    console.error('选择失败:', error);
  }
});

function uploadImages(localIds) {
  localIds.forEach(localId => {
    wx.uploadImage({
      localId: localId,
      isShowProgressTips: 1,
      success: function(res) {
        const serverId = res.serverId; // 服务器端图片ID
        // 将serverId发送给后端保存
        console.log('上传成功:', serverId);
      }
    });
  });
}

三、微信小程序双线程架构

3.1 小程序架构设计

微信小程序采用双线程模型,将渲染层与逻辑层完全隔离:

graph TB
    subgraph 渲染层[渲染层 View - WebView]
        A[WXML模板]
        B[WXSS样式]
        C[组件系统]
    end

    subgraph 逻辑层[逻辑层 AppService - JSCore]
        D[JavaScript代码]
        E[小程序API - wx对象]
        F[数据管理]
    end

    subgraph 系统层[Native - 微信客户端]
        G[JSBridge]
        H[网络请求]
        I[文件系统]
        J[设备能力]
    end

    A -.->|数据绑定| F
    C -.->|事件触发| D
    D -->|setData| G
    G -->|更新视图| A
    E -->|调用能力| G
    G -->|转发请求| H
    G -->|转发请求| I
    G -->|转发请求| J

    style 渲染层 fill:#e3f2fd
    style 逻辑层 fill:#f3e5f5
    style 系统层 fill:#fff3e0

架构设计的核心优势:

  1. 安全隔离: 逻辑层无法直接操作DOM,防止XSS攻击
  2. 多WebView支持: 每个页面独立WebView,支持多页面并存
  3. 性能优化: 逻辑层使用JSCore,不加载DOM/BOM,执行更快

3.2 小程序JSBridge通信机制

sequenceDiagram
    participant Logic as 逻辑层<br/>(JSCore)
    participant Bridge as JSBridge
    participant Native as Native层
    participant View as 渲染层<br/>(WebView)

    Note over Logic,View: 场景1: 数据更新
    Logic->>Bridge: setData({key: value})
    Bridge->>Native: 序列化数据
    Native->>View: 传递Virtual DOM diff
    View->>View: 更新页面渲染

    Note over Logic,View: 场景2: 事件响应
    View->>Bridge: bindtap事件触发
    Bridge->>Native: 序列化事件对象
    Native->>Logic: 调用事件处理函数
    Logic->>Logic: 执行业务逻辑

    Note over Logic,View: 场景3: API调用
    Logic->>Bridge: wx.request(options)
    Bridge->>Native: 转发网络请求
    Native->>Native: 发起HTTP请求
    Native-->>Bridge: 返回响应数据
    Bridge-->>Logic: 触发success回调

3.3 数据通信实现

setData是小程序中最核心的通信API,用于逻辑层向渲染层传递数据:

Page({
  data: {
    userInfo: {},
    items: []
  },

  onLoad: function() {
    // 通过setData更新数据,触发视图更新
    this.setData({
      userInfo: {
        name: '张三',
        avatar: 'https://example.com/avatar.jpg'
      },
      items: [1, 2, 3, 4, 5]
    });
  },

  // 优化建议: 只更新变化的字段
  updateUserName: function(newName) {
    this.setData({
      'userInfo.name': newName  // 使用路径语法,减少数据传输
    });
  },

  // 避免频繁setData
  handleScroll: function(e) {
    // 错误示范: 每次滚动都setData
    // this.setData({ scrollTop: e.detail.scrollTop });

    // 正确做法: 节流处理
    clearTimeout(this.scrollTimer);
    this.scrollTimer = setTimeout(() => {
      this.setData({ scrollTop: e.detail.scrollTop });
    }, 100);
  }
});

setData底层流程:

  1. 序列化数据: 将JS对象序列化为JSON字符串
  2. 通过JSBridge发送: Native层接收数据
  3. 传递到渲染层: Native将数据转发到WebView
  4. Virtual DOM Diff: 计算差异并更新视图

3.4 小程序API调用机制

小程序的wx对象是Native注入的JSBridge接口:

// 网络请求示例
function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    wx.request({
      url: `https://api.example.com/user/${userId}`,
      method: 'GET',
      header: {
        'content-type': 'application/json'
      },
      success(res) {
        if (res.statusCode === 200) {
          resolve(res.data);
        } else {
          reject(new Error(`请求失败: ${res.statusCode}`));
        }
      },
      fail(err) {
        reject(err);
      }
    });
  });
}

// 使用async/await优化
async function loadUserInfo() {
  wx.showLoading({ title: '加载中...' });

  try {
    const userData = await fetchUserData(123);
    this.setData({ userInfo: userData });
  } catch (error) {
    wx.showToast({
      title: '加载失败',
      icon: 'none'
    });
  } finally {
    wx.hideLoading();
  }
}

API调用流程图:

flowchart TD
    A[小程序调用 wx.request] --> B{JSBridge检查}
    B -->|参数校验| C[序列化请求参数]
    C --> D[Native接管网络请求]
    D --> E[系统发起HTTP请求]
    E --> F{请求结果}
    F -->|成功| G[回调success函数]
    F -->|失败| H[回调fail函数]
    G --> I[返回数据到逻辑层]
    H --> I
    I --> J[complete函数执行]

    style A fill:#07c160
    style D fill:#ff9800
    style E fill:#2196f3

四、自定义JSBridge实现

4.1 基础实现方案

基于URL Schema拦截实现一个简单的JSBridge:

class JSBridge {
  constructor() {
    this.callbacks = {};
    this.callbackId = 0;

    // 注册全局回调处理函数
    window._handleMessageFromNative = this._handleCallback.bind(this);
  }

  // JavaScript调用Native
  callNative(method, params = {}, callback) {
    const cbId = `cb_${this.callbackId++}`;
    this.callbacks[cbId] = callback;

    const schema = `jsbridge://${method}?params=${encodeURIComponent(
      JSON.stringify(params)
    )}&callbackId=${cbId}`;

    // 创建隐藏iframe触发schema
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.src = schema;
    document.body.appendChild(iframe);

    setTimeout(() => {
      document.body.removeChild(iframe);
    }, 100);
  }

  // Native回调JavaScript
  _handleCallback(callbackId, result) {
    const callback = this.callbacks[callbackId];
    if (callback) {
      callback(result);
      delete this.callbacks[callbackId];
    }
  }

  // 注册可被Native调用的方法
  registerHandler(name, handler) {
    this[name] = handler;
  }
}

// 使用示例
const bridge = new JSBridge();

// 调用Native方法
bridge.callNative('getLocation', {
  type: 'wgs84'
}, function(location) {
  console.log('位置信息:', location);
});

// 注册供Native调用的方法
bridge.registerHandler('updateTitle', function(title) {
  document.title = title;
});

4.2 Promise风格封装

将回调风格改造为Promise,提升开发体验:

class ModernJSBridge extends JSBridge {
  invoke(method, params = {}) {
    return new Promise((resolve, reject) => {
      this.callNative(method, params, (result) => {
        if (result.code === 0) {
          resolve(result.data);
        } else {
          reject(new Error(result.message));
        }
      });
    });
  }
}

// 现代化使用方式
const bridge = new ModernJSBridge();

async function getUserLocation() {
  try {
    const location = await bridge.invoke('getLocation', {
      type: 'wgs84'
    });
    console.log('经度:', location.longitude);
    console.log('纬度:', location.latitude);
  } catch (error) {
    console.error('获取位置失败:', error.message);
  }
}

4.3 Native端实现(以Android为例)

Android端需要拦截WebView的URL请求并解析协议:

// 这是伪代码示意,用JavaScript语法描述Android的WebViewClient逻辑

class JSBridgeWebViewClient {
  shouldOverrideUrlLoading(view, url) {
    // 拦截自定义协议
    if (url.startsWith('jsbridge://')) {
      this.handleJSBridgeUrl(url);
      return true;  // 拦截处理,不加载URL
    }
    return false;  // 正常加载
  }

  handleJSBridgeUrl(url) {
    // 解析: jsbridge://getLocation?params=xxx&callbackId=cb_1
    const urlObj = new URL(url);
    const method = urlObj.hostname;  // getLocation
    const params = JSON.parse(
      decodeURIComponent(urlObj.searchParams.get('params'))
    );
    const callbackId = urlObj.searchParams.get('callbackId');

    // 调用原生能力
    switch(method) {
      case 'getLocation':
        this.getLocation(params, (location) => {
          // 回调JavaScript
          this.callJS(callbackId, {
            code: 0,
            data: location
          });
        });
        break;
    }
  }

  callJS(callbackId, result) {
    const script = `window._handleMessageFromNative('${callbackId}', ${
      JSON.stringify(result)
    })`;
    webView.evaluateJavascript(script, null);
  }

  getLocation(params, callback) {
    // 调用Android LocationManager获取位置
    // 这里是伪代码,实际需要原生Java/Kotlin实现
    const location = {
      longitude: 116.404,
      latitude: 39.915
    };
    callback(location);
  }
}

五、性能优化与最佳实践

5.1 性能优化要点

graph TB
    A[JSBridge性能优化] --> B[通信优化]
    A --> C[数据优化]
    A --> D[调用优化]
    A --> E[内存管理]

    B --> B1[减少通信频次]
    B --> B2[批量传输数据]
    B --> B3[使用增量更新]
    B --> B4[避免大数据传输]

    C --> C1[JSON序列化优化]
    C --> C2[数据压缩]
    C --> C3[惰性加载]
    C --> C4[缓存机制]

    D --> D1[异步非阻塞]
    D --> D2[超时处理]
    D --> D3[失败重试]
    D --> D4[降级方案]

    E --> E1[及时释放回调]
    E --> E2[避免内存泄漏]
    E --> E3[限制队列长度]

    style A fill:#e3f2fd
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style D fill:#e8f5e9
    style E fill:#ffe0b2

5.2 最佳实践

1. 合理使用setData(小程序场景):

// 不好的做法
for (let i = 0; i < 100; i++) {
  this.setData({
    [`items[${i}]`]: data[i]
  });  // 100次通信
}

// 好的做法
const updates = {};
for (let i = 0; i < 100; i++) {
  updates[`items[${i}]`] = data[i];
}
this.setData(updates);  // 1次通信

2. 实现超时与错误处理:

class SafeJSBridge extends ModernJSBridge {
  invoke(method, params = {}, timeout = 5000) {
    return Promise.race([
      super.invoke(method, params),
      new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`调用${method}超时`));
        }, timeout);
      })
    ]);
  }
}

// 使用
try {
  const result = await bridge.invoke('slowMethod', {}, 3000);
} catch (error) {
  if (error.message.includes('超时')) {
    console.error('请求超时,请检查网络');
  }
}

3. 权限与安全检查:

// JSSDK安全最佳实践
const secureConfig = {
  // 1. 签名在后端生成,前端不暴露secret
  getSignature: async function(url) {
    const response = await fetch('/api/wechat/signature', {
      method: 'POST',
      body: JSON.stringify({ url })
    });
    return response.json();
  },

  // 2. 动态配置jsApiList,按需授权
  init: async function() {
    const signature = await this.getSignature(location.href);
    wx.config({
      ...signature,
      jsApiList: ['chooseImage']  // 只申请需要的权限
    });
  }
};

六、调试技巧

6.1 调试流程

flowchart LR
    A[开发阶段] --> B{启用debug模式}
    B -->|wx.config debug:true| C[查看vconsole日志]
    B -->|Chrome DevTools| D[断点调试]

    C --> E[检查API调用]
    D --> E

    E --> F{定位问题}
    F -->|签名错误| G[检查后端签名逻辑]
    F -->|API调用失败| H[检查权限配置]
    F -->|通信异常| I[检查JSBridge实现]

    G --> J[修复并重测]
    H --> J
    I --> J

    style B fill:#ff9800
    style F fill:#f44336
    style J fill:#4caf50

6.2 常见问题排查

1. 微信JSSDK签名失败:

// 调试签名问题
wx.config({
  debug: true,  // 开启调试模式
  // ... 其他配置
});

wx.error(function(res) {
  console.error('配置失败详情:', res);
  // 常见错误:
  // invalid signature - 签名错误,检查URL是否一致(不含#hash)
  // invalid url domain - 域名未配置到白名单
});

// 检查点:
// 1. 确保URL不包含hash部分
const url = location.href.split('#')[0];

// 2. 确保timestamp是整数
const timestamp = Math.floor(Date.now() / 1000);

// 3. 确保签名算法正确(SHA1)
// 签名原串: jsapi_ticket=xxx&noncestr=xxx&timestamp=xxx&url=xxx

2. 小程序setData性能问题:

// 开启性能监控
wx.setEnableDebug({
  enableDebug: true
});

// 监控setData性能
const perfObserver = wx.createPerformanceObserver((entries) => {
  entries.getEntries().forEach((entry) => {
    if (entry.entryType === 'render') {
      console.log('渲染耗时:', entry.duration);
    }
  });
});

perfObserver.observe({ entryTypes: ['render', 'script'] });

七、总结

JSBridge作为Hybrid开发的核心技术,通过建立JavaScript与Native的通信桥梁,实现了Web技术与原生能力的完美融合。本文通过剖析微信公众号JSSDK和小程序SDK,深入理解了以下关键点:

  1. 通信机制: URL Schema拦截、API注入、MessageHandler三种主流方式
  2. 架构设计: 微信小程序的双线程模型提供了安全性和性能的最佳平衡
  3. 实现原理: 从JSSDK的签名验证到小程序的setData机制,理解了完整的调用链路
  4. 最佳实践: 性能优化、错误处理、安全防护等工程化经验

掌握JSBridge原理不仅能帮助我们更好地使用微信生态的各种能力,也为构建自己的Hybrid框架提供了坚实的理论基础。在实际项目中,应根据具体场景选择合适的实现方案,并持续关注性能与安全,打造更优质的用户体验。

参考资料

年终总结 - 2025 故事集

📕 如果您刚好点了进来,却不想完整阅读该文章但又想知道它记录了什么。可跳到文末总结

前言

时隔四个月,再执笔即将进入了新的一年 2026 年...

2025 & 2026

时间像往常一样无声息地流动,已近年尾,在过去的 2025 年,三百多天时间里面,发生了很多的事情,或喜,或悲,或静,或闹...此时,灯亮着,窗外偶尔有远处汽车的沙沙声。我在其中,开始回顾并记录撞进心底的瞬间和感受。

你好,世界

还是熟悉的四月份的一天凌晨,老妈跟我在走廊里踱步~

随着清脆的哭声响起,二宝如期而至。过了段时间,护士出来报出母女平安是我们听到的此刻最让人心安的话语。

为什么说是熟悉的四月份,因为老大也是四月份出生的

因为老婆在工作日凌晨分娩,所以我的休陪产的单也先提交了。在收到老婆产后无需我协助事情的话语后,我撤销了陪产单,屁颠屁颠地去上班赚奶粉钱了😄

嗯,从准奶爸到首次喜当爹至今,短短三年时间里面,自己已经是两个小孩的爸爸,真是一个让自己意想不到的速度。

自从当了父母之后,我们更加懂得自己父母的无私且伟大,孩子的天真和无知

相对于第一次喜当爹时候,自己慌张无措,老妈辛苦地忙前忙后,手慌脚乱。有了第一次的经验,我们对于二宝的处理还是挺稳定:

  • 在预产期临近的两三天,我们准备好了大包小包的待产包 -> alway stand by
  • 产后的三天时间,请护工照看老婆和新生儿,老妈在旁边陪同,老爸在家照看大宝
  • 出院后,老婆和二宝直接月子中心坐月子。老妈和我在家照看大宝,周末月子中心看二宝

daughters in nursing room

👆即将出月子中心,大宝和二宝的合影👆

在日常里接力的我们

每天,我们都觉得时间不够用,能留出些许空间和时间来放松,已经很满足😌

老婆来回奔波的工作日

在休完三个多月的产假之后,老婆就去复工了。因为二宝还小,老婆会每天中午都回来哺乳。从小孩三个多月到七个多月,雷打不动,公司和家两头跑。

那一台小电驴,隔三差五就需要去充电。小小电驴,已经超出了它的价值~

好不容易,让二宝断奶了。断奶是件很痛苦的事情,要熬夜,涨奶胸痛等。我还记得在成功断奶后的那天晚上,老婆还特意叫我出去买瓶酒回来庆祝一下✨

beer

👆5%-8% vol 的鸡尾酒👆

虽然二宝断奶了,但是老婆在工作不忙的时候,还是会中午回来看看。用我老婆的话说:有点讨厌,但是又有点舍不得二宝

工作日,爷爷奶奶的时光

老婆跟我,工作日都需要上班,嗯~赚奶粉钱😀

然后,两个宝宝,工作日的时候主要给爷爷和奶奶带。

有时候,两个宝宝都需要奶奶抱,这可苦了奶奶的腰板子了。爷爷更多的时候,是充当了厨师的角色,保证一家人的三餐伙食,嗯~老爸的厨艺真好👍

爷爷奶奶一天下来的流程:早上带娃出去晒太阳,遛娃(主要是让大宝动起来,中午好睡觉);中午喂饭,午休(大宝一般中午休息两个钟,下午三或四点起来);下午洗澡(怕冷着小孩,一般天黑前洗完),喂饭,陪玩;晚上,等老婆和我下班回来,爷爷和奶奶才有空闲的时间。一般这个时候,爷爷就喜欢下楼去周边逛,奶奶就会躺着床上直一下腰板子(有时会跟爷爷下楼逛街)。工作日的时候,如果奶奶晚上没有出去逛街,那么,会在九点多喂完奶给大宝,奶奶会哄大宝睡觉;如果奶奶外出,那么我就会哄大宝睡觉。

mother's birthday

👆奶奶生日的时候,两宝和爷爷奶奶合影👆

休息日,我们的时光

工作日,班上完了;休息日,该带娃了。爷爷奶奶休息日放假,想去哪里就去哪里,放松放松。

休息日带娃,我们的宗旨就是:尽量让娃多动。所以,我们基本都会外出。忙忙碌碌,嗯,我们分享两件事情:

我还记得,某个周末,我们在商场逛了一天,让大宝在商场里面走,她逛得贼开心(这可不,逛得有多累,睡得有多香),推着二宝。中午直接在商场里面解决吃饭的问题,大宝直接在婴儿车上解决了午睡的事情,二宝则是被老婆或者我背在身上睡觉。母婴室没人的时候,我们就会在里面小憩一会。等两宝醒来之后,再逛一下,一天的时间过得慢但是又很快

今年的国庆连着中秋,我们在这个长假期里面,会带他们在小区里面露营(在草坪上铺一垫子),让她们自己玩。大宝走路的年纪,这里走那里走,我得屁颠屁颠跟她后面,从这里把她抱过来那里,从那边把她哄过来这边,真想拿条链子绑着她。相反,二宝就淡定多了,只能在那块布那里爬来爬去,被她妈妈限制着。

Mid-Autumn Festival

👆中秋节当晚,在哄两娃睡着后,老婆跟我在阳台拜月👆

没有惊喜的工位

相对于上一年工作的惊吓,今年的工作可以用没有惊喜来形容。

至于为什么说上一年是惊吓,今年没有惊喜。后面有时间,会出一篇文章来分享下。

简简单单的工位,一水杯,一记事本,一台式电脑,一绿植。屁股一坐,一整天嗖一下就过去了~

在公司,让我活跃起来的,就是中午吃饭的时候。我们的小团体(一安卓,一产品和我)开车去周边吃饭。这段时间,是我们唠嗑的时间,无拘无束,即使我们偶尔会浪费掉午休的时间,但是我还是觉得挺不错的,时间花得值...

工作上糟心的事十根手指可数不过来,触动且温暖了心窝的事情屈指可数。

记得招进来的一个新人,我带了他几天,最后入职短短几天被某人恶心而离职了。他离职的前一天,点了一杯奶茶给我,虽然自己嘴里面说着别客气,但是心里面暖暖的。他才进来短短几天就走人了,自己心里莫名生气:为什么我自己招的人,自己带着熟悉项目后,一转手就被恶心到要离职了???最终他却还温柔地以自我问题作离职的原因。

colleague communication

👆点了份奶茶放我桌面后的对话👆

把明天轻轻放进心里

2026 年悄然将至。在对新的一年有所展望之前,我们先回顾下年终总结 - 2024 故事集中立下的两个 Flags 和完成的情况:

序号 目标 实际 完成率
1 分享文章 20+ 分享文章 18 90%
2 锻炼 30+ 锻炼 32 107%

嗯~ 目标完成率还不赖。

do execise

👆每次锻炼我都会在朋友圈记录,每次耗时 45 分钟左右👆

对于分享文章,一开始就是秉承着记录自己在工作中遇到的一些问题,方便自己和其他人查找的宗旨来进行记录,后面是因为平台搞了奖励而进行的一些创作。而现在,随着 chatgpt, deepseek 等大语言模型的机器人横空出世,浅显的分享和问题的记录都显得鸡肋。所以,在 2026 新的一年内,文章的分享要更加有目的性和实际的意义。2026 年,谁知道会有几篇文章会出炉,也许一篇,也许十篇,也许二十篇,也许零篇。

对于锻炼,这是我长期需要坚持的一件事情,也是最好量化的事情。在新的一年里面,锻炼的次数需 35+

为人父母,为人儿女。我们都有自己的那份责任,2026 年,希望自己更多的时间是回归家庭 - 去听听孩子的欢声笑语,去看看爸妈脸上的笑容,去体验大家聚在一起热热闹闹的氛围 and more

family gathering

👆老爸生日,大姐,二姐大家的娃聚在一起👆

总结

2025 年,简简单单却忙忙碌碌👇:

在生活方面,欢迎二宝加入大家庭。这让我们接下来的一年时间里面,时间安排更加充实紧凑,更感受到当爹妈的不容易,感恩自己的父母在以前那年代含辛茹苦带大了我们三姐弟。在工作方面,没有太多想记录的东西,平平淡淡地打卡上下班。

展望 2026,还是给自己制定了锻炼次数的量化目标。在这个人工智能逐渐成熟的环境下,希望自己能够使用它提升工作效率和帮助自己成长。在 2026 年,自己的重心会放在家庭这边,去感受孩子的成长和家的氛围。

完成于中国广东省广州市

2025 年 12 月 22 日

C# 正则表达式(2):Regex 基础语法与常用 API 全解析

一、IsMatch 入门

using System;
using System.Text.RegularExpressions;

class Program
{
    static void Main()
    {
        string input = "2025-12-18";
        string pattern = @"^\d{4}-\d{2}-\d{2}$";

        bool isValid = Regex.IsMatch(input, pattern);
        Console.WriteLine(isValid); // True
    }
}

解析:

  • pattern 是正则表达式,@"..." 是 C# 的逐字字符串字面量。
  • ^$:锚点,表示“从头到尾整串匹配”
  • \d{4}:4 位数字。
  • -:字面量“-”。
  • Regex.IsMatch:看字符串中是不是“满足这个模式”。

二、C# Regex 的 5 个核心方法

System.Text.RegularExpressions.Regex 里,最常用的就是这 5 个方法:

  1. IsMatch
  2. Match
  3. Matches
  4. Replace
  5. Split

三、Regex.IsMatch:最常用的“判断是否匹配”

IsMatch 是表单校验、输入合法性检查中使用频率最高的方法。

bool isEmail = Regex.IsMatch(email, pattern);

示例:

string input = "Order12345";
string pattern = @"\d{3}";

bool has3Digits = Regex.IsMatch(input, pattern); // True

注意点:

  • 默认只要“包含”满足 pattern 的子串,就返回 true,并不要求整个字符串都完全匹配。
  • 如果你想“整个字符串必须符合这个规则”,要在 pattern 外面加上 ^$
// 只允许由 3~5 位数字组成,不允许多一个字符
string pattern = @"^\d{3,5}$";

四、Regex.Match:获取第一个匹配

string text = "My phone is 123-456-7890.";
string pattern = @"\d{3}-\d{3}-\d{4}";

Match match = Regex.Match(text, pattern);
if (match.Success)
{
    Console.WriteLine(match.Value);  // "123-456-7890"
    Console.WriteLine(match.Index);  // 起始索引
    Console.WriteLine(match.Length); // 匹配的长度
}

常用成员:

  • match.Success:是否匹配成功。
  • match.Value:匹配到的字符串。
  • match.Index:匹配在原文本中的起始位置(从 0 开始)。
  • match.Length:长度。

Regex.Match 也有带起始位置、带 RegexOptions 的重载:


五、Regex.Matches:获取所有匹配结果(多个)

string text = "ID: 100, 200, 300";
string pattern = @"\d+";

MatchCollection matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
    Console.WriteLine($"{m.Value} at {m.Index}");
}
// 输出:
// 100 at 4
// 200 at 9
// 300 at 14

解析:

  • 返回的是一个 MatchCollection,可以 foreach 遍历。
  • 每个 Match 和前面一样,有 ValueIndexGroups 等属性。

六、Regex.Replace:按模式搜索并替换

Regex.Replace 和字符串的 Replace 很像,但支持模式匹配。

1. 固定字符串替换匹配内容

string input = "2025/12/18";
string pattern = @"/";

string result = Regex.Replace(input, pattern, "-");
Console.WriteLine(result); // "2025-12-18"

这相当于“把所有 / 都换成 -”,和 input.Replace("/", "-") 类似,但 pattern 可以写得更复杂。

2.用捕获组重排内容

string input = "2025-12-18";
string pattern = @"(\d{4})-(\d{2})-(\d{2})";

// 把 yyyy-MM-dd 改成 dd/MM/yyyy
string result = Regex.Replace(input, pattern, "$3/$2/$1");
// result: "18/12/2025"

解析:

这里的 $1$2$3 是捕获组

3. 更高级的 MatchEvaluator 版本

string input = "Price: 100 USD, 200 USD";
string pattern = @"(\d+)\s*USD";

string result = Regex.Replace(input, pattern, m =>
{
    int value = int.Parse(m.Groups[1].Value);
    int converted = (int)(value * 7.2); // 假设汇率
    return $"{converted} CNY";
});

Console.WriteLine(result);
// "Price: 720 CNY, 1440 CNY"

七、Regex.Split:按“模式”切割字符串

可以实现多分隔符的切割

string input = "apple, banana; cherry|date";
string pattern = @"[,;|]\s*"; // 逗号;分号;竖线 + 可选空白

string[] parts = Regex.Split(input, pattern);

foreach (var p in parts)
{
    Console.WriteLine(p);
}
// apple
// banana
// cherry
// date

八、正则基础语法(一):字面字符与转义

1. 字面字符

绝大多数普通字符在正则里就是字面意思:

  • 模式:abc → 匹配文本中出现的 abc
  • 模式:hello → 匹配文本中出现的 hello

2. 特殊字符(元字符)

这些字符在正则中有特殊含义:

  • . ^ $ * + ? ( ) [ ] { } \ |

如果你要匹配其中任意一个“字面意义上的”字符,就要用 \ 转义。

例如:

  • 匹配一个点号 . → 模式 \.
  • 匹配一个星号 * → 模式 \*
  • 匹配一对括号 (abc) → 模式 \( + abc + \)

在 C# 中配合逐字字符串:

string pattern = @"\.";   // 匹配 "."
string pattern2 = @"\*";  // 匹配 "*"

如果不用 @

string pattern = "\\.";   // C# 字符串里写成 "\\." 才表示一个反斜杠+点

实践中几乎所有正则字符串都用 @"",可以少一半反斜杠。


九、正则基础语法(二):预定义字符类 \d / \w / \s

预定义字符类是正则里最常用的工具,它们代表一类字符。

1. \d / \D:数字与非数字

  • \d:digit,匹配 0–9 的任意一位数字,相当于 [0-9]
  • \D:非数字,相当于 [^0-9]

示例:匹配一个或多个数字

string pattern = @"\d+";

2. \w / \W:单词字符与非单词字符

  • \w:word,匹配字母、数字和下划线,相当于 [A-Za-z0-9_]
  • \W:非 \w

示例:匹配“单词”(一串字母数字下划线)

string pattern = @"\w+";

3. \s / \S:空白字符与非空白字符

  • \s:space,匹配空格、制表符、换行等所有空白字符。
  • \S:非空白。

示例:

string pattern = @"\s+"; // 匹配一个或多个空白

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

GIS 数据转换:使用 GDAL 将 TXT 转换为 Shp 数据

前言

TXT 作为一种文本格式,可以很方便的存储一些简单几何数据。在 GIS 开发中,经常需要进行数据的转换处理,其中常见的便是将 TXT 转换为 Shp 数据进行展示。

本篇教程在之前一系列文章的基础上讲解

如如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

TXT(纯文本文件)是一种最基本的文件格式,仅存储无格式的文本数据,适用于各种场景(如数据交换、日志记录、配置文件等)。

如下是全国省会城市人口 TXT 文本结构:

ID,Name,Longitude,Latitude,Population
1,Beijing,116.40,39.90,21712,Shanghai,121.47,31.23,24873,Guangzhou,113.26,23.12,18684,Shenzhen,114.05,22.55,17565,Tianjin,117.20,39.08,13736,Chongqing,106.50,29.53,32057,Chengdu,104.06,30.67,20948,Wuhan,114.30,30.60,11219,Hangzhou,120.15,30.28,119410,Nanjing,118.78,32.04,93111,Xi'an,108.93,34.27,129512,Changsha,112.97,28.20,83913,Zhengzhou,113.62,34.75,126014,Harbin,126.63,45.75,107615,Shenyang,123.43,41.80,83116,Qingdao,120.38,36.07,100717,Dalian,121.62,38.92,74518,Xiamen,118.08,24.48,51619,Ningbo,121.55,29.88,85420,Hefei,117.28,31.86,93721,Fuzhou,119.30,26.08,82922,Jinan,117.00,36.67,92023,Taiyuan,112.55,37.87,53024,Changchun,125.35,43.88,90625,Kunming,102.72,25.04,84626,Nanning,108.37,22.82,87427,Lanzhou,103.82,36.06,43528,Yinchuan,106.27,38.47,28529,Xining,101.77,36.62,26330,Urümqi,87.62,43.82,40531,Lhasa,91.11,29.65,8632,Haikou,110.20,20.05,287

3. 导入依赖

TXT作为一种矢量数据格式,可以使用矢量库OGR进行处理,以实现TXT数据从文本格式转换为Shp格式。其中还涉及坐标定义,所以还需要引入osr模块。

from osgeo import ogr,osr
import os
import csv

4. 数据读取与转换

定义一个方法Txt2Shp(txtPath,shpPath,encoding="UTF-8")用于将TXT数据转换为Shp数据。

"""
说明:将 TXT 文件转换为 Shapfile 文件
参数:
    -txtPath:TXT 文件路径
    -shpPath:Shp 文件路径
    -encoding:TXT 文件编码
"""
def Txt2Shp(txtPath,shpPath,encoding="UTF-8")

在进行TXT数据格式转换之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(txtPath):
    print("TXT 文件存在。")
else:
    print("TXT 文件不存在,请重新选择文件!")
    return

通过GetDriverByName获取Shp数据驱动,并使用os.path.exists方法检查Shp文件是否已经创建,如果存在则将其删除。

# 注册所有驱动
ogr.RegisterAll()

# 添加Shp数据源
shpDriver = ogr.GetDriverByName('ESRI Shapefile')

if os.path.exists(shpPath):
    try:
        shpDriver.DeleteDataSource(shpPath)
        print("文件已删除!")
    except Exception as e:
        print(f"文件删除出错:{e}")
        return False

接着创建Shp数据源和空间参考,数据坐标系这里定义为4326。

# 创建Shp数据源
shpDataSource = shpDriver.CreateDataSource(shpPath)
if shpDataSource is None:
    print("无法创建Shp数据源,请检查文件!")
    return false
# 创建空间参考
spatialReference = osr.SpatialReference()
spatialReference.ImportFromEPSG(4326)

之后通过数据源方法CreateLayer创建Shp图层,使用图层方法CreateField添加属性字段,需要定义属性名称以及属性字段类型。

# 创建图层
shpLayer = shpDataSource.CreateLayer("points",spatialReference,ogr.wkbPoint)

# 添加图层字段
shpLayer.CreateField(ogr.FieldDefn("ID",ogr.OFTString))
shpLayer.CreateField(ogr.FieldDefn("Name",ogr.OFTString))
shpLayer.CreateField(ogr.FieldDefn("Longitude",ogr.OFTReal))
shpLayer.CreateField(ogr.FieldDefn("Latitude",ogr.OFTReal))
shpLayer.CreateField(ogr.FieldDefn("Population",ogr.OFTString))

读取TXT数据并将其转换为Shapefile数据,在打开数据时,根据TXT文件属性,使用逗号分隔符进行读取并跳过表头行数据。之后根据行数据进行属性遍历,将读取的字段值和几何属性写入到要素对象中。

# 读取TXT文件
with open(txtPath,"r",encoding=encoding) as txtFile:
    # 根据逗号分隔符进行读取
    reader = csv.reader(txtFile,delimiter=",")
    # 跳过表头
    header = next(reader)
    # 遍历记录
    for row in reader:
        print(f"要素记录:{row}")
        # 创建要素
        feature = ogr.Feature(shpLayer.GetLayerDefn())

        # 根据图层字段写入属性
        feature.SetField("ID",str(row[0]))
        feature.SetField("Name",str(row[1]))
        feature.SetField("Longitude",float(row[2]))
        feature.SetField("Latitude",float(row[3]))
        feature.SetField("Population",str(row[4]))

        # 创建几何对象
        wkt = f"POINT({float(row[2])} {float(row[3])})"
        pointGeom = ogr.CreateGeometryFromWkt(wkt)

        feature.SetGeometry(pointGeom)

        # 将要素添加到图层
        shpLayer.CreateFeature(feature)
        feature = None

CreateCpgFile2Encode(shpPath,encoding)
# 释放数据资源        
shpDataSource = None

其中CreateCpgFile2Encode方法用于创建字符编码文件,后缀名为.cpg

"""
说明:创建.cpg文件指定字符编码
参数:
    -shpPath:Shp文件路径
    -encoding:Shp文件字符编码
"""
def CreateCpgFile2Encode(shpPath,encoding):
    fileName = os.path.splitext(shpPath)[0]
    cpgFile = fileName + ".cpg"

    with open(cpgFile,"w",encoding=encoding) as f:
        f.write(encoding)
        print(f"成功创建编码文件: {cpgFile}")

程序成功转换数据显示如下:

使用ArcMap打开显示结果如下:

从 v5 到 v6:这次 Ant Design 升级真的香

2025 年 11 月底,Ant Design 正式发布了 v6 版本。

回顾过去,从 v3 到 v4 的断崖式升级,到 v5 引入 CSS-in-JS 带来的心智负担和性能压力,很多前端同学一提到“升级”就条件反射般护住发际线。但这一次,Ant Design 团队明显听到了社区的呼声。

v6 没有为了“创新”而搞大刀阔斧的重构,而是聚焦于解决长期痛点提升开发体验平滑迁移。本文结合一线业务开发中的真实场景,聊聊 v6 的核心变化,以及这次升级到底值不值得升。

样式覆盖不再是“玄学”

你一定深有体会:设计师要求改 Select 下拉框背景色、调整 Modal 头部内边距,或者给 Table 的某个单元格加特殊样式。在 v5 及之前,你只能打开控制台,一层层扒 DOM 结构,找到类似 .ant-select-selector 的 class,然后用 :global!important 暴力覆盖。一旦组件库内部 DOM 微调,你的样式就崩了。

全量 DOM 语义化 + 细粒度 classNames / styles API
v6 对所有组件进行了 DOM 语义化改造(如用 <header><main> 等代替无意义的 <div>),更重要的是引入了复数形式的 classNamesstyles 属性,让你直接通过语义化的 key 来定制关键区域。

// v6 写法:精准、安全、健壮
<Modal
  title="业务配置"
  open={true}
  classNames={{
    header: 'my-modal-header',
    body: 'my-modal-body',
    footer: 'my-modal-footer',
    mask: 'glass-blur-mask', // 甚至能直接控制遮罩
    content: 'my-modal-content',
  }}
  styles={{
    header: { borderBottom: '1px solid #eee', padding: '16px 24px' },
    body: { padding: '24px' },
  }}
>
  <p>内容区域...</p>
</Modal>

v5 vs v6 对比(Modal 头部样式定制)

// v5(hack 写法,易崩)
import { global } from 'antd'; // 或直接写 less
:global(.ant-modal-header) {
  border-bottom: 1px solid #eee !important;
}

v6 技术价值

  • 不再依赖内部 class 名:官方承诺这些 key(如 header、body)的存在,即使未来 DOM 结构变化,你的样式依然有效。
  • 支持动态样式styles 属性接受对象,方便结合主题或 props 动态生成。

原生 CSS 变量全面回归

v5 的 CSS-in-JS 方案虽然解决了按需加载和动态主题,但在大型后台系统里,运行时生成样式的 JS 开销仍然明显,尤其在低端设备上切换主题或路由时容易掉帧、闪烁。

v6 的解法:零运行时(Zero-runtime)CSS 变量模式
彻底抛弃 CSS-in-JS,默认使用原生 CSS Variables(Custom Properties)。

  • 体积更小:CSS 文件显著减小(官方称部分场景下减少 30%+)。
  • 响应更快:主题切换只需修改 CSS 变量值,浏览器原生处理,毫秒级生效,无需重新生成哈希类名。
  • 暗黑模式友好:直接通过 --antd-color-primary 等变量实现全局主题切换。

这对需要支持多品牌色、暗黑模式的 SaaS 平台来说,是巨大的性能红利。

高频场景官方接管

瀑布流布局、Drawer 拖拽调整大小、InputNumber 加减按钮等,都是业务中常见需求,但之前往往需要引入第三方库或自己手写,增加维护成本和打包体积。

v6 的解法:新增实用组件 & 交互优化

  • Masonry 瀑布流(内置)
import { Masonry } from 'antd';

<Masonry columns={{ xs: 1, sm: 2, md: 3, lg: 4 }} gutter={16}>
  {items.map(item => (
    <Card key={item.id} cover={<img src={item.cover} />} {...item} />
  ))}
</Masonry>
  • Drawer 支持拖拽:原生支持拖拽改变宽度,无需自己写 resize 逻辑。
  • InputNumber 支持 spinner 模式:加减按钮直接在输入框两侧,像购物车那样。
  • 其他:Tooltip 支持平移(panning)、弹层默认支持模糊蒙层(blur mask)等交互优化。

这些补齐了业务高频场景,减少了“自己造轮子”的痛苦。

升级建议:这次真的“平滑”吗?

v6 迁移关键事实

  • React 版本要求:必须升级到 React 18+(不再支持 React 17 及以下)。
  • 破坏性变更:部分 API 被废弃(如 borderedvariantheadStylestyles.header 等),v7 将彻底移除。
  • 兼容性:v5 项目绝大多数业务逻辑代码无需改动,但若大量使用了深层 hack 样式,可能需要调整。
  • 推荐工具:官方提供 Codemod 迁移脚本,可自动化处理大部分废弃 API。

建议

  1. 新项目:直接上 v6,享受更好的性能、体验和未来维护性。
  2. v5 项目:先在 dev 分支尝试升级。无大量 hack 样式的话,成本很低。
  3. v4 及更老项目:跨度较大,建议先逐步迁移到 v5,再升 v6;或在新模块中使用 v6(配合微前端或包隔离)。
  4. 升级前检查
    • 确认 React ≥ 18
    • 运行官方 Codemod
    • 验证目标浏览器支持 CSS 变量(IE 彻底不支持)

总结

Ant Design v6 是一次**“返璞归真”**的升级。它把控制权还给开发者(语义化 API),用现代浏览器特性解决性能问题(零运行时 CSS 变量),并补齐了业务高频组件。

升级核心收益

  • 更少的 hack 代码,更健壮的样式
  • 显著的性能提升(主题切换、渲染速度)
  • 官方接管高频业务组件,减少第三方依赖
  • 平滑迁移路径,真正降低了“升级火葬场”的风险

对于业务开发者来说,这意味着:更少的加班、更快的页面、更早下班

参考链接

WebCut前端视频编辑UI框架一周开源进度

自从上次发布了WebCut的开源信息之后,获得了非常多小伙伴的关注,也有很多小伙伴还不知道,所以我打算写一些更新进度文章,让关注的小伙伴了解WebCut的最新进展。

什么是WebCut?

对于还不了解的小伙伴,我先介绍一下WebCut。它是一款开源的前端(Web)视频编辑UI框架。说的简单点,就是它可以在你的网页上快速植入一个可以用来进行视频编辑的插件,这样你就可以以最低的成本快速实现相关需求。它以组件化思想对外开放,可以通过任意组合的形式,实现各种造型的视频编辑界面。

什么人需要WebCut?

  • 开发者:如果你的老板要你在你们的web产品中快速上线一个视频编辑功能,你需要它

  • 老网站系统维护者:如果你们的系统希望实现用户在系统中完成所有操作,包括视频的编辑,你需要它

  • 创作者:如果你在找剪映的免费替代品,你需要它

目前支持哪些功能?

  • 核心功能:

  • 视频播放/编辑画布

  • 时间轴素材管理器:不同类型素材的渲染效果、高度、分栏、滚动、时间刻度(含缩放)、时间游标、素材时长和位置拖拽调整等等

  • 处理工具:分割裁剪、水平翻转、两段视频合并连接

  • 视频导出:视频格式、分辨率、码率、音频导出等

  • 基础信息编辑:位置、大小、透明度、旋转

  • 信息编辑:音视频的声量调节

  • 外围功能

  • 长宽比调整

  • 播放器进度显示器

  • 暗黑主题和浅色主题切换

  • 多语言(8种),含语言切换组件

  • 进阶功能

  • 时间轴定制:可在时间轴上扩展各种附加元素

  • 媒体库管理:管理各种素材、导入(含文件夹导入)、添加、多选按顺序添加等等

  • 素材类型:视频、音频、图片(含gif)、文字(含文字样式编辑)

  • 媒体库定制:特定类型素材新增菜单添加功能、新增媒体类型等

  • 动画:可扩展的动画功能

  • 滤镜:可扩展的滤镜功能

  • 转场效果:可扩展的转场效果

  • VIP功能

  • 视频水印(含运动效果)

  • 文本转语音

本周开源日志

本周我们实现了动画、滤镜、转场这3个在视频编辑器技术实现中非常麻烦的功能,这里展开讲一下。

  • 动画

动画主要指素材在视频中的运动效果,这里的运动主要通过改变素材的基本属性来实现。例如通过改变素材的位置信息来实现位移,通过改变大小来实现运动中的缩放效果,通过改变透明度来实现闪动效果等等。同时,这些属性的变化可以两两组合,实现非常酷炫的动画。

在WebCut中,一个动画由两个部分组成,一个部分定义keyframe,它是一组key-value,其中key代表时间进度,例如5%, 40%, 90%,value代表在这个时间点上应该以什么属性展示时间点之间,则通过动画运算来进行补间;另一部分是参数,用于计算动画的速度。如果以前开发过css的小伙伴,应该对animation非常了解。

  • 滤镜

滤镜主要指对素材的画面表现进行变化,例如灰度、鲜亮、高斯模糊等效果。在WebCut中,主要通过对视频画面的帧进行滤镜处理,从而让整个视频拥有滤镜效果。

  • 转场

转场主要针对的是两段视频的连接方式,它的实现比较复杂,结合了动画、滤镜等效果,而且还要处理两段视频的衔接。其实现难度非常大。不过WebCut已经做了高度集成,提供了非常便捷的接口,开发者只需要根据接口文档,传入参数,即可完成自己的转场效果。

除了上面提到的3个非常重要的功能,本周,我们还对WebCut做了一项非常重要的设计实现,就是实现了“扩展包”功能。开发者可基于该功能,定义自己的扩展包,并把它注册到WebCut中,即可实现自己的复杂功能。我们的VIP功能就是基于该扩展包功能实现。简单讲,扩展包提供了非常明确的typescript类型接口,你只需要实现该接口,就可以轻松对视频编辑器进行扩展,以增加WebCut本身没有的功能。

另外,我们还做了一些小更新。例如增加了在媒体库中直接上传整个文件夹的能力,增加了多选多个素材并按顺序插入到画布中的能力等。更多功能,还需要你自己去发现~

👉传送门:webcut.top

🌹Github点赞:github.com/tangshuang/…

关注我的公众号 wwwtangshuangnet ,持续获取WebCut更新日志。

pnpm + monorepo 才是 AI 协同开发的最佳方案!🚀🚀🚀

前言

最近业余时间一直忙着开发 AudioDock:github.com/mmdctjj/Aud…

很少更新文章,但是今天抽空总结下最近开发时的一些思考,希望可以给大家带来新的开发思路!

pnpm + monorepo + AI = 效率翻倍

今天的主角是 pnpm ,不过还是得结合正在做的项目来说明!

我的项目是包含了桌面端、移动端的全栈项目,并且前后端分离,除此之外还包含了一些工具库。

整体使用 pnpm + monorepo 的形式管理项目。

soundX/
├── apps/                # 应用层 (前端/客户端)
│   ├── mobile/         # 移动端应用 (React Native / Expo)
│   ├── desktop/        # 桌面端应用 (Electron)
│   └── mini/           # 迷你端/小程序应用
├── services/            # 后端服务层
│   └── api/            # 核心后端 API 服务 (通常是 NestJS)
├── packages/            # 公共模块与包 (内部依赖)
│   ├── db/             # 数据库模型与 Prisma 配置
│   ├── ws/             # WebSocket 通讯协议与逻辑
│   ├── utils/          # 公共工具函数
│   └── test/           # 测试辅助工具
├── Dockerfile           # 容器化部署配置
├── docker-compose.yml   # 多服务编排配置
├── package.json         # 根目录配置与工作区管理
├── pnpm-workspace.yaml  # pnpm 工作区定义
└── nginx.conf           # Nginx 反向代理配置

最近一直重度使用 AI 开发,没想到这种结构,让我的开发更加流畅:

全量的上下文信息

AI 在开发时,始终是全量的上下文(前、后端)信息,让AI 生成一个请求函数和参数返回值类型,基本上可以很准确的实现

统一的类型管理

这是 pnpm + monorepo 最大的优点了,统一的类型管理和统一的构建范式,即使想让 AI 重构,很少会出现重构失败的情况

跨应用开发

我开发移动端的时候,如果对 AI 生成的效果不满意,经常让 AI 熟悉下桌面端的相同功能,在开发移动端的功能,效果会得到很大的提升!

遇到的问题

多模块复用

我们知道,一个标准的前后端分离项目,需要多个模块的配合:models、servies、views。

像我的这个项目移动端(React Native)和桌面端(Electron)都会出现这些模块,包括后续会实现的小程序和电视端,大概率还是需要这些的,所以,我将公共的部分抽离成了单独的包:db、services(后知后觉,还没抽离)!

让人摸不着头脑的 nestjs

我之前已经写过 nestjs 的文章了,介绍了他基础的开发范式,controller、servide、module。可以说是 spring boot 无痛切换 Node 的最佳方案了,但是在我第一次打包的时候傻眼了,打包之后一直报错:

Error: Cannot find module '@nestjs/core'
Require stack:
| - /usr/src/app/dist/main.js

一开始以为是我的 Dockerfile 配置的有问题,反复调整,折磨了我一个周末才发现这是在 pnpm monorepo 项目里开发必然会出现的问题。

解决方法很简单,构建的时候,在单独下载一次生产环境的依赖包就可以了!

# 2. 安装生产依赖 + 全局安装 prisma (用于 db push)
RUN apt-get update -y && apt-get install -y openssl
RUN npm i -g pnpm prisma@6.6.0 && pnpm install --prod --frozen-lockfile --ignore-scripts

出现这个问题的主要原因是 pnpm 的软连接机制在打包的时候会失效,所以找不到这个包。

Prisma 让我又爱又恨

解决完第一个问题,以为可以正常运行我的包了,结果没想到这时候是 Prisma 的问题了。

Prisma 最大的好处是可以自动生成类型文件、基础的业务查询!

但是真的没想到,这些便捷的后面隐藏着无数的坑,第二个问题是 Prisma@6.x 版本默认输出客户端文件在 pnpm 项目里也会找不到文件在哪。

node:internal/modules/cjs/loader:1386
  throw err;
  ^
Error: Cannot find module '.prisma/client/default'
Require stack: - /app/node_modules/.pnpm/@prisma+client@6.8.2_prisma@6.8.2_typescript@5.9.3__typescript@5.9.3/node_modules/@prisma/client/default.js

后来看到需要使用自定义输出路径才不会出现这个问题。

但是使用自定义文件输出的文件是 CMD 格式的文件,于是我将 Prisma 的版本升级到了最新的 7.x 版本,新版本要求必须使用自定义路径输出客户端文件,并且生成的是 ES 格式的文件,可以在前端直接引入类型。

但是玩玩没想到,输出之后的文件会有三个变量一直报错

apps/api dev: generated/prisma/internal/prismaNamespace.ts:114:14 - error TS2742: The inferred type of 'DbNull' cannot be named without a reference to '.pnpm/@prisma+client-runtime-utils@7.0.0/node_modules/@prisma/client-runtime-utils'. This is likely not portable. A type annotation is necessary.

apps/api dev: 114 export const DbNull = runtime.DbNull
apps/api dev:                  ~~~~~~

apps/api dev: generated/prisma/internal/prismaNamespace.ts:121:14 - error TS2742: The inferred type of 'JsonNull' cannot be named without a reference to '.pnpm/@prisma+client-runtime-utils@7.0.0/node_modules/@prisma/client-runtime-utils'. This is likely not portable. A type annotation is necessary.

apps/api dev: 121 export const JsonNull = runtime.JsonNull
apps/api dev:                  ~~~~~~~~

apps/api dev: generated/prisma/internal/prismaNamespace.ts:128:14 - error TS2742: The inferred type of 'AnyNull' cannot be named without a reference to '.pnpm/@prisma+client-runtime-utils@7.0.0/node_modules/@prisma/client-runtime-utils'. This is likely not portable. A type annotation is necessary.

apps/api dev: 128 export const AnyNull = runtime.AnyNull
apps/api dev:                  ~~~~~~~

没办法又回退到了 6.x 版本直接手写 modal 了。

最后

目前已经开发完了大部分功能了,大概集中开发了两三个周末的时间,整体来说 pnpm + monorepo 协同 AI 开发我感觉是个人或者小团队开发的最快形式了。

可能会有小伙伴担心是不是很费大模型的 token ?

我没试过小项目,但是如果真的免费 token 用完了,可以再注册一个新账号继续使用。

💪别再迷茫!一份让你彻底掌控 TypeScript 类型系统的终极指南

TypeScript现在的普及度已经很高了,虽然它是一种静态类型定义,但它的庞大已足够我们要向对待一门语言一样对待它了,去深入学习,以便更好的利用它的能力。

当然了,我们首先要明确为什么需要它,而不是把它当作一种负担:

  • 编译时类型检查,避免了运行时才检查的数据类型错误,导致系统奔溃。
  • 提升开发效率,IDE基于类型系统提供了精准的代码补全、接口提示。减少了查询文档、查询API的成本。
  • 增强了代码可读性,通过类型注解帮助成员快速理解函数输入、输出,降低了协作成本。

类型系统基石

像一门语言有基本的语法一样,TypeScript也有基本的类型定义,这些基本类型对应JavaScript中的基本数据类型,包括numberstringbooleannullundefinedsymbolbigintobject

const name: string = "hboot";
const age: number = 18;
const message: string = `hello, ${name}`
const uniqueKey: symbol = Symbol("key");

空类型、任意类型、未知类型

void表示空类型,any表示任意类型,unknown表示未知类型。

  • void 常用于函数无返回值时的返回类型声明;
  • any 表示任意类型,失去了类型检查的能力,非必要不要用;
  • unknown 表示未知类型,可以用来代替any,但是它不能直接用,必须通过类型断言或类型守卫明确具体类型才能操作。
  • never 表示没有任何类型,变量和函数返回不会存在任何实际值。它是所有类型的字类型,可以赋值给任意类型。
function noReturn(msg: string): void {
    console.log(msg);
}

let name: any = "admin";
// 可以任意赋值
name = 123;
// 甚至是当作函数调用,这导致开发不易发现,运行时才报错
name():

let age: unknown = 18;
// 类型为未知,无法直接操作
age += 10;
// 通过类型守卫,明确类型为 number
if(typeof age === 'number') { 
    age += 10;
}

复合类型

复合类型就是基础类型的组合,包括数组、对象、元组、枚举、类。

数组

数组的类型定义T[]Array<T>

// 数组
let names: string[] = ['admin', 'user'];
let names: Array<string> = ['admin', 'user'];

对象

对象类型定义对象的属性名、属性类型。属性支持可选、只读、索引签名。

// 对象
let user: { name: string; age: number } = {
    name: 'hboot',
    age: 18
}

元组

元组是明确了数组长度以及元素类型的。

// 元组
let userInfo: [string, number] = ['hboot', 18];

枚举

枚举用于定义一组有特殊含义的常量,比如状态码、类型标识等。

// 枚举
const enum Status { 
    Success = 200,
    Fail = 500
}

未赋值时,枚举值从 0 开始递增。如果自定义了开始值,则后面的值递增。

const enum Status { 
  Success, // 0
  Fail // 1
}

const enum Status { 
  Success = 1, // 1
  Fail // 2
}

可以通过const enum修饰枚举定义,在编译时仅保留常量值,减少代码体积。

Class

class是面向对象编程的核心。es6中已经增强了对类的支持,可以通过类定义对象,明确属性、方法。类不仅可以用来创建实例,也可以作为类型注解描述实例类型。

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

类的定义包括实例属性、实例方法、静态属性、静态方法,同时还支持通过访问修饰符控制成员可见。

子类可以通过extends继承父类的非private成员,重写父类方法。仅能继承一个父类。

class Dog extends Animal {
  WangWang() {
    return "WangWang";
  }
}

类可以通过implements实现接口,用于约束类的实现,包括属性、方法,仅约束结构,不提供实现。可实现多个接口

interface Animal {
  name: string;
  speak(): void;
}

class Dog implements Animal {
  name: string;
  speak(): void {
    console.log("WangWang");
  }
  constructor(name: string) {
    this.name = name;
  }
}

通过abstract关键字可以声明抽象类,抽象类不能被实例化。它只定义了字段、方法结构,未实现具体逻辑,它仅可被子类继承,并需要实现它定义的所有抽象成员。

高级类型

基础类型可以满足绝大多数业务,但面对复杂业务需要类型复用、条件判断、属性筛选则需要高级类型。

复用类型

为了复用类型,可以通过type \ interface定义类型。type可以定义任意类型,包括基础类型、复合类型、联合类型等,比较灵活;interface 只能定义对象类型,但是可以继承同名合并

// type
type Age = number;
type User = { 
    name: string;
    age: Age;
}
type Status = 'success' | 200;

// interface
interface User { 
    name: string;
    age: number;
}

interface SuperUser extends User { 
    role: string;
}

interface 不同于类class,它可以继承多个接口。

联合/交叉类型

联合类型通过|表示变量可以是任意其中一种类型;交叉类型通过&表示变量必须同时满足多个类型。

联合类型使用时需要类型守卫明确类型后才能操作,如果变量已经赋值,则会自动推导出类型;

type Age = number | string;

let age: Age = 18;
age+=2;

function agePlus(age: Age){ 
    if(typeof age === 'number'){ 
        age+=2;
    }
    return age;
}

交叉类型常用合并对象类型,需要满足所有类型条件。如果无法满足所有类型条件,则该类型为never

type User = { 
    name: string;
}
type Address = { 
    address: string;
}

type UserAddress = User & Address;

泛型

泛型是将类型参数化,可以不指定具体类型来定义函数、类、接口。在使用时在传入具体类型,它可以高度抽象定义类型,保障类型复用和类型安全。

// 函数泛型
function getData<T>(data: T): T {
    return data;
}

// 接口泛型
interface IData<T> {
    data: T;
}

// 类泛型
class Data<T> {
    data: T[] = [];
}

因为不知道具体类型,就无法获知这个类型有什么属性,可以调用什么方法。为了使用某个属性或者某个方法,我们使用泛型约束来指定这个类型必须有哪些属性或者方法。

function getData<T extends { name: string }>(data: T): string {
    return data.name;
}

在调用具有泛型的类型时,需要传递具体类型,有时候这个泛型我们知道在很多情况下就是某一个类型,避免重复传入,我们可以指定泛型的默认类型,从而在使用时不再需要传入。

type User = { 
    name: string;
}

function getData<T extends User = User>(data: T): string {
  return data.name;
}

泛型约束和默认类型不需要同时出现,也可以定义其一,根据需求使用。

类型运算

除了定义具体类型,还可以通过类型运算从一个类型得到一个新的类型。也可以称之为推导,从一个类型推导为另一个类型。类型运算也是导致一些复杂类型产生的因素。

类型守卫

类型守卫是在运行时判断数据类型,它可以精准到具体类型,用于类型收窄,包括unknown、联合类型、类继承。关键字包括typeof instanceof in is

typeof 用于判断基础类型。但是无法判断 nulltypeof null 返回 'object',可通过value!==null 进行判断。

function agePlus(age: string | number) {
  if (typeof age === "number") {
    age += 2;
  }
  return age;
}

instanceof 判断是否为某个类实例。


class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  WangWang() {
    return "WangWang";
  }
}

class Cat extends Animal {
  MiaoMiao() {
    return "MiaoMiao";
  }
}

function speak(animal: Animal) {
  if (animal instanceof Dog) {
    return `${animal.name}:${animal.WangWang()}`;
  }
  if (animal instanceof Cat) {
    return `${animal.name}:${animal.MiaoMiao()}`;
  }

  return `${animal.name}`;
}

in 适用于对象类型定义,用于判断是否包含某个属性

type Dog = {
  a: string;
  b: number;
};
type Cat = {
  d: string;
  c: number;
};

function getAttr(animal: Dog | Cat) {
  if ("b" in animal) {
    return animal.a;
  }

  if ("d" in animal) {
    return animal.c;
  }

  return "unknown";
}

is 用于精准明确是什么类型,但是判断逻辑是我们自己写的,这就要求我们判断逻辑要精确,否则可能导致运行时错误。

type Dog = {
  a: string;
  b: number;
};
type Cat = {
  d: string;
  c: number;
};

// 内部逻辑自定义判断是否为某个类型
function isDog(animal: Dog | Cat): animal is Dog {
  return "b" in animal;
}

function getAttr(animal: Dog | Cat) {
  // 调用时如果为true,则推导为Dog
  if (isDog(animal)) {
    return animal.a;
  } else {
    return animal.c;
  }
}

类型断言

类型断言是将一个类型临时转为另一个类型,仅在编译阶段告诉TypeScript是什么类型,不能改变变量的实际类型。

类型断言需要开发者明确保证断言类型正确,否则可能会导致运行时异常。对于不确定的类型,因该优先使用类型守卫.

使用as断言类型,可以转unknown类型、父类型转子类型场景。

let age: unknown = 34;
// 可以断言类型为string,从而调用字符串的方法。
// 但是在运行时会报错
(age as string).toUpperCase();

还可以通过as const断言为只读常量,常用于固定值的类型约束。

// 类型为 string
const name = "hboot";

// 类型为字面量 "hboot" 类型
const name = "hboot" as const;

使用!断言非空,常用于访问某个可选属性时,断言非空

type Cat = {
  d?: string;
  c: number;
};

let cat: Cat = {
  c: 20,
};
// 未断言时调用报错属性 d 可能未定义
// cat.d.toUpperCase();

// 断言非空后则不会再提时报错,但是运行时报错 
cat.d!.toUpperCase();

所以,断言要谨慎使用、避免滥用,确保断言的类型是是实际数据类型的兼容类型。

还可以通过双层断言将一个明确的类型断言为另一个类型。

这个操作很危险,实例中将string类型断言为Cat对象类型去访问属性c,编译通过,运行时爆炸。

type Cat = {
  d?: string;
  c: number;
};

let name = "hboot";

(name as unknown as Cat).c;

运算符

运算符可以从一个类型的到另一个类型。类型安全,类型运算的操作有很多

keyof 得到对象类型的所有字段key类型构成的联合类型。

type Cat = {
  d?: string;
  c: number;
};
// 得到 Cat 的所有字段 key 类型 联合类型 "d" | "c"
type Keys = keyof Cat;

// 如果字段是索引签名,则返回索引签名的类型
type Cat = {
  [x: string]: unknown;
};
// 得到的是索引签名类型 string
type Keys = keyof Cat;

typeof 之前再类型守卫里已经介绍过了,它可以返回变量的类型(基本类型)。

索引访问(IndexedAccessType)获取类型,比如我们要获取对象类型里某个字段的类型,就可以使用索引获取。

type Cat = {
  d?: string;
  c: number;
};

// 获取字段类型为 string,但由于字段是可选 ?,
// 所以返回的是 string | undefined
type D = Cat["d"];

也可以通过联合类型获取到多个字段的类型,结果为一个联合类型。

type D = Cat["d"|"c"]

// 如果想要获取所有字段类型,可以通过keyof 获取到所有key的联合类型
type D = Cat[keyof Cat]

索引最重要的一点是可以对数组、元组元素的类型索引获取.

type names = [string, number];
// 索引第一个元素的类型
type Name = names[0];

type Names = Array<string>;
// 索引数组元素的类型
type Name = Names[number];

还可以搭配typeof 对变量的类型进行索引获取。

条件类型(ConditionalType),通过输入的类型,决定输出类型,通过extends关键字和三元表达式来标识。

type Name<T> = T extends string ? string : number;

// 输入泛型参数返回类型,
// 输出类型为 string
type Admin = Name<string>;
// 输出类型为 number
type User = Name<unknown>;

也可以声明一个函数书写更复杂的判断逻辑。既然通过extends关键字以及泛型参数,那么也可以增加泛型约束、泛型默认值。

映射类型(MappedType),从一个类型创建一个新类型,建立在索引签名语法之上,通过对对象的字段键值、字段类型进行转换操作。

type Cat = {
  name?: string;
  age: number;
};

type ToFunction<T> = {
  [K in keyof T]: () => T[K];
};

// 从基本类型转换成函数类型
type CatFunction = ToFunction<Cat>;
/**
 *  type CatFunction = {
 *    name?: (() => string | undefined) | undefined;
 *    age: () => number;
 *  }
 */

文本类型(LiteralType),通过扩展字符串生成文本类型,表现同字符串字面值相同。

type Name = "hboot";
type GoodName = `Good, ${Name}`;

利用文本类型扩展,可以结合映射类型,改变键值,得到一个全新的类型。

type ToFunction<T> = {
  [K in keyof T as `on_${string & K}`]: () => T[K];
};

// 就会得到不同键值、不同类型的新对象类型
type CatFunction = ToFunction<Cat>;
/**
 *  type CatFunction = {
 *    on_name?: (() => string | undefined) | undefined;
 *    on_age: () => number;
 *  }
 */

infer 类型推断,通常用于泛型参数推导;函数返回值类型推导

type Name<T> = T extends Array<infer U> ? U : T;

// 条件类型推导 得到数组元素类型 string
type str = Name<string[]>;
// 非数组类型,返回原类型 { name: string }
type info = Name<{ name: string }>;

内置工具函数

除了上述我们要手动去实现逻辑从而创建新的类型外,提供了内置函数可以直接使用,这些工具提供一些常用的类型转换逻辑。

Partial<T> 得到一个新类型,定义类型所有的字段都是可选的,与之相反的是Required<T>

type Cat = {
  name?: string;
  age: number;
};

type NewCat = Partial<Cat>;
/**
 * type NewCat = {
 *   name?: string;
 *   age?: number;
 * }
 */
type RequiredCat = Required<Cat>;
/**
 * type RequiredCat = {
 *   name: string;
 *   age: number;
 * }
 */

Pick<T,Keys> 从一个类型中选择某些字段得到一个新类型,Omit<T,Keys>从一个类型中删除某些字段得到一个新类型

type Cat = {
  name?: string;
  age: number;
};

type NewCat = Pick<Cat, 'name'>;
/**
 * type NewCat = {
 *   name?: string;
 * }
 */
type NewCat = Omit<Cat, 'age'>;
/**
 * type NewCat = {
 *   name?: string;
 * }
 */

Readonly<T> 得到一个新类型,定义类型所有的字段都是只读的,初始化后不可以修改

type Cat = {
  name?: string;
  age: number;
};

type ReadonlyCat = Readonly<Cat>;

let cat: ReadonlyCat = {
  name: "hboot",
  age: 18,
};

// 报错,不可以修改再赋值,属性是只读的
// cat.age = 20;

它没有与之相反定义的工具,怎么处理让所有只读属性都变为可编辑的呢?还记得上面讲过的映射类型吗?

在映射类型中通过使用-符号将所有字段的readonly修饰符都移除掉

type MutableType<T> = {
  -readonly [K in keyof T]: T[K];
};

let cat: MutableType<ReadonlyCat> = {
  name: "hboot",
  age: 18,
};

// 通过映射类型 -readonly 移除掉属性的只读修饰符
cat.age = 20;

Record<Keys,T> 可以创建一个由Keys组成的属性对应类型为T的对象类型

type Animal = Record<
  "dog" | "cat",
  {
    name: string;
    age: number;
  }
>;

/**
 * type Animal = {
 *   dog: {
 *     name: string;
 *     age: number;
 *   };
 *   cat: {
 *     name: string;
 *     age: number;
 *  }; 
 * }
 */

Exclude<U,T>从指定联合类型U排除某个满足 T类型的成员,得到新类型;与之相反的是Extract<U,T>

type Type = Exclude<"name" | "age" | 32, number | "name">;

/**
 * type Type = "age"
 */

type Type = Extract<"name" | "age" | 32, number | "name">;
/**
 * type Type = "name" | 32
 */

NonNullable<Type>排除nullundefined的成员,得到新类型

Parameters<Type> 从函数类型提取函数的参数类型,得到一个元组类型,非函数类型定义得到never类型。

type Fun = (name: string, age: number) => void;

type Params = Parameters<Fun>;
/**
 * type Params = [name: string, age: number]
 */

ConstructorParameters<Type> 从构造函数类型提取构造函数的参数类型,得到一个元组类型或数组类型

type FunConstructorParams = ConstructorParameters<FunctionConstructor>;
/**
 * type FunConstructorParams = string[]
 */

type ErrorConstructorParams = ConstructorParameters<ErrorConstructor>;
/**
 * type ErrorConstructorParams =  [message?: string, options?: ErrorOptions]
 */

ReturnType<Type> 从函数类型提取函数的返回值类型,对于非函数类型会得到any.

Awaited 用来获取异步函数的返回值类型

type Fun = () => Promise<{ data: string }>;

type FunType = Awaited<ReturnType<Fun>>;
/**
 * type FunType = {
 *   data: string;
 * }
 */

InstanceType<Type> 从构造函数类型提取构造函数的实例类型

type Fun = new () => { name: string };

type FunType = InstanceType<Fun>;
/**
 * type FunType = {
 *   name: string;
 * }
 */

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

type AnimalType = InstanceType<typeof Animal>;
/**
 * type AnimalType = Animal
 */

NoInfer<T>阻止从该处推导泛型参数类型

function compare<T extends string>(a: T[], b?: NoInfer<T>) {
  console.log(a, b);
}

// 锁定了泛型 T 类型是 "admin" | "test" , 所以传入参数 "hboot" 会提示报错
// 如果没有 NoInfer 则不会报错,推断类型 string
compare(["admin", "test"], "hboot");

ThisParameterType<T> 提取函数类型中的this参数的类型,如果没有this参数则返回unknown

OmitThisParameter<Type> 移除函数类型中的this参数,得到一个新的函数类型,如果没有this参数或不是函数类型则返回类型本身。

ThisType<Type> 用于指定方法上下文中的this类型,不会返回新类型,指定后在方法中就可以通过this访问到指定属性。但是需要开启noImplicitThis(tsconfig.json)标志才可以使用。

interface Animal {
  name: string;
}

type Dog = {
  speak(): void;
} & ThisType<Animal>;

const dog: Dog = {
  speak() {
    // 通过this 可以访问到 name 属性
    console.log(this.name);
  },
};

文本类型工具,用于处理文本,包括:

  • Uppercase<StringType> 将字符串转换为大写
  • Lowercase<StringType> 将字符串转换为小写
  • Capitalize<StringType> 将字符串的第一个字符转换为大写
  • Uncapitalize<StringType> 将字符串的开头字符转换为小写

命名空间

通过关键字namespace 定义空间名,用于分组相关的类型和值,避免命名冲突。并可通过export导出供外部使用

namespace Hboot {
  //外部无法访问
  const name = "hboot" as const;

  // 外部可以访问
  export function getName() {
    return name;
  }
}

Hboot.getName();

Nuxt 3 项目自动化部署到宝塔服务器全攻略 (GitHub Actions + rsync)

本指南详细介绍了如何利用 GitHub Actions 持续集成工具,将 Nuxt 3 项目(静态生成 SSG 模式)自动化部署到宝塔面板服务器。


插件介绍

nuxt-web-plugin 是一款面向 Nuxt 3/4 的全能增强插件,旨在提升开发体验(DX)并为应用提供坚实的基础能力。

核心特性:

  • 🔐 深度安全防护: 集成 AES-GCM 对称加密、RSA 非对称加密及 SHA-256 哈希算法,支持加密存储(Storage/Cookie)。
  • 🛰️ 智能请求封装: 基于 $fetch 的统一网络层,内置 自动去重 (Dedupe)短时缓存 (Cache)并发锁 (Lock),有效防止重复请求。
  • 🖼️ 页面水印系统: 动态 Canvas 水印,支持防篡改监测(Anti-Tamper),保护页面内容版权。
  • 🔍 SEO & 设备检测: 自动元标签生成与移动端/平板/桌面端精准识别。
  • 🎨 玻璃拟态布局: 内置一套现代化的插件控制台模板,完美支持 Tailwind 暗色模式。

一、 准备工作

1.1 服务器环境

  • 确保服务器已安装 宝塔面板
  • 在宝塔面板中创建一个 静态网站(或 PHP 网站,但我们只需其静态能力)。
  • 记住你的网站根目录,例如:/www/wwwroot/nuxt.haiwb.com

1.2 生成 SSH 密钥对

在你的本地终端或服务器执行以下命令(建议在服务器执行):

# 生成密钥对 (ed25519 算法更安全且简短)
ssh-keygen -t ed25519 -C "github-actions-deploy"
  1. 公钥 (.pub): 将内容复制并添加到服务器的 ~/.ssh/authorized_keys 文件中。
  2. 私钥: 将内容完整复制,下一步使用。

二、 GitHub 仓库配置

进入你的 GitHub 项目仓库,点击 Settings -> Secrets and variables -> Actions,点击 New repository secret 添加以下变量:

变量名 说明 示例值
SERVER_SSH_KEY 刚才生成的 私钥 内容 -----BEGIN OPENSSH PRIVATE KEY----- ...
SERVER_HOST 服务器公网 IP 或域名 1.2.3.4nuxt.haiwb.com
SERVER_USER SSH 登录名 root (建议使用有权限的普通用户)
SERVER_TARGET 宝塔面板中的网站根目录 /www/wwwroot/nuxt.haiwb.com

三、 编写工作流文件

在项目根目录创建 .github/workflows/deploy-playground.yml 文件:

name: Deploy Playground to Baota

on:
  push:
    branches: [main] # 仅在代码推送到 main 分支时触发
  workflow_dispatch:  # 支持在 GitHub Actions 页面手动点击运行

jobs:
  deploy-to-baota:
    name: Upload to Server
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 24 # 关键:Node 版本需匹配项目要求 (v24.4.1+)
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install --no-frozen-lockfile

      - name: Build Playground (Static)
        run: |
          # 准备模块环境
          pnpm run dev:prepare
          cd playground
          # 生成静态文件 (SSG)
          npx nuxi generate 
          echo "Build Output Check:"
          ls -R .output/public/ # 打印构建结果,方便排查路径问题

      - name: Deploy to Server
        uses: easingthemes/ssh-deploy@main
        with:
          # SSH 私钥
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          # 远程主机信息
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USER }}
          TARGET: ${{ secrets.SERVER_TARGET }}
          # 部署源目录 (Nuxt 3 SSG 产物路径)
          SOURCE: "playground/.output/public/"
          # rsync 参数: r(递归), l(链接), g(组), o(所有者), D(设备), z(压缩), v(详细), c(校验), --delete(删除多余文件)
          ARGS: "-rlgoDzvc -i --delete"
          # 关键排坑:排除服务器系统锁定的文件,否则会报 Operation not permitted (rsync code 23)
          EXCLUDE: "/.user.ini, /.htaccess, /.well-known/, /cgi-bin/"

四、 核心避坑指南 (Troubleshooting)

4.1 Node 引擎版本报错

错误信息: Unsupported engine: wanted: {"node":">=24.4.1"} 原因: 项目 package.json 限制了高版本 Node,而 GitHub Actions 默认环境较低。 对策: 在 actions/setup-node 步骤中明确指定 node-version: 24

4.2 预渲染死链报错

错误信息: Exiting due to prerender errors 原因: Nuxt 3 在 generate 过程中会检查所有链接,如果发现指向 /docs 等不存在的内部路径会报错。 对策:

  1. nuxt.config.ts 中配置 nitro: { prerender: { failOnError: false } }
  2. 将外部链接或独立部署的链接改为绝对路径(如 https://...)。

4.3 rsync exited with code 23

错误信息: unlink(.user.ini) failed: Operation not permitted (1) 原因: 宝塔面板会自动在网站目录创建 .user.ini 并锁定(i 权限)。rsync 尝试删除该文件以便同步时会被拦截。 对策: 在部署脚本中使用 EXCLUDE 配置将其排除掉。


五、 Nginx 伪静态设置 (非常重要)

为了让 Nuxt 的客户端路由正常工作,请在宝塔面板的网站设置 -> 伪静态 中添加以下内容:

location / {
  # 优先寻找文件和目录,找不到则 fallback 到 index.html 让 Vue 处理路由
  try_files $uri $uri/ /index.html;
}

Spec-Kit应用指南

GitHub Spec-Kit 使用指南

规范驱动开发(Spec-Driven Development) - 让 AI 编码更可控、更高效

一、什么是 Spec-Kit?

1.1 简介

Spec-Kit 是 GitHub 官方开源的规范驱动开发工具包,旨在改变传统的 AI 编码方式。

  • 官方仓库: github.com/github/spec…
  • 支持的 AI 工具: Claude Code、GitHub Copilot、Cursor、Gemini CLI、Windsurf 等

1.2 核心理念

传统开发 Spec-Driven 开发
想法 → 直接写代码 → 调试 → 补文档 想法 → 写规范 → AI 生成方案 → AI 实现 → 验证
代码是源头,文档是副产品 规范是源头,代码是规范的实现
“Vibe Coding” - 凭感觉写 结构化、可预测、可追溯

1.3 为什么需要 Spec-Kit?

传统 AI 编码的问题:

  • AI 理解不准确,生成的代码与预期不符
  • 缺乏上下文,AI 无法理解项目架构约束
  • 多人协作时,AI 生成的代码风格不一致
  • 难以追溯需求和实现的对应关系

Spec-Kit 的解决方案:

  • 规范即合约:AI 必须按照规范生成代码
  • Constitution(章程):定义项目的架构约束和编码规范
  • 结构化流程:Specify → Plan → Tasks → Implement
  • 质量门禁:每个阶段都有验证点

二、安装与配置

2.1 前置要求

  • Node.js 18+ 或 Python 3.10+(用于 CLI)
  • Git
  • AI 编码工具(推荐 Claude Code)

2.2 安装方式

方式一:使用 uvx(推荐,无需安装)

# 直接运行,无需安装
uvx --from git+https://github.com/github/spec-kit.git specify init my-project --ai claude

方式二:使用 uv 全局安装

# 安装 CLI
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git

# 初始化项目
specify init my-project --ai claude

方式三:使用 npm/bun

# 使用 bun
bun install -g @spec-kit/cli

# 或使用 npm
npm install -g @spec-kit/cli

# 初始化项目
specify init my-project --ai claude

2.3 初始化项目

# 新项目 + Claude Code
specify init my-project --ai claude

# 在当前目录初始化
specify init . --ai claude

# 跳过 git 初始化
specify init my-project --ai claude --no-git

# 其他 AI 工具
specify init my-project --ai copilot      # GitHub Copilot
specify init my-project --ai cursor-agent  # Cursor
specify init my-project --ai gemini        # Gemini CLI

2.4 初始化后的目录结构

my-project/
├── .specify/                    # Spec-Kit 配置目录
│   ├── memory/
│   │   └── constitution.md      # ⭐ 项目章程(架构约束)
│   ├── scripts/
│   │   ├── bash/                # Bash 脚本
│   │   └── powershell/          # PowerShell 脚本
│   └── templates/
│       ├── spec-template.md     # 规范模板
│       ├── plan-template.md     # 方案模板
│       └── tasks-template.md    # 任务模板
├── specs/                       # 规范文档存放目录
│   └── 001-feature-name/
│       ├── spec.md              # 功能规范
│       ├── plan.md              # 技术方案
│       └── tasks.md             # 任务列表
└── .claude/commands/            # Claude Code 自定义命令
    ├── specify.md
    ├── plan.md
    ├── tasks.md
    └── implement.md

三、工作流程

image.png

3.1 六阶段流程

Constitution → Specify → Clarify → Plan → Tasks → Implement
    ↓            ↓         ↓        ↓       ↓         ↓
 项目章程    功能规范   需求澄清   技术方案  任务拆分   代码实现

3.2 核心命令

阶段 命令 作用 输出物
1. Specify /specify 定义功能规范(WHAT) spec.md
2. Clarify /clarify 澄清模糊需求 更新 spec.md
3. Plan /plan 生成技术方案(HOW) plan.md
4. Tasks /tasks 拆分可执行任务 tasks.md
5. Implement /implement 执行代码实现 源代码
6. Analyze /analyze 质量检查 分析报告

3.3 详细流程说明

阶段一:编写 Constitution(章程)

Constitution 是项目的"宪法",定义了:

  • 技术栈和架构约束
  • 编码规范和命名规则
  • 依赖策略
  • 设计原则

示例(.specify/memory/constitution.md):

# 项目章程

## 技术栈
- 后端:Spring Boot 2.7 + MyBatis Plus + Dubbo 3.3
- 数据库:MySQL 8.0 + Redis
- 前端:Vue 3 + Element Plus

## 架构约束
- 分层架构:Controller/DubboApi → Service → Mapper
- Entity 必须放在 xxx.api.entity 包下
- 禁止在 Controller/DubboApi 中写业务逻辑

## 编码规范
- 使用 Spring Java Format 格式化代码
- 方法必须有 JavaDoc 注释
- 增删改操作必须添加 @Transactional

## 命名规则
- Entity:大驼峰,如 UserInfo
- Service 接口:I{Entity}Service,如 IUserInfoService
- Mapper:{Entity}Mapper,如 UserInfoMapper
阶段二:Specify(功能规范)
# 在 Claude Code 中执行
/specify

输入功能描述后,AI 会生成:

  1. 功能分支(如 001-user-login
  2. 规范目录(specs/001-user-login/
  3. 规范文档(spec.md

spec.md 示例:

# 功能规范:用户登录

## 背景
当前系统没有用户登录功能,需要实现基于手机号+验证码的登录流程。

## 用户故事
- 作为用户,我希望通过手机号和验证码登录,以便访问我的个人中心。
- 作为用户,我希望在验证码错误时收到明确提示。

## 验收标准
1. [ ] 用户输入手机号,点击发送验证码,后端生成并发送(模拟)。
2. [ ] 验证码有效期 5 分钟。
3. [ ] 登录成功返回 JWT Token。
4. [ ] 登录失败提示具体原因(验证码错误/过期)。

## 业务规则
- 手机号必须是 11 位数字。
- 同一手机号 1 分钟内只能请求一次验证码。
阶段三:Plan(技术方案)

在明确了“做什么”之后,下一步是确定“怎么做”。

# 在 Claude Code 中执行
/plan

输入spec.md + constitution.md 或直接回车

输出plan.md

plan.md 示例:

# 技术方案:用户登录

## 架构决策
- 使用 Redis 存储验证码,Key 格式:`auth:code:{phone}`- 使用 JJWT 库生成 Token。
- 遵循 Controller -> Service -> Manager -> Mapper 分层。

## 数据库变更
- 无需新增表,复用 `user_info` 表。

## API 设计
1. POST /api/v1/auth/code
   - Req: { phone: string }
   - Res: { success: boolean }
2. POST /api/v1/auth/login
   - Req: { phone: string, code: string }
   - Res: { token: string, user: UserInfo }

## 模块设计
- `AuthController`: 处理 HTTP 请求。
- `AuthService`: 核心业务逻辑(校验、颁发 Token)。
- `SmsManager`: 对接短信网关(Mock 实现)。
阶段四:Tasks(任务拆分)

将技术方案拆解为 AI 可以独立执行的原子任务(Atomic Tasks)。

# 在 Claude Code 中执行
/tasks

输入plan.md 或直接回车

输出tasks.md

tasks.md 示例:

# 任务列表

## 1. 基础设施
- [ ] Task 1.1: 添加 JJWT 和 Redis 依赖到 `pom.xml`- [ ] Task 1.2: 配置 Redis 连接参数。

## 2. 核心逻辑
- [ ] Task 2.1: 创建 `SmsManager` 并实现发送模拟逻辑。
- [ ] Task 2.2: 创建 `AuthService` 接口及实现,编写 `sendCode` 方法。
- [ ] Task 2.3: 在 `AuthService` 中实现 `login` 方法(含 Token 生成)。

## 3. 接口层
- [ ] Task 3.1: 创建 `AuthController` 并暴露 REST 接口。
- [ ] Task 3.2: 编写 Controller 层单元测试。
阶段五:Implement(执行实现)

AI 逐个读取任务并执行。

# 在 Claude Code 中执行
/implement

执行逻辑:

  1. AI 读取 tasks.md 中第一个未完成的任务。
  2. 读取相关文件上下文。
  3. 编写代码。
  4. 运行测试(如果定义了验证步骤)。
  5. 标记任务为 [x]
  6. 重复上述步骤,直到所有任务完成。
阶段六:Analyze(质量检查)
# 在 Claude Code 中执行
/analyze

对生成的代码进行质量分析,检查是否符合 constitution.md 中的规范,例如:

  • 是否遗漏了 JavaDoc?
  • 是否使用了被禁止的类?
  • 事务注解是否添加?

四、最佳实践

4.1 什么时候使用 Spec-Kit?

  • 推荐:复杂功能开发、需要多人协作、对代码质量有严格要求。
  • 不推荐:简单的 Bug 修复、临时脚本、纯文案修改。

4.2 存量项目接入(Brownfield)

对于已有项目,不需要一次性补全所有文档。可以采用增量接入策略:

  1. 初始化 Spec-Kit。
  2. 配置 constitution.md 以反映当前项目的最佳实践。
  3. 在开发新功能时,按照 Specify -> Plan -> Tasks -> Implement 流程进行。
  4. 对于旧代码的重构,可以先让 AI 读取旧代码生成 spec.md(逆向工程),再进行修改。

五、高级特性

5.1 自定义规范模板

你可以创建自己的规范模板,以适应不同的项目需求或团队标准。

# spec_templates/my_template.py
TEMPLATE = """
项目名称: {project_name}
技术栈: {tech_stack}
功能模块: {features}
性能要求: {performance}
"""

5.2 集成 CI/CD

Spec-Kit 可以与 CI/CD 工具集成,确保所有提交的规范和代码都符合标准。

# .github/workflows/spec-kit.yml
name: Spec-Kit Validation
on: [push]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Validate Specifications
        run: specify validate

5.3 团队协作模式

通过共享规范库实现团队协作,确保所有人使用统一的规范。

# 导出规范
specify export --output team-specs.json

# 导入团队规范
specify import team-specs.json

六、与现有工作流整合

6.1 与 Claude Code 集成

初始化后,Claude Code 自动获得 /specify/plan/tasks/implement 命令。

6.2 与 Git 工作流整合
# 1. 开始新功能
/specify "实现用户登录功能"

# AI 自动创建分支:001-user-login

# 2. 完成规范和方案
/plan
/tasks

# 3. 实现代码
/implement

# 4. 代码审查
/review-code

# 5. 提交 PR
git add .
git commit -m "feat: 实现用户登录功能"
git push
6.3 与现有项目规范整合

将公司现有规范整合到 Constitution 中:

# .specify/memory/constitution.md

## 引用公司规范
本项目遵循《城市停车微服务框架规范》,详见 CLAUDE.md

## 补充约束
- Entity 必须在 xxx.api.entity 包下
- 使用 ThreadPoolFactory 创建线程池
- 分页查询必须调用 startDubboPage()

七、常见问题 (FAQ)

Q1: Spec-Kit 支持哪些编程语言? Spec-Kit 是语言无关的,支持所有主流编程语言,包括 Python, Java, JavaScript, TypeScript, Go, Rust 等。它通过自然语言描述规范,因此不受限于特定编程语言。

Q2: 发现代码逻辑走不通怎么办?

千万不要直接改代码! 这会破坏“规范即源码”的原则,导致文档与代码脱节。 正确做法:

  1. 回滚:回到 Plan 阶段。
  2. 修改:更新 plan.md 中的技术决策或 tasks.md 中的任务拆分。
  3. 重生成:让 AI 重新生成受影响的代码。

Q3: 如何避免 Spec 文档腐烂?

  • 定期归档:Sprint 结束后,将完成的 specs/ 下的文档移动到 docs/archive/,保持工作区整洁。
  • 反哺章程:如果某个 Spec 引入了新的通用模式(例如确立了“新的权限控制方案”),应将其总结并更新到 .specify/memory/constitution.md 中,使其成为后续开发的标准。

Q4: 团队如何协作?

  • 产品经理 (PM): 负责 Review spec.md,重点关注验收标准(Acceptance Criteria)是否覆盖业务需求。
  • 架构师 / Tech Lead: 负责 Review plan.md,把控技术方案、数据库设计和 API 定义是否符合架构规范。
  • 开发者: 负责执行 tasks.md,并监督 AI 生成的代码质量,进行最终的 Code Review。

七、参考资料

  1. 官方仓库: github.com/github/spec…
  2. 官方博客: github.blog/ai-and-ml/g…
  3. Martin Fowler 文章: martinfowler.com/articles/ex…
  4. Microsoft 教程: developer.microsoft.com/blog/spec-d…
  5. 本文档参考来源: blog.csdn.net/a309220728/…

八、总结

Spec-Kit 不仅仅是一个工具,更是一种工程化思维的体现。它通过强制的结构化流程,解决了 AI 编程中常见的“幻觉”、“上下文丢失”和“不可控”问题。

  • 对于个人开发者:它是你的“外脑”,帮你理清思路,保持代码整洁。
  • 对于团队:它是无形的“架构师”,确保所有 AI 生成的代码都遵循统一的团队规范。

🔥 手写 Vue 自定义指令:实现内容区拖拽调整大小(超实用)

日常开发中经常遇到需要手动调整内容区大小的场景,比如侧边栏、弹窗、报表面板等。分享一个我写的「拖拽调整大小指令」,支持自定义最小尺寸、拖拽手柄样式,能监听尺寸变化

📌 先看效果

image.png

🛠 核心代码解析

指令文件 directives/resizable-full.js ,关键部分:

1. 指令钩子:初始化 + 更新 + 清理

Vue 指令的 3 个核心钩子,保证指令的生命周期完整:

js

export default {
  bind(el, binding) {
    // 指令绑定时初始化拖拽功能
    initResizable(el, binding);
  },
  update(el, binding) {
    // 禁用状态变化时,重新初始化
    if (binding.value?.disabled !== binding.oldValue?.disabled) {
      cleanupResizable(el); // 先清理旧的
      initResizable(el, binding); // 再初始化新的
    }
  },
  unbind(el) {
    // 指令解绑时,清理所有手柄和事件(避免内存泄漏)
    cleanupResizable(el);
  }
};

2. 初始化拖拽:创建手柄 + 核心逻辑

initResizable 是核心函数,主要做 2 件事:创建拖拽手柄、写拖拽逻辑。

(1)创建拖拽手柄

我只保留了「右下角」的拖拽手柄(其他方向注释掉了,需要的话自己解开),样式可自定义:

js

// 定义手柄配置(只留了bottom-right)
const handles = [
  { dir: 'bottom-right', style: { bottom: 0, right: 0, cursor: 'nwse-resize' } }
];

// 循环创建手柄元素
handles.forEach(handleConf => {
  const handle = document.createElement('div');
  handle.className = `resizable-handle resizable-handle--${handleConf.dir}`;
  handle.dataset.dir = handleConf.dir;
  
  // 手柄样式:小方块、半透明、hover高亮
  Object.assign(handle.style, {
    position: 'absolute',
    width: `${handleSize}px`,
    height: `${handleSize}px`,
    background: handleColor,
    opacity: '0.6',
    zIndex: 999,
    transition: 'opacity 0.2s',
    ...handleConf.style
  });

  // hover时手柄高亮
  handle.addEventListener('mouseenter', () => handle.style.opacity = '1');
  handle.addEventListener('mouseleave', () => handle.style.opacity = '0.6');

  el.appendChild(handle); // 把手柄加到目标元素上
  el._resizableConfig.handles.push(handle); // 存起来方便后续清理
});

(2)拖拽核心逻辑

分 3 步:按下鼠标(记录初始状态)→ 移动鼠标(计算新尺寸)→ 松开鼠标(触发回调 + 清理):

js

// 1. 按下鼠标:记录初始位置和尺寸
const mouseDownHandler = (e) => {
  const handle = e.target.closest('.resizable-handle');
  if (!handle) return;

  e.stopPropagation();
  e.preventDefault();
  
  const dir = handle.dataset.dir;
  const rect = el.getBoundingClientRect(); // 获取元素当前位置和尺寸

  // 存初始状态:鼠标位置、元素尺寸/位置
  startState = {
    dir,
    startX: e.clientX,
    startY: e.clientY,
    startWidth: rect.width,
    startHeight: rect.height
  };

  // 绑定移动/松开事件(绑在document上,避免拖拽时鼠标移出元素失效)
  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
};

// 2. 移动鼠标:计算新宽高并赋值
const onMouseMove = (e) => {
  if (!startState) return;
  const { dir, startX, startY, startWidth, startHeight } = startState;
  let newWidth = startWidth;
  let newHeight = startHeight;

  // 只处理右下角拖拽:宽高都增加
  if (dir === 'bottom-right') {
    newWidth = startWidth + (e.clientX - startX);
    newHeight = startHeight + (e.clientY - startY);
  }

  // 限制最小宽高(避免拖到太小)
  newWidth = Math.max(minWidth, newWidth);
  newHeight = Math.max(minHeight, newHeight);

  // 给元素设置新尺寸
  el.style.width = `${newWidth}px`;
  el.style.height = `${newHeight}px`;
};

// 3. 松开鼠标:触发回调+清理事件
const onMouseUp = () => {
  // 拖拽结束,触发自定义回调,返回最新尺寸
  if (startState && el._resizableConfig.onResize) {
    el._resizableConfig.onResize({
      width: parseInt(el.style.width),
      height: parseInt(el.style.height)
    });
  }
  startState = null;
  // 移除事件(避免重复绑定)
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
};

// 给元素绑定按下事件
el.addEventListener('mousedown', mouseDownHandler);

3. 清理函数:避免内存泄漏

cleanupResizable 负责移除所有手柄元素和事件监听器,指令解绑时必执行:

js

function cleanupResizable(el) {
  if (el._resizableConfig) {
    // 移除所有手柄
    el._resizableConfig.handles.forEach(handle => {
      if (handle.parentNode === el) el.removeChild(handle);
    });
    // 移除所有事件监听器
    el.removeEventListener('mousedown', el._resizableConfig.mouseDownHandler);
    document.removeEventListener('mousemove', el._resizableConfig.mouseMoveHandler);
    document.removeEventListener('mouseup', el._resizableConfig.mouseUpHandler);
    // 删除配置(释放内存)
    delete el._resizableConfig;
  }
}

🚀 如何使用?

  1. 全局注册指令(main.js):

js

import resizableFull from './directives/resizable-full';
Vue.directive('resizable-full', resizableFull);

2. 页面中使用

vue

<template>
  <!-- 给需要拖拽的元素加指令 -->
  <div 
    v-resizable-full="{
      minWidth: 300, // 最小宽度
      minHeight: 200, // 最小高度
      handleSize: 10, // 手柄大小
      handleColor: '#409eff', // 手柄颜色
      onResize: handleResize // 拖拽结束回调
    }"
    style="position: relative; width: 400px; height: 300px; border: 1px solid #eee;"
  >
    我是可拖拽调整大小的内容区~
  </div>
</template>

<script>
export default {
  methods: {
    // 拖拽结束,拿到最新尺寸
    handleResize({ width, height }) {
      console.log('新尺寸:', width, height);
    }
  }
};
</script>

💡 关键注意点(避坑)

  1. 目标元素必须设 position: relative/absolute/fixed:因为手柄是绝对定位,依赖父元素的定位;
  2. 事件绑在 document 上:拖拽时鼠标可能移出目标元素,绑在 document 上才不会断;
  3. 一定要清理事件 / 元素:指令解绑时执行 cleanupResizable,避免内存泄漏;
  4. 最小尺寸限制:通过 minWidth/minHeight 避免元素被拖到太小,影响体验。

🎨 扩展玩法

  1. 解开注释的其他 7 个方向手柄,实现全方向拖拽;
  2. 给手柄加 hover 提示(比如 “拖拽调整大小”);
  3. 支持拖拽时实时触发回调(不止结束时);
  4. 自定义手柄样式(比如改成虚线、加图标)。

📝 总结

这个自定义指令核心是「创建拖拽手柄 + 监听鼠标事件 + 计算尺寸变化」,逻辑不复杂,可以根据自己的业务场景定制。亲测报表和弹窗都很适用~

如果觉得有用,可以点个赞收藏一下,下次需要直接翻出来用😜

深入理解 React Hooks:useState 与 useEffect 的核心原理与最佳实践

在现代 React 开发中,函数式组件配合 Hooks 已成为主流开发范式。其中,useStateuseEffect 是最基础、最常用的两个内置 Hook。它们分别负责管理组件的响应式状态和处理副作用逻辑。本文将结合代码示例与深入分析,带你全面掌握这两个核心 Hook 的使用方式、底层思想以及常见陷阱。


一、useState:让函数组件拥有“记忆”

1.1 基本用法

useState 是 React 提供的第一个 Hook,用于在函数组件中声明状态变量:

import { useState } from "react";

export default function App() {
  const [num, setNum] = useState(1);
  
  return (
    <div onClick={() => setNum(num + 1)}>
      {num}
    </div>
  );
}

这里 num 是当前状态值,setNum 是更新该状态的函数。每次调用 setNum 都会触发组件重新渲染,并使用新的状态值。

⚠️ 注意:不要直接修改状态(如 num++),必须通过 setNum 触发更新,否则 React 无法感知变化,也就无法触发视图的更新。

1.2 初始值支持函数形式

当初始状态需要复杂计算时,可以传入一个纯函数作为 useState 的参数:

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 2 + 3;
  return num1 + num2; // 返回 6
});

这个函数只在组件首次渲染时执行一次,后续更新不会再次调用。这有助于避免不必要的性能开销。

✅ 关键点:该函数必须是同步的、无副作用的纯函数。不能包含 setTimeoutfetch 等异步操作,因为状态必须是确定的,如果是类似于fetch这种异步请求,它的状态是不确定的。

1.3 更新状态时使用函数式更新

当新状态依赖于前一个状态时,推荐使用函数式更新:

<div onClick={() => setNum(prevNum => prevNum + 1)}>
  {num}
</div>

prevNum会接收最新的num状态值,这种方式能确保你总是基于最新的状态值进行计算。


二、useEffect:处理副作用的“生命周期钩子”

如果说 useState 赋予组件“记忆”,那么 useEffect 就赋予组件“行动能力”——执行那些不属于纯渲染逻辑的操作,比如数据请求、订阅、定时器等。

2.1 基本结构

useEffect(() => {
  // 副作用逻辑
  return () => {
    // 清理函数(可选)
  };
}, [dependencies]); // 依赖数组
  • 第一个参数:副作用函数
  • 第二个参数:依赖项数组(决定何时重新执行)
  • 返回值(可选):清理函数,在下次 effect 执行前或组件卸载时调用

2.2 三种典型使用场景

场景一:模拟 componentDidMount(挂载时执行一次)

useEffect(() => {
  console.log('组件已挂载');
  queryData().then(data => setNum(data));
}, []); // 空依赖数组

📌 注意:空数组 [] 表示“仅在挂载时执行一次”。但如果组件被卸载后重新挂载,仍会再次执行。

场景二:监听状态变化(类似 watch

useEffect(() => {
  console.log('num 发生变化:', num);
}, [num]); // 依赖 num
  • 首次渲染时执行一次
  • 每当 num 变化时重新执行

场景三:无依赖项(每次渲染后都执行)

useEffect(() => {
  console.log('每次渲染后都会执行');
}); // 没有第二个参数

⚠️ 谨慎使用!容易引发无限循环或性能问题。

2.3 清理副作用:避免内存泄漏

很多副作用会创建持久资源(如定时器、事件监听器),必须在组件卸载或依赖变化时清理:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num); // 注意:这里打印的是 effect 创建时的 num(闭包)
  }, 1000);

  return () => {
    console.log('清理定时器');
    clearInterval(timer);
  };
}, [num]);
  • 每次 num 变化时,先执行上一次的清理函数(clearInterval),再创建新定时器。
  • 若不清理,会导致多个定时器同时运行,造成内存泄漏,每次新建的定时器那一块内存,没有办法回收了。

🔍 重要细节:console.log(num) 打印的是闭包中的旧值,不是最新状态!这是初学者常踩的坑。


三、纯函数 vs 副作用:React 的哲学基础

理解 useStateuseEffect 的设计,离不开对 纯函数副作用 的区分。

什么是纯函数?

  • 相同输入 → 相同输出
  • 无外部依赖(不修改外部变量)
  • 无 I/O 操作(如网络请求、DOM 操作)
// 纯函数 ✅
function add(x, y) {
  return x + y;
}

// 非纯函数 ❌(修改了外部数组)
function add(nums) {
  nums.push(3); // 副作用!
  return nums.reduce((a, b) => a + b, 0);
}

React 组件本身应尽量保持“纯”:输入 props,输出 JSX。而 useEffect 正是用来隔离副作用的机制。


四、常见误区与最佳实践

❌ 误区1:在 useState 初始值中使用异步函数

// 错误!useState 不支持异步
const [data, setData] = useState(async () => {
  const res = await fetch('/api');
  return res.json();
});

✅ 正确做法:用 useEffect 处理异步初始化:

useEffect(() => {
  fetch('/api').then(res => res.json()).then(setData);
}, []);

❌ 误区2:忘记清理定时器/监听器

会导致内存泄漏,尤其在路由切换或条件渲染组件时。

✅ 总是考虑是否需要返回清理函数。

❌ 误区3:依赖项遗漏或冗余

  • 遗漏依赖 → 使用旧值(闭包陷阱)
  • 冗余依赖 → 不必要的重复执行

五、总结

Hook 作用 关键特性
useState 管理响应式状态 支持函数式更新、惰性初始化
useEffect 处理副作用(数据请求、订阅等) 依赖控制、自动清理、闭包陷阱
  • 状态是组件的核心useState 让函数组件具备状态管理能力。
  • 副作用必须被隔离useEffect 是 React 对“纯组件”理念的优雅妥协。
  • 纯函数是基石,理解它才能写出可预测、可维护的 React 代码。

掌握 useStateuseEffect,就掌握了函数式组件的“灵魂”。在实际开发中,善用它们的特性,避开常见陷阱,你的 React 应用将更加健壮、高效。

📚 延伸阅读:React 官方文档 - Hooks


希望这篇文章能帮助你更深入地理解 React Hooks 的核心思想。如果你觉得有用,欢迎点赞、收藏并在评论区交流你的实践经验!

❌