# 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()都會回傳伺服器端記憶體內快取。
> 資料快取架構圖如下,結合伺服器端快取和客戶端狀態管理:

#### 使用環境變數
* 參考[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的過程:

#### 實作
使用者點擊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分數

