嘿,歡迎回來!經過昨天的「純開會」行程,你可能覺得有點手癢,想開始寫點程式碼了。昨天我們定義了使用者故事、畫了架構圖和線框稿,把整個「AI前端面試官」專案的輪廓都描繪出來了。這很棒,因為清晰的目標是成功的一半。
今天,我們要來實作藍圖中的第一個小方塊:題庫系統。
回想一下 Day 2 的專案,我們的題目是直接寫死在程式碼裡的 (const question = '請解釋 JavaScript 中的 hoisting 是什麼?'
)。這在做Protoyping時完全沒問題,但我們心裡都很清楚,我們遲早要跟這種硬編碼的行為說再見的,要打造一個真正的應用,我們需要一個更靈活、可擴充的方式來管理題目,這就會牽扯到資料庫的使用。
不過別擔心,我不會一開始就叫你裝什麼 MySQL、PostgreSQL 或 MongoDB,他們都是很優秀的選擇!但在最一開始的概念驗證階段,任何額外的設置都可能會勸退一批讀者,在台上奮力表演,結果底下沒人看也是挺沒意思的!盡可能快速地端出概念驗證後再做優化也是個常見的選擇(就像你被主管要求快速做個POC去驗證某個套件或概念,你也不該過度複雜化),但你也不用覺得既然這只是暫時的舉措,今天做的東西是不是就是做白工,看到成果後,之後我們會將今天的實作加入完整的概念搬到真正的資料庫上。
今天結束時,我們那個雖然規劃好但還沒套用新UI的難看應用程式將會:
在建立檔案之前,先定義好我們的資料長什麼樣子,這是一個好習慣,也是避免Typescript抱怨的必要措施。我們得先思考根據我們的目標,一個這樣的練習題目需要哪些資訊呢?快速過一下腦袋後覺得至少要有:
這些應該就足夠我們目前的介面使用了,不過考慮到未來我們還會加入的其他功能,比方說評分、難度和作答介面的提示等,我們勢必需要先盡可能的考量可能會需要的欄位,也許無法完全避免掉未來調整資料結構的命運,但至少我們能減少一點反覆調整的時間。
因此應該還要新增以下的欄位:
這樣一來,我們就可以很清楚地分類和管理我們的題庫了,馬上進入今天的主要目標,來建立資料庫吧!
在我們實際建立「資料庫」之前,我們最好還是先定義一下相關的型別,請你在app/
下建立一個新的資料夾types
並在之中建立一個questions.ts
檔案,寫入以下的內容:
// app/types/questions.ts
/**
* 定義單一測試案例的結構
*/
export interface TestCase {
name: string; // 測試案例的名稱,例如 "基本攤平"
setup: string; // 執行測試前需要的前置程式碼,YOUR_CODE_HERE 會被替換成使用者的程式碼
test: string; // 實際執行的測試程式碼
expected: string; // 預期的 console.log 輸出結果
}
/**
* 定義一個完整題目的結構
*/
export interface Question {
id: string;
topic: string;
type: 'concept' | 'code';
difficulty: 'easy' | 'medium' | 'hard';
question: string;
hints: string[];
keyPoints: string[];
starterCode: string | null;
testCases: TestCase[] | null;
}
接下來,我們就在專案裡手動建立這個「資料庫」吧。
在你的專案根目錄下,建立一個新的資料夾叫做 data
,並在裡面新增一個檔案 questions.json
。
// 在專案根目錄執行
mkdir data
touch data/questions.json
然後,打開 data/questions.json
,把下面這段內容貼進去。我先準備了幾題當作範例:
[
{
"id": "js-con-001",
"topic": "JavaScript",
"type": "concept",
"difficulty": "easy",
"question": "請解釋 JavaScript 中的 hoisting 是什麼?",
"hints": [
"想想變數宣告和函數宣告的行為",
"var 和 let/const 的差異"
],
"keyPoints": [
"變數和函數宣告會被提升到其作用域的頂部",
"只有宣告被提升,賦值不會",
"let 和 const 也有 hoisting,但因存在暫時性死區 (TDZ),在宣告前存取會拋出錯誤"
],
"starterCode": null,
"testCases": null
},
{
"id": "js-pro-001",
"topic": "JavaScript",
"type": "code",
"difficulty": "medium",
"question": "實作一個 flatten 函數,將巢狀陣列攤平成一維陣列",
"starterCode": "function flatten(arr) {\n // 例如:[1, [2, 3], [4, [5]]] => [1, 2, 3, 4, 5]\n}",
"hints": [
"可以用遞迴",
"檢查元素是否為陣列用 Array.isArray()"
],
"testCases": [
{
"name": "基本攤平",
"setup": "const flatten = YOUR_CODE_HERE;",
"test": "console.log(JSON.stringify(flatten([1, [2, 3]])));",
"expected": "[1,2,3]"
},
{
"name": "深層巢狀",
"setup": "const flatten = YOUR_CODE_HERE;",
"test": "console.log(JSON.stringify(flatten([1, [2, [3, [4]]]])));",
"expected": "[1,2,3,4]"
},
{
"name": "空陣列",
"setup": "const flatten = YOUR_CODE_HERE;",
"test": "console.log(JSON.stringify(flatten([])));",
"expected": "[]"
}
]
},
{
"id": "react-con-001",
"topic": "React",
"type": "concept",
"difficulty": "medium",
"question": "請解釋 React 中的 Class Component 和 Functional Component 之間的差異,以及 Hooks 的出現帶來了什麼改變?",
"hints": [
"生命週期方法",
"state 管理方式",
"程式碼複用"
],
"keyPoints": [
"Class Component 使用 'this' 和 'extends React.Component'",
"Functional Component 是純函式,過去被稱為無狀態元件",
"Hooks 讓 Functional Component 也能擁有 state 和生命週期等特性",
"Hooks (如 custom hooks) 改善了邏輯複用的問題,解決了 HOC/Render Props 的複雜性"
],
"starterCode": null,
"testCases": null
},
{
"id": "js-pro-002",
"topic": "JavaScript",
"type": "code",
"difficulty": "hard",
"question": "實作一個 Promise.all() 的 polyfill,命名為 promiseAll",
"starterCode": "function promiseAll(promises) {\n // promises 是一個 promise 物件的陣列\n}",
"hints": [
"回傳一個新的 Promise",
"需要一個計數器來追蹤已完成的 promise",
"注意處理空陣列的邊界情況",
"結果陣列的順序需要和傳入的 promises 陣列順序一致"
],
"testCases": [
{
"name": "全部成功",
"setup": "const promiseAll = YOUR_CODE_HERE; const p1 = Promise.resolve(1); const p2 = 2; const p3 = new Promise((res) => setTimeout(() => res(3), 100));",
"test": "promiseAll([p1, p2, p3]).then(vals => console.log(JSON.stringify(vals)));",
"expected": "[1,2,3]"
},
{
"name": "其中一個失敗",
"setup": "const promiseAll = YOUR_CODE_HERE; const p1 = Promise.resolve(1); const p2 = Promise.reject('error');",
"test": "promiseAll([p1, p2]).catch(err => console.log(err));",
"expected": "error"
},
{
"name": "傳入空陣列",
"setup": "const promiseAll = YOUR_CODE_HERE;",
"test": "promiseAll([]).then(vals => console.log(JSON.stringify(vals)));",
"expected": "[]"
}
]
},
{
"id": "css-con-001",
"topic": "CSS",
"type": "concept",
"difficulty": "easy",
"question": "請解釋 CSS Box Model (盒子模型) 是什麼,以及 `box-sizing: border-box;` 的作用?",
"hints": [
"content, padding, border, margin",
"width 和 height 的計算方式"
],
"keyPoints": [
"標準盒子模型的 width/height 只包含 content",
"border-box 的 width/height 包含 content, padding, 和 border",
"`box-sizing` 改變了寬高計算的行為,讓排版更直觀"
],
"starterCode": null,
"testCases": null
}
]
以下幾個點特別說明關於資料結構的部分:
{topic}-{type}-{number}
的格式,其中type的部分我們用con
表示概念題、pro
表示程式實作題,除了取英文前面的一部分作為縮寫之外,另外就是我想玩一下pro & con的雙關,請原諒我。現在型別定義好了、資料也有了!我們需要一個方法讓前端能拿到這些資料。最好的方式就是建立一個專門的 API Endpoint。這也是我們選擇 Next.js 的好處之一,建立後端 API 就像建立一個頁面一樣簡單,還記得我們怎麼建立 Gemini API EndPoint 的吧?請你在 app/api/
資料夾下,建立一個新的資料夾 questions,並在裡面新增 route.ts 檔案。
到目前為止,你的資料夾結構應該是這樣的,你可以先比對一下是否正確再繼續。
ai-frontend-interviewer/
├── app/
│ ├── api/
│ │ ├── gemini/
│ │ │ └── route.ts
│ │ └── questions/
│ │ └── route.ts
│ ├── types/
│ │ └── questions.ts
│ ├── layout.tsx
│ └── page.tsx
├── data/
│ └── questions.json
├── node_modules/
├── public/
├── .env.local
├── package.json
└── tsconfig.json
確認結構沒問題後我們就撰寫那個簡單的 API Endpoint 吧!
// app/api/questions/route.ts
import { NextResponse } from 'next/server';
import path from 'path';
import { promises as fs } from 'fs';
export async function GET() {
try {
// 找到 public 資料夾的路徑
const jsonDirectory = path.join(process.cwd(), 'data');
// 讀取 JSON 檔案
const fileContents = await fs.readFile(jsonDirectory + '/questions.json', 'utf8');
// 解析 JSON 內容
const questions = JSON.parse(fileContents);
// 從題庫中隨機選一題
const randomQuestion = questions[Math.floor(Math.random() * questions.length)];
return NextResponse.json(randomQuestion);
} catch (error) {
console.error(error);
return NextResponse.json({ error: '無法讀取題庫' }, { status: 500 });
}
}
程式碼說明:
現在,你可以試著啟動專案
npm run dev
然後在瀏覽器打開 http://localhost:3000/api/questions,你會發現每次重新整理,都會得到一題不一樣的題目!
![]() |
---|
圖1 :API 測試圖 |
好啦!最後一步!就是讓我們 Day 1 打造的介面 去呼叫這個新 API,而不是顯示寫死的題目。
再次打開 app/page.tsx
,我們需要做一些修改,讓它不要再這麼死,該起來工作了。
import { useState, useEffect } from 'react'; // 引入useEffect
import { Question } from './types/question'; // 引入我們定義的型別
export default function Home() {
const [answer, setAnswer] = useState('');
const [feedback, setFeedback] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null); // 加入currentQuestion state
const [isFetchingQuestion, setIsFetchingQuestion] = useState(false); // 加入isFetchingQuestion state
useEffect(() => {
const fetchQuestion = async () => {
try {
setIsFetchingQuestion(true);
const response = await fetch('/api/questions');
const data = await response.json();
setCurrentQuestion(data);
} catch (error) {
console.error('無法抓取題目:', error);
} finally {
setIsFetchingQuestion(false);
}
};
fetchQuestion();
}, []);
}
const handleSubmit = async () => {
if (!answer) return;
try {
setIsLoading(true);
// 發送請求到我們剛剛在後端app/api/gemini/route.ts建立的API
const response = await fetch('/api/gemini', {
method: 'POST',
body: JSON.stringify({
question: currentQuestion?.question, // 在這邊用我們新的currentQuestion變數
answer,
}),
});
const data = await response.json();
setFeedback(data.result);
} catch (error) {
console.error('錯誤:', error);
} finally {
setIsLoading(false);
}
};
// ...中間省略
return (
<main className="min-h-screen bg-gray-900 text-white">
<div className="container mx-auto px-4 py-16">
<h1 className="text-4xl font-bold text-center mb-8">
AI 前端面試官 🤖
</h1>
<div className="max-w-2xl mx-auto">
{/* 題目區 */}
<div className="bg-gray-800 rounded-lg p-6 mb-6">
{isFetchingQuestion ? (
<p className="text-center text-gray-400">正在從題庫抽取題目...</p>
) : (
currentQuestion && (
<>
<div className="text-sm text-blue-400 mb-2">
{currentQuestion.topic} 題目
</div>
<p className="text-lg">{currentQuestion.question}</p>
</>
)
)}
</div>
// 以下省略...
)
useEffect
在組件掛載時替我們從後端請求隨機一個問題展示在頁面上。Question
型別handleSubmit
函數讓它不再用硬編碼的問題。isFetchingQuestion
和currentQuestion
並修改問題區的 UI 。儲存檔案後,回到你的瀏覽器 http://localhost:3000,你會發現頁面每次重新整理,都會顯示一題從 questions.json
隨機抽出的新題目!我們的頁面終於不再只會問 hoisting 了,暫時擺脫了薪水小偷的稱號。
![]() |
---|
圖2 :API 整合後畫面 |
辛苦啦! 果然動手寫程式碼還是稍稍有趣一些,今天的內容相對輕鬆很多,都是我們作為前端工程師最為熟悉的部分,來回顧一下今天的進度:
✅ 設計了題目的資料結構
✅ 用 JSON 檔案建立了我們的第一個「資料庫」
✅ 建立了一個後端 API 來隨機讀取題目
✅ 讓前端介面能動態載入題目
雖然只是一個簡單的 JSON 檔,但實際上與我們最後要使用的資料庫格式其實也相差不遠,作為一個 POC 是完全足夠的,很多時候真的不要在一開始就試圖加入過多的設置,尤其是你還對整個專案所選用的技術沒有太多了解時(例如我現在這樣,坦白說雖然規劃是做好了,但就像我跟你們坦白過的,那些也並不是我涉略過的領域,我與你們一樣都在摸索!),快速出個可用的版本再做優化會更適合我們的情況!
現在我們的問答系統有了雛形,但對於一個「面試官」來說,光能問答還不夠,特別是當我們未來要處理程式題時。一個陽春的 絕對不夠看。明天(Day 5),我們要來點專業的:整合跟 VS Code 同款核心的 Monaco Editor,讓我們的作答區瞬間升級,擁有語法高亮、程式碼提示等強大功能!
今天也辛苦啦,我們明天見!🚀
今日程式碼: GitHub Day-04 Branch