搞了快十年这行,我发现很多团队聊到前端工程化,嘴上全是架构、微前端、跨端方案,但真正每天折磨业务同学的,往往是些很不起眼的小破事。
比如:图标库更新了,设计师甩过来 50 个 SVG,谁去手动拷到项目里并转成组件?发版本的时候,四个相关的子包依赖版本号又漏改了一个;或者本地搜一个被弃用的废弃组件,几十万行代码的 Monorepo 能把编辑器的全局搜索直接干死。
开源社区的轮子确实多,但总有那么 20% 的团队定制逻辑,开源工具照顾不到。这时候,自研工具就成了必经之路。今天聊聊我自己这几年折腾内部 CLI 和辅助工具的经验,少谈点大词,多看点落地的代码。
1. 别一上来就搞“可视化平台”,先从一个能跑的脚本开始
很多工程师有个毛病,想做工具先想画大饼,动不动就要搞个微服务加 Web 控制台。其实工具开发的第一步永远是:解决一个高频痛点,越简单越好。
拿我们组件库的图标构建举例。最开始大家也是手写 React 图标组件,累且容易漏属性。我们第一版工具就是一个只有几十行的 Node.js 脚本,直接读取目录下的 .svg,跑一遍 SVGR 然后吐出 .tsx。
// scripts/build-icons.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { transform } from '@svgr/core';
const ICON_DIR = path.resolve('packages/icons/svgs');
const OUT_DIR = path.resolve('packages/icons/src');
async function run() {
const files = await fs.readdir(ICON_DIR);
for (const file of files) {
if (!file.endsWith('.svg')) continue;
const svgCode = await fs.readFile(path.join(ICON_DIR, file), 'utf-8');
const componentName = file.replace('.svg', '').replace(/^[a-z]/, c => c.toUpperCase());
const jsxCode = await transform(
svgCode,
{ icon: true, typescript: true },
{ componentName }
);
await fs.writeFile(path.join(OUT_DIR, `${componentName}.tsx`), jsxCode);
}
}
run();
写完丢在 package.json 的 scripts 里面,设计师切好图,丢到文件夹里跑一下 pnpm gen:icons,立刻省下半小时。工具开发的收益,在这个极简阶段是最高的。
2. 规范化:从散装脚本到真正的 CLI
当你的脚本越写越多——图标生成、多包发版校验、Changelog 抓取,脚本之间到处是复制粘贴的 path.resolve 和参数处理,维护成本就上来了。
这时候就需要把它规范成一个真正的 CLI。做前端 CLI 我个人极其讨厌臃肿的框架,像 Commander 虽好但略重,我自己更喜欢用 cac,体积小、类型推导舒服,配合 tsup 打包,爽快得很。
在工程化这块,最大的坑通常不是逻辑本身,而是 CLI 交互体验和容错。比如你的参数校验:
// src/index.ts
import { cac } from 'cac';
import { checkDependencies } from './commands/check';
const cli = cac('frame-cli');
cli
.command('check-deps <dir>', '检查目录下的幽灵依赖')
.option('--fix', '自动修复 package.json')
.action(async (dir, options) => {
try {
await checkDependencies(dir, options);
} catch (err: any) {
// 很多人写 CLI 喜欢直接 throw err,屏幕上一堆调用栈
// 对使用者来说,友好的错误信息远比 stack trace 有用
console.error(`\x1b[31m[Error]: ${err.message}\x1b[0m`);
process.exit(1);
}
});
cli.help();
cli.version('1.0.0');
cli.parse();
另外吐槽一句,很多团队自研 CLI 时,喜欢用 ora 搞一大堆花里胡哨的 Loading 动画,CI 环境里直接刷出来几百行乱码。写 CLI 务必加上 CI 环境判断,如果在无交互终端里跑,老老实实打静态日志就行。
3. 当 Node.js 变慢时:顺手用 Golang 救个场
我是做前端起步的,但近几年特别喜欢 Golang。原因很简单:单一二进制、交叉编译省心、并发处理文件快到飞起。
去年我们要做一个大仓全量扫描工具,需求是顺着几万个业务文件,找出所有跨包引用的未上报组件,顺便分析废弃 API 的命中率。最开始用 Node.js 的 fast-glob + Babel 遍历 AST,跑一次要将近 40 秒,放在 Pre-commit 或者本地 Watcher 里,业务同学直接摔键盘骂娘。
后来这块逻辑我用 Go 重写了底层的文件匹配与正则初筛,核心思路特别简单:把重度 IO 和多核并发交给 Go,筛选出候选文件后再给前端工具链去解析。
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// 并发扫文件中的特定组件调用
func scanFile(path string, target string, wg *sync.WaitGroup, results chan<- string) {
defer wg.Done()
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 1
for scanner.Scan() {
if strings.Contains(scanner.Text(), target) {
results <- fmt.Sprintf("%s:%d", path, lineNum)
}
lineNum++
}
}
func main() {
var wg sync.WaitGroup
results := make(chan string, 1000)
target := "OldButton" // 比如我们要找过期的按钮组件
filepath.Walk("packages", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".tsx") {
return nil
}
wg.Add(1)
go scanFile(path, target, &wg, results)
return nil
})
go func() {
wg.Wait()
close(results)
}()
for res := range results {
fmt.Println(res)
}
}
几万个文件扫一遍,从原来的 40 秒直接干到 1.5 秒。编译成跨平台的二进制文件之后,用 Node.js 的 execFile 直接调用,或者直接把它打包分发给团队。大家感知到的就是一个字:快。
前端工程化并不意味着技术栈只能被死死限制在 JavaScript 里面。Esbuild 和 Turbopack 已经证明了这一点,只要能解决业务性能卡点,引入合适的工具语言是迟早的事。
4. 工具开发的终点:别让工具变成团队的负担
折腾了这么多内部工具,我最大的教训只有一条:少折腾开发者,多帮业务省时间。
以前我也犯过技术自嗨的毛病,恨不得加一堆花哨的 Hook、强制的规则拦截,甚至把构建流搞得像迷宫一样复杂,最后业务线同学遇到问题根本排查不动,天天拉着我调试。
一个好的工程化工具,应该具备三个特质:
- 静默且高效:不该报错时别刷存在感,耗时必须对得起它的产出。
- 渐进式接入:别搞一刀切,允许业务项目一段一段地把旧流程迁移过来。
- 失败时讲人话:如果依赖校验失败,直接告诉他「你缺了什么包,执行什么命令能修」,而不是抛一个包含 20 层的内部报错。
工具是写给人用的,不管是 Node 还是 Go,能让团队早点打卡下班的工具,才算没白写。
License: CC BY-NC 4.0
Updated 8 hours ago
Was this article helpful? Give it a like.
0 comments


