背景
在使用 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));
參考鏈接
請求響應攔截
異常捕獲