与AI的一次斗智斗勇

74 阅读9分钟

最近在工作过程中,遇到在页面初始化时,多个模块都请求同一个接口的场景,导致network面板中存在很多重复请求拖慢页面整体初始化时间

由于不想改动业务代码,所以考虑在请求层面做优化,即对于某个时间段内所有的幂等请求都返回同一个promise实例,这样只会有一个promise去发送网络请求,且请求完成后,通过该promise.then添加的回调都会被执行。

由于这个需求输入输出明确,且网上有大量类似案例,所以想通过AI来帮忙生成代码,以下是与豆包 斗智斗勇的全过程。

开始battle

image.png

import axios from 'axios';

// 用于存储请求的缓存对象
const requestCache = {};

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {Object} config - Axios 请求配置对象
 * @returns {Promise} - Axios 请求的 Promise
 */
function cachedAxios(config) {
    const url = config.url;
    // 如果缓存中已经存在该 URL 对应的 Promise,直接返回
    if (requestCache[url]) {
        return requestCache[url];
    }
    // 发起新的 Axios 请求
    const promise = axios(config)
     .then(response => {
            // 请求成功后,将响应数据存入缓存
            requestCache[url] = response;
            return response;
        })
     .catch(error => {
            // 请求失败后,也将错误信息存入缓存
            requestCache[url] = Promise.reject(error);
            return Promise.reject(error);
        });
    // 将 Promise 存入缓存
    requestCache[url] = promise;
    return promise;
}

很明显,这份代码没有考虑请求参数的情况,继续battle

image.png

import axios from 'axios';

// 用于存储请求的缓存对象
const requestCache = {};

/**
 * 生成唯一的缓存键
 * @param {Object} config - Axios 请求配置对象
 * @returns {string} - 唯一的缓存键
 */
function generateCacheKey(config) {
    let key = config.url;
    if (config.params) {
        // 将 params 转换为字符串并排序,确保相同参数顺序不同时键相同
        const sortedParams = Object.entries(config.params).sort((a, b) => a[0].localeCompare(b[0]));
        key += '?' + sortedParams.map(([k, v]) => `${k}=${v}`).join('&');
    }
    if (config.data) {
        // 将 body 数据转换为字符串
        key += JSON.stringify(config.data);
    }
    return key;
}

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {Object} config - Axios 请求配置对象
 * @returns {Promise} - Axios 请求的 Promise
 */
function cachedAxios(config) {
    const cacheKey = generateCacheKey(config);
    // 如果缓存中已经存在该键对应的 Promise,直接返回
    if (requestCache[cacheKey]) {
        return requestCache[cacheKey];
    }
    // 发起新的 Axios 请求
    const promise = axios(config)
      .then(response => {
            // 请求成功后,将响应数据存入缓存
            requestCache[cacheKey] = response;
            return response;
        })
      .catch(error => {
            // 请求失败后,也将错误信息存入缓存
            requestCache[cacheKey] = Promise.reject(error);
            return Promise.reject(error);
        });
    // 将 Promise 存入缓存
    requestCache[cacheKey] = promise;
    return promise;
}

可以看到,AI生成的代码中使用了一个全局的对象requestCache,会存在内存泄漏的风险,让AI自查一下

image.png

import axios from 'axios';

// 用于存储请求的缓存对象
const requestCache = {};

/**
 * 生成唯一的缓存键
 * @param {Object} config - Axios 请求配置对象
 * @returns {string} - 唯一的缓存键
 */
function generateCacheKey(config) {
    let key = config.url;
    if (config.params) {
        // 将 params 转换为字符串并排序,确保相同参数顺序不同时键相同
        const sortedParams = Object.entries(config.params).sort((a, b) => a[0].localeCompare(b[0]));
        key += '?' + sortedParams.map(([k, v]) => `${k}=${v}`).join('&');
    }
    if (config.data) {
        // 将 body 数据转换为字符串
        key += JSON.stringify(config.data);
    }
    return key;
}

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {Object} config - Axios 请求配置对象
 * @param {number} cacheDuration - 缓存持续时间(毫秒),默认 5 分钟
 * @returns {Promise} - Axios 请求的 Promise
 */
function cachedAxios(config, cacheDuration = 5 * 60 * 1000) {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    // 检查缓存中是否存在该键对应的 Promise 且未过期
    if (requestCache[cacheKey] && requestCache[cacheKey].expiry > now) {
        return requestCache[cacheKey].promise;
    }
    // 发起新的 Axios 请求
    const promise = axios(config)
     .then(response => {
            // 请求成功后,将响应数据存入缓存
            requestCache[cacheKey] = {
                promise: response,
                expiry: now + cacheDuration
            };
            return response;
        })
     .catch(error => {
            // 请求失败后,也将错误信息存入缓存
            requestCache[cacheKey] = {
                promise: Promise.reject(error),
                expiry: now + cacheDuration
            };
            return Promise.reject(error);
        });
    return promise;
}

可以看到AI添加了缓存时间的处理,但并没有清理过期的requestCache key,继续battle

image.png

// 代码同上
// 定期清除过期缓存的函数
function clearExpiredCache() {
    const now = Date.now();
    for (const key in requestCache) {
        if (requestCache.hasOwnProperty(key) && requestCache[key].expiry < now) {
            delete requestCache[key];
        }
    }
}

// 每 5 分钟执行一次清除过期缓存的操作
setInterval(clearExpiredCache, 5 * 60 * 1000);

AI添加了定时清除的逻辑,基本满足了我们的需求,来一份ts的代码

image.png

import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';

// 用于存储请求的缓存对象
const requestCache: { [key: string]: { promise: AxiosResponse | Promise<AxiosError>, expiry: number } } = {};

/**
 * 生成唯一的缓存键
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @returns {string} - 唯一的缓存键
 */
function generateCacheKey(config: AxiosRequestConfig): string {
    let key = config.url || '';
    if (config.params) {
        // 将 params 转换为字符串并排序,确保相同参数顺序不同时键相同
        const sortedParams = Object.entries(config.params).sort((a, b) => a[0].localeCompare(b[0]));
        key += '?' + sortedParams.map(([k, v]) => `${k}=${v}`).join('&');
    }
    if (config.data) {
        // 将 body 数据转换为字符串
        key += JSON.stringify(config.data);
    }
    return key;
}

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @param {number} cacheDuration - 缓存持续时间(毫秒),默认 5 分钟
 * @returns {Promise<AxiosResponse | AxiosError>} - Axios 请求的 Promise
 */
function cachedAxios(config: AxiosRequestConfig, cacheDuration: number = 5 * 60 * 1000): Promise<AxiosResponse | AxiosError> {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    // 检查缓存中是否存在该键对应的 Promise 且未过期
    if (requestCache[cacheKey] && requestCache[cacheKey].expiry > now) {
        return requestCache[cacheKey].promise;
    }
    // 发起新的 Axios 请求
    const promise = axios(config)
      .then((response: AxiosResponse) => {
            // 请求成功后,将响应数据存入缓存
            requestCache[cacheKey] = {
                promise: response,
                expiry: now + cacheDuration
            };
            return response;
        })
      .catch((error: AxiosError) => {
            // 请求失败后,也将错误信息存入缓存
            requestCache[cacheKey] = {
                promise: Promise.reject(error),
                expiry: now + cacheDuration
            };
            return Promise.reject(error);
        });
    return promise;
}

// 定期清除过期缓存的函数
function clearExpiredCache() {
    const now = Date.now();
    for (const key in requestCache) {
        if (requestCache.hasOwnProperty(key) && requestCache[key].expiry < now) {
            delete requestCache[key];
        }
    }
}

// 每 5 分钟执行一次清除过期缓存的操作
setInterval(clearExpiredCache, 5 * 60 * 1000);

可以看到AI生成的ts代码质量还是很不错的,我们复制到项目中看一下

image.png 额,啪啪打脸啊,让AI自查一下

image.png

import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';

// 用于存储请求的缓存对象
const requestCache: { [key: string]: { promise: Promise<AxiosResponse | AxiosError>, expiry: number } } = {};

/**
 * 生成唯一的缓存键
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @returns {string} - 唯一的缓存键
 */
function generateCacheKey(config: AxiosRequestConfig): string {
    let key = config.url || '';
    if (config.params) {
        // 将 params 转换为字符串并排序,确保相同参数顺序不同时键相同
        const sortedParams = Object.entries(config.params).sort((a, b) => a[0].localeCompare(b[0]));
        key += '?' + sortedParams.map(([k, v]) => `${k}=${v}`).join('&');
    }
    if (config.data) {
        // 将 body 数据转换为字符串
        key += JSON.stringify(config.data);
    }
    return key;
}

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @param {number} cacheDuration - 缓存持续时间(毫秒),默认 5 分钟
 * @returns {Promise<AxiosResponse | AxiosError>} - Axios 请求的 Promise
 */
function cachedAxios(config: AxiosRequestConfig, cacheDuration: number = 5 * 60 * 1000): Promise<AxiosResponse | AxiosError> {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    // 检查缓存中是否存在该键对应的 Promise 且未过期
    if (requestCache[cacheKey] && requestCache[cacheKey].expiry > now) {
        return requestCache[cacheKey].promise;
    }
    // 发起新的 Axios 请求
    const promise = axios(config)
     .then((response: AxiosResponse): AxiosResponse => {
            // 请求成功后,将响应数据存入缓存
            requestCache[cacheKey] = {
                promise: Promise.resolve(response),
                expiry: now + cacheDuration
            };
            return response;
        })
     .catch((error: AxiosError): AxiosError => {
            // 请求失败后,也将错误信息存入缓存
            requestCache[cacheKey] = {
                promise: Promise.reject(error),
                expiry: now + cacheDuration
            };
            return error;
        });
    return promise;
}

// 定期清除过期缓存的函数
function clearExpiredCache() {
    const now = Date.now();
    for (const key in requestCache) {
        if (requestCache.hasOwnProperty(key) && requestCache[key].expiry < now) {
            delete requestCache[key];
        }
    }
}

// 每 5 分钟执行一次清除过期缓存的操作
setInterval(clearExpiredCache, 5 * 60 * 1000);

image.png 可以看到,修改之后已经没有类型问题了,给AI点个赞👍

但我希望这个方法可以以工具函数的形式提供出去,这意味着这个文件很可能会被多次import,这样就会导致开启多个定时清除任务,浪费浏览器资源,继续让AI优化

image.png

image.png 很贴心,还给出了验证demo(莫不是以为我不懂!!!),看看它的解决方案

// 省略重复代码
let intervalId: ReturnType<typeof setInterval> | null = null;
function startCacheCleanup() {
    if (!intervalId) {
        intervalId = setInterval(clearExpiredCache, 5 * 60 * 1000);
    }
}

// 只在模块首次加载时启动一次
startCacheCleanup();

很棒,人工分析下AI写的代码

image.png

这块的处理,是在请求完成之后才写入缓存requestCache,很明显在并发请求的情况下,如果第一个请求未完成,后续的请求无法复用promise实例,不满足我们的要求。battle继续

image.png

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @param {number} cacheDuration - 缓存持续时间(毫秒),默认 5 分钟
 * @returns {Promise<AxiosResponse | AxiosError>} - Axios 请求的 Promise
 */
function cachedAxios(config: AxiosRequestConfig, cacheDuration: number = 5 * 60 * 1000): Promise<AxiosResponse | AxiosError> {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    // 如果缓存中已经存在该键对应的 Promise 且未过期
    if (requestCache[cacheKey] && requestCache[cacheKey].expiry > now) {
        return requestCache[cacheKey].promise;
    }

    // 创建一个占位的 Promise
    let pendingPromise: Promise<AxiosResponse | AxiosError>;
    if (!requestCache[cacheKey] || requestCache[cacheKey].expiry <= now) {
        pendingPromise = new Promise((resolve, reject) => {
            requestCache[cacheKey] = {
                promise: pendingPromise,
                expiry: now + cacheDuration
            };
        });
    } else {
        pendingPromise = requestCache[cacheKey].promise;
    }

    // 如果占位的 Promise 还没有结果,发起实际的请求
    if (!requestCache[cacheKey].promise || requestCache[cacheKey].expiry <= now) {
        axios(config)
         .then((response: AxiosResponse) => {
                const updatedPromise = Promise.resolve(response);
                requestCache[cacheKey].promise = updatedPromise;
                requestCache[cacheKey].expiry = now + cacheDuration;
                resolve(response);
            })
         .catch((error: AxiosError) => {
                const updatedPromise = Promise.reject(error);
                requestCache[cacheKey].promise = updatedPromise;
                requestCache[cacheKey].expiry = now + cacheDuration;
                reject(error);
            });
    }

    return pendingPromise;
}

image.png emmm... 感觉AI已经神志不清了,让它自查一下吧

image.png

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @param {number} cacheDuration - 缓存持续时间(毫秒),默认 5 分钟
 * @returns {Promise<AxiosResponse | AxiosError>} - Axios 请求的 Promise
 */
function cachedAxios(config: AxiosRequestConfig, cacheDuration: number = 5 * 60 * 1000): Promise<AxiosResponse | AxiosError> {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    // 如果缓存中已经存在该键对应的 Promise 且未过期
    if (requestCache[cacheKey] && requestCache[cacheKey].expiry > now) {
        return requestCache[cacheKey].promise;
    }

    // 创建一个占位的 Promise
    const pendingPromise = new Promise<AxiosResponse | AxiosError>((resolve, reject) => {
        requestCache[cacheKey] = {
            promise: pendingPromise,
            expiry: now + cacheDuration
        };
        axios(config)
         .then((response: AxiosResponse) => {
                const updatedPromise = Promise.resolve(response);
                requestCache[cacheKey].promise = updatedPromise;
                requestCache[cacheKey].expiry = now + cacheDuration;
                resolve(response);
            })
         .catch((error: AxiosError) => {
                const updatedPromise = Promise.reject(error);
                requestCache[cacheKey].promise = updatedPromise;
                requestCache[cacheKey].expiry = now + cacheDuration;
                reject(error);
            });
    });

    return pendingPromise;
}

这下应该没问题了,部署测试下

image.png 有点不对劲,点进去看看

image.png

cachedAxios函数逻辑应该有问题,会在某种情况下返回undefined,先让AI自查一下

image.png

image.png 看来它还挺自信的,人工介入下:

image.png

问题出在这一行,这段代码在requestCache中创建了一个占位promise,但注意此时pendingPromise还没有执行完成,赋值给requestCache[key].promiseundefined,所以后续(并发情况缓存命中的时候,返回的promiseundefined

AI点提示,看它能否修正

image.png

// ...省略重复代码
function cachedAxios(config: AxiosRequestConfig, cacheDuration: number = 5 * 60 * 1000): Promise<AxiosResponse | AxiosError> {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    // 如果缓存中已经存在该键对应的 Promise 且未过期
    const cached = requestCache[cacheKey];
    if (cached && cached.expiry > now) {
        // 再次确认 promise 是否已经被正确赋值
        if (cached.promise) {
            return cached.promise;
        } else {
            return cached.promise as Promise<AxiosResponse | AxiosError>;
        }
    }

    // ...省略重复代码
}

有点意思,居然能想到用as,这不自欺欺人么,battle继续

image.png

/**
 * 封装的带有缓存功能的 Axios 请求函数
 * @param {AxiosRequestConfig} config - Axios 请求配置对象
 * @param {number} cacheDuration - 缓存持续时间(毫秒),默认 5 分钟
 * @returns {Promise<AxiosResponse | AxiosError>} - Axios 请求的 Promise
 */
function cachedAxios(config: AxiosRequestConfig, cacheDuration: number = 5 * 60 * 1000): Promise<AxiosResponse | AxiosError> {
    const cacheKey = generateCacheKey(config);
    const now = Date.now();
    let cached = requestCache[cacheKey];
    if (cached && cached.expiry > now) {
        return cached.promise;
    }

    // 创建一个占位的 Promise
    const newPromise = new Promise<AxiosResponse | AxiosError>((resolve, reject) => {
        axios(config)
         .then((response: AxiosResponse) => {
                const updatedPromise = Promise.resolve(response);
                requestCache[cacheKey] = {
                    promise: updatedPromise,
                    expiry: now + cacheDuration
                };
                resolve(response);
            })
         .catch((error: AxiosError) => {
                const updatedPromise = Promise.reject(error);
                requestCache[cacheKey] = {
                    promise: updatedPromise,
                    expiry: now + cacheDuration
                };
                reject(error);
            });
    });

    if (!cached || cached.expiry <= now) {
        requestCache[cacheKey] = {
            promise: newPromise,
            expiry: now + cacheDuration
        };
    }

    return newPromise;
}

应该没什么问题了,保险期间,让AI再自证一下:

image.png

image.png 逻辑自洽,验证代码也没啥问题,勉强给AI点个赞吧

image.png

后续

目前已经正常发布到测试环境,正好借一轮验收的机会验证下,后续有问题再补充。

ps: AI代码的生成很大程度受限于上下文信息的完整性,往往要通过多轮对话来反复验证其生成代码的准确性,但对开发效率的提升确实很明显。