如何在 Gatsby 网站上添加 Lunr 搜索

Avatar of Paulina Hetman
Paulina Hetman

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

Jamstack 方式 思考和构建网站越来越受欢迎。

您是否已经尝试过 Gatsby, Nuxt,Gridsome(仅举几例)?很可能您的第一次接触是“哇!”时刻——如此多的内容自动设置并可以使用。

不过,也有一些挑战,其中之一是搜索功能。如果您正在开发任何类型的以内容为中心的网站,您可能会遇到搜索以及如何处理它。它可以在没有任何外部服务器端技术的情况下完成吗?

搜索不是 Jamstack 附带的那些东西之一。需要一些额外的决定和实现。

幸运的是,我们有很多选项可能或多或少地适应项目。我们可以使用 Algolia 的 强大的搜索即服务 API。它提供免费计划,该计划仅限于容量有限的非商业项目。如果我们要使用带有 WPGraphQL 的 WordPress 作为数据源,我们可以利用 WordPress 原生搜索功能和 Apollo 客户端。 Raymond Camden 最近 探索了一些 Jamstack 搜索选项,包括将搜索表单直接指向 Google。

在本文中,我们将构建一个搜索索引,并使用 Lunr(一个轻量级的 JavaScript 库,无需外部服务器端服务即可提供可扩展且可定制的搜索)向 Gatsby 网站添加搜索功能。我们最近在我们的 Gatsby 项目 tartanify.com 中使用它添加了“按苏格兰呢绒名称搜索”。我们绝对想要持久化的即时搜索功能,这带来了一些额外的挑战。但这正是它有趣的地方,对吧?我将在本文的后半部分讨论我们遇到的困难以及我们如何处理它们。

入门

为了简单起见,让我们使用 官方 Gatsby 博客入门。使用通用入门可以让我们抽象构建静态网站的许多方面。如果您正在关注,请确保安装并运行它

gatsby new gatsby-starter-blog https://github.com/gatsbyjs/gatsby-starter-blog
cd gatsby-starter-blog
gatsby develop

这是一个只有三个帖子的小型博客,我们可以通过在浏览器中打开http://localhost:8000/___graphql 来查看这些帖子。

Showing the GraphQL page on the localhost installation in the browser.

使用 Lunr.js 反转索引 🙃

Lunr 使用 记录级倒排索引 作为其数据结构。倒排索引存储网站中找到的每个单词与其位置(基本上是一组页面路径)的映射。由我们决定哪些字段(例如标题、内容、描述等)提供索引的键(单词)。

对于我们的博客示例,我决定包含每篇文章的所有标题和内容。处理标题很简单,因为它们由单词唯一组成。索引内容稍微复杂一些。我的第一次尝试是使用rawMarkdownBody 字段。不幸的是,rawMarkdownBody 引入了来自 Markdown 语法的某些不需要的键。

Showing an attempt at using markdown syntax for links.

我使用 html 字段结合 striptags 包获得了“干净”的索引(顾名思义,该包会剥离 HTML 标签)。在我们详细介绍之前,让我们看看 Lunr 文档。

以下是我们创建和填充 Lunr 索引的方式。我们将在稍后使用此代码片段,具体是在我们的gatsby-node.js 文件中。

const index = lunr(function () {
  this.ref('slug')
  this.field('title')
  this.field('content')
  for (const doc of documents) {
    this.add(doc)
  }
})

 documents 是一个对象数组,每个对象都具有slugtitlecontent 属性

{
  slug: '/post-slug/',
  title: 'Post Title',
  content: 'Post content with all HTML tags stripped out.'
}

我们将定义一个唯一的文档键(slug)和两个字段(titlecontent,或键提供者)。最后,我们将逐个添加所有文档。

让我们开始吧。

在 gatsby-node.js 中创建索引

让我们先安装要使用的库。

yarn add lunr graphql-type-json striptags

接下来,我们需要编辑gatsby-node.js 文件。此文件中的代码在构建网站的过程中只运行一次,我们的目标是将索引创建添加到 Gatsby 在构建时执行的任务中。

CreateResolvers 是 Gatsby API 之一,用于控制 GraphQL 数据层。在这种特定情况下,我们将使用它来创建一个新的根字段;让我们称之为LunrIndex

Gatsby 的内部数据存储和查询功能在 context.nodeModel 上暴露给 GraphQL 字段解析器。使用 getAllNodes,我们可以获取指定类型的所有节点

/* gatsby-node.js */
const { GraphQLJSONObject } = require(`graphql-type-json`)
const striptags = require(`striptags`)
const lunr = require(`lunr`)

exports.createResolvers = ({ cache, createResolvers }) => {
  createResolvers({
    Query: {
      LunrIndex: {
        type: GraphQLJSONObject,
        resolve: (source, args, context, info) => {
          const blogNodes = context.nodeModel.getAllNodes({
            type: `MarkdownRemark`,
          })
          const type = info.schema.getType(`MarkdownRemark`)
          return createIndex(blogNodes, type, cache)
        },
      },
    },
  })
}

现在让我们专注于createIndex 函数。那就是我们将使用上一节中提到的 Lunr 代码片段的地方。

/* gatsby-node.js */
const createIndex = async (blogNodes, type, cache) => {
  const documents = []
  // Iterate over all posts 
  for (const node of blogNodes) {
    const html = await type.getFields().html.resolve(node)
    // Once html is resolved, add a slug-title-content object to the documents array
    documents.push({
      slug: node.fields.slug,
      title: node.frontmatter.title,
      content: striptags(html),
    })
  }
  const index = lunr(function() {
    this.ref(`slug`)
    this.field(`title`)
    this.field(`content`)
    for (const doc of documents) {
      this.add(doc)
    }
  })
  return index.toJSON()
}

您是否注意到我们不是直接使用 const html = node.html 访问 HTML 元素,而是使用了一个 await 表达式?这是因为node.html 尚未可用。gatsby-transformer-remark 插件(由我们的入门程序用于解析 Markdown 文件)在创建MarkdownRemark 节点时不会立即从 Markdown 生成 HTML。相反, html 在查询中调用 html 字段解析器时会延迟生成。实际上,我们将在稍后需要的excerpt 也是如此。

让我们展望未来,思考一下我们将如何显示搜索结果。用户希望获得指向匹配帖子的链接,并将其标题作为锚文本。很可能,他们也不介意使用简短的摘录。

Lunr 的搜索返回一个表示匹配文档的对象数组,通过ref 属性(在我们的示例中是唯一的文档键slug)。此数组不包含文档标题或内容。因此,我们需要在某个地方存储与每个 slug 对应的帖子标题和摘录。我们可以像下面这样在我们的LunrIndex 中做到这一点

/* gatsby-node.js */
const createIndex = async (blogNodes, type, cache) => {
  const documents = []
  const store = {}
  for (const node of blogNodes) {
    const {slug} = node.fields
    const title = node.frontmatter.title
    const [html, excerpt] = await Promise.all([
      type.getFields().html.resolve(node),
      type.getFields().excerpt.resolve(node, { pruneLength: 40 }),
    ])
    documents.push({
      // unchanged
    })
    store[slug] = {
      title,
      excerpt,
    }
  }
  const index = lunr(function() {
    // unchanged
  })
  return { index: index.toJSON(), store }
}

我们的搜索索引仅在帖子之一被修改或添加新帖子时才会更改。我们不需要每次运行gatsby develop 时都重新构建索引。为了避免不必要的构建,让我们利用 缓存 API

/* gatsby-node.js */
const createIndex = async (blogNodes, type, cache) => {
  const cacheKey = `IndexLunr`
  const cached = await cache.get(cacheKey)
  if (cached) {
    return cached
  }
  // unchanged
  const json = { index: index.toJSON(), store }
  await cache.set(cacheKey, json)
  return json
}

使用搜索表单组件增强页面

现在我们可以继续进行实现的前端。让我们先构建一个搜索表单组件。

touch src/components/search-form.js 

我选择了一个简单的解决方案:一个type="search" 的输入,加上一个标签,并附带一个提交按钮,所有这些都包裹在一个带有search 地标角色的表单标签中。

我们将添加两个事件处理程序,handleSubmit 用于表单提交,handleChange 用于搜索输入的更改。

/* src/components/search-form.js */
import React, { useState, useRef } from "react"
import { navigate } from "@reach/router"
const SearchForm = ({ initialQuery = "" }) => {
  // Create a piece of state, and initialize it to initialQuery
  // query will hold the current value of the state,
  // and setQuery will let us change it
  const [query, setQuery] = useState(initialQuery)
  
  // We need to get reference to the search input element
  const inputEl = useRef(null)

  // On input change use the current value of the input field (e.target.value)
  // to update the state's query value
  const handleChange = e => {
    setQuery(e.target.value)
  }
  
  // When the form is submitted navigate to /search
  // with a query q paramenter equal to the value within the input search
  const handleSubmit = e => {
    e.preventDefault()
    // `inputEl.current` points to the mounted search input element
    const q = inputEl.current.value
    navigate(`/search?q=${q}`)
  }
  return (
    <form role="search" onSubmit={handleSubmit}>
      <label htmlFor="search-input" style={{ display: "block" }}>
        Search for:
      </label>
      <input
        ref={inputEl}
        id="search-input"
        type="search"
        value={query}
        placeholder="e.g. duck"
        onChange={handleChange}
      />
      <button type="submit">Go</button>
    </form>
  )
}
export default SearchForm

您是否注意到我们从@reach/router 包中导入navigate?这是必要的,因为 Gatsby 的<Link/> 或者navigate 都不提供带有查询参数的路由内导航。相反,我们可以导入@reach/router——无需安装它,因为 Gatsby 已经包含它——并使用它的navigate 函数。

现在我们已经构建了我们的组件,让我们将它添加到我们的主页(如下)和 404 页面中。

/* src/pages/index.js */
// unchanged
import SearchForm from "../components/search-form"
const BlogIndex = ({ data, location }) => {
  // unchanged
  return (
    <Layout location={location} title={siteTitle}>
      <SEO title="All posts" />
      <Bio />
      <SearchForm />
      // unchanged

搜索结果页面

我们的SearchForm 组件在提交表单时导航到/search 路由,但目前,此 URL 后面没有任何内容。这意味着我们需要添加一个新页面

touch src/pages/search.js 

我通过复制并调整index.js 页面的内容来进行操作。其中一项重要修改涉及 页面查询(参见文件的最底部)。我们将用LunrIndex 字段替换allMarkdownRemark

/* src/pages/search.js */
import React from "react"
import { Link, graphql } from "gatsby"
import { Index } from "lunr"
import Layout from "../components/layout"
import SEO from "../components/seo"
import SearchForm from "../components/search-form"


// We can access the results of the page GraphQL query via the data props
const SearchPage = ({ data, location }) => {
  const siteTitle = data.site.siteMetadata.title
  
  // We can read what follows the ?q= here
  // URLSearchParams provides a native way to get URL params
  // location.search.slice(1) gets rid of the "?" 
  const params = new URLSearchParams(location.search.slice(1))
  const q = params.get("q") || ""


  // LunrIndex is available via page query
  const { store } = data.LunrIndex
  // Lunr in action here
  const index = Index.load(data.LunrIndex.index)
  let results = []
  try {
    // Search is a lunr method
    results = index.search(q).map(({ ref }) => {
      // Map search results to an array of {slug, title, excerpt} objects
      return {
        slug: ref,
        ...store[ref],
      }
    })
  } catch (error) {
    console.log(error)
  }
  return (
    // We will take care of this part in a moment
  )
}
export default SearchPage
export const pageQuery = graphql`
  query {
    site {
      siteMetadata {
        title
      }
    }
    LunrIndex
  }
`

现在我们知道如何检索查询值和匹配的帖子,让我们显示页面的内容。请注意,在搜索页面上,我们通过initialQuery 属性将查询值传递给 <SearchForm /> 组件。当用户到达搜索结果页面时,他们的搜索查询应该保留在输入字段中。

return (
  <Layout location={location} title={siteTitle}>
    <SEO title="Search results" />
    {q ? <h1>Search results</h1> : <h1>What are you looking for?</h1>}
    <SearchForm initialQuery={q} />
    {results.length ? (
      results.map(result => {
        return (
          <article key={result.slug}>
            <h2>
              <Link to={result.slug}>
                {result.title || result.slug}
              </Link>
            </h2>
            <p>{result.excerpt}</p>
          </article>
        )
      })
    ) : (
      <p>Nothing found.</p>
    )}
  </Layout>
)

您可以在这个 gatsby-starter-blog 分支 中找到完整的代码,以及 部署在 Netlify 上的实时演示

即时搜索小部件

找到实现搜索的最“逻辑”和用户友好的方法本身可能是一个挑战。现在让我们转向 tartanify.com 的现实世界示例——一个由 Gatsby 提供支持的网站,收集了 5,000 多种苏格兰格纹图案。由于苏格兰格纹通常与氏族或组织相关联,因此可以通过名称搜索苏格兰格纹似乎很有意义。 

我们构建了 tartanify.com 作为一项副项目,我们在这里可以自由地尝试各种事物。我们不想使用经典的搜索结果页面,而是想使用一个即时搜索“小部件”。通常,给定的搜索关键字对应多个结果——例如,“Ramsay” 有六种变体。 我们设想搜索小部件将是持久存在的,这意味着当用户从一个匹配的苏格兰格纹导航到另一个匹配的苏格兰格纹时,它应该保持在原位。

让我向您展示我们如何使用 Lunr 使其工作。  构建索引的第一步与 gatsby-starter-blog 示例非常相似,只是更简单

/* gatsby-node.js */
exports.createResolvers = ({ cache, createResolvers }) => {
  createResolvers({
    Query: {
      LunrIndex: {
        type: GraphQLJSONObject,
        resolve(source, args, context) {
          const siteNodes = context.nodeModel.getAllNodes({
            type: `TartansCsv`,
          })
          return createIndex(siteNodes, cache)
        },
      },
    },
  })
}
const createIndex = async (nodes, cache) => {
  const cacheKey = `LunrIndex`
  const cached = await cache.get(cacheKey)
  if (cached) {
    return cached
  }
  const store = {}
  const index = lunr(function() {
    this.ref(`slug`)
    this.field(`title`)
    for (node of nodes) {
      const { slug } = node.fields
      const doc = {
        slug,
        title: node.fields.Unique_Name,
      }
      store[slug] = {
        title: doc.title,
      }
      this.add(doc)
    }
  })
  const json = { index: index.toJSON(), store }
  cache.set(cacheKey, json)
  return json
}

我们选择了即时搜索,这意味着搜索由搜索输入中的任何更改触发,而不是表单提交。

/* src/components/searchwidget.js */
import React, { useState } from "react"
import lunr, { Index } from "lunr"
import { graphql, useStaticQuery } from "gatsby"
import SearchResults from "./searchresults"


const SearchWidget = () => {
  const [value, setValue] = useState("")
  // results is now a state variable 
  const [results, setResults] = useState([])


  // Since it's not a page component, useStaticQuery for quering data
  // https://www.gatsbyjs.org/docs/use-static-query/
  const { LunrIndex } = useStaticQuery(graphql`
    query {
      LunrIndex
    }
  `)
  const index = Index.load(LunrIndex.index)
  const { store } = LunrIndex
  const handleChange = e => {
    const query = e.target.value
    setValue(query)
    try {
      const search = index.search(query).map(({ ref }) => {
        return {
          slug: ref,
          ...store[ref],
        }
      })
      setResults(search)
    } catch (error) {
      console.log(error)
    }
  }
  return (
    <div className="search-wrapper">
      // You can use a form tag as well, as long as we prevent the default submit behavior
      <div role="search">
        <label htmlFor="search-input" className="visually-hidden">
          Search Tartans by Name
        </label>
        <input
          id="search-input"
          type="search"
          value={value}
          onChange={handleChange}
          placeholder="Search Tartans by Name"
        />
      </div>
      <SearchResults results={results} />
    </div>
  )
}
export default SearchWidget

SearchResults 的结构如下

/* src/components/searchresults.js */
import React from "react"
import { Link } from "gatsby"
const SearchResults = ({ results }) => (
  <div>
    {results.length ? (
      <>
        <h2>{results.length} tartan(s) matched your query</h2>
        <ul>
          {results.map(result => (
            <li key={result.slug}>
              <Link to={`/tartan/${result.slug}`}>{result.title}</Link>
            </li>
          ))}
        </ul>
      </>
    ) : (
      <p>Sorry, no matches found.</p>
    )}
  </div>
)
export default SearchResults

使其持久化

我们应该在哪里使用此组件?我们可以将其添加到 Layout 组件中。问题是我们的搜索表单将以这种方式在页面更改时卸载。如果用户想要浏览与“Ramsay” 氏族相关的所有苏格兰格纹,他们将不得不多次重新输入他们的查询。这不是理想的。

Thomas Weibenfalk 撰写了一篇关于 在 Gatsby.js 中使用本地状态在页面之间保持状态 的很棒的文章。我们将使用相同的技术,其中 wrapPageElement 浏览器 API 在页面周围设置持久 UI 元素。 

让我们将以下代码添加到 gatsby-browser.js 中。您可能需要将此文件添加到项目的根目录中。

/* gatsby-browser.js */
import React from "react"
import SearchWrapper from "./src/components/searchwrapper"
export const wrapPageElement = ({ element, props }) => (
  <SearchWrapper {...props}>{element}</SearchWrapper>
)

现在让我们添加一个新的组件文件

touch src/components/searchwrapper.js

与其将 SearchWidget 组件添加到 Layout 中,不如将其添加到 SearchWrapper 中,神奇的事情就会发生。✨

/* src/components/searchwrapper.js */
import React from "react"
import SearchWidget from "./searchwidget"


const SearchWrapper = ({ children }) => (
  <>
    {children}
    <SearchWidget />
  </>
)
export default SearchWrapper

创建自定义搜索查询

此时,我开始尝试不同的关键字,但很快意识到,当用于即时搜索时,Lunr 的默认搜索查询可能不是最佳解决方案。

为什么?假设我们正在寻找与MacCallum 这个名字相关的苏格兰格纹。在逐个字母键入“MacCallum” 时,这是结果的演变

  • m – 2 个匹配项(Lyon, Jeffrey M, Lyon, Jeffrey M (Hunting))
  • ma – 没有匹配项
  • mac – 1 个匹配项(Brighton Mac Dermotte)
  • macc – 没有匹配项
  • macca – 没有匹配项
  • maccal – 1 个匹配项(MacCall)
  • maccall – 1 个匹配项(MacCall)
  • maccallu – 没有匹配项
  • maccallum – 3 个匹配项(MacCallum, MacCallum #2, MacCallum of Berwick)

如果我们提供一个按钮,用户可能会键入完整名称并点击该按钮。但使用即时搜索,用户很可能在早期放弃,因为他们可能会期望结果只能缩小添加的关键字查询中的字母。

 这不是唯一的问题。以下是使用“Callum” 获得的结果

  • c – 3 个不相关的匹配项
  • ca – 没有匹配项
  • cal – 没有匹配项
  • call – 没有匹配项
  • callu – 没有匹配项
  • callum – 1 个匹配项 

如果有人在键入完整查询中途放弃,您就会明白其中的问题。

幸运的是,Lunr 支持更复杂的查询,包括模糊匹配、通配符和布尔逻辑(例如 AND、OR、NOT)用于多个术语。所有这些都可以通过特殊的查询语法使用,例如: 

index.search("+*callum mac*")

我们也可以使用 索引 query 方法 以编程方式进行处理。

第一个解决方案并不令人满意,因为它需要用户付出更多努力。我改为使用了 index.query 方法

/* src/components/searchwidget.js */
const search = index
  .query(function(q) {
    // full term matching
    q.term(el)
    // OR (default)
    // trailing or leading wildcard
    q.term(el, {
      wildcard:
        lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,
    })
  })
  .map(({ ref }) => {
    return {
      slug: ref,
      ...store[ref],
    }
  })

为什么要使用完整术语匹配和通配符匹配?对于所有“受益”于 词干提取过程 的关键字,这都是必要的。例如,“different” 的词干是 “differ”。 因此,使用通配符的查询——例如 differe*differen* 或  different*——都会导致无匹配项,而完整术语查询 differedifferendifferent 会返回匹配项。

模糊匹配也可以使用。在我们的案例中,它们仅允许用于五个或更多个字符的术语

q.term(el, { editDistance: el.length > 5 ? 1 : 0 })
q.term(el, {
  wildcard:
    lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,
})

handleChange 函数还会“清理”用户输入并忽略单个字符术语

/* src/components/searchwidget.js */  
const handleChange = e => {
  const query = e.target.value || ""
  setValue(query)
  if (!query.length) {
    setResults([])
  }
  const keywords = query
    .trim() // remove trailing and leading spaces
    .replace(/\*/g, "") // remove user's wildcards
    .toLowerCase()
    .split(/\s+/) // split by whitespaces
  // do nothing if the last typed keyword is shorter than 2
  if (keywords[keywords.length - 1].length < 2) {
    return
  }
  try {
    const search = index
      .query(function(q) {
        keywords
          // filter out keywords shorter than 2
          .filter(el => el.length > 1)
          // loop over keywords
          .forEach(el => {
            q.term(el, { editDistance: el.length > 5 ? 1 : 0 })
            q.term(el, {
              wildcard:
                lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,
            })
          })
      })
      .map(({ ref }) => {
        return {
          slug: ref,
          ...store[ref],
        }
      })
    setResults(search)
  } catch (error) {
    console.log(error)
  }
}

让我们检查一下它的实际效果

  • m – 正在处理
  • ma – 861 个匹配项
  • mac – 600 个匹配项
  • macc – 35 个匹配项
  • macca – 12 个匹配项
  • maccal – 9 个匹配项
  • maccall – 9 个匹配项
  • maccallu – 3 个匹配项
  • maccallum – 3 个匹配项

搜索“Callum” 也能正常工作,并产生四个匹配项:Callum、MacCallum、MacCallum #2 和 MacCallum of Berwick。

不过,还有一个问题:多术语查询。假设您正在寻找“Loch Ness”。有两个苏格兰格纹与该术语相关联,但使用默认的 OR 逻辑,您将获得 96 个结果总计。(苏格兰还有很多其他湖泊。)

我最终决定 AND 搜索对于这个项目来说会更好。不幸的是,Lunr 不支持嵌套查询,而我们实际上需要的是 (keyword1 OR *keyword*) AND (keyword2 OR *keyword2*)。 

为了克服这个问题,我最终将术语循环移到 query 方法之外,并按术语对结果进行交集。(通过交集,我的意思是找到出现在所有单个关键字结果中的所有 slug。)

/* src/components/searchwidget.js */
try {
  // andSearch stores the intersection of all per-term results
  let andSearch = []
  keywords
    .filter(el => el.length > 1)
    // loop over keywords
    .forEach((el, i) => {
      // per-single-keyword results
      const keywordSearch = index
        .query(function(q) {
          q.term(el, { editDistance: el.length > 5 ? 1 : 0 })
          q.term(el, {
            wildcard:
              lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,
          })
        })
        .map(({ ref }) => {
          return {
            slug: ref,
            ...store[ref],
          }
        })
      // intersect current keywordSearch with andSearch
      andSearch =
        i > 0
          ? andSearch.filter(x => keywordSearch.some(el => el.slug === x.slug))
          : keywordSearch
    })
  setResults(andSearch)
} catch (error) {
  console.log(error)
}

用于 tartanify.com 的源代码已发布在 GitHub 上。您可以在那里看到 Lunr 搜索的完整实现。

最后的思考

搜索通常是网站上查找内容不可或缺的功能。搜索功能的实际重要性可能因项目而异。然而,没有理由以它与 Jamstack 网站的静态特性不符为借口而放弃它。有很多可能性。我们刚刚讨论了其中之一。

而且,矛盾的是,在这个具体的例子中,由于实现搜索并非一项显而易见的任务,而是需要大量的思考,因此结果是更好的全方位用户体验。如果使用现成的解决方案,我们可能无法这么说。