正确使用 WebSockets 和 React.js(无需库)
TL;DR
在这篇文章中,我介绍了有用的自定义 React.js 钩子,它将 websocket 客户端提升到一个新的水平。
介绍
在我目前正在做的项目中,我需要连接一个 React.js 前端和一个 WebSocket 服务器。我花了数周时间寻找最佳的 WebSocket 使用方法,并想在这里分享我学到的东西。
我介绍的代码解决方案:
- 为什么
useReducer()
在使用 WebSockets 时使用钩子? - 我的自定义
useSession()
钩子 - 我对
useCallback()
钩子的用法。 - 自定义钩子提供自动重连功能
useDisconnectionHandler()
。额外功能:必要时,页面刷新时自动重连。
useReducer 钩子
当我第一次尝试实现我的状态管理系统并在收到消息时正确更新它时,这是一场灾难。
我的GameContextProvider
组件负责处理此类事件,如下所示:
// GameContextProvider.js
const GameContextProvider = ({ children }) => {
const [isStarted, setStarted] = useState(false);
const [isConnected, setConnected] = useState(false);
const [isJudge, setIsJudge] = useState(false);
const [judge, setJudge] = useState('');
const [question, setQuestion] = useState('');
const [deck, setDeck] = useState([]);
const [showEndScreen, setEndScreenShown] = useState(false);
const [scoreboard, setScoreboard] = useState([]);
........
// Much more state!
.....
}
然后,当我想处理 websocket 消息时,处理程序如下所示:
// GameContextProvider.js
const onMessage = (ev) => {
const data = JSON.parse(ev.data);
if (data.question) { // a round is started
setJudge(data.judge);
setIsJudge(data.isJudge);
setQuestion(data.question);
}
...... // super long, unreadable message handler
}
解决方案
我在服务器中为每条消息附加了一个“上下文”字符串,并使用此字符串在 useReducer hook 中分派相应的操作。
例如,我设置了“JOINED”上下文、“GAME_STARTED”、“ROUND_STARTED”、“GAME_ENDED”等等。
然后,我的GameContextProvider
样子是这样的:
// GameContextProvider.js
const [state, dispatch] = useReducer(reducer, initialState);
const onMessage = (ev) => {
const data = JSON.parse(ev.data);
if (data.context)
dispatch({ type: data.context, payload: data })
}
簡單又清潔!
此外,这遵循了单一职责规则。现在组件的职责是连接状态和 websocket 技术,以供应用程序的其余部分使用。
useSession 钩子
在我将 WebSocket 实用程序拆分为自定义钩子之前,我的上下文提供程序有一个混乱、难以阅读的代码来处理 websocket 事件。
// GameContextProvider.js
const [ws, setWebsocket] = useState(null)
const join = (gameCode, name) => {
const URL = `${process.env.REACT_APP_WS_URL}?code=${gameCode}&name=${name}`
setWebsocket(() => {
const ws = new WebSocket(URL);
ws.onmessage = onMessage;
ws.onclose = () => {
dispatch({ type: 'DISCONNECTED' })
};
return ws;
})
}
表面上看,这种方法似乎没问题。
但如果我想在断开连接时检查游戏状态怎么办?如果我按原样注册该函数,那么当状态值更新时,该函数将不会更新!
解决方案
我创建了一个自定义钩子来处理 websocket 实用程序。(注:当时我已经将项目重构为 TypeScript)
// websocketUtils.ts
export const useSession = (
onOpen: OpenHandler,
onMessage: MessageHandler,
onClose: CloseHandler
): SessionHook => {
const [session, setSession] = useState(null as unkown as Websocket);
const updateOpenHandler = () => {
if (!session) return;
session.addEventListener('open', onOpen);
return () => {
session.removeEventListener('open', onOpen);
};
};
const updateMessageHandler = () => {
if (!session) return;
session.addEventListener('message', onMessage);
return () => {
session.removeEventListener('message', onMessage);
};
};
const updateCloseHandler = () => {
if (!session) return;
session.addEventListener('close', onClose);
return () => {
session.removeEventListener('close', onClose);
};
};
useEffect(updateOpenHandler, [session, onOpen]);
useEffect(updateMessageHandler, [session, onMessage]);
useEffect(updateCloseHandler, [session, onClose]);
.... // connect, sendMessage utils
}
这真是太棒了!但不知何故,网站的性能急剧下降。
useCallback 钩子
说实话,直到上周我终于找到了解决方案,我才明白这个钩子是如何工作的。
事实证明,我的打开、消息和关闭处理程序在应用程序每次重新渲染时都会更新,也就是说每秒更新几次。
当我调试应用程序时,我尝试测试useCallback
钩子对我的性能的影响。事实证明,回调钩子仅在其依赖项之一发生变化时才更新函数,这意味着几分钟内更新一次!
这极大地提高了我的应用程序的性能。
// GameContextProvider.tsx
const disconnectHandler = useCallback(() => {
if (state.gameStatus !== GameLifecycle.STOPPED) // unexpected disconnection!
console.log('unexpected disconnection')
}, [state.gameStatus])
我的自定义断开连接处理程序钩子
在我的项目的当前版本中,我想开发一个功能——意外断开连接时,尝试重新连接!
我对我的 API 进行了更改,并准备在我的 React.js 客户端中实现它们。
事实证明,这是可能的:
// eventHandlers.ts
export const useConnectionPauseHandler(
state: IGameData,
dispatch: React.Dispatch<any>
) => {
const [connectFn, setConnectFn] = useState<ConnectFN>(
null as unknown as ConnectFN
);
const disconnectCallback = useCallback(() => {
if (state.connectionStatus !== ConnectionLifecycle.RESUMED)
dispatch({ type: 'DISCONNECTED' });
}, [dispatch, state.connectionStatus]);
const pauseCallback = useCallback(() => {
if (...) {
// disconnection is expected, or an error is prevting the connection from reconnecting
console.log('expected disconnection');
dispatch({ type: 'DISCONNECTED' });
} else if (...) {
// connection is unexpected, and not attempting reconnection
console.log('unexpected disconnection');
dispatch('SESSION_PAUSED');
if (connectFn) connectFn(state.gameCode!, null, state.playerId);
setTimeout(disconnectCallback, 30 * 1000);
}
}, [
disconnectCallback,
dispatch,
connectFn,
state.gameCode,
state.playerId,
state.connectionStatus,
state.gameStatus,
]);
const registerConnectFunction = useCallback((fn: ConnectFN) => {
setConnectFn(() => fn); // do this to avoid confusing the react dispatch function
}, []);
return [registerConnectFunction, pauseCallback];
}
// GameContextProvider.tsx
const [setConnectFn, onClose] = useConnectionPauseHandler(state, dispatch);
const [connect, sendMessage] = useSession(
onOpen,
onMessage,
onClose
);
useEffect(() => {
console.log('wiring everything...');
setConnectFn(connect);
}, [setConnectFn, connect]);
这个功能就像魔法一样。
奖金
这是一个在页面刷新时保存连接凭据的组件。你能想办法把它重构为钩子吗?
export default class LocalStorageConnectionRestorer extends Component<Wrapper> {
static contextType = GameContext;
state = { isReady: false };
saveValuesBeforeUnload = () => {
const { connectionStatus, showEndScreen, gameCode, playerId, close } =
this.context;
if (connectionStatus === ConnectionLifecycle.RESUMED && !showEndScreen) {
// going away before game is over
console.log('saving reconnection before unmount', gameCode, playerId);
LocalStorageUtils.setValues(gameCode!, playerId!);
close();
}
};
componentDidMount() {
const [gameCode, playerId] = LocalStorageUtils.getValues();
if (gameCode && playerId) {
console.log('attempting reconnection after render');
this.context.reconnect(gameCode, playerId);
LocalStorageUtils.deleteValues();
}
this.setState({ isReady: true });
window.addEventListener('beforeunload', this.saveValuesBeforeUnload);
}
componentWillUnmount() {
window.removeEventListener('beforeunload', this.saveValuesBeforeUnload);
}
render() {
return this.state.isReady ? (
this.props.children
) : (
<div className="flex items-center justify-center">Loading...</div>
);
}
}