ลองทำ Gatsby Search ค้นหาบทความด้วย Algolia

Chai Phonbopit

Software Engineer & Blogger

14 April 2020

In

สวัสดีครับ บทความนี้ผมจะมาแชร์ วิธีการทำ Search บนเว็บไซต์นะครับ เนื่องจากว่าผมเพิ่งลองใส่ช่อง ค้นหา ที่บล็อกนี้ โดยใช้ Algolia นั่นเอง

และตัวบล็อกของผมเป็น Gatsby ที่ built เป็น Static Web ฉะนั้น ผมก็เลยใช้ Gatsby + Algolia นั่นเอง

โดยทั้งหมด ผมได้ไอเดียและ Refenence จากบทความนี้ Adding Search with Algolia สามารถอ่านเพิ่มเติมได้ครับ

เราลองมาทำ Gatsby Search ด้วย Algolia กันดูนะครับ โดยผมจะใช้ Gatsby Starter Blog ครับ เป็น Starter project

ทำไมต้อง Algolia?

เพราะว่า Algolia นั้นเป็น Service ที่ทำ Search โดยเฉพาะอยู่แล้ว ช่วยให้เราสามารถทำ Search บนเว็บไซต์ได้ ทำ Search สำหรับ Document หรือค้นหาแบบ Full Text Search ได้เช่นกัน โดยราคา ก็มีแตกต่างกันไป ซึ่งแบบฟรี สามารถใช้งานได้ 50k requests ต่อเดือน (ก็เพียงพอสำหรับเริ่มต้น หรือโปรเจ็กเล็กๆแล้วครับ)

และข้อดีคือ ตัว Algolia มี Instants Search สำหรับ React ด้วย และโดยเฉพาะ มี Gatsby Plugin ด้วยเช่นกัน

Create Project

เริ่มต้นด้วยการ สร้าง Project จาก Gatsby CLI ครับ (ใครไม่มีอย่าลืมติดตั้งด้วย npm install gatsby-cli -g นะครับ)

gatsby new my-blog-with-algolia https://github.com/gatsbyjs/gatsby-starter-blog

ติดตั้ง Plugins ที่ใช้

npm install gatsby-plugin-algolia react-instantsearch-dom algoliasearch dotenv
  • gatsby-plugin-algolia - Plugin สำหรับ create index ส่งไป Algolia
  • react-instantsearch-dom - เป็นตัว React Component สำหรับ Search / Query
  • algoliasearch - ตัว Core หลักของ Algolia
  • dotenv - เอาไว้จัดการ environment เนื่องจากเราจะใช้ API_KEY, SECRET KEY ของ Algolia ด้วย

จากนั้น ลอง Start server ขึ้นมาเลยครับ

cd my-blog-with-algolia
npm start

ตัว Gatsby Starter Blog จะเสร็จอัพอะไรต่างๆ ไว้ให้แล้ว เราสามารถเขียนบทความขึ้นใหม่ได้โดยไปที่โฟลเดอร์ content/blog เราสามารถเพิ่มโฟลเดอร์ หรือ Markdown ไฟล์ได้เลย (ตรงนี้ผมไม่พูดถึง Basic Gatsby หรือวิธีใช้ Gatsby Starter Blog เนอะ)

ลองเพิ่มบทความซักตัวนึงขึ้นมา content/blog/gatsby-with-algolia/index.md

---
title: ทดสอบสร้างบทความสำหรับ Gatsby Search with Algolia
date: "2020-04-14"
---
This is example
ทดสอบสร้างบทความ สำหรับทดสอบ Gatsby Search

Workflow

ทีนี้ขั้นตอนต่อมา เรามาดูตัว Workflow กันก่อนเลย คือ เราอยากจะให้ Algolia Search บนเว็บเราได้ เราก็ต้องเตรียมข้อมูลที่จะส่งไปให้ Algolia เก็บ โดยจะเป็น index และก็พวก meta data ต่างๆ ที่เราอยากให้เก็บ เช่น post tag title อะไรต่างๆ

เมื่อ Algolia มีข้อมูลในระบบแล้ว ต่อมา เราก็มา Implement ส่วน Gatsby โดยการทำ Search และก็จะ Request ไปที่ Algolia Server โดยใช้ API ของทาง Algolia เราก็จะได้ผลลัพธ์มาที่เว็บเรานั่นเอง ซึ่งการที่เราจะเก็บข้อมูล หรือส่ง ข้อมูลไปเก็บ index ใน Algolia ก็อาจจะมีขั้นตอนอยู่เหมือนกัน แต่โชคดี เรามี gatsby plugin เลยลดงานไปเยอะเลย

  • มอง Gatsby คือ Frontend
  • และ Algolia เสมือน Backend คอยเก็บ data เรา

จากคำอธิบายของ Workflow แล้ว เราได้ขั้นตอนการทำงานดังนี้

  1. ใช้ Gatsby Plugins เพื่อทำการส่งข้อมูลไป Algolia
  2. เราจะได้ API ก็ต้องทำการสร้าง App ใน Alogia Dashboard ก่อน (ไม่มี account ก็สมัครเลยจ้า)
  3. เมื่อข้อมูลของเราถูกเก็บไว้ที่ Algolia แล้ว ก็ทำการเพิ่ม search โดยใช้ react-instantsearch-dom
  4. เมื่อได้ผลลัพธ์​เราก็มาแสดงผลบนเว็บไซต์ของเรานั่นเอง

ส่วนวิธีการแสดงผล จะกดปุ่ม Search แล้วไปหน้าใหม่ จะโชว์ Modal จะ Overlay ก็แล้วแต่เลยครับ สำหรับบทความนี้ผมเป็นแค่ไอเดียเฉยๆ จะไม่ได้ทำหน้า UI หรือพูดถึง style นะครับ

Gatsby Plugin Config

ทำการ Config Gatsby โดยแก้ไขไฟล์ gatsby-config.js

const queries = require("./src/utils/algolia")
require("dotenv").config()
module.exports = {
plugins: [
{
resolve: `gatsby-plugin-algolia`,
options: {
appId: process.env.GATSBY_ALGOLIA_APP_ID,
apiKey: process.env.ALGOLIA_ADMIN_KEY,
queries,
chunkSize: 10000, // default: 1000
}
}
],
}

สังเกตว่า มีการโหลด dotenv ด้วย โดยใช้ไฟล์ .env นั่นเองครับ

ต่อมาเข้าไปหน้าเว็บ Algolia แล้วสร้าง App ขึ้นใหม่ จะได้ หน้า Dashboard แบบด้านล่าง เลือกไปที่ API Keys จากนั้น Copy ทั้ง 3 ค่ามาใส่ไฟล์ .env ได้เลย

Dashboard Algolia

GATSBY_ALGOLIA_APP_ID=XXXX
GATSBY_ALGOLIA_SEARCH_KEY=XXX
ALGOLIA_ADMIN_KEY=XXXX

จากนั้น สร้างไฟล์ src/utils/algolia.js ขึ้นมา

const postQuery = `{
posts: allMarkdownRemark {
edges {
node {
objectID: id
fields {
slug
}
frontmatter {
title
date(formatString: "MMM D, YYYY")
}
excerpt(pruneLength: 3000)
}
}
}
}`
const flatten = arr =>
arr.map(({ node: { frontmatter, ...rest } }) => ({
...frontmatter,
...rest,
}))
const settings = {
attributeForDistinct: "slug",
distinct: true,
}
const queries = [
{
query: postQuery,
transformer: ({ data }) => flatten(data.posts.edges),
indexName: `Posts`,
settings,
},
]
module.exports = queries

โค๊ดด้านบน จะเป็น Query เพื่อดึง data จาก markdown file ที่เราเขียนบทความนั่นเอง ด้วย allMarkdownRemark สิ่งที่เราจะส่งไปเก็บคือ title, date, slug ครับ โดย index name เราตั้งชื่อให้มันคือ Posts ครับ

ซึ่งตัว Gatsby Plugin Algolia ถ้าเรารัน develop มันจะไม่ ทำงานครับ เราต้องรัน build ครับ ด้วยคำสั่ง

npm run build

จะสังเกตเห็น console/terminal แบบนี้

...
Algolia: 1 queries to index
Algolia: query 0: executing query
Algolia: query 0: splitting in 1 jobs
⠹ onPostBuild

ลองเข้าไปดูหน้าเว็บ Algolia จะเห็นว่า Index เรามีข้อมุลมาเก็บไว้แล้วครับ ที่ Menu Indices นั่นเอง

Add Search to Frontend

ต่อมาเมื่อเราได้ข้อมูลมาเก็บที่ Algolia (Backend) เรียบร้อย ขั้นต่อมาคือ implement search ครับ โดยตัวอย่าง ผมจะทำแค่ functional มันนะ คือ search และแสดงผลลัพธ์ได้ โดยแบ่งออกเป็น 3 ไฟล์ดังนี้

  • components/search/index.js - component หลักสำหรับแสดงช่อง search
  • components/search/input.js - สำหรับ input เพื่อค้นหา
  • components/search/result.js - ผลลัพธ์ หรือ
// components/search/index.js
import React, { useState, useEffect, createRef } from "react"
import {
InstantSearch,
Index,
Configure,
Hits,
connectStateResults,
} from "react-instantsearch-dom"
import algoliasearch from "algoliasearch/lite"
import Input from "./Input"
import { PostHit } from "./result"
const Results = connectStateResults(
({ searchState: state, searchResults: res, children }) =>
res && res.nbHits > 0 ? children : `No results for '${state.query}'`
)
const useClickOutside = (ref, handler, events) => {
if (!events) events = [`mousedown`, `touchstart`, `focus`]
const detectClickOutside = event => {
if (!ref.current) return // 🔑เพิ่มตรงนี้ เพื่อไม่ error
!ref.current.contains(event.target) && handler()
}
useEffect(() => {
for (const event of events)
document.addEventListener(event, detectClickOutside)
return () => {
for (const event of events)
document.removeEventListener(event, detectClickOutside)
}
})
}
export default function Search({ indices, collapse, hitsAsGrid }) {
const ref = createRef()
const [query, setQuery] = useState(``)
const [focus, setFocus] = useState(false)
const searchClient = algoliasearch(
process.env.GATSBY_ALGOLIA_APP_ID,
process.env.GATSBY_ALGOLIA_SEARCH_KEY
)
useClickOutside(ref, () => setFocus(false))
return (
<InstantSearch
searchClient={searchClient}
indexName={indices[0].name}
onSearchStateChange={({ query }) => setQuery(query)}
root={{ props: { ref } }}
>
<Configure distinct />
<Input
onFocus={() => setFocus(true)}
{...{ collapse, focus, setFocus }}
/>
<div className="search-content">
{indices.map(({ name, title }) => (
<Index key={name} indexName={name}>
<header>
<h3 className="search-title">{title}</h3>
</header>
<Results>
<Hits hitComponent={PostHit(() => setFocus(false))} />
</Results>
</Index>
))}
</div>
</InstantSearch>
)
}

ไฟล์ search.js จะเป็นส่วนแสดงผล การค้น มี ส่วนที่เป็น handle inside/outside เวลากดปุ่ม ค้นหาด้วย โดย InstantSearch จะเป็น container component และจัดการ onSearchStateChange เมื่อ input มีการเปลี่ยนค่าครับ

// components/search/input.js
import React from "react"
import { connectSearchBox } from "react-instantsearch-dom"
export default connectSearchBox(({ refine, setFocus, ...rest }) => {
return (
<input
type="text"
placeholder="Search"
aria-label="Search"
onChange={e => refine(e.target.value)}
{...rest}
/>
)
})

Search Input ก็ไม่มีอะไรมาก เป็นแค่ input ธรรมดา มีแค่ตอน onchange จะไปเรียก refine() ที่เป็น function ในการ query algolia

// components/search/result.js
import React from "react"
import { Highlight } from "react-instantsearch-dom"
import { Link } from "gatsby"
export const PostHit = clickHandler => ({ hit }) => {
return (
<div>
<Link to={hit.fields.slug} onClick={clickHandler}>
<span>
<Highlight attribute="title" hit={hit} tagName="mark" />
</span>
</Link>
<div>
<Highlight attribute="date" hit={hit} tagName="mark" />
</div>
</div>
)
}

ส่วนนี้ไว้แสดงผล เวลาที่เรากดค้นหาครับ สามารถใช้ component Highlight ของ instantsearch-dom ได้

ทีนี้ เมื่อเรามี 3 components เรียบร้อย ก็ไปหน้า src/pages/index.js เพื่อเพิ่มกล่อง Search ครับ โดยผมเพิ่มไว้ก่อน รายการบทความเลย ด้านล่างของ <Bio>

import Search from "../components/search"
const indices = [{ name: `Posts`, title: `Blog Posts` }]
const BlogIndex = ({ data, location }) => {
const siteTitle = data.site.siteMetadata.title
const posts = data.allMarkdownRemark.edges
return (
<Layout location={location} title={siteTitle}>
<SEO title="All posts" />
<Bio />
<Search indices={indices} />
...
}

ลอง Start server ใหม่ จะเห็นว่า เราสามารถค้นหา และแสดงผลของ Search ได้เรียบร้อย

Result

ทีนี้มันจะมีปัญหานิดนึง ตรงการ search มันจะ render ทกครั้งที่ state เปลี่ยนเพราะ searchClient ของ Algolia ครับ ที่ไฟล์ components/search/index.js

const searchClient = algoliasearch(
process.env.GATSBY_ALGOLIA_APP_ID,
process.env.GATSBY_ALGOLIA_SEARCH_KEY
)

ตรงส่วนนี้ มันจะ query ก่อน ตอน page โหลด แล้วเมื่อ state เปลี่ยน เช่น setFocus มันก็ re-render อีก แก้ด้วยการใส่ useMemo() ซะ เป็นแบบนี้

import { useMemo } from 'react'
const searchClient = useMemo(
() =>
algoliasearch(
process.env.GATSBY_ALGOLIA_APP_ID,
process.env.GATSBY_ALGOLIA_SEARCH_KEY
),
[]
)

ต่อมา ถ้าอยากหน่วง user input ไม่ให้ query เร็ว ก็ใส่พวก debounce เช่น lodash.debounce ก็ได้ครับ ง่ายดี ตัว components/search/input.js จะได้แบบนี้

import React from 'react'
import debounce from 'lodash.debounce'
import { connectSearchBox } from 'react-instantsearch-dom'
export default connectSearchBox(({ refine, setFocus, ...rest }) => {
const debouncedSearch = debounce(e => refine(e.target.value), 500)
return (
<input
type="text"
placeholder="Search"
aria-label="Search"
onChange={e => {
e.persist()
if (e.target.value === '') return
debouncedSearch(e)
}}
{...rest}
/>
)
})

ข้อควรระวัง

  • เวลาเรา index ไป algolia ตัว free plan มี size limit อยู่ครับ ฉะนั้น ถ้าเรา Query ข้อมูลโพสทั้งหมด ไปเก็บ ก็อาจจะเกิน size ได้ อาจจะเลือก excerpt น้อยๆ
  • อย่าลืมเรื่อง useMemo() สำหรับ search นะครับ ย้ำอีกครั้ง เพื่อไม่ใช่ react มัน re-render ทุกๆครั้งที่กด input หรือ focus เปลี่ยน (ผมลืม จนทำให้ Quota limit ตั้งแต่วันแรก ฮ่าๆ เมล์แจ้งมา ใช้ไป 300k queies แอพโดนบล็อกเลย ฮ่าๆ)
  • ถ้าอยากให้หน่วงเวลาเยอะๆ ไม่ให้ User ค้นหาถี่ๆ ก็ใส่ debounce เพิ่มไปครับ

ก็มีประมาณนี้ครับ หวังว่าจะเป็นประโยชน์ สำหรับใครหลายๆ คนที่ลองทำ Search และใช้ Gatsby อยู่เหมือนกัน

Source Code

Happy Coding ♥️

  • #Gatsby
  • #Gatsby.js
  • #Gatsby Search
  • #Gatsby Algolia
  • #Algolia
  • #Full Text Search
  • #Web Dev
  • #React