+ {Object.entries(cachedComponents).map(([cacheId, cacheItem]) => {
+ const { visible, element, rect } = cacheItem;
+
+ return (
+
+ {element}
+
+ );
+ })}
+
+ >
+ );
+};
+KeepAliveOverlayHost.displayName = 'KeepAliveOverlayHost';
diff --git a/client/web/src/components/KeepAliveOverlay/README.md b/client/web/src/components/KeepAliveOverlay/README.md
new file mode 100644
index 00000000..a9ec9504
--- /dev/null
+++ b/client/web/src/components/KeepAliveOverlay/README.md
@@ -0,0 +1,3 @@
+组件状态缓存, 使用覆盖层的方式来保持存活(类似小程序叠加原生程序的感觉)
+
+支持`iframe`的渲染缓存.
diff --git a/client/web/src/components/KeepAliveOverlay/index.ts b/client/web/src/components/KeepAliveOverlay/index.ts
new file mode 100644
index 00000000..d753ddba
--- /dev/null
+++ b/client/web/src/components/KeepAliveOverlay/index.ts
@@ -0,0 +1,2 @@
+export { KeepAliveOverlayHost } from './KeepAliveOverlayHost';
+export { withKeepAliveOverlay } from './withKeepAliveOverlay';
diff --git a/client/web/src/components/KeepAliveOverlay/store.ts b/client/web/src/components/KeepAliveOverlay/store.ts
new file mode 100644
index 00000000..9b389b6e
--- /dev/null
+++ b/client/web/src/components/KeepAliveOverlay/store.ts
@@ -0,0 +1,79 @@
+import type React from 'react';
+import create from 'zustand';
+import { immer } from 'zustand/middleware/immer';
+
+interface ElementDisplayRect {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+interface KeepAliveState {
+ cachedComponents: Record<
+ string,
+ {
+ visible: boolean;
+ element: React.ReactElement;
+ rect: ElementDisplayRect;
+ }
+ >;
+ mount: (cacheId: string, element: React.ReactElement) => void;
+ show: (cacheId: string) => void;
+ hide: (cacheId: string) => void;
+ updateRect: (cacheId: string, rect: ElementDisplayRect) => void;
+}
+
+export const useKeepAliveStore = create()(
+ immer((set) => ({
+ cachedComponents: {},
+ mount: (cacheId, element) => {
+ set((state) => {
+ const cachedComponents = state.cachedComponents;
+ if (cachedComponents[cacheId]) {
+ // 已经挂载过
+ state.cachedComponents[cacheId].visible = true;
+ return;
+ }
+
+ cachedComponents[cacheId] = {
+ visible: true,
+ element,
+ rect: {
+ left: 0,
+ top: 0,
+ width: 0,
+ height: 0,
+ },
+ };
+ });
+ },
+ show: (cacheId) => {
+ set((state) => {
+ if (!state.cachedComponents[cacheId]) {
+ return;
+ }
+
+ state.cachedComponents[cacheId].visible = true;
+ });
+ },
+ hide: (cacheId) => {
+ set((state) => {
+ if (!state.cachedComponents[cacheId]) {
+ return;
+ }
+
+ state.cachedComponents[cacheId].visible = false;
+ });
+ },
+ updateRect: (cacheId: string, rect: ElementDisplayRect) => {
+ set((state) => {
+ if (!state.cachedComponents[cacheId]) {
+ return;
+ }
+
+ state.cachedComponents[cacheId].rect = rect;
+ });
+ },
+ }))
+);
diff --git a/client/web/src/components/KeepAliveOverlay/withKeepAliveOverlay.tsx b/client/web/src/components/KeepAliveOverlay/withKeepAliveOverlay.tsx
new file mode 100644
index 00000000..27c341e5
--- /dev/null
+++ b/client/web/src/components/KeepAliveOverlay/withKeepAliveOverlay.tsx
@@ -0,0 +1,86 @@
+import React, { useEffect, useMemo, useRef } from 'react';
+import { useKeepAliveStore } from './store';
+import _omit from 'lodash/omit';
+
+/**
+ * 样式相关配置
+ */
+interface StyleProps {
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+/**
+ * 注意: 样式相关的字段(className, style)会被单独抽出来, 会应用于占位组件,不会透传到渲染组件中
+ * 如果实际渲染组件需要样式自定义的话需要另外换个名字
+ */
+export function withKeepAliveOverlay<
+ P extends StyleProps = StyleProps,
+ OP extends Omit = Omit
+>(
+ OriginComponent: React.ComponentType,
+ config: { cacheId: string | ((props: OP) => string) }
+) {
+ // eslint-disable-next-line react/display-name
+ return (props: P) => {
+ const containerRef = useRef(null);
+ const originProps = _omit(props, ['className', 'style']) as OP;
+ const { mount, hide, updateRect } = useKeepAliveStore();
+ const cacheId = useMemo(() => {
+ if (typeof config.cacheId === 'function') {
+ return config.cacheId(originProps);
+ }
+
+ return config.cacheId;
+ }, []);
+
+ useEffect(() => {
+ mount(cacheId, );
+
+ return () => {
+ hide(cacheId);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!containerRef.current) {
+ return;
+ }
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ entries.forEach((entry) => {
+ const { target } = entry;
+ if (!target.parentElement) {
+ return;
+ }
+
+ const rect = target.getBoundingClientRect();
+
+ updateRect(cacheId, {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ });
+ });
+ });
+
+ resizeObserver.observe(containerRef.current);
+
+ return () => {
+ if (containerRef.current) {
+ resizeObserver.unobserve(containerRef.current);
+ }
+ };
+ }, []);
+
+ return (
+
+ );
+ };
+}
diff --git a/client/web/src/components/Webview.tsx b/client/web/src/components/Webview.tsx
index 002e8a02..bebc761b 100644
--- a/client/web/src/components/Webview.tsx
+++ b/client/web/src/components/Webview.tsx
@@ -1,17 +1,20 @@
import React from 'react';
+import { withKeepAliveOverlay } from './KeepAliveOverlay';
interface WebviewProps {
- url: string;
className?: string;
style?: React.CSSProperties;
+ url: string;
}
/**
* 网页渲染容器
*/
-export const Webview: React.FC = React.memo((props) => {
- return (
-
+export const Webview: React.FC =
+ withKeepAliveOverlay(
+ (props) => {
+ return ;
+ },
+ { cacheId: (props) => props.url }
);
-});
Webview.displayName = 'Webview';
diff --git a/client/web/src/routes/Main/Provider.tsx b/client/web/src/routes/Main/Provider.tsx
index e5998156..8a01293f 100644
--- a/client/web/src/routes/Main/Provider.tsx
+++ b/client/web/src/routes/Main/Provider.tsx
@@ -18,6 +18,7 @@ import { PortalHost } from '@/components/Portal';
import { setGlobalSocket, setGlobalStore } from '@/utils/global-state-helper';
import { SocketContextProvider } from '@/context/SocketContext';
import { Problem } from '@/components/Problem';
+import { KeepAliveOverlayHost } from '@/components/KeepAliveOverlay';
/**
* 应用状态管理hooks
@@ -92,7 +93,9 @@ export const MainProvider: React.FC = React.memo((props) => {
- {props.children}
+
+ {props.children}
+