引言
*.md、src/**/*.ts、.gitignore 里的 node_modules/——glob 匹配无处不在,但它和正则有个最常被忽略的差异:glob 匹配"文件名路径",正则匹配"任意文本"。本文把 glob 讲透:先讲基础通配符(* ? [])与 Shell 展开的机制,再讲与正则的本质分野(锚定语义、** 递归、大小写),接着讲跨语言差异(gitignore 的特殊规则、Python glob/fnmatch、Node fast-glob),再讲 extglob/brace 展开这些进阶语法,最后给性能陷阱(隐藏文件、重叠匹配、大目录)与配置文件 glob 的最佳实践。
前置:/regex-deep-dive/(正则的完整机制,用于对照)、/others-big-o-complexity-guide/(匹配算法复杂度)。命令行工具链见 /text-processing-toolkit/。
目录
- 1. 什么是 Glob:文件名匹配 DSL
- 2. 通配符基础:星号、问号与中括号
- 3. 与正则的本质差异
- 4. ** 递归匹配:目录树的 glob
- 5. gitignore 的特殊规则
- 6. 跨语言落地:Python、Node 与 Shell
- 7. 进阶语法:extglob 与 brace 展开
- 8. 性能与陷阱:隐藏文件、重叠与大目录
- 9. 配置文件 glob 最佳实践
- 10. 速查表与一句话记忆
- 延伸阅读
1. 什么是 Glob:文件名匹配 DSL
Glob 是一套"匹配文件名/路径"的小语言,出现在 Shell(*.py)、.gitignore、find、构建工具、CI 里。
它和正则的分工:
正则:匹配"任意文本内容"(正则表达式 → 字符串任意位置)
Glob:匹配"文件名/路径结构"(模式 → 整条路径)
为什么不能用正则替换 glob:
- 语义锚定:glob 天然"匹配整条路径",正则默认子串匹配(要 ^ $ 包)
- 路径感知:glob 能区分"/"与文件片段,正则没有路径概念
- 可读性:*.md 比 ^.*\.md$ 直观得多
一个关键事实:很多语言把 glob 编译成正则来实现(Python fnmatch.translate、Node 的 minimatch)——但语义约定(锚定、路径、**)由 glob 规则决定,正则只是底层实现。
记忆:glob 是"路径 DSL",正则文本 DSL——glob 天然锚定整条路径,这是它比正则更适合文件名匹配的原因。
2. 通配符基础:星号、问号与中括号
三个核心通配符:
| 通配符 | 含义 | 例子 |
|---|---|---|
* | 任意多个字符(含 0 个) | *.md → a.md、index.md |
? | 恰好一个字符 | a?c → abc、axc |
[...] | 字符集合/范围 | [a-c]*.ts → a.ts、b.ts、c.ts |
[!...] / [^...] | 排除集合 | [!a]* → 不以 a 开头 |
关键语义:* 不跨越目录分隔符:
*.md # 只匹配当前目录的 .md(不匹配 sub/x.md)
# Shell 展开
echo *.md # 展开成匹配到的文件列表
ls src/*.ts
集合语法:
[abc] → a 或 b 或 c
[a-z] → 小写字母
[0-9] → 数字
[!a-z] → 非小写字母
[[] → 字面量 [
Shell 展开 vs 程序内 glob:Shell(bash/zsh)会在执行命令前展开通配符(由 shell 自己展开),而 find/python 等是程序内部解释——这一点经常造成"为什么 find 的 * 没展开"的困惑(find 的 -name 是它自己匹配的)。
find . -name "*.md" # find 自己匹配(引号防止 shell 展开)
ls *.md # shell 展开后再传给 ls
记忆:
*管"任意字符、不跨 /"、?管单字符、[]管集合——先分清是 shell 展开还是程序匹配。
3. 与正则的本质差异
五点对照:
| 维度 | Glob | 正则 |
|---|---|---|
| 锚定 | 整条路径(天然 ^$) | 子串匹配(默认) |
| 分隔符 | 路径感知,* 不跨 / | / 只是普通字符 |
| 递归 | ** 匹配多层目录 | 无对应(要自己写 (?:[^/]+/)*) |
| 大小写 | 与平台/实现相关 | 默认敏感 |
| 转义 | 特殊字符较少(* ? [ ]) | 大量元字符要转义 |
“同模式不同语义"的经典例子:
import re, fnmatch
pattern = "a*"
# 正则:匹配"含 a 后跟任意"的子串
print(re.match("a*", "baaaa")) # 匹配空(开头没有 a)
print(bool(re.search("a*", "baaaa")) ) # 匹配到子串 "aaaa"
# glob:匹配"整个名字以 a 开头"
print(fnmatch.fnmatch("baaaa", "a*")) # False
print(fnmatch.fnmatch("apple", "a*")) # True
互转方向:glob → 正则(fnmatch.translate);正则 → glob 一般不成立(正则表达力远超 glob)。
import fnmatch
print(fnmatch.translate("*.md"))
# '(?s:.*\\.md)\\Z' ← 实现上锚定到末尾
踩坑:把正则习惯带进 glob——\. 在 glob 里就是字面量点(不需要转义),[.] 反而会匹配点;反之把 glob 当正则会漏掉 ^/+/() 等语法。
记忆:glob 天然锚定、路径感知、有
**;正则表达力强但要自己锚定——用错场景就是’为什么 *.md 匹配了子目录/没匹配到’。
4. ** 递归匹配:目录树的 glob
** 是 glob 相对正则的独门语法:匹配任意深度**的目录。
src/**/*.ts → src/a.ts、src/ui/button.ts、src/ui/x/y.ts
**/*.md → 任意层级的 .md
src/**/test.ts → src/test.ts、src/ui/test.ts
跨实现的一致性坑:
- Python glob:`**` 只在 recursive=True 时生效
- Node fast-glob/minimatch:`**` 默认生效
- Shell:`**` 需 shopt -s globstar(bash)
- gitignore:`**` 有特殊规则(见下节)
注意:src/** 是否匹配 src 本身、** 是否匹配隐藏目录,各实现不一致——跨工具迁移要重测。
shopt -s globstar # bash 开启递归通配
echo **/*.md # 递归列出 md
import glob
glob.glob("src/**/*.ts", recursive=True) # Python 要显式 recursive
记忆:
**= 任意深度目录;不同实现默认行为不同(Python 要 recursive、bash 要 globstar)——迁移时按实现验证。
5. gitignore 的特殊规则
.gitignore 是 glob 的"方言”,规则最容易踩坑:
node_modules/ → 匹配任意层级的 node_modules 目录(末尾 / 限定目录)
*.log → 任意层级的 .log 文件(* 不跨 / 但模式可出现在任意位置)
/build/ → 仅根目录的 build
a/b → 仅根下 a/b(无前导 / 的含斜杠模式锚定到根)
gitignore 四大特殊点:
1. 尾斜杠 = 只匹配目录
2. 无斜杠模式 = 任意层级(*.log 匹配 a/b.log)
3. 含斜杠模式 = 相对根(build/ 只匹配根 build/)
4. 通配符里的 * 不匹配 /,但 ** 可以跨层级
否定规则:
!important.log → 重新包含被忽略的
*.log
!important.log → 忽略所有 .log 但保留 important.log
# 注意:无法重新包含父目录被忽略的文件
实践检查:
git check-ignore -v path/to/file # 看哪条规则命中
git status --ignored # 查看被忽略清单
一个经典坑:*.log 用 glob 直觉是"仅当前目录",但 gitignore 里它是递归任意层级——这就是"为什么子目录的日志也被忽略了"的真相。
记忆:gitignore 方言四规则——尾斜杠限定目录、无斜杠递归任意层、含斜杠锚定根、
!重新包含;用git check-ignore -v验证命中规则。
6. 跨语言落地:Python、Node 与 Shell
Python glob(文件名遍历):
import glob
glob.glob("*.py") # 当前目录
glob.glob("src/**/*.py", recursive=True) # 递归
glob.glob("[a-c]*.py") # 集合
# 返回按实现排序的实际文件路径
Python fnmatch(单一名字匹配):
import fnmatch
fnmatch.fnmatch("report2026.md", "report*.md") # True
fnmatch.filter(files, "*.md") # 过滤列表
# 注意:fnmatch 处理的是"名字",不含路径语义(* 会跨 / !)
关键差异:fnmatch 的 * 跨 /、glob 的 * 不跨——一个匹配"名字"、一个遍历"路径"。
Node fast-glob / minimatch:
import fg from "fast-glob";
const files = await fg("src/**/*.ts", { onlyFiles: true, ignore: ["**/*.spec.ts"] });
// minimatch(单名字匹配,语义接近 fnmatch)
import mm from "minimatch";
mm("src/ui.ts", "src/*.ts"); // true
Shell:
ls *.md; echo src/**/*.ts # globstar 才递归
find . -name "*.md" -type f # find 自身匹配
选型速查:
| 需求 | 用 |
|---|---|
| 遍历文件系统 | Python glob / Node fast-glob |
| 单名字匹配 | fnmatch / minimatch |
| 忽略规则 | gitignore 风格库(gitignore-parser) |
| Shell 交互 | Shell 通配 + globstar |
| 跨工具一致 | 用同一库统一(避免方言差异) |
记忆:遍历用 glob、单名用 fnmatch/minimatch、忽略走 gitignore 库——’ 跨不跨 /’ 是实现差异的最大分水岭*。
7. 进阶语法:extglob 与 brace 展开
Brace 展开(brace expansion)——Shell 先展开、生成多个模式:
echo {a,b,c}.md # a.md b.md c.md
cp src/{index,main}.ts dest/
echo {1..5}.txt # 1.txt ... 5.txt
# brace 展开发生在通配之前:{a,b}.md → a.md b.md 再分别匹配
extglob(扩展通配)——bash/zsh/ksh 的增强语法:
shopt -s extglob
@(a|b).md # 恰好 a 或 b
+(a|b).md # 一个或多个
*(a|b).md # 零个或多个
?(a|b).md # 零或一个
!(a|b).md # 非 a 也非 b
extglob 与正则的对应:
@(a|b) ≈ (?:a|b)
+(x) ≈ x+
*(x) ≈ x*
?(x) ≈ x?
!(x) ≈ 负向前瞻
注意:extglob 是 Shell 特有,Python glob/gitignore 不支持(它们有各自的字符集语法)。跨平台用 fast-glob 的 extglob: true 选项可启用。
记忆:brace 展开是 shell 预展开({a,b} → 多模式)、extglob 是 or/重复语法(@(+?*!))——跨实现支持不一,别默认可用。
8. 性能与陷阱:隐藏文件、重叠与大目录
隐藏文件(dotfiles):多数 glob 默认不匹配 . 开头的文件:
echo *.md # 不匹配 .foo.md
echo .* # 匹配隐藏文件(但也会匹配 . 和 ..)
# Python glob 也不匹配隐藏文件,除非显式 .*
重叠匹配与排除:
src/**/*.ts 与 src/*.ts 重叠 → 用 ignore 去重(fast-glob 的 ignore)
排除构建产物:src/**/!(*.spec).ts
大目录性能:
递归 glob 要遍历全目录树 → 大仓库(node_modules/万级文件)耗时秒级
优化:限定深度(/*/*.ts)、用 ignore 剪枝(fast-glob 的 ignore 会跳目录)、只 files/只 dirs
避免:把整个仓库 **/* 再 filter
# 陷阱:recursive glob 会遍历整个树
for f in glob.glob("**/*", recursive=True): # 百万文件 → 慢
if f.endswith(".py"): ...
# 优化:直接用有剪枝的库或限制深度
三个高频坑:
□ 以为 * 匹配隐藏文件 → 漏掉 .env/.gitignore 类配置
□ 以为 ** 在 Python 默认开启 → recursive=True 忘传
□ 大目录递归没 ignore 剪枝 → 扫描卡死
记忆:glob 默认跳过隐藏文件、递归要显式开启、大目录要用 ignore 剪枝——三个高频坑记住了,glob 就不翻车。
9. 配置文件 glob 最佳实践
写配置文件(.gitignore、CI、构建、eslint/tsconfig 的 include)时的规范:
□ 路径语义明确:*.md 到底想匹配"当前目录"还是"任意层级"?
明确就写 ./*.md(仅当前)或 **/*.md(任意)
□ 目录用尾斜杠:dist/ 表示目录本身及内容
□ 排除用 ! 且放在忽略规则之后
□ 用 .md 而非 .MD(跨平台大小写?gitignore 默认敏感)
□ 别用脆弱模式:src/**/* 在很多实现里匹配"目录本身"
示例:一套"只要源码、不要构建"的忽略:
node_modules/
dist/
build/
*.log
!important.log
**/*.spec.ts
CI/构建的 include 实践(TypeScript/Vite 等):
// tsconfig.json include 用相对、明确
"include": ["src/**/*.ts", "vite.config.ts"],
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
验证习惯:
git check-ignore -v dist/index.html # gitignore 命中检查
# 用 dry-run 工具模拟 glob(fast-glob/fnmatch)验证配置
记忆:配置文件 glob 三件套——明确当前 vs 任意层级、目录加尾斜杠、排除用 ! 放后面;写完用 check-ignore/模拟工具验证,别靠感觉。
10. 速查表与一句话记忆
全篇速查:
| 语法 | 含义 |
|---|---|
* | 任意字符(不跨 /) |
** | 任意深度目录 |
? | 单字符 |
[abc] / [a-z] | 集合 / 范围 |
[!a] | 排除集合 |
{a,b} | brace 展开 |
| `@(a | b)` |
| 尾斜杠 | 限定目录 |
! | gitignore 重新包含 |
| 需求 | 用 |
|---|---|
| 遍历文件 | Python glob / Node fast-glob |
| 单名匹配 | fnmatch / minimatch |
| 忽略规则 | gitignore 方言 |
一句话记忆:glob 是路径 DSL、正则文本 DSL——* 任意不跨 /、** 任意深度、? 单字符、[] 集合;gitignore 方言特殊(尾斜杠目录、无斜杠递归、含斜杠锚根、! 重含);Python glob 递归要 recursive、shell ** 要 globstar、extglob/brace 是 shell 专属;默认不匹配隐藏文件、大目录要 ignore 剪枝——先分清 shell 展开还是程序匹配,再用 check-ignore 验证,glob 就是最顺手的文件选择器。
延伸阅读
- /regex-deep-dive/ — 正则与 glob 的分野(文本 vs 路径)
- /text-processing-toolkit/ — 命令行文本处理工具链
- /others-big-o-complexity-guide/ — 递归匹配的复杂度
- [[devops]] — CI/构建配置里的 glob 实践
- [[golang]] — 各语言文件匹配库的差异
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。