React 与 WebSocket 协同:在实时通信应用中处理状态推送与断线重连的生命周期管理

各位同学,大家好!

今天咱们不聊那些虚头巴脑的理论,咱们来聊点“生死攸关”的事儿。在当今的 Web 开发界,如果说 React 是那个负责画皮的画师,负责把 UI 漂亮地展示出来;那么 WebSocket 就是那个负责输送血液的血管。没有 React,界面是一具死尸;没有 WebSocket,应用就是个瞎子。

我们今天要讲的主题是:React 与 WebSocket 协同:在实时通信应用中处理状态推送与断线重连的生命周期管理

这听起来是不是有点像修仙小说里的“渡劫”?确实,在实时通信场景下,网络波动就像是修仙路上的心魔,稍不注意,你的应用就会崩溃,或者更惨——数据不同步,用户以为他点了发送,其实消息在宇宙中迷失了。

准备好了吗?系好安全带,咱们这就开始这场“实时通信”的硬核探险。


第一部分:WebSocket 是个什么鬼?原生 JS 的“野性”呼唤

在 React 介入之前,我们必须先认识一下 WebSocket。它不是 HTTP。千万别把它和 HTTP 混为一谈,虽然它们长得有点像。

HTTP 是什么?HTTP 是个“发好人卡”的高手。它发一次消息,服务器回一次,发一次,回一次。如果你想聊两句,它得先断开,再重新连。这就好比你和女朋友(或男朋友)打电话,每次想说话都得先挂断,再拨过去,这效率,谈恋爱都嫌慢。

而 WebSocket 是个“直球选手”。它建立连接后,就是全双工的。服务器想发消息,直接推给你;你想发消息,直接发出去。这就好比你们俩装了隐形耳机,随时随地都能说话,不用挂电话。

原生 WebSocket 的写法

让我们先看看原生 JavaScript 是怎么写 WebSocket 的。这代码有点“野”,没有 React 的花哨,但它是基础。

const socket = new WebSocket('wss://api.example.com/ws');

// 1. 连接打开
socket.onopen = function(event) {
    console.log('连接成功!网络通畅!');
    // 发送一个心跳包,告诉服务器“我还活着”
    socket.send(JSON.stringify({ type: 'ping' }));
};

// 2. 收到消息
socket.onmessage = function(event) {
    const data = JSON.parse(event.data);
    console.log('收到消息:', data);
    // 这里就是处理逻辑了,比如更新 UI
};

// 3. 连接关闭
socket.onclose = function(event) {
    console.log('连接关闭,状态码:', event.code);
};

// 4. 发生错误
socket.onerror = function(error) {
    console.error('哎呀,出错了!', error);
};

// 5. 发送数据
function sendMessage(message) {
    if (socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify(message));
    } else {
        console.warn('连接没开,别乱发消息!');
    }
}

看,原生代码很简单,对吧?但是,一旦你把这段代码塞进 React 组件里,麻烦事儿就来了。React 的核心哲学是“声明式”,而 WebSocket 是“命令式”的。这两者结合,就像让一个严谨的会计去飙摩托车,稍不留神就会翻车。


第二部分:React Hooks 的陷阱——闭包与状态同步

很多新手(甚至有些老手)在 React 中写 WebSocket,通常会这么干:

function ChatComponent() {
    const [messages, setMessages] = useState([]);

    useEffect(() => {
        const socket = new WebSocket('wss://api.example.com/ws');

        socket.onmessage = (event) => {
            const msg = JSON.parse(event.data);
            // 问题来了!这里的 setMessages 会触发重渲染
            // 如果重渲染时 socket 还没完全初始化好,或者闭包陷阱……
            setMessages(prev => [...prev, msg]);
        };

        return () => {
            socket.close(); // 清理
        };
    }, []); // 依赖数组是空的

    return <div>...</div>;
}

这段代码能跑吗?能跑。但它非常脆弱。为什么?因为 闭包

useEffect 的第一次执行中,我们创建了一个 socket 变量。当 socket.onmessage 被触发时,它捕获的是第一次 useEffect 执行时的 socket 引用。

如果 React 在组件重渲染时,useEffect 没有再次执行(因为依赖为空),那么你更新 messages 状态导致组件重渲染,socket 依然停留在第一次创建的状态

更糟糕的是,如果你把 useEffect 的依赖改成 [messages],那你就进入了一个死循环:socket 变了 -> useEffect 重跑 -> socket 被关闭并重建 -> onmessage 监听器被清空 -> 消息来了没人接 -> 组件挂掉。

所以,在 React 中处理 WebSocket,核心原则是:WebSocket 的生命周期必须独立于 React 的渲染生命周期,或者至少要非常小心地管理它们的关系。


第三部分:生命周期管理——大管家是如何工作的?

在实时通信应用中,我们需要一个强有力的“大管家”来管理 WebSocket 的生命周期。这个大管家要负责:开启、发送心跳、接收消息、处理错误、以及——最重要的——断线重连。

我们引入几个核心概念:

  1. 连接状态CONNECTING, OPEN, CLOSING, CLOSED
  2. 消息队列:当连接断开时,用户发送的消息不能丢,得存起来,等连上了再发。
  3. 重连策略:不要像疯狗一样狂咬,要有策略地重连。

1. 状态管理:不要用 useState 存 WebSocket 实例

React 的 useState 会导致状态更新触发重渲染。如果我们把 socket 实例放在 useState 里,每次重连(状态更新)都会导致组件重新挂载,那之前的消息队列不就清空了吗?那太糟糕了。

正确做法:使用 useRef

useRef 不会在值改变时触发重渲染。它就像一个躲在角落里的观察员,你改了它的值,React 不会知道,界面也不会刷新,但内存里的值变了。

const socketRef = useRef(null);
const messagesRef = useRef([]); // 消息队列也用 Ref

2. useEffect 的编排

我们需要在组件挂载时开启连接,在组件卸载时关闭连接。

useEffect(() => {
    console.log('组件挂载,尝试连接 WebSocket...');
    connectWebSocket();

    return () => {
        console.log('组件卸载,准备断开连接...');
        disconnectWebSocket();
    };
}, []);

第四部分:断线重连的艺术——指数退避与心跳包

网络是脆弱的。有时候是服务器炸了,有时候是运营商的网线被猫偷走了。这时候,我们需要一个健壮的重连机制。

1. 指数退避

如果你断线了,立刻重连,服务器会以为你是 DDoS 攻击,直接把你 IP 封了。我们需要“冷静”一下。

策略是:第一次等 1 秒,第二次等 2 秒,第四次等 4 秒,第八次等 8 秒……直到成功为止。这就是指数退避。

2. 心跳包

为了确认连接是否真的活着,我们需要心跳包。就像两个人谈恋爱,不能一直不说话,得时不时发个微信确认一下“我在”。

完整的重连逻辑代码

让我们写一段稍微复杂点的代码。这里我们用一个状态来管理重连的定时器。

const useWebSocket = (url) => {
    const socketRef = useRef(null);
    const reconnectTimeoutRef = useRef(null);
    const reconnectCountRef = useRef(0); // 重连次数
    const messagesQueueRef = useRef([]); // 消息缓存队列

    // 发送消息的核心逻辑
    const sendMessage = (data) => {
        if (socketRef.current?.readyState === WebSocket.OPEN) {
            socketRef.current.send(JSON.stringify(data));
        } else {
            console.warn('连接未建立,消息进入队列:', data);
            messagesQueueRef.current.push(data);
        }
    };

    // 连接逻辑
    const connectWebSocket = () => {
        if (socketRef.current?.readyState === WebSocket.OPEN) return;

        console.log(`正在连接第 ${reconnectCountRef.current + 1} 次...`);
        const socket = new WebSocket(url);

        socketRef.current = socket;

        socket.onopen = () => {
            console.log('连接成功!');
            reconnectCountRef.current = 0; // 重置重连计数器

            // 如果有缓存消息,发送出去
            if (messagesQueueRef.current.length > 0) {
                console.log(`发送缓存队列中的 ${messagesQueueRef.current.length} 条消息`);
                messagesQueueRef.current.forEach(msg => {
                    socket.send(JSON.stringify(msg));
                });
                messagesQueueRef.current = []; // 清空队列
            }

            // 启动心跳
            startHeartbeat();
        };

        socket.onmessage = (event) => {
            // 处理业务消息
            // 这里通常会有一个 reducer 或者 dispatch 逻辑
            handleMessage(event.data);
        };

        socket.onclose = (event) => {
            console.log('连接断开', event.code, event.reason);
            stopHeartbeat();
            scheduleReconnect();
        };

        socket.onerror = (error) => {
            console.error('WebSocket Error:', error);
            // 通常不需要在这里处理,onclose 会处理
        };
    };

    // 断开逻辑
    const disconnectWebSocket = () => {
        stopHeartbeat();
        if (reconnectTimeoutRef.current) {
            clearTimeout(reconnectTimeoutRef.current);
        }
        if (socketRef.current) {
            socketRef.current.close();
            socketRef.current = null;
        }
    };

    // 重连调度器
    const scheduleReconnect = () => {
        if (reconnectTimeoutRef.current) return;

        // 指数退避算法:1秒, 2秒, 4秒, 8秒, 16秒, 30秒...
        let delay = Math.min(1000 * Math.pow(2, reconnectCountRef.current), 30000);

        console.log(`${delay/1000} 秒后尝试重连...`);

        reconnectTimeoutRef.current = setTimeout(() => {
            reconnectCountRef.current++;
            connectWebSocket();
        }, delay);
    };

    // 心跳机制
    let heartbeatTimer = null;
    const startHeartbeat = () => {
        stopHeartbeat(); // 防止多重定时器
        heartbeatTimer = setInterval(() => {
            if (socketRef.current?.readyState === WebSocket.OPEN) {
                socketRef.current.send(JSON.stringify({ type: 'ping' }));
            }
        }, 30000); // 每30秒发一次心跳
    };

    const stopHeartbeat = () => {
        if (heartbeatTimer) {
            clearInterval(heartbeatTimer);
            heartbeatTimer = null;
        }
    };

    return {
        sendMessage,
        // 这里我们返回一个状态供 UI 使用,比如连接状态
        isConnected: socketRef.current?.readyState === WebSocket.OPEN
    };
};

这段代码是不是很香?它把所有的逻辑都封装了,而且没有让 React 的渲染机制干扰 WebSocket 的稳定性。


第五部分:状态同步与 UI 渲染——当数据来了,界面怎么变?

现在,我们有了 WebSocket 的连接,也有了消息队列。接下来最关键的一步是:如何把这些消息变成 React 的 State,进而驱动 UI 的变化?

这里有两个常见的误区:

  1. 直接在 onmessagesetState

    • 如果你频繁收到消息,这会导致组件疯狂重渲染。虽然 React 做了 Diff 算法优化,但如果是高频实时推送(比如股票行情每秒更新),这会卡死浏览器。
    • 解决方法:对于高频数据,可以使用 useReducer 或者批处理。但对于聊天应用,直接 setState 通常是可以接受的,因为 React 的渲染速度已经很快了。
  2. 消息顺序问题

    • 如果网络不稳定,先收到消息 B,后收到消息 A,界面显示顺序就乱了。
    • 解决方法:在消息对象里加一个 timestamp 或者 id,在渲染列表时按时间排序。

代码示例:集成到 React 组件

假设我们做一个聊天室:

import React, { useState, useEffect, useCallback } from 'react';

const ChatRoom = () => {
    const [messages, setMessages] = useState([]);
    const [input, setInput] = useState('');
    const { sendMessage, isConnected } = useWebSocket('wss://api.example.com/ws');

    // 发送消息
    const handleSend = useCallback(() => {
        if (!input.trim()) return;

        const newMessage = {
            id: Date.now(),
            text: input,
            user: 'Me',
            timestamp: Date.now()
        };

        // 1. 立即更新 UI (乐观更新,提升用户体验)
        setMessages(prev => [...prev, newMessage]);

        // 2. 发送给 WebSocket
        sendMessage({ type: 'chat', payload: newMessage });

        setInput('');
    }, [input, sendMessage]);

    // 处理 WebSocket 推送的消息
    useEffect(() => {
        // 我们在 useWebSocket 里怎么处理?这里假设 useWebSocket 内部有一个回调
        // 实际上,为了解耦,我们通常在 useWebSocket 里 dispatch Redux action,
        // 或者这里直接用一个监听器。

        // 简化演示:我们假设 useWebSocket 会在收到消息时调用一个全局的 listener
        const handleRemoteMessage = (data) => {
            const msg = {
                id: data.id,
                text: data.text,
                user: data.user,
                timestamp: data.timestamp
            };
            setMessages(prev => [...prev, msg]);
        };

        // 注册监听器 (实际项目中可能用 Redux middleware)
        window.addEventListener('ws:message', handleRemoteMessage);

        return () => {
            window.removeEventListener('ws:message', handleRemoteMessage);
        };
    }, []);

    return (
        <div style={{ padding: 20, maxWidth: 600, margin: '0 auto' }}>
            <div style={{ 
                height: 400, 
                border: '1px solid #ccc', 
                overflowY: 'auto', 
                marginBottom: 20,
                backgroundColor: '#f9f9f9'
            }}>
                {messages.map(msg => (
                    <div key={msg.id} style={{ margin: '10px 0', textAlign: 'left' }}>
                        <span style={{ fontWeight: 'bold', color: 'blue' }}>{msg.user}: </span>
                        <span>{msg.text}</span>
                        <div style={{ fontSize: 12, color: '#888' }}>
                            {new Date(msg.timestamp).toLocaleTimeString()}
                        </div>
                    </div>
                ))}
            </div>

            <div style={{ display: 'flex', gap: 10 }}>
                <input 
                    value={input} 
                    onChange={(e) => setInput(e.target.value)} 
                    style={{ flex: 1, padding: 10 }}
                    placeholder="输入消息..." 
                    disabled={!isConnected}
                />
                <button onClick={handleSend} disabled={!isConnected}>
                    发送
                </button>
            </div>

            {!isConnected && <div style={{ color: 'red', marginTop: 10 }}>⚠️ 连接断开,正在重连...</div>}
        </div>
    );
};

export default ChatRoom;

第六部分:高级模式——心跳与状态同步的“微操”

在上一节的代码中,我们提到了心跳。但是,心跳包怎么发?发了之后服务器怎么回?

通常有两种模式:

  1. Server-Sent Events (SSE) 风格:服务器收到 Ping,回一个 Pong。
  2. 独立模式:服务器收到 Ping,什么都不回,或者回一个简单的 ACK。

如果是模式1,我们需要在 socket.onmessage 里判断一下,如果收到的是 { type: 'pong' },说明心跳成功,我们可以重置一个“最后收到心跳的时间戳”。

为什么要这么做?为了判断连接是否真的死了。

有时候 onclose 事件不会触发(比如用户直接关了浏览器,或者网络突然拔了)。这时候,如果服务器那边认为连接断了,它不会再发消息过来,但客户端的 readyState 还是 OPEN。这时候客户端还在傻傻地等消息,或者还在发心跳,这都是浪费资源。

心跳保活逻辑增强版

let lastHeartbeatTime = 0;
const HEARTBEAT_TIMEOUT = 45000; // 45秒没收到心跳,认为断线

socket.onmessage = (event) => {
    const data = JSON.parse(event.data);

    if (data.type === 'pong') {
        lastHeartbeatTime = Date.now();
        return;
    }

    // 处理业务消息
    handleMessage(data);
};

// 定时检查心跳
const checkHeartbeat = setInterval(() => {
    if (Date.now() - lastHeartbeatTime > HEARTBEAT_TIMEOUT) {
        console.warn('心跳超时,强制断开重连');
        clearInterval(checkHeartbeat);
        socketRef.current.close(); // 触发 onclose,进而触发 scheduleReconnect
    }
}, 5000);

第七部分:消息队列的“排雷”机制

在上一节的代码里,我们只是简单地把消息推到了队列里。但是,如果用户在连接断开的时候疯狂点击“发送”,队列会变得非常长。

这时候,我们需要考虑几个问题:

  1. 队列大小限制:如果用户发了 1000 条消息,你都要存吗?显然不用。通常保留最近 50 条或 100 条即可。
  2. 重复消息:如果队列里有一条消息已经发送过了(比如网络延迟导致发了两次),重连后发送会重复吗?通常不会,因为 socket.send 是同步的,如果没发出去,它还在队列里。但如果用户重连了,发送了队列里的消息,然后服务器回了一个确认,这时候用户又发了一条,队列又多了。这没问题,只要处理逻辑是幂等的即可。

优化后的队列管理

const MAX_QUEUE_SIZE = 50;

const sendMessage = (data) => {
    if (socketRef.current?.readyState === WebSocket.OPEN) {
        socketRef.current.send(JSON.stringify(data));
    } else {
        console.warn('连接未建立,消息进入队列:', data);
        messagesQueueRef.current.push(data);

        // 裁剪队列,防止内存溢出
        if (messagesQueueRef.current.length > MAX_QUEUE_SIZE) {
            messagesQueueRef.current.shift(); // 移除最旧的
            console.warn('队列已满,移除最旧消息');
        }
    }
};

第八部分:实战演练——构建一个“股市行情”模拟器

为了让大家更直观地理解,我们来模拟一个简单的股市行情推送。假设我们有一个 WebSocket 服务端,每隔 1 秒推送一次股票价格。

客户端代码

import React, { useState, useEffect, useRef } from 'react';

const StockTicker = () => {
    const [stocks, setStocks] = useState([
        { symbol: 'AAPL', price: 150.00, change: 0 },
        { symbol: 'GOOG', price: 2800.00, change: 0 },
        { symbol: 'TSLA', price: 700.00, change: 0 }
    ]);

    const socketRef = useRef(null);
    const reconnectTimeoutRef = useRef(null);
    const reconnectCountRef = useRef(0);
    const messagesQueueRef = useRef([]);

    // 模拟 WebSocket 连接
    const connectWebSocket = () => {
        // 注意:这里为了演示,我们用模拟连接,实际项目中替换为 new WebSocket(url)
        console.log(`正在连接股市行情服务器...`);

        // 模拟连接延迟
        setTimeout(() => {
            console.log('连接成功!');
            socketRef.current = {
                readyState: 1, // WebSocket.OPEN
                send: (data) => {
                    console.log('发送数据:', data);
                },
                close: () => {
                    console.log('连接关闭');
                    handleDisconnect();
                }
            };
            startHeartbeat();
            sendCachedMessages();
        }, 1000);
    };

    const handleDisconnect = () => {
        stopHeartbeat();
        scheduleReconnect();
    };

    const scheduleReconnect = () => {
        if (reconnectTimeoutRef.current) return;
        const delay = Math.min(1000 * Math.pow(2, reconnectCountRef.current), 5000);
        console.log(`${delay/1000}秒后重连...`);

        reconnectTimeoutRef.current = setTimeout(() => {
            reconnectCountRef.current++;
            connectWebSocket();
        }, delay);
    };

    const startHeartbeat = () => {
        stopHeartbeat();
        setInterval(() => {
            if (socketRef.current?.readyState === 1) {
                // 模拟心跳
                console.log('发送心跳包');
            }
        }, 5000);
    };

    const stopHeartbeat = () => {
        // 清理逻辑
    };

    const sendCachedMessages = () => {
        if (messagesQueueRef.current.length > 0) {
            messagesQueueRef.current.forEach(msg => {
                // 模拟发送
                console.log('发送缓存:', msg);
            });
            messagesQueueRef.current = [];
        }
    };

    // 模拟接收消息
    const simulateIncomingMessage = (data) => {
        setStocks(prevStocks => {
            return prevStocks.map(stock => {
                if (stock.symbol === data.symbol) {
                    return { ...stock, price: data.price, change: data.change };
                }
                return stock;
            });
        });
    };

    // 模拟 WebSocket 消息监听
    useEffect(() => {
        // 这里实际上应该是在 socket.onmessage 里调用
        // 为了演示效果,我们模拟一个消息流
        const interval = setInterval(() => {
            const randomStock = stocks[Math.floor(Math.random() * stocks.length)];
            const newPrice = randomStock.price + (Math.random() - 0.5) * 10;

            const msg = {
                symbol: randomStock.symbol,
                price: newPrice,
                change: (newPrice - randomStock.price).toFixed(2)
            };

            // 如果 socket 没连上,进队列
            if (!socketRef.current || socketRef.current.readyState !== 1) {
                messagesQueueRef.current.push(msg);
                console.log('连接未建立,消息进队列');
            } else {
                simulateIncomingMessage(msg);
            }
        }, 2000);

        connectWebSocket();

        return () => {
            clearInterval(interval);
            if (socketRef.current) socketRef.current.close();
        };
    }, []);

    return (
        <div style={{ padding: 20 }}>
            <h1>实时股市行情</h1>
            <div style={{ display: 'grid', gap: 10 }}>
                {stocks.map(stock => (
                    <div key={stock.symbol} style={{ 
                        padding: 15, 
                        border: '1px solid #ddd', 
                        borderRadius: 8,
                        display: 'flex',
                        justifyContent: 'space-between',
                        backgroundColor: stock.change >= 0 ? '#e6fffa' : '#fff5f5'
                    }}>
                        <div>
                            <strong>{stock.symbol}</strong>
                            <div style={{ fontSize: 12, color: '#666' }}>
                                {stock.change >= 0 ? '+' : ''}{stock.change}%
                            </div>
                        </div>
                        <div style={{ fontSize: 24, fontWeight: 'bold' }}>
                            ${stock.price.toFixed(2)}
                        </div>
                    </div>
                ))}
            </div>
            <div style={{ marginTop: 20, color: '#666' }}>
                状态: {socketRef.current?.readyState === 1 ? '● 在线' : '○ 离线'}
            </div>
        </div>
    );
};

export default StockTicker;

这个例子展示了:

  1. 状态同步:通过 setStocks 更新 UI。
  2. 队列机制:当“模拟连接”失败时,消息进队列。
  3. 重连逻辑:断开后自动尝试重连。

第九部分:性能优化与最佳实践——别让你的浏览器“猝死”

写好了基础功能,我们还要考虑性能。WebSocket 频繁的 setState 会导致 React 大量的 Diff 计算。如果推送频率很高(比如每秒 60 次),UI 会很卡。

1. 批处理更新

React 18 引入了自动批处理,但在某些情况下(如 setTimeout 回调中),我们需要手动批处理。

// 旧方法
socket.onmessage = (event) => {
    setCount(c => c + 1);
    setCount(c => c + 1); // 可能导致两次渲染
};

// 优化方法
import { unstable_batchedUpdates } from 'react-dom';

socket.onmessage = (event) => {
    unstable_batchedUpdates(() => {
        setCount(c => c + 1);
        setCount(c => c + 1); // 只渲染一次
    });
};

2. 虚拟滚动

如果你的消息列表有几千条,React 的虚拟滚动(如 react-windowreact-virtualized)是必须的。不要一次性渲染几千个 DOM 节点。

3. 序列化与反序列化

onmessage 里,频繁调用 JSON.parse 是有开销的。如果你的数据结构很复杂,可以考虑使用更快的序列化库(如 msgpack),或者手动解析(虽然这很麻烦,但为了性能值得)。

4. 内存泄漏检查

这是老生常谈但总是被忽视的点。

useEffect(() => {
    const timer = setInterval(() => {
        // do something
    }, 1000);

    // 监听 WebSocket 消息
    const handleMessage = (e) => { /* ... */ };
    window.addEventListener('ws:message', handleMessage);

    return () => {
        clearInterval(timer);
        window.removeEventListener('ws:message', handleMessage);
        // 确保关闭 socket
        if (socketRef.current) socketRef.current.close();
    };
}, []);

第十部分:总结——React 与 WebSocket 的“婚姻”哲学

好了,咱们今天聊了很多。

React 和 WebSocket 的结合,就像是一场精心策划的婚姻。React 是那个温柔体贴的伴侣,负责展示美好的生活瞬间(UI);WebSocket 是那个负责赚钱养家、风雨无阻的伴侣,负责源源不断地输送资源(数据)。

但婚姻中总会吵架(网络波动),总会有一方离家出走(断线重连)。这时候,我们需要:

  1. 信任useRef 存储实例,不因渲染而改变)。
  2. 沟通(消息队列,不丢失信息)。
  3. 包容(指数退避,给对方一点冷静的时间)。
  4. 健康检查(心跳包,确认对方还在不在)。

最后,记住一点:永远不要在 React 的渲染循环里去处理 WebSocket 的逻辑。保持它们的职责分离。把 WebSocket 的逻辑封装进自定义 Hook 里,让你的组件变得干净、清爽、易于维护。

当你下次再看到那个恼人的“连接断开”弹窗时,别慌。你知道该怎么处理它。你是一个专业的工程师,你掌握着连接世界的钥匙。

谢谢大家!希望今天的讲座能让你在实时通信的道路上走得更稳、更远!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注