- 前端
- 搜索引擎
【免费下载链接】Fuse
Lightweight fuzzy-search, in JavaScript
Fuse.js 是一个零依赖的轻量级 JavaScript 模糊搜索库,核心解决"输入错误或拼写不完整时仍能命中目标"的搜索需求。本文以本仓库文档站首页(docs/index.md)为主线,完整展开其快速上手、模糊搜索(Bitap 算法)、分词搜索、扩展搜索操作符与逻辑查询五大能力,并结合仓库源码说明各配置项的默认值与底层原理。读完后,你将能在浏览器、Node.js 与 Deno 中搭建一套支持错别字容错、多词查询、精确过滤的结构化搜索方案。
一分钟快速上手
Fuse.js 的使用极其简洁:构造Fuse实例时传入文档数组与索引键(keys),随后调用search()即可得到按相关度排序的结果。
import Fuse from 'fuse.js' const books = [ { title: "Old Man's War", author: 'John Scalzi' }, { title: 'The Lock Artist', author: 'Steve Hamilton' }, { title: 'JavaScript Patterns', author: 'Stoyan Stefanov' } ] const fuse = new Fuse(books, { keys: ['title', 'author'], includeScore: true }) fuse.search('jon') // [{ item: { title: "Old Man's War", author: "John Scalzi" }, refIndex: 0, score: 0.25 }] fuse.search('patterns') // [{ item: { title: "JavaScript Patterns", ... }, refIndex: 2, score: 0.0 }]注意两个细节:查询'jon'能命中'John Scalzi'(错字容错),且得分0.25;而'patterns'的完美命中得分是0.0。Fuse.js 的分数范围是 0~1,0 表示完美匹配,1 表示完全不匹配,refIndex指向该文档在原数组中的下标。
首页列出的核心特性可归纳为四层能力,后续章节逐一展开:
- 模糊搜索:基于 Bitap 算法的容错匹配;
- 分词搜索:把多词查询拆成词元逐个模糊匹配,并用 IDF 加权排序;
- 扩展搜索:支持精确、前缀、后缀、排除、包含等 unix 风格操作符;
- 逻辑搜索:用
$and/$or表达式构造结构化查询。
此外还支持加权键(boost 指定字段)、嵌套搜索(点号/数组记法或自定义getFn)、零依赖跨端运行,以及完整版(约 8.6 kB gzip)与基础版(约 6.8 kB gzip)两种构建产物。上述体积数据与特性清单均出自 docs/index.md 与 docs/getting-started.md,当前仓库版本为 7.4.2(见 package.json)。
安装、导入与构建选择
包管理器安装
npm install fuse.js也支持 pnpm、yarn 与 bun:
pnpm add fuse.js yarn add fuse.js bun add fuse.js模块导入同时支持 ESM 与 CommonJS 两种风格:
// ESM import Fuse from 'fuse.js' // CommonJS const Fuse = require('fuse.js')仓库 package.json 的exports字段定义了多入口映射:fuse.js默认指向 ESM 的dist/fuse.mjs与 CJS 的dist/fuse.cjs,require与import可自动解析到对应格式。零依赖意味着安装后没有传递依赖负担,包体即为全部代码,且sideEffects: false声明了纯模块语义,便于摇树优化。
两种构建:完整版与基础版
| 构建 | 包含能力 | gzip 体积 |
|---|---|---|
| 完整版(Full) | 模糊 + 扩展 + 逻辑 + 分词搜索 | 约 8.6 kB |
| 基础版(Basic) | 仅模糊搜索 | 约 6.8 kB |
导入路径与构建文件对应关系如下:
// 完整版(默认) import Fuse from 'fuse.js' // 基础版 import Fuse from 'fuse.js/basic' // 压缩变体 import Fuse from 'fuse.js/min' import Fuse from 'fuse.js/min-basic'package.json 中./basic、./min、./min-basic三个子路径出口与之一一对应,构建文件统一落在dist/目录:
| UMD | CommonJS | ES Module | |
|---|---|---|---|
| 完整版 | fuse.js | fuse.cjs | fuse.mjs |
| 基础版 | fuse.basic.js | fuse.basic.cjs | fuse.basic.mjs |
| 完整版(压缩) | fuse.min.js | — | fuse.min.mjs |
| 基础版(压缩) | fuse.basic.min.js | — | fuse.basic.min.mjs |
基础版不包含扩展搜索与分词搜索。如果你在基础版上仍需这两项能力,可以在运行时通过插件机制Fuse.use()注册(详见 src/core/register.ts 对应的注册入口):
import Fuse from 'fuse.js/basic' import { ExtendedSearch } from 'fuse.js' Fuse.use(ExtendedSearch)CDN 与 Deno
浏览器可直接通过 CDN 以<script>标签引入完整版,或以<script type="module">方式引入 ESM 产物;Deno 环境可配合类型声明文件使用dist/fuse.min.mjs构建产物(具体 URL 请以安装时的版本为准,完整示例见 docs/getting-started.md 的 "CDN" 与 "Deno" 小节)。
构造选项与默认值:从源码看配置全貌
Fuse构造函数的签名是new Fuse(docs, options?, index?)(见 src/core/index.ts),options 缺省时逐层合并默认配置。所有默认值集中定义在 src/core/config.ts,分为四组:
基础选项(BasicOptions)
| 选项 | 默认值 | 说明 |
|---|---|---|
isCaseSensitive | false | 是否大小写敏感 |
ignoreDiacritics | false | 是否忽略变音符(如é可匹配e) |
includeScore | false | 是否在结果中附带score |
keys | [] | 要搜索的字段键 |
shouldSort | true | 是否按相关度排序结果 |
sortFn | 内置 | 默认按分数升序、同分按原始下标排序 |
匹配选项(MatchOptions)
| 选项 | 默认值 | 说明 |
|---|---|---|
includeMatches | false | 是否返回匹配位置信息 |
findAllMatches | false | 完美匹配后是否继续扫描全文(高亮需要) |
minMatchCharLength | 1 | 低于该长度的匹配片段不返回 |
模糊选项(FuzzyOptions)
| 选项 | 默认值 | 说明 |
|---|---|---|
location | 0 | 模式在文本中的期望出现位置 |
threshold | 0.6 | 模糊度阈值,0 要求完全匹配,1 匹配一切 |
distance | 100 | 距location多远开始惩罚到排除 |
高级选项(AdvancedOptions)
| 选项 | 默认值 | 说明 |
|---|---|---|
useExtendedSearch | false | 启用扩展搜索操作符 |
useTokenSearch | false | 启用分词搜索 |
tokenMatch | 'any' | 分词匹配模式:'any'(OR)或'all'(AND) |
getFn | 内置 | 自定义字段取值函数 |
ignoreLocation | false | 是否关闭位置计分 |
ignoreFieldNorm | false | 是否忽略字段长度归一化 |
fieldNormWeight | 1 | 字段长度归一化的强度系数 |
构造时(src/core/index.ts 的构造函数)会检查两个特性开关:当useExtendedSearch: true或useTokenSearch: true而当前构建未启用对应能力时,直接抛出错误(错误消息定义见 src/core/errorMessages.ts)——这就是"基础版使用分词搜索会报错"的源码级原因。
模糊搜索:Bitap 算法与三参数控制
模糊搜索是 Fuse.js 的根基。它使用改进的 Bitap 算法做近似字符串匹配:容忍错字、字符换位与缺字,本质上在每个文本位置上计算模式与文本的编辑距离,并用位运算加速(每个搜索词的模式长度上限为 32 字符,这也是分词搜索存在的原因之一)。算法输出 0~1 的模糊分:0 完美匹配、1 完全不匹配。
const fuse = new Fuse(['apple', 'banana', 'orange'], { includeScore: true }) fuse.search('aple') // [{ item: 'apple', refIndex: 0, score: 0.25 }]'aple'缺少一个p,依然命中'apple'。编辑距离的可视化推演可参考仓库文章 docs/articles/how-fuzzy-search-works.md。
threshold、location、distance 三者如何协同
threshold(默认0.6):模糊分阈值。0.0要求完美匹配,1.0匹配任意内容;location(默认0):模式在文本中的预期位置,远离该位置的匹配会被惩罚;distance(默认100):距location多远开始惩罚到排除。有效搜索窗口的计算公式为:
threshold × distance = 距 location 的最大偏移量用默认值0.6 × 100 = 60:模式必须出现在距位置 0 的 60 个字符以内才可能命中。原文档给出的例子很直观——在句子"Fuse.js is a powerful, lightweight fuzzy-search library, with zero dependencies"中搜索"zero",它出现在第 62 个字符处,恰好超出窗口,因此不会命中。
由此得出一个实战要点:如果字段是长文本,默认配置只会扫描开头约 60 个字符。此时应增大distance,或直接设置ignoreLocation: true关闭位置计分,让模式在文本任意位置都可命中。
其余匹配相关选项
isCaseSensitive(默认false):开启后比较区分大小写;ignoreDiacritics(默认false):开启后忽略重音,如é可匹配e(实现见 src/helpers/diacritics.ts);findAllMatches(默认false):即使已找到完美匹配也继续扫描到文本末尾,用于高亮所有匹配位置;minMatchCharLength(默认1):只返回长度超过该值的匹配,设为2可忽略单字符匹配。
最终评分:模糊分 × 键权重 × 字段长度归一化
最终相关度分数由三部分组合(计算逻辑见 src/core/computeScore.ts):
- 模糊分:上文 Bitap 算法的原始输出;
- 键权重:每个键可配
weight(默认1),权重更高对排序影响更大,内部会做归一化(相关实现见 src/tools/fieldNorm.ts 与 src/tools/KeyStore.ts); - 字段长度归一化:短字段的命中比长字段更显著,例如标题中的命中权重高于长描述中的相同命中。
两个调节开关:ignoreFieldNorm: true让字段长度不再影响分数;fieldNormWeight调节归一化强度,0等价于忽略、0.5减弱、2.0放大。开启includeScore: true即可在结果中看到最终分数。键权重相关的类型定义(weight、getFn)见 src/types.ts 的FuseOptionKeyObject。
分词搜索:多词查询的正确打开方式
默认模糊搜索把整个查询当作一个模式,适合"javscript"→"JavaScript"这种单词纠错。但面对"javascript design patterns"这类多词查询,单个 Bitap 搜索会撞上 32 字符上限,也无法逐词独立匹配。
何时使用分词搜索
- 搜索框:用户输入
"react state management"这类自然多词查询; - 文档检索:标题、描述、正文多词同时相关;
- 自动补全:按命中的词元数量排序,罕见词加权更高。
开启方式:
const fuse = new Fuse(docs, { useTokenSearch: true, keys: ['title', 'author', 'description'] }) fuse.search('javascrpt paterns') // → [{ item: { title: 'JavaScript Patterns', ... }, score: 0.12 }]两个词都有拼写错误仍能命中。原有的includeScore、includeMatches、键权重、threshold、limit、shouldSort等选项全部照常生效。
内部四步流水线
分词(Tokenization):默认使用 unicode 感知的正则
/[\p{L}\p{M}\p{N}_]+/gu把查询拆成词元,开箱即支持 CJK、西里尔、希腊、阿拉伯、希伯来、天城文等文字;可用tokenize选项覆盖(见下文);逐词模糊匹配:每个词元对每个字段独立执行 Bitap 匹配,且强制
ignoreLocation: true,词元可出现在字段任意位置——多词查询不再受 32 字符模式上限约束;IDF 加权:构造时即构建倒排索引(实现见 src/search/token/InvertedIndex.ts),每个词元的 IDF 权重采用 BM25 风格公式:
idf = log(1 + (fieldCount - docFreq + 0.5) / (docFreq + 0.5))罕见词(出现在更少文档中)权重更高,命中一个特征鲜明的词比命中一个随处可见的词贡献更大;
分数合并:各词元得分按 IDF 权重做加法合并,再归一化到 0~1 区间(0 为完美匹配)。
关键行为
- 部分命中仍返回:3 个词命中 2 个的文档依然在结果里,但排在 3 词全中的文档之后;
- 词序无关:
"patterns javascript"与"javascript patterns"结果完全一致; - 逐词容错:每个词独立模糊匹配,任一词的错字都被容忍;
- 长查询可用:6 个词的查询会执行 6 次独立的 Bitap 搜索,每次都在 32 字符上限之内。
匹配模式 tokenMatch:'any'与'all'
默认tokenMatch: 'any'(OR 语义):命中任意一个词即返回该记录,适合"排序搜索"场景——把最佳匹配排最前,部分匹配仍浮出水面。
需要"过滤"语义时改用tokenMatch: 'all'(AND 语义):只有每个查询词都在该记录中命中才返回,即加词即收窄列表。
const list = ['red shirt', 'red hat', 'blue shirt'] new Fuse(list, { useTokenSearch: true }) .search('red shirt') .map((r) => r.item) // 'any'(默认):['red shirt', 'red hat', 'blue shirt'] ← 命中任一词即可 new Fuse(list, { useTokenSearch: true, tokenMatch: 'all' }) .search('red shirt') .map((r) => r.item) // 'all':['red shirt'] ← 两词都须命中注意'all'是按整条记录跨字段评估的——每个词只需出现在记录的任意字段(或数组元素)中即可,而不是要求同一字段同时包含所有词:
const products = [ { title: 'Red', description: 'cotton shirt' }, // "red" 与 "shirt" 分处不同字段 { title: 'Red dress', description: 'silk' } ] new Fuse(products, { useTokenSearch: true, tokenMatch: 'all', keys: ['title', 'description'] }) .search('red shirt') .map((r) => r.item) // → [{ title: 'Red', description: 'cotton shirt' }] (第二条记录里没有任何 "shirt")两点补充:'all'只改变哪些记录被返回,不改变幸存记录的排序(IDF 计分不变);tokenMatch仅对分词搜索生效,与逻辑搜索的$and/$or操作符是两套独立机制——后者组合的是"按字段划分的子句"而非"一个查询中的多个词";并且逐词模糊匹配依然生效,拼错的词只要足够接近就计入 AND 条件。
自定义分词器 tokenize
默认 tokenizer 把任意 unicode 字母、附加符号、数字视为词的一部分,对绝大多数自然语言文本都够用,但两类场景需要覆盖:
- 含内部标点的词元:如
node.js、c++、U.S.A、文件路径、hashtag——传一个把这些标点包含进词元的自定义正则; - 需要分词的中文/泰文:默认会把每个连续脚本段当一个词元,可以传入基于
Intl.Segmenter的函数做真正的按词切分。
正则形式(必须带g全局标志,否则每段文本只取第一个词元;开发构建中缺失g会输出一次性console.warn):
const fuse = new Fuse(docs, { useTokenSearch: true, keys: ['text'], // 把点、加号、短横线保留在词元内部 tokenize: /[\w.+-]+/g }) fuse.search('node.js') // 命中包含字面量 "node.js" 的文档函数形式(适用于 CJK 等非空格分词语言,用Intl.Segmenter做 locale-aware 切词,并通过isWordLike过滤标点与空白段):
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' }) const fuse = new Fuse(docs, { useTokenSearch: true, keys: ['text'], tokenize: (text) => Array.from(segmenter.segment(text), (s) => s.isWordLike ? s.segment : null) .filter(Boolean) })函数形式接收的文本是经过大小写折叠与去变音符之后的字段/查询文本(依isCaseSensitive/ignoreDiacritics而定),必须返回string[]且保证确定性——不确定的分词器会静默破坏文档频率统计。另外,函数分词器无法通过postMessage传输到 Web Worker,因此FuseWorker 不支持函数形式分词器(相关说明见 docs/web-workers.md)。
动态集合更新与性能
分词搜索的倒排索引在构造时构建,并在集合变化时同步维护:
const fuse = new Fuse(docs, { useTokenSearch: true, keys: ['title'] }) // 新增文档会同步更新倒排索引 fuse.add({ title: 'New Book' }) // 删除文档同样更新索引 fuse.remove((doc) => doc.title === 'Old Book')新增/删除的索引维护逻辑在 src/core/index.ts 中体现:add追加文档并调用倒排索引的addToInvertedIndex,remove调用removeAndShiftInvertedIndex处理下标偏移。仓库还提供了可复跑的基准脚本(bench/token-search.mjs)。原文档在 2 个键(title + body)的随机文档上测得:
| 指标 | 100 篇 | 1,000 篇 | 5,000 篇 |
|---|---|---|---|
| 索引创建开销 | 2.5x | 5.2x | 5.5x |
| 单词查询开销 | 1.8x | 1.8x | 1.7x |
| 多词查询开销 | 1.3x | 1.3x | 1.2x |
索引创建是一次性成本(5,000 篇约 46ms),查询开销 1.2~1.8x 主要来自每个查询词各自执行一次 Bitap 搜索,而倒排索引本身的查找是 O(1)。分词搜索仅包含在完整版构建中,基础版使用useTokenSearch: true会直接抛错(与构造函数中的特性检查一致)。
扩展搜索:unix 风格操作符
扩展搜索让查询字符串携带精确、前缀、后缀、排除、包含等操作符,开启方式为useExtendedSearch: true:
const fuse = new Fuse(list, { useExtendedSearch: true, keys: ['title', 'author'] })操作符一览
| Token | 匹配类型 | 含义 |
|---|---|---|
jscript | 模糊匹配 | 模糊匹配jscript |
=scheme | 精确匹配 | 恰好是scheme |
'python | 包含匹配 | 包含python |
!ruby | 反向精确匹配 | 不包含ruby |
^java | 前缀精确匹配 | 以java开头 |
!^earlang | 反向前缀匹配 | 不以earlang开头 |
.js$ | 后缀精确匹配 | 以.js结尾 |
!.go$ | 反向后缀匹配 | 不以.go结尾 |
操作符的解析实现见 src/search/extended/parseQuery.ts,各操作符的匹配器在 src/search/extended/matchers.ts,测试覆盖见 test/extended-search.test.js。
组合规则:空格 AND、竖线 OR
- 空格表示AND:所有词元都必须匹配;
- 竖线
|表示OR:任一组匹配即可。
// 同时包含 "Man" 与 "Old",或者以 "Artist" 结尾 fuse.search("'Man 'Old | Artist$")解析为两个 OR 组:①'ManAND'Old(包含 Man 且包含 Old);②Artist$(以 Artist 结尾)。
带空格的短语:双引号引用
fuse.search('="scheme language"') // 精确匹配 "scheme language" fuse.search("'^hello world") // 包含匹配 "hello world"完整示例
const books = [ { title: "Old Man's War", author: 'John Scalzi' }, { title: 'The Lock Artist', author: 'Steve Hamilton' }, { title: 'Artist for Life', author: 'Michelangelo' } ] const fuse = new Fuse(books, { useExtendedSearch: true, keys: ['title'] }) // 以 "Old" 开头 AND 模糊匹配 "war" fuse.search('^Old war') // 不包含 "Artist" AND 以 "Old" 开头 fuse.search('!Artist ^Old') // 以 "Artist" 结尾 OR 包含 "War" fuse.search("Artist$ | 'War")扩展搜索操作符还可以内嵌到逻辑查询中使用(详见下节)。与分词搜索相同,扩展搜索只包含在完整版构建中;基础版可通过Fuse.use(ExtendedSearch)运行时注册启用。
逻辑搜索:$and / $or 结构化查询
当搜索条件需要结构化组合时,search()的参数可以从字符串升级为表达式对象(解析实现在 src/core/queryParser.ts)。
$and:全部子句须匹配
const result = fuse.search({ $and: [{ author: 'abc' }, { title: 'xyz' }] })采用短路求值——第一个表达式为假则跳过其余。
$or:任一子句匹配
const result = fuse.search({ $or: [{ author: 'abc' }, { author: 'def' }] })同样短路求值——第一个表达式为真即跳过其余。
任意深度嵌套
const result = fuse.search({ $and: [ { title: 'old war' }, { $or: [ { title: '^lock' }, { title: '!arts' } ] } ] })隐式 AND
对象内逗号分隔的表达式列表默认执行隐式 AND;当同一字段或同一操作符在多个表达式中出现时,用显式$and更清晰。
含字面点的键:$path 与 $val
如果数据中的键本身就含点(例如"first.name"是单个键而非嵌套路径),逻辑查询需要使用$path与$val显式声明路径与取值:
const books = [ { title: "Old Man's War", author: { 'first.name': 'John', 'last.name': 'Scalzi' } } ] const fuse = new Fuse(books, { keys: ['title', ['author', 'first.name'], ['author', 'last.name']] }) const result = fuse.search({ $and: [ { $path: ['author', 'first.name'], $val: 'jon' }, { $path: ['author', 'last.name'], $val: 'scazi' } ] })与扩展搜索联用
开启useExtendedSearch后,逻辑表达式中的字符串值会被当作扩展搜索模式解析:
const fuse = new Fuse(books, { useExtendedSearch: true, keys: ['title', 'color'] }) const result = fuse.search({ $and: [ { title: 'old war' }, // 模糊匹配 "old war" { color: "'blue" }, // 精确包含 "blue" { $or: [ { title: '^lock' }, // 以 "lock" 开头 { title: '!arts' } // 不包含 "arts" ] } ] })逻辑查询的完整测试见 test/logical-search.test.js。
嵌套字段、数组与自定义取值
首页特性清单中的"嵌套搜索"由keys的三种写法支撑(类型定义见 src/types.ts 的FuseOptionKey):
- 字符串键:
'title',直接取值; - 点号路径:
'author.name',按嵌套路径取值; - 数组路径:
['author', 'name'],等价于点号路径; - 键对象:
{ name: 'title', weight: 2 },附加权重或自定义getFn。
默认取值函数实现于 src/helpers/get.ts:它递归遍历路径,遇到数组会对每个元素分别取值并记录下标(返回{ v, i }结构以支持数组元素级别的匹配位置定位),最终把字符串、数字、布尔、bigint 统一字符串化。这也是为什么includeMatches能精确到数组中的哪个元素命中了匹配。
当数据结构特殊(如字段名动态、需先做清洗、或要拼接多个字段再搜索)时,通过getFn提供自定义取值逻辑:
const fuse = new Fuse(docs, { keys: [ { name: 'title', weight: 2, getFn: (book) => book.meta?.title ?? '' }, 'description' ] })加权键的实际影响在排序阶段体现:权重越高的键命中后对最终分数的贡献越大,配合fieldNormWeight即可精细调控"标题优先于描述"这类排序策略(仓库中另有 test/key-weight-normalization.test.js 验证权重归一化行为)。
进阶资源导航
本文覆盖了首页 docs/index.md 全部主题,各主题的完整专项文档与配套资源如下:
- 分步安装与构建细节:docs/getting-started.md
- 模糊搜索参数与评分详解:docs/fuzzy-search.md
- 分词搜索完整指南:docs/token-search.md
- 扩展搜索操作符手册:docs/extended-search.md
- 逻辑查询语法:docs/logical-search.md
- 编辑距离的交互式可视化讲解:docs/articles/how-fuzzy-search-works.md
- Web Worker 多线程搜索方案:docs/web-workers.md
- 性能基准方法论:docs/performance.md,可运行脚本见 bench/search.mjs 与 bench/index-creation.mjs
- 配套测试:模糊搜索 test/fuzzy-search.test.js、扩展搜索 test/extended-search.test.js、逻辑搜索 test/logical-search.test.js、分词搜索 test/token-search.test.js
实际动手时,建议从三组默认值出发微调:搜索范围受threshold、distance、location约束,长文本记得调大distance或开启ignoreLocation;多词场景优先useTokenSearch并按"排序 or 过滤"选择tokenMatch;结构化筛选场景组合useExtendedSearch与$and/$or。这套配置组合可以覆盖从搜索框自动补全到复杂文档检索的绝大多数前端搜索需求。
- 前端
- 搜索引擎
【免费下载链接】Fuse
Lightweight fuzzy-search, in JavaScript
相关推荐
10分钟搞懂Fuse.js模糊搜索:Bitap算法实战指南
10分钟搞懂Fuse.js模糊搜索:Bitap算法实战指南 Fuse.js是一款轻量级的JavaScript模糊搜索库,能够帮助开发者轻松实现高效的文本搜索功能
前端搜索引擎Sunshine 游戏串流新手教程:5 步装完串出第一帧
Sunshine 游戏串流新手教程:5 步装完串出第一帧 书房里的游戏主机配置拉满,人却在客厅,手里只有一台带不动 3A 的轻薄本。想玩又懒得折腾显示硬件,Su
前端搜索引擎KJFrameForAndroid进阶教程:自定义组件与扩展框架功能
KJFrameForAndroid进阶教程:自定义组件与扩展框架功能 KJFrameForAndroid是一个功能强大的Android开发框架,它封装了Andr
移动开发开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考