实现一款无需重启服务切换代理地址的插件(webpack、vue/cli、vite)

271 阅读9分钟

项目中配置代理地址后(如webpack配置devServer.proxy后),每次想要切换远程地址,会有一些不方便的情况

  • 需要重启服务(不能接受)
  • 必须改动文件,会出现git的文件改动,一不小心就会提交到远端仓库和别人产生文件冲突
  • 想要同时测试多个服务器环境不可实现,只能一次性修改一个代理地址然后重启服务

为了提高开发效率和排查问题效率,可按照下方的代码实现在浏览器上提供一个remote参数即可直接动态切换proxy代理地址的功能,使用方式如:

http://localhost:8090?remote=http://test.com

此方案有以下优点

  • 开发环境生效,生产环境不生效
  • 切换代理地址不需要重新前端服务
  • 不产生文件改动
  • 可同事测试多个代理地址,很简单,打开多个浏览器标签页传递remote参数为不同的服务器地址即可

远程代理插件

会实现三种版本的插件,webpack、vue/cli、vite;

webpack插件不能应用于vue/cli项目中,因为vue/cli项目的devServer配置不会共享到webpack中,webpack插件的方案是借助http-proxy-middleware的router属性(也就是devServer中的router属性)实现的功能,所以在vue/cli中想要实现插件,需要重新封装vue/cli插件

vite的代理功能由于底层用的是http-proxy,所以也需要重新实现一套功能

Webpack插件

插件实现

const urlHelper = require("url");

// url链接用于远程代理的参数名
const REMOTE_KEY = "remote";
// 使用本地proxy的target
const PROXY_STR = "proxy";

/**
 * webpack配置devServer.proxy后,每次想要切换远程地址,会有一些不方便的情况
 *
 * 需要重启服务(不能接受)
 * 必须改动文件,会出现git的文件改动,一不小心就会提交到远端仓库和别人产生文件冲突
 * 想要同时测试两个服务器环境就得不同的切换
 *
 * 为了提高开发效率和排查问题效率,可按照下方的代码实现在浏览器上提供一个remote参数即可直接动态切换proxy代理地址的功能,使用方式如:
 *
 * example: http://localhost:8090?remote=http://test.com
 *
 * 实现动态代理设计,登录页面登录的时候如果携带remote参数,
 * 那么就会使用remote参数的值作为代理地址
 * 1. 登录成功后,会将remote参数的值保存到闭包参数中,
 *
 * @param {*} options.customTarget 自定义处理远程代理地址,传递一个函数
 * @param {*} options.remoteParamName remote参数名
 * @param {*} options.cacheRemote 是否缓存代理地址,如果开启缓存,切换页面后如果url没有了remote参数,下次请求的时候该参数还是会生效(这样做的原因是,有些项目业务页面跳转不会携带参数,导致remote会丢失,除非重新传递remote参数重置)
 * @returns
 */
class RemoteProxyPlugin {
  constructor(options = {}) {
    this.customTarget =
      options.customTarget ||
      function (curTarget, _req) {
        return curTarget;
      }; // 默认空函数
    this.remoteParamName = options.remoteParamName || REMOTE_KEY;
    this.remoteUrlMap = {};
    this.cacheRemote =
      options.cacheRemote !== undefined ? options.cacheRemote : true;
  }

  apply(compiler) {
    if (process.env.NODE_ENV !== "development") {
      return;
    }
    const me = this;
    compiler.hooks.environment.tap("RemoteProxyPlugin", () => {
      const devServer = compiler.options.devServer;
      if (devServer && devServer.proxy) {
        Object.keys(devServer.proxy).forEach((key, index) => {
          const proxyConfig = devServer.proxy[key];

          if (typeof proxyConfig == "string") {
            devServer.proxy[key] = {
              target: proxyConfig,
            };
          }
          me.remoteUrlMap[key] = me.formatRemoteUrl(
            devServer.proxy[key].target
          )[0];
          me.consoleUrl("初始化", key, me.remoteUrlMap[key]);

          // 处理 router 逻辑
          const originalRouter = devServer.proxy[key].router;
          devServer.proxy[key].router = (req) => {
            const originalTarget = originalRouter
              ? originalRouter(req)
              : undefined;
            let remoteUrl = me.getRemoteUrl(req)
            if (remoteUrl) {
              remoteUrl = remoteUrl[index] || remoteUrl[0];
            }
            let newRemoteUrl =
              originalTarget || me.customTarget(remoteUrl, req);

            // 以下两种情况才会还原代理地址为原来的target
            // 1)如果remoteUrl为空并且没有开启缓存时,
            // 2)配置了?remote=proxy,需要使用本地proxy配置的target
            if (
              (!newRemoteUrl && !me.cacheRemote) ||
              newRemoteUrl === PROXY_STR
            ) {
              newRemoteUrl = devServer.proxy[key].target;
            }
            if (newRemoteUrl && newRemoteUrl !== me.remoteUrlMap[key]) {
              me.remoteUrlMap[key] = newRemoteUrl;
              me.consoleUrl("切换", key, me.remoteUrlMap[key]);
            }
            return me.remoteUrlMap[key];
          };
        });
      }
    });
  }

  consoleUrl(operText, key, url) {
    const boldBlue = `\x1b[34m`; // ANSI escape codes for bold blue
    const reset = `\x1b[0m`; // ANSI escape code to reset styles
    console.log(
      "🚀 " +
        boldBlue +
        "[RemoteProxyPlugin] " +
        key +
        " 远程代理地址已" +
        operText +
        "为:" +
        url +
        reset
    );
  }

  getRemoteUrl(req) {
    // 解析referer参数,获取remoteUrl
    return this.formatRemoteUrl(
      this.getParseParam(req.headers.referer, this.remoteParamName)
    );
  }

  formatRemoteUrl(remoteUrl) {
    if (!remoteUrl) return remoteUrl
    // 以","分割,说明传递的是多个地址
    const urlArr = remoteUrl.split(',')
    return urlArr.map((item) => (item.endsWith('/') ? item.slice(0, -1) : item))
  }

  /**
   * 获取url的query参数
   * @param {*} url
   * @param {*} key
   * @returns
   */
  getParseParam(url, key) {
    const queryObject = urlHelper.parse(url, true).query;
    return queryObject[key];
  }
}

module.exports = RemoteProxyPlugin;

注册插件

在webpack.config.js中注册这个插件

const PROXY_TARGET = "http://test.com";

module.exports = {
  devServer: {
    port: 8090,
    open: true,
    proxy: {
      '/api': {
        changeOrigin: true,
        target: PROXY_TARGET,
      }
    },
  },
  plugins: [
    new RemoteProxyPlugin(), // 动态远程代理
    // ... other
  ],
  // ... other
};

vue/cli插件

插件实现

const urlHelper = require("url");

// 是否启用日志(打印代理地址变动信息)
const logEnable = true;

// 使用本地proxy的target
const PROXY_STR = "proxy";

// remote参数名
const remoteParamName = "remote";

// 是否缓存代理地址,如果开启缓存,切换页面后如果url没有了remote参数,下次请求的时候该参数还是会生效(这样做的原因是,有些项目业务页面跳转不会携带参数,导致remote会丢失,除非重新传递remote参数重置)
const cacheRemote = true;

// 自定义处理远程代理地址
const customTarget = function (curTarget, _req) {
  return curTarget;
};

/**
 * 配置devServer.proxy后,每次想要切换远程地址,会有一些不方便的情况
 *
 * 需要重启服务(不能接受)
 * 必须改动文件,会出现git的文件改动,一不小心就会提交到远端仓库和别人产生文件冲突
 * 想要同时测试两个服务器环境就得不同的切换
 *
 * 为了提高开发效率和排查问题效率,可按照下方的代码实现在浏览器上提供一个remote参数即可直接动态切换proxy代理地址的功能,使用方式如:
 *
 * example: http://localhost:8090?remote=http://test.com
 *
 * 实现动态代理设计,登录页面登录的时候如果携带remote参数,
 * 那么就会使用remote参数的值作为代理地址
 *
 * @returns
 */
function RemoteProxyPlugin(api, options) {
  if (process.argv[2] !== "serve" || process.env.NODE_ENV !== "development") {
    return;
  }

  const proxy = JSON.parse(JSON.stringify(options.devServer.proxy));
  const remoteUrlMap = {};

  if (proxy) {
    Object.keys(proxy).forEach((key, index) => {
      const proxyConfig = proxy[key];

      if (typeof proxyConfig == "string") {
        proxy[key] = {
          target: proxyConfig,
        };
      }
      remoteUrlMap[key] = formatRemoteUrl(proxy[key].target)[0];
      consoleUrl("初始化", key, remoteUrlMap[key], true);

      // 处理 router 逻辑
      const originalRouter = proxy[key].router;
      proxy[key].router = (req) => {
        const originalTarget = originalRouter ? originalRouter(req) : undefined;
        let remoteUrl = getRemoteUrl(req);
        if (remoteUrl) {
          remoteUrl = remoteUrl[index] || remoteUrl[0];
        }
        let newRemoteUrl = originalTarget || customTarget(remoteUrl, req);

        // 如果remoteUrl为空时,如果没有开启缓存,才会还原代理地址为原来的target
        if (!newRemoteUrl && !cacheRemote || newRemoteUrl === PROXY_STR) {
          newRemoteUrl = proxy[key].target;
        }
        if (newRemoteUrl && newRemoteUrl !== remoteUrlMap[key]) {
          remoteUrlMap[key] = newRemoteUrl;
          consoleUrl("切换", key, remoteUrlMap[key]);
        }
        return remoteUrlMap[key];
      };
    });
  }
  options.devServer.proxy = proxy;
}

function consoleUrl(operText, key, url, forceLog = false) {
  if (!logEnable && !forceLog) {
    return;
  }
  const boldBlue = `\x1b[34m`; // ANSI escape codes for bold blue
  const reset = `\x1b[0m`; // ANSI escape code to reset styles
  console.log(
    "🚀 " +
      boldBlue +
      "[RemoteProxyPlugin] " +
      key +
      " 远程代理地址已" +
      operText +
      "为:" +
      url +
      reset
  );
}

function getRemoteUrl(req) {
  // 解析referer参数,获取remoteUrl
  return formatRemoteUrl(getParseParam(req.headers.referer, remoteParamName));
}

function formatRemoteUrl(remoteUrl) {
  if (!remoteUrl) return remoteUrl;
  // 以","分割,说明传递的是多个地址
  const urlArr = remoteUrl.split(",");
  return urlArr.map((item) => (item.endsWith("/") ? item.slice(0, -1) : item));
}

/**
 * 获取url的query参数
 * @param {*} url
 * @param {*} key
 * @returns
 */
function getParseParam(url, key) {
  const queryObject = urlHelper.parse(url, true).query;
  return queryObject[key];
}

module.exports = RemoteProxyPlugin;


注册插件

vue/cli的本地插件需要再package.json中引入,在vuePlugins.service中配置本地插件的所在相对路径即可,比如remoteProxyPlugin的相对路径是:scripts/plugins/remoteProxyPlugin.js

{
  "name": "xxx",
  "version": "1.0.0",
  "main": "index.html",
  "license": "MIT",
  "scripts": {},
  "vuePlugins": {
    "service": [
      "scripts/plugins/remoteProxyPlugin"
    ]
  },
}

vite插件

插件实现

import httpProxy from 'http-proxy'

const REMOTE_KEY = 'remote'
// 使用本地proxy的target
const PROXY_STR = 'proxy'
// 默认是否开启代理缓存
const PROXY_CACHE_REMOTE = true
// 创建代理实例
const proxy = httpProxy.createProxyServer()

/**
 * webpack配置devServer.proxy后,每次想要切换远程地址,会有一些不方便的情况
 *
 * 需要重启服务(不能接受)
 * 必须改动文件,会出现git的文件改动,一不小心就会提交到远端仓库和别人产生文件冲突
 * 想要同时测试两个服务器环境就得不同的切换
 *
 * 为了提高开发效率和排查问题效率,可按照下方的代码实现在浏览器上提供一个remote参数即可直接动态切换proxy代理地址的功能,使用方式如:
 *
 * example: http://localhost:8090?remote=http://test.com
 *
 * 实现动态代理设计,登录页面登录的时候如果携带remote参数,
 * 那么就会使用remote参数的值作为代理地址
 * 1. 登录成功后,会将remote参数的值保存到闭包参数中,
 *
 * @param {*} options.customTarget 自定义处理远程代理地址,传递一个函数
 * @param {*} options.remoteParamName remote参数名
 * @param {*} options.cacheRemote 是否缓存代理地址,如果开启缓存,切换页面后如果url没有了remote参数,下次请求的时候该参数还是会生效(这样做的原因是,有些项目业务页面跳转不会携带参数,导致remote会丢失,除非重新传递remote参数重置)
 * @returns
 */
export default function createRemoteProxyPlugin(options = {}) {
  return {
    name: 'vite-plugin-remote-proxy',
    apply: 'serve',
    configureServer(server) {
      const proxyCfg = server.config.server.proxy
      let remoteUrlMap = new Map()
      // 修改 devServer 配置
      server.middlewares.use((req, res, next) => {
        const proxyKeys = Object.keys(proxyCfg)
        let remoteUrl, proxyCfgItem
        for (let index = 0; index < proxyKeys.length; index++) {
          const context = proxyKeys[index]
          let curProxyItem = proxyCfg[context]
          const curRemoteUrl = getRemoteUrl(req, remoteUrlMap, context, index, proxyCfg, options)
          if (curProxyItem && doesProxyContextMatchUrl(context, req.url)) {
            if (typeof curProxyItem === 'string') {
              curProxyItem = { target: curProxyItem, changeOrigin: true }
            }
            remoteUrl = curRemoteUrl
            proxyCfgItem = curProxyItem
          }
        }

        if (remoteUrl && proxyCfgItem && proxyWeb({ req, res, remoteUrl, proxyCfgItem })) {
          return
        }

        next()
      })
    },
  }
}

function proxyWeb({ req, res, remoteUrl, proxyCfgItem } = options) {
  // 配置了?remote=proxy,不需要特殊代理,直接使用本地proxy配置的target
  if (remoteUrl === PROXY_STR) {
    return false
  }

  const proxyOptions = {
    ...proxyCfgItem,
    target: remoteUrl,
  }
  if (proxyCfgItem.rewrite) {
    req.url = proxyCfgItem.rewrite(req.url)
  }
  // 实现代理
  proxy.web(req, res, proxyOptions, (error) => {
    console.error('Proxy error:', error)
    res.writeHead(500, { 'Content-Type': 'text/plain' })
    res.end('[vite-plugin-remote-proxy] Proxy error occurred')
  })
  return true
}

function doesProxyContextMatchUrl(context, url) {
  return (context[0] === '^' && new RegExp(context).test(url)) || url.startsWith(context)
}

/**
 *
 * @param {*} req 请求对象
 * @param {*} remoteUrlMap 缓存的远程代理地址Map
 * @param {*} context 当前匹配的proxy的key
 * @param {*} index 当前匹配的proxy的key的索引位置
 * @param {*} proxyCfg proxy配置
 * @param {*} options 配置的对象
 * @returns
 */
function getRemoteUrl(req, remoteUrlMap, context, index, proxyCfg, options = {}) {
  const {
    customTarget = (curTarget, _req, _proxyCfg) => curTarget,
    remoteParamName = REMOTE_KEY,
    cacheRemote = PROXY_CACHE_REMOTE,
  } = options

  const url = req.headers.referer || req.originalUrl || ''
  // 解析referer参数,获取remoteUrl
  const remoteUrls = formatRemoteUrl(getParseParam(url, remoteParamName))

  let remoteUrl
  if (remoteUrls) {
    remoteUrl = customTarget(remoteUrls[index] || remoteUrls[0], req, proxyCfg)

    // 将 remote URL 缓存到内存中
    if (remoteUrl && remoteUrl !== remoteUrlMap.get(context)) {
      consoleUrl('切换', context, remoteUrl)
      remoteUrlMap.set(context, remoteUrl)
    }
  } else if (cacheRemote && remoteUrlMap.has(context)) {
    // 使用缓存的代理地址
    remoteUrl = remoteUrlMap.get(context)
  }
  return remoteUrl
}

function consoleUrl(operText, key, url) {
  const boldBlue = `\x1b[34m` // ANSI escape codes for bold blue
  const reset = `\x1b[0m` // ANSI escape code to reset styles
  console.log(
    '🚀 ' + boldBlue + '[vite-plugin-remote-proxy] ' + key + ' 远程代理地址已' + operText + '为:' + url + reset,
  )
}

function getParseParam(url, key) {
  return new URLSearchParams(url.split('?')[1]).get(key)
}

function formatRemoteUrl(remoteUrl) {
  if (!remoteUrl) return remoteUrl
  // 以","分割,说明传递的是多个地址
  const urlArr = remoteUrl.split(',')
  return urlArr.map((item) => (item.endsWith('/') ? item.slice(0, -1) : item))
}

注册插件

const PROXY_TARGET = "http://test.com";

export default defineConfig(({ mode }) => {
  const envConfig = loadEnv(mode, process.cwd())
  const isDev = envConfig.VITE_ENV === 'development'

  return {
    server: {
      port: 9528,
      host: '0.0.0.0',
      proxy: {
        '/api': {
          changeOrigin: true,
          target: PROXY_TARGET,
          rewrite: (path) => path.replace(/^\/api/, '/'),
        }
      },
    },
    plugins: [
      createVuePlugin(),
      // ... other
    ],
    // ... other
  }
})