前言#
重新開發部署博客到後深刻地體會到 Nextjs 這個 react 元框架的優秀之處,於是就基於 Nextjs 創建一個簡單的應用用於記錄自己的讀書觀影記錄
JAMStack#
使用 Nextjs、使用 Github Gists 作為數據來源,使用 windicss 簡化應用樣式
比較簡單的實現了一個JAMStack 結構
創建 Nextjs 應用#
Next 官方並沒有一個 windicss 的模板,我們直接從零開始構建一個項目
創建項目文件夾#
mkdir records
cd records
初始化項目#
pnpm init
git init #或許可選?
安裝依賴#
pnpm add next react react-dom @octokit/core
pnpm add -D @types/node @types/react @types/react-dom typescript windicss windicss-webpack-plugin
其中 @octokit/core
是 github 官方 api 的 js client,我們通過這個包來獲取 Github Gists 的內容來生成頁面
在package.json
中新增一個 script
{
...
"scripts":{
"dev":"next",
}
}
配置 windicss#
在項目根目錄下新建next.config.js
const WindiCSSWebpackPlugin = require("windicss-webpack-plugin");
/** @type {import('next').NextConfig} */
const config = {
webpack(config) {
config.plugins.push(new WindiCSSWebpackPlugin());
return config;
}
};
module.exports = config;
然後我們新建一個 pages 目錄用於放置 nextjs 基於約定的路由頁面,裡面新建_app.tsx
文件
在頂端引入import 'windi.css'
並自定義 App
import "windi.css";
import { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";
import Head from "next/head";
const App: React.FC<AppProps> = ({ Component, pageProps }) => {
return (
<ThemeProvider attribute="class" defaultTheme="system">
<Head>
<title>我看過的</title>
<meta content="width=device-width, initial-scale=1" name="viewport" />
</Head>
<Component {...pageProps}></Component>
</ThemeProvider>
);
};
export default App;
創建頁面#
在 pages / 下新建一個index.tsx
,這是應用的根頁面,我們先創建一個空頁面
const Home: React.FC<Props> = ({ records }) => {
return <div></div>
};
export default Home;
創建組件#
我們需要一個卡片組件用於展示記錄的信息,新建一個components/card.tsx
簡單的設置下卡片樣式
const Card: React.FC = () => {
return (
<section className="pb-10 relative before:(border-l-2 inset-y-0 -left-30px absolute content-open-quote) first:before:top-1 last:before:bottom-10">
content
</section>
);
};
export default Card;
然後我們先定義一個 interface 用於描述記錄的信息,順便導出一下
export interface RecordItem {
/** 名稱 */
title: string
/** 分類 */
type: 'anime' | 'book' | 'tv' | 'movie'
/** 發布年份 */
year: number
/** 封面圖片url */
cover: string
/** 評分 */
score: 1 | 2 | 3 | 4 | 5
/** 觀看日期 */
date: string
/** 評論 */
comment: string
}
然後我們借助這個類型將組件完善一下,其中使用 next/image 來圖片優化
如果選擇 SSG 的話,可以直接使用 img 標籤以及把對應的圖片資源放在 public 下做靜態文件服務或者使用圖床鏈接如果準備托管到 Vercel 就直接使用 Image 組件
import Image from "next/image";
import { useState } from "react";
export interface RecordItem {
/** 名稱 */
title: string
/** 分類 */
type: 'anime' | 'book' | 'tv' | 'movie'
/** 發布年份 */
year: number
/** 封面圖片url */
cover: string
/** 評分 */
score: 1 | 2 | 3 | 4 | 5
/** 觀看日期 */
date: string
/** 評論 */
comment: string
}
const Score: React.FC<Pick<RecordItem, "score">> = ({ score }) => {
switch (score) {
case 1:
return <big className="font-bold text-gray-500">🍅 爛</big>;
case 2:
return <big className="font-bold text-green-500">🥱 無聊</big>;
case 3:
return <big className="font-bold text-blue-500">🤔 還行</big>;
case 4:
return <big className="font-bold text-violet-500">🤩 值得一看</big>;
case 5:
return <big className="font-bold text-orange-500">💯 神作!</big>;
}
};
const renderType = (type: RecordItem["type"]) => {
const typeMap = {
movie: "電影",
tv: "劇集",
book: "書籍",
anime: "動漫"
};
return typeMap[type] ?? "未知";
};
export const Card: React.FC<RecordItem> = (props) => {
const [loading, setLoading] = useState(true);
const loadingClasses =
"backdrop-filter backdrop-grayscale backdrop-blur-lg transform scale-110 hover:opacity-75 duration-300 ease-in-out";
const loadedClasses =
"backdrop-filter backdrop-grayscale-0 backdrop-blur-0 transform scale-100 hover:opacity-75 duration-300 ease-in-out";
const classes = loading ? loadingClasses : loadedClasses;
return (
<section className="pb-10 relative before:(border-l-2 inset-y-0 -left-30px absolute content-open-quote) first:before:top-1 last:before:bottom-10 ">
<p className="text-sm mb-2 relative sm:text-base sm:mb-3">
{new Date(props.date).toLocaleDateString()}
<i className="rounded-full bg-gray-200 h-4 transform top-1/2 -left-9 w-4 translate-y-[-50%] absolute" />
</p>
<div className="flex justify-between">
<div className="flex-1 mr-2">
<p className="text-md mb-2 leading-6 sm:mb-3 sm:text-2xl ">
{props.title}
<span>({props.year})</span>
</p>
<p className="text-base md:text-sm">
<span>評分:</span>
<Score score={props.score} />
</p>
<p className="text-base md:text-sm">
<span>分類:</span>
{renderType(props.type)}
</p>
<div className="mt-4 text-sm md:text-x text-gray-700 dark:text-gray-300">{props.comment}</div>
</div>
<div className="rounded-xl w-87px overflow-hidden md:rounded-md">
<Image
src={props.cover}
layout="fixed"
width={87}
height={116}
objectFit="cover"
alt={props.title}
className={classes}
onLoadingComplete={() => setLoading(false)}
/>
</div>
</div>
</section>
);
};
如果使用了 next/image 組件,我們需要修改一下 next.config.js 文件,添加圖片域名配置,添加封面圖片可能的域名
const WindiCSSWebpackPlugin = require("windicss-webpack-plugin");
/** @type {import('next').NextConfig} */
const config = {
webpack(config) {
config.plugins.push(new WindiCSSWebpackPlugin());
return config;
},
images: {
domains: ["img1.doubanio.com", "img2.doubanio.com", "img3.doubanio.com", "img9.doubanio.com"]
}
};
module.exports = config;
然後可以在 pages/index.tsx 設置看看效果
import Card from 'components/Card.tsx'
const Home: React.FC<Props> = ({ records }) => {
return (
<div>
<Card
title="獵罪圖鑑"
type="tv"
year={2022}
cover="https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2647399331.webp"
score={4}
date="2022-04-02"
comment="國產刑偵劇,除了最後關於程序員的部分有點讓從業者尷尬,總體感覺值得一看"
/>
</div>
)
};
export default Home;
設置和獲取數據#
首先我們以設定好的先新建一個 json 格式的 Gist,我們 gist 中每一個 file 就是一條記錄按照設定的類型如下
{
"title": "獵罪圖鑑",
"type": "tv",
"year": 2022,
"cover": "https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2647399331.webp",
"score": 4,
"date": "2022-04-02",
"comment": "國產刑偵劇,除了最後關於程序員的部分有點讓從業者尷尬,總體感覺值得一看"
}
如果想直接新建多條可以點擊Add file
,然後Create public gist
或者Create private gist
,
然後記住這條 Gist 的 id https://gist.github.com/enpitsuLin/<gist id>
,新建一個.env
文件,添加如下內容
GIST_ID=<gist id>
獲取 token#
如果你的 gist 是 private 的則需要新建 token,過期時間建議不過期,然後也在.env
文件中添加如下內容
GIST_ID=<gist id>
GIT_TOKEN=<token>
當然這一部分建議不要上傳至代碼庫,因為這樣會導致你的 token 被洩露,如果使用 Vercel 托管就到 settings 裡寫入 Environment Variables
獲取數據#
新增lib/get-records.ts
文件用於獲取數據的邏輯
import { Octokit } from '@octokit/core'
const octokit = new Octokit({ auth: process.env.GIT_TOKEN })
export async function getRecords() {
const res = await octokit.request("GET /gists/{gist_id}", { gist_id: process.env.GIST_ID })
return res
}
這裡有幾種方法在頁面中拿到數據,使用getStaticProps
/getServerSideProps
,或者在頁面中使用 fetch 或者 xhr 獲取數據並渲染推薦使用 swr
如果目的是創建一個靜態頁面的話只能使用getStaticProps
或者在頁面運行是的時候獲取數據
如果是托管在 Vercel 之類的網站托管服務或者自己的伺服器部署的話可以使用getServerSideProps
getStaticProps#
使用 getStaticProps 則只能獲取在每次構建的時候的數據,用於 ssg 最好但是數據不及時
import { Card } from "components/Crad";
import { GetStaticProps } from "next";
import { getRecords } from "lib/get-records";
import { RecordItem } from "types/records";
interface Props {
records: RecordItem[];
}
function filterTruthy<T>(x: T | false): x is T {
return Boolean(x);
}
export const getStaticProps: GetStaticProps<Props> = async () => {
const { data } = await getRecords();
const records = Object.keys(data.files)
.map((key) => {
try {
return JSON.parse(data.files[key].content) as RecordItem;
} catch (error) {
return false;
}
})
.filter(filterTruthy);
return {
props: {
records: records.sort((a, b) => {
return new Date(a.date) < new Date(b.date) ? 1 : -1;
})
}
};
};
const Home: React.FC<Props> = ({ records }) => {
return (
<div>
{records.map((record) => (
<Card {...record} key={record.title} />
))}
</div>
);
};
export default Home;
使用 getServerSideProps 的話則和 getStaticProps 基本一致但是需要托管在平台上或者自己部署
如果在運行時獲取的話需要在useEffect
中獲取或者使用 swr 的useSWR
,具體就不展示這兩種用法了
優化樣式#
最後再美化一下頁面佈局然後可以增加多主題功能,還有列表懶加載之類的功能