Vibe coding 的后遗症

你打开 Cursor,敲下一段 prompt,看着 Claude Code 在几秒内生成一整套 React Native UI。Flex layout、圆角 button、color theme,第一次就能跑起来。你把它发到 TestFlight。用户开始下载。这是第一周的故事。

到了第六周,你只是想改一个 brand color,结果要在 47 个文件里做三个小时的 find-and-replace。

这就是没有 styling contract 的 Vibe coding 的隐藏成本。当每一个 component 都在硬编码 Tailwind utility class 时,你的 styling engine 就会变成一个泄漏到每个 screen、每个 hook、每个 route guard 里的 dependency。改 A 会连带打坏 B、C、D。不是因为你的 logic 错了,而是因为你的 styling 根本没有 boundary。

为什么 NativeWind 会变成陷阱

下面是你让 Cursor 帮你写一个 button 时,它常常会给出的东西。看起来很干净,但维护起来就是灾难。

// This is what Claude Code gives you. It works until it doesn't.
<View className='p-4 flex-row justify-between'>
  <Text className='text-lg font-bold text-slate-800'>
    Title
  </Text>
  <Button className='bg-blue-500 px-4 py-2 rounded-lg' />
</View>

问题不在代码本身,而在 pattern。本质上,只要每个 file 里都塞着 raw styling logic,你就不可能在不触碰所有文件的前提下修改 design system。AI 的确交付得快,但它也会把 coupling 一起交付出去。你眼下这个“能跑”的 codebase,其实是个 house of cards。

解决办法: Theme Contract

你的 app 应该和 theme contract 对话,而不是直接和 styling engine 对话。contract 放在一个 file 里。primitive 放在一个 folder 里。其他地方只使用这些 primitive。

// src/constants/theme.ts
// The ONLY file that defines your app's visual identity.
// Claude Code reads this. Cursor references it.

export interface ThemeContract {
  colors: {
    background: string
    foreground: string
    surface: string
    primary: string
    danger: string
  }
  spacing: { xs: number; sm: number; md: number; lg: number; xl: number }
  radius: { sm: number; md: number; lg: number; full: number }
  typography: { title: number; body: number; caption: number }
}

export const theme = {
  colors: {
    background: '#0B0F19',
    foreground: '#F8FAFC',
    surface: '#111827',
    primary: '#2563EB',
    danger: '#DC2626',
  },
  spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
  radius: { sm: 8, md: 12, lg: 20, full: 999 },
  typography: { title: 28, body: 16, caption: 13 },
} as const satisfies ThemeContract

satisfies keyword 就是这里的 secret weapon。TypeScript 会检查 contract,但同时保留 literal type。你能拿到 theme.spacing.md 的 autocomplete。Claude Code 也能拿到一份明确可遵守的 schema。双方都受益。

Semantic Primitives:你的 UI 词汇表

只有 semantic primitive 可以碰 StyleSheetButton 知道 variant 和 size。Stack 知道 gap 和 alignment。screen 本身不写 className

// src/components/ui/button.tsx
// Public API: semantic props only.

import { Pressable, Text } from 'react-native'
import { theme } from '@/constants/theme'

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  onPress: () => void
  children: string
}

export function Button({
  variant = 'primary',
  size = 'md',
  loading,
  onPress,
  children,
}: ButtonProps) {
  const bgMap = {
    primary: theme.colors.primary,
    secondary: theme.colors.surface,
    danger: theme.colors.danger,
  }
  const heightMap = { sm: 36, md: 44, lg: 52 }

  return (
    <Pressable
      onPress={onPress}
      disabled={loading}
      accessibilityRole='button'
      accessibilityState={{ busy: loading }}
      style={{
        backgroundColor: bgMap[variant],
        height: heightMap[size],
        paddingHorizontal: theme.spacing.lg,
        borderRadius: theme.radius.md,
        justifyContent: 'center',
        alignItems: 'center',
        opacity: loading ? 0.6 : 1,
      }}>
      <Text style={{ color: '#FFF', fontSize: theme.typography.body }}>
        {children}
      </Text>
    </Pressable>
  )
}

只改一个 file,所有 button 都会一起更新。新 screen 只要按 contract 生成,architecture 就还能保持完整。

ESLint Gate

没有 enforcement 的 contract,本质上只是建议。你需要加一条 rule,在 primitive seam 之外直接拒绝 className

// eslint.config.js
{
  files: ['app/**/*', 'src/**/*'],
  ignores: ['src/components/ui/**/*', 'src/components/layout/**/*'],
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'JSXAttribute[name.name="className"]',
      message: 'Use semantic UI primitives instead of className.'
    }]
  }
}

Autotomy Expo Starter Pack 已经把这道 guardrail 预先配好了。你保留 Cursor 的速度,同时也保住一套能撑过前 1000 个用户的 architecture。