收藏功能是内容型应用中不可或缺的基础模块。在WanAndroid这类技术社区应用中,用户经常需要收藏优质文章以便后续查阅。这个看似简单的功能背后,实际上需要考虑多种边界情况和交互细节。
核心要解决的四个关键问题:
采用React Native + TypeScript组合主要基于以下考虑:
typescript复制<CollectList>
├── <CardContainer>
│ ├── <Header> (标题 + 刷新按钮)
│ ├── <EmptyState> (未登录/无收藏提示)
│ └── <ArticleList> (收藏文章列表)
└── <StyleProvider> (主题管理)
这种结构实现了:
采用React Hooks管理组件状态:
typescript复制interface CollectState {
articles: Article[];
loading: boolean;
error?: Error;
}
const [state, setState] = useState<CollectState>({
articles: [],
loading: false
});
这种设计比单独声明多个state变量更有优势:
原始方案使用嵌套三元表达式:
jsx复制{!isLoggedIn ? (
<Text>登录后查看收藏</Text>
) : collectArticles.length === 0 ? (
<Text>暂无收藏</Text>
) : (
// 渲染列表
)}
改进为更可读的渲染函数:
typescript复制const renderContent = () => {
if (!isLoggedIn) return <LoginPrompt />;
if (isEmpty(collectArticles)) return <EmptyCollect />;
return <ArticleList articles={collectArticles} />;
};
// 使用处
<View>{renderContent()}</View>
优势:
针对可能的长列表做了三项优化:
typescript复制const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
const nextPage = currentPage + 1;
const res = await api.getCollectList(nextPage);
setArticles(prev => [...prev, ...res.data]);
setCurrentPage(nextPage);
};
jsx复制<FlashList
data={articles}
renderItem={renderArticle}
estimatedItemSize={72}
onEndReached={loadMore}
/>
jsx复制<FastImage
source={{uri: item.cover}}
resizeMode="cover"
onLoad={() => setLoaded(true)}
/>
创建主题Provider统一管理样式:
typescript复制const ThemeContext = createContext({
colors: lightColors,
spacing: baseSpacing
});
// 使用示例
const { colors } = useContext(ThemeContext);
<View style={{backgroundColor: colors.card}}>
定义的主题对象包含:
typescript复制const lightColors = {
primary: '#07C160',
card: '#FFFFFF',
text: '#333333',
border: '#F0F0F0'
};
const darkColors = {
primary: '#07C160',
card: '#1E1E1E',
text: '#E0E0E0',
border: '#383838'
};
采用StyleSheet.create创建可复用的样式块:
typescript复制const styles = StyleSheet.create({
shadow: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2
},
textXs: {
fontSize: 12,
lineHeight: 16
},
// 更多原子样式...
});
组合使用示例:
jsx复制<Text style={[styles.textXs, {color: theme.colors.text}]}>
创建独立的API服务模块:
typescript复制class CollectService {
private client: AxiosInstance;
constructor(client: AxiosInstance) {
this.client = client;
}
async getList(page: number): Promise<ApiResponse<Article[]>> {
try {
const res = await this.client.get('/lg/collect/list/${page}/json');
return normalizeResponse(res.data);
} catch (error) {
throw new ApiError('GET_COLLECT_FAILED', error);
}
}
}
统一处理API响应:
typescript复制interface ApiResponse<T> {
data: T;
error?: ApiError;
meta?: PaginationMeta;
}
function normalizeResponse<T>(data: any): ApiResponse<T> {
if (data.errorCode !== 0) {
return {
data: null,
error: new ApiError(data.errorCode, data.errorMsg)
};
}
return {
data: data.data as T,
meta: {
page: data.curPage,
total: data.pageCount
}
};
}
创建错误边界组件捕获渲染错误:
typescript复制class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <FallbackUI />;
}
return this.props.children;
}
}
typescript复制const handleApiError = (error: ApiError) => {
switch (error.code) {
case 'UNAUTHORIZED':
navigateToLogin();
break;
case 'NETWORK_ERROR':
showToast('网络连接异常');
break;
default:
logError(error);
showGenericError();
}
};
typescript复制describe('CollectList', () => {
it('显示登录提示当未认证', () => {
render(<CollectList isLoggedIn={false} />);
expect(screen.getByText('登录后查看收藏')).toBeTruthy();
});
it('加载首屏数据当已认证', async () => {
mockApi.getCollectList.mockResolvedValue(mockData);
render(<CollectList isLoggedIn={true} />);
await waitFor(() => {
expect(screen.getByText('我的收藏')).toBeTruthy();
});
});
});
typescript复制describe('收藏功能', () => {
it('完整用户流程', async () => {
await device.launchApp();
await loginTestUser();
await navigateToCollect();
await expect(element(by.text('暂无收藏'))).toBeVisible();
await collectTestArticle();
await pullToRefresh();
await expect(element(by.text('测试文章标题'))).toBeVisible();
});
});
使用React.memo优化列表项:
typescript复制const ArticleItem = React.memo(({ article }: { article: Article }) => {
return (
<TouchableOpacity>
<Text>{article.title}</Text>
</TouchableOpacity>
);
}, areEqual);
function areEqual(prevProps, nextProps) {
return prevProps.article.id === nextProps.article.id;
}
实现渐进式图片加载:
typescript复制const [loaded, setLoaded] = useState(false);
<View style={styles.placeholder}>
{!loaded && <ActivityIndicator />}
<FastImage
onLoad={() => setLoaded(true)}
source={{uri: item.cover}}
style={loaded ? styles.image : {width: 0, height: 0}}
/>
</View>
使用Storybook创建可视化文档:
typescript复制export default {
title: 'Components/CollectList',
component: CollectList,
} as Meta;
const Template: Story<CollectListProps> = (args) => <CollectList {...args} />;
export const LoggedInEmpty = Template.bind({});
LoggedInEmpty.args = {
isLoggedIn: true,
articles: []
};
配置metro.config.js加速构建:
javascript复制module.exports = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
resolver: {
resolverMainFields: ['react-native', 'browser', 'main'],
}
};
设计可插拔的功能模块:
typescript复制interface CollectFeature {
id: string;
renderItem: (article: Article) => ReactNode;
onPress?: (article: Article) => void;
}
const features: Record<string, CollectFeature> = {
basic: {
id: 'basic',
renderItem: (article) => <BasicItem article={article} />
},
pro: {
id: 'pro',
renderItem: (article) => <ProItem article={article} />,
onPress: (article) => analytics.track('pro_click')
}
};
配置多语言资源:
typescript复制const resources = {
en: {
collect: {
title: 'My Collections',
empty: 'No collections yet'
}
},
zh: {
collect: {
title: '我的收藏',
empty: '暂无收藏'
}
}
};
const { t } = useTranslation();
<Text>{t('collect.title')}</Text>
typescript复制useEffect(() => {
const sub = EventEmitter.addListener('COLLECT_CHANGED', refresh);
return () => sub.remove();
}, []);
typescript复制const controller = new AbortController();
try {
const res = await fetch(url, {
signal: controller.signal
});
} finally {
controller.abort();
}
typescript复制useEffect(() => {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
if (isEditing) {
showConfirmDialog();
return true;
}
return false;
}
);
return () => backHandler.remove();
}, [isEditing]);
这个收藏列表模块从最初简单实现到现在健壮的生产级组件,经历了多次迭代优化。核心体会是:看似简单的功能,要真正做好需要考虑各种边界情况、性能优化和可扩展性设计。特别是在跨平台场景下,更需要抽象通用逻辑同时保留各平台特性。