背景
在使用 Electron
桌面应用时,有时我们需要将其他平台上的业务页面嵌入到桌面应用中,以便快速满足业务需求。
这种需求的优势在于可以重用已有的业务页面,无需重新开发桌面应用的界面和功能,从而节省时间和资源。通过将其他端的业务页面嵌入桌面应用中,我们可以快速将现有的功能和用户界面带入桌面环境,提供一致的用户体验。
虽然 Electron
框架提供 <webview>
标签来帮助我们嵌入其他端的业务页面,但是某些需求要求在使用<webview>
标签创建的窗口内嵌套其他业务页面,显然只能另辟蹊径。恰恰 iframe
能满足我们的需求,它用于在页面中嵌入独立的文档,它创建了一个完全独立的浏览上下文,可以加载来自不同域的内容,具备天然的沙箱隔离性,可以避免与宿主页面的相互污染。
封装
使用 <iframe>
标签直接开发需求的确可以完成任务,但这种方法在过程中可能不够优雅,需要对宿主页面和被嵌套页面进行许多修改,且缺乏代码的复用性。当未来遇到类似的需求时,可能需要重新编写大量代码,缺乏效率和可维护性。
为了优化这种情况,可以考虑以下方法来提高代码的复用性和开发效率:
- 抽象封装:将
iframe
相关的逻辑和操作封装为可重用的组件或模块。通过定义清晰的接口和功能,使其能够适应不同的嵌入页面需求。这样,下次遇到类似的需求时,只需要引用和配置相应的组件,而无需从头编写相关代码。 - 参数化配置:通过将嵌入页面的
URL
、大小、样式等参数化配置,使组件具有更大的灵活性和适应性。这样,可以在不同的场景中使用同一个组件,并通过配置不同的参数来实现不同的嵌入需求,从而提高代码的复用性。 - 通用接口和事件:定义通用的接口和事件,使得宿主页面和嵌套页面之间可以进行双向通信和数据交互。这样,可以通过事件机制或消息传递来实现页面间的交互,而无需直接修改宿主页面和被嵌套页面的代码,从而减少改动点,提高维护性。
页面加载
<iframe>
的主要职能是加载页面,而加载页面的形式可以多样化,包括通过 HTTP
协议加载网页内容,或通过 Blob
协议和 base64
编码加载网页内容。这几种加载方式有明显的区别。
当使用 HTTP
协议加载网络内容时,<iframe>
只能在页面加载完成后对其进行生命周期的管理。也就是说,当网络地址的页面加载完成后,才能对其进行操作、修改或注入脚本等。
相比之下,使用 Blob
协议或者 base64
编码加载网页内容的 <iframe>
具有更大的灵活性。可以在请求到页面的内容后进行篡改、提前注入脚本并监听和控制页面的生命周期。这意味着可以在页面加载过程中对其进行更细粒度的操作和控制。
在使用 Blob
协议或 Base64
编码加载网页内容之前先请求获取网页内容(text/html
)。然而,如果该网页内容与宿主页面不同源,就会面临跨域问题,导致无法获取内容。在这种情况下,走兜底逻辑,通过使用 HTTP
协议来加载网页内容。
在将获取到的网页内容编码为 Base64 时,有时会出现报错信息:Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
这是因为 Base64
编码采用的是 ASCII
编码范围,而 JavaScript
中的字符串采用的是 UTF-16
编码规范,因此在内容字符串中可能存在超出 ASCII
编码范围的字符。
为了解决这个问题,可以使用 encodeURIComponent
将 UTF-16
编码的字符串转换为 UTF-8
,然后再进行 Base64
编码。
const base64Data = `data:text/html;base64,${btoa(
unescape(encodeURIComponent(tamperHtml(responseText))),
)}`;
本以为问题会游刃而解,但运行时发现由于浏览器的安全策略限制,使用 Base64
编码的数据设置 iframe
的 src
属性无法获取到 contentDocument
对象,只能放弃使用 Base64
编码方式加载网页内容,另辟蹊径。
可以采用另一种方式解决此问题,可以创建一个空白的 iframe
元素,并将其添加到文档中,获取 document
使用 document.open()
方法打开新的 document
对象,然后 document.write()
写入网页内容。
const iframe = document.createElement('iframe');
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
containerRef.current.appendChild(iframe);
const doc = iframe.contentWindow?.document;
if (doc) {
// 新打开一个文档并且写入内容
doc.open().write(responseText);
// 内容写入完成后相当于页面加载完成,执行onload方法
onload();
doc.close();
}
以下是“页面加载”整体代码逻辑:
import React, {
memo,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
type IType = 'http' | 'base64' | 'blob';
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = () => {
const {
src,
type = 'http',
width,
height,
className = '',
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const onload = () => {};
const strategies = useMemo(() => {
const loadPage = (src: string) => {
if (containerRef.current && src) {
containerRef.current.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
iframe.onload = onload;
containerRef.current.appendChild(iframe);
}
};
const strategy = {
http: () => {
loadPage(src);
},
base64: () => {
// base64和blob形式都需要提前请求网页内容,如果与宿主页面不同源,会有跨域问题,此时兜底使用http形式
new Promise<string>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
const { responseText } = xhr;
resolve(responseText);
} else {
reject(new Error('网络异常'));
}
};
xhr.open('GET', src, true);
xhr.send();
})
.then((base64Data) => {
if (containerRef.current && src) {
containerRef.current.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
containerRef.current.appendChild(iframe);
const doc = iframe.contentWindow?.document;
if (doc) {
// 新打开一个文档并且写入内容
doc.open().write(base64Data);
// 内容写入完成后相当于页面加载完成,执行onload方法
onload();
doc.close();
}
}
})
.catch(() => {
// 请求的页面资源可能与宿主页面不同源,会有跨域问题,此时兜底使用 http 协议加载
loadPage(src);
});
},
blob: () => {
new Promise<string>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
// 创建 Blob 对象,此处可以篡改html内容,提前注入脚本
const blob = new Blob([xhr.responseText], { type: 'text/html' });
const blobURL = URL.createObjectURL(blob);
resolve(blobURL);
} else {
reject(new Error('网络异常'));
}
};
xhr.open('GET', src, true);
xhr.send();
})
.then((blobURL) => {
loadPage(blobURL);
})
.catch(() => {
loadPage(src);
});
},
};
return strategy;
}, [src, width, height, onload, tamperHtml]);
// 使用策略模式根据不同type采用不同形式加载页面
const loadIfr = useCallback(
(type: IType) => {
strategies[type]();
},
[strategies],
);
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
}
export default memo(WebView);
前端页面调用应用侧函数
开发者将应用侧代码注册到前端页面中,注册完成之后,前端页面中使用注册的对象名称就可以调用应用侧的函数,实现在前端页面中调用应用侧方法。
注册应用侧代码有两种方式,一种在组件初始化调用,使用 javaScriptProxy()
接口。另外一种在组件初始化完成后调用,使用 registerJavaScriptProxy()
接口。
首先,无论采用哪些方式注入应用侧代码,都先需要定义数据结构
interface IJavascriptProxy {
object: Record<string, Function>;
name: string;
methodList: Array<string>;
}
object
:表示要注入的对象,其每一个属性都是一个方法(同步或者异步方法);name
:表示注入对象的变量名称,前端页面根据该变量名调用对象属性;methodList
:类似白名单,表示对象中哪些属性会注入到前端页面中;
此外,还有三个问题需要考虑,分别是注入方式、同步和异步函数执行调用的统一性,以及函数返回值的回馈给调用方。
为了方便前端页面调用,可以通过在 window
对象上声明全局变量的形式,将对象方法挂载到 window
对象上。这样做可以使得前端页面可以方便地访问这些方法。但需要注意的是,这种挂载方式并不是真正意义上的挂载,而是在 window
对象上声明与对象方法同名的属性方法。
为了确保异步和同步方法的执行调用在前端页面中能够统一,我们可以将方法体封装在一个 Promise
对象中,通过 postMessage
方法与应用侧进行通信,从而调用应用侧的函数,并且将 Promise
状态控制权交付给应用侧。
使用 registerJavaScriptProxy()
接口注册应用侧代码,需要先将接口暴露给应用侧,这里我们使用 useImperativeHandle
钩子函数将其抛出。
在下面的示例中,将 test()
方法注册在前端页面中,该函数可以在前端页面触发运行。
import React, {
memo,
useEffect,
useRef,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
} from 'react';
export interface Instance {
registerJavascriptProxy: () => void;
}
interface IJavascriptProxy {
object: Record<string, Function>;
name: string;
methodList: Array<string>;
}
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
ref: Instance;
javascriptProxy?: IJavascriptProxy;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = (props: IProps, ref: React.Ref<Instance>) => {
const {
src,
type = 'http',
width,
height,
className = '',
javascriptProxy,
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 向H5页面注入对象
const registerJavascriptProxy = useCallback(
(obj?: IJavascriptProxy) => {
const newObj = obj || javascriptProxy;
if (newObj && newObj.object && newObj.name && newObj.methodList) {
let code = `
window.addEventListener("message", (e) => {
if (e.data.type === "registerJavascriptProxyCallback") {
window?.[e.data.result.promiseResolve](e.data.result.data);
delete window?.[e.data.result.promiseResolve];
}
});
`;
newObj.methodList.forEach((method) => {
if (typeof newObj.object[method] === 'function') {
// 注入的对象属性必须都是方法
code += `
if (!("${newObj.name}" in window)) {
window["${newObj.name}"] = {};
}
window["${newObj.name}"]["${method}"] = function(...args) {
return new Promise((resolve) => {
const promiseResolve = 'promiseResolve${Math.random().toString().slice(2)}';
window[promiseResolve] = resolve;
window.top.postMessage({ type: 'registerJavascriptProxy-${method}', result: { data: args, promiseResolve: promiseResolve } }, window.top.location.origin);
});
}
`;
}
});
const doc = ifrRef.current?.contentDocument;
if (doc) {
const script = doc.createElement('script');
script.id = "registerJavascriptProxy";
script.textContent = code;
doc.head.appendChild(script);
}
}
},
[javascriptProxy],
);
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type?.startsWith('registerJavascriptProxy')) {
const { data, promiseResolve } = e.data.result;
const win = ifrRef.current?.contentWindow;
// 向H5注册的方法被执行
const [, method] = e.data.type.split('-');
const res = await javascriptProxy?.object?.[method]?.apply(javascriptProxy?.object, data);
win?.postMessage(
{
type: 'registerJavascriptProxyCallback',
result: {
data: res,
promiseResolve,
},
},
win.location.origin,
);
}
},
[javascriptProxy],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
useImperativeHandle(ref, () => ({
registerJavascriptProxy,
}));
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
};
export default memo(forwardRef(WebView));
应用侧使用示例:
const ref = useRef<Instance>(null);
const handleRegister = () => {
const javascriptProxy = {
object: {
greet: function() { console.log('hello') },
},
name: 'javascriptHandler2',
methodList: ['greet'],
};
ref.current.registerJavascriptProxy(javascriptProxy);
};
<Button secondary onClick={handleRegister}>
手动注册应用侧代码
</Button>
<WebView
type="http"
src="http://localhost:3000/rawfile/index.html"
width="100%"
height="100%"
ref={ref}
javascriptProxy={{
name: 'javascriptHandler',
object: {
async test(content: string) {
return content;
},
},
methodList: ['test'],
}}
/>
前端页面使用示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button onclick="handleTest()">调用test</button>
<script>
async function handleTest() {
const result = await javascriptHandler.test("hello");
}
</script>
</body>
</html>
应用侧调用前端页面函数
应用侧可以通过 runJavaScript()
方法调用前端页面的 JavaScript
相关函数,其执行逻辑如下:首先向前端页面动态注入 script
标签,使用 Promise
封装执行方法体,然后使用 window.eval()
执行目标代码,将执行结果用 postMessage
反馈到应用侧,应用侧更改 Promise
状态,返回执行结果。
import React, {
memo,
useEffect,
useRef,
forwardRef,
useCallback,
useImperativeHandle,
} from 'react';
export interface Instance {
runJavascript: (code: string) => Promise<unknown>;
}
type IType = 'http' | 'base64' | 'blob';
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
ref: Instance;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = (props: IProps, ref: React.Ref<Instance>) => {
const {
src,
type = 'http',
width,
height,
className = '',
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 应用端异步执行JavaScript脚本
const runJavascript = (code: string) => {
const scriptId = Math.random().toString(36).slice(2);
const doc = ifrRef.current?.contentDocument;
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
return new Promise((resolve) => {
window[promiseResolve as keyof Window] = resolve as never;
const script = document.createElement('script');
script.id = scriptId;
const content = `
!(async function runJavascript() {
const data = await window.eval("${code.replace(/"/g, "'")}");
if (data) {
window.top.postMessage({ type: "runJavascriptCallback", result: { data: data, promiseResolve: "${promiseResolve}" } }, window.top.location.origin);
}
})();
`;
script.textContent = content;
doc?.head.appendChild(script);
}).then((res) => {
delete window[promiseResolve as keyof Window];
// 执行完之后删除script
doc?.getElementById(scriptId)?.remove();
return res;
});
};
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type === 'runJavascriptCallback') {
const { promiseResolve, data } = e.data.result;
// 应用端异步执行JavaScript脚本的回调
(window?.[promiseResolve] as any)(data);
}
},
[],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
useImperativeHandle(ref, () => ({
runJavascript,
}));
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
};
export default memo(forwardRef(WebView));
应用侧使用示例:
const ref = useRef<Instance>(null);
const handleRunJavascript = async () => {
const result = await ref.current.runJavascript(`handlerTest()`);
};
<Button secondary onClick={handleRunJavascript}>
调用前端页面函数
</Button>
<WebView
type="http"
src="http://localhost:3000/rawfile/index.html"
width="100%"
height="100%"
ref={ref}
/>
前端页面使用示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
function handleTest() {
return new Promise((resolve) => setTimeout(resove, 1000)).then(() => "我是测试数据");
}
</script>
</body>
</html>
建立应用侧与前端页面数据通道
在前端页面和应用侧之间建立通信时,直接使用 postMessage
可以达到相同的效果。然而,这种直接的实现方式可能会导致两端的代码耦合性增强,不符合封装组件的开闭原则。
为了解决这个问题,我们可以搭建一个通信桥梁("bridge
"),将建立通信和调用过程封装在这个组件中。这样,前端页面和应用侧可以通过与桥梁组件的交互来实现通信,而无需直接修改彼此的代码。
import React, {
memo,
useEffect,
useRef,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
} from 'react';
export interface Instance {
runJavascript: (code: string) => Promise<unknown>;
}
interface IMessage {
name: string;
func: (...args: unknown[]) => unknown;
context?: unknown;
}
type IType = 'http' | 'base64' | 'blob';
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
ref: Instance;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = (props: IProps, ref: React.Ref<Instance>) => {
const {
src,
type = 'http',
width,
height,
className = '',
onMessages = [],
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 应用端异步执行JavaScript脚本
const runJavascript = (code: string) => {
const scriptId = Math.random().toString(36).slice(2);
const doc = ifrRef.current?.contentDocument;
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
return new Promise((resolve) => {
window[promiseResolve as keyof Window] = resolve as never;
const script = document.createElement('script');
script.id = scriptId;
const content = `
!(async function runJavascript() {
const data = await window.eval("${code.replace(/"/g, "'")}");
if (data) {
window.top.postMessage({ type: "runJavascriptCallback", result: { data: data, promiseResolve: "${promiseResolve}" } }, window.top.location.origin);
}
})();
`;
script.textContent = content;
doc?.head.appendChild(script);
}).then((res) => {
delete window[promiseResolve as keyof Window];
// 执行完之后删除script
doc?.getElementById(scriptId)?.remove();
return res;
});
};
const initJSBridge = () => {
const code = `
window.addEventListener("message", (e) => {
if (e.data.type === "h5CallNativeCallback") {
window?.[e.data.result.promiseResolve](e.data.result.data);
delete window?.[e.data.result.promiseResolve];
}
});
window.h5CallNative = function(name, ...args) {
return new Promise((resolve) => {
const promiseResolve = 'promiseResolve${Math.random().toString().slice(2)}';
window[promiseResolve] = resolve;
window.top.postMessage({ type: 'h5CallNative', result: { name, data: args, promiseResolve } }, window.top.location.origin);
});
};
`;
const doc = ifrRef.current?.contentDocument;
if (doc) {
const script = doc.createElement('script');
script.id = "h5CallNative";
script.textContent = code;
doc.head.appendChild(script);
}
};
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type === 'runJavascriptCallback') {
const { promiseResolve, data } = e.data.result;
// 应用端异步执行JavaScript脚本的回调
(window?.[promiseResolve] as any)(data);
}
if (e.data.type === 'h5CallNative') {
const { name, data, promiseResolve } = e.data.result;
onMessages.forEach((item) => {
if (item.name === name) {
Promise.resolve()
.then(() => item.func.apply(item.context, data))
.then((res) => {
ifrRef.current?.contentWindow?.postMessage(
{
type: 'h5CallNativeCallback',
result: {
data: res,
promiseResolve,
},
},
ifrRef.current?.contentWindow.location.origin,
);
});
}
});
}
},
[onMessages],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
useImperativeHandle(ref, () => ({
runJavascript,
}));
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
};
export default memo(forwardRef(WebView));
应用侧使用示例:
const ref = useRef<Instance>(null);
const handleRunJavascript = async () => {
const result = await ref.current.runJavascript(`handlerTest()`);
};
<Button secondary onClick={handleRunJavascript}>
调用前端页面函数
</Button>
<WebView
type="http"
src="http://localhost:3000/rawfile/index.html"
width="100%"
height="100%"
ref={ref}
onMessages={[
{
name: 'native.string.join',
func: (...args: unknown[]) => {
return args.join('');
},
context: null,
},
]}
/>
前端页面使用示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button onclick="handleCallNative()">调用应用侧代码></button>
<script>
function handleTest() {
return new Promise((resolve) => setTimeout(resove, 1000)).then(() => "我是测试数据");
}
async function handleCallNative() {
const content = await window.h5CallNative('native.string.join', '我', '❤️', '中国');
}
</script>
</body>
</html>
自定义页面请求响应
在使用 iframe
嵌套网页时,可能会遇到一个常见问题,即嵌套的网页与宿主页面存在跨域限制,导致网页中的异步请求无法携带浏览器的 cookie,从而无法传递身份验证信息。为了解决这个问题,我们需要在外部注入身份信息。
由于浏览器限制了自定义请求头中的 Cookie
字段,我们通常会选择自定义请求头 Authorization
或 Bearer
,并将身份验证信息放置在其中。这样,我们需要拦截异步请求并修改请求头。
在拦截请求的情况下,我们可以使用装饰器模式来扩展原始的请求处理函数,添加额外的处理逻辑,例如修改请求头、处理响应数据等。
通过装饰器模式,我们可以在不修改原始请求处理函数的情况下,将拦截逻辑嵌入到函数调用链中。这样,每当请求被调用时,装饰器函数将首先执行自定义的处理逻辑,然后再调用原始的请求处理函数,确保功能的增强而不破坏原有的代码结构和接口。
interface IRequestConfig {
method?: string;
url?: string;
headers?: Record<string, string>;
params?: Record<string, unknown>;
}
const rawOpen = XMLHttpRequest.prototype.open;
const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
const rawSend = XMLHttpRequest.prototype.send;
const requestInterceptorManager = new Map<XMLHttpRequest, IRequestConfig>();
XMLHttpRequest.prototype.open = function(method, url) {
requestInterceptorManager.set(this, Object.assign(requestInterceptorManager.get(this) || {}, {
method,
url,
}));
};
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
const headers = requestInterceptorManager.get(this)?.headers;
const newHeaders = headers ? Object.assign(headers, { [name]: value }) : { [name]: value };
const config = Object.assign(requestInterceptorManager.get(this) as IRequestConfig, {
headers: newHeaders,
});
requestInterceptorManager.set(this, config);
};
const tamperRequestConfig = (xhr, body) => {
const config = requestInterceptorManager.get(this);
// 篡改请求
return newConfig;
}
XMLHttpRequest.prototype.send = async function(body) {
// 篡改请求
const config = await tamperRequestConfig(this, body);
// 重新设置请求
rawOpen.call(this, config.method, config.url);
Object.keys(config.headers).forEach((key) => {
rawSetRequestHeader.call(this, key, config.headers[key]);
})
// 发送请求
rawSend.call(this, config.params);
}
或者采用类式写法:
const RawXMLHttpRequest = window.XMLHttpRequest;
class XMLHttpRequest extends RawXMLHttpRequest {
constructor() {
this.requestInterceptorManager = new Map();
}
open(method, url) {
this.requestInterceptorManager.set(this, Object.assign(this.requestInterceptorManager.get(this) || {}, {
method,
url,
}));
}
setRequestHeader(name, value) {
const headers = this.requestInterceptorManager.get(this)?.headers;
const newHeaders = headers ? Object.assign(headers, { [name]: value }) : { [name]: value };
const config = Object.assign(this.requestInterceptorManager.get(this) as IRequestConfig, {
headers: newHeaders,
});
this.requestInterceptorManager.set(this, config);
};
async send(body) {
// 篡改请求
const config = await tamperRequestConfig(this, body);
// 重新设置请求
super.open.call(this, config.method, config.url);
Object.keys(config.headers).forEach((key) => {
super.setRequestHeader.call(this, key, config.headers[key]);
})
// 发送请求
super.send.call(this, config.params);
}
}
在拦截前端页面请求和响应,并在应用侧进行篡改逻辑的情况下,由于重写请求方法需要动态注入到前端页面中,所以我们需要使用 postMessage 进行前端页面和应用侧之间的通信交互。
// 篡改请求
const tamperRequestConfig = (xhr, body) => {
const config = requestInterceptorManager.get(this) || {};
return new Promise((resolve) => {
const params = typeof body === 'string' ? JSON.parse(body) : body;
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
window[promiseResolve as keyof Window] = resolve as never;
window.top?.postMessage(
{
type: 'interceptRequestConfig',
result: {
data: Object.assign(config, {
params,
}),
promiseResolve,
},
},
window.top.location.origin,
);
}).catch(() => {
return config;
});
}
XMLHttpRequest.prototype.send = async function(body) {
// 篡改请求
const config = await tamperRequestConfig(this, body);
// 重新设置请求
rawOpen.call(this, config.method, config.url);
Object.keys(config.headers).forEach((key) => {
rawSetRequestHeader.call(this, key, config.headers[key]);
})
// 发送请求
rawSend.call(this, config.params);
// 删除记录
requestInterceptorManager.delete(xhr);
}
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type === 'interceptRequestConfig') {
const { promiseResolve, data: config, xhr } = e.data.result;
const win = ifrRef.current?.contentWindow;
const resolve = win?.[promiseResolve] as any;
try {
if (typeof interceptRequest === 'function') {
// interceptRequest是组件的props
resolve?.(interceptRequest(config));
delete win?.[promiseResolve];
} else {
resolve?.(config);
delete win?.[promiseResolve];
}
} catch (error) {
resolve?.(config);
delete win?.[promiseResolve];
}
}
},
[interceptRequest],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
在响应拦截的逻辑中,我们通常需要拦截对响应对象(如 response 或 responseText)的访问,并进行相应的处理。在这种情况下,存取器属性允许我们在访问属性时执行自定义的逻辑,因此可以用于拦截对响应对象的访问。
function getter() {
// 执行delete xhr.response;后xhr.response不会触发getter存取器方法,所以需要再setup();
delete (xhr as any).response;
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
return new Promise((resolve) => {
window[promiseResolve as keyof Window] = resolve as never;
window.top?.postMessage({
type: 'interceptResponseResult',
result: { data: xhr.response, promiseResolve },
});
setup();
}).then((result) => {
delete window[promiseResolve as keyof Window];
return result;
});
}
function setup() {
Object.defineProperty(xhr, 'response', {
get: getter,
configurable: true,
});
}
setup();
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type === 'interceptResponseResult') {
const { promiseResolve, data: response, xhr } = e.data.result;
const win = ifrRef.current?.contentWindow;
const resolve = win?.[promiseResolve] as any;
try {
if (typeof interceptResponse === 'function') {
// interceptResponse是组件的props
resolve?.(interceptResponse(response));
delete win?.[promiseResolve];
} else {
resolve?.(response);
delete win?.[promiseResolve];
}
} catch (error) {
resolve?.(response);
delete win?.[promiseResolve];
}
}
},
[interceptRequest],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
组件使用示例
<WebView
type="base64"
src="http://localhost:3000/rawfile/index.html"
width="100%"
height="100%"
ref={ref}
interceptRequest={(config) => {
// 添加自定义请求头 Authorization
return merge(config, { headers: { Authorization: token } });
}}
interceptResponse={(response) => {
console.log(response);
return { errorCode: 0, data: 'hello world' };
}}
/>
异常监听
在前端页面的运行过程中,可能会出现意外的异常情况。为了及时发现和解决这些问题,捕获并监听前端页面的异常是非常有必要的,并且对于应用侧来说,这是一项非常方便的功能,可以帮助我们进行问题的分析和解决。
我们可以通过监听前端页面的错误事件(onerror
事件),来捕获前端页面运行过程中抛出的异常,并且抛给应用侧。
onerror
事件主要用于捕获前端代码中的错误,包括同步代码和某些异步代码,如 setTimeout
、eval
、XMLHttpRequest(XHR)
等。但是,当一个 Promise
被拒绝(rejected
),但没有在后续的 catch
方法中进行处理时,onerror
事件不会捕获,需要监听 unhandledrejection
事件捕获该异常。
import React, {
memo,
useEffect,
useRef,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
} from 'react';
interface IPageError {
type: string;
message: string;
stack: string;
}
type IType = 'http' | 'base64' | 'blob';
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
ref: Instance;
onErrorReceive?: (error: IPageError) => void;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = (props: IProps, ref: React.Ref<Instance>) => {
const {
src,
type = 'http',
width,
height,
className = '',
onErrorReceive,
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type === 'onPageError') {
const { data } = e.data.result;
if (typeof onErrorReceive === 'function') {
onErrorReceive.call(ifrRef.current?.contentWindow, data);
}
}
},
[onErrorReceive],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
/**
* onerror 事件主要用于捕获代码中的错误,包括同步代码和某些异步代码(如setTimeout、eval、xhr等)。
* 而 unhandledrejection 事件主要用于捕获异步操作中的未处理的 Promise 拒绝。
* 以下这种也是只能 unhandledrejection 捕获异常
new Promise(() => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `http://localhost:3000/`);
xhr.onreadystatechange = function () {
throw new Error('我是错误');
};
xhr.send();
})
*/
const onPageError = () => {
const func = () => {
const handlerPageError = (event: any) => {
const error = { type: event.type, message: event.error.message, stack: event.error.stack };
window.top?.postMessage(
{ type: 'onPageError', result: { data: error } },
window.top.location.origin,
);
};
const handlePageUnhandledrejection = (event: any) => {
// 获取拒绝的 Promise 对象和错误信息
const error = {
type: event.type,
message: event.reason.message,
stack: event.reason.stack,
};
window.top?.postMessage(
{ type: 'onPageError', result: { data: error } },
window.top.location.origin,
);
};
window.addEventListener('error', handlerPageError);
window.addEventListener('unhandledrejection', handlePageUnhandledrejection);
window.addEventListener('unload', () => {
window.removeEventListener('error', handlerPageError);
window.removeEventListener('unhandledrejection', handlePageUnhandledrejection);
});
};
const code = `
!(${func.toString()})();
`;
const doc = ifrRef.current?.contentDocument;
if (doc) {
const script = doc.createElement('script');
script.id = "onPageError";
script.textContent = code;
doc.head.appendChild(script);
}
};
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
};
export default memo(forwardRef(WebView));
应用侧示例:
// --run--
<WebView
type="base64"
src="http://localhost:3000/rawfile/index.html"
width="100%"
height="100%"
ref={ref}
onErrorReceive={(error) => {
console.log(error);
}}
/>
生命周期
在前端页面的执行过程中,生命周期钩子函数是非常有用的工具,可以满足开发者在不同阶段处理不同逻辑的需求。通过使用生命周期钩子函数,我们可以在前端页面的不同生命周期阶段执行相关的逻辑,例如在页面加载开始之前(onPageBegin
)执行注入脚本,或在页面加载完成(onPageEnd
)后在应用侧执行前端页面函数等。
import { JSDOM } from 'jsdom';
import React, {
memo,
useEffect,
useRef,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
} from 'react';
type IType = 'http' | 'base64' | 'blob';
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
ref: Instance;
onPageBegin?: (doc: Document) => void;
onPageEnd?: () => void;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = (props: IProps, ref: React.Ref<Instance>) => {
const {
src,
type = 'http',
width,
height,
className = '',
onPageEnd,
onPageBegin,
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const onload = useCallback(() => {
if (containerRef.current) {
(ifrRef as React.MutableRefObject<HTMLIFrameElement>).current = document.getElementById(
IframeId,
) as HTMLIFrameElement;
if (typeof onPageEnd === 'function') onPageEnd();
}
}, [onPageEnd]);
// 篡改html
const tamperHtml = useCallback(
(htmlStr: string) => {
if (typeof onPageBegin === 'function') {
const dom = new JSDOM(htmlStr);
const contentDocument = dom.window.document;
onPageBegin(contentDocument);
return dom.serialize();
}
return htmlStr;
},
[onPageBegin],
);
const strategies = useMemo(() => {
const loadPage = (src: string) => {
if (containerRef.current && src) {
containerRef.current.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
iframe.onload = onload;
containerRef.current.appendChild(iframe);
}
};
const strategy = {
http: () => {
loadPage(src);
},
base64: () => {
// base64和blob形式都需要提前请求网页内容,如果与宿主页面不同源,会有跨域问题,此时兜底使用http形式
new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
const { responseText } = xhr;
// 此处可以篡改html内容,提前注入脚本
const base64Data = tamperHtml(responseText);
resolve(base64Data);
} else {
reject(new Error('网络异常'));
}
};
xhr.open('GET', src, true);
xhr.send();
})
.then((base64Data) => {
if (containerRef.current && src) {
containerRef.current.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
containerRef.current.appendChild(iframe);
const doc = iframe.contentWindow?.document;
if (doc) {
// 新打开一个文档并且写入内容
doc.open().write(base64Data);
// 内容写入完成后相当于页面加载完成,执行onload方法
onload();
doc.close();
}
}
})
.catch(() => {
// 请求的页面资源可能与宿主页面不同源,会有跨域问题,此时兜底使用 http 协议加载
loadPage(src);
});
},
blob: () => {
new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
// 创建 Blob 对象,此处可以篡改html内容,提前注入脚本
const blob = new Blob([tamperHtml(xhr.responseText)], { type: 'text/html' });
const blobURL = URL.createObjectURL(blob);
resolve(blobURL);
} else {
reject(new Error('网络异常'));
}
};
xhr.open('GET', src, true);
xhr.send();
})
.then((blobURL) => {
loadPage(blobURL);
})
.catch(() => {
loadPage(src);
});
},
};
return strategy;
}, [src, width, height, onload, tamperHtml]);
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
};
export default memo(forwardRef(WebView));
应用侧使用示例:
<WebView
type="base64"
src="http://localhost:3000/rawfile/index.html"
width="100%"
height="100%"
ref={ref}
onPageBegin={(doc) => {
// 该钩子只有在type为base64或者blob时有效
const script = doc.createElement("script");
script.textContent = `console.log("我是早onPageBegin钩子执行期间注入的")`;
doc.appendChild(script);
}}
onPageEnd={() => {
console.log("页面已经加载完成");
}}
/>
完整示例代码
import { JSDOM } from 'jsdom';
import React, {
memo,
useEffect,
useRef,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
} from 'react';
import { createScript } from './util';
export interface Instance {
runJavascript: (code: string) => Promise<unknown>;
registerJavascriptProxy: () => void;
getIframeInstance: () => HTMLIFrameElement | null;
loadUrl: (url: string | URL) => void;
}
interface IJavascriptProxy {
object: Record<string, Function>;
name: string;
methodList: Array<string>;
}
interface IRequestConfig {
method?: string;
url?: string;
headers?: Record<string, string>;
params?: Record<string, unknown>;
}
interface IPageError {
type: string;
message: string;
stack: string;
}
interface IMessage {
name: string;
func: (...args: unknown[]) => unknown;
context?: unknown;
}
type IType = 'http' | 'base64' | 'blob';
interface IProps {
className?: string;
type?: IType;
src: string;
width: string;
height: string;
ref: Instance;
javascriptProxy?: IJavascriptProxy;
onMessages?: Array<IMessage>;
onPageBegin?: (doc: Document) => void;
onPageEnd?: () => void;
onErrorReceive?: (error: IPageError) => void;
interceptRequest?: (config: IRequestConfig) => IRequestConfig;
interceptResponse?: (response: unknown) => unknown;
}
const IframeId = Math.random().toString(36).slice(2);
const WebView = (props: IProps, ref: React.Ref<Instance>) => {
const {
src,
type = 'http',
width,
height,
className = '',
javascriptProxy,
onMessages = [],
onPageEnd,
onPageBegin,
onErrorReceive,
interceptRequest,
interceptResponse,
} = props;
const ifrRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 应用端异步执行JavaScript脚本
const runJavascript = (code: string) => {
const scriptId = Math.random().toString(36).slice(2);
const doc = ifrRef.current?.contentDocument;
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
return new Promise((resolve) => {
window[promiseResolve as keyof Window] = resolve as never;
const script = document.createElement('script');
script.id = scriptId;
const content = `
!(async function runJavascript() {
const data = await window.eval("${code.replace(/"/g, "'")}");
if (data) {
window.top.postMessage({ type: "runJavascriptCallback", result: { data: data, promiseResolve: "${promiseResolve}" } }, window.top.location.origin);
}
})();
`;
script.textContent = content;
doc?.head.appendChild(script);
}).then((res) => {
delete window[promiseResolve as keyof Window];
// 执行完之后删除script
doc?.getElementById(scriptId)?.remove();
return res;
});
};
// 向H5页面注入对象
const registerJavascriptProxy = useCallback(
(obj?: IJavascriptProxy) => {
const newObj = obj || javascriptProxy;
if (newObj && newObj.object && newObj.name && newObj.methodList) {
let code = `
window.addEventListener("message", (e) => {
if (e.data.type === "registerJavascriptProxyCallback") {
window?.[e.data.result.promiseResolve](e.data.result.data);
delete window?.[e.data.result.promiseResolve];
}
});
`;
newObj.methodList.forEach((method) => {
if (typeof newObj.object[method] === 'function') {
// 注入的对象属性必须都是方法
code += `
if (!("${newObj.name}" in window)) {
window["${newObj.name}"] = {};
}
window["${newObj.name}"]["${method}"] = function(...args) {
return new Promise((resolve) => {
const promiseResolve = 'promiseResolve${Math.random().toString().slice(2)}';
window[promiseResolve] = resolve;
window.top.postMessage({ type: 'registerJavascriptProxy-${method}', result: { data: args, promiseResolve: promiseResolve } }, window.top.location.origin);
});
}
`;
}
});
const doc = ifrRef.current?.contentDocument;
createScript(doc, 'registerJavascriptProxy', code);
}
},
[javascriptProxy],
);
const initJSBridge = () => {
const code = `
window.addEventListener("message", (e) => {
if (e.data.type === "h5CallNativeCallback") {
window?.[e.data.result.promiseResolve](e.data.result.data);
delete window?.[e.data.result.promiseResolve];
}
});
window.h5CallNative = function(name, ...args) {
return new Promise((resolve) => {
const promiseResolve = 'promiseResolve${Math.random().toString().slice(2)}';
window[promiseResolve] = resolve;
window.top.postMessage({ type: 'h5CallNative', result: { name, data: args, promiseResolve } }, window.top.location.origin);
});
};
`;
const doc = ifrRef.current?.contentDocument;
createScript(doc, 'h5CallNative', code);
};
// 消息处理器
const handleMessage = useCallback(
async (e: any) => {
if (e.data.type === 'runJavascriptCallback') {
const { promiseResolve, data } = e.data.result;
// 应用端异步执行JavaScript脚本的回调
(window?.[promiseResolve] as any)(data);
}
if (e.data.type?.startsWith('registerJavascriptProxy')) {
const { data, promiseResolve } = e.data.result;
const win = ifrRef.current?.contentWindow;
// 向H5注册的方法被执行
const [, method] = e.data.type.split('-');
const res = await javascriptProxy?.object?.[method]?.apply(javascriptProxy?.object, data);
win?.postMessage(
{
type: 'registerJavascriptProxyCallback',
result: {
data: res,
promiseResolve,
},
},
win.location.origin,
);
}
if (e.data.type === 'h5CallNative') {
const { name, data, promiseResolve } = e.data.result;
onMessages.forEach((item) => {
if (item.name === name) {
Promise.resolve()
.then(() => item.func.apply(item.context, data))
.then((res) => {
ifrRef.current?.contentWindow?.postMessage(
{
type: 'h5CallNativeCallback',
result: {
data: res,
promiseResolve,
},
},
ifrRef.current?.contentWindow.location.origin,
);
});
}
});
}
if (e.data.type === 'interceptRequestConfig') {
const { promiseResolve, data: config, xhr } = e.data.result;
const win = ifrRef.current?.contentWindow;
const resolve = win?.[promiseResolve] as any;
try {
if (typeof interceptRequest === 'function') {
resolve?.(interceptRequest(config));
delete win?.[promiseResolve];
} else {
resolve?.(config);
delete win?.[promiseResolve];
}
} catch (error) {
resolve?.(config);
delete win?.[promiseResolve];
}
}
if (e.data.type === 'interceptResponseResult') {
const { promiseResolve, data: response, xhr } = e.data.result;
const win = ifrRef.current?.contentWindow;
const resolve = win?.[promiseResolve] as any;
try {
if (typeof interceptResponse === 'function') {
resolve?.(interceptResponse(response));
delete win?.[promiseResolve];
} else {
resolve?.(response);
delete win?.[promiseResolve];
}
} catch (error) {
resolve?.(response);
delete win?.[promiseResolve];
}
}
if (e.data.type === 'onPageError') {
const { data } = e.data.result;
if (typeof onErrorReceive === 'function') {
onErrorReceive.call(ifrRef.current?.contentWindow, data);
}
}
},
[interceptRequest, interceptResponse, javascriptProxy, onErrorReceive, onMessages],
);
// 监听消息
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [handleMessage]);
// 请求拦截
const onInterceptNetwork = () => {
const func = () => {
const requestInterceptorManager = new Map<XMLHttpRequest, IRequestConfig>();
const rawOpen = XMLHttpRequest.prototype.open;
const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function open(method, url) {
const config = requestInterceptorManager.get(this) as IRequestConfig;
if (
!requestInterceptorManager.has(this) ||
config.method !== method ||
config.url !== url
) {
requestInterceptorManager.set(
this,
Object.assign(requestInterceptorManager.get(this) || {}, {
method,
url,
}),
);
}
};
XMLHttpRequest.prototype.setRequestHeader = function setRequestHeader(name, value) {
if (
!requestInterceptorManager.has(this) ||
(requestInterceptorManager.get(this) as IRequestConfig).headers?.[name] !== value
) {
const headerItem = {};
Object.defineProperty(headerItem, name, {
value,
configurable: true,
enumerable: true,
writable: true,
});
const headers = requestInterceptorManager.get(this)?.headers;
const newHeaders = headers ? Object.assign(headers, headerItem) : headerItem;
const config = Object.assign(requestInterceptorManager.get(this) as IRequestConfig, {
headers: newHeaders,
});
requestInterceptorManager.set(this, config);
}
};
// 响应拦截
function setupInterceptResponseHook(xhr: XMLHttpRequest) {
function getter() {
// 执行delete xhr.response;后xhr.response不会触发getter存取器方法,所以需要再setup();
delete (xhr as any).response;
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
return new Promise((resolve) => {
window[promiseResolve as keyof Window] = resolve as never;
window.top?.postMessage({
type: 'interceptResponseResult',
result: { data: xhr.response, promiseResolve },
});
setup();
}).then((result) => {
delete window[promiseResolve as keyof Window];
return result;
});
}
function setup() {
Object.defineProperty(xhr, 'response', {
get: getter,
configurable: true,
});
}
setup();
}
// 请求拦截
function setupInterceptRequestHook(
xhr: XMLHttpRequest,
body: Document | XMLHttpRequestBodyInit | null | undefined,
) {
// 发送请求前,拦截请求
const params = typeof body === 'string' ? JSON.parse(body) : body;
new Promise((resolve: (value: IRequestConfig) => void) => {
const promiseResolve = `promiseResolve${Math.random().toString().slice(2)}`;
window[promiseResolve as keyof Window] = resolve as never;
window.top?.postMessage(
{
type: 'interceptRequestConfig',
result: {
data: Object.assign(requestInterceptorManager.get(xhr) || {}, {
params,
}),
promiseResolve,
},
},
window.top.location.origin,
);
}).then(
(config: IRequestConfig) => {
rawOpen.call(xhr, config.method as string, config.url as string, true);
Object.keys(config.headers as Record<string, any>).forEach((key) => {
rawSetRequestHeader.call(xhr, key, (config.headers as Record<string, any>)[key]);
});
rawSend.call(xhr, JSON.stringify(config.params));
// 删除记录
requestInterceptorManager.delete(xhr);
},
(reason) => {
// 删除记录
requestInterceptorManager.delete(xhr);
},
);
}
XMLHttpRequest.prototype.send = function send(body) {
// 响应拦截
setupInterceptResponseHook(this);
// 请求拦截
setupInterceptRequestHook(this, body);
};
};
const code = `
!(${func.toString()})();
`;
const doc = ifrRef.current?.contentDocument;
createScript(doc, 'interceptRequest', code);
};
/**
* onerror 事件主要用于捕获代码中的错误,包括同步代码和某些异步代码(如setTimeout、eval、xhr等)。
* 而 unhandledrejection 事件主要用于捕获异步操作中的未处理的 Promise 拒绝。
* 以下这种也是只能 unhandledrejection 捕获异常
new Promise(() => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `http://localhost:3000/`);
xhr.onreadystatechange = function () {
throw new Error('我是错误');
};
xhr.send();
})
*/
const onPageError = () => {
const func = () => {
const handlerPageError = (event: any) => {
const error = { type: event.type, message: event.error.message, stack: event.error.stack };
window.top?.postMessage(
{ type: 'onPageError', result: { data: error } },
window.top.location.origin,
);
};
const handlePageUnhandledrejection = (event: any) => {
// 获取拒绝的 Promise 对象和错误信息
const error = {
type: event.type,
message: event.reason.message,
stack: event.reason.stack,
};
window.top?.postMessage(
{ type: 'onPageError', result: { data: error } },
window.top.location.origin,
);
};
window.addEventListener('error', handlerPageError);
window.addEventListener('unhandledrejection', handlePageUnhandledrejection);
window.addEventListener('unload', () => {
window.removeEventListener('error', handlerPageError);
window.removeEventListener('unhandledrejection', handlePageUnhandledrejection);
});
};
const code = `
!(${func.toString()})();
`;
const doc = ifrRef.current?.contentDocument;
createScript(doc, 'onPageError', code);
};
const onload = useCallback(() => {
if (containerRef.current) {
(ifrRef as React.MutableRefObject<HTMLIFrameElement>).current = document.getElementById(
IframeId,
) as HTMLIFrameElement;
// 向H5页面注入对象
registerJavascriptProxy();
// 初始化H5调用native
initJSBridge();
// 请求响应拦截
onInterceptNetwork();
// 监听页面异常
onPageError();
if (typeof onPageEnd === 'function') onPageEnd();
}
}, [onPageEnd, registerJavascriptProxy]);
// 篡改html
const tamperHtml = useCallback(
(htmlStr: string) => {
if (typeof onPageBegin === 'function') {
const dom = new JSDOM(htmlStr);
const contentDocument = dom.window.document;
onPageBegin(contentDocument);
return dom.serialize();
}
return htmlStr;
},
[onPageBegin],
);
const strategies = useMemo(() => {
const loadPage = (src: string) => {
if (containerRef.current && src) {
containerRef.current.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
iframe.onload = onload;
containerRef.current.appendChild(iframe);
}
};
const strategy = {
http: () => {
loadPage(src);
},
base64: () => {
// base64和blob形式都需要提前请求网页内容,如果与宿主页面不同源,会有跨域问题,此时兜底使用http形式
new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
const { responseText } = xhr;
// 此处可以篡改html内容,提前注入脚本
// 因为javascript是utf-16编码,html中可能有超出utf-8编码的范围,直接使用btoa(html)会有问题,需要先使用encodeURIComponent将其转化成utf-8
// const base64Data = `data:text/html;base64,${btoa(
// unescape(encodeURIComponent(tamperHtml(responseText))),
// )}`;
const base64Data = tamperHtml(responseText);
resolve(base64Data);
} else {
reject(new Error('网络异常'));
}
};
xhr.open('GET', src, true);
xhr.send();
})
.then((base64Data) => {
if (containerRef.current && src) {
containerRef.current.innerHTML = '';
const iframe = document.createElement('iframe');
// 将 Base64 编码的数据作为数据 URL 并将其设置为 iframe 的 src 属性可能会导致无法访问 iframe 的 contentDocument。这是因为数据 URL 被视为不同的源,存在跨域访问限制。
// iframe.src = src;
iframe.width = width;
iframe.height = height;
iframe.id = IframeId;
iframe.style.border = 'none';
containerRef.current.appendChild(iframe);
const doc = iframe.contentWindow?.document;
if (doc) {
// 新打开一个文档并且写入内容
doc.open().write(base64Data);
// 内容写入完成后相当于页面加载完成,执行onload方法
onload();
doc.close();
}
}
})
.catch(() => {
// 请求的页面资源可能与宿主页面不同源,会有跨域问题,此时兜底使用 http 协议加载
loadPage(src);
});
},
blob: () => {
new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
// 创建 Blob 对象,此处可以篡改html内容,提前注入脚本
const blob = new Blob([tamperHtml(xhr.responseText)], { type: 'text/html' });
const blobURL = URL.createObjectURL(blob);
resolve(blobURL);
} else {
reject(new Error('网络异常'));
}
};
xhr.open('GET', src, true);
xhr.send();
})
.then((blobURL) => {
loadPage(blobURL);
})
.catch(() => {
loadPage(src);
});
},
};
return strategy;
}, [src, width, height, onload, tamperHtml]);
// 使用策略模式根据不同type采用不同形式加载页面
const loadIfr = useCallback(
(type: IType) => {
strategies[type]();
},
[strategies],
);
const getIframeInstance = () => {
return ifrRef.current;
};
const loadUrl = (url: string | URL) => {
const win = ifrRef.current?.contentWindow;
if (url && win) {
const newUrl = new URL(url);
win.location.replace(newUrl.href);
}
};
useImperativeHandle(ref, () => ({
runJavascript,
registerJavascriptProxy,
getIframeInstance,
loadUrl,
}));
useEffect(() => {
loadIfr(type);
}, [loadIfr, type]);
return <div className={className} style={{ width: '100%', height: '100%' }} ref={containerRef} />;
};
export default memo(forwardRef(WebView));
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。