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 可以碰 StyleSheet。Button 知道 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。