Привет, друзья!


Недавно (где-то в конце августа) у MDN появился новый (крутой, по заявлению разработчиков) поиск.


Речь идет об этом виджете на главной странице:







Основной код виджета находится здесь, а все остальное, кроме данных для поиска, можно найти здесь.


Данные для поиска также найти не сложно — для этого достаточно добавить к https://developer.mozilla.org локаль, например, /ru и /search-index.json:




Код поиска написан на React и TypeScript. Что это означает? Это означает, что мы с вами можем провести его обратную разработку.


Если вам это интересно, прошу следовать за мной.


Обратите внимание: данная статья рассчитана на разработчиков, которые имеют некоторый опыт работы с React. Желательно также иметь хотя бы общее представление о TypeScript.


Для удобства я вынес весь код, связанный с поиском в отдельный проект, исходный код которого находится здесь.


Практически каждая строка кода проекта снабжена подробным комментарием.


Скриншот проекта:





Песочница:

Команды для локального запуска проекта:


git clone https://github.com/harryheman/mdn-search-clone.git

cd mdn-search-clone

yarn

yarn start

Общая логика поиска MDN такова:


  • Когда пользователь заходит на главную страницу сайта, мы показываем ему упрощенную версию поискового виджета (болванку). Это избавляет пользователя от загрузки лишних данных в случае, когда он не будет пользоваться поиском
  • Когда пользователь выражает намерение воспользоваться поиском, мы выполняем инициализацию поискового индекса и заменяем болванку на настоящий виджет
  • Намерение пользователя воспользоваться поиском определяется по наведению курсора на виджет или установке фокуса на инпут с помощью клавиатуры или при нажатии /
  • Инициализация поискового индекса начинается с получения данных для поиска с помощью хука useSWR из SWR на основе локали пользователя, которая содержится в URI (например, https://developer.mozilla.org/ru)
  • Файл с данными для поиска называется search-index, имеет формат JSON и представляет собой массив объектов с двумя полями — заголовком и адресом статьи, например, { title: 'Веб-технологии для разработчиков', url: '/ru/docs/Web' }
  • Сам поиск выполняется после нажатия пользователем клавиши Enter и может быть двух видов:
    • поиск по url — для этого используется Fzf (я перевел "fuzzy search" как "неточный поиск", но не уверен, что это правильно). Данный поиск выполняется, когда строка для поиска начинается с / и не содержит пробелов
    • поиск по title — для этого используется FlexSearch (полнотекстовый поиск)
  • После получения результатов поиска мы рендерим их с помощью хука useCombobox из [Downshift] — это обеспечивает доступность выпадающего списка
  • При наведении пользователем курсора на результат поиска, мы начинаем загрузку данных для соответствующей статьи (prefetching)
  • При клике пользователем по результату поиска, мы перенаправляем его на страницу соответствующей статьи
  • Статья для предварительной загрузки и перенаправления определяется с помощью Downshift
  • При нажатии пользователем Enter после ввода строки для поиска, мы перенаправляем его на страницу поиска
  • Если для строки поиска не найдено совпадений, мы сообщаем об этом пользователю и предлагаем ему перейти на страницу поиска

Отличия проекта от оригинала состоят в следующем:


  • Отсутствует получение локали пользователя для формирования адреса статьи, поскольку мы работаем только с одной локалью — ru
  • Вместо переключения страниц выполняется открытие страницы на сайте MDN в новой вкладке браузера
  • Отсутствует предварительная загрузка данных для статей, поскольку у нас нет этих данных
  • Я не стал скрывать кнопку для очистки инпута, предоставляемую Donwshift, под иконкой лупы (синий крестик), как это сделано в оригинале
  • Я добавил обработчик нажатия кнопки для отправки формы (лупа) (в оригинале эта кнопка не работает)

Вместе с тем, я сохранил оригинальный код в закомментированном виде (и комментарии разработчиков поиска), кроме кода для prefething.


Рассмотрим, как работает поиск.


С вашего позволения, я немного упрощу оригинальный код.


Сначала мы получаем данные:


// Индикатор начала инициализации
const [shouldInitialize, setShouldInitialize] = useState(false)
// Поисковый индекс
const [searchIndex, setSearchIndex] = useState(null)

// Адрес данных для поиска
const url = `/search-index.json`

const { error, data } = useSWR(
 shouldInitialize ? url : null,
 async (url) => {
   // Получаем ответ
   const response = await fetch(url)
   if (!response.ok) {
     // Если возникла ошибка, выбрасываем исключение
     // с сообщением в виде текста
     throw new Error(await response.text())
   }
   // Возвращаем ответ в формате `JSON`
   return await response.json()
 },
 // Отключаем повторную валидацию - выполнение повторного запроса при возвращении фокуса
 { revalidateOnFocus: false }
)

Такая сигнатура хука включает режим ожидания — useSWR ждет начала инициализации. Другими словами, он запустится автоматически, как только shouldInitialize получит значение true. К слову, для отключения повторной валидации (revalidation) при возвращении фокуса и в некоторых других случаях в последних версиях SWR предназначен хук useSWRImmutable из swr/immutable.


Далее с помощью полученных данных инициализируется поиск:


// Мы подробно рассмотрим каждый вид поиска ниже
const flex = new FlexSearch.Index({ tokenize: 'forward' })

data.forEach(({ title }, i) => {
 flex.add(i, title)
})

// Обертка над `fzf`
const fuzzy = new FuzzySearch(data)

setSearchIndex({ flex, fuzzy, items: data })

Логика инициализации поискового индекса инкапсулирована в хуке useSearchIndex:


function useSearchIndex() {
 // Получение данных и инициализация индекса

 return useMemo(
   () => [
     searchIndex,
     error || null,
     () => setShouldInitialize(true)
   ],
   [searchIndex, error, setShouldInitialize]
 )
}

Как мы видим, хук возвращает массив, состоящий из поискового индекса, ошибки, которая может иметь значение null, и функции для запуска инициализации.


Остановимся на том, как работает каждый вид поиска.


Для определения того, какой поиск следует использовать (неточный или полнотекстовый) применяется такая функция:


function isFuzzySearchString(str: string) {
 // Данная функция возвращает `true`,
 // если строка начинается с `/` и не содержит пробелов
 return str.startsWith('/') && !/\s/.test(str)
}

Рассмотрим работу fzf и flexsearch на практических примерах.


Предположим, что у нас имеются такие данные для поиска:


const data = [
 {
   title: 'Веб-технологии для разработчиков',
   url: '/ru/docs/Web'
 },
 {
   title: 'Стандартные встроенные объекты',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects'
 },
 {
   title: 'Справочник по JavaScript',
   url: '/ru/docs/Web/JavaScript/Reference'
 },
 {
   title: 'JavaScript',
   url: '/ru/docs/Web/JavaScript'
 },
 {
   title: 'Array',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects/Array'
 }
]

И мы хотим выполнять поиск по url объектов.


Для этого отлично подойдет fzf:


import { useState } from 'react'
// yarn add fzf
import { Fzf } from 'fzf'

const data = [
 {
   title: 'Веб-технологии для разработчиков',
   url: '/ru/docs/Web'
 },
 {
   title: 'Стандартные встроенные объекты',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects'
 },
 {
   title: 'Справочник по JavaScript',
   url: '/ru/docs/Web/JavaScript/Reference'
 },
 {
   title: 'JavaScript',
   url: '/ru/docs/Web/JavaScript'
 },
 {
   title: 'Array',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects/Array'
 }
]

// Форматируем данные подобно тому, как это сделано в оригинале
const formattedData = data.map(({ title, url }) => ({
 title,
 url: url
   .split('/')
   .slice(1)
   .filter((i) => i !== 'docs')
   .join(' / ')
}))
/*
  Было, например, `/ru/docs/Web/JavaScript`,
  стало `ru / Web / JavaScript`
*/

// Создаем экземпляр `fzf`
// Конструктор принимает данные для поиска и объект с настройками
const fzf = new Fzf(formattedData, {
 // Количество возвращаемых (лучших) результатов
 limit: 3,
 // Поле для поиска
 selector: (item) => item.url
})

В документации fzf имеется пример реализации подсветки совпадения:


// `url` - это совпавшая строка
// `positions` - набор индексов совпавших символов
const HighlightChars = ({ url, positions }) => {
 const chars = url.split('')

 const nodes = chars.map((char, i) => {
   if (positions.has(i)) {
     return <b key={i}>{char}</b>
   } else {
     return char
   }
 })

 return <>{nodes}</>
}

Простой компонент для поиска:


export const FzfSearch = () => {
 // Строка для поиска
 const [value, setValue] = useState('')
 // Результаты
 const [results, setResults] = useState([])

 const search = (e) => {
   e.preventDefault()
   // Выполняем поиск
   const results = fzf.find(value.trim())
   // console.log(results)
   /*
     [
       {
         item: {
           title: 'Стандартные встроенные объекты',
           url: 'ru / Web / JavaScript / Reference / Global_Objects'
         },
         positions: Set(4) { 14, 13, 12, 11 },
         start: 11,
         end: 15,
         // чем выше `score`, тем лучше
         score: 104
       },
       // ...
     ]
   */
   // Обновляем результаты
   setResults(results)
 }

 return (
   <>
     <form onSubmit={search}>
       <input
         type='text'
         value={value}
         onChange={(e) => setValue(e.target.value)}
       />
       <button>Search</button>
     </form>
     <ul>
       {results.map((result, i) => (
         <li key={i}>
           <HighlightChars
             url={result.item.url}
             positions={result.positions}
           />
         </li>
       ))}
     </ul>
   </>
 )
}

Если ввести в инпуте java и нажать Enter, результат будет таким:




Почему бы для такого поиска не воспользоваться методом String.prototype.includes()? Хороший вопрос.


Насколько я понимаю, fzf выигрывает в производительности при большом количестве объектов для поиска и при больших (длинных) значениях строки для поиска. Кроме того, он умеет определять наличие в строке искомых символов, которые находятся в разных местах строки. Например, предположим, что у нас имеется строка aaYbbbbXccc и мы ввели в инпуте yx. Такая строка будет обнаружена fzf, но не методом includes(), поскольку символы y и x не следуют друг за другом.


Но, если наши объекты отсортированы по популярности, как в search-index.json, то при длине строки для поиска менее 4 символов (<= 3), как утверждают разработчики поиска, выборка подходящих объектов с помощью метода includes() будет более быстрой, чем исследование каждого объекта с помощью fzf.


Вот почему для поиска используется "оптимизирующая" обертка над fzf. Оптимизация заключается в том, что мы сокращаем список объектов, передаваемых в конструктор fzf.


fuzzy-search.ts
import { Fzf, FzfResultItem } from 'fzf'

// Тип для результата поиска
export interface Doc {
 url: string
 title: string
}

// Класс-обертка для поиска
export class FuzzySearch {
 // Данные для поиска
 docs: Doc[]

 constructor(docs: Doc[]) {
   // Инициализация данных для поиска
   this.docs = docs
 }

 // Метод для поиска
 // Принимает строку для поиска и объект с настройками:
 // настройкой по умолчанию является
 // ограничение на 10 элементов (см. ниже)
 search(needle: string, { limit = 10 }): FzfResultItem<Doc>[] {
   // Use `let` because we might come up with a new list (aka. shortlist)
   // that makes the haystack search much simpler to send to `Fzf()`.

   // Используем `let`, поскольку список переданных объектов (данных для поиска)
   // в случае, когда строка состоит менее чем из 4 символов,
   // будет сокращен
   let docs = this.docs
   // The list of docs is possible over 10,000 entries (in 2021). If the
   // search input is tiny, don't bother with the overhead of Fzf().
   // Because we don't even need it when the test is so easy in that
   // it just needs to contain a single character.

   // Количество объектов для русской локали по состоянию на 24.10.2021 составляет 2910.
   // Если строка для поиска состоит менее чем из 4 символов
   if (needle.length <= 3) {
     const needleLowerCase = needle.toLowerCase()
     // The reason this works and makes sense is because the `this.docs` is
     // already sorted by popularity.
     // So if someone searches for something short like `x` we just take
     // the top 'limit' docs that have an `x` in the `.url`. This is
     // faster than going through every doc with Fzf.

     // Причина, по которой это работает, состоит в том,
     // что объекты `this.docs` уже отсортированы по популярности.
     // Поэтому если строка для поиска состоит, например, из `x`,
     // мы просто берем 10 первых документов, в которых встречается `x`.
     // Это быстрее, чем исследование каждого документа с помощью `Fzf`.
     // Создаем переменную для сокращенного списка
     const shortlistDocs: Doc[] = []
     // Перебираем данные для поиска
     for (const doc of this.docs) {
       // Если в документе встречается строка для поиска (без учета регистра)
       if (doc.url.toLowerCase().includes(needleLowerCase)) {
         // Помещаем документ в сокращенный список
         shortlistDocs.push(doc)
         // Прекращаем перебор при достижении лимита
         if (shortlistDocs.length === limit) {
           break
         }
       }
     }
     // Suppose the needle was `yx` and the `limit` as 10, then if we only found
     // 9 (which is less than 10) docs that match exactly this, then we might
     // be missing out, so we can't use the shortlist.
     // For example, there might be more docs like `aaYbbbbXccc` which
     // will be found by Fzf() but wouldn't be find in our shortlist because
     // the two characters aren't next to each other.

     // Предположим, что строка имеет значение `yx`, а `limit` - 10,
     // если мы обнаружили только 9 (т.е. менее 10) подходящих документов,
     // то не можем использовать сокращенный список.
     // Предположим, что в документе имеется строка `aaYbbbbXccc`.
     // Такая строка будет обнаружена `Fzf`, но ее не будет в нашем сокращенном списке,
     // поскольку символы `y` и `x` не следуют друг за другом.
     // Используем сокращенный список, только если его
     // длина превышает или равняется лимиту
     if (shortlistDocs.length >= limit) {
       docs = shortlistDocs
     }
   }

   // Создаем экземпляр поиска
   const haystack = new Fzf(docs, {
     // Лимит
     limit,
     // Поле для поиска
     selector: (item: Doc) => item.url
   })
   // All longer strings, default to using the already initialized `Fzf()` instance.
   // Для более длинных строк используется существующий экземпляр поиска
   return haystack.find(needle)
 }
}

Отлично. С fzf разобрались.


В остальных случаях (когда строка запроса начинается не с / или содержит пробелы) выполняется поиск по title. Для этого используется полнотекстовый поиск.


import { useState } from 'react'
// yarn add flexsearch
import { Index } from 'flexsearch'

const data = [
 {
   title: 'Веб-технологии для разработчиков',
   url: '/ru/docs/Web'
 },
 {
   title: 'Стандартные встроенные объекты',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects'
 },
 {
   title: 'Справочник по JavaScript',
   url: '/ru/docs/Web/JavaScript/Reference'
 },
 {
   title: 'JavaScript',
   url: '/ru/docs/Web/JavaScript'
 },
 {
   title: 'Array',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects/Array'
 }
]

const flex = new Index({ tokenize: 'forward' })

data.forEach(({ title }, i) => {
 flex.add(i, title)
})

// Реализация подсветки совпадения из оригинала
const HighlightMatch = ({ title, q }) => {
 const words = q.trim().toLowerCase().split(/[ ,]+/)
 const regexWords = words.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
 const regex = `\\b(${regexWords.join('|')})`
 const parts = title.split(new RegExp(regex, 'gi'))

 return (
   <>
     {parts.map((part, i) => {
       const key = `${part}:${i}`
       if (words.includes(part.toLowerCase())) {
         return <mark key={key}>{part}</mark>
       } else {
         return <span key={key}>{part}</span>
       }
     })}
   </>
 )
}

// Компонент поиска
export const FlexSearch = () => {
 const [value, setValue] = useState('')
 const [results, setResults] = useState([])

 const search = (e) => {
   e.preventDefault()
   const indexes = flex.search(value, {
     limit: 3
   })
   // console.log(indexes) // [3, 2]
   const results = indexes.map((i) => data[i])
   setResults(results)
 }

 return (
   <>
     <form onSubmit={search}>
       <input
         type='text'
         value={value}
         onChange={(e) => setValue(e.target.value)}
       />
       <button>Search</button>
     </form>
     <ul>
       {results.map((result, i) => (
         <li key={i}>
           <HighlightMatch title={result.title} q={value} />
         </li>
       ))}
     </ul>
   </>
 )
}

При создании экземпляра в конструктор Index может передаваться объект с настройками. В данном случае мы определяем режим индексации tokenize: 'forward'. forward означает инкрементальную (частичную) индексацию в прямом направлении, т.е. вперед. Подробнее об этом можно почитать здесь.


Затем мы перебираем объекты данных для поиска и записываем их заголовки в индекс под идентификаторами (индексами объектов).


Метод flex.search() принимает строку для поиска и опциональный объект с настройками и возвращает массив индексов совпадений. Мы перебираем этот массив и извлекаем из данных для поиска соответствующие объекты.


Если ввести в инпуте java и нажать Enter, результат будет таким:




К слову, в документации flexsearch имеется ссылка на хук useFlexSearch, однако он не совместим с новым синтаксисом flexsearch. Если его немного причесать, получится следующее:


import { useState, useMemo, useEffect } from 'react'
import { Index } from 'flexsearch'

export const useFlexSearch = (query, providedIndex, store, searchOptions) => {
 const [index, setIndex] = useState(null)

 useEffect(() => {
   if (!providedIndex && !store)
     console.warn(
       'A FlexSearch index and store was not provided. Your search results will be empty.'
     )
   else if (!providedIndex)
     console.warn(
       'A FlexSearch index was not provided. Your search results will be empty.'
     )
   else if (!store)
     console.warn(
       'A FlexSearch store was not provided. Your search results will be empty.'
     )
 }, [providedIndex, store])

 useEffect(() => {
   if (!providedIndex) {
     setIndex(null)
     return
   }

   if (providedIndex instanceof Index) {
     setIndex(providedIndex)
     return
   }

   // Если `providedIndex` - это строка,
   if (typeof providedIndex === 'string') {
     const importedIndex = new Index(searchOptions)
     // Данные для поиска импортируются из локального хранилища
     importedIndex.import(providedIndex, localStorage.getItem(providedIndex))

     setIndex(importedIndex)
     return
   }
 }, [providedIndex, searchOptions])

 return useMemo(() => {
   if (!query || !index || !store) return []

   const rawResults = index.search(query, searchOptions)

   return rawResults.map((id) => store[id])
 }, [query, index, store, searchOptions])
}

Пример использования данного хука:


import { useState, useRef } from 'react'
import { Index } from 'flexsearch'
import { useFlexSearch } from 'hooks'

const data = [
 {
   title: 'Веб-технологии для разработчиков',
   url: '/ru/docs/Web'
 },
 {
   title: 'Стандартные встроенные объекты',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects'
 },
 {
   title: 'Справочник по JavaScript',
   url: '/ru/docs/Web/JavaScript/Reference'
 },
 {
   title: 'JavaScript',
   url: '/ru/docs/Web/JavaScript'
 },
 {
   title: 'Array',
   url: '/ru/docs/Web/JavaScript/Reference/Global_Objects/Array'
 }
]

const flex = new Index({ tokenize: 'forward' })

data.forEach(({ title }, i) => {
 flex.add(i, title)
})

const HighlightMatch = ({ title, q }) => {
 const words = q.trim().toLowerCase().split(/[ ,]+/)
 const regexWords = words.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
 const regex = `\\b(${regexWords.join('|')})`
 const parts = title.split(new RegExp(regex, 'gi'))

 return (
   <>
     {parts.map((part, i) => {
       const key = `${part}:${i}`
       if (words.includes(part.toLowerCase())) {
         return <mark key={key}>{part}</mark>
       } else {
         return <span key={key}>{part}</span>
       }
     })}
   </>
 )
}

export const FlexSearchHook = () => {
 const [value, setValue] = useState('')
 const inputRef = useRef()
 const results = useFlexSearch(value, flex, data)
 // console.log(results)

 const search = (e) => {
   e.preventDefault()
   setValue(inputRef.current.value.trim())
 }

 return (
   <>
     <form onSubmit={search}>
       <input type='text' defaultValue='' ref={inputRef} />
       <button>Search</button>
     </form>
     <ul>
       {results.map((result, i) => (
         <li key={i}>
           <HighlightMatch title={result.title} q={value} />
         </li>
       ))}
     </ul>
   </>
 )
}

Замечательно. С flexsearch тоже все более-менее понятно.


Примеры использования поисков и хука находятся в директории basic-search-examples.


В основном файле с кодом поиска происходит еще много интересных вещей.


search.tsx
import React, { useEffect, useMemo, useRef, useState } from 'react'
// Хуки для маршрутизации на стороне клиента -
// здесь не используются
// import { Link, useHistory } from 'react-router-dom'

// Хук для формирования выпадающего списка,
// соответствующего всем критериям доступности
import { useCombobox } from 'downshift'
// Полнотекстовый поиск по `title`
import FlexSearch from 'flexsearch'
// Хук для выполнения HTTP-запросов
import useSWR from 'swr'

// Тип и класс для "неточного" поиска по `url`
import { Doc, FuzzySearch } from './fuzzy-search'

// Хук для определения текущей локали -
// здесь не используется
// import { useLocale } from './hooks'

// Утилиты для получения плейсхолдера и
// установки фокуса на инпут при нажатии `/`,
// а также тип для пропов для поиска
import { getPlaceholder, SearchProps, useFocusOnSlash } from './search-utils'

const SHOW_INDEXING_AFTER_MS = 500

// Типы
// Для объекта данных
type Item = {
 url: string
 title: string
}
// Для поискового индекса
type SearchIndex = {
 flex: any
 fuzzy: FuzzySearch
 items: null | Item[]
}
// Для объекта результатов поиска
type ResultItem = {
 title: string
 url: string
 positions: Set<number>
}

// Хук для работы с поисковым индексом
function useSearchIndex(): readonly [
 null | SearchIndex,
 null | Error,
 () => void
] {
 // Состояние инициализации
 const [shouldInitialize, setShouldInitialize] = useState(false)
 // Состояние индекса
 const [searchIndex, setSearchIndex] = useState<null | SearchIndex>(null)
 // const locale = useLocale()

 // Default to 'en-US' if you're on the home page without the locale prefix.
 // По умолчанию локаль имеет значение 'en-US',
 // если мы находимся на главной странице без префикса локали

 // const url = `${locale}/search-index.json`
 // Поиск файла выполняется в директории `public`
 const url = `/search-index.json`

 // Если состояние инициализации имеет значение `true`,
 // вызываем хук `useSWR`:
 // первый аргумент - `url`, он же ключ для кеша,
 // второй - так называемый `fetcher`, функция
 // для запроса-получения данных,
 // третий - настройки (в данном случае мы отключаем
 // выполнение повторного запроса при установке фокуса).
 // Такая сигнатура означает, что хук ожидает инициализации
 // https://swr.vercel.app/docs/conditional-fetching
 const { error, data } = useSWR<null | Item[], Error | undefined>(
   shouldInitialize ? url : null,
   async (url: string) => {
     // Получаем ответ
     const response = await fetch(url)
     if (!response.ok) {
       // Если возникла ошибка, выбрасываем исключение
       // с сообщением в виде текста
       throw new Error(await response.text())
     }
     // Возвращаем ответ в формате `JSON`
     return await response.json()
   },
   // Отключаем повторную валидацию
   { revalidateOnFocus: false }
 )

 // Количество объектов для русской локали
 // console.log(data?.length) // 2910

 // Выполняем побочный эффект
 useEffect(() => {
   // Ничего не делаем при отсутствии данных
   // или наличии поискового индекса
   if (!data || searchIndex) {
     return
   }

   // Создаем поисковый индекс
   // `tokenize` - это режим индексации (в данном случае
   // используется инкрементальная индексация в прямом направлении, т.е. вперед)
   // https://github.com/nextapps-de/flexsearch#tokenizer-prefix-search
   const flex = new FlexSearch.Index({ tokenize: 'forward' })

   // Данными является массив объектов с заголовками и адресами статей -
   // `{ title: 'Заголовок', url: 'https://...' }`
   // Перебираем объекты и помещаем заголовок и его индекс (в массиве) в поисковый индекс
   data!.forEach(({ title }, i) => {
     flex.add(i, title)
   })

   // Создаем экземпляр неточного поиска
   const fuzzy = new FuzzySearch(data as Doc[])

   // Обновляем состояние поискового индекса
   setSearchIndex({ flex, fuzzy, items: data! })
 }, [searchIndex, shouldInitialize, data])

 // Хук возвращает мемоизированный массив из 3 элементов:
 // поискового индекса, ошибки (которая может иметь значение `null`)
 // и функции для обновления состояния инициализации
 return useMemo(
   () => [searchIndex, error || null, () => setShouldInitialize(true)],
   [searchIndex, error, setShouldInitialize]
 )
}

// The fuzzy search is engaged if the search term starts with a '/'
// and does not have any spaces in it.
// Неточный поиск используется в случае, когда строка для поиска
// начинается с `/` и не содержит пробелов.
// Данная утилита определяет, должен ли использоваться такой поиск
function isFuzzySearchString(str: string) {
 return str.startsWith('/') && !/\s/.test(str)
}

// Утилита для подсветки совпадения
// при выполнении полнотекстового поиска
// `title` - это подходящий (совпавший) заголовок
// `q` - совпадение
function HighlightMatch({ title, q }: { title: string; q: string }) {
 // FlexSearch doesn't support finding out which "typo corrections"
 // were done unfortunately.
 // See https://github.com/nextapps-de/flexsearch/issues/99
 // `FlexSearch`, к сожалению, не умеет исправлять опечатки

 // Split on higlight term and include term into parts, ignore case.
 // Разделяем совпадение на части без учета регистра
 const words = q.trim().toLowerCase().split(/[ ,]+/)

 // $& means the whole matched string
 // `$&` означает совпавшую строку целиком
 const regexWords = words.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
 const regex = `\\b(${regexWords.join('|')})`
 // Разделяем заголовок на части по совпадению
 const parts = title.split(new RegExp(regex, 'gi'))

 return (
   <b>
     {/* Перебираем части */}
     {parts.map((part, i) => {
       const key = `${part}:${i}`
       // Если часть является совпадением
       if (words.includes(part.toLowerCase())) {
         // Подсвечиваем ее
         return <mark key={key}>{part}</mark>
       } else {
         return <span key={key}>{part}</span>
       }
     })}
   </b>
 )
 // Если ввести в поиске `query`, то данная утилита вернет такую разметку
 /*
   <b>
     <span>Document.</span>
     // совпадение
     <mark>query</mark>
     <span>Selector()</span>
   </b>
 */
}

// Утилита для генерации "хлебных крошек" для адреса статьи
// и подсветки совпадения
function BreadcrumbURI({
 uri,
 positions
}: {
 uri: string
 positions?: Set<number>
}) {
 // Если имеется совпадение
 // `positions` - набор индексов совпавших символов
 if (positions && positions.size) {
   // Разбиваем строку на массив символов
   const chars = uri.split('')

   return (
     <small>
       {chars.map((char, i) => {
         // Если символ имеется в совпадении
         if (positions.has(i)) {
           return <mark key={i}>{char}</mark>
         } else {
           return <span key={i}>{char}</span>
         }
       })}
     </small>
   )
 }

 // Форматирование `url`
 // В оригинальном файле `search-index.json`
 // адреса статей начинаются с `/`, который удаляется.
 // Также удаляется `docs`
 const keep = uri
   .split('/')
   // .slice(1)
   .slice(3)
   .filter((p) => p !== 'docs')
 // При объединении элементы массива разделяются ' / '
 return <small className='search-item-uri'>{keep.join(' / ')}</small>
 // Было, например: `https://developer.mozilla.org/ru/docs/Web`,
 // стало: `ru / Web`
}

// Тип для поискового виджета
type InnerSearchNavigateWidgetProps = SearchProps & {
 onResultPicked?: () => void
 defaultSelection: [number, number]
}

// Хук для определения изменения значения инпута
function useHasNotChangedFor(value: string, ms: number) {
 // Состояние для изменений
 const [hasNotChanged, setHasNotChanged] = useState(false)
 // Предыдущее значение
 const previousValue = useRef(value)

 // Побочный эффект для определения наличия изменений
 useEffect(() => {
   // Если предыдущее значение равняется текущему,
   // ничего не делаем
   if (previousValue.current === value) {
     return
   }
   // Обновляем предыдущее значение текущим
   previousValue.current = value
   setHasNotChanged(false)
   // while timeouts are not accurate for counting time there error is only
   // upwards, meaning they might trigger after more time than specified,
   // which is fine in this case
   // Здесь речь идет о том, что таймеры не являются
   // хорошим средством для точного измерения времени,
   // поскольку могут срабатывать по истечении времени,
   // больше указанного, но в данном случае такой подход является приемлемым
   const timeout = setTimeout(() => {
     // Если значение инпута в течение указанного времени не изменилось,
     // значит, изменений не было :)
     setHasNotChanged(true)
   }, ms)
   // Очистка побочного эффекта во избежание утечек памяти
   return () => {
     clearTimeout(timeout)
   }
   // Хук запускается повторно при изменении значения инпута или задержки
 }, [value, ms])

 // Возвращаем состояние
 return hasNotChanged
}

// Виджет для поиска
function InnerSearchNavigateWidget(props: InnerSearchNavigateWidgetProps) {
 // Пропы:
 // значение инпута
 // функция для обработки изменения этого значения
 // индикатор того, находится ли инпут в фокусе
 // функция для обработки изменения состояния фокуса
 // функция для сохранения результатов поиска
 // выделение по умолчанию
 const {
   inputValue,
   onChangeInputValue,
   isFocused,
   onChangeIsFocused,
   onResultPicked,
   defaultSelection
 } = props

 // Объект истории браузера и текущая локаль -
 // здесь не используются
 // const history = useHistory()
 // const locale = useLocale()

 // Получаем поисковый индекс, ошибку и функцию для запуска инициализации поискового индекса
 const [searchIndex, searchIndexError, initializeSearchIndex] =
   useSearchIndex()

 // Ссылка на ипнут
 const inputRef = useRef<null | HTMLInputElement>(null)
 // Ссылка на форму
 const formRef = useRef<null | HTMLFormElement>(null)
 // Переменная для выделения
 const isSelectionInitialized = useRef(false)

 // Отображение результатов зависит от состояния изменений
 const showIndexing = useHasNotChangedFor(inputValue, SHOW_INDEXING_AFTER_MS)

 // Побочный эффект для инициализации выделения
 useEffect(() => {
   // Если отсутствует инпут или выделение не инициализировано, ничего не делаем
   if (!inputRef.current || isSelectionInitialized.current) {
     return
   }

   // Если инпут находится в фокусе
   if (isFocused) {
     // Выделение по умолчанию имеет значение `[0, 0]`
     inputRef.current.selectionStart = defaultSelection[0]
     inputRef.current.selectionEnd = defaultSelection[1]
   }
   // Инициализируем выделение
   isSelectionInitialized.current = true
 }, [isFocused, defaultSelection])

 // Мемоизированные результаты поиска
 const resultItems: ResultItem[] = useMemo(() => {
   // Если отсутствует поисковый индекс или инпут, или при инициализации индекса возникла ошибка,
   // ничего не делаем
   if (!searchIndex || !inputValue || searchIndexError) {
     // This can happen if the initialized hasn't completed yet or
     // completed un-successfully.
     // Это может произойти, если инициализация еще не завершилась
     // или завершилась неудачно
     return []
   }

   // The iPhone X series is 812px high.
   // If the window isn't very high, show fewer matches so that the
   // overlaying search results don't trigger a scroll.
   // Данное ограничение позволяет избежать появления
   // полосы прокрутки на экранах с маленькой высотой
   const limit = window.innerHeight < 850 ? 5 : 10

   // Если строка начинается с `/` и не содержит пробелов,
   // выполняется неточный поиск
   if (isFuzzySearchString(inputValue)) {
     // Если значением инпута является `/`
     if (inputValue === '/') {
       // Возвращаем пустой массив
       return []
     } else {
       // Выполняем поиск,
       // удаляя начальный `/`
       const fuzzyResults = searchIndex.fuzzy.search(inputValue.slice(1), {
         // Возвращается 5 или 10 лучших результатов
         limit
       })
       // Возвращаем массив объектов
       return fuzzyResults.map((fuzzyResult) => ({
         // Адрес статьи
         url: fuzzyResult.item.url,
         // Заголовок статьи
         title: fuzzyResult.item.title,
         // Набор индексов совпавших символов
         positions: fuzzyResult.positions
       }))
     }
   } else {
     // Full-Text search
     // Выполняем полнотекстовый поиск
     const indexResults: number[] = searchIndex.flex.search(inputValue, {
       // Ограничение
       limit,
       // Предположения
       suggest: true // This can give terrible result suggestions
       // Это может приводить к ужасным результатам :)
     })
     // Возвращаем массив оригинальных объектов
     return indexResults.map(
       (index: number) => (searchIndex.items || [])[index] as ResultItem
     )
   }
   // Результаты вычисляются повторно при изменении
   // значения инпута, поискового индекса или возникновении ошибки
 }, [inputValue, searchIndex, searchIndexError])

 // Базовый адрес для выполнения HTTP-запроса
 // const formAction = `/${locale}/search`
 const formAction = `https://developer.mozilla.org/ru-RU/search`
 // Формируем полный путь для поиска
 const searchPath = useMemo(() => {
   // Новая строка запроса
   const sp = new URLSearchParams()
   // Получается `?q=inputValue`
   sp.set('q', inputValue.trim())
   // Возвращаем полный путь
   // Если ввести в инпуте `qwerty`,
   // вернется `https://developer.mozilla.org/ru/search?q=qwerty`
   return `${formAction}?${sp.toString()}`
 }, [formAction, inputValue])

 // Пустой результат - когда ничего не найдено
 const nothingFoundItem = useMemo(
   () => ({ url: searchPath, title: '', positions: new Set() }),
   [searchPath]
 )

 // Формируем выпадающий список, отвечающий всем критериям доступности
 const {
   getInputProps,
   getItemProps,
   getMenuProps,
   getComboboxProps,

   // Индекс элемента, на которого наведен курсор или который находится в фокусе
   highlightedIndex,
   // Индикатор того, находится ли меню в открытом состоянии
   isOpen,

   reset,
   toggleMenu
 } = useCombobox({
   // Результаты поиска или пустой результат в виде массива из одного элемента
   items: resultItems.length === 0 ? [nothingFoundItem] : resultItems,
   // Значение инпута
   inputValue,
   // Список по умолчанию будет находится в открытом состоянии
   // только при фокусировке на инпуте
   defaultIsOpen: isFocused,
   // При клике по выделенному (highlightedIndex) результату
   onSelectedItemChange: ({ selectedItem }) => {
     if (selectedItem) {
       // history.push(selectedItem.url)
       // Открывается новое окно с соответствующей статьей на MDN
       window.open(selectedItem.url, '_blank')
       // Значение инпута обнуляется
       onChangeInputValue('')
       // Выполняется сброс меню
       reset()
       // Меню скрывается
       toggleMenu()
       // Расфокусировка
       inputRef.current?.blur()
       // Если передана функция для сохранения результатов,
       // вызываем ее
       if (onResultPicked) {
         onResultPicked()
       }
       // Плавная прокрутка в верхнюю часть области просмотра
       window.scroll({
         top: 0,
         left: 0,
         behavior: 'smooth'
       })
     }
   }
 })

 // При нажатии `/` на инпут устанавливается фокус
 useFocusOnSlash(inputRef)

 // Побочный эффект для инициализации поискового индекса
 useEffect(() => {
   // Если инпут находится в фокусе
   if (isFocused) {
     // Выполняем инициализацию
     initializeSearchIndex()
   }
 }, [initializeSearchIndex, isFocused])

 // Результаты поиска - рендеринг
 // значением переменной является `IIFE` - автоматически вызываемое функциональное выражение
 const searchResults = (() => {
   // Если меню закрыто, т.е. инпут не находится в фокусе,
   // или значение инпута является пустая строка (без учета пробелов),
   // ничего не делаем
   if (!isOpen || !inputValue.trim()) {
     return null
   }

   // Если при инициализации поискового индекса возникла ошибка
   if (searchIndexError) {
     return (
       // <div className='searchindex-error'>Error initializing search index</div>
       <div className='searchindex-error'>
         При инициализации поискового индекса возникла ошибка
       </div>
     )
   }

   // Если инициализация индекса еще не завершилась
   if (!searchIndex) {
     // и не было изменений значения инпута на протяжении 500 мс
     return showIndexing ? (
       <div className='indexing-warning'>
         {/* <em>Initializing index</em> */}
         <em>Инициализация индекса</em>
       </div>
     ) : null
   }

   return (
     <>
       {/* Если отсутствуют результаты поиска и значением инпута не является `/` */}
       {resultItems.length === 0 && inputValue !== '/' ? (
         <div
           {...getItemProps({
             className:
               'nothing-found result-item ' +
               (highlightedIndex === 0 ? 'highlight' : ''),
             // Возвращаем пустой результат
             item: nothingFoundItem,
             index: 0
           })}
         >
           {/* No document titles found. */}
           Подходящих статей не найдено.
           <br />
           {/* <Link to={searchPath}>
             Site search for `{inputValue}`
           </Link> */}
           {/* Используется полный адрес статьи, сформированный ранее */}
           <a href={searchPath} target='_blank' rel='noopener noreferrer'>
             Искать на сайте MDN `{inputValue}`
           </a>
         </div>
       ) : (
         // Перебираем результаты поиска
         resultItems.map((item, i) => (
           <div
             {...getItemProps({
               key: item.url,
               className:
                 'result-item ' + (i === highlightedIndex ? 'highlight' : ''),
               item,
               index: i
             })}
           >
             {/* Выполняем подсветку совпадения */}
             <HighlightMatch title={item.title} q={inputValue} />
             <br />
             {/* Формируем хлебные крошки для адреса статьи */}
             <BreadcrumbURI uri={item.url} positions={item.positions} />
           </div>
         ))
       )}
       {/* Если выполняется неточный поиск */}
       {isFuzzySearchString(inputValue) && (
         // <div className='fuzzy-engaged'>Fuzzy searching by URI</div>
         <div className='fuzzy-engaged'>Выполняется поиск по URI</div>
       )}
     </>
   )
 })()

 // Рендеринг
 return (
   <form
     action={formAction}
     {...getComboboxProps({
       // Ссылка на форму
       ref: formRef as any, // downshift's types hardcode it as a div
       // для `downshift` все есть `div` с точки зрения типов :)
       className: 'search-form',
       id: 'nav-main-search',
       role: 'search',
       // Обработчик отправки формы
       onSubmit: (e) => {
         // This comes into effect if the input is completely empty and the
         // user hits Enter, which triggers the native form submission.
         // When something *is* entered, the onKeyDown event is triggered
         // on the <input> and within that handler you can
         // access `event.key === 'Enter'` as a signal to submit the form.

         // Здесь речь идет о том, что если инпут является пустым и
         // пользователь нажимает `Enter`, выполняется нативная отправка формы.
         // Если же инпут не является пустым, отправку формы можно перехватить
         // с помощью`event.key === 'Enter'` в обработчике
         // события нажатия клавиши `onKeyDown`.
         // Отключаем дефолтную обработку отправки формы
         e.preventDefault()
         // Это сломало кнопку для отправки формы в виде лупы (см. ниже)
       }
     })}
   >
     <div className='search-field'>
       <label htmlFor='main-q' className='visually-hidden'>
         {/* Search MDN */}
         Поиск MDN
       </label>

       <input
         {...getInputProps({
           type: 'search',
           className: isOpen
             ? 'has-search-results search-input-field'
             : 'search-input-field',
           id: 'main-q',
           name: 'q',
           // Формируем плейсхолдер с помощью утилиты
           placeholder: getPlaceholder(isFocused),
           // При наведении курсора на инпут
           // запускается инициализация поискового индекса
           onMouseOver: initializeSearchIndex,
           // Метод для обновления состояния фокуса
           onFocus: () => {
             onChangeIsFocused(true)
           },
           // и здесь
           onBlur: () => onChangeIsFocused(false),
           // Обработчик нажатия клавиш
           onKeyDown(event) {
             // Если нажата клавиша `Escape` и имеется инпут
             if (event.key === 'Escape' && inputRef.current) {
               // Меню скрывается, инпут очищается
               toggleMenu()
             } else if (
               // Если нажата клавиша `Enter`, инпут не является пустым и отсутствует выделенный результат поиска
               event.key === 'Enter' &&
               inputValue.trim() &&
               highlightedIndex === -1
             ) {
               // Убираем фокус с инпута
               inputRef.current!.blur()
               // Отправляем форму
               // formRef.current!.submit()
               // Переходим на страницу поиска MDN
               window.open(searchPath, '_blank')
             }
           },
           // обработчик изменения значения инпута
           onChange(event) {
             if (event.target instanceof HTMLInputElement) {
               onChangeInputValue(event.target.value)
             }
           },
           // Ссылка на инпут
           ref: (input) => {
             inputRef.current = input
           }
         })}
       />

       {/* Кнопка для отправки формы, которая не работала */}
       {/* создал пулреквест :) */}
       <input
         type='submit'
         className='search-button'
         value=''
         aria-label='Поиск'
         onClick={() => {
           if (inputValue.trim() && inputValue !== '/') {
             // history.push(searchPath)
             window.open(searchPath, '_blank')
           }
         }}
       />
     </div>

     {/* Выпадающий список с результатами */}
     <div {...getMenuProps()}>
       {searchResults && <div className='search-results'>{searchResults}</div>}
     </div>
   </form>
 )
}

// Предохранитель - обработчик ошибок
class SearchErrorBoundary extends React.Component {
 // Состояние ошибки
 state = { hasError: false }

 // Регистрация ошибки
 static getDerivedStateFromError(error: Error) {
   // console.error('There was an error while trying to render search', error)
   console.error('В процессе рендеринга поиска возникла ошибка', error)
   // Обновляем состояние ошибки
   return { hasError: true }
 }
 render() {
   return this.state.hasError ? (
     // <div>Error while rendering search. Check console for details.</div>
     <div>
       В процессе рендеринга поиска возникла ошибка. Загляните в консоль за подробностями.
     </div>
   ) : (
     this.props.children
   )
 }
}

// Экспорт виджета для поиска, обернутого в предохранитель
export default function SearchNavigateWidget(
 props: InnerSearchNavigateWidgetProps
) {
 return (
   <SearchErrorBoundary>
     <InnerSearchNavigateWidget {...props} />
   </SearchErrorBoundary>
 )
}

С тем, как работает хук useCombobox, я предоставлю вам возможность разобраться самостоятельно (вот более чем подробная документация).


Пожалуй, это все, чем я хотел поделиться с вами в этой статье.


Теперь в вашем распоряжении имеется готовый к использованию продвинутый компонент для поиска, который легко приспособить для нужд конкретного приложения.


Что касается моего личного мнения по поводу "крутости" поиска, безусловно, он очень хорош. Я бы немного упростил код, поскольку нахожу несколько вещей лишними. Также я убрал бы парочку оптимизаций, поскольку они стоят дороже повторного рендеринга, который в любом случае происходит из-за большого количества постоянно меняющихся зависимостей. Также в оригинале, судя по всему, отключена проверка на неявный тип any ("noImplicitAny": false), что, на мой взгляд, не есть правильно.


Надеюсь, вам было также интересно, как и мне.


Приветствуется любая форма обратной связи.


Благодарю за внимание и хорошего дня!




Комментарии (4)


  1. nin-jin
    28.10.2021 17:40
    +4

    Отлично, давайте сделаем то же самое на $mol..

    Сперва, нам потребуется тип для модельки страницы:

    // DTO информации о странице
    export type $my_search_page = {
    	title: string,
    	path: string,
    }

    Теперь опишем кастомное поле поиска страниц:

    $my_search $mol_search
    	- \Наследуемся от обычного поискового поля с подсказками
    	pages /$my_search_page
    		- \Данные всех страниц
    	Suggest_link!id $mol_link
    		- \Вьюшка пункта подсказки будет ссылкой
    		uri <= suggest_uri!id \
    		sub /
    			<= Suggest_info!id $mol_list
    				- \Вертикальный лейаут с виртуализацией
    				rows /
    					<= Suggest_title!id $mol_dimmer
    						- \Подсветка поискового запроса в заголовке
    						haystack <= suggest_title!id \
    						needle <= query
    					<= Suggest_path!id $mol_dimmer
    						- \Подсветка поискового запроса в пути
    						haystack <= suggest_path!id \
    						needle <= query

    Добавляем немного логики:

    // Расширяем декларативное описание компонента
    export class $my_search extends $.$my_search {
    	
    	// Словарик для быстрого поиска страницы по пути
    	@ $mol_mem
    	dictionary() {
    		const pages = this.pages()
    		const dict = new Map
    		for( const page of pages ) dict.set( page.path, page )
    		return dict
    	}
    	
    	// Список путей, соответствующих поисковому запросу
    	@ $mol_mem
    	suggests() {
    		return this.pages()
    			.filter( $mol_match_text( this.query(), page => [ page.path, page.title ] ) )
    			.slice( 0, 100 )
    			.map( page => page.path )
    	}
    	
    	// Выпадающее меню из наших вьюшек
    	menu_items() {
    		return this.suggests().map( ( path : string ) => this.Suggest_link( path ) )
    	}
    	
    	// Название страницы
    	suggest_title( path: string ) {
    		return this.dictionary().get( path ).title
    	}
    	
    	// Отображаемый путь к странице
    	suggest_path( path: string ) {
    		return path
    	}
    	
    	// Путь для перехода
    	suggest_uri( path: string ) {
    		return 'https://developer.mozilla.org' + path
    	}
    	
    }

    Добавляем немного красоты:

    $mol_style_define( $my_search, {
    	
    	// Название страниц чуть жирнее
    	Suggest_title: {
    		textShadow: '0 0',
    	},
    	
    	// Пути красим обычным цветом текста
    	Suggest_path: {
    		color: $mol_theme.text,
    	},
    	
    } )

    Поле поиска готова. Давайте теперь воспользуемся им. Создадим демку:

    $my_search_demo $mol_view
    	title \Демка поиска
    	pages_uri \https://developer.mozilla.org/ru/search-index.json
    		- \Откуда качать данные
    	sub /
    		<= Row $mol_row
    			sub /
    				<= Search $my_search
    					- \Используем наше поисковое поле
    					pages <= pages /$my_search_page

    Опишем типизированный валидатор ответа от сервера.

    // Схема ответа сервера
    const Response = $mol_data_array(
    	$mol_data_record({
    		path: $mol_data_string,
    		title: $mol_data_string,
    	})
    )

    Загрузим данные с сервера и провалидируем их:

    // Логика для демки поиска
    export class $my_search_demo extends $.$my_search_demo {
    	
      @ $mol_mem
    	pages() {
        // Грузим и валидируем JSON получая на выходе корректный тип
    		return Response( $mol_fetch.json( this.pages_uri() ) )
    	}
      
    }

    Вуаля:


    1. laatoo
      29.10.2021 07:04
      +1

      давайте сделаем то же самое [поисковое поле с подсказками как у MDN] на $mol

      давайте


      1. Наследуемся от обычного поискового поля с подсказками
      2. [делаем чтобы как у MDN]

      читер!


      1. nin-jin
        29.10.2021 11:02
        -2

        Да, на $mol не обязательно изобретать велосипеды с нуля, ибо всегда можно взять похожий компонент и допилить его под свои нужны.


  1. demimurych
    01.11.2021 17:21

    это все прекрасно, только бесполезно чуть более чем полностью, по той простой причине, что адекватность запросу, выдаваемой информации, стремиться к нулю. Как следствие все равно пользуешься поиском от корпорации бобра.

    интересно, когда на mdn найдут ресурс починить сломаные к чертям локализации переводов?

    или вернут виджет выбора языка на свое место. А то люди уже устали придумывать версии, накой черт его перенесли вниз страницы