功能门控:我们如何在不复制组件的情况下构建 Freemium SaaS
Source: Dev.to
问题
我们需要在分析仪表盘中添加订阅层级。某些功能在免费计划下应显示升级提示,其他功能则应完全隐藏。但我们不想:
- 去触碰每一个需要限制的组件
- 打破已有的测试
- 让组件感知计费逻辑
- 为 “锁定” 状态复制 UI
解决方案:包装组件
与其在组件内部检查访问权限,我们改为把组件包裹起来:
// Before: No restrictions
// After: Gated by feature flag
组件本身保持纯粹,计费逻辑集中在一个地方。
两种门控模式
我们在 FeatureGate 中实现了两种行为:
模式 1:完全隐藏(默认)
// If the user doesn't have access, the component doesn't render.
UI 能自然流畅地呈现,不会出现空白。
模式 2:显示升级提示
// This shows a standardized upgrade card with the plan requirement and CTA.
为其提供动力的 Hook
const { hasAccess, isLoading, planName } = useFeatureAccess('reading_insights');
该 Hook:
- 检查当前用户的计划
- 返回他们是否拥有该功能的访问权限
- 提供加载状态
- 给出计划名称以便展示提示信息
处理页面级别的门控
对于需要升级才能使用的完整页面,我们在路由层面加入检查:
export default function ComparePage() {
const { hasAccess, isLoading, planName } = useFeatureAccess('document_comparison');
if (!isLoading && !hasAccess) {
return (
<>
<h3>Document Comparison</h3>
<p>Currently on: {planName}</p>
<button onClick={() => router.push('/settings/subscription')}>
Upgrade to Business
</button>
</>
);
}
// Normal page content...
}
提前返回的模式把锁定状态隔离在页面顶部。
本次提交的改动
以分析页面为例:
// Before
// After
每个分析小部件都被独立地进行门控。免费用户只能看到基础指标,付费用户则看到完整细分。
测试收益
最大的收获是:我们的组件保持可测试性:
// Component test – no billing logic
it('renders device breakdown', () => {
render();
expect(screen.getByText('Mobile')).toBeInTheDocument();
});
// Integration test – with feature gate
it('hides device breakdown for free users', () => {
mockUser({ plan: 'free' });
render(
<FeatureGate feature="device_analytics">
<DeviceBreakdown />
</FeatureGate>
);
expect(screen.queryByText('Mobile')).not.toBeInTheDocument();
});
注意事项:提前返回与 Hook
我们在链接详情页遇到问题。最初的代码:
const { hasAccess } = useFeatureAccess('reading_insights');
// 🚫 This violates Rules of Hooks
if (!hasAccess) {
return <Redirect />;
}
const someOtherHook = useSomeHook(); // Hook called conditionally!
**修复方案:**先调用所有 Hook,再检查访问权限。
const { hasAccess } = useFeatureAccess('reading_insights');
const someOtherHook = useSomeHook();
const router = useRouter();
// ✅ Now we can return early safely
if (!hasAccess) {
return <Redirect />;
}
配置集中管理
所有功能定义都放在同一个配置对象中:
const FEATURE_ACCESS = {
free: ['basic_analytics'],
pro: ['basic_analytics', 'reading_insights', 'device_analytics'],
business: [
'basic_analytics',
'reading_insights',
'device_analytics',
'document_comparison',
'ab_tests',
],
};
想要修改 Pro 计划包含的功能?只需更新这一个对象。
结果
- 17 个文件加入了功能门控
- 实际功能组件零改动
- 应用内统一的升级提示
- 功能与计费逻辑清晰分离
包装模式把计费关注点隔离开来,组件只专注于自身职责,而不需要关心谁能看到它们。