jobhound-lite:把一个越写越重的数据采集工具做减法

之前想分析招聘市场的岗位数据分布,顺手写了个工具从几个主流招聘平台采集公开岗位数据。工具一开始越写越重——加了数据库、ORM、迁移、API 层……

某天想给朋友用,发现一个尴尬的问题:他只想输入城市和关键词、拿一份 Excel,却得先装数据库、跑迁移、理解一整套 schema。太重了。

于是我剥了一层皮,做了 jobhound-lite:没有数据库,没有 API,就一个命令行——装依赖、登录、采集导出,三步出结果。这篇文章记一下设计思路。

一、为什么不要数据库

完整版用数据库,是因为我需要增量:今天采一遍、明天再采一遍,看新增岗位、记录薪资变化。这要求持久化和去重指针。

但给别人用的场景完全不同——别人可能就跑一次,看看某个城市的岗位分布。这种场景下,数据库是负担,每次还得解释”数据在哪””怎么清空”。

所以 lite 版的核心决定:结果只活在内存里,跑完直接写 JSON 或 Excel 文件,不落地任何持久存储。代价是失去增量能力,但对一次性使用完全够用。

二、技术栈与多平台适配

整体用 Python 3.11+,依赖压到 5 个:DrissionPage(浏览器自动化)、typer(CLI)、rich(终端输出)、openpyxl(Excel)、selectolax(HTML 解析)。

浏览器层用 DrissionPage 复用系统 Chrome,不单独装 Chromium。几个平台的页面结构各不相同,适配策略也各异,这是工具最核心的部分:

  • 有的平台列表页每个岗位卡片里就内嵌了包含薪资/经验/学历等字段的 JSON,光列表页就能拿全结构化数据,不用进详情页。
  • 有的平台列表页卡片里同样带薪资、公司、城市、经验、学历、行业等字段,在解析层提取出来即可。
  • 有的平台列表页只有标题和链接,薪资和岗位描述(JD)都在详情页,需要逐个访问详情页解析。

也就是说,前两类走”列表直出”,第三类走”列表 + 详情页”。这种差异决定了各平台的数据完整度不同:走详情页的那个 JD 最全,列表直出的次之,有个别平台详情页访问受限,干脆不要 JD 正文。

三、去重与薪资解析

几个平台经常有同一个岗位重复发版——同一家公司的”前端开发”在多个平台都有,甚至同平台发两次。lite 版做了两层去重:

  1. 同平台:岗位 ID 相同的只留一个
  2. 跨平台:归一化后的”公司 + 标题 + 城市”相同的,按平台数据完整度排优先级,留数据最全的那条

标题归一化要去括号后缀——前端开发工程师(WZ)Java 开发(J10230) 这种工号后缀去掉后才能比对,不然同一个岗会因为标题带不带工号被当成两个。

薪资解析支持各平台的常见格式:15-25k1.5-2万8000-10000元/月15-25k·14薪。统一转成”千元/月”的整数上下限,方便后续筛选。过滤时用”触及语义”——15-20k 的岗位用 18 过滤会命中(区间触及 18k),而不是只有起薪 ≥ 18 才命中,这更符合”看这个岗位能不能给到 18k”的直觉。

四、限制

工具能跑,但不是万能的:

  • 只支持 21 个一二线城市。每个平台对城市有内部数字码,得手动映射,目前录了 21 个(上海/北京/深圳/广州/杭州/成都/南京/武汉/苏州/西安/重庆/天津/长沙/青岛/郑州/厦门/福州/济南/宁波/无锡/大连)。
  • 部分岗位的数值化薪资解析失败。原文保留,可人工补。
  • 个别平台没有行业/公司规模字段,结构差异所致。
  • 个别平台没有 JD 正文,详情页访问受限时的取舍。
  • 必须用有窗口的模式跑。采集时会弹 Chrome 窗口,不能完全后台。
  • 只适合本地手动跑,不要放自动化流程里。

五、怎么用

三步:

1
2
3
4
cd lite
uv sync # 装依赖
uv run jobhound-lite login <platform> # 登录(每平台一次)
uv run jobhound-lite crawl <platform> --city 成都 --keyword 前端 --pages 5 -o jobs.json

输出格式按文件后缀自动判断:.json 出 JSON,.xlsx 出 Excel。传 all 作为平台名会多平台串行采集 + 跨平台去重,一次出全量:

1
uv run jobhound-lite crawl all --city 成都 --keyword Java --pages 5 -o java.xlsx

输出的 Excel 字段:平台 / 岗位标题 / 公司 / 城市 / 区 / 薪资原文 / 薪资下限 / 薪资上限 / 年薪倍数 / 经验 / 学历 / 行业 / 公司规模 / JD / 详情链接。

写在最后

这个 lite 版剥皮的过程,让我对”自用工具”和”给别人用的工具”的区别有了更深的体会。自用可以堆功能,给别人用得做减法——把”我需要但别人不需要”的东西全砍掉。砍到最后只剩 1400 行代码、12 个文件,几个平台都能跑通,装一个依赖就能用。

工具先能用起来,比工具完美更重要。

把多个大模型塞进 Alfred:一次 Workflow 改造记录

按下 ⌥ Space,输入 ai 这段 curl 报错什么意思,回车。几秒后,回答直接在 Alfred 的 Text View 里流出来。

这就是我想要的 AI 入口:不打开网页,不找 tab,不切客户端,也不用每次换模型都重新组织问题。Alfred 已经是我每天最常用的启动器,所以这个项目的目标很直接,把常用的大模型接进 Alfred。

这不是一篇使用说明。它更像是一份开发记录:为什么要做这个 workflow,中间踩了哪些坑,它现在给我的工作流带来了什么方便,以及后面还能怎么长。

Alfred Text View 流式输出

多模型对比示例

我需要的是一个更短的 AI 路径

网页端当然好用。我日常也会打开 ChatGPT、Gemini、Qwen、Kimi 这些产品页面,它们各有适合自己的场景。

但网页不是我思考时的最短路径。Alfred 对我来说已经接近第二个 Finder,很多动作都是从那个输入框开始。问 AI 也应该一样,敲几个字,看一眼结果,然后回到正在做的事。

我刚开始用 Mac 时,就很容易被 Alfred 的 workflow 吸引。当时为了省几步操作,会用 JavaScript、Python 再配合 Alfred 的各种节点手搓小工具:查资料、处理文本、打开固定项目、拼接命令。那些 workflow 不复杂,但确实把很多重复动作从日常工作里拿掉了。

到了 AI 时代,这件事又变了。很多 workflow 已经不用完全自己手搓,只要把需求讲清楚,大模型基本能生成一个可用的骨架,再由我补上 Alfred 运行时里的细节。相比以前一边查文档一边拼节点,生成速度和实现难度都变了一个量级。

很多问题发生在写代码、查资料、读文档的中间。我只是想问一句:“这个错误更可能是哪一层的问题?”或者“翻译一下这段话?”如果每次都要切浏览器、找 tab、等页面加载,动作本身就开始打断思路。

另外,我刚好订阅了几个大模型的 Coding Plan,手里有 DeepSeek、MiMo、火山方舟、阿里云百炼这些 API key。既然 Alfred 已经是固定入口,把这些 API 接进去,比再多开几个网页更符合我的日常节奏。

所以我希望这个 workflow 做几件事:

  • 输入 ai 就能问。
  • 支持连续对话。
  • 能在几个国产模型之间切换。
  • API key、endpoint、模型名集中管理。
  • 失败时直接告诉我哪里错了,而不是空白轮询。

这就是项目起点。

从官方 Workflow 改,而不是从零写

我没有从零写。

Alfred 官方 ChatGPT Workflow 已经解决了几个关键问题:用 JXA 调 curl,解析 OpenAI 风格 SSE,通过 Text View JSON 流式刷新,保存多轮上下文,并支持清空、停止、复制回答。

这些单独看都不复杂,但组合在 Alfred 里就很容易出边界问题。既然官方版本已经跑通了 Alfred 这部分,我更应该基于它改,而不是重新造一个看起来干净、实际上没被 Alfred 打过的轮子。

官方 ChatGPT Workflow 源码

现在它能做什么

现在这个 workflow 叫 Alfred Multi-Model AI,这是整个workflow:

Alfred Multi-Model AI Workflow 全景

它把几个 OpenAI-compatible 的模型服务放进同一个 Alfred 入口:

  • DeepSeek
  • MiMo Token Plan
  • 火山方舟 Coding Plan
  • 阿里云百炼 DashScope
  • 还可以继续扩展

我可以用 ai 发起对话,用 aimodel 切换模型,用 aihist 查看和切换历史会话。Text View 顶部会显示当前模型,响应通过 SSE 流式回来,并带一点打字机式的显示效果。

它也保留了官方 workflow 里几个顺手动作:清空当前对话、复制上一条回答、停止生成。真正每天会用的工具,最后都要靠这些小动作省时间。

后来加的几个小细节,反而对体验影响很大:

  • 对话轮次之间加 --- 分割线。
  • 首包未返回时显示“正在生成,请稍候…”。
  • 网络错误、curl 错误、API 错误都直接显示。

工具用久了,影响心情的往往不是有没有大功能,而是失败时它是不是讲人话。

Alfred 真正麻烦的是运行时细节

我原以为主要工作是“换 endpoint”。

事实证明,endpoint 只是最容易的部分。

第一个坑是 JXA 和 Foundation 的桥接。脚本里曾经把 JavaScript 的 null 传给 Foundation 方法,真实请求路径一跑就报:

1
-[NSNull count]: unrecognized selector

这不是业务逻辑错,而是桥接层把“空值”变成了 NSNull。最后的修复也很朴素:需要空字典时就传 NSDictionary.dictionary,不要让 Foundation 猜。

第二个坑是 Text View 的 rerun。我一开始依赖 streaming_now 变量判断是否正在流式输出,后来发现 Alfred 的 rerun 环境不能完全按这个假设工作。结果是脚本每 0.1 秒都像新请求一样重置 stream 文件,界面一直停在空的 AI:

这里还有一个细节:Alfred 的 rerun 间隔有范围限制,低于 0.1s 会被忽略。我曾经把它设成 0.06,看起来像是在优化打字机效果,实际只是让 Alfred 直接拒绝执行。

后来改成用本地状态文件判断:

  • ai.stream 保存 curl 写入的响应流。
  • ai.pid 保存当前 curl 进程。
  • ai.query 保存本次问题。
  • ai.display_count 控制打字机显示进度。

这不花哨,但可靠。Alfred 每次 rerun 都是一次独立脚本执行,本地文件反而是更清楚的状态边界。

后来又补了一个失败路径:stream 里不一定总是 SSE。有时候里面只有 curl 的纯文本错误,如果解析器只认 data:,界面就会继续空白轮询。现在这类内容会直接显示成错误,至少我能知道该查 endpoint、DNS 还是 API key。

多模型不只是多几个配置

一开始我以为多模型只是四个字段:endpoint、API key、model、provider name。

做着做着发现,真正需要管理的是模型身份和会话身份。

比如切到 MiMo 后问“你是什么模型”,如果当前会话里残留 DeepSeek 的历史,模型很可能被上下文带偏。它不是接口错了,而是我把错误的历史喂给了它。

现在项目里有 sessions/index.jsonsessions/<id>.json。每个会话记录 provider、标题和更新时间。chat.json 仍然保留,作为当前会话的兼容镜像。

这里还有取舍。我试过“切换 provider 自动新建会话”,后来撤回了。因为有时候我确实想让另一个模型接着同一段上下文回答。更合理的做法,是以后在 aimodel 里提供两个显式动作:

  • 切换模型,保留当前上下文。
  • 切换模型,并新建会话。

行为应该让用户选择,而不是藏在脚本里猜。

它现在给我的方便

最直接的变化是少切上下文。

我可以在写代码、读文档、写说明的时候直接呼出 Alfred,输入 ai,把问题扔进去。如果想换个模型看回答差异,再用 aimodel 切一下。

它最适合这些场景:

  • 快速解释报错。
  • 让不同模型改写同一段话。
  • 比较 DeepSeek、MiMo、火山、百炼的回答差异。
  • 起草说明、提交信息、文章小节。
  • 问一些不值得专门打开网页的小问题。

还有一个意外的好处:它不会把我带进网页端的信息流。没有推荐,没有侧边栏,没有一堆历史卡片。它只回答当前问题。

这让我更容易把 AI 当工具,而不是当另一个工作场。

后面还能怎么扩展

这个 workflow 现在只是通用聊天入口,但它很适合继续拆出更小的专用入口。

我比较想做这些:

  • aitrans:固定翻译提示词,输出直译、自然表达、技术文档表达。
  • aidoc:先查常用技术文档,再让模型总结,减少胡说。
  • aireview:读取当前 git diff,输出变更摘要、风险点和测试缺口。
  • Universal Action:选中文件或文本后直接交给 AI,解释代码、总结文档或改写片段。
  • 剪贴板输入:空查询时自动读取剪贴板,少一步粘贴。
  • 个人知识库:索引本地笔记和文档,让回答基于自己的上下文。
  • prompt 预设:严格代码审查、中文博客编辑、技术方案反驳者、需求澄清助手。
  • 会话管理增强:按 provider 过滤、重命名、删除、导出 Markdown。

这些扩展不需要一个庞大的 AI 客户端。它们只需要在需要的时候,从 Alfred 里出现。

我怎么看这个小项目

这个 workflow 没有发明新技术。

它只是把几个常用模型入口放到 Alfred 里,再把流式显示、会话、模型切换、错误处理这些细节补到能日常使用。

但这类工具的价值就在这里。它不是为了展示 AI 能做什么,而是让 AI 出现在我真正需要它的位置。少一次浏览器切换,少一次复制粘贴,少一个“等会再问”的念头。时间长了,这些摩擦会决定一个工具到底会不会留下来。

这个项目也提醒我,个人工具可以做得快,但不能只靠“看起来能跑”。plutil 通过不代表 Text View 行为正确,JXA 能编译不代表 Foundation 桥接不会炸,API 能返回不代表 stream 解析器能看懂。

我现在更愿意把它当成一个小产品维护。功能可以少,但失败路径要清楚,配置要集中,状态要能解释。

对我来说,它已经跨过了“玩具脚本”的线。

烧 token 治不好 AI 焦虑

睡前想到今天的 coding plan 额度还没用完,我爬起来给 agent 派了个活,才安心去睡。

说出来有点荒诞——我居然怕”浪费”额度。但那阵子这就是我的日常。4 月左右,我订阅了一堆 coding plan,装了十几个 agent,听了几档 AI 播客,vibecoding 出好几个小工具。比上班还累,而且是一种说不清的累。

焦虑从哪来

大模型一个月迭代好几次,每次都比上一次强。agent 一个接一个冒出来,openclaw、hermes、codex……每一个都号称能替你干更多活。便利是真的,但便利背后跟着一句没说出口的话:能替你干活的,也能替掉你。

我是程序员,本来就是个得不断学习的行当。框架年年换,工具月月新,这份”学不动了”的疲惫感我熟。但 AI 把这个速度又拧快了一档。现在不只是学新框架,是连”用什么学”都在变——上个月还流行的工具,这个月就被另一个盖过去。一种很具体的恐慌冒出来:没用上最强大模型、没学最新那套 agent、没用上最新那把神器,是不是就已经落后了。

vibecoding这几个月,比上班还累

有一段时间,我把大量精力投进了 AI 工具。订阅各家 coding plan,装能装的 agent,听几档 AI 播客,vibecoding 出 CrossChat 这些小工具。

本来想”好好利用这段时间”,结果发现 vibecoding 比上班累。上班至少有边界——下班就是下班。vibecoding 没有边界,只要你愿意,agent 可以 24 小时给你干活。于是我就真这么干了。

会上瘾。指挥 AI 把脑子里的想法一个个变成真东西,那种快感很上头。每出一个新模型就想去试,每有一个想法就想丢给 agent 跑。我一边烧 token、一边 vibecoding,用这种方式缓解 AI 焦虑——你看,我在用、我在学、我没掉队。

但额度一刷新,焦虑就跟着来。睡前躺在床上,想着今天的额度没用完,不给 agent 找点事做就亏了。于是半梦半醒间还在派活。然后陷入另一种焦虑:我到底是在用 AI,还是被 AI 用。

哪个模型才是最强的焦虑

还有一种焦虑更细碎:到底用哪个模型。

顶尖模型太贵,订阅一个就吃掉不少预算,只能省着用在 plan、架构这种关键决策上。日常写代码、问问题,还是得靠网页版和那些 coding plan。可一打开网页版就犯纠结——同一个问题,是问 Claude 还是 GPT,是问 Gemini 还是 Kimi?

后来我慢慢理解了一件事:大模型的输出本身带随机性,同一模型同一问题,多问几次,答案可能不一样,回答质量也有波动。既然单次回答有波动,那就让几个模型对同一个问题各答一遍,再交叉对比,靠谱答案出现的概率就高得多。于是我经常让几个大模型针对同一个问题来一场多轮大乱斗,谁答得靠谱用谁。

但这太累了。在几个网页之间复制粘贴、对比、来回切换,本身就是一种焦虑放大器。后来我干脆 vibecoding 出 CrossChat——把在多个大模型间来回折腾的那堆操作收拢到一个地方,省掉复制粘贴和切换。焦虑没消失,但至少少了一层摩擦。

怎么让自己缓下来

慢慢我摸出几个让自己缓下来的办法。

承认它强,然后主动用。 AI 确实能带来很大提升,这是事实。但社会淘汰的不是”不会 AI 的人”,是”不主动拥抱 AI 的人”。所以与其焦虑,不如主动上手。焦虑是被动,拥抱是主动,差一个心态。

别迷信顶尖模型。 各家模型的差距在缩小,不少国产模型现在的 coding 体验已经很好,日常够用。顶尖模型留给关键决策就行,不必每个任务都追求”最强”,那既贵又累。

决策权始终在人手里。 AI 能给方案、能给选项,但给不了最终决策。一个架构怎么选、一段代码该不该上线、一个需求要不要接——这些还是得靠你自己的专业素养和判断力。AI 越强,人的判断力反而越值钱,因为它能替你做更多执行,但替不了你拍板。

信息源要挑。 AI 相关的内容我只听最顶尖那几档播客、看几个真正在做事的人。剩下大量贩卖焦虑的自媒体,是投机者不是创造者,他们的生意就是让你焦虑,直接忽略。

把焦虑性使用和真产出分开。 我后来意识到,睡前给 agent 派活那种,是焦虑不是产出。真产出是有目标、有交付的。现在我会给自己定边界:哪些时间是认真做东西,哪些时间是随便玩。随便玩可以,但不能假装它在代替学习。

不可能用上所有最新,建个稳定组合就够。 承认这件事之后轻松很多——挑一两个模型、一两个 agent 用透,比追着每个新出的工具跑要踏实。新东西出来看看就行,不必每个都上手。


焦虑这东西没法根治,AI 时代更不会消失。但我慢慢学会一件事:烧 token 烧不出安全感,真正的安全感来自我知道自己在做什么、为什么做。睡前给 agent 派活的那个我,其实不是在用 AI,是在用”忙碌”骗自己别怕。

现在我不那么怕了。不是因为 AI 停下来,是因为我开始分清,哪些是我的事,哪些只是噪音。

AI 时代的笔记选择:从囤积步骤到记录思考

前两天整理旧电脑,翻出一个几年前的笔记文件夹。打开一看:某框架的安装步骤、某个报错的解决方法、某工具的配置参数,密密麻麻几百条。我一条条看过去,发现一个尴尬的事——这些当年花时间记下来的东西,现在随便丢给一个 AI,回答得比我当年记的还全、还新。

这让我开始想一个问题:在这个年代,笔记到底还记什么、怎么记。

一、工具换了一茬又一茬

做程序员的,多少都有点”记录癖”。技术迭代太快,脑子记不住,只能靠记。

载体也一路在变。最早是纯文本,后来 markdown 一统江湖——代码人本来就活在标记符里,写起来顺手。

工具换得更勤:

  • 印象笔记、OneNote、有道云笔记——那个年代”网页剪藏 + 多端同步”的三件套
  • Typora——本地 markdown,所见即所得,写起来舒服
  • Notion、语雀——结构化、数据库、团队协作
  • Obsidian——现在最火的,本地优先、双向链接、插件生态

二、AI 改变了记录的目的

为什么这两年 Obsidian 越来越流行,甚至盖过 Notion 的势头?不是因为它更好看。

是因为两件事变了。

一是多端协作早就不是问题。云盘加本地文件,手机电脑随时同步,没必要再被绑在一个在线服务里。

二是 AI。Notion 的数据库再强,AI 也能给你搭一个。但 Obsidian 的本地 markdown 加双向链接,配合 AI,能干一件以前干不了的事——让 AI 读你所有的笔记,基于你自己的知识库回答问题

记笔记这件事,从”存起来以后查”,变成了”喂给 AI 当上下文”。

三、到底该记什么

这才是我想说的。

翻了翻十年前、五年前的笔记,大部分都是这三类:

  1. 技术栈介绍(某框架是什么、怎么用)
  2. 问题解决步骤(遇到某报错怎么办)
  3. 使用说明(某工具怎么配置)

现在看,这三类几乎全变成了”没有价值的文字”。不是它们错了,是 AI 比它们更全、更新、更准。你记的旧版本配置,今天问 AI 它会告诉你新版本的写法;你记的某个报错解法,AI 能直接复现你的场景给方案。

那 AI 时代到底该记什么?我的答案是:记 AI 给不了你的东西

  • 决策和理由——为什么选 A 不选 B,当时的取舍是什么。AI 不知道你的上下文。
  • 踩坑的场景——不是”怎么解决”,而是”在什么约束下、什么业务里遇到的”。解法会过时,场景不会。
  • 自己的判断和观点——对某个技术的看法、对某个趋势的预测。这是你的,AI 抄不走。
  • 知识之间的连接——A 和 B 怎么关联起来的,那个”灵光一现”的瞬间。

一句话:记”为什么”和”我怎么想”,不记”是什么”和”怎么做”

四、怎么记才跟上时代

顺着这个思路,记录方式也得变:

  • 少记操作步骤,多记决策上下文。步骤问 AI 就有,上下文只有你有。
  • 记问题本身,而不是答案。好问题比好答案活得久。
  • 让笔记能被检索、能被 AI 读取。本地 markdown、清晰的结构、打好标签,就是为了这一天。
  • 定期清理。那些”是什么””怎么做”的旧笔记,该删就删,留着也是噪音,还会干扰 AI 读你的知识库。

写在最后

笔记软件从印象笔记换到 Obsidian,工具在变,但真正变的不是工具,是记录的目的。

以前记录是为了”以后能查到”,现在记录是为了”让 AI 更懂我”。

囤积步骤的时代过去了。该记的,是那些只有你自己才能写下来的东西。

从六个浏览器标签到一个多模型工作台

一个很常见的场景:你想问一个稍微有点分量的问题——一段代码为什么慢、一篇论文靠不靠谱、一个产品决策该往哪走——于是打开 ChatGPT、Gemini、Kimi、Qwen、DeepSeek、豆包,把同一个问题复制粘贴六遍,等它们各自答完,再在六个窗口之间来回切,对比谁说得对、谁在胡说、谁给的例子能直接用。

这件事我干了几个月。最后决定写个东西终结它。

这就是 CrossChat。

它干什么

CrossChat 是一个桌面应用,把上面那几个(以及更多)大模型的网页塞进同一个窗口里。三件事:

  1. 画布:多个模型并排铺开,你聚焦其中一个,其余的留在余光里。左右箭头或滑动切换焦点,不用再开六个浏览器标签来回切。每个模型都还是它自己网页原本的样子,登录态、历史、插件都在。
  2. 广播:底下输入框写一次问题,一键发给所有正在用的模型。点完发送,你能看着六个页面同时开始打字——有人秒回,有人转圈,有人答到一半自己改主意。这种同时性,是开六个标签页给不了的。
  3. PK:答完后点一下,选一个裁判模型(或用 API),让它给所有答案打分、排名、给出胜负理由,结果卡片按冠军在前展示。裁判会先判断这是代码题、事实题、还是写作题,再用对应标准打分——代码看正确性和边界,事实看证据和幻觉,写作看是否切题。

仅此而已。它不替你决定哪个答案最好,只是把”开六个标签页复制六遍”压成一两次点击,再帮你把对比这件事结构化。

为什么是桌面应用

我没把它做成网页服务,也没做成 SaaS。原因很朴素:这些大模型的网页本身就需要登录态,把它嵌进一个本地窗口里,登录态、Cookie、历史都还是你自己的,CrossChat 不经手任何对话内容,也不存数据。PK 的结果关掉就没了。

它不是一个平台,不是一个评测网站,就是一个跑在你自己电脑上的工具。

真正难的部分

听起来简单,做起来全是坑。

坑不在”把网页嵌进来”,而在”让网页听话”。CrossChat 要替你往输入框里填问题、点发送、抓回答、切侧边栏——这些都是和第三方网页的 DOM 打交道。而每个大模型的网页底层都不一样。

输入框有的是普通文本框、有的是可编辑区域;有的用了 React,你模拟的输入事件它当没看见,非得走系统级键盘事件才会响应。侧边栏按钮在 A 模型上能正常开合,同一个动作在 B 模型上却打开了上传历史。

最让我长记性的一次:我想修一个模型的侧边栏切换,顺手用了一条”看起来差不多”的通用选择器,想着能省一段分支代码。结果在另一个模型上,这条规则没点到侧边栏,反而点到了”新建对话”——页面当场跳走了。

更隐蔽的是,有几家模型长得几乎一样(都是国产、布局类似),行为却相反:A 模型的侧边栏点一下”开”是真的开,B 模型同一个位置点”开”其实是关。我连续修好一家、弄坏另一家好几次,才承认”长得像”不等于”行为像”。

从那以后我给自己定了一条死规矩:没有 DOM 证据,不写选择器。改任何一家页面的交互前,先抓真实 DOM 结构,看清楚标签、属性、坐标、可见性,再动手。每家模型的逻辑单独隔离,谁也不连累谁。

这条规矩不是洁癖,是疤。

还有一件事我最终没做:自动判断”模型答完了没”。第三方网页的加载状态千奇百怪,误判比不判更烦人——你正想看答案,它啪地帮你开了 PK。所以第一版手动来,你觉得答完了就点。

现在到哪了

版本 0.1.0。手动 PK——由你决定什么时候答完了、可以开判了。已接入 ChatGPT、Gemini、Kimi、Qwen、Grok、DeepSeek、豆包、Kiro 等。

后面想做的:自动检测回答完成、PK 历史记录、更多模型。但都不急,先把现在的体验打磨稳。

写在最后

CrossChat 不打算取代任何一个模型,也不想做评测平台。它就是一个个人工具,解决一个我自己的、每天都会遇到的小烦心事:把同一个问题问六遍,然后人肉对比。

如果你也天天在六个标签页之间切,可以试试。

agent 跨进日常以后

4 月 那正是 openclaw 的”龙虾热”刚退、hermes 接棒的声音开始多起来的时候。我两个都装了,用到后来才明白——它们要解决的根本不是同一件事,而真正决定我留下哪个的,是另一件事。

年初那波龙虾热

OpenClaw(那只龙虾 🦞)是去年底今年初爆的。它破圈的点不是”又一个 AI 工具”,而是让大家发现:用即时通讯软件就能指挥一个 agent 在你电脑上干活。你在飞书、微信 里发一句”帮我把这个视频剪一下”,小龙虾就在你电脑上真的去干。再加上能给它装 skill、让它自己生成 skill,玩法几乎无限,于是一传十十传百,迅速破圈。GitHub 上几十万 star,几乎一夜之间。

我当时在它里面建了个房产顾问角色,挂一个视频剪辑技能,做房产短视频。openclaw 把”角色 = 人设 + 技能 + 产出”打包成工作空间,这套组织方式很清晰,用起来也确实新鲜。

hermes 为什么后来居上

今年 4 月 推出 Hermes。导火索是 Anthropic 切断了 OpenClaw 的 Claude 调用,给继任者让出了位置。但真正让我转过去、也让它后来居上的,是另一件事:稳定

openclaw 玩法花、破圈快,但跑长任务是个坑。一旦涉及大文件下载、视频剪辑、ASR 这种动辄几小时的活,它经常莫名其妙撂挑子——任务跑到一半就停了,优化提示词、改设置都救不回来。我那个房产短视频流程正好踩在这上面,深受其苦。hermes 不一样,同样的长任务能稳稳跑几个小时不挂。

有意思的是,hermes 初期其实短板明显:没打通即时通讯软件、没有官方 web 界面、本地网关功能也很少。按理说这些全是 openclaw 的强项,hermes 全没有。但它把两件事做到了极致——足够稳定,以及越用越懂你。它的自学习闭环会自动沉淀技能、优化记忆,用得越久越顺手;升级迭代也不像 openclaw 那样频繁出 bug。口碑一点点攒起来,现在增长势头已经压过 openclaw。

我把它们用在了哪

openclaw 那边,我主要跑那个房产顾问角色做短视频,正是这些长任务让我最终受不了。

切到 hermes 后,我把这个角色搬了过来。我没用一键迁移命令,而是让 hermes 自己读 openclaw 的角色资料,重新整理人设、挑能用的技能学过来——借这个机会把人设重做一遍。搬完又加了个基金顾问角色,两个常驻。然后接上飞书,不在电脑前也能从手机消息里触达它。看板和定时任务也搬进 agent 内部。

还有一件有意思的事:我做一个小工具时,没自己写提示词,让 hermes 写了一份详细 brief,再丢给另一个执行型 agent 去实现。hermes 在这里是策划,不是执行。

踩过的几个坑

大文件干扰角色学习。 让 hermes 读 openclaw 的角色资料时,里面夹着几个大视频文件,得专门叮嘱一句”自动忽略”。agent 不会自己过滤噪音,边界得你来画。

角色迁移不是复制粘贴。 即使让 hermes 自己学,人设也要重写、人格要合并、表达要规范化。那个视频剪辑技能还有 v1 到 v4 多个版本,得明确只学 v4。角色不能无痛迁移,是现在 agent 生态的真痛点。

agent 之间的交接也要磨合。 让 hermes 给别的 agent 写 brief,第一版对方没法参考源码,只能返工。agent 之间没有天然的交接协议,比想象中费来回。

它们不是一类东西,甚至不是竞争者

我早先总想拿 openclaw 和 hermes 比个高下,后来发现这是错的问题。

openclaw 是”网关优先”——它解决的是”我得到处都在”,一个网关接几十个渠道,路由到多个 agent。hermes 是”agent 优先”——它解决的是”我得在一件事上越做越好”,靠自学习闭环让单个 agent 越用越强。一个做广度,一个做深度。

有句话说得准:openclaw 把 agent 装进网关,hermes 把网关裹在 agent 外面。负载的东西不一样。架构上它们是不同层,严肃的用法是叠起来——openclaw 当外层路由和控制面,hermes 当里面那个会学习的执行专家,两者靠 ACP打通。不过在用户争夺上,hermes 靠稳定和自学习已经追平甚至反超,”不同层”是架构判断,不影响市场此消彼长。

openclaw、hermes 和 codex 各自的位置

codex 经常被拉进比较,但很多人对它的印象还停留在”写代码的 agent”,这其实低估了它。

codex 不只是写代码。它能设定时任务、能操作你的电脑、能当成一个高级 agent 来帮你处理各种事——能力面比”代码工具”宽得多。写代码只是它最出名的一面。

所以三者真正的区别在侧重,不在”谁专一谁全能”:openclaw 靠即时通讯破圈,强在多渠道触达和 skill 生态,弱在长任务稳定;hermes 强在稳定和自学习,越用越懂你;codex 是个能力全面、偏执行侧的 agent,写代码、定时任务、操作电脑都能干。

我现在的分工顺着这个来:要拆需求、写 brief、跑几小时的长流程找 hermes;要动代码、操作电脑、定时跑某些任务找 codex。让一个 agent 给另一个派活,比我自己下场写更准。

agent 这一年的几点想法

稳定性才是分水岭。 花哨的功能吸引你装上,但决定你留不留的,是它能不能稳稳跑完一个几小时的长任务。openclaw 破圈最快,却栽在长任务撂挑子上;hermes 初期功能少得多,靠稳定和迭代质量一点点把人拉过来。agent 时代,”靠谱”比”惊艳”更值钱。

自学习闭环是真正的换代。 以前的 agent 靠你手动喂记忆、写技能,hermes 这类开始自己沉淀——用得越久越懂你。这是从”agent 你配置”到”agent 自己长出来”的转折。

角色可移植性还是真痛点。 我从 openclaw 迁到 hermes,哪怕有迁移工具,人设重做、技能版本挑选还是费了不少手工。agent 生态各自为政,谁能把”角色便携”做透,谁就锁住用户。

agent 间分工在成型。 ACP 这类协议让 agent 不再各干各的,而是能互相调用——hermes 策划、codex 执行、openclaw 路由,这种小队协作比一个大而全的 agent 靠谱。未来不会是赢家通吃,而是各有所长的小 agent 编队。

边界还得人来画。 我那个房产角色一开始塞了视频、文档、下载目录一堆,最后得专门叮嘱忽略。agent 的能力不画边界,它就会被噪音淹没,反而变笨。


年初那波热潮退去后,留下来的不是最花哨的那个,是最先跨进日常、还能稳稳留下来的那个。于我,是 hermes 接上飞书、再没撂过挑子的那天。从那以后,问 AI 这件事,不再是我打开什么,而是它在那里。

从古法编程到 vibecoding:一个前端程序员的范式转变

我从移动端转到前端后,做了多年业务前端开发。前几年的工作流大致稳定:查文档、写代码、调 lint、修 type。从今年我开始用 Claude Code,工作流被打散重组,到现在我花在写 CLAUDE.md 上的时间比写 Vue 组件还多。这篇文章记录我最近的转变,不是教程,不是结论。

古法编程是什么样子

写一个 Vue 组件,传统流程是这样:

在 VS Code 里敲,Volar 实时标红——props 类型不对、模板里变量拼错、ref 忘了 .value。hover 一下看报错,改。跑 pnpm dev,浏览器控制台一堆 warn,v-for 没给 key、watch 依赖不对,再改。提交前 pnpm lint 又红一片,逐条修。改完跑 tsc --noEmit,再红一片。提交时 pre-commit hook 拦一次,CI 再拦一次。

这套流程的本质是:人写代码,工具兜底。lint、typecheck、test 是三条独立防线,每条都假设上游会出错。你跟工具博弈,它告诉你哪里不对,你改。

成熟、可教、可复制。问题是慢——一个组件从想法到合并,光在工具博弈上就能耗掉半小时。

vibecoding 是什么样子

现在我写一个 Vue 组件,流程变成这样:

CLAUDE.md 里写清楚项目用 Vue 3 + TS、<script setup> 优先、不写 Options API、import.meta.env 不许散布。在栈级 SKILL.md 顶部写一段《代码风格》:缩进 2 空格、单引号、分号必带、const 优先。在 eslint 配置里写好兜底规则。然后对 Claude 说:”写一个用户列表组件,分页,搜索框防抖 300ms。”

它写。我 review。它自己跑 lint、跑 typecheck、跑测试。hook 在它写完文件后自动 format,在它 git commit 前自动 lint。

本质变了:LLM 写代码,工具兜底,人写规则。我不再跟工具博弈,变成了给 LLM 写规则、给工具配兜底的人。CLAUDE.md 是给 LLM 的项目宪法,SKILL.md 是栈级风格指南,工具配置是兜底的硬墙。

转变落在三个地方

不是工具换代,是工种换位。

写什么:从代码到规则

组件代码由 LLM 生成,我写的是 CLAUDE.md、SKILL.md、工具配置。产出物从”代码”变成”约束代码的规则”。这听起来像退后一步,实际是上升一层——同一个 Vue 列表组件会在十个项目里被写十遍,但”v-for 的 key 必须稳定、禁止 index”这条规则只写一次。代码是消耗品,规则是资产。代价也真实:写规则要把模糊的”我觉得这样不对”显式成可执行的约束,比写代码难。

调什么:从工具链到 LLM 行为

古法调 lint 规则、调 tsconfig、调 vite。现在调 hook 触发时机、调 sub-agent 上下文边界、调 SKILL 触发词、调 MCP 组合。前者成熟——ESLint 怎么配、tsconfig 哪个字段干嘛、vite 怎么优化,社区有十年答案,senior 能讲半小时。后者没成熟——hook 什么时机触发不误伤、上下文边界画在哪、触发词怎么写不漏不误,没有标准答案,社区还在试。调工具链是工程,调 LLM 行为是驯兽。工程有规范,驯兽靠观察。

怕什么:从报错到静默

古法怕运行时 bug、怕 type 不对、怕 lint 红一片。这些错误有一个共同点:会响。控制台报红、CI 挂、测试失败——显式的,看得见就能修。vibecoding 最怕的是 LLM 静默写错。比如你写了一条风格规则,表面上看 LLM 每次都”遵守”了,你以为生效了。实际上那条规则字段名写错了,工具根本不认,LLM 写对是因为训练数据里见过类似风格——是默认值的功劳,不是你规则的功劳。规则形同虚设,但你不会查,因为”看起来在工作”。显式的报错逼你修,静默的错误让你以为自己不用修。古法的错误大声告诉你,vibecoding 的错误是温水煮青蛙。

找不到最佳实践

转变后第一反应是找参考:GitHub 上肯定有人做过 vibecoding 最佳实践仓库吧?搜一遍,高星的要么是机制说明,要么是演示性质的例子,没有一套敢说”我在生产项目跑了半年,团队 5 个人用,这些规则真触发了,那些没触发”。高星不等于经过验证。

这不是大模型问题,也不是 agent 问题,是领域本身还没成熟。vibecoding 现在的状态,像极了 Vue 3 Composition API 刚出来的那阵——<script setup> 还没落地,ref 还是 reactive 吵成一团,composables 怎么拆没人有定论。社区摸索了一两年才有共识。vibecoding 现在就在那个摸索期,只是还没人知道要摸多久。

重起炉灶的陷阱

我开始这套配置时反复陷入一个循环:做一版,不满意,重起炉灶做第二版,又不满意,再做第三版。LLM 让”重起炉灶”变得极便宜——一句话,半小时新仓库就起来了。

直到有一天我意识到:每一版的问题都不是”基础选错了”,而是还没在真实项目里踩过坑。配置里那个字段名写错、那条规则没触发、那个 hook 漏匹配——都不是设计问题,是没用过真实项目的副作用。重起炉灶会让我再次绕开这一步,换个目录名,问题原样复制过去,再加一层”这次肯定对了”的幻觉。

这是 vibecoding 时代最常见的陷阱:用再找一个更好的方案,来推迟开始真用的不适感。便宜的动作容易上瘾,上瘾的结果是永远停在原型阶段。古法时代重起炉灶成本高——重构一个 vite 配置能调三天,所以你会先把现有配置改透。现在这个摩擦消失了,副作用是失去”逼你深入”的外力。

现状的尴尬

承认一下:工具链不成熟(ESLint flat config 还在迁移期,eslint-plugin-vue 的某些规则也跟着调);最佳实践不存在(不是”我没找到”,是”行业还没有”);护栏覆盖不全(Claude Code 的 hook 只在它调 Bash 时触发,同事用 VS Code 提交按钮或终端直接 git commit,完全绕过)。这些不是 bug,是阶段。

古法教会我的事

古法 7 年,我以为学到的是 Vue、TypeScript、vite。转变后我才看清,古法真正教我的是三件事:

敬畏报错。 lint 红了、type 错了、测试挂了——这些显式的报错是朋友不是敌人。vibecoding 时代最大的风险是它不响了,所以我把工具链配得比以前更严,宁可噪音多一点,也不能让错误静默。

规则要显式。 古法时代很多团队默契是隐式的——“我们这里不这么写”。vibecoding 时代隐式默契失效,LLM 不读空气。必须把所有”我觉得这样不对”显式成可执行的规则,这逼着把团队的隐性知识沉淀成显性资产。

慢动作有价值。 古法查文档、看 type、调 lint 这些”慢”的动作,其实是加深理解的过程。vibecoding 时代 LLM 帮你跳过这些,效率上去了,理解没跟上。我现在刻意保留一些慢动作——比如重要决策先手写一遍思路再让 LLM 实现——就是为了不让自己变成只会审 AI 代码的人。

下一步

不是再找一个更好的方案。是开始用。

接到一个非关键项目里——不是主力 SaaS,是内部工具或 side project。跑一周。看哪些规则真触发了,哪些没触发。看同事用 VS Code 提交时护栏失效是不是真问题。看 strict 模式在真实老代码上是不是噪音过载。

撞墙。改。再撞。

vibecoding 最佳实践今天还不存在到能被”找到”的程度。它正在被几千个开发者各自摸索,我也是其中一个。这篇文章是我摸索的中途笔记,不是终点。

前端视角:NestJS 和 Spring Boot,我靠对比学习啃下两个后端框架

Spring Boot 在 Java 技术栈里的地位我早有耳闻——但凡跟后端同事聊两句,十有八九项目跑的就是它。一个前端写多了业务,总会好奇:这个”霸主”到底长什么样。我是带着这个好奇去学的,顺便想把自己对后端的理解往前推一截,以后看业务、和后端协作时心里更有底。

我没直接啃 Java,而是先学了 NestJS,因为它是 TypeScript,前端上手快。学完发现它和 Spring Boot 长得像得离谱——同样是装饰器满天飞,同样有个容器帮你管对象。于是我反过来用 NestJS 当跳板摸 Spring Boot,一个没写过 Java 的人,靠着这条路径也算摸出了它的大致骨架。

这篇记一下”对比学习”这套方法,以及这两个框架到底哪里相通、哪里只是看起来像。

先说方法:从已知映射未知

我不是第一次用这套方法。我是 Vue 技术栈出身,学 React 的时候,就是从 Vue 映射过去的:ref/reactive 对应 useStatecomputed 对应 useMemowatch 对应 useEffect。映射建立之后,新框架的大部分就不用从零学了,剩下真正陌生的部分才是要花时间死磕的。

对比学习的前提是:新旧两样东西在解决同一类问题。NestJS 和 Spring Boot 都是后端框架,都要解决”请求进来 → 路由 → 业务逻辑 → 数据访问 → 响应出去”这条链路。问题一样,解法就难免趋同,这给映射提供了土壤。

但也不是什么都能这么学。比如 Angular,我完全没碰过,手里没有任何老底可以映射,真要学也只能从零硬啃。能靠对比省力的,只是那些”我这边正好有对应经验”的部分——这次算我运气好,Nest 和 Spring 恰好是这种情况。

AI 让建映射变快了

以前建映射要自己两边翻文档,慢。现在我把已掌握的那个框架丢给 agent,让它当翻译:

  • “我是前端,熟 NestJS。请用 NestJS 的概念给我讲 Spring Boot 的依赖注入,每条都给出 NestJS 里的对应物。”
  • “写一段 Spring Boot 代码,故意用 NestJS 的习惯写错,让我找问题。”
  • “我理解了这个注入方式,请当面试官追问我三个会暴露理解偏差的问题。”

agent 建的映射表只是草稿,不能照单全收。它经常把”形似”当成”神似”——比如会把两边某个注解直接划等号,但细看生命周期、作用域这些细节,其实不是一回事。这种地方得自己动手写一遍代码才会露馅。

AI 加速的是”建立对照”这一步,不是”判断对照对不对”。判断还在人。

两个框架真正相通的地方

下面这些是我摸下来对应得最实的部分。说实话,最先让我”啊,原来是这个意思”的,是横切关注这一块——大概是因为它跟前端的中间件、拦截逻辑离得最近,一下就有画面感,反倒是 IoC 容器那套,我是回头才慢慢理清楚的。

横切关注:拦截器、守卫、管道 vs 过滤器、切面

列个自己整理的对照表:

关注点 NestJS Spring Boot
鉴权 Guard Filter / Spring Security
请求/响应转换 Interceptor Interceptor
参数校验/转换 Pipe @Valid + Converter
异常处理 Exception Filter @ControllerAdvice
横切日志 Interceptor 切面(@Aspect

两边的思路是一样的:不侵入业务代码,把”所有请求都要走一遍”的逻辑(鉴权、日志、参数校验)抽出来集中写,而不是散在每个业务方法里到处复制粘贴。前端写多了拦截逻辑(比如 axios 的请求/响应拦截器)对这套一点都不陌生,只是后端把它做得更彻底、更成体系。

理解这一层之后,后端同事说”加个切面统一处理”,我脑子里大概能对上号:NestJS 里我会写个 Interceptor,Spring 里他写个 @Aspect,落在请求链路上的位置差不多。

IoC 容器:东西放进盒子,要用时拿出来

NestJS:

1
2
3
4
5
6
7
8
9
10
@Injectable()
class UserService {
findAll() { return [...] }
}

@Module({
providers: [UserService],
controllers: [UserController],
})
export class AppModule {}

Spring Boot:

1
2
3
4
5
6
7
@Service
public class UserService {
public List<...> findAll() { return ...; }
}

@SpringBootApplication
public class App { ... }

@Injectable()@Service 干的是同一件事:告诉容器”这是个可被注入的东西”,写业务代码的人不用自己去 new,用的时候容器会把实例递过来。NestJS 靠 @Module 里显式列一份 providers 清单,Spring 靠扫描注解自动找——一个手动登记,一个自动收集,但目的都是”把能用的东西丢进一个池子里管起来”。这块概念我是先在 Nest 里对着代码跑通一遍,再回头看 Spring 才算真的理清楚,一开始光看文字说明是没懂的。

装饰器:声明式地把元数据钉在代码上

NestJS 的控制器:

1
2
3
4
5
@Controller('users')
export class UserController {
@Get(':id')
findOne(@Param('id') id: string) { ... }
}

Spring Boot:

1
2
3
4
5
6
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public User findOne(@PathVariable String id) { ... }
}

对照几乎是逐行的:用一个标记说”这是个路由”,用另一个标记说”这个参数从 URL 里取”,框架在运行时读出来帮你接线,你不用自己写解析逻辑。

我查资料的时候看到说 NestJS 这套装饰器风格是从 Angular 那边搬过来的——Angular 我没用过,具体渊源说不上来,但单看这种”用标记描述意图,让框架做机械活”的思路,跟 Vue 里 <script setup> 的一些编译期魔法、React 的高阶组件其实是同一个路数,前端看到不会太陌生。

生命周期钩子

NestJS 的 OnModuleInitOnApplicationBootstrap;Spring 的 @PostConstruct。都是”对象创建完/应用启动完”这类时机的回调。前端类比:Vue 的 mounted、React 的 useEffect,同一种”框架告诉我到点了”的机制,这个映射基本不用怎么想就能对上。

哪里只是看起来像

对比学习最容易翻车的地方,是只看到像、看不到不像。我刻意记了几条,免得自己回头拿 NestJS 的经验去套 Spring 出问题:

运行时不一样。 NestJS 跑在 Node 上,单线程,异步靠 async/await。Spring 跑在 JVM 上,是多线程模型。这意味着 NestJS 里一个请求处理阻塞了,会连累后面排队的请求;Spring 那边一个请求占一个线程,互相干扰小一些。写拦截逻辑的时候这个差异会直接咬人——NestJS 的拦截器里如果塞了同步的重活,容易拖慢整个服务。

依赖注入的实现方式不一样。 NestJS 靠 TypeScript 的装饰器 + 元数据,没标 @Injectable() 的东西是注入不进去的;Spring 靠 Java 的反射机制,能拿到的运行时信息更多。这个差异导致两边”动态注入一组东西”的写法不太一样,具体细节我也还在慢慢摸。

生态成熟度不一样。 Spring Boot 更像”全家桶”,数据层、安全、多环境配置基本都有官方现成的方案,跟着官方文档走大概率不会错。NestJS 更像”核心 + 自己拼”,数据库要自己在 TypeORM/Prisma 里选,鉴权要自己接 Passport——这点上反而更像前端的习惯,什么都得自己挑生态。Spring 官方给的答案多,NestJS 的自由度更高,两种风格没有绝对的好坏,适应成本不一样而已。

对比学习不是省力,是省时间

对比学习给我的不是”少学”,而是”学得快”。Spring Boot 那些核心概念本来就要学,但因为 NestJS 里我已经摸过一遍类似的东西,又是熟悉的 TypeScript,所以能借这条路径把 Spring 的骨架搭得快一些。真正要花时间死磕的,是那些映射不上、或者一开始映射错了的部分——而这部分往往才是真正值得学的。

AI agent 在这中间扮演的是”快速出题人 + 对照表初稿生成器”的角色。它能让我把两边的代码放在一起跑一遍看效果,省了不少来回翻文档的时间。但它给的所有对照我都得自己动手验一遍,因为它经常分不清”形似”和”神似”,草稿信不得,得自己较真。

后端同事再讲架构,我至少能接上一部分话了——不是因为我会写 Java 了,是因为我手里多了一张不算精确但能用的映射图。前端去啃一个陌生的后端框架,最大的收获不是多会一门语言,是多少看清了”一个请求在服务端到底经历了什么”。这张图反过来对我写前端也有点用:知道后端大概是 Controller 接请求、Service 写业务、Repository 管数据这么一路传下去之后,前端这边怎么组织接口调用层,多少更有谱一点。

给一个 Vue2 老项目做 Webpack 到 Vite 迁移

接手过老项目的人都懂那种感受:改一行代码,等热更新等到手停在键盘上发呆。我负责迁移的这个项目是一个跑了多年的广告投放前端工程,Vue2 + Webpack,模块一年年长出来,启动和热更新的等待时间也一年年长出来。

迁移到 Vite 不是为了追新。真实动机就两个:开发时的等待成本,和 Webpack 配置本身的维护成本——那份配置文件经手过很多人,loader 和 plugin 叠了一层又一层,没人敢动。

这篇记录迁移过程中真正花时间的几个地方。不是教程,更像一份”如果重来一遍,我会先看哪里”的清单。

Vue2 项目跑 Vite,第一件事是插件层

Vite 官方模板只认 Vue3。Vue2 项目要跑起来,得先解决 SFC 编译:

  • Vue 2.7+:用官方的 @vitejs/plugin-vue2
  • Vue 2.6 及以下:用社区的 vite-plugin-vue2

这一步看起来只是装个插件,但有个隐蔽的前提:template 编译器版本必须和运行时版本严格一致。Vue2 的 vue-template-compilervue 是成对的,版本对不上启动时会直接报错——报错信息倒是清楚,真正麻烦的是有些老项目锁版本的方式很随意,package.json 里写的和 lock 文件里装的不是一回事,先对齐版本再动手。

另外两个连带问题:

  • 项目里如果有 JSX 写法(Vue2 时代不少高阶组件这么写),要额外配 Vue2 的 JSX 插件,不配的话报错位置会出现在很奇怪的地方。
  • Element UI 的按需加载原来靠 babel-plugin-component,Vite 下这条路不通。我的做法是先全量引入把项目跑通,再换 unplugin-vue-components 的 resolver 做按需——迁移期一次只解决一类问题,不要把”能跑”和”跑得好”混在一个阶段。

环境变量:最容易”看起来没问题”的部分

Webpack 时代的习惯是 process.env.VUE_APP_XXX,Vite 的口径是 import.meta.env.VITE_XXX。改配置文件只要十分钟,真正的工作量在业务代码里。

我踩的最深的一个坑:老代码里埋点开关是用 process.env.VUE_APP_ENV 判断的,散落在多个业务文件里。迁移后构建能通过,页面也能打开——但 process 在浏览器里根本不存在,某些分支一执行就是 process is not defined。而且这种错误只在特定操作路径下触发,冒烟测试根本测不出来。

处理方式分两步:

  1. 过渡期先用 defineprocess.env.VUE_APP_* 整体映射掉,保证任何遗漏的读取点都不会炸;
  2. 再把散落的读取点逐步收敛到一个统一的 env 模块,业务代码只从这个模块拿配置。

这个坑给我留下的习惯是:迁移前先全局搜一遍 process.env,把所有出现位置列成清单。清单比记忆可靠。

require.context 和动态 require

Webpack 对模块加载很宽容:require()、运行时拼路径、require.context 批量导入,都能跑。Vite 基于 ESM,要求导入路径静态可分析——这是两种构建哲学的根本差异,也是迁移中真正要”改代码”而不是”改配置”的部分。

分三类处理:

  • require.context 批量导入(路由、store 模块、全局组件注册最常见):换成 import.meta.glob。注意它是编译期展开的,路径规则要写死,不能运行时拼。
  • 普通 require():能改的直接改成静态 import 或动态 import()
  • 运行时拼路径的 require(比如按接口返回的名字加载图片):这类没有银弹,要么用 import.meta.glob 预先建立映射表,要么把资源挪到 public 目录走 URL 引用。哪种合适取决于资源数量和更新频率。

第三方 CJS 依赖大部分靠 Vite 的依赖预构建就能吃掉,个别顽固的再用 alias 指到 ESM 版本或单独配置。我的经验是:不要一上来就给每个报错找插件,先分清是”项目代码要改”还是”依赖要兼容”,前者占的比例比想象中大。

静态资源和别名

这部分琐碎但不难,列几个必查项:

  • resolve.alias:把 Webpack 的别名逐条搬过来,注意 CSS 里 ~@/assets 这种带 ~ 前缀的写法 Vite 不认,要全局清理。
  • base:对应 Webpack 的 publicPath,部署在子路径或走 CDN 的项目,构建后资源 404 十有八九是它。
  • 写死的 /static 路径:老项目常见,统一梳理成 import 引用或挪进 public

最不能省的一步:回归

构建工具迁移最怕的不是报错,是”本地能跑、线上白屏”。开发环境 Vite 用 esbuild 做依赖预构建,生产构建走 Rollup——两条链路,某些依赖开发能跑、build 就挂是真实会发生的,所以 vite build 加产物预览必须纳入验证,不能看 dev server 正常就收工。

我当时的回归清单按业务主链路列:登录、菜单权限、核心业务页面、接口代理、静态资源、埋点上报、异常监控。埋点和监控最容易被漏——它们不影响页面显示,坏了没人第一时间发现,但对业务是暗伤。

迁移完,值不值?

说实话,我当时没有保留严格的启动/HMR 耗时对比数据,所以给不出”提升了 X%”这种数字——这也是复盘时最大的遗憾:下次做这类迁移,第一步应该是记基准数据,启动耗时、HMR 耗时、build 耗时、产物体积,前后各测一轮,收益才有据可查。

能确定的是体感:本地启动从”起身倒水”变成”眨眼”,热更新基本无感,配置从一份没人敢碰的遗产变成一份看得懂的声明。对一个还要继续维护多年的老项目,这笔账是划算的。

最后一点心得:老项目迁移的难度不在 Vite 本身,在于你得先弄清楚这个项目这些年积累了哪些”隐性约定”——哪些环境变量在哪些地方被读、哪些资源路径是写死的、哪些依赖是 CJS。迁移工具链的过程,本质上是把这些隐性约定一条条显性化的过程。工具换完,项目也顺便被重新理解了一遍。