为了解决异步 window.open() 调用,写了一个webpack插件

208 阅读6分钟

创建一个 Webpack 插件来拦截项目中的异步 window.open() 调用,并自动实现“预先打开加载窗口,异步获取 URL 后再更新地址”的策略,这是一个相对复杂的任务,因为它涉及到静态代码分析和 JavaScript 的动态特性。

核心思路是使用 Babel 来转换你代码的抽象语法树 (AST)。我们主要识别在异步操作之后(例如 await 之后或 .then() 回调中)的事件处理器(如 click 事件)中调用 window.open() 的模式。

下面是一个 Webpack 插件的实现,它包含一个自定义的 Babel 插件来完成这个代码转换。

1. Babel 插件 (babel-plugin-transform-async-window-open-cn.js)

这个 Babel 插件将执行实际的代码转换。

// babel-plugin-transform-async-window-open-cn.js

function transformAsyncWindowOpenBabelPlugin({ types: t }) {
    // 生成加载页面的 HTML,支持自定义选项
    const getLoadingHTML = (options = {}) => {
        const title = options.title || '加载中...';
        const message = options.message || '正在加载内容,请稍候...';
        const bgColor = options.bgColor || '#f0f0f0'; // 背景颜色
        const textColor = options.textColor || '#333'; // 通用文字颜色
        const messageColor = options.messageColor || '#555'; // 提示信息文字颜色
        const loaderColor = options.loaderColor || '#3498db'; // 加载动画颜色
        const loaderBgColor = options.loaderBgColor || '#f3f3f3'; // 加载动画轨道颜色

        // 压缩后的 HTML 和 CSS
        return `<html><head><title>${title}</title><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{display:flex;flex-direction:column;justify-content:center;align-items:center;height:100vh;margin:0;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";background-color:${bgColor};color:${textColor};text-align:center;padding:20px;box-sizing:border-box;}.loader{border:6px solid ${loaderBgColor};border-top:6px solid ${loaderColor};border-radius:50%;width:50px;height:50px;animation:spin 1s linear infinite;margin-bottom:20px;}@keyframes spin{0%{transform:rotate(0deg);}100%{transform:rotate(360deg);}}p{font-size:16px;color:${messageColor};}@media (max-width:600px){.loader{width:40px;height:40px;border-width:5px;}p{font-size:14px;}}</style></head><body><div class="loader"></div><p>${message}</p></body></html>`.replace(/\n\s*/g, '');
    };

    return {
        visitor: {
            // 主要处理函数调用表达式
            CallExpression(path, state) {
                const node = path.node;
                const callee = node.callee;

                // 目标:element.addEventListener('click', handler)
                // 可以扩展事件类型: 'mousedown', 'mouseup', 'touchend' 等
                const eventListenerNames = ['click', 'mousedown', 'mouseup', 'touchend', 'pointerdown'];
                if (
                    t.isMemberExpression(callee) &&
                    t.isIdentifier(callee.property, { name: 'addEventListener' }) &&
                    node.arguments.length >= 2 // 确保至少有两个参数 (事件名, 回调函数)
                ) {
                    const eventNameNode = node.arguments[0];
                    const handlerNode = node.arguments[1];

                    // 检查事件名是否是我们关心的,并且回调函数是函数表达式或箭头函数
                    if (
                        t.isStringLiteral(eventNameNode) &&
                        eventListenerNames.includes(eventNameNode.value) &&
                        (t.isFunctionExpression(handlerNode) || t.isArrowFunctionExpression(handlerNode))
                    ) {
                        const handlerBodyPath = path.get('arguments.1.body');
                        let isAsyncContextForOpen = false; // 标记 window.open 是否在异步上下文中
                        let windowOpenPathDetails = null; // 存储 window.open 调用的路径和相关信息

                        // 标记事件处理器本身是否是 async 函数
                        let isHandlerAsync = handlerNode.async === true;

                        // 遍历事件处理器的函数体,查找 window.open 调用
                        handlerBodyPath.traverse({
                            CallExpression(innerPath) {
                                const innerNode = innerPath.node;
                                // 检查是否是 window.open() 或 open()
                                if (
                                    (t.isIdentifier(innerNode.callee, { name: 'open' }) ||
                                    (t.isMemberExpression(innerNode.callee) &&
                                     t.isIdentifier(innerNode.callee.object, { name: 'window' }) &&
                                     t.isIdentifier(innerNode.callee.property, { name: 'open' }))) &&
                                     innerNode.arguments.length > 0 // 确保有参数 (至少一个URL参数)
                                ) {
                                    let currentPath = innerPath;
                                    let inPromiseChain = false; // 是否在 Promise 链 (.then, .catch, .finally) 中
                                    let precededByAwait = false; // window.open 是否在 await 表达式之后

                                    //向上追溯 AST 节点,判断上下文
                                    while (currentPath && currentPath.node !== handlerNode.body) {
                                        // 如果 window.open 被一个 AwaitExpression 包裹或在其后
                                        if (currentPath.isAwaitExpression() && innerPath.isDescendant(currentPath)) {
                                            precededByAwait = true;
                                        }
                                        // 检查 window.open 是否在 .then, .catch, .finally 的回调函数内部
                                        if (currentPath.key === 'callee' && currentPath.parentPath && currentPath.parentPath.isCallExpression()) {
                                            const parentCall = currentPath.parentPath.node;
                                            if (t.isMemberExpression(parentCall.callee)) {
                                                const prop = parentCall.callee.property;
                                                if (t.isIdentifier(prop) && ['then', 'catch', 'finally'].includes(prop.name)) {
                                                    // 确保 window.open 确实在 Promise 回调的作用域内
                                                    const funcScope = innerPath.scope.getFunctionParent();
                                                    const callbackScope = currentPath.parentPath.get('arguments.0').scope.getFunctionParent();
                                                    if(funcScope === callbackScope){
                                                        inPromiseChain = true;
                                                    }
                                                }
                                            }
                                        }
                                        currentPath = currentPath.parentPath;
                                    }
                                    
                                    // 如果事件处理器是 async,或者 window.open 在 Promise 链中,或者在 await 之后
                                    if (isHandlerAsync || inPromiseChain || precededByAwait) {
                                        isAsyncContextForOpen = true;
                                        windowOpenPathDetails = {
                                            path: innerPath, // window.open 调用的路径
                                            urlArg: innerNode.arguments[0], // URL 参数
                                            targetArg: innerNode.arguments.length > 1 ? innerNode.arguments[1] : t.stringLiteral('_blank'), // target 参数
                                            featuresArg: innerNode.arguments.length > 2 ? innerNode.arguments[2] : t.stringLiteral(''), // features 参数
                                        };
                                        innerPath.stop(); // 找到一个符合条件的 window.open,停止当前子遍历
                                    }
                                }
                            },
                            AwaitExpression() {
                                // 如果事件处理器中存在 await 表达式,则认为后续的 window.open 可能处于异步上下文
                                isHandlerAsync = true;
                            }
                        });

                        // 如果找到了处于异步上下文的 window.open 调用
                        if (isAsyncContextForOpen && windowOpenPathDetails) {
                            const { path: openCallPath, urlArg, targetArg, featuresArg } = windowOpenPathDetails;

                            // 生成一个唯一的变量名来存储预打开的窗口对象
                            const tempVarName = handlerBodyPath.scope.generateUidIdentifier("asyncOpenedWin");
                            // 获取加载页面的 HTML,允许通过插件选项自定义
                            const loadingHTML = getLoadingHTML(state.opts.loadingScreen || {});

                            // 1. 创建预打开窗口的 AST 节点: window.open('', target, features)
                            const preOpenCall = t.callExpression(
                                t.memberExpression(t.identifier("window"), t.identifier("open")),
                                [t.stringLiteral(""), targetArg, featuresArg] // 使用原始的 target 和 features
                            );
                            // 2. 声明变量存储预打开的窗口: const asyncOpenedWin = window.open(...);
                            const tempWinDeclaration = t.variableDeclaration("const", [
                                t.variableDeclarator(tempVarName, preOpenCall)
                            ]);

                            // 3. 尝试向预打开的窗口写入加载页面 HTML 的 AST 节点
                            const writeLoadingTryCatch = t.tryStatement(
                                t.blockStatement([ // try block
                                    t.expressionStatement(
                                        t.callExpression(
                                            t.memberExpression(t.memberExpression(tempVarName, t.identifier("document")), t.identifier("write")),
                                            [t.stringLiteral(loadingHTML)]
                                        )
                                    ),
                                    t.expressionStatement(
                                        t.callExpression(
                                            t.memberExpression(t.memberExpression(tempVarName, t.identifier("document")), t.identifier("close")),
                                            []
                                        )
                                    )
                                ]),
                                t.catchClause(t.identifier('errLoading'), t.blockStatement([ // catch block
                                    // 可选: 打印警告信息
                                    t.expressionStatement(t.callExpression(
                                        t.memberExpression(t.identifier("console"), t.identifier("warn")),
                                        [t.stringLiteral("AsyncWindowOpenPlugin: 写入加载页面失败"), t.identifier("errLoading")]
                                    ))
                                ]))
                            );
                            
                            // 4. 如果窗口成功打开,则写入加载页面:if (asyncOpenedWin) { try...catch... }
                            const showLoadingIfWinStatement = t.ifStatement(
                                tempVarName, // 条件:asyncOpenedWin 是否存在 (即 window.open 是否成功)
                                t.blockStatement([writeLoadingTryCatch])
                            );

                            // 5. 替换原始的 window.open(url, ...) 调用
                            const replacementCall = t.ifStatement(
                                // 条件:if (asyncOpenedWin && !asyncOpenedWin.closed)
                                t.logicalExpression("&&", tempVarName, t.unaryExpression("!", t.memberExpression(tempVarName, t.identifier("closed")))),
                                // true 分支: asyncOpenedWin.location.href = url;
                                t.blockStatement([
                                    t.expressionStatement(
                                        t.assignmentExpression("=", t.memberExpression(tempVarName, t.identifier("location"), false), urlArg)
                                    )
                                ]),
                                // false 分支 (else): 回退到原始的 window.open 调用 (可能仍然被拦截,但作为备选方案)
                                t.blockStatement([ 
                                    t.expressionStatement(
                                        t.callExpression(
                                            t.memberExpression(t.identifier("window"), t.identifier("open")),
                                            [urlArg, targetArg, featuresArg]
                                        )
                                    )
                                ])
                            );
                            // 用新的逻辑替换掉原始的 window.open() 调用路径
                            openCallPath.replaceWith(replacementCall);

                            // 6. 将事件处理器的原始主体内容包裹在 try...catch 中,以便在发生错误时关闭预打开的窗口
                            const originalBodyStmts = Array.isArray(handlerNode.body.body) ? handlerNode.body.body : [handlerNode.body];
                            const tryBlock = t.blockStatement([...originalBodyStmts]); // 原始的语句(包含已替换的 window.open)

                            const catchClause = t.catchClause(
                                path.scope.generateUidIdentifier("e"), // 捕获的错误变量
                                t.blockStatement([
                                    // 如果预打开的窗口存在且未关闭,则关闭它
                                    t.ifStatement(
                                        t.logicalExpression("&&", tempVarName, t.unaryExpression("!", t.memberExpression(tempVarName, t.identifier("closed")))),
                                        t.expressionStatement(t.callExpression(t.memberExpression(tempVarName, t.identifier("close")), []))
                                    ),
                                    t.throwStatement(t.identifier("e")) // 重新抛出错误
                                ])
                            );
                            const tryCatchStatement = t.tryStatement(tryBlock, catchClause);

                            // 7. 将预打开窗口的声明、写入加载页面的逻辑,以及 try...catch 包裹的原始逻辑,设置为新的事件处理器函数体
                            handlerNode.body.body = [
                                tempWinDeclaration,          // const asyncOpenedWin = ...
                                showLoadingIfWinStatement,   // if (asyncOpenedWin) { document.write(...) }
                                tryCatchStatement            // try { ...original logic... } catch { ... }
                            ];
                            
                            // 标记该文件已被插件转换
                            if (state.file && state.file.metadata) {
                                state.file.metadata.transformedByAsyncWindowOpenPlugin = true;
                            }
                        }
                    }
                }
            }
        }
    };
}

module.exports = transformAsyncWindowOpenBabelPlugin; // 导出 Babel 插件

2. Webpack 插件 (AsyncWindowOpenWebpackPluginCn.js)

这个 Webpack 插件将使用上面的 Babel 插件来处理你的 JavaScript/TypeScript 文件。

// AsyncWindowOpenWebpackPluginCn.js
const { sources } = require('webpack');
const babel = require('@babel/core');
// 假设 babel-plugin-transform-async-window-open-cn.js 与此文件在同一目录
const transformAsyncWindowOpenBabelPlugin = require('./babel-plugin-transform-async-window-open-cn'); 

class AsyncWindowOpenWebpackPluginCn {
    constructor(options = {}) {
        this.pluginName = 'AsyncWindowOpenWebpackPluginCn'; // 插件名称
        // Babel 插件的选项,主要用于自定义加载屏幕
        this.babelPluginOptions = {
            loadingScreen: { // 默认加载屏幕配置
                title: options.title || '加载中...',
                message: options.message || '正在打开内容,请稍候...',
                bgColor: options.bgColor || '#f0f0f0',
                textColor: options.textColor || '#333',
                messageColor: options.messageColor || '#555',
                loaderColor: options.loaderColor || '#3498db',
                loaderBgColor: options.loaderBgColor || '#f3f3f3',
                ...(options.loadingScreen || {}) // 合并用户传入的 loadingScreen 配置
            }
        };
    }

    apply(compiler) {
        compiler.hooks.compilation.tap(this.pluginName, (compilation) => {
            // 在资源处理阶段进行操作
            compilation.hooks.processAssets.tapAsync(
                {
                    name: this.pluginName,
                    // 选择一个合适的阶段,例如 PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE
                    // 通常对于转译,更早的阶段或 loader 更合适,但作为独立插件,这个阶段也可以工作
                    stage: compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, 
                },
                async (assets, callback) => {
                    const assetNames = Object.keys(assets);
                    let transformedCount = 0; // 记录转换的文件数量

                    for (const filename of assetNames) {
                        // 只处理 JS/MJS 文件,并尝试排除 node_modules 中的文件
                        // 更可靠的排除方式可能需要检查模块的 issuer 或资源路径
                        if (!/.(js|mjs)$/.test(filename) || filename.includes("node_modules")) {
                            continue;
                        }

                        const asset = compilation.getAsset(filename);
                        if (!asset) continue;

                        const sourceCode = asset.source.source(); // 获取文件源码字符串
                        if (typeof sourceCode !== 'string') continue; // 跳过非字符串源码

                        try {
                            // 使用 Babel 进行代码转换
                            const { code, map, ast, metadata } = await babel.transformAsync(sourceCode, {
                                plugins: [[transformAsyncWindowOpenBabelPlugin, this.babelPluginOptions]], // 应用自定义 Babel 插件并传入选项
                                filename, // 文件名,对 sourcemap 和错误信息很重要
                                sourceMaps: !!compiler.options.devtool, // 遵循 Webpack 的 devtool 设置来生成 sourcemap
                                ast: false, // 我们不需要返回 AST,除非其他插件需要
                                configFile: false, // 避免 Babel 查找项目级的配置文件
                                babelrc: false,    // 避免 Babel 查找 .babelrc 文件
                            });

                            // 如果 Babel 插件确实进行了转换并且生成了代码
                            if (metadata && metadata.transformedByAsyncWindowOpenPlugin && code) {
                                transformedCount++;
                                // 更新资源内容,如果存在 sourcemap 则使用 SourceMapSource
                                const newSource = map
                                    ? new sources.SourceMapSource(code, filename, map, sourceCode, asset.source.map(), true)
                                    : new sources.RawSource(code);
                                compilation.updateAsset(filename, newSource);
                            }
                        } catch (error) {
                            // 将 Babel 转换错误添加到 Webpack 的错误收集中
                            compilation.errors.push(
                                new Error(`${this.pluginName} Babel 转换失败 (文件: ${filename}):\n${error.message}\n${error.stack || ''}`)
                            );
                        }
                    }
                    
                    // 可以在构建日志中输出转换信息
                    // if (transformedCount > 0) {
                    //     console.log(`${this.pluginName}: 已成功转换 ${transformedCount} 个资源文件。`);
                    // }
                    callback(); // 异步钩子完成
                }
            );
        });
    }
}

module.exports = AsyncWindowOpenWebpackPluginCn; // 导出 Webpack 插件

代码解释:

  1. babel-plugin-transform-async-window-open-cn.js (Babel 插件) :

    • 访问者模式 (Visitor Pattern) : 使用 Babel 的访问者模式来查找 CallExpression (函数调用) 节点。

    • 事件监听器检测: 特别关注 element.addEventListener('click', handler) (以及类似的用户交互事件)。这是异步操作导致 window.open 调用的常见模式。

    • 异步上下文检测: 在事件处理函数内部,再次遍历以查找 window.open() 调用。它通过以下方式判断调用是否在“异步上下文”中:

      • 事件处理函数本身是否是 async 函数。
      • window.open() 调用是否在 Promise 链 (.then(), .catch(), .finally()) 的回调中。
      • window.open() 调用之前是否存在 await 表达式。
    • 代码转换: 如果找到这样的 window.open() 调用:

      • 在事件处理函数的开头注入代码,立即打开一个空白窗口 (const tempWin = window.open('', target, features);)。这里的 targetfeatures 来自原始的 window.open 调用。
      • 注入 JavaScript 将一个加载界面写入这个 tempWin。加载界面的 HTML 可以通过插件选项自定义。
      • 原始的 window.open(url, ...) 调用被替换为:如果 tempWin 有效且未关闭,则设置 tempWin.location.href = url;。否则,回退到原始的 window.open() 调用(这可能仍会被浏览器拦截,但作为一种备选)。
      • 整个事件处理器的原始主体被包裹在一个 try...catch 块中。catch 块确保如果在异步操作期间发生错误,预打开的 tempWin 会被关闭。
    • 唯一标识符: 使用 path.scope.generateUidIdentifier() 创建唯一的变量名(如 asyncOpenedWin, e)以避免命名冲突。

    • 可定制的加载屏幕: 加载屏幕的 HTML 和样式可以通过传递给 Babel 插件的选项进行自定义。

  2. AsyncWindowOpenWebpackPluginCn.js (Webpack 插件) :

    • apply(compiler) : Webpack 插件的入口点。

    • compilation.hooks.processAssets: 插件挂载到 Webpack 编译的 processAssets 阶段。这允许它在资源(如 JavaScript 文件)写入输出目录之前修改它们。

      • 注意: 为了性能和更好地与其他 Babel 转换集成,通常首选将此 Babel 插件配置为现有 babel-loader 的一部分。但是,如果项目中未使用 babel-loader,或者希望此转换在此特定阶段运行,则使用 processAssets 可以使插件更独立。
    • 资源处理: 遍历 JavaScript 资源。

    • Babel 转换: 对每个 JS 资源,以编程方式使用 @babel/core 来应用 transformAsyncWindowOpenBabelPlugin

    • Source Maps: 如果 Webpack 配置中启用了 source map,插件会尝试正确处理它们。

    • 选项: Webpack 插件构造函数接受选项(例如,用于自定义加载屏幕),这些选项随后会传递给 Babel 插件。

如何使用:

  1. 保存文件:

    • 将 Babel 插件代码保存为 babel-plugin-transform-async-window-open-cn.js
    • 将 Webpack 插件代码保存为 AsyncWindowOpenWebpackPluginCn.js
    • 确保它们位于 webpack.config.js 可以访问到的路径。
  2. 安装依赖:

    npm install --save-dev @babel/core
    # 或者
    yarn add --dev @babel/core
    
  3. 配置 Webpack (webpack.config.js) :

    const AsyncWindowOpenWebpackPluginCn = require('./path/to/AsyncWindowOpenWebpackPluginCn'); // 修改为你的插件路径
    
    module.exports = {
        // ... 其他 Webpack 配置
        plugins: [
            new AsyncWindowOpenWebpackPluginCn({
                // 可选: 自定义加载屏幕的文本和样式
                loadingScreen: {
                    title: '请稍候',
                    message: '内容正在努力加载中...',
                    bgColor: '#e0e0e0',       // 背景色
                    loaderColor: '#ff6347'    // 加载动画颜色 (番茄色)
                }
            }),
            // ... 其他插件
        ],
    };
    

重要注意事项和局限性:

  • 静态分析的复杂性: 可靠地检测所有“异步 window.open”场景非常困难。此插件专注于一种常见模式:事件处理器内部的异步操作后的 window.open。它可能无法捕获所有边缘情况(例如,如果异步逻辑被高度抽象,或者 window.open 是从一个深层嵌套的工具函数中调用的,其异步性质在事件处理器级别不明显)。

  • 依赖代码结构: 插件的有效性在很大程度上取决于你的代码是否遵循可识别的模式,如 addEventListener 内部包含异步操作。

  • Babel 集成:

    • 提供的 Webpack 插件使用 processAssets 并直接调用 Babel。如果你已经在使用 babel-loader,将 babel-plugin-transform-async-window-open-cn.js 添加到 babel-loader 的插件列表通常更高效。如果只需要 Babel 插件的功能,那么这个 Webpack 插件可能就不是必需的。

    • 如果你选择与 babel-loader 集成,你的 webpack.config.js 中相关规则可能如下所示:

      // ...
      module: {
          rules: [
              {
                  test: /.(js|jsx|ts|tsx)$/,
                  exclude: /node_modules/,
                  use: {
                      loader: 'babel-loader',
                      options: {
                          plugins: [
                              // 其他 Babel 插件...
                              [require.resolve('./path/to/babel-plugin-transform-async-window-open-cn.js'), {
                                  // Babel 插件的选项 (加载屏幕等)
                                  loadingScreen: { title: '自定义标题' }
                              }]
                          ]
                      }
                  }
              }
          ]
      }
      // ...
      

      在这种情况下,AsyncWindowOpenWebpackPluginCn 可能就不再需要了,除非它还执行其他任务。

  • 启发式检测: “异步上下文”的检测使用了一些启发式规则(检查 async 关键字、Promise 链、await)。这些规则通常有效,但在非常复杂的代码中可能会产生误报或漏报。

  • 回退机制: 转换后的代码包含一个回退机制:如果预打开窗口的策略失败(例如,tempWin 被用户关闭或发生意外错误),它会尝试执行原始的 window.open()。如果这个调用确实处于异步上下文中且没有用户手势,它仍然可能被浏览器拦截。

  • 测试: 请在你的代码库中彻底测试此插件,以确保其行为符合预期,并且不会引入意外的副作用。

这个解决方案提供了一种强大的方法来自动修复一个常见的弹出窗口拦截问题,但理解其底层机制和潜在限制至关重要。