返回博客

Blocks 与 Components:改版后留下的是什么

一篇讲清 ShipAny Next blocks 和 components 分工的实用指南:哪些该重写,哪些该保留,以及为什么这个切分能让 SaaS 定制更干净。

2026年5月28日ShipAny 团队ShipAny 团队

每个 SaaS 模板都会说自己"轻松定制"。真正难的不是改第一屏标题,而是在换产品、换品牌、换价格故事、换导航、换落地页结构时,不把底下可复用的 UI 和业务底座一起改坏。

ShipAny Next 在前端文件里划了一条很硬的线:

读取翻译文案的文件是 block,所有内容都通过 props 传入的文件是 component

这条规则很小,但它决定了改版后什么应该留下。

这个切分解决什么问题

模板默认带的是别人的产品叙事。它有别人的 hero 文案、功能顺序、截图位置、价格表达和默认导航。如果这些内容和可复用 UI 混在同一个文件里,每开一个新项目都像是在拆旧装修。

你会不断遇到这些问题:

  • 这个文件到底是产品文案,还是可复用 UI?
  • 我能不能安全删掉这个区块?
  • 为什么一个价格卡片知道落地页的语言文案?
  • 为什么 header 组件知道这个项目有哪些导航链接?
  • 改掉演示区块会不会影响后台或 admin 页面?

Blocks 与 components 的切分,就是为了让这些答案变得简单。

Block 可以知道当前产品。Component 不应该知道。

Block 是产品装配层

Block 放在 src/blocks/。它们是零配置的页面区块,比如 <Header /><Hero /><Pricing /><Blog /><Footer />

一个 block 可以读取翻译、选择文案、组装导航链接、挑选 icon、决定展示哪些价格计划,然后把最终内容传给耐用的 component。

这是当前项目里真实的 block 写法:

import { m } from '@/paraglide/messages.js';
import { SiteHeader } from '@/components/site-header';

export function Header() {
  const navLinks = [
    { href: '/', label: m['landing.nav.generator']() },
    { href: '/#features', label: m['landing.nav.use_cases']() },
    { href: '/pricing', label: m['landing.nav.pricing']() },
    { href: '/blog', label: m['landing.nav.blog']() },
  ];

  return <SiteHeader navLinks={navLinks} />;
}

这个文件知道落地页的导航文案,也知道当前产品需要哪些链接。这没问题,因为它是 block。

当你基于这个引擎做一个新的 SaaS 产品时,这类文件本来就应该重写。

Component 是可复用 UI

Component 放在 src/components/。它不应该从翻译文件里读取产品文案,而应该通过 props 接收内容并负责渲染。

SiteHeader 是 component。它不决定有哪些导航链接,只渲染传进来的 navLinks

这样它才能跨项目复用:

  • 播客工具站可以传 generator、pricing、blog 链接
  • lead magnet 工具可以传 validation、examples、billing 链接
  • 内部后台可以直接用另一套 app shell
  • 以后重新改版时,可以换掉整个 header block,而不用重写 header primitive

同样的规则也适用于 PricingTableBlogCardAppSidebar、表单字段、数据表格、弹窗、按钮等基础组件。你的落地页叙事不应该写在这些文件里。

文件归属规则

改页面前先问这几个问题:

问题如果是,放在
它读取 m['...']() 或其他产品文案吗?src/blocks/
它决定区块顺序、导航链接、功能列表或 CTA 文案吗?src/blocks/ 或 route
它只是根据 props 渲染可复用形状吗?src/components/
完整改版后它还应该有意义吗?src/components/
替换首页时你会删掉它吗?src/blocks/

重点不是文件夹洁癖。重点是让下一次重写的边界足够清楚。

开新项目时应该改什么

当 ShipAny Next 被用作新产品底座时,最快的干净路径是:

  1. 保留 src/components/*src/components/ui/*
  2. 重写 src/blocks/* 里的落地区块。
  3. 重写 messages/en.jsonmessages/zh.json 里的 landing.* 文案。
  4. src/routes/index.tsx 重新组合首页。
  5. 保留 SaaS 引擎、auth、payments、credits、admin 页面和可复用 UI primitives。

这比维护一个巨大的首页组件便宜得多。后者通常把业务文案、布局 primitive、价格行为和导航逻辑全部搅在一起。

具体例子:pricing

Pricing 是最容易看出这个切分价值的地方。

Pricing block 可以决定:

  • 有哪些套餐
  • 每个套餐展示哪些功能
  • 哪个套餐是 featured
  • 启用哪些支付方式
  • 按钮文案是什么
  • checkout 成功后跳到哪里

Pricing table component 应该只负责根据结构化 props 渲染表格,并在用户选择套餐时调用 onCheckout

这样价格叙事可以随产品变化,但表格 UI 仍然稳定。新产品可以改套餐文案和 product ID,不用重新做 tabs、badge、功能行、响应式列和 checkout 按钮状态。

具体例子:blog cards

Blog section block 可以接收或加载文章,决定首页展示几篇,格式化当前语言的日期,并决定这个区块的标题。

BlogCard component 只应该渲染:

  • title
  • description
  • image
  • date
  • author
  • link

如果首页以后从"最新文章"改成"客户指南",card 不需要知道。Block 改叙事,component 继续渲染卡片。

常见错误

当可复用 component 开始向上读取应用层信息时,这个切分就会坏掉。

尽量避免这些写法:

  • 在通用 component 里 import m from @/paraglide/messages.js
  • SiteHeader 里硬编码首页导航链接
  • PricingTable 直接知道 product ID
  • 在视觉组件里 import 数据库或 server-only module
  • 因为今天看起来更快,就写一个巨大的 landing page component

第一版可能省事,第二个项目会还债。

这对改版意味着什么

改版时,可抛弃层应该承担大部分变化:

  • 新 headline
  • 新导航
  • 新产品分类
  • 新功能顺序
  • 新截图
  • 新套餐名称
  • 新 CTA 文案
  • 新 FAQ 角度

耐用层应该尽量少动:

  • buttons
  • cards
  • dialogs
  • tables
  • sidebars
  • pricing table layout
  • form fields
  • authenticated app shell

这就是检验标准。如果一次改版迫使你重写 primitives,说明这些 primitives 之前知道得太多。

页面文件为什么能保持很小

Route 应该主要负责组合 blocks。

营销首页的 route 应该像目录一样清楚:

function HomePage() {
  const { posts } = Route.useLoaderData();

  return (
    <>
      <Header />
      <ShowNotesLandingPage variantKey="podcast" />
      <Blog posts={posts} />
      <Footer />
    </>
  );
}

Route 决定组合。Block 决定产品内容。Component 渲染可复用 UI。

模型就这么简单。

一个好用的心智模型

把 block 理解成产品叙事和设计系统之间的 adapter。

它负责把这些东西翻译成 UI props:

  • message keys -> labels
  • product strategy -> page sections
  • pricing config -> card props
  • route data -> visible content
  • locale-aware copy -> reusable UI

Component 不应该关心内容从哪里来。它只需要关心 props 是否有效,以及如何稳定、可访问地渲染它们。

最后一条规则

如果你在定制 ShipAny Next,请放心删除并重写 blocks。它们本来就是产品特定的。

但对 components 要更谨慎。它们是你希望跨多个项目保留下来的部分。

这就是这个架构切分的意义:blocks 表达当前产品,components 活到下一个产品。