Blocks 与 Components:改版后留下的是什么
一篇讲清 ShipAny Next blocks 和 components 分工的实用指南:哪些该重写,哪些该保留,以及为什么这个切分能让 SaaS 定制更干净。
每个 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
同样的规则也适用于 PricingTable、BlogCard、AppSidebar、表单字段、数据表格、弹窗、按钮等基础组件。你的落地页叙事不应该写在这些文件里。
文件归属规则
改页面前先问这几个问题:
| 问题 | 如果是,放在 |
|---|---|
它读取 m['...']() 或其他产品文案吗? | src/blocks/ |
| 它决定区块顺序、导航链接、功能列表或 CTA 文案吗? | src/blocks/ 或 route |
| 它只是根据 props 渲染可复用形状吗? | src/components/ |
| 完整改版后它还应该有意义吗? | src/components/ |
| 替换首页时你会删掉它吗? | src/blocks/ |
重点不是文件夹洁癖。重点是让下一次重写的边界足够清楚。
开新项目时应该改什么
当 ShipAny Next 被用作新产品底座时,最快的干净路径是:
- 保留
src/components/*和src/components/ui/*。 - 重写
src/blocks/*里的落地区块。 - 重写
messages/en.json和messages/zh.json里的landing.*文案。 - 在
src/routes/index.tsx重新组合首页。 - 保留 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
mfrom@/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 活到下一个产品。