如何为转换自定义主题样式的React Hook编写TypeScript类型
实现带主题映射与TypeScript类型约束的React Native useStyle Hook
需求说明
封装一个useStyle Hook,接收包含自定义主题键的样式对象,将主题键映射为实际主题值,最终返回符合StyleSheet.create()要求的样式对象,同时通过TypeScript保证输入输出的类型严格匹配。
示例效果:
function useStyle(style){ const theme = useTheme(); const newStyle = 转换主题键为实际值的逻辑 return StyleSheet.create(newStyle) } // 输入:带主题键的样式 const style = { container: { margin: "small", backgroundColor: "background" }, text: { color: "text", fontSize: "medium" } } const styleObject = useStyle(style) // 输出类型自动推导为: { container: { margin: number, backgroundColor: string }, text: { color: string, fontSize: number } }
简化示例的类型报错问题
以下简化示例中,访问style.margin和style.fontSize时触发类型错误:
type RNViewStyle = { margin?: number; } type RNTextStyle = { fontSize?: number; } type ViewStyle = { margin?: keyof Theme['spacing']; } type TextStyle = { fontSize?: keyof Theme['font']['size']; } type Style<T> = { [Key in keyof T]: ViewStyle | TextStyle; }; type TranslatedStyle<T> = { [Key in keyof T]: RNViewStyle | RNTextStyle } type Theme = { spacing: { small: number; medium: number; large: number; } font: { size: { small: number; medium: number; large: number; } } } const theme: Theme = { spacing: { small: 10, medium: 20, large: 30, }, font: { size: { small: 10, medium: 20, large: 30, } } } const translateStyle = <T extends Style<T>>(styles: T): TranslatedStyle<T> => { const newStyles: TranslatedStyle<T> = {} as TranslatedStyle<T>;; for (const key in styles) { const style = styles[key]; const newStyle: RNViewStyle | RNTextStyle = {}; // 类型报错:类型“ViewStyle | TextStyle”上不存在属性“margin” if (style.margin) { newStyle.margin = theme.spacing[style.margin] } // 类型报错:类型“ViewStyle | TextStyle”上不存在属性“fontSize” if (style.fontSize) { newStyle.fontSize = theme.font.size[style.fontSize] } newStyles[key] = newStyle; } return newStyles; } const style = translateStyle({ container: {margin: 'small'}, text: {fontSize: 'small'} });
报错原因
Style<T>中每个属性是ViewStyle | TextStyle的联合类型,TypeScript无法确定当前遍历的style具体属于哪一种类型,因此只允许访问联合类型的共有属性,而margin和fontSize是各自类型独有的属性,触发类型错误。
简化示例修复方案
通过类型守卫区分ViewStyle和TextStyle,让TypeScript明确当前样式的类型:
// 新增类型守卫函数 const isViewStyle = (style: ViewStyle | TextStyle): style is ViewStyle => { return 'margin' in style; }; const isTextStyle = (style: ViewStyle | TextStyle): style is TextStyle => { return 'fontSize' in style; }; const translateStyle = <T extends Style<T>>(styles: T): TranslatedStyle<T> => { const newStyles: TranslatedStyle<T> = {} as TranslatedStyle<T>;; for (const key in styles) { const style = styles[key]; const newStyle: RNViewStyle | RNTextStyle = {}; if (isViewStyle(style) && style.margin) { newStyle.margin = theme.spacing[style.margin]; } if (isTextStyle(style) && style.fontSize) { newStyle.fontSize = theme.font.size[style.fontSize]; } newStyles[key] = newStyle; } return newStyles; }
完整React Native实现
主题与样式类型定义
export type FontWeight = | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | 'bold' | 'normal'; export type FontTheme = { family: string; size: { small: number; medium: number; large: number; jumbo: number; }; weight: { light: FontWeight; regular: FontWeight; medium: FontWeight; bold: FontWeight; }; }; export type SpacingTheme = { none: number; small: number; medium: number; large: number; }; export type ColorTheme = { background: string; foreground: string; border: string; text: string; textInverse: string; primary: string; secondary: string; attention: string; // 行动召唤色 toned: string; // 灰色系 success: string; // 绿色 warning: string; // 黄色 error: string; // 红色 info: string; // 蓝色 facebook: string; google: string; apple: string; }; export type Theme = { isDark: boolean; font: FontTheme; spacing: SpacingTheme; colors: ColorTheme; }; // 主题键类型别名 export type KeyofFontSizeTheme = keyof FontTheme['size']; export type KeyofFontWeightTheme = keyof FontTheme['weight']; export type KeyofSpacingTheme = keyof SpacingTheme; export type KeyofColorTheme = keyof ColorTheme; // 自定义样式类型(带主题键) export type MarginStyle = { margin?: KeyofSpacingTheme; marginVertical?: KeyofSpacingTheme; marginHorizontal?: KeyofSpacingTheme; marginTop?: KeyofSpacingTheme; marginRight?: KeyofSpacingTheme; marginBottom?: KeyofSpacingTheme; marginLeft?: KeyofSpacingTheme; }; export type PaddingStyle = { padding?: KeyofSpacingTheme; paddingVertical?: KeyofSpacingTheme; paddingHorizontal?: KeyofSpacingTheme; paddingTop?: KeyofSpacingTheme; paddingRight?: KeyofSpacingTheme; paddingBottom?: KeyofSpacingTheme; paddingLeft?: KeyofSpacingTheme; }; export type SpacingStyle = MarginStyle & PaddingStyle; export type AlignmentStyle = { align?: 'center' | 'start' | 'end'; }; export type FlexStyle = { flex?: boolean | number; direction?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; wrap?: 'wrap' | 'nowrap' | 'wrap-reverse'; justify?: | 'center' | 'start' | 'end' | 'space-between' | 'space-around' | 'space-evenly'; overflow?: 'visible' | 'hidden' | 'scroll'; gap?: KeyofSpacingTheme; rowGap?: KeyofSpacingTheme; columnGap?: KeyofSpacingTheme; }; export type BorderStyle = { borderColor?: KeyofColorTheme; borderWidth?: number; borderTopWidth?: number; borderRightWidth?: number; borderBottomWidth?: number; borderLeftWidth?: number; borderRadius?: number; }; export type DimensionStyle = { width?: number | `${number}%`; minWidth?: number | `${number}%`; maxWidth?: number | `${number}%`; height?: number | `${number}%`; minHeight?: number | `${number}%`; maxHeight?: number | `${number}%`; }; export type PositionStyle = { position?: 'absolute' | 'relative'; top?: number; right?: number; bottom?: number; left?: number; }; export type BackgroundStyle = { backgroundColor?: KeyofColorTheme; }; export type ViewStyle = SpacingStyle & AlignmentStyle & FlexStyle & BorderStyle & DimensionStyle & PositionStyle & BackgroundStyle; export type ImageStyle = {}; export type TextStyle = AlignmentStyle & SpacingStyle & { fontSize?: KeyofFontSizeTheme; fontWeight?: KeyofFontWeightTheme; fontColor?: KeyofColorTheme; textAlign?: 'left' | 'right' | 'center' | 'justify'; };
类型映射工具与useStyles Hook实现
import { useTheme, StyleSheet, useMemo } from 'react-native'; import NamedStyles = StyleSheet.NamedStyles; // 定义输入样式类型 export type InputStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle; }; // 定义转换后的输出样式类型:将主题键替换为实际值类型 type TranslatedViewStyle = Omit<ViewStyle, keyof SpacingStyle | 'backgroundColor' | 'borderColor'> & { [K in keyof SpacingStyle]: number | undefined; } & { backgroundColor?: string; borderColor?: string; }; type TranslatedTextStyle = Omit<TextStyle, keyof SpacingStyle | 'fontSize' | 'fontWeight' | 'fontColor'> & { [K in keyof SpacingStyle]: number | undefined; } & { fontSize?: number; fontWeight?: FontWeight; fontColor?: string; }; type TranslatedImageStyle = ImageStyle; type TranslatedStyles<T> = { [P in keyof T]: T[P] extends ViewStyle ? TranslatedViewStyle : T[P] extends TextStyle ? TranslatedTextStyle : TranslatedImageStyle; }; // 样式转换工具函数 const translateStyle = <S extends ViewStyle | TextStyle | ImageStyle>(style: S, theme: Theme): S extends ViewStyle ? TranslatedViewStyle : S extends TextStyle ? TranslatedTextStyle : TranslatedImageStyle => { const translated: Record<string, any> = {...style}; // 处理间距样式 const spacingProps: (keyof SpacingStyle)[] = [ 'margin', 'marginVertical', 'marginHorizontal', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'padding', 'paddingVertical', 'paddingHorizontal', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'gap', 'rowGap', 'columnGap' ]; spacingProps.forEach(prop => { if (translated[prop]) { translated[prop] = theme.spacing[translated[prop] as KeyofSpacingTheme]; } }); // 处理ViewStyle专属属性 if ('backgroundColor' in translated && translated.backgroundColor) { translated.backgroundColor = theme.colors[translated.backgroundColor as KeyofColorTheme]; } if ('borderColor' in translated && translated.borderColor) { translated.borderColor = theme.colors[translated.borderColor as KeyofColorTheme]; } // 处理TextStyle专属属性 if ('fontSize' in translated && translated.fontSize) { translated.fontSize = theme.font.size[translated.fontSize as KeyofFontSizeTheme]; } if ('fontWeight' in translated && translated.fontWeight) { translated.fontWeight = theme.font.weight[translated.fontWeight as KeyofFontWeightTheme]; } if ('fontColor' in translated && translated.fontColor) { translated.fontColor = theme.colors[translated.fontColor as KeyofColorTheme]; } return translated as any; }; // 最终的useStyles Hook export function useStyles<T extends InputStyles<T>>(styles: T): NamedStyles<TranslatedStyles<T>> { const theme = useTheme<Theme>(); return useMemo(() => { const translatedStyles: Record<string, any> = {}; Object.entries(styles).forEach(([key, style]) => { translatedStyles[key] = translateStyle(style, theme); }); return StyleSheet.create(translatedStyles) as NamedStyles<TranslatedStyles<T>>; }, [styles, theme]); }
使用示例
import { View, Text } from 'react-native'; import { useStyles } from './path-to-useStyles'; const ExampleComponent = () => { const styles = useStyles({ container: { margin: 'medium', backgroundColor: 'background', justify: 'center', align: 'center', flex: 1 }, title: { fontSize: 'large', fontWeight: 'bold', fontColor: 'primary', marginBottom: 'small' } }); return ( <View style={styles.container}> <Text style={styles.title}>示例文本</Text> </View> ); };
内容的提问来源于stack exchange,提问作者Michal Fedyna
相关产品推荐
相关产品推荐

