博客 / 詳情

返回

如何將iframe封裝成一個組件

背景

在使用 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 編碼範圍的字符。

為了解決這個問題,可以使用 encodeURIComponentUTF-16 編碼的字符串轉換為 UTF-8,然後再進行 Base64 編碼。

const base64Data = `data:text/html;base64,${btoa(
  unescape(encodeURIComponent(tamperHtml(responseText))),
)}`;

本以為問題會遊刃而解,但運行時發現由於瀏覽器的安全策略限制,使用 Base64 編碼的數據設置 iframesrc 屬性無法獲取到 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 字段,我們通常會選擇自定義請求頭 AuthorizationBearer,並將身份驗證信息放置在其中。這樣,我們需要攔截異步請求並修改請求頭。

在攔截請求的情況下,我們可以使用裝飾器模式來擴展原始的請求處理函數,添加額外的處理邏輯,例如修改請求頭、處理響應數據等。

通過裝飾器模式,我們可以在不修改原始請求處理函數的情況下,將攔截邏輯嵌入到函數調用鏈中。這樣,每當請求被調用時,裝飾器函數將首先執行自定義的處理邏輯,然後再調用原始的請求處理函數,確保功能的增強而不破壞原有的代碼結構和接口。

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 事件主要用於捕獲前端代碼中的錯誤,包括同步代碼和某些異步代碼,如 setTimeoutevalXMLHttpRequest(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));

參考鏈接

請求響應攔截
異常捕獲

user avatar steven_code 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.