前言
分享一个近期工作中遇到的关于IFrame的需求,以及解决方案。
需求大致是说在我们系统中嵌套了另一个文档页面,这个文档页面是爬取的,并且页面是原先使用后端渲染实现的,取到的css和script标签都是相对路径比如: "./mian.css" 这种,这么写会导致当origin发生变化时取不到静态资源,怎么解决这个问题呢?
解决方案思考
使用Nginx做反向代理
配置Nginx反向代理,在Nginx配置中添加一个代理规则,将请求定向到目标文档页面的地址。
后端动态修改页面路径
将相对路径改为绝对路径,通过解析文档页面,找到其中的相对路径资源引用,将其改为绝对路径。这样不受origin变化的影响。
前端代理(只能在dev环境下实现)
使用vite的proxy实现反向代理效果
先说结论,上述三种方式均被pass了
第一种方法由于页面请求到了宿主的网址下,导致Nginx监听不到资源请求,再有是前端想配置Nginx并不容易。。。
第二种缺乏可复用性,如果宿主的origin发送变化,则后端规则也要跟着改变
第三种就更不用说了,只能在开发环境下实现,不过这种方式给了我一定的启发,如果将静态资源打包进正式包里,再动态修改IFrame的资源路径,或许可以解决相关问题
设计概要
有了方案就需要技术的实施,总共有两步:
第一步是将静态资源打包进正式包中,这里可以使用rollup的插件rollup-plugin-copy来达到复制静态资源的目的
第二步是动态修改iframe中的link标签的href地址,达到资源替换的效果
方案实现
静态资源打包
和webpack有些不同,webpack可以通过CopyWebpackPlugin或者IgnorePlugin等方式复制或排除文件,而使用vite则需要借助其他plugs工具实现,比如vite-plugin-cp或者vite-plugin-static-copy,然而事情并没有这么简单,由于项目环境的复杂性较高,在esm和cjs上发生了错误,有些包是以esm导入的,但是项目中什么文件都有,无法兼顾既要又要,就像下面这样:
为了尽量不改变项目结构,我决定自己造轮子,自己写个插件,在vite的closeBundle生命时将上面要用到的静态文件复制到dist文件夹下
在项目根目录下新建script文件夹,创建新的脚本
其中helpers是工具函数
const noop = (_ = {}) => {} export const defer = () => { let resolve = noop, reject = noop const promise = new Promise((_resolve, _reject) => { resolve = _resolve reject = _reject }) return { promise, resolve, reject } }
接着实现一下复制文件夹的node脚本
import fs from 'fs-extra' import { defer } from './helpers.js' // 手动复制文件夹 export const copyFile = async ( source, target, config = { overwrite: true }, ) => { const { promise, resolve, reject } = defer() fs.copy(source, target, config, (err) => { if (err) { reject(err) console.error('复制出错', err) } else { resolve() console.log('复制成功!') } }) return promise }
然后实现一下vite插件的hook函数,需要注意的是,在我的脚本之前有个打包zip的插件,为了在zip打包之前进行复制静态文件操作,我做了个异步响应操作
import { copyFile } from './copyFile.js' export const copyStaticAfterBuild = (opts, cb) => { return { name: 'copy-static-after-build', closeBundle() { const taskers = opts.map((item) => { console.log(`copy static ${item.src} to ${item.dest}`) return copyFile(item.src, item.dest, { overwrite: true }) }) return Promise.all(taskers).then(cb) }, } }
最后是在vite.config中使用
import { defineConfig } from 'vite' import { zipAfterBuild, copyStaticAfterBuild } from "./scripts" // https://vitejs.dev/config/ export default defineConfig(({ command }) => { return { plugins: [ copyStaticAfterBuild([ { src: './static', dest: './dist/static' } ], zipAfterBuild({}).closeBundle), ], } })
实现效果就是下面这样的
打包后的效果
iframe的通信及标签动态修改
参考之前写的博客,我们可以取iframe的标签并对其dom进行操作,这里我是在react中进行操作,所以写个ref获取标签,其中我们通过iframe.contentWindow.document获取到iframe的dom对象,然后对其内容进行修改,由于操作的步骤不多,使用ipc反而会增加代码量,完整的代码如下
import { useLocation } from 'react-router-dom' import './detail.scss' import { useCallback, useEffect, useRef } from 'react' // 定义 LinksType 类型,可以是 HTMLLinkElement 或 HTMLScriptElement type LinksType = HTMLLinkElement | HTMLScriptElement // 定义网址替换规则的接口 IRule type IRule = { source: string // 源网址 target: string // 目标网址 } // 定义替换网址参数的接口 ParamsOfReplaceUrl interface ParamsOfReplaceUrl<T extends LinksType> { links: NodeListOf<T> // 标签列表,可以是 link、a 等等 rules: IRule[] // 网址替换规则,全字匹配 } // 获取当前页面的基础网址 const base = `${window.location.origin}/` // 定义默认的替换规则数组 const rules = [ { source: base + 'static/css/', target: base + 'static/' }, // 替换 CSS 资源的规则 { source: base + 'static/components/bootstrap-4.3.1/css/', // Bootstrap CSS 资源的规则 target: base + 'static/', // 替换目标 }, ] /** * 批量替换Href网址 * @param links 标签列表,link、a 等等 * @param rules 网址替换规则,全字匹配 */ const replaceHrefUrl = <T extends HTMLLinkElement>({ links, rules, }: ParamsOfReplaceUrl<T>) => { links.forEach((link) => rules.forEach((rule) => { link.href.includes(rule.source) && (link.href = link.href?.replace?.(rule.source, rule.target)) }), ) } /** * 批量替换Src网址 * @param links 标签列表,img、video、script 等等 * @param rules 网址替换规则,全字匹配 */ const replaceSrcUrl = <T extends HTMLScriptElement>({ links, rules, }: ParamsOfReplaceUrl<T>) => { links.forEach((link) => rules.find((rule) => { if (rule.source.includes(link.src)) { const newLink = document.createElement('script') newLink.src = link.src?.replace?.(rule.source, rule.target) link?.parentNode?.replaceChild(newLink, link) } }), ) } // IntelDetail 组件 const IntelDetail = () => { const location = useLocation() const iframe = useRef<HTMLIFrameElement>(null) // 加载处理程序 const loadHandler = useCallback(() => { const elem = iframe?.current const scriptSrc = elem?.contentWindow?.document.querySelectorAll('script') const cssLink = elem?.contentWindow?.document.querySelectorAll('link') if (cssLink) { replaceHrefUrl({ links: cssLink, rules }) // 替换 CSS 链接 } if (scriptSrc) { replaceSrcUrl({ links: scriptSrc, rules }) // 替换 Script 资源 } }, []) // 组件加载时执行加载处理程序,并在组件卸载时清理事件监听器 useEffect(() => { const elem = iframe?.current elem?.addEventListener('load', loadHandler) return () => { elem?.removeEventListener('load', loadHandler) } }, []) // 渲染组件 return ( <div className="intel-detail"> <iframe ref={iframe} src={`${window.location.origin}/jaguar/vul_intelligence/content_s/${location.state.id}`} width="100%" height="100%" frameBorder={0} ></iframe> </div> ) } export default IntelDetail
上述代码中,我们将iframe中的相对资源路径改到了项目的./static中,修复了资源缺失的问题。
效果展示
在使用了上述方案对项目打包后,之前的资源未找到的问题也被解决,效果如下
可以看到在iframe中首先会加载两次资源,在未取到资源后,又会重新获取两个css资源,随后DOM树和CSS树发生重排操作重新渲染,虽然有比较明显的样式过渡,但是这已经是目前最佳的解决方案了