# Next.js和Github Issues實作部落格 ## 0.執行方式 * 網址:https://blog-example-sooty.vercel.app/ * 本機端測試:http://localhost:3000 Run the development server ```bash npm run dev # or next dev ``` Run production build ``` # build npx next build npx next start ``` ## 1.專案架構 以Next.js App Router預設架構為基礎 * component:存放可重複使用UI元件 * public: 存放圖片。 * service:存放呼叫API的函式 * types: 存放type/interface ``` root/ ├── src/ │ ├── app/ │ │ ├── post/[id]/ │ │ │ └── page.tsx │ │ ├── redirect/ │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── component/ │ ├── public/ │ ├── service/ │ └── types/ ├── next.config.mjs ├── package.json └── tailwind.config.ts ``` ## 2.路由 ### 動態路由 * url:`/post/[id]`,建立`app/post/[id]`資料夾以及底下的`page.ts`,可以避免重複為每一個id建立頁面。 * dynamicParams: 設為false,在url輸入不存在的id時會回傳404。 * generateStaticParams()會在build time執行,如果是client compoenent就不能用。 ```javascript! import { getData } from "@/service/fetch"; export async function generateStaticParams() { const items = await getData(); return items.map((item) => ({ id: item.number.toString(), })); } export const dynamicParams = false; const Post = ({ params }: { params: { id: String } }) => { return( <div></div> ); } export default Post; ``` ## 3.資料取得及快取 ### A. 文章資料快取 * 參考[Data Fetching, Caching, and Revalidating](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#time-based-revalidation) 呼叫Github Issues API使用fetch(),因為Next.js有延伸原生的fetch() Web API,預設在伺服器端進行快取。但是快取後如果Issues內容有更動,也不會重新呼叫fetch(),因此訪客會看到舊的資料。有以下幾種快取策略: 1. 配合CI/CD pipeline重新部署 2. 不進行快取,訪客瀏覽時需要重複呼叫API 3. 定時或按需求重新驗證快取 在部落格的應用場景中,因為作者更新的頻率可能不高,所以採取定時重新驗證的方式,設定為30分鐘內重新呼叫fetch()都會回傳伺服器端記憶體內快取。 > 資料快取架構圖如下,結合伺服器端快取和客戶端狀態管理: ![dataCache](https://hackmd.io/_uploads/BkEfWr1Ra.jpg) #### 使用環境變數 * 參考[Environment Variables](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables) Nex.js預設會從`.env.local`檔案將變數載入`process.env`,不用額外安裝dotenv。 ```javascript //.env.local SECRET=... ``` ```javascript //fetch.tsx const secret = process.env.SECRET ``` #### 定時重新驗證 * 參考[Time-based Revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#time-based-revalidation) * `user server`宣告為server action ```javascript 'use server'; export async function getData() { const url = `${process.env.BaseUrl}/ ${process.env.USER}/ ${process.env.REPO}/issues`; try { const res = await fetch(url, { method: 'GET', next: { revalidate: 1800 } }); const data = await res.json(); return data; } catch (error) { console.log('error', error); return undefined; } } ``` #### 客戶端儲存狀態 使用context在客戶端儲存取得的文章資料,避免重複向伺服器端發送請求,流程如下: 1. 第一次啟動DataProvider時,呼叫context內的getData(),取得所有文章編號、標題及內容,並儲存到context。 2. 換頁時改變page,取得新的資料。 3. 文章個別頁面渲染時,從context取得資料。 4. 超過驗證時間重新呼叫Github API取得最新資料,更新context。 雖然客戶端context和伺服器端fetch()都有儲存文章資料,但是如果重新整理頁面會清理掉context,因此仍保留伺服器端的快取。 ```javascript //datacontext.tsx 'use client' export const DataContext = createContext<DataContextType>({ //default values }); export const DataProvider = ({children}: {children: React.ReactNode}) => { const [data, setData] = useState<DataType[]>([]); const [page, setPage] = useState(1); const [code, setCode] = useState(""); const pageSize = 10; useEffect(() => { const fetchData = async () => { const result = await getData(page, pageSize) //deal with data } fetchData() }, [page]) return ( <DataContext.Provider value={{data, page, setPage, code, setCode}}> {children} </DataContext.Provider> ) } ``` 在layout加上DataProvider,讓所有頁面(children)都可以存取。 ```javascript //layout.tsx return ( <html> <body> <DataProvider>{children}</DataProvider> </body> </html> ); ``` 在文章頁面從context取得資料。 ```javascript 'use client'; import { DataContext } from '@/component/dataContext'; const Post = ({ params }: { params: { id: string } }) => { const {data, code} = useContext<DataContextType>(DataContext); const record = data.filter(r => r.id === Number(params.id))[0]; return( <div> <h2>{record.title}</h2> <p>{record.body}</p> </div> ); } ``` ### B. Github OAuth驗證及toke快取 * 參考[Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#redirect-urls) 目的為驗證使用者身份,讓OAuth app(這個部落格)代替使用者操作Github Issues,需要access_token才能進行新增、修改、刪除issues。取得步驟如下: 1. GET `https://github.com/login/oauth/authorize?client_id={CLIENT_ID}&scope={SCOPE}` 2. POST `https://github.com/login/oauth/access_token?code={CODE}&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}` 說明: * client_id: 設定OAuth app時取得,儲存於環境變數 * client_secret: 設定OAuth app時取得,儲存於環境變數 * code: 發送GET之後會轉址回設定的url,並加上query如`/redirect?code=` * access_token: 發送POST後回傳 * scope: 設定OAuth app可以代替使用者操作的權限 > token快取架構圖如下,簡化交換code、client_id、client_secret的過程: ![tokenCache](https://hackmd.io/_uploads/S1Gy-HJ0a.jpg) #### 實作 使用者點擊nav元件後會導航到Github的身份驗證頁面。 ```javascript //nav.jsx const Nav = () => { const url = `${process.env.AuthUrl}/ ?client_id=${process.env.ClientId} &scope=${process.env.SCOPE} &redirect_uri=${process.env.RedirectUri}`; return ( <Link href={url}> <div> <span>登入</span> </div> </Link> ); } ``` 授權後轉址回到`/redirect`路由,取得query parameter的code之後呼叫getToken。因為`/redirect`頁面是伺服器端元件,無法將code存到context,所以轉址回首頁(客戶端元件)後傳入code,在首頁將code存入context。 ```javascript const Redirect = async ({searchParams={code: undefined},}: { searchParams: { [key: string]: string | string[] | undefined } }) => { const code = searchParams.code; if (code !== undefined ) { try { await getToken(code); } catch (error) { console.log('redirect error:', error); } } return ( <>{redirect(`/?code=${code}`)}</> ); } export default Redirect; ``` 將呼叫Github API的函式getToken設為server action,在`/redirect`第一次呼叫後會在伺服器端記憶體保存快取,之後需要token時就不用再向Github API發送請求。因安全考量將token的快取保留在記憶體內、不會傳到客戶端;code無法直接用來取得資料,因此存在客戶端的context內。 ```javascript 'use server'; export async function getToken(code: string | string[] ) { const url = `${process.env.TokenUrl} ?client_id=${process.env.ClientId} &client_secret=${process.env.ClientKey} &code=${code}`; try { const res = await fetch(url, { method: 'POST', headers: { 'Accept': 'application/json', next: { revalidate: 7200 } }); const data = await res.json(); return data.access_token; } catch (error) { console.log('postecret error', error); return undefined; } } ``` ### C. Form提交資料 * 參考[Server Actions and Mutations #forms](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#forms) 取得token以後,就可以使用提交資料的server action(createIssue)。在createIssue中呼叫getToken,讓token保留在伺服器端。 ```javascript 'use server'; export default async function createIssue(code: string, formData: FormData) { const url = `${process.env.BaseUrl}/ ${process.env.USER}/ ${process.env.REPO}/issues`; try { const token = await getToken(code); const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.v3+json', contentType: 'application/json', }, body: JSON.stringify({title: formData.get('title'), body: formData.get('body')}), }); const data = await res.json(); return data; } catch (error) { console.log('createIssue error', error); return undefined; } } ``` 在form當中使用server action,可以寫`<form action={createIssue}>`,也可以用event handler。由於提交後要更新context中的資料,因此用handleSubmit處理。 ```javascript 'use client'; const Create = ({code}: {code: string}) => { const {setData} = useContext<DataContextType>(DataContext); const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); const res = await createIssue(code, formData); //update context if (res.number !== undefined) { const newData = {id: res.number, title: res.title, body: res.body, created_at: res.created_at}; setData(prevData => {return[...prevData, newData]}); } setFormData({ title: '', body: '' }); //clear form } return ( <div> <form onSubmit={handleSubmit} method="post"> <label>Input title</label> <input type='text' name='title' required /> <label>Input content</label> <textarea name='body' /> <button type="submit" onClick={handleClose}>新增</button> </form> </div> ); } export default Create; ``` ## 4.功能及UI元件 ### A.渲染markdown 使用[react-markdown](https://remarkjs.github.io/react-markdown/)套件渲染markdown,用以下指令安裝。 ```bash npm install react-markdown ``` 需要修改tailwind.config.ts,使用[Tailwind CSS Typography plugin](https://tailwindcss.com/docs/typography-plugin)才能渲染,並且在元件上新增兩個className `prose lg:prose-xl`。由於類別`prose`的預設max-width只有65vw,參考文件中[覆蓋的方法](https://tailwindcss.com/docs/typography-plugin#overriding-max-width),加上`max-w-none`這個className。 ```javascript! import ReactMarkdown from 'react-markdown'; const Post = ({ params }: { params: { id: String } }) => { //fetch data record return( <div> <ReactMarkdown className="prose lg:prose-xl max-w-none">{record && (record.body).toString()}</ReactMarkdown> </div> ); } export default Post; ``` ### B.Modal 使用MUI的[Backdrop元件](https://mui.com/material-ui/react-backdrop/),點第一個button後開啟Backdrop內的div。 ```javascript 'use client'; import Backdrop from '@mui/material/Backdrop'; const Create = () => { const [open, setOpen] = useState(false); const handleClose = () => { setOpen(false); }; const handleOpen = () => { setOpen(true); }; return ( <div> <button onClick={handleOpen}>新增</button> <Backdrop open={open}> <div> <button onClick={handleClose}>關閉</button> </div> </Backdrop> </div> ); } export default Create; } ``` ### C.Infinite scroll 使用useRef和IntersectionObserver (Web API),在要渲染的資料最下方新增一個div作為參照,出現在畫面時會將頁數加一,在DataContext內執行fetch()的useEffect會取得新的資料存入data。 ```javascript //page.tsx 'use client'; import { useContext, useEffect, useState, useRef } from "react"; const Home = () => { const reference = useRef<HTMLDivElement | null>(null); const {data, setPage, code, setCode, isLast}: DataContextType = useContext(DataContext); useEffect(() => { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !isLast) { setPage((prevPage) => prevPage + 1); } }, { threshold: 1 }); if (reference.current && !isLast) { observer.observe(reference.current); } return () => { if (reference.current) { observer.unobserve(reference.current); } } }, [reference, isLast]); return ( <main> // render data <div ref={reference}></div> </main> ); } export default Home; ``` 如果回傳的資料長度為0,表示已經沒有更多issues,將isLast設為true讓頁數不會繼續增加。 ```javascript //dataContext.tsx 'use client'; import { createContext, useState, useEffect } from 'react'; import { getData } from '@/service/getData'; export const DataContext = createContext(); export const DataProvider = ({children}) => { const [data, setData] = useState<DataType[]>([]); const [page, setPage] = useState(1); const [isLast, setIsLast] = useState(false); const pageSize = 10; useEffect(() => { const fetchData = async () => { const result = await getData(page, pageSize); if (result.length > 0) { console.log('fetching page', page); setData((prevData) => { return [...prevData, ...result]; }); } else { setIsLast(true); } } fetchData(); }, [page]); // return DataProvider } ``` ## 5.部署 使用開發Next.js的Vercel公司所提供的託管服務,不需更改`next.config.mjs`也不用撰寫YAML檔,commit到Github之後即可自動部署。需注意取得Github token的`RedirectUri`和`callback URL`,要從localhost改為部署後的URL。 1. 註冊帳號 2. 連結Github 3. 匯入專案 4. 設定環境變數 5. 部署 ## 6.優化 用Lighthouse檢視core web vitals分數 ![截圖 2024-03-10 下午9.21.36](https://hackmd.io/_uploads/HJvkB4jp6.png) ![截圖 2024-03-10 下午9.25.12](https://hackmd.io/_uploads/HJ-lBVjpT.png)