功能门控:我们如何在不复制组件的情况下构建 Freemium SaaS

发布: (2025年12月13日 GMT+8 09:27)
4 min read
原文: Dev.to

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 个文件加入了功能门控
  • 实际功能组件零改动
  • 应用内统一的升级提示
  • 功能与计费逻辑清晰分离

包装模式把计费关注点隔离开来,组件只专注于自身职责,而不需要关心谁能看到它们。

Back to Blog

相关文章

阅读更多 »

在 LLM 聊天 UI 中追求 240 FPS

TL;DR 我构建了一个 benchmark suite 来测试在 React UI 中 streaming LLM responses 的各种优化。关键要点:1. 首先构建合适的 state,然后再进行优化……