30分钟开发Vue 3插入客制化脚本插件

1. 引言

嘿,大家好,我是星辰编程理财,今天要和大家聊聊一个让我颇有心得的话题:Vue组件的客制化方式之前-“埋点”。或许你也曾为埋点这个问题烦恼过,在历史的包袱下加上技术债,我们需要实现一套客制化的脚本,埋点成了我们选择的方向。

为什么要进行 Vue 组件的埋点?

曾经,我们在没有引入Vue框架是,代码里面到处都是埋点,随着业务的增长和技术迭代,需要向Vue框架下过度。于是,在这个痛点的驱使下,开始对迁移和兼容代码。通过我们统计分析发现,会有一部分公共埋点的位置。如果每个文件都去加上通用埋点代码,工作量大并和业务代码耦合。于是采用编译时插入代码的方式实现。

为何使用AST?

最初,我们尝试了简单的字符串匹配,想着解决问题应该不难。但是,特征字符串太少,简单匹配往往事倍功半,不好扩展,而且容易翻车。于是AST算是一个稳妥,虽然不太熟悉开发时间要长一些。

2. AST 技术调研

首先,我开始进行了 AST 技术的调研,由于技术上我们使用Vite作为开发及打包工具。于是直接问GTP:

Q: 开发vite插件,如何在插件中用ast分析代码?请简要回答。
A:
在Vite插件中使用AST(抽象语法树)来分析代码可以通过以下步骤完成:

  1. 安装依赖:首先,在你的插件项目中安装 @babel/parser 或其他适用的AST解析器。
    npm install @babel/parser --save-dev
  2. 引入解析器:在你的插件代码中引入AST解析器。
    const parser = require('@babel/parser');
  3. 解析代码:使用解析器来解析你感兴趣的代码文件。
const code = fs.readFileSync('path/to/your/file.js', 'utf-8');
const ast = parser.parse(code, {
    sourceType: 'module',
    plugins: ['jsx']  // 如果你的代码中包含JSX
});
  1. 遍历AST:通过遍历AST节点,你可以获取代码中的各种信息。
traverse(ast, {
    // 在这里添加处理不同类型节点的逻辑
});

你可能需要使用 @babel/traverse 这个插件来实现遍历。

通过上面回答,已了解需要@babel/parser与@babel/traverse实现。我们再向文心一言侧面印证一下常规方案是不是采用@babel/parser与@babel/traverse即可。

3. 具体需求描述

既然已经确认用@babel/parser、@babel/traverse作为AST解析和处理库。接下来就是整理具体的需求描述,需求描述一定要整理好,因为我并不打算直接开发。而是直接基于需求问GTP。

Q:在开发Vite插件时,使用@babel/traverse遍历AST如何找到选项式API的mounted方法?如何找到组合式API的setup方法中直接包含的最后一个return语句,需要忽略setup方法内部的子方法的return语句。
A:
在开发Vite插件时,使用@babel/traverse遍历AST可以找到选项式API的mounted方法和组合式API的setup方法中直接包含的最后一个return语句。下面是具体的步骤:

  1. 找到选项式API的mounted方法:
    在AST遍历过程中,可以通过匹配对象方法节点中键名为mounted的节点来找到选项式API的mounted方法。具体步骤如下:

    traverse(ast, {
        ObjectMethod(path) {
            if (
                path.node.key.type === 'Identifier' &&
                path.node.key.name === 'mounted'
            ) {
                // 处理选项式API的mounted方法逻辑
                // path.node 包含了该方法的完整信息
            }
        }
    });
    
  2. 找到组合式API的setup方法中的最后一个return语句:
    对于组合式API的setup方法,需要在遍历过程中找到最后一个return语句,但要忽略setup方法内部的子方法的return语句。可以通过递归遍历ReturnStatement节点来实现。具体步骤如下:

    traverse(ast, {
        ObjectMethod(path) {
            if (
                path.node.key.type === 'Identifier' &&
                path.node.key.name === 'setup'
            ) {
                let lastReturnIndex = -1;
                path.traverse({
                    Function(path) {
                        // 忽略子方法内部的return语句
                        path.skip();
                    },
                    ReturnStatement(returnPath) {
                        // 更新最后一个return语句的位置
                        lastReturnIndex = returnPath.node.start;
                    }
                });
                // 处理最后一个return语句逻辑
                if (lastReturnIndex >= 0) {
                    // 在这里可以处理最后一个return语句
                }
            }
        }
    });
    

通过这些步骤,你可以在开发Vite插件时使用AST找到选项式API的mounted方法和组合式API的setup方法中直接包含的最后一个return语句。

4. 整理开发

通过提需求给GTP的回答,我们大概知道了这类需求开发实现的基本套路。再结合vite插件API文档,基本可以写一个插件了。

具体我整理后的插件简单版代码如下🔍:

import * as parser from '@babel/parser';
import traverse from '@babel/traverse';

const defaultConfig = {
    code: `;console.log("插入代码");`, // 具体插入代码
    include: (id) => id.includes('/src/pages/') && id.includes('.vue'), // 处理时包含
    exclude: (id) => id.includes('?vue&type') || id.includes('App.vue'), // 处理时不包含
};

/**
 * 对Vue文件指定位置插入一段代码
 * 用法:
 *  import vueScript from './plugins/plugin.js';
 *  // vite plugins
 *  plugins: [..., vueScript(), ...]
 * @param config
 * @returns {*|{transform(*, *): (*|undefined), name: string}}
 */
export default (config = defaultConfig) => {
    return {
        name: 'vue-insert-script',
        transform(code, id) {
            if ((config?.include(id) ?? true) && !config?.exclude(id)) {
                try {
                    let index = -1;
                    let insertCodes = [];
                    const ast = parser.parse(code, {
                        sourceType: 'module', // 指定代码类型,例如 'module' 或 'script'
                    });
                    traverse.default(ast, {
                        // 进入对象方法节点
                        ObjectMethod(path) {
                            // 选项api created、mounted
                            if (
                                path.node.key.type === 'Identifier' &&
                                ['created', 'mounted'].includes(path.node.key.name)
                            ) {
                                let index = path.node.end - 1; // created、mounted方法代码最后面插入
                                if (path.node.key.name === 'created') {
                                    insertCodes.push({
                                        code: config.code,
                                        index,
                                    });
                                }
                                if (path.node.key.name === 'mounted') {
                                    insertCodes.push({
                                        code: config.code,
                                        index,
                                    });
                                }
                            }
                            // 组合式api setup
                            if (path.node.key.type === 'Identifier' && path.node.key.name === 'setup') {
                                let setupIndex = -1;
                                path.traverse({
                                    Function(path) {
                                        path.skip();
                                    },
                                    ReturnStatement(returnPath) {
                                        setupIndex = returnPath.node.start - 1;  // return语句前面插入
                                    },
                                });
                                if (setupIndex >= 0) {
                                    insertCodes.push({
                                        code: config.code,
                                        index: setupIndex,
                                    });
                                }
                            }
                        },
                    });
                    if (insertCodes.length) {
                        return insertCodes
                            .sort((c1, c2) => c2.index - c1.index)
                            .reduce((code, item) => {
                                return code.substring(0, item.index) + item.code + code.substring(item.index);
                            }, code);
                    }
                } catch (e) {
                    console.log('报错文件ID:', id);
                    throw e;
                }
            }
        },
    };
};

测试

在测试阶段,我选择了一些代表性的组件进行抽样测试,并且确保了插件在各种场景下都能正常运行。效果如下:
选项式Api

组合式Api

5. 最后

在实践中,我发现某些特殊情况下的处理可能还不够完善,后面还需要一些优化。通过这次插件开发,我体会到 AST 的优势,AST 分析能够更精准、更灵活地操作代码、提高了代码的可扩展和维护性。AST 技术应用前景应该非常广泛,它为前端开发带来了更多可能性。