🎈前言
最近一直都在用 Fuse.js 来进行表格搜索,有点好奇是怎么实现的,于是就翻了一下源码。
Fuse.js 是一个功能强大、轻量级、零依赖关系的 JS 模糊搜索库,适用于前后端各种 JS 项目。Fuse.js 根据匹配度、匹配位置、句子长度的因素,给每个搜索项评分并排序,得到模糊搜索的结果。举个利兹🕊:
ts- const list = ['apple', 'orange', 'banana']
- const fuse = new Fuse(list)
- console.log(fuse.search('appnana'));
- // [ { item: 'banana', refIndex: 2 }, { item: 'apple', refIndex: 0 } ]
也可以用于复杂对象的搜索:
ts- const list = [
- { item: 'vue', tag: [ 'front end', 'browser' ] },
- { item: 'axios', tag: [ 'request lib' ] },
- { item: 'echart', tag: [ 'graph lib' ] },
- { item: 'naive ui', tag: [ 'UI lib' ] },
- { item: 'next', tag: [ 'full stack', 'server side render' ] }
- ]
- const fuse = new Fuse(list, {
- keys: ['item', 'tag']
- })
- console.log(fuse.search('livue'));
- // [
- // { item: { item: 'vue', tag: [Array] }, refIndex: 0 },
- // { item: { item: 'naive ui', tag: [Array] }, refIndex: 3 },
- // { item: { item: 'axios', tag: [Array] }, refIndex: 1 }
- // ]
它的核心模糊匹配算法是源自 Bitap 算法。Bitap算法(也被称为 shift-or、shift-and 或 Baeza-Yates-Gonnet 算法)是一种模糊字符串搜索算法。该算法可以判断给定文本是否包含与给定模式“大致相等”的子字符串,这里的“大致相等”是根据子串和模式之间的 Levenshtein 编辑距离是否在给定的距离以内来判断的。Bitap 算法预先计算模式串字符位置,使用二进制运算来进行,速度非常的快,时间复杂度是 ,、 分别是目标串和模式串长度。
Levenshtein 编辑距离,就是两个字符串之间,通过增加、减少、替换字符,由一个转换成另一个所需的最少编辑操作次数。
下面以字符串数组为例,分析 Fuse.js 的算法。
✨初始化
我们一般先会创建Fuse
的实例:
ts- const list = [/*...*/]
- const fuse = new Fuse(list, {/*...*/})
创建实例时,内部会根据传入的数据创建索引FuseIndex
,它的数据结构大概长这样子:
ts- {
- v: string // 待匹配的目标串
- i: number // 目标串在原数据的位置
- n: number // 范数(?),随目标串单词个数上升,影响搜索结果的排序
- }[]
📑创建索引
函数入口在 src/core/index.js。创建Fuse
实例后,根据传入的docs
构建索引。
ts- export default class Fuse {
- constructor(docs, options = {}, index) {
- this.options = { ...Config, ...options }
- if (
- this.options.useExtendedSearch &&
- !process.env.EXTENDED_SEARCH_ENABLED
- ) {
- throw new Error(ErrorMsg.EXTENDED_SEARCH_UNAVAILABLE)
- }
- this._keyStore = new KeyStore(this.options.keys)
- this.setCollection(docs, index)
- }
- setCollection(docs, index) {
- this._docs = docs
- if (index && !(index instanceof FuseIndex)) {
- throw new Error(ErrorMsg.INCORRECT_INDEX_TYPE)
- }
- this._myIndex =
- index ||
- createIndex(this.options.keys, this._docs, {
- getFn: this.options.getFn,
- fieldNormWeight: this.options.fieldNormWeight
- })
- }
- }
这里由于我们以字符串数组为例,这里就没有this.options.keys
了,KeyStore
先不看了,来看createIndex
:
ts- export function createIndex(
- keys,
- docs,
- { getFn = Config.getFn, fieldNormWeight = Config.fieldNormWeight } = {}
- ) {
- const myIndex = new FuseIndex({ getFn, fieldNormWeight })
- myIndex.setKeys(keys.map(createKey))
- myIndex.setSources(docs)
- myIndex.create()
- return myIndex
- }
- export default class FuseIndex {
- constructor({
- getFn = Config.getFn,
- fieldNormWeight = Config.fieldNormWeight
- } = {}) {
- this.norm = normGenerator(fieldNormWeight, 3)
- this.getFn = getFn
- this.isCreated = false
- this.setIndexRecords()
- }
- setIndexRecords(records = []) {
- this.records = records
- }
- setSources(docs = []) {
- this.docs = docs
- }
- }
FuseIndex
创建后,加入keys
和docs
,然后调用create
创建索引:
ts- create() {
- if (this.isCreated || !this.docs.length) {
- return
- }
- this.isCreated = true
- // List is Array<String>
- if (isString(this.docs[0])) {
- this.docs.forEach((doc, docIndex) => {
- this._addString(doc, docIndex)
- })
- } else {
- // List is Array<Object>
- this.docs.forEach((doc, docIndex) => {
- this._addObject(doc, docIndex)
- })
- }
- this.norm.clear()
- }
- _addString(doc, docIndex) {
- if (!isDefined(doc) || isBlank(doc)) {
- return
- }
- let record = {
- v: doc,
- i: docIndex,
- n: this.norm.get(doc)
- }
- this.records.push(record)
- }
对于字符串的数据源来说,每一条索引是由v
文本(也就是匹配时的目标串)、i
在原数据数组中的下标、n
norm(大概叫…范数吧,和长度有关的一个值)。this.norm.get
是一个随文本长度下降的权重,计算方法如下:
ts- const SPACE = /[^ ]+/g
- // Field-length norm: the shorter the field, the higher the weight.
- // Set to 3 decimals to reduce index size.
- export default function norm(weight = 1, mantissa = 3) {
- const cache = new Map()
- const m = Math.pow(10, mantissa)
- return {
- get(value) {
- // 这里 Token 是按空格分割算的,那中文就只有一个 Token,感觉像 bug
- const numTokens = value.match(SPACE).length
- if (cache.has(numTokens)) {
- return cache.get(numTokens)
- }
- // Default function is 1/sqrt(x), weight makes that variable
- const norm = 1 / Math.pow(numTokens, 0.5 * weight)
- // In place of `toFixed(mantissa)`, for faster computation
- const n = parseFloat(Math.round(norm * m) / m)
- cache.set(numTokens, n)
- return n
- },
- clear() {
- cache.clear()
- }
- }
- }
简单来说,也就是
可见,numTokens
越小,norm
就大,后续可以看到分数随norm
增加而下降,数据项根据分数从小到大排序,这样子比较短的数据项的排名就靠前。
这里的weight
,可以在创建时从options.fieldNormWeight
传入,默认为 1,norm
随weight
减小而增加。
到这里,索引就创建完了,如果传入['apple', 'orange', 'banana']
,就有索引
json- [
- { v: 'apple', i: 0, n: 1 },
- { v: 'orange', i: 1, n: 1 },
- { v: 'banana', i: 2, n: 1 }
- ]
📙小结
简单总结一下大致流程:
graph LR A("new Fuse()")-->B("createIndex")-->C("myIndex = new FuseIndex()")-->D("myIndex.create()")
🗄搜索
创建完索引后,使用search
函数进行模糊搜索,就像这样子
ts- const fuse = new Fuse(/*...*/)
- const ans = fuse.search('appnana')
🚪搜索入口
搜索的流程从search
方法开始:
ts- search(query, { limit = -1 } = {}) {
- // ...
- let results = isString(query)
- ? isString(this._docs[0])
- ? this._searchStringList(query)
- : this._searchObjectList(query)
- : this._searchLogical(query)
- computeScore(results, { ignoreFieldNorm })
- if (shouldSort) {
- results.sort(sortFn)
- }
- // ...
- }
字符串的数据调用_searchStringList
方法,得出符合要求的每一项及其得分。
ts- _searchStringList(query) {
- const searcher = createSearcher(query, this.options)
- const { records } = this._myIndex
- const results = []
- // Iterate over every string in the index
- records.forEach(({ v: text, i: idx, n: norm }) => {
- if (!isDefined(text)) {
- return
- }
- const { isMatch, score, indices } = searcher.searchIn(text)
- if (isMatch) {
- results.push({
- item: text,
- idx,
- matches: [{ score, value: text, norm, indices }]
- })
- }
- })
- return results
- }
结果中,results
的每一项对于每一个源数据项的结果,matches
对于字符串的数据来说只有一项,在搜索对象时,是每一个key
对应的结果。computeScore
计算其总分。
可以看到,结果是matches
各个元素分数乘方之积。分数的取值范围是,后面的results.sort(sortFn)
默认从小到大排序,key
的weight
越大,norm
越大,分数越小,排名也越靠前。
ts- import Config from './config'
- // Practical scoring function
- export default function computeScore(
- results,
- { ignoreFieldNorm = Config.ignoreFieldNorm }
- ) {
- results.forEach((result) => {
- let totalScore = 1
- result.matches.forEach(({ key, norm, score }) => {
- const weight = key ? key.weight : null
- totalScore *= Math.pow(
- score === 0 && weight ? Number.EPSILON : score,
- (weight || 1) * (ignoreFieldNorm ? 1 : norm)
- )
- })
- result.score = totalScore
- })
- }
下面进入 Fuse.js 的核心部分,就是它的模糊匹配。
🗜创建搜索器
回到_searchStringList
方法,可以看到创建了一个搜索器createSearcher
,然后把每项索引扔进去计算分值:
ts- _searchStringList(query) {
- const searcher = createSearcher(query, this.options)
- const { records } = this._myIndex
- const results = []
- // Iterate over every string in the index
- records.forEach(({ v: text, i: idx, n: norm }) => {
- if (!isDefined(text)) {
- return
- }
- const { isMatch, score, indices } = searcher.searchIn(text)
- if (isMatch) {
- results.push({
- item: text,
- idx,
- matches: [{ score, value: text, norm, indices }]
- })
- }
- })
- return results
- }
在createSearcher
,它会根据搜索的模式串创建一个BitapSearch
并缓存起来:
ts- import { BitapSearch } from '../search'
- const registeredSearchers = []
- export default function register(...args) {
- registeredSearchers.push(...args)
- }
- export function createSearcher(pattern, options) {
- for (let i = 0, len = registeredSearchers.length; i < len; i += 1) {
- let searcherClass = registeredSearchers[i]
- if (searcherClass.condition(pattern, options)) {
- return new searcherClass(pattern, options)
- }
- }
- return new BitapSearch(pattern, options)
- }
✒用二进制掩码记录目标串字符
Bitap 算法会把模式串转化为二进制掩码,利用位运算来快速计算模式串和目标串之间的相似度。来看BitapSearch
的构造函数:
ts- export default class BitapSearch {
- constructor(/* ... */) {
- // ...
- this.chunks = []
- if (!this.pattern.length) {
- return
- }
- const addChunk = (pattern, startIndex) => {
- this.chunks.push({
- pattern,
- alphabet: createPatternAlphabet(pattern),
- startIndex
- })
- }
- const len = this.pattern.length
- // const MAX_BITS = 32
- if (len > MAX_BITS) {
- let i = 0
- const remainder = len % MAX_BITS
- const end = len - remainder
- while (i < end) {
- addChunk(this.pattern.substr(i, MAX_BITS), i)
- i += MAX_BITS
- }
- if (remainder) {
- const startIndex = len - MAX_BITS
- addChunk(this.pattern.substr(startIndex), startIndex)
- }
- } else {
- addChunk(this.pattern, 0)
- }
- }
- }
在构造函数中,BitapSearch
按照 31 的长度为一节对模式串进行切分,用二进制掩码每一个字符的位置。
ts- function createPatternAlphabet(pattern) {
- var mask = {};
- for (var i = 0, len = pattern.length; i < len; i += 1) {
- var _char = pattern.charAt(i);
- mask[_char] = (mask[_char] || 0) | 1 << len - i - 1;
- }
- return mask;
- }
例如:abc,a 记录为 100,b 就是 010, c 就是 001。
至于为什么是 31 的长度,大概是因为 JS 二进制运算使用 32 位的二进制整数吧。另外,Bitap 算法中也是使用 31 位的二进制记录字符位置的。
🔍匹配入口
回到_searchStringList
,serachIn
方法用来进行字符串匹配。
ts- _searchStringList(query) {
- const searcher = createSearcher(query, this.options)
- const { records } = this._myIndex
- const results = []
- records.forEach(({ v: text, i: idx, n: norm }) => {
- // ...
- const { isMatch, score, indices } = searcher.searchIn(text)
- // ...
- })
- return results
- }
searchIn
遇到完全匹配直接返回:
ts- searchIn(text) {
- const { isCaseSensitive, includeMatches } = this.options
- if (!isCaseSensitive) {
- text = text.toLowerCase()
- }
- // Exact match
- if (this.pattern === text) {
- let result = {
- isMatch: true,
- score: 0
- }
- if (includeMatches) {
- result.indices = [[0, text.length - 1]]
- }
- return result
- }
- // ... Bitap
- }
如果模式串this.pattern
和目标串text
相等,则记分值为 0,也就是完全匹配,结束逻辑。否则进入 Bitap 模糊匹配的逻辑。
后面是 Bitap 的逻辑,这里用this.chunks
中每一个模式串分块的掩码和目标串进行匹配,最后分数取每一分块分数的平均值。这里有几个值得注意的变量,后面会用到。
location
:预期匹配子串出现的位置;distance
:影响匹配的子串与期望位置偏离对分数的影响,越大,偏离对分数影响越小;ignoreLocation
可以使得分数计算忽略距离因素,如果你想搜索超长的文本,这大概会有用吧。threshold
:分数阈值,分数大于此值会被视为不匹配。
这些变量的值都是可以在new Fuse()
的时候配置的,具体看Fuse.js 的文档。
ts- searchIn(text) {
- // ...
-
- let totalScore = 0
- let hasMatches = false
- this.chunks.forEach(({ pattern, alphabet, startIndex }) => {
- const { isMatch, score, indices } = search(text, pattern, alphabet, {
- location: location + startIndex,
- distance,
- threshold,
- findAllMatches,
- minMatchCharLength,
- includeMatches,
- ignoreLocation
- })
- if (isMatch) {
- hasMatches = true
- }
- totalScore += score
- // ...
- })
- let result = {
- isMatch: hasMatches,
- score: hasMatches ? totalScore / this.chunks.length : 1
- }
- if (hasMatches && includeMatches) {
- result.indices = allIndices
- }
- return result
- }
📘小结
下面进一步介绍算法,这里先小结一下。Fuse 进行模糊匹配的总体流程是,先根据传入的数据源对每一项数据进行索引创建,搜索的时候,输入模式串,根据索引使用 Bitap 算法计算出每一项数据的得分,经过处理后返回允许的匹配度下符合匹配的数据。
graph LR D("search")-->E("searcher = new BitapSearch")-->|用二进制掩码记录目标串|H(addChunk) D-->F("searcher.searchIn")-->|完全匹配|I("return { isMatch: true, score: 0 }") F-->|Bitap 算法|G(search)
💻基于 Bitap 的模糊匹配
下面来看模糊匹配算法的实现,也是 Fuse.js 的核心。文件位置在 src/search/bitap/search.js,这里进行分段地解析。下面定义了一些变量,用到的时候下面再说,先可以不用关注它们:
ts- export default function search(
- text,
- pattern,
- patternAlphabet,
- {
- location = Config.location,
- distance = Config.distance,
- threshold = Config.threshold,
- findAllMatches = Config.findAllMatches,
- minMatchCharLength = Config.minMatchCharLength,
- includeMatches = Config.includeMatches,
- ignoreLocation = Config.ignoreLocation
- } = {}
- ) {
- if (pattern.length > MAX_BITS) {
- throw new Error(ErrorMsg.PATTERN_LENGTH_TOO_LARGE(MAX_BITS))
- }
- const patternLen = pattern.length
- // Set starting location at beginning text and initialize the alphabet.
- const textLen = text.length
- // Handle the case when location > text.length
- const expectedLocation = Math.max(0, Math.min(location, textLen))
- // Highest score beyond which we give up.
- let currentThreshold = threshold
- // Is there a nearby exact match? (speedup)
- let bestLocation = expectedLocation
- // Performance: only computer matches when the minMatchCharLength > 1
- // OR if `includeMatches` is true.
- const computeMatches = minMatchCharLength > 1 || includeMatches
- // A mask of the matches, used for building the indices
- const matchMask = computeMatches ? Array(textLen) : []
- // ...
- }
🧮计算分数
先来看看每个目标串和模式串匹配得分是怎么计算的。得分主要受 Bitap 算法中的错误(匹配子串和模式串的编辑距离)和与预期位置的偏离影响。简单的来说,也就是:
错误越多、离预期位置越远分数越高,搜索结果排名越靠后,或者被排除出搜索结果。
ts- export default function computeScore(
- pattern,
- {
- errors = 0,
- currentLocation = 0,
- expectedLocation = 0,
- distance = Config.distance,
- ignoreLocation = Config.ignoreLocation
- } = {}
- ) {
- const accuracy = errors / pattern.length
- if (ignoreLocation) {
- return accuracy
- }
- const proximity = Math.abs(expectedLocation - currentLocation)
- if (!distance) {
- // Dodge divide by zero error.
- return proximity ? 1.0 : accuracy
- }
- return accuracy + proximity / distance
- }
🔦记录匹配分数下限
注意到currentThreshold
的初始值是Config.threshold
,在这里的作用是记录当前最小得分,如果说后面不可能比这个分数更小了,就退出。
类似地,expectedLocation
也会根据当前最佳的匹配,更新位置。
先检测目标串text
中是否包含模式串pattern
,用于更新currentThreshold
,后续减少运算。
ts- // ...
- let index
- // Get all exact matches, here for speed up
- while ((index = text.indexOf(pattern, bestLocation)) > -1) {
- let score = computeScore(pattern, {
- currentLocation: index,
- expectedLocation,
- distance,
- ignoreLocation
- })
- currentThreshold = Math.min(score, currentThreshold)
- bestLocation = index + patternLen
- // ...
- }
- // ...
📚模糊匹配
下面进入模糊匹配环节,它有两个循环,外层是允许的错误数,内层遍历目标串text
。这里使用动态规划算法,状态记录在bitArr
和lastBitArr
中,分别表示当前和上次允许错误数的结果,finalScore
记录最优的分数。在bitArr
每一项都是二进制,每一位j
表示在当前允许错误数下text.slice(j, j + patternLen)
和pattern
是否已成功匹配。
ts- // Reset the best location
- bestLocation = -1
- let lastBitArr = []
- let finalScore = 1
- let binMax = patternLen + textLen
- const mask = 1 << (patternLen - 1)
- for (let i = 0; i < patternLen; i += 1) {
- // ...
- }
🔎搜索匹配范围
下面进入外层循环,先根据bestLocation
和currentThreshold
计算出需要检索目标串的范围,也就是离bestLocation
多远的地方分数不会大于currentThreshold
。很明显这就是二分搜索。
ts- for (let i = 0; i < patternLen; i += 1) {
- // Scan for the best match; each iteration allows for one more error.
- // Run a binary search to determine how far from the match location we can stray
- // at this error level.
- let binMin = 0
- let binMid = binMax
- while (binMin < binMid) {
- const score = computeScore(pattern, {
- errors: i,
- currentLocation: expectedLocation + binMid,
- expectedLocation,
- distance,
- ignoreLocation
- })
- if (score <= currentThreshold) {
- binMin = binMid
- } else {
- binMax = binMid
- }
- binMid = Math.floor((binMax - binMin) / 2 + binMin)
- }
- // Use the result from this iteration as the maximum for the next.
- binMax = binMid
- let start = Math.max(1, expectedLocation - binMid + 1)
- let finish = findAllMatches
- ? textLen
- : Math.min(expectedLocation + binMid, textLen) + patternLen
-
- // ...
- }
搜索的左端点自然是expectedLocation - binMid + 1
,右端点一般情况下是expectedLocation + binMid
加上模式串长度patternLen
,其实这里考虑的是在text
末尾,在允许的错误下匹配了模式串pattern
的情况。
这里有点没看懂:
findAllMatches
可以在创建Fuse
时配置,为true
表示:即使已经在字符串中找到了一个完全匹配,匹配函数会在字符串中继续执行,直到搜索模式的末尾。看起来像是考虑匹配text
全文,但是这里没太看懂,有几个疑问,textLen
为什么看起来是可能小于Math.min(expectedLocation + binMid, textLen) + patternLen
,而且为什么它对start
为什么没有影响。
⭕进行匹配
初始化状态后,从后往前开始匹配:
ts- for (let i = 0; i < patternLen; i += 1) {
- // ...
- let bitArr = Array(finish + 2)
- bitArr[finish + 1] = (1 << i) - 1
- for (let j = finish; j >= start; j -= 1) {
- let currentLocation = j - 1
- let charMatch = patternAlphabet[text.charAt(currentLocation)]
- // ...
-
- // First pass: exact match
- bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch
- // Subsequent passes: fuzzy match
- if (i) {
- bitArr[j] |=
- ((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1 | lastBitArr[j + 1]
- }
- if (bitArr[j] & mask) {
- // 匹配完成...
- }
- // ...
- }
- }
遍历匹配串时,每一步倒序加入目标串一个字符,这里用j
记录循环变量,从finish
递减到 1。
通过charMatch = patternAlphabet[text.charAt(j - 1)]
获取当前目标串text
的字符在模式串中的位置,通过((bitArr[j + 1] << 1) | 1) & charMatch
记录当前位置是否与模式串匹配。举个栗子🌰:
如果目标串是'test'
,模式串是tEst
,patternAlphabet
大概长这个样子:{ t: 1001, s: 10, E: 100 }
。假设finish
是 8,允许错误数i
为 0,匹配情况如下所示:
j | text.charAt(j - 1) | charMatch | bitArr[j + 1] | bitArr[j] |
---|---|---|---|---|
8 | undefined | undefined | 0 | 0 |
7 | undefined | undefined | 0 | 0 |
6 | undefined | undefined | 0 | 0 |
5 | undefined | undefined | 0 | 0 |
4 | ‘t’ | 1001 | 0 | 1 |
3 | ‘s’ | 10 | 1 | 10 |
2 | ‘e’ | undefined | 10 | 0 |
1 | ‘t’ | 1001 | 0 | 1 |
可以发现,如果上面匹配成功,最终bitArr[1]
就是1000
,也就是说bitArr[j] & (1 << (patternLen - 1))
为真时,匹配成功。
我们看i > 1
的情况,如果上次匹配当前位置离完全匹配就差 1 个编辑距离,那自然是匹配成功的。
Fuse.js 用lastBitArr[j] << 1 | 1
、lastBitArr[j + 1] | 1
、lastBitArr[j + 1]
分别表示上次匹配的子串增加、替换、删除 1 个末尾的字符后,匹配的情况,总之合起来就是((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1 | lastBitArr[j + 1]
。
到这里状态转移的过程大致描述清楚了,具体代码如下所示:
ts- for (let i = 0; i < patternLen; i += 1) {
- // ...
-
- // Initialize the bit array
- let bitArr = Array(finish + 2)
- bitArr[finish + 1] = (1 << i) - 1
- for (let j = finish; j >= start; j -= 1) {
- let currentLocation = j - 1
- let charMatch = patternAlphabet[text.charAt(currentLocation)]
- // ...
- // First pass: exact match
- bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch
- // Subsequent passes: fuzzy match
- if (i) {
- bitArr[j] |=
- ((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1 | lastBitArr[j + 1]
- }
- if (bitArr[j] & mask) {
- finalScore = computeScore(pattern, {
- errors: i,
- currentLocation,
- expectedLocation,
- distance,
- ignoreLocation
- })
- // This match will almost certainly be better than any existing match.
- // But check anyway.
- if (finalScore <= currentThreshold) {
- // Indeed it is
- currentThreshold = finalScore
-
- bestLocation = currentLocation
- // Already passed `loc`, downhill from here on in.
- if (bestLocation <= expectedLocation) {
- break
- }
- // When passing `bestLocation`, don't exceed our current distance from `expectedLocation`.
- start = Math.max(1, 2 * expectedLocation - bestLocation)
- }
- }
- }
- // ...
- lastBitArr = bitArr
- }
🆗后续处理
然后计算下次循环分数的最小值,如果高于阈值则退出
ts- for (let i = 0; i < patternLen; i += 1) {
- // ...
- // No hope for a (better) match at greater error levels.
- const score = computeScore(pattern, {
- errors: i + 1,
- currentLocation: expectedLocation,
- expectedLocation,
- distance,
- ignoreLocation
- })
- if (score > currentThreshold) {
- break
- }
- // ...
- }
返回匹配结果
ts- export default function search(/* ... */) {
- // ...
- const result = {
- isMatch: bestLocation >= 0,
- // Count exact matches (those with a score of 0) to be "almost" exact
- score: Math.max(0.001, finalScore)
- }
- // ...
- return result
- }
✔结语
到这里,Fuse.js 的源码大致解读完了。当然例如key
分值的计算,对复杂数据项的处理还没有涉及到,可能后续更新一下,欢迎催更。总之我们对 Fuse.js 的工作原理和实现细节有了大致的理解。Fuse.js 通过其高效的索引机制和 Bitap 算法,提供了一种既快速又准确的模糊搜索解决方案。在前端长列表搜索等场景非常有用,作为一个轻量级无依赖的第三方库,在服务端小型项目也有用武之地。