RN + TS + Mobx MVVM架构实践指南

212 阅读6分钟

RN + TS 的 MVVM 架构设计

MVVM (Model-View-ViewModel) 架构通过引入 ViewModel 层,实现了视图与业务逻辑的分离,非常适合 React Native + TypeScript 的项目。下面是一个完整的 RN + TS MVVM 架构设计方案:

架构核心概念

  • View:React 组件,负责 UI 展示,通过属性绑定接收数据,通过事件绑定触发 ViewModel 中的操作
  • ViewModel:核心业务逻辑层,处理数据转换、状态管理和业务逻辑,与 Model 和 View 进行双向数据绑定
  • Model:数据模型层,负责数据的存储、获取和处理,包括本地存储和远程 API 调用

数据流与通信机制

  1. View 到 ViewModel:通过事件绑定(如 onPress、onChange 等)触发 ViewModel 中的方法
  2. ViewModel 到 View:使用状态管理库(MobX)实现数据的响应式更新
  3. ViewModel 到 Model:ViewModel 调用 Model 层的方法获取或保存数据
  4. Model 到 ViewModel:数据变化通过 Promise 或回调通知 ViewModel

项目结构

my-rn-app/
├── src/
│   ├── app/                 # 应用全局配置
│   │   ├── navigation/      # 导航配置
│   │   ├── theme/           # 主题和样式
│   │   └── App.tsx          # 应用入口
│   │
│   ├── features/            # 功能模块
│   │   ├── auth/            # 认证模块
│   │   │   ├── models/      # 用户模型
│   │   │   ├── viewmodels/  # 认证 ViewModel
│   │   │   ├── views/       # 认证视图
│   │   │   └── services/    # 认证服务
│   │   │
│   │   └── posts/           # 文章模块
│   │       ├── models/      # 文章模型
│   │       ├── viewmodels/  # 文章 ViewModel
│   │       ├── views/       # 文章视图
│   │       └── services/    # 文章服务
│   │
│   ├── shared/              # 共享资源
│   │   ├── components/      # 通用组件
│   │   ├── viewmodels/      # 通用 ViewModel
│   │   ├── models/          # 通用模型
│   │   ├── utils/           # 工具函数
│   │   └── types/           # 全局类型
│   │
│   ├── services/            # 外部服务
│   │   ├── api/             # API 服务
│   │   ├── storage/         # 存储服务
│   │   └── eventBus/        # 事件总线
│   │
│   └── state/               # 状态管理
│       ├── stores/          # 全局存储
│       └── index.ts         # 状态入口
│
├── __tests__/               # 测试
├── babel.config.js          # Babel 配置
├── tsconfig.json            # TypeScript 配置
└── package.json             # 依赖配置

代码

model层 User.ts

BaseModel为自定义的基类model

import { BaseModel } from '../../../shared/models/BaseModel';

export interface UserData {
  id?: string;
  name: string;
  email: string;
  avatar?: string;
  token?: string;
  createdAt?: Date;
  updatedAt?: Date;
}

export class User extends BaseModel {
  name: string;
  email: string;
  avatar?: string;
  token?: string;

  constructor(data: UserData) {
    super(data);
    this.name = data.name;
    this.email = data.email;
    this.avatar = data.avatar;
    this.token = data.token;
  }

  // 从API数据创建用户实例
  static fromApi(data: any): User {
    return new User({
      id: data.id,
      name: data.name,
      email: data.email,
      avatar: data.avatar,
      token: data.token,
      createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
      updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined
    });
  }

  // 转换为API数据格式
  toApi() {
    return {
      id: this.id,
      name: this.name,
      email: this.email,
      avatar: this.avatar,
      token: this.token
    };
  }
}
    

网络请求 service层

import { User } from '../models/User';

// 模拟API服务
export class AuthService {
  // 登录方法
  async login(email: string, password: string): Promise<User> {
    // 实际项目中这里会调用真实API
    console.log('模拟登录请求:', email, password);
    
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    // 模拟登录成功返回
    return new User({
      id: '1',
      name: 'John Doe',
      email,
      avatar: 'https://picsum.photos/200/200',
      token: 'fake_token_123456'
    });
  }

  // 注册方法
  async register(name: string, email: string, password: string): Promise<User> {
    // 模拟注册请求
    console.log('模拟注册请求:', name, email, password);
    
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 1500));
    
    // 模拟注册成功返回
    return new User({
      id: Math.random().toString(36).substr(2, 9),
      name,
      email,
      avatar: 'https://picsum.photos/200/200',
      token: 'fake_token_' + Math.random().toString(36).substr(2, 10)
    });
  }

  // 登出方法
  async logout(): Promise<void> {
    // 模拟登出请求
    console.log('模拟登出请求');
    
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500));
  }
}
    

viewModel层

import { makeObservable, observable, action, computed } from 'mobx';
import { AuthService } from '../services/AuthService';
import { User } from '../models/User';
import { AppStore } from '../../../state/stores/AppStore';

export class LoginViewModel {
  @observable email: string = '';
  @observable password: string = '';
  @observable loading: boolean = false;
  @observable error: string | null = null;

  private authService: AuthService;
  private appStore: AppStore;

  constructor(authService: AuthService, appStore: AppStore) {
    this.authService = authService;
    this.appStore = appStore;
    
    makeObservable(this);
  }

  @computed
  get isValid(): boolean {
    return this.email.includes('@') && this.password.length >= 6;
  }

  @action
  setEmail = (email: string) => {
    this.email = email;
    this.error = null;
  };

  @action
  setPassword = (password: string) => {
    this.password = password;
    this.error = null;
  };

  @action
  login = async () => {
    if (!this.isValid) {
      this.error = '请输入有效的邮箱和密码';
      return;
    }

    this.loading = true;
    this.error = null;

    try {
      const user = await this.authService.login(this.email, this.password);
      this.appStore.setCurrentUser(user);
      // 登录成功后的操作
      console.log('登录成功:', user);
    } catch (error: any) {
      this.error = error.message || '登录失败';
      console.error('登录错误:', error);
    } finally {
      this.loading = false;
    }
  };
}
    

view容器层

import React, { Component } from 'react';
import { View, TextInput, Button, StyleSheet, ActivityIndicator, Text } from 'react-native';
import { observer } from 'mobx-react';
import { LoginViewModel } from '../viewmodels/LoginViewModel';

interface LoginViewProps {
  viewModel: LoginViewModel;
  onLoginSuccess?: () => void;
}

@observer
export default class LoginView extends Component<LoginViewProps> {
  render() {
    const { viewModel } = this.props;
    
    return (
      <View style={styles.container}>
        <TextInput
          style={styles.input}
          placeholder="邮箱"
          value={viewModel.email}
          onChangeText={viewModel.setEmail}
          keyboardType="email-address"
          autoCapitalize="none"
        />
        <TextInput
          style={styles.input}
          placeholder="密码"
          value={viewModel.password}
          onChangeText={viewModel.setPassword}
          secureTextEntry
        />
        {viewModel.error && <Text style={styles.error}>{viewModel.error}</Text>}
        <Button
          title={viewModel.loading ? '登录中...' : '登录'}
          onPress={viewModel.login}
          disabled={viewModel.loading || !viewModel.isValid}
        />
        {viewModel.loading && (
          <ActivityIndicator style={styles.loading} size="small" />
        )}
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    padding: 20,
    flex: 1,
    justifyContent: 'center'
  },
  input: {
    height: 40,
    borderColor: 'gray',
    borderWidth: 1,
    marginBottom: 15,
    paddingLeft: 10
  },
  error: {
    color: 'red',
    marginBottom: 10,
    textAlign: 'center'
  },
  loading: {
    marginTop: 10
  }
});    

其他辅助类:

AppStore : 用于全局的数据存储

import { makeObservable, observable, action } from 'mobx';
import { User } from '../../features/auth/models/User';

export class AppStore {
  @observable currentUser: User | null = null;
  @observable isAuthenticated: boolean = false;

  constructor() {
    makeObservable(this);
  }

  @action
  setCurrentUser = (user: User | null) => {
    this.currentUser = user;
    this.isAuthenticated = user !== null;
  };

  @action
  logout = () => {
    this.currentUser = null;
    this.isAuthenticated = false;
  };
}
    

ServiceLocator:提供全局的工具类方法,单利实现

import { AuthService } from '../features/auth/services/AuthService';
import { AppStore } from '../state/stores/AppStore';

export class ServiceLocator {
  private static instance: ServiceLocator;

  private authService: AuthService;
  private appStore: AppStore;

  private constructor() {
    this.authService = new AuthService();
    this.appStore = new AppStore();
  }

  static getInstance() {
    if (!this.instance) {
      this.instance = new ServiceLocator();
    }
    return this.instance;
  }

  getAuthService() {
    return this.authService;
  }

  getAppStore() {
    return this.appStore;
  }
}
    

实现要点说明

  1. 双向数据绑定:

    1. 使用 MobX 的 @observable 装饰器标记可观察属性
    2. 使用 @action 装饰器标记修改状态的方法
    3. 通过 observer 高阶组件使 React 组件响应状态变化
    4. 视图中的变化通过事件处理函数更新 ViewModel,ViewModel 的变化自动更新视图
  2. ViewModel 设计:

    1. 每个功能模块有自己的 ViewModel
    2. ViewModel 负责处理业务逻辑,与服务层交互
    3. ViewModel 不直接引用视图组件,保持松耦合
  3. 服务层:

    1. 负责与外部系统交互(API、存储等)
    2. 提供数据获取和操作的接口
    3. 可以被多个 ViewModel 共享使用
  4. 状态管理:

    1. 使用 AppStore 管理全局状态
    2. 通过 ServiceLocator 实现依赖注入
    3. 状态变化自动触发视图更新
  5. 视图层:

    1. 纯展示组件,不包含业务逻辑
    2. 通过 props 接收 ViewModel
    3. 通过事件处理函数将用户操作传递给 ViewModel

运行流程示例

  1. 用户在登录页面输入邮箱和密码

  2. 输入内容通过 onChangeText 事件更新到 LoginViewModel

  3. 用户点击登录按钮,触发 LoginViewModel 的 login 方法

  4. LoginViewModel 调用 AuthService 的 login 方法

  5. AuthService 模拟 API 请求并返回用户数据

  6. LoginViewModel 更新 AppStore 中的当前用户状态

  7. 状态变化触发所有观察该状态的组件重新渲染

  8. 导航到主页

子组件共用ViewModel处理方式

复杂业务下 ViewModel 的共享方案

在复杂业务场景中,LoginView 可能包含多个子组件,如验证码组件、密码强度指示器等。这些子组件需要共享同一个 ViewModel 实例以保持数据一致性。以下是几种实现方案:

方案一:通过 props 逐层传递

最直接的方法是通过 props 从父组件逐层传递 ViewModel:

// LoginView.tsx
@observer
export default class LoginView extends Component<LoginViewProps> {
  render() {
    return (
      <View style={styles.container}>
        <EmailInput viewModel={this.props.viewModel} />
        <PasswordInput viewModel={this.props.viewModel} />
        <CaptchaComponent viewModel={this.props.viewModel} />
        <LoginButton viewModel={this.props.viewModel} />
      </View>
    );
  }
}

// 子组件示例
@observer
class PasswordInput extends Component<{ viewModel: LoginViewModel }> {
  render() {
    return (
      <TextInput
        value={this.props.viewModel.password}
        onChangeText={this.props.viewModel.setPassword}
        secureTextEntry
      />
    );
  }
}
方案二:使用 React Context

对于多层次嵌套的子组件,可以使用 React Context 避免 props 地狱:

// AuthContext.ts
import React from 'react';
import { LoginViewModel } from './viewmodels/LoginViewModel';

export const AuthContext = React.createContext<LoginViewModel | null>(null);

// LoginView.tsx
@observer
export default class LoginView extends Component<LoginViewProps> {
  render() {
    return (
      <AuthContext.Provider value={this.props.viewModel}>
        <EmailInput />
        <PasswordInput />
        <PasswordStrengthIndicator />
        <CaptchaComponent />
        <LoginButton />
      </AuthContext.Provider>
    );
  }
}

// 深层子组件示例
@observer
class PasswordStrengthIndicator extends Component {
  static contextType = AuthContext;
  context!: LoginViewModel;

  render() {
    const strength = calculatePasswordStrength(this.context.password);
    return <Text>密码强度: {strength}</Text>;
  }
}
方案三:自定义 Hook (函数组件)

对于函数组件,可以创建自定义 Hook 获取 ViewModel:

// useLoginViewModel.ts
import { useContext } from 'react';
import { AuthContext } from './AuthContext';

export const useLoginViewModel = () => {
  const viewModel = useContext(AuthContext);
  if (!viewModel) {
    throw new Error('useLoginViewModel must be used within AuthContext');
  }
  return viewModel;
};

// 函数子组件
const PasswordStrengthIndicator = observer(() => {
  const viewModel = useLoginViewModel();
  const strength = calculatePasswordStrength(viewModel.password);
  
  return <Text>密码强度: {strength}</Text>;
});
方案四:使用 ServiceLocator 直接获取

通过单例服务定位器直接获取 ViewModel 实例:

// ServiceLocator.ts
export class ServiceLocator {
  // ...其他服务
  
  getLoginViewModel() {
    return new LoginViewModel(
      this.getAuthService(),
      this.getAppStore()
    );
  }
}

// 任意子组件中
@observer
class LoginButton extends Component {
  private viewModel = ServiceLocator.getInstance().getLoginViewModel();

  render() {
    return (
      <Button
        title="登录"
        onPress={this.viewModel.login}
        disabled={this.viewModel.loading}
      />
    );
  }
}

最佳实践建议

  1. 浅层嵌套:使用 props 传递(方案一),简单直观
  2. 深层嵌套:使用 Context(方案二)或自定义 Hook(方案三)
  3. 全局访问:使用 ServiceLocator(方案四),适合工具类组件