ลองทำ Gatsby Search ค้นหาบทความด้วย Algolia
 สวัสดีครับ บทความนี้ผมจะมาแชร์ วิธีการทำ 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 dotenvgatsby-plugin-algolia- Plugin สำหรับ create index ส่งไป Algoliareact-instantsearch-dom- เป็นตัว React Component สำหรับ Search / Queryalgoliasearch- ตัว Core หลักของ Algoliadotenv- เอาไว้จัดการ environment เนื่องจากเราจะใช้ API_KEY, SECRET KEY ของ Algolia ด้วย
จากนั้น ลอง Start server ขึ้นมาเลยครับ
cd my-blog-with-algolianpm startตัว Gatsby Starter Blog จะเสร็จอัพอะไรต่างๆ ไว้ให้แล้ว เราสามารถเขียนบทความขึ้นใหม่ได้โดยไปที่โฟลเดอร์ content/blog เราสามารถเพิ่มโฟลเดอร์ หรือ Markdown ไฟล์ได้เลย (ตรงนี้ผมไม่พูดถึง Basic Gatsby หรือวิธีใช้ Gatsby Starter Blog เนอะ)
ลองเพิ่มบทความซักตัวนึงขึ้นมา content/blog/gatsby-with-algolia/index.md
---title: ทดสอบสร้างบทความสำหรับ Gatsby Search with Algoliadate: '2020-04-14'---
This is example
ทดสอบสร้างบทความ สำหรับทดสอบ Gatsby SearchWorkflow
ทีนี้ขั้นตอนต่อมา เรามาดูตัว 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 แล้ว เราได้ขั้นตอนการทำงานดังนี้
- ใช้ Gatsby Plugins เพื่อทำการส่งข้อมูลไป Algolia
 - เราจะได้ API ก็ต้องทำการสร้าง App ใน Alogia Dashboard ก่อน (ไม่มี account ก็สมัครเลยจ้า)
 - เมื่อข้อมูลของเราถูกเก็บไว้ที่ Algolia แล้ว ก็ทำการเพิ่ม search โดยใช้ 
react-instantsearch-dom - เมื่อได้ผลลัพธ์เราก็มาแสดงผลบนเว็บไซต์ของเรานั่นเอง
 
ส่วนวิธีการแสดงผล จะกดปุ่ม 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 ได้เลย

GATSBY_ALGOLIA_APP_ID=XXXXGATSBY_ALGOLIA_SEARCH_KEY=XXXALGOLIA_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 indexAlgolia: query 0: executing queryAlgolia: 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 หลักสำหรับแสดงช่อง searchcomponents/search/input.js- สำหรับ input เพื่อค้นหาcomponents/search/result.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 มีการเปลี่ยนค่าครับ
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
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 ได้เรียบร้อย

ทีนี้มันจะมีปัญหานิดนึง ตรงการ 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 อยู่เหมือนกัน
Happy Coding ♥️
- Authors
 -  
 Chai Phonbopit
เป็น Web Dev ในบริษัทแห่งหนึ่ง ทำงานมา 10 ปีกว่าๆ ด้วยภาษาและเทคโนโลยี เช่น JavaScript, Node.js, React, Vue และปัจจุบันกำลังสนใจในเรื่องของ Blockchain และ Crypto กำลังหัดเรียนภาษา Rust