原文链接:cekrem.github.io/posts/depen…
在 React 开发领域,我们常会发现自己编写的组件与其依赖项紧密耦合。这使得测试变得困难、维护充满挑战,甚至几乎无法进行变更。依赖倒置原则(DIP)为我们提供了一条出路,但如何在 React 中有效应用它呢?
注:若需了解更侧重后端的依赖倒置实践,可参阅我先前关于Go语言中使用插件实现依赖倒置的文章。
问题:React中的紧密耦合
考虑这个常见场景:
const UserProfile = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <LoadingSpinner />;
return <UserDetails user={user} />;
};
该组件存在以下问题:
- 与fetch API耦合过紧
- 因直接调用API导致难以测试
- 数据源难以变更
- 无法轻松测试加载状态
解决方案:依赖倒置
依赖倒置原则指出,高层模块不应依赖低层模块,两者都应依赖抽象层。在 React 中,这意味着组件应依赖接口而非具体实现。
让我们看看如何重构代码:
interface UserRepository {
getUser: () => Promise<User>;
}
const UserProfile = ({
userRepository,
}: {
userRepository: UserRepository;
}) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
userRepository.getUser().then((data) => {
setUser(data);
setLoading(false);
});
}, [userRepository]);
if (loading) return <LoadingSpinner />;
return <UserDetails user={user} />;
};
(当然,你可以考虑将所有这些状态管理和useEffect逻辑提取到自定义钩子中,但这已超出当前讨论范围。)
实现存储库
现在我们可以创建存储库的具体实现:
class ApiUserRepository implements UserRepository {
async getUser(): Promise<User> {
const response = await fetch("/api/user");
return response.json();
}
}
class MockUserRepository implements UserRepository {
private resolveUser: (user: User) => void = () => {};
private rejectUserPromise: (error: Error) => void = () => {};
getUser(): Promise<User> {
return new Promise((resolve, reject) => {
this.resolveUser = resolve;
this.rejectUserPromise = reject;
});
}
// Helper method to resolve the promise
resolveWithUser(user: User) {
this.resolveUser(user);
}
// Helper method to reject the promise
rejectUser(error: Error) {
this.rejectUserPromise(error);
}
}
测试变得简单
采用这种结构,测试变得直截了当:
describe("UserProfile", () => {
it("shows loading state initially", () => {
const mockRepo = new MockUserRepository();
render(<UserProfile userRepository={mockRepo} />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
it("displays user data when loaded", async () => {
const mockRepo = new MockUserRepository();
render(<UserProfile userRepository={mockRepo} />);
// Simulate data fetching
mockRepo.resolveWithUser({
id: 1,
name: "Test User",
email: "test@example.com",
});
const userData = await screen.findByText("Test User");
expect(userData).toBeInTheDocument();
});
// Testing exceptions would be equally stragihtforward, but excluded for brevity
});
最佳实践
- 定义清晰接口:创建代表依赖关系的接口
- 注入依赖:通过 props 或上下文传递依赖,更优方案是使用 TSyringe
- 隔离测试:每个组件应能在脱离依赖的情况下独立测试
结论
在 React 中应用依赖倒置原则可带来:
- 更易测试的组件
- 更轻松的维护
- 更清晰的职责分离
- 更灵活且可复用的代码
请谨记:目标并非增加复杂性,而是提升代码的可维护性与可测试性。从小处着手,在最合适的场景应用这些原则。
延伸阅读
- Clean Architecture 作者:Robert C. Martin
- React Testing Library(官方文档)
- React中的单一职责原则 (前文)
关于依赖注入的说明
虽然本指南侧重于在 React 中应用依赖倒置原则,但我们不会深入探讨如何以简洁且可扩展的方式实现依赖注入。不过,若您希望进一步探索此领域,TSyringe 等库可作为在 React 应用中高效管理依赖关系的良好起点。