bulletproof-react Next.js Pages 应用:app 层与 pages 层分离的项目结构与本地开发流
本篇围绕 bulletproof-react 仓库中 Next.js Pages 应用 的启动流程与项目结构展开:先完成 Node 20+ / Yarn 1.22+ 环境下的环境配置、Mock 服务器与开发服务启动,再深入解析该应用“业务代码收敛在 app 目录、pages 目录仅做 Next.js 入口再导出”的双层结构设计及其服务端数据预取实现。读完后你将掌握该应用的完整本地运行方式,并能理解为什么 Pages Router 应用要刻意绕开 pages 目录直接写业务代码。
环境要求与启动命令
官方文档列出的前置条件为:
- Node 20+
- Yarn 1.22+
进入 apps/nextjs-pages 目录后,按如下步骤初始化:
# 克隆仓库后进入 Pages Router 应用目录
cd bulletproof-react
cd apps/nextjs-pages
cp .env.example .env
yarn install
其中 cp .env.example .env 一步很关键。该应用通过 src/config/env.ts 中的 Zod schema 对环境变量做运行时强校验,createEnv 会解析以下变量并在缺失时直接抛出带字段清单的错误:
| 内部变量 | 读取的环境变量 | 约束与默认值 |
|---|---|---|
API_URL |
NEXT_PUBLIC_API_URL |
必填字符串 |
ENABLE_API_MOCKING |
NEXT_PUBLIC_ENABLE_API_MOCKING |
可选,只接受 'true'/'false',会被转换为布尔值 |
APP_URL |
NEXT_PUBLIC_URL |
可选,默认 http://localhost:3000 |
APP_MOCK_API_PORT |
NEXT_PUBLIC_MOCK_API_PORT |
可选,默认 '8080' |
也就是说,不复制 .env.example 就运行应用,会在解析 API_URL 时立刻失败。NEXT_PUBLIC_ 前缀变量会被 Next.js 暴露给浏览器端代码,与 src/lib/api-client.ts 中发起的 HTTP 请求配合使用。
先启动 Mock 服务器:yarn run-mock-server
文档明确要求:运行应用前必须先启动 Mock 服务器,其 API 入口为 http://localhost:8080/api。
该脚本在 package.json 中定义为 tsx ./mock-server.ts,实际实现见 mock-server.ts:它用 Express 挂载 cors(来源限制为 NEXT_PUBLIC_URL、credentials: true)、express.json()、pino-http 日志(silent 级别),然后通过 @mswjs/http-middleware 的 createMiddleware(...handlers) 注入全部 MSW handler;在 initializeDb() 初始化基于 @mswjs/data 的内存数据库后,监听 NEXT_PUBLIC_MOCK_API_PORT 端口并打印 Mock DB initialized / Mock API server started 日志。
从源码结构看,Mock handler 定义在 src/testing/mocks/handlers(覆盖 auth、discussions、comments、teams、users 五组路由),数据库工厂在 src/testing/mocks/db.ts。测试与 E2E 也复用同一套 Mock 设施:test-e2e 脚本会先用 pm2 拉起 yarn run-mock-server,再执行 yarn playwright test(对应 e2e/tests 下的用例)。
开发模式:yarn dev
yarn dev 等价于 next dev,启动后访问 http://localhost:3000 即可在浏览器中查看应用(该默认地址与 env.ts 中 APP_URL 的默认值一致)。
package.json 中还提供了配套脚本,日常开发会频繁用到:
| 脚本 | 命令 | 用途 |
|---|---|---|
dev |
next dev |
开发模式 |
build / start |
next build / next start |
生产构建与启动 |
lint |
next lint |
静态检查 |
test |
vitest |
单元/组件测试 |
test-e2e |
pm2 start "yarn run-mock-server" ... && yarn playwright test |
拉起 Mock 服务器后跑 Playwright E2E |
check-types |
tsc --noEmit |
类型检查 |
storybook / build-storybook |
storybook dev -p 6006 / storybook build |
组件开发工作台 |
generate |
plop |
基于 plopfile.cjs 与 generators/component 生成组件脚手架 |
run-mock-server |
tsx ./mock-server.ts |
独立 Mock API 服务 |
Next.js 侧配置非常克制,next.config.mjs 只开启了 reactStrictMode: true,路由与渲染行为全部由 Pages Router 约定驱动。
项目结构:app 是应用层,pages 只做再导出
README 给出的结构性解释是:pages 文件夹不够灵活、也不支持文件同层归置(collocation),因此把真正的应用层放在 app 目录——所有 feature 在这里组合;pages 目录则只负责再导出 Next.js 所需的页面文件和 getServerSideProps,让 Next.js 能够识别并按路由提供服务。
这一设计在源码中体现得非常直接。src/pages/app/index.tsx 全部内容只有一行:
export { DashboardPage as default } from '@/app/pages/app/discussions/discussions';
真实的页面组件位于 src/app/pages/app/dashboard.tsx,它同时通过 getLayout 声明自己的布局:
DashboardPage.getLayout = (page: ReactElement) => {
return (
<DashboardLayout>
<ContentLayout title="Dashboard">{page}</ContentLayout>
</DashboardLayout>
);
};
getLayout 的消费方是 src/pages/_app.tsx:这里定义并扩展了 NextPageWithLayout 类型,App 组件默认执行 Component.getLayout ?? ((page) => page),把整棵页面树包进 AppProvider。这套布局注入机制正是“页面组件写在哪里都行”的前提——布局信息与页面组件同文件共存,而不依赖目录位置。
一个应用层组件,两个路由入口
讨论详情页 src/app/pages/app/discussions/discussion.tsx 是这套结构的最佳例证。该文件同时导出三样东西:
getServerSideProps:在服务端用独立的QueryClient依次prefetchQuery讨论详情与prefetchInfiniteQuery评论列表(均透传请求 Cookie),再把dehydrate(queryClient)的结果塞进props.dehydratedState,完成 React Query 的服务端预取与序列化;DiscussionPage:纯展示组件,内部用useDiscussion消费水合后的数据,评论区块还套了一层ErrorBoundary,失败时提示 “Failed to load comments. Try to refresh the page.”;PublicDiscussionPage:把dehydratedState包进HydrationBoundary后再渲染DiscussionPage,作为公开路由的默认导出。
而两个 Next.js 入口只是各自挑取所需导出:
- 需登录的应用内路由 src/pages/app/discussions/[discussionId].tsx:
export { DiscussionPage as default } from '@/app/pages/app/discussions/discussion'; - 公开路由 src/pages/public/discussions/[discussionId].tsx:
export { getServerSideProps, PublicDiscussionPage as default } from ...,连getServerSideProps也一并复用。
列表页 src/pages/app/discussions/index.tsx 则展示了应用层组件里“路由无关”的交互细节:DiscussionsPage 在用户悬停列表项时通过 queryClient.prefetchInfiniteQuery 预取该讨论的评论数据,而这份逻辑写在 src/app/pages/app/discussions/discussions.tsx 中,pages 目录完全不知道它的存在。
小结
apps/nextjs-pages 应用值得借鉴的点有两个层面:
- 流程层面:环境变量经 Zod 强校验、Mock 服务器(Express + MSW http-middleware +
@mswjs/data)与应用共用同一套 handler 定义、E2E 用 pm2 自动拉起 Mock 服务器,构成一条可独立复现的本地开发链路(Node 20+ / Yarn 1.22+,run-mock-server需在dev之前启动); - 结构层面:业务代码、布局注入(
getLayout)与服务端数据预取全部收敛在app应用层,pages目录退化为单行再导出的路由适配器,从而在不放弃 Pages Router 的前提下实现 feature 级文件同层归置,并让同一个页面组件能同时挂载到受保护路由与公开路由两个入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
