react navite实现全局弹窗、Toast 组件

可以。这个是 React Native 面试里非常高频的手写/实操题 :要求实现一个类似 Web 端 Modal.confirm() / message.success()全局弹窗 + Toast,业务页面不需要自己维护组件状态。

核心思路是:

Provider + Context + 全局状态/事件中心 + Portal(或顶层渲染)

我推荐面试时先写一个简单、可扩展、不依赖第三方库的版本。


一、最终使用效果

业务代码希望做到:

php 复制代码
import { toast, dialog } from './global-feedback';

toast.success('保存成功');

toast.error('网络异常');

dialog.alert({
  title: '提示',
  message: '确定要删除吗?',
});

dialog.confirm({
  title: '删除',
  message: '确定删除这条数据吗?',
  onConfirm: () => {
    console.log('删除');
  },
});

也就是说:

scss 复制代码
业务页面
   │
   ├── toast.success()
   ├── toast.error()
   └── dialog.confirm()
             │
             ▼
       全局 Feedback Manager
             │
             ▼
       React Context / Provider
             │
             ▼
      App 最顶层渲染
             │
       ┌─────┴─────┐
       │           │
     Toast       Modal

二、Toast 实现

Toast.tsx

typescript 复制代码
import React, {
  createContext,
  useCallback,
  useContext,
  useState,
} from 'react';

import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
} from 'react-native';

type ToastType = 'success' | 'error' | 'info';

interface ToastOptions {
  message: string;
  type?: ToastType;
  duration?: number;
}

interface ToastContextValue {
  showToast: (options: ToastOptions) => void;
}

const ToastContext = createContext<ToastContextValue | null>(null);

export const ToastProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [toast, setToast] = useState<ToastOptions | null>(null);

  const showToast = useCallback((options: ToastOptions) => {
    setToast(options);

    setTimeout(() => {
      setToast(null);
    }, options.duration ?? 2000);
  }, []);

  return (
    <ToastContext.Provider value={{showToast}}>
      {children}

      {toast && (
        <View pointerEvents="none" style={styles.container}>
          <View style={styles.toast}>
            <Text style={styles.text}>
              {toast.message}
            </Text>
          </View>
        </View>
      )}
    </ToastContext.Provider>
  );
};

export const useToast = () => {
  const context = useContext(ToastContext);

  if (!context) {
    throw new Error(
      'useToast must be used inside ToastProvider',
    );
  }

  return context;
};

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 80,
    alignItems: 'center',
  },

  toast: {
    paddingHorizontal: 20,
    paddingVertical: 12,
    borderRadius: 8,
    backgroundColor: '#333',
  },

  text: {
    color: '#fff',
    fontSize: 14,
  },
});

然后 App:

javascript 复制代码
export default function App() {
  return (
    <ToastProvider>
      <RootNavigator />
    </ToastProvider>
  );
}

业务页面:

ini 复制代码
const HomeScreen = () => {
  const {showToast} = useToast();

  const handleSave = () => {
    showToast({
      message: '保存成功',
      type: 'success',
    });
  };

  return (
    <Button
      title="保存"
      onPress={handleSave}
    />
  );
};

这个版本已经能用了。


三、但是面试官很可能继续问

面试官:

"这样每个页面都得 useToast(),我想直接 toast.success() 怎么办?"

这时候就要做成:

arduino 复制代码
toast.success('保存成功');

而不是:

scss 复制代码
const {showToast} = useToast();

showToast({
  message: '保存成功',
});

这就是全局服务模式


四、实现 Global Feedback Manager

创建:

csharp 复制代码
global-feedback/
├── index.ts
├── manager.ts
├── FeedbackProvider.tsx
└── types.ts

manager.ts

typescript 复制代码
type ToastType = 'success' | 'error' | 'info';

interface ToastOptions {
  message: string;
  type?: ToastType;
  duration?: number;
}

type ToastHandler = (
  options: ToastOptions,
) => void;

let toastHandler: ToastHandler | null = null;

export const registerToastHandler = (
  handler: ToastHandler,
) => {
  toastHandler = handler;
};

export const unregisterToastHandler = () => {
  toastHandler = null;
};

export const toast = {
  show(options: ToastOptions) {
    toastHandler?.(options);
  },

  success(message: string) {
    toastHandler?.({
      message,
      type: 'success',
    });
  },

  error(message: string) {
    toastHandler?.({
      message,
      type: 'error',
    });
  },

  info(message: string) {
    toastHandler?.({
      message,
      type: 'info',
    });
  },
};

这样业务层:

javascript 复制代码
import {toast} from './global-feedback';

toast.success('保存成功');

就可以了。


五、Provider 注册 Handler

javascript 复制代码
import React, {useEffect, useState} from 'react';

import {
  View,
  Text,
  StyleSheet,
} from 'react-native';

import {
  registerToastHandler,
  unregisterToastHandler,
} from './manager';

export const FeedbackProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [toast, setToast] = useState<ToastOptions | null>(
    null,
  );

  useEffect(() => {
    registerToastHandler(options => {
      setToast(options);

      setTimeout(() => {
        setToast(null);
      }, options.duration ?? 2000);
    });

    return () => {
      unregisterToastHandler();
    };
  }, []);

  return (
    <View style={{flex: 1}}>
      {children}

      {toast && (
        <View
          pointerEvents="none"
          style={styles.container}>
          <View style={styles.toast}>
            <Text style={styles.text}>
              {toast.message}
            </Text>
          </View>
        </View>
      )}
    </View>
  );
};

然后:

javascript 复制代码
export default function App() {
  return (
    <FeedbackProvider>
      <RootNavigator />
    </FeedbackProvider>
  );
}

业务代码:

javascript 复制代码
import {toast} from './global-feedback';

function LoginScreen() {
  const login = async () => {
    try {
      await requestLogin();

      toast.success('登录成功');
    } catch (error) {
      toast.error('登录失败');
    }
  };

  return (
    <Button
      title="登录"
      onPress={login}
    />
  );
}

六、全局 Dialog

Dialog 和 Toast 的实现思路完全一样。

定义:

typescript 复制代码
interface DialogOptions {
  title?: string;
  message: string;
  cancelText?: string;
  confirmText?: string;

  onCancel?: () => void;
  onConfirm?: () => void;
}

Manager:

ini 复制代码
type DialogHandler = (
  options: DialogOptions,
) => void;

let dialogHandler: DialogHandler | null = null;

export const registerDialogHandler = (
  handler: DialogHandler,
) => {
  dialogHandler = handler;
};

export const unregisterDialogHandler = () => {
  dialogHandler = null;
};

export const dialog = {
  alert(options: DialogOptions) {
    dialogHandler?.(options);
  },

  confirm(options: DialogOptions) {
    dialogHandler?.(options);
  },
};

Provider:

csharp 复制代码
const [dialog, setDialog] =
  useState<DialogOptions | null>(null);

注册:

ini 复制代码
useEffect(() => {
  registerDialogHandler(options => {
    setDialog(options);
  });

  return () => {
    unregisterDialogHandler();
  };
}, []);

渲染:

ini 复制代码
{dialog && (
  <View style={styles.overlay}>
    <View style={styles.modal}>
      <Text style={styles.title}>
        {dialog.title}
      </Text>

      <Text style={styles.message}>
        {dialog.message}
      </Text>

      <View style={styles.actions}>
        <Button
          title={dialog.cancelText ?? '取消'}
          onPress={() => {
            setDialog(null);
            dialog.onCancel?.();
          }}
        />

        <Button
          title={dialog.confirmText ?? '确定'}
          onPress={() => {
            setDialog(null);
            dialog.onConfirm?.();
          }}
        />
      </View>
    </View>
  </View>
)}

于是业务代码:

php 复制代码
dialog.confirm({
  title: '删除',
  message: '确定删除这条数据吗?',

  onConfirm: async () => {
    await deleteUser();

    toast.success('删除成功');
  },
});

这个题目重点是考你有没有理解:

① 为什么需要 Provider?

因为 RN 的 UI 必须处于 React 渲染树中。

markdown 复制代码
toast.success()
      ↓
改变全局状态
      ↓
Provider
      ↓
重新 render
      ↓
Toast

② 为什么不能随便在 API 文件里面直接 render?

因为:

arduino 复制代码
toast.success('成功');

这个调用发生在 React 组件树之外。

React Native 本身没有类似:

javascript 复制代码
document.body.appendChild(...)

这种 DOM 操作。

所以需要一个常驻在 Root 的 UI 容器


③ 为什么用 Handler?

因为:

scss 复制代码
toast.success()

本身不负责 UI。

它只负责:

复制代码
发送命令
   ↓
Manager
   ↓
Provider
   ↓
UI

这实际上是一种:

命令与 UI 解耦

的设计。


八、进一步优化:Promise Dialog

如果面试官继续问:

"能不能让我这样写?"

csharp 复制代码
const confirmed = await dialog.confirm({
  title: '删除',
  message: '确定删除?',
});

if (confirmed) {
  await deleteUser();
}

这个就更漂亮。

Manager:

typescript 复制代码
interface DialogOptions {
  title?: string;
  message: string;
}

interface DialogTask {
  options: DialogOptions;
  resolve: (result: boolean) => void;
}

let dialogHandler:
  ((task: DialogTask) => void) | null = null;

export const registerDialogHandler = (
  handler: (task: DialogTask) => void,
) => {
  dialogHandler = handler;
};

export const dialog = {
  confirm(
    options: DialogOptions,
  ): Promise<boolean> {
    return new Promise(resolve => {
      dialogHandler?.({
        options,
        resolve,
      });
    });
  },
};

Provider:

ini 复制代码
const [task, setTask] =
  useState<DialogTask | null>(null);

useEffect(() => {
  registerDialogHandler(task => {
    setTask(task);
  });
}, []);

点击确定:

ini 复制代码
const handleConfirm = () => {
  task?.resolve(true);
  setTask(null);
};

点击取消:

ini 复制代码
const handleCancel = () => {
  task?.resolve(false);
  setTask(null);
};

业务代码直接:

csharp 复制代码
const confirmed = await dialog.confirm({
  title: '删除',
  message: '确定删除这条数据吗?',
});

if (!confirmed) {
  return;
}

await deleteUser();

这个版本就已经比较接近生产级设计了。


九、如果是面试,我建议你记住这一套

全局 Toast / Modal 的核心答案:

scss 复制代码
                    ┌──────────────┐
                    │ 业务代码      │
                    │              │
                    │ toast.xxx()  │
                    │ dialog.xxx() │
                    └──────┬───────┘
                           │
                           ▼
                  ┌─────────────────┐
                  │ Global Manager  │
                  │                 │
                  │ handler / queue │
                  └────────┬────────┘
                           │
                           ▼
                  ┌─────────────────┐
                  │ FeedbackProvider│
                  │                 │
                  │ React State     │
                  └────────┬────────┘
                           │
                           ▼
                  ┌─────────────────┐
                  │ Root UI         │
                  │                 │
                  │ Toast / Modal   │
                  └─────────────────┘

一句话回答面试官:

我会把 Toast 和 Dialog 常驻在 App Root,通过 Provider 挂载 UI,通过一个全局 Manager 暴露命令式 API。业务层调用 toast.success()dialog.confirm() 时,Manager 通知 Provider 更新状态,从而触发 Root 层 UI 渲染。这样业务组件不需要关心弹窗的状态和挂载位置,实现 UI 与业务逻辑解耦。

相关推荐
WebInfra14 分钟前
Rslib 1.0 正式发布:面向多场景的 JavaScript 库开发工具
前端·javascript·github
Aolith1 小时前
我在React全栈项目中搭建了一个完整题库
数据库·react.js·全栈
Tongsr1 小时前
别只混淆代码:用 Kaleido 加固整个 Android Release AAB
前端·算法·github
郭邯1 小时前
从零到一:我用 AI 写了一个贷款计算器,顺便把等额本息公式彻底搞懂了
前端
汉堡大王95271 小时前
面试官:讲讲归并排序 —— 为什么它能在面试里反复出现?
前端·javascript·面试
kyriewen1 小时前
别再只给人写页面了:AI 已经开始自己点你的按钮、填你的表单
前端·javascript·人工智能
天天喝旺仔1 小时前
Vue 3 组合式 API:从 Options 迁移到 script setup
前端·javascript·vue.js
知了清语1 小时前
以测试为盾,重构为矛——老旧前端系统迭代中的“测试先行”实践总结
前端
攀小黑2 小时前
vue3+Web Speech API 封装了一个弹窗语音输入
前端·macos·xcode