当一个后台系统需要嵌入另一个独立项目的完整详情页面,而两个项目技术栈、API 层、权限体系完全不同时,iframe 是成本最低的跨项目复用方案。本文通过一个运维系统嵌入 Event 工单项目的真实案例,完整拆解从 0 到 1 的实现过程。
一、场景描述
我们有两个独立的前端项目:
bash
运维系统 (React 18 + AG Grid + MobX) Event 项目 (React 19 + Ant Design 5)
┌──────────────────────────────────┐ ┌────────────────────────────┐
│ 审计列表 → 审计详情 │ │ 工单详情页 │
│ ┌────┬────┬──────┬────┐ │ │ WorkbenchInfo (50+ 文件) │
│ │ ID │类型│ 编号 │详情│ │ │ ├─ API: /v3/p/event/v1/... │
│ └────┴────┴──────┴────┘ │ │ ├─ Auth Store │
│ ↓ 点击工单编号 │ │ └─ 权限、路由体系 │
│ 审计详情页 → 标题区可点击编号 │───────→│ iframe 嵌入 │
└──────────────────────────────────┘ └────────────────────────────┘
需求:审计列表和审计详情页都能查看关联的工单完整信息,但不跳转离开当前系统。
二、为什么选 iframe 而不是直接 import 组件?
Event 项目的详情组件深度耦合自身基础设施:
| 依赖项 | 说明 |
|---|---|
| API 层 | 完全独立的接口路径和鉴权方式 |
| 状态管理 | 独立的 auth store、全局路由守卫 |
| 权限系统 | 独立的 RBAC 权限树 |
| 文件规模 | 50+ 文件,拆分成本极高 |
直接 import 一套独立系统的详情组件,等于要把半个项目的依赖迁移过来。iframe 在零迁移成本的前提下复用了完整的 UI 和业务逻辑。
三、完整实现:演进过程
3.1 初版:列表页直接弹窗
第一版将 iframe 弹窗放在列表页,当点击「详情」按钮时,如果是工单类型就直接弹窗。
tsx
// 审计列表页 (初版,后已演进)
const [workOrderModal, setWorkOrderModal] = useState({
visible: false,
relationId: 0,
code: '',
});
const openDetail = useCallback((record: AuditListItem) => {
if (record.type === 'work_order') {
setWorkOrderModal({ visible: true, relationId: record.relation_id, code: record.code });
return;
}
navigate(`/audit/detail/${record.id}`);
}, [navigate]);
// Modal + iframe 渲染
<Modal
title={`工单详情 - ${workOrderModal.code}`}
open={workOrderModal.visible}
onCancel={closeWorkOrderModal}
footer={null}
width="90%"
destroyOnClose
styles={{ body: { height: 'calc(100vh - 180px)', padding: 0 } }}
>
{workOrderLink && (
<iframe
src={workOrderLink.href}
style={{ width: '100%', height: '100%', border: 'none' }}
title="工单详情"
/>
)}
</Modal>
问题:列表页「详情」按钮对不同事项类型产生了不一致的行为------有些跳转、有些弹窗。违背了界面行为的一致性。
3.2 重构:弹窗移至详情页 + embed 模式去外壳
第二版做了三个关键改动:
① 弹窗从列表页移到详情页标题区
列表页「详情」按钮恢复为统一跳转 /audit/detail/:id,在详情页的标题区,工单编号渲染为可点击链接,点击弹出 iframe:
tsx
// 审计详情页
const [workOrderModalVisible, setWorkOrderModalVisible] = useState(false);
const subjectLink = subject
? getSubjectLink(subject.type, subject.relation_id, CHILD_ORIGIN)
: undefined;
const title = subject ? (
<span>
<span className={styles.summaryCode}>
{subject.type === 'work_order' && subjectLink ? (
<a
className="zs-link"
onClick={e => {
e.preventDefault();
setWorkOrderModalVisible(true);
}}
>
{subject.code}
</a>
) : subjectLink?.external ? (
<a href={subjectLink.href} target="_blank" rel="noreferrer">
{subject.code}
</a>
) : subjectLink ? (
<Link to={subjectLink.href}>{subject.code}</Link>
) : (
<span>{subject.code}</span>
)}
</span>
<span>:{subject.name || '(已删除)'}</span>
</span>
) : null;
三种链接分支策略:
bash
subject.type
├─ work_order → 标题区点击弹出 iframe Modal(当前页不跳转)
├─ 其他 external → <a target="_blank"> 新窗口打开
└─ 内部路由 → React Router <Link> SPA 无刷新跳转
② 列表页回退到纯净状态
diff
- // 删除了列表页的全部 Modal 逻辑
- const [workOrderModal, setWorkOrderModal] = useState({...});
- if (record.type === 'work_order') { setWorkOrderModal(...); return; }
+ // 「详情」按钮统一跳转
+ navigate(`/audit/detail/${record.id}`);
③ iframe src 追加 ?embed=1 去外壳
tsx
<Modal ...>
{subjectLink && (
<iframe
src={`${subjectLink.href}?embed=1`}
style={{ width: '100%', height: '100%', border: 'none' }}
title="工单详情"
/>
)}
</Modal>
Event 项目侧配合:
tsx
// Event 项目入口布局
const isEmbed = new URLSearchParams(location.search).get('embed') === '1';
// embed 模式跳过 Header + Sidebar + AI 助手面板
function MainLayout({ children }) {
if (isEmbed) return <>{children}</>;
return (
<>
<Header />
<Sidebar />
<main>{children}</main>
<AIAssistant />
</>
);
}
3.3 6d13c925 --- 修复:Vite 代理从硬编码改为环境变量
第一版的 Vite 代理写死了 localhost:4175,不同开发者本地端口不一致就会 404。
问题根因 :iframe src 用的是 location.origin + '/ops-event/...',本地 dev server 的 location.origin 是 http://localhost:8000,但这个地址上没有 Event 服务。线上 Nginx 已配置好反代,本地需要 Vite proxy 补齐。
修复前:
ts
'/ops-event/': {
target: 'http://localhost:4175', // ← 写死端口,换机器就挂
changeOrigin: true,
},
修复后:
ts
'/ops-event/': {
target: env.VITE_EVENT_HOST, // ← 从 .env 读取,每人配置自己的端口
changeOrigin: true,
},
对应 .env.development:
env
VITE_EVENT_HOST=http://localhost:4175
3.4 最终形态:提取独立组件
最终将 iframe Modal 抽取为 WorkOrderDetailModal 组件:
tsx
// components/work-order-detail-modal/index.tsx
interface WorkOrderDetailModalProps {
code?: string;
relationId?: number;
open: boolean;
onClose: () => void;
}
const WorkOrderDetailModal = ({ code, relationId, open, onClose }: WorkOrderDetailModalProps) => {
const subjectLink = relationId
? getSubjectLink('work_order', relationId, CHILD_ORIGIN)
: undefined;
return (
<Modal
title={code ? `工单详情 - ${code}` : '工单详情'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
destroyOnClose
styles={{
body: { height: 'calc(100vh - 180px)', padding: 0 },
}}
>
{subjectLink ? (
<iframe
src={subjectLink.href}
style={{
width: '99.5%',
height: '99%',
paddingTop: '20px',
background: 'rgb(240,240,240)',
border: 'none',
}}
title="工单详情"
/>
) : null}
</Modal>
);
};
调用方只需三行:
tsx
<WorkOrderDetailModal
code={subject?.code}
relationId={subject?.relation_id}
open={workOrderModalVisible}
onClose={() => setWorkOrderModalVisible(false)}
/>
四、核心工具:通用链接构建器
这个模式的核心是一套跨项目路径映射表,抽象后可以适配任何子项目:
ts
// constants.ts
const SUBJECT_LINK_CONFIG: Record<string, { path: string; external?: boolean }> = {
event: { path: '/event/fullevent' },
todolist: { path: '/todolist/detail/:relationId' },
work_order: { path: '/ops-event/incidents/:relationId', external: true }, // ← 跨项目
change_flow: { path: '/change/flow/:relationId' },
};
export const getSubjectLink = (type: string, relationId: number, childOrigin = '') => {
const config = SUBJECT_LINK_CONFIG[type];
if (!config) return undefined;
const path = config.path.replace(':relationId', encodeURIComponent(String(relationId)));
return {
href: config.external ? `${childOrigin}${path}` : path,
external: !!config.external,
};
};
关键设计点:
external: true标记跨项目路径,拼接location.origin(线上同一域名、本地靠代理):relationId占位符统一替换- 返回
{ href, external },调用方根据external决定用<a>还是<Link>
五、Modal + iframe 关键参数表
| 参数 | 推荐值 | 作用 |
|---|---|---|
destroyOnClose |
true |
关闭弹窗销毁 iframe DOM,防止未挂载的 iframe 继续占用内存 |
width |
"90%" |
充分利用屏幕宽度展示详情 |
body.height |
calc(100vh - 180px) |
撑满可视区,180px 留给页头(60) + 弹窗标题栏(55) + 预留 |
body.padding |
0 |
去掉默认 padding,让 iframe 完全占满 |
footer |
null |
弹窗不需要额外按钮,操作由 iframe 内部处理 |
iframe.border |
none |
去除默认边框 |
iframe.width/height |
99.5% / 99% |
略小于 100%,避免出现双滚动条 |
六、完整数据流
bash
审计详情页
│
├─ subject.type === 'work_order'
│ │
│ ├─ getSubjectLink('work_order', relationId, location.origin)
│ │ └─ { href: 'https://ops.example.com/ops-event/incidents/48', external: true }
│ │
│ ├─ 用户点击标题区工单编号
│ │
│ └─ <WorkOrderDetailModal>
│ └─ <iframe src=".../incidents/48?embed=1" />
│ │
│ ├─ 本地开发:Vite proxy /ops-event/ → env.VITE_EVENT_HOST
│ ├─ 线上环境:Nginx location /ops-event/ → event-service
│ │
│ └─ Event 项目加载工单详情页
│ ├─ MainLayout 检测 ?embed=1 → 跳过 Header/Sidebar/AI助手
│ └─ WorkbenchInfo 单列布局渲染详情
│
└─ 其他类型
├─ external → <a target="_blank"> 新窗口
└─ 内部路由 → <Link> SPA 跳转
七、总结
- iframe 零迁移复用:两个独立项目在同一域名下无缝集成,无需改造子项目基础设施,50+ 文件的组件一套代码双端复用
- 分支策略分层 :列表页保持统一行为,差异逻辑放在详情页标题区;弹窗触发而非路由跳转,用户不离开当前上下文;
?embed=1让子项目自适应嵌入场景 - 本地开发代理是关键 :Vite
server.proxy用环境变量而非硬编码,适配不同开发者本地环境;线上 Nginx 反代理已覆盖无需额外改动