读 platform 源码
这一课你将能做到
- 在 platform 仓库里用「URL → 文件 → 组件 → lib → 后端」的路径独立走读任意一个页面
- 对照源码指出第 1-10 课每个知识点的真实用例(文件路由、RSC、缓存、BFF、门控、Tailwind 全部对上号)
- 具备给 platform 提 PR 的代码定位能力:改一个需求,知道动哪几个文件
为什么要学
前 10 课的所有概念都还停在「示例项目」里。这一课把它们放进一个真实生产仓库——你正在用的学习平台,前端目录就是 platform/,Next.js 15 + App Router + Tailwind + 自研 Spring Boot 后端。读真实源码和读教程是两种能力:教程代码干净漂亮,真实代码有历史包袱、有取舍、有「为什么这么写」的深意。面试里最有说服力的一句话是「我读过一个生产级 Next.js 项目的完整源码」,而读源码的能力可以迁移到任何新仓库。
走读方法论先立住——固定四步,任何页面通用:
① 从 URL 定位文件:/tracks/java/lessons/intro → src/app/tracks/[slug]/lessons/[lessonSlug]/page.tsx
② 读 page.tsx 骨架:取了什么数据、渲染了哪些组件、有没有特殊导出(dynamic/metadata)
③ 沿 import 追依赖:@/lib/api(取数)→ @/components(交互)→ 逐个打开
④ 对照后端:lib/api 里调的 /api/xxx,对应 Spring Boot 的 Controller
先建立图像
platform 的目录全景(带功能注释版):
platform/
├── next.config.ts # Next 配置
├── .env.example # 环境变量样例(NEXT_PUBLIC_API_URL 等)
├── src/
│ ├── app/ # ★ App Router 路由
│ │ ├── page.tsx # 首页
│ │ ├── layout.tsx # 根布局(全站导航/页脚)
│ │ ├── login/ register/ # 登录注册页
│ │ ├── tracks/
│ │ │ ├── page.tsx # 课程列表
│ │ │ └── [slug]/
│ │ │ ├── page.tsx # 课程详情
│ │ │ └── lessons/[lessonSlug]/
│ │ │ └── page.tsx # ★ 课时页(本课主角)
│ │ ├── practice/[trackSlug]/ # 练习判题页
│ │ ├── roadmaps/[slug]/ # 路线图(嵌套动态段)
│ │ └── api/roadmap-node/[slug]/[nodeKey]/
│ │ └── route.ts # ★ BFF 转发接口(第 7 课真实用例)
│ ├── components/ # 业务组件(LessonQuiz、LoginGate…)
│ └── lib/ # ★ 服务层(下表详解)
└── ...
src/lib/ 服务层速查——这层是全站的「关节」:
| 文件 | 职责 | 对应课程 |
|---|
api.ts | 所有后端调用的封装(fetch + 类型) | 第 5 课 数据获取 |
apiBase.ts | API 地址分流(公网/内网) | 第 5 课 环境变量 |
serverAuth.ts | 服务端读 Cookie 取 token | 第 10 课 认证 |
mdxSanitize.ts | 课时内容消毒 | 第 8 课 MDX |
|
code.ts | 读课时代码文件 | 第 4 课 Server 能力 |
跟着做
步骤 1:打开仓库,先跑起来
仓库根目录下(platform 是其中的前端子目录):
cd platform
npm install
npm run dev # 本地开发,通常代理到本地或测试环境后端
跑不起来没关系——本课以静态走读为主,但能跑起来体验最好。
步骤 2:走读课时页(全站最核心页面)
打开 src/app/tracks/[slug]/lessons/[lessonSlug]/page.tsx,先看开头(节选):
import Link from "next/link";
import { notFound } from "next/navigation";
import { LessonPractice } from "@/components/LessonPractice";
import { LessonQuiz } from "@/components/LessonQuiz";
import { LoginGate } from "@/components/LoginGate";
import { getTrack, getLessons, getLessonDetail } from "@/lib/api";
import { getServerToken } from "@/lib/serverAuth";
import { renderAssignmentMd, renderMdSource } from "@/lib/assignment";
import { readCodeFiles } from "@/lib/code";
interface PageProps {
params: Promise<{ slug: string; lessonSlug: string }>; // ← Next 15:Promise 参数
}
export const dynamic = "force-dynamic"; // ← 第 6 课:个人化页面强制动态
export default async function LessonPage({ params }: PageProps) {
const { slug, lessonSlug } = await params; // ← 第 3 课 动态段
const token = await getServerToken(); // ← 第 10 课 登录态
const track = await getTrack(slug);
const lesson = await getLessonDetail(slug, lessonSlug, token);
if (!track || !lesson) notFound(); // ← 第 3 课 404
// ...
}
20 行代码里至少 6 个前 10 课的知识点。继续读主体逻辑:
const lessons = (await getLessons(slug, token)) ?? [];
const sorted = [...lessons].sort((a, b) => a.orderIndex - b.orderIndex);
const idx = sorted.findIndex((l) => l.slug === lesson.slug);
const prev = sorted[idx - 1] ?? null; // 上一课/下一课:前端算序
const next = sorted[idx + 1] ?? null;
const locked = lesson.locked === true; // 付费/登录门控标记来自后端
const courseContent = lesson.contentMd
? await renderMdSource(lesson.contentMd) // ← 第 8 课:字符串渲染 MDX
: locked && lesson.preview
? await renderMdSource(lesson.preview) // 锁定课时只渲染预览
: null;
最后 JSX 里组件各就各位:LoginGate 包住需要登录的区域、LessonQuiz/LessonPractice 是交互块、ProgressToggle 管进度打钩。整个页面是一个 async Server 组件,交互全部下沉到 'use client' 的子组件——第 4 课的拆分套路的生产版。
步骤 3:追进 lib/api.ts,看取数层
打开 src/lib/api.ts,找 getLessonDetail(节选示意):
export async function getLessonDetail(
trackSlug: string,
lessonSlug: string,
token?: string
): Promise<ApiLesson | null> {
const res = await fetch(
`${getApiBase()}/api/tracks/${trackSlug}/lessons/${lessonSlug}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: "no-store", // ← 带登录态的数据:绝不缓存(第 5 课铁律)
}
);
if (!res.ok) return null;
return res.json();
}
再打开 src/lib/apiBase.ts(全文很短):
export function getApiBase(): string {
if (typeof window === "undefined") {
const internal = process.env.API_INTERNAL_URL?.trim();
if (internal) return internal.replace(/\/$/, "");
}
return getPublicApiBase();
}
typeof window === "undefined" = 当前在服务端 → 走内网直连后端;浏览器端走公网域名。第 5 课「环境变量分流」在这里落地。
步骤 4:走读 BFF 接口
打开 src/app/api/roadmap-node/[slug]/[nodeKey]/route.ts(节选结构):
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; nodeKey: string }> }
) {
const { slug, nodeKey } = await params;
const token = request.cookies.get("token")?.value; // ← 读登录态
const res = await fetch(`${backend}/api/roadmap-node/${slug}/${nodeKey}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: "no-store",
});
return NextResponse.json(await res.json(), { status: res.status });
}
这就是第 7 课 BFF 转发模式的仓库实体:藏后端地址、透传 token、按需聚合。你可以对照第 7 课自己写的 /api/progress,结构几乎一样——教程代码和生产代码的差距比想象小。
步骤 5:读练习判题页的数据流
src/app/practice/[trackSlug]/page.tsx 负责练习入口;判题交互在组件里(src/components/ 下的练习相关组件)走「Client 组件提交答案 → 后端判题接口 → 返回结果渲染」。自己走一遍四步法:URL → 页面文件 → 组件 → lib/api 里的调用。走读完写一句话总结数据流,这一步没有标准答案,重要的是把方法用熟。
步骤 6:给自己出一个「改造任务」
模拟真实需求:「课时页给锁定课时加一个『开通会员』按钮」。定位路径:
- UI 改动 →
lessons/[lessonSlug]/page.tsx 的 locked 分支附近
- 按钮点击行为 → 需要交互 → 新建 Client 组件放进
src/components/
- 样式 → 复用
gh-box 语义类 + Tailwind 工具类
- 数据 →
lib/api.ts 加一个会员状态接口调用(token 透传、no-store)
这一个任务串起 4、5、9 三课知识。看完本课,你可以真去仓库提 PR。
讲清楚要点
- 页面薄、服务厚是本仓库的核心架构原则:
page.tsx 只做「取数 + 组装」,逻辑都在 src/lib/,交互都在 src/components/。好处:页面可替换(重构路由不动业务)、服务层可测试、新人可按层阅读。
- 「个人化数据 no-store,公开数据可缓存」贯穿全仓库:凡是函数签名里带
token 的取数函数,清一色 no-store;公开目录数据才有缓存策略。这是第 5 课原则的生产级一致性执行。
- 门控是「标记 + 组件」双层:后端返回
locked: true(业务判断在 Java 侧),前端只负责按标记渲染 LoginGate/预览内容——判断逻辑不重复实现,前端永远不自己算「该不该锁」。
- Next 15 语法痕迹是判读版本的锚点:
params: Promise<...>、await cookies() 说明是 15+ 语法。读任何 Next 仓库先扫一眼这两个写法,能立刻校准你的语法记忆。
- 读源码先读 lib/ 再读 components/:初学者爱从 UI 读起,但 UI 是结果不是原因;lib 层的 api.ts、serverAuth.ts 才是数据流的源头。先源头后展示,理解成本最低。
你可能会懵的地方
-
为什么不把课程数据构建时静态化(SSG)?
因为课时页带登录态(锁定、进度、是否免费预览),响应因人而异——上一课的结论「用户级页面必须动态」。首页、课程列表这类公开页则可以走静态/ISR,各自选型,仓库里 dynamic = "force-dynamic" 只出现在个人化页面,印证了「按路由最小干预」。
-
lib/api.ts 里为什么返回 null 而不是 throw?
取数失败分两种:数据不存在(正常业务,渲染 404 页)和基础设施故障(异常,该报错)。课程不存在返回 null 让页面 notFound(),是语义化处理;全抛异常会让「查无此课」和「后端宕机」混为一谈。
-
为什么组件不直接 fetch 后端,非要经过 lib?
三笔账:类型账(TS 接口集中定义)、缓存账(策略一处管理)、环境账(内网/公网地址分流只写一次)。绕过 lib 直接 fetch 的代码在这个仓库里应该被 code review 打回。
-
readCodeFiles 这种「服务端读文件」为什么安全?
它跑在 Server 组件里,读的是服务器上的路径;若等价逻辑放到 Client 组件,路径和文件内容都会暴露到浏览器。第 4 课「Server 组件能访问后端资源」的生产实例。
-
我怎么确认某段交互是 Client 组件?
打开组件文件看第一行有没有 "use client";或看有没有 useState/onClick。快速判断法:能用键盘/鼠标产生「即时反应」的部分,几乎都是 Client。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 只盯 JSX 读页面 | 只看到皮,看不到数据流 | 先读取数段(await 序列)再读 JSX |
把教程语法硬套源码(如 params.slug) | 源码是 Next 15 Promise 语法,理解错位 | 以仓库实际写法为准,await params |
| 改 UI 时顺手在 page.tsx 里加 useState | page 是 Server 组件,直接报错 | 交互逻辑拆 Client 组件放 src/components |
| 在组件里绕过 lib 直接 fetch | 类型、缓存、环境三处失控 | 一律经 src/lib/api.ts |
| 在前端复算 locked 逻辑(如 lessonCount > 3) | 与后端规则漂移,出现「前端解锁」 | 以后端 locked 标记为唯一事实 |
卡点急救
npm run dev 起不来或页面白屏:大概率是没配 .env.local(对照 .env.example 补 NEXT_PUBLIC_API_URL 等),或本地没有后端可连——先只浏览公开页面,登录相关功能连测试环境。
- 找不到某个 URL 对应的文件:记住动态段规则反向映射(
/tracks/x/lessons/y → [slug]/lessons/[lessonSlug]);再不行全局搜页面里的独特文案定位文件。
- 看不懂某个 lib 函数被谁调用:编辑器里用「查找引用」(VS Code: Shift+F12)向上追;配合全局搜函数名,一般两三层就能追到页面。
面试常问
-
介绍一下你读过的这个 Next.js 项目的架构。
参考回答:学习平台前端,Next.js 15 App Router + TypeScript + Tailwind。三层结构:src/app 承载路由与页面(薄组装层)、src/lib 承载数据获取与鉴权工具(api.ts 统一 fetch 封装、serverAuth.ts 读 Cookie、apiBase.ts 做公网/内网地址分流)、src/components 承载交互组件。个人化页面(课时页、练习页)force-dynamic 并对带 token 的请求 no-store;公开内容可缓存。后端 Spring Boot 提供 REST,Next 通过 BFF route.ts 转发并透传 token。
-
这个项目里 RSC/Client 组件是怎么划分的?
页面主体全为 async Server 组件:服务端读 token、取课程数据、渲染 MDX 内容、判断锁定状态;只有测验答题、进度打钩、代码练习这类需要状态与事件的单元拆成 'use client' 组件。数据由 Server 取好后经 props 下传,凭证(token)从不进入客户端 JS。
-
如果让你在这个项目加一个新页面,你的步骤?
先定路由与渲染策略(是否个人化 → 决定 dynamic 配置);在 lib/api.ts 补取数函数与 TS 类型(确定缓存策略);page.tsx 做组装(数据不存在走 notFound);可复用交互拆 Client 组件进 components;样式复用语义类 + Tailwind;最后用 build 输出核对新路由的渲染标记符合预期。
自测三问
- 从 URL
/tracks/nextjs-essentials/lessons/next-intro 说出完整文件路径,并说出它为什么必须是动态渲染。
getApiBase() 里 typeof window === "undefined" 判断的是什么?两个地址各在什么环境用?
- 课时锁定判断为什么放在后端?前端拿到 locked 标记后做哪两件事?
小结一句话
读真实仓库用固定四步:URL 定位文件、读页面骨架、沿 import 追 lib、对照后端;platform 的每一行代码都能和前 10 课的知识点对上号。
延伸