3

背景

在使用 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));

参考链接

请求响应拦截
异常捕获


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。