通配符与 Glob 匹配:与正则的分野与落地

系统讲解 glob 模式匹配:通配符基础(*/?/[])、与正则的本质差异(锚定 vs 子串)、跨语言的 glob 库(gitignore/fnmatch/glob)、extglob 与 brace 展开、性能陷阱(隐藏文件/递归/overlap)、以及配置文件的 glob 最佳实践与常见坑。

引言

*.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

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 展开
`@(ab)`
尾斜杠限定目录
!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]] — 各语言文件匹配库的差异

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. 算法复杂度速查:Big-O、空间复杂度与工程直觉
  2. 正则表达式深层解析:引擎、回溯与灾难性回溯
  3. 标识符设计:UUID v4/v7、ULID、雪花算法与工程权衡