Purikura的页面系统

3 min read
Zekari
架构设计ReactNext.jsTypeScriptPurikura 项目

为什么这么设计五层架构

架构不是为了炫技,而是为了解决一个简单的问题:如何让修改变得容易。

模型页面系统 (/m/[modelId]) 展示了这个理念。它通过五层分层架构,让页面生成变成了编辑 JSON 文件这么简单的事。不需要改代码,不需要重新部署组件,只需要修改数据。

这就是数据驱动开发的核心:把变化的部分抽象成数据,把不变的部分固化成代码。

五层架构的设计思路

第一层:类型定义层

类型定义层的职责是定义数据的形状。它不关心数据从哪来,也不关心数据怎么用,只关心数据应该长什么样。

// lib/ui-i18n-types.ts
export interface ShowcaseItem {
  id: string;
  title: string;
  promptText: string;
  mediaUrl: string;
  mediaType: 'image' | 'video';
  mediaAlt: string;
  metadata: ShowcaseItemMetadata;
  useCase?: string;    // 可选字段
  proTip?: string;     // 可选字段
}

关键在于可选字段。useCase?proTip? 的问号不是随便加的,它代表了架构的灵活性:有些模型需要这些字段,有些不需要。类型系统通过可选字段支持这种差异,而不是强制所有数据都一样。

你可能会想:为什么不定义多个接口,比如 BasicShowcaseItemAdvancedShowcaseItem

答案是:可选字段更灵活。如果用联合类型,你需要在每个使用的地方做类型判断。而可选字段只需要一个条件渲染:

{item.useCase && <div>{item.useCase}</div>}

这是架构设计的权衡:复杂的类型系统 vs 简单的条件渲染。我们选择后者。

第二层:数据层

数据层存储实际内容。它的形式是 JSON 文件,这不是偶然的选择。

{
  "model_info": {
    "id": "kie-sora2",
    "name": "Sora 2",
    "tagline": "OpenAI Sora - Text-to-Video AI Generator (2025)",
    "vendor": "OpenAI",
    "category": "text-to-video"
  },
  "example_showcase": {
    "title": "Scroll down to see OpenAI Sora 2 video examples",
    "items": [
      {
        "id": "sora2-pillow-fight-001",
        "title": "Epic Pillow Fight Battle",
        "promptText": "...",
        "mediaUrl": "https://s.purikura.io/...",
        "mediaType": "video"
      }
    ]
  }
}

JSON 的优势在于:非技术人员可以编辑。你不需要懂 React,不需要懂 TypeScript,只需要知道如何填写字段。这降低了维护成本,也降低了出错概率。

嵌套结构支持复杂的内容组织,但整体还是扁平的。model_infoexample_showcasefaq 每个都是独立的模块,可以单独修改,也可以完全删除。

第三层:组件层

组件层负责把数据渲染成 UI。它的设计原则是:永远不假设数据一定存在。

// components/example-showcase/index.tsx
{item.useCase && (
  <div className="mb-4">
    <h5 className="text-rose-300 font-medium text-sm mb-1">Use Case</h5>
    <p className="text-slate-300 text-sm">{item.useCase}</p>
  </div>
)}

{item.proTip && (
  <div className="bg-pink-500/10 border border-pink-500/30 rounded-lg p-3">
    <h5 className="text-pink-300 font-medium text-sm mb-1">💡 Pro Tip</h5>
    <p className="text-slate-300 text-sm">{item.proTip}</p>
  </div>
)}

条件渲染是这一层的核心。item.useCase && 不仅仅是一个语法糖,它代表了一种设计哲学:组件应该自动适配数据。如果数据存在就渲染,如果不存在就跳过。

这种设计让组件具有通用性。同一个组件可以处理不同结构的数据,只要数据符合类型定义。

对于 SEO 重要但视觉不需要的信息,使用 sr-only 类:

{metadata && (
  <div className="sr-only" aria-hidden="true">
    Model: {metadata.model}, Resolution: {metadata.resolution},
    Generation Time: {metadata.generationTime}
  </div>
)}

这样搜索引擎能读到元数据,但用户界面保持简洁。这是一种折衷:保留 SEO 价值但不影响视觉体验。

组件层还实现了渐进增强。服务端渲染静态内容用于 SEO,客户端渲染动态交互用于体验。两者互不干扰,也互相补充。

第四层:加载器层

加载器层的任务是把 JSON 文件读取进来,并确保它符合类型定义。

// lib/model-content.ts
export async function getModelContent(modelId: string) {
  try {
    const content = await import(`@/content/models/${modelId}.json`);
    validateModelContent(content);
    return content;
  } catch (error) {
    console.error(`Failed to load model content: ${modelId}`, error);
    return null;
  }
}

这一层看起来简单,但它是类型安全的关键。JSON 文件本身是无类型的,加载器通过运行时验证把它转换成有类型的数据。

错误处理也在这一层。如果文件不存在,如果格式错误,都在这里优雅地处理,而不是让错误传播到上层。

第五层:页面层

页面层是整个架构的入口。它从 URL 中提取参数,调用加载器获取数据,然后把数据传递给组件。

// app/m/[modelId]/page.tsx
export default async function ModelPage({ params }: { params: { modelId: string } }) {
  const content = await getModelContent(params.modelId);

  if (!content) {
    notFound();
  }

  return (
    <>
      <ModelHero content={content.model_info} />

      {content.example_showcase && (
        <ExampleShowcase content={content.example_showcase} locale="en" />
      )}

      {content.faq && (
        <FAQSection content={content.faq} />
      )}
    </>
  );
}

这一层的代码几乎不需要修改。新增模型?创建一个 JSON 文件。新增字段?组件会自动处理。这就是零代码修改的含义。

动态路由生成更进一步:

export async function generateStaticParams() {
  const modelIds = await getAvailableModelIds();
  return modelIds.map((modelId) => ({ modelId }));
}

系统会自动扫描 content/models/ 目录,为每个 JSON 文件生成一个静态页面。你不需要手动维护路由列表。

架构验证

理论上的设计需要实践验证。这个架构经过三个测试场景:

场景 1:添加新模型

创建 content/models/runway-gen3.json 文件。结果:页面自动生成,无需修改代码。

这验证了数据驱动的核心:变化被隔离在数据层,代码层完全不受影响。

场景 2:不同数据结构

有些模型有 useCase 字段,有些没有。结果:组件自动适配,不会显示空白或报错。

这验证了条件渲染的有效性:组件能够处理可选字段。

场景 3:不同媒体类型

混合使用 imagevideo。结果:组件根据 mediaType 自动选择正确的渲染方式。

这验证了类型驱动的设计:通过联合类型 'image' | 'video',让组件知道如何处理不同的媒体。

设计原则总结

这个架构体现了几个设计原则:

关注点分离:数据、类型、组件、加载、页面各自独立。修改一个不影响其他。

类型安全:从 TypeScript 接口到运行时验证,确保数据一致性。

条件渲染:永远不假设数据存在,让组件自动适配。

渐进增强:先保证 SEO,再增强交互。两者独立但互补。

数据驱动:把变化的部分(内容)和不变的部分(逻辑)分开。

这些原则不是为了追求完美,而是为了让修改变得容易。当你需要添加新模型时,你只需要创建一个 JSON 文件。这就是架构的价值。

五层架构不是免费的。它增加了初始开发的复杂度,也增加了学习成本。

如果你只需要创建一个页面,这个架构是过度设计。但如果你需要创建十个、二十个相似的页面,这个架构会节省大量时间。

架构设计是权衡:前期投入 vs 长期收益。这个架构选择了后者。

技术栈

这个架构基于现代前端技术栈:

  • Next.js 14:App Router 和 Server Components 支持服务端渲染
  • TypeScript:类型安全和接口定义
  • Framer Motion:滚动驱动的动画效果
  • Tailwind CSS:原子化的样式系统
  • React:组件化和条件渲染

每个技术都有明确的作用。TypeScript 提供类型安全,Next.js 提供路由和渲染,Tailwind 提供样式,Framer Motion 提供动画。它们共同支撑了这个数据驱动的架构。

Related Posts

Articles you might also find interesting

离屏渲染:照片捕获为什么需要独立的 canvas

2 min read

实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。

架构设计前端开发
Read More

集中式配置:让 Reddit 组件脱离重复泥潭

2 min read

当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。

架构设计React组件
Read More

双重导出管道的架构选择

2 min read

在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。

架构设计图像导出
Read More

继承基础配置

2 min read

配置不需要重复书写。继承机制让每个层次只表达自己的差异。

TypeScript配置管理
Read More

重复数据的迁移实践:从 N 个文件到 1 个真相源

3 min read

当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。

架构设计配置管理
Read More

让错误浮现

1 min read

Next.js 构建悬挂问题的根源不在工具,而在掩盖。严格类型检查不是负担,而是质量的守护者。

TypeScriptNext.js
Read More

Studio 前端架构:从画布到组件的设计思考

3 min read

深入 Purikura Studio 前端架构设计,探讨 DOM-based 画布、状态管理和组件化的实践经验

Purikura 项目前端架构
Read More

Context 驱动的认证状态管理

3 min read

认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。

软件设计认证系统
Read More
Featured

定价界面优化的三层方法

4 min read

数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题

UI/UX定价策略
Read More

约束驱动设计:为何选择内存追踪

2 min read

在 Cloudflare Workers 环境中实现追踪系统,持久化和内存存储之间的权衡不是技术偏好,而是约束驱动的必然选择。

架构设计Cloudflare Workers
Read More

依赖注入

2 min read

依赖注入不是关于框架或工具,而是关于控制权的转移。理解这个转移,就理解了软件设计的核心原则。

软件设计系统思维
Read More

让文档跟着代码走

2 min read

文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。

文档软件工程
Read More

动态元数据生成:让机器读懂你的页面

2 min read

generateMetadata 不只是填写表单。它决定了搜索引擎、社交平台、AI 系统如何理解和呈现你的内容。

Next.jsSEO
Read More

tsc --noEmit:即时类型反馈

2 min read

类型错误不应该等到构建时才发现。最快的反馈来自最简单的命令。

TypeScript类型检查
Read More

Google Fonts 官方集成

2 min read

Next.js 提供了 next/font 模块,让字体加载变得简单且性能优化。Google Fonts 是最直接的商用免费字体选择。

Next.js字体
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

全局安装 TypeScript

1 min read

当 tsc 命令未找到时,你缺少的不是命令,而是编译器本身。

TypeScript开发工具
Read More

分层修复

3 min read

生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。

工程实践问题修复
Read More

多厂商 AI 调度:统一混乱的供应商生态

3 min read

当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。

Purikura 项目系统架构
Read More

next-intl localePrefix:默认语言不显示前缀

2 min read

理解 next-intl 中 localePrefix 配置的设计哲学,以及为什么默认语言不应在 URL 中显现。

Next.jsi18n
Read More

next-intl 的服务端与客户端协同机制

3 min read

理解 next-intl 如何在 Next.js App Router 中协调服务端渲染和客户端交互,以及为什么需要显式设置 locale。

Next.jsi18n
Read More

减少 Next.js 启动时的工作量

2 min read

开发服务器启动缓慢不是偶然。它在做的事太多了。

Next.js性能优化
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

分布式 Workers 的解耦设计

3 min read

通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。

Purikura 项目系统架构
Read More

React Context Provider

3 min read

Context 不是状态管理。它是数据传递的通道。这个区别决定了你应该如何使用它。

ReactContext API
Read More