【前端效率工具】再也不用 APIfox 联调!零侵入 Mock,全程不改代码、不开代理

上周四下午,我正在调样式,突然电脑风扇开始咆哮。

打开任务管理器:内存占用 15.5G / 16G

我人傻了,16G 内存开发个前端项目还能卡?

仔细一看:

  • VSCode:2.1G(跑着项目,正常)
  • Chrome 30 个标签页:4.5G(掘金、Stack Overflow、GitHub、文档...能理解)
  • APIfox:1.5G(???)
  • 微信 + 企业微信:1.1G(没办法,工作要用)
  • 其他若干进程...:...

APIfox这一个工具,占了 1.5G?

关键是,我只是想 Mock 几个接口而已,为什么要:

  • 装一个 500M+ 的客户端
  • 开代理(还得记得关)
  • 来回切窗口(浏览器 → APIfox → VSCode)
  • 占用 1.5G 内存

我试过其他方案:

  • 在代码里写死 Mock 数据 → 有时候忘记删掉,上线差点带着假数据冲进生产
  • 用 Mock.js → 配置太麻烦,还要改代码

我想要的是:不装客户端、不改代码、不开代理、不耗内存、一键切换 Mock 开关 。 于是,我花了一个周末,做了一个chrome浏览器插件。

现在mock不用 APIfox,内存占用降了不少,电脑顺多了,浏览器里一键 Mock,调试又快又省心。

效果演示: 下面是插件在拦截 fetch/xhr 请求并返回 Mock 数据时的演示:

插件功能预览

  • 拦截页面的 fetchxhr 请求
  • 规则支持模糊匹配和完全匹配
  • 支持按 HTTP 方法过滤(GET/POST/PUT/DELETE)
  • 自定义返回 JSON 数据
  • 持续优化中:性能、匹配规则和用户体验会不断迭代,欢迎反馈与建议

根本原理:劫持浏览器的网络请求方法

一句话总结

在网页加载前,偷偷替换掉浏览器原生的 fetch XMLHttpRequest ,让所有网络请求先经过我们的"检查站",符合规则的就返回假数据,不符合的就放行。

原理:

sql 复制代码
正常情况:
网页代码 → fetch('/api/user') → 浏览器发送真实请求 → 服务器

插件介入后:
网页代码 → fetch('/api/user') 
           ↓
       我们的假 fetch(检查是否需要 Mock)
           ↓
    需要 Mock?
    ├─ 是 → 直接返回假数据 ✅
    └─ 否 → 调用真正的 fetch 发送请求 → 服务器

具体实现(简化版)

js 复制代码
// 1. 保存原始方法
const 真正的fetch = window.fetch;

// 2. 替换成我们的方法
window.fetch = function(url) {
  // 3. 检查是否需要 Mock
  if (url.includes('/api/user')) {
    console.log('拦截成功!返回假数据');
    return Promise.resolve({
      json: () => ({ name: 'Mock User' })
    });
  }
  
  // 4. 不需要 Mock,调用原始方法
  return 真正的fetch(url);
};

关键点:

  • 必须先保存原始方法,否则无法发送真实请求
  • 必须在网页加载前执行,否则拦截不到早期请求
  • XMLHttpRequest 同理,重写 opensend 方法

项目结构

bash 复制代码
quick-mock/
├── manifest.json      # 插件配置文件
├── popup.html         # 弹窗页面
├── popup.css          # 弹窗样式
├── popup.js           # 弹窗逻辑
├── content.js         # 内容脚本 
└── injected.js        # 注入脚本 

🚀 浏览项目的完整代码可以点击这里 github.com/Teernage/qu...,如果对你有帮助欢迎Star。

文件分工:6 个文件的角色

界面层(用户交互)

1. popup.html - 插件的"脸面"- 作用:用户点击插件图标看到的弹窗界面

  • 包含:输入框、按钮、规则列表
html 复制代码
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <link rel="stylesheet" href="popup.css" />
  </head>
  <body>
    <div class="header">
      <div>
        <h1>API Mock</h1>
        <p>轻量级接口模拟工具</p>
      </div>
      <button class="btn-open-tab" id="openTab">在新标签页中打开</button>
    </div>

    <div class="container">
      <div class="card">
        <div class="input-group">
          <label>URL 匹配规则</label>
          <div class="url-row">
            <select id="matchMode" class="match-mode-select">
              <option value="contains">包含</option>
              <option value="exact">完整匹配</option>
            </select>
            <input id="url" placeholder="输入 URL 或关键词" />
          </div>
        </div>

        <div class="input-group">
          <label>请求方法</label>
          <select id="method">
            <option value="ALL">ALL</option>
            <option value="GET">GET</option>
            <option value="POST">POST</option>
            <option value="PUT">PUT</option>
            <option value="DELETE">DELETE</option>
          </select>
        </div>

        <div class="input-group">
          <label>Mock 数据 (JSON)</label>
          <textarea id="data" placeholder='{"code": 0, "data": {}}'></textarea>
        </div>

        <div class="btn-group">
          <button id="add" class="btn-primary">添加规则</button>
          <button id="clear" class="btn-secondary">清空全部</button>
        </div>
      </div>

      <div class="rules-header">
        <h2>已添加规则</h2>
        <span class="rules-count" id="count">0 条</span>
      </div>

      <div id="rules"></div>
    </div>

    <script src="popup.js"></script>
  </body>
</html>

2. popup.css - 界面样式

css 复制代码
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 480px;
  min-height: 480px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
    sans-serif;
  background: #fafafa;
  color: #1d1d1f;
  font-size: 13px;
}

.header {
  padding: 20px 18px 18px;
  background: transparent;
  display: flex;
  justify-content: space-between;
  align-items: start;
}

.header h1 {
  font-size: 18px;
  font-weight: 600;
  letter-spacing: -0.3px;
  color: white;
  background: linear-gradient(135deg, #6b7cff 0%, #8b9aff 100%);
  display: inline-block;
  padding: 2px 11px;
  border-radius: 10px;
  margin-bottom: 6px;
}

.header p {
  font-size: 12px;
  color: #8e8e93;
  font-weight: 400;
  margin-left: 2px;
}

.btn-open-tab {
  padding: 6px 12px;
  background: white;
  color: #6b7cff;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  font-size: 11px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
  white-space: nowrap;
}

.btn-open-tab:hover {
  background: #f5f5f7;
  border-color: #6b7cff;
  transform: translateY(-1px);
  box-shadow: 0 2px 8px rgba(107, 124, 255, 0.15);
}

.btn-open-tab:active {
  transform: translateY(0);
}

.container {
  padding: 0 14px 14px;
}

.card {
  background: white;
  border-radius: 10px;
  padding: 14px;
  margin-bottom: 10px;
  border: 1px solid #e8e8e8;
}

.input-group {
  margin-bottom: 10px;
}

.input-group:last-of-type {
  margin-bottom: 12px;
}

.input-group label {
  display: block;
  font-size: 12px;
  font-weight: 500;
  color: #8e8e93;
  margin-bottom: 5px;
}

/* URL 输入行 */
.url-row {
  display: flex;
  gap: 6px;
  align-items: center;
}

.match-mode-select {
  width: 85px;
  flex-shrink: 0;
  border: 1px solid #e0e0e0;
  border-radius: 7px;
  padding: 8px 6px;
  font-size: 12px;
  background: white;
  color: #1d1d1f;
  cursor: pointer;
  transition: all 0.2s;
}

.match-mode-select:hover {
  border-color: #6b7cff;
}

.match-mode-select:focus {
  outline: none;
  border-color: #6b7cff;
  box-shadow: 0 0 0 3px rgba(107, 124, 255, 0.1);
}

input,
textarea,
select {
  width: 100%;
  border: 1px solid #e0e0e0;
  border-radius: 7px;
  padding: 8px 10px;
  font-size: 13px;
  background: white;
  color: #1d1d1f;
  transition: all 0.2s;
}

input:focus,
textarea:focus,
select:focus {
  outline: none;
  border-color: #6b7cff;
  box-shadow: 0 0 0 3px rgba(107, 124, 255, 0.1);
}

textarea {
  min-height: 80px;
  resize: vertical;
  font-family: 'SF Mono', Monaco, Consolas, monospace;
  font-size: 12px;
  line-height: 1.5;
}

.btn-group {
  display: flex;
  gap: 8px;
}

.btn-primary,
.btn-secondary {
  flex: 1;
  padding: 9px 0;
  border: none;
  border-radius: 7px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-primary {
  background: linear-gradient(135deg, #6b7cff 0%, #8b9aff 100%);
  color: white;
}

.btn-primary:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(107, 124, 255, 0.3);
}

.btn-primary:active {
  transform: translateY(0);
}

.btn-secondary {
  background: #f5f5f7;
  color: #1d1d1f;
}

.btn-secondary:hover {
  background: #e8e8ea;
}

.rules-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 4px 8px;
}

.rules-header h2 {
  font-size: 14px;
  font-weight: 600;
  color: #1d1d1f;
}

.rules-count {
  font-size: 11px;
  color: #8e8e93;
  background: #f5f5f7;
  padding: 2px 8px;
  border-radius: 10px;
}

#rules {
  max-height: 300px;
  overflow-y: auto;
}

.rule-item {
  background: white;
  border: 1px solid #e8e8e8;
  border-radius: 8px;
  padding: 10px;
  margin-bottom: 8px;
  transition: all 0.2s;
}

/*  添加禁用状态样式 */
.rule-item.disabled {
  opacity: 0.5;
}

.rule-item:hover {
  border-color: #6b7cff;
  box-shadow: 0 2px 8px rgba(107, 124, 255, 0.1);
}

.rule-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 6px;
}

.rule-url {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: #1d1d1f;
  flex: 1;
  min-width: 0;
}

.rule-url span:last-child {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.method-badge {
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 10px;
  font-weight: 600;
  flex-shrink: 0;
}

.method-all {
  background: #e0e7ff;
  color: #4338ca;
}

.method-get {
  background: #d1fae5;
  color: #065f46;
}

.method-post {
  background: #fef3c7;
  color: #92400e;
}

.method-put {
  background: #dbeafe;
  color: #1e40af;
}

.method-delete {
  background: #fee2e2;
  color: #991b1b;
}

.match-mode-badge {
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 10px;
  font-weight: 500;
  flex-shrink: 0;
}

.match-mode-contains {
  background: #f3f4f6;
  color: #6b7280;
}

.match-mode-exact {
  background: #e0e7ff;
  color: #4338ca;
}



/* 新的开关样式 */
.rule-toggle-wrapper {
  display: flex;
  align-items: center;
  gap: 6px;
}

/* 隐藏 checkbox */
.rule-toggle-checkbox {
  display: none;
}

.toggleSwitch {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  width: 36px;
  height: 20px;
  background-color: rgb(82, 82, 82);
  border-radius: 20px;
  cursor: pointer;
  transition-duration: .2s;
  flex-shrink: 0;
}

.toggleSwitch::after {
  content: "";
  position: absolute;
  height: 6px;
  width: 6px;
  left: 4px;
  background-color: transparent;
  border-radius: 50%;
  transition-duration: .2s;
  box-shadow: 3px 1px 5px rgba(8, 8, 8, 0.26);
  border: 4px solid white;

}

.rule-toggle-checkbox:checked+.toggleSwitch::after {
  transform: translateX(100%);
  transition-duration: .2s;
  background-color: white;
}

.rule-toggle-checkbox:checked+.toggleSwitch {
  background-color: rgb(148, 118, 255);
  transition-duration: .2s;
}

/* 禁用状态时的开关样式 */
.disabled .toggleSwitch {
  opacity: 0.6;
}

.btn-delete {
  padding: 4px 10px;
  background: #fee2e2;
  color: #991b1b;
  border: none;
  border-radius: 5px;
  font-size: 11px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
  flex-shrink: 0;
}

.btn-delete:hover {
  background: #fecaca;
  transform: translateY(-1px);
}

.btn-delete:active {
  transform: translateY(0);
}

.rule-data {
  font-family: 'SF Mono', Monaco, Consolas, monospace;
  font-size: 11px;
  color: #6b7280;
  background: #f9fafb;
  padding: 6px 8px;
  border-radius: 5px;
  overflow-x: auto;
  white-space: pre-wrap;
  word-break: break-all;
}

.empty-state {
  text-align: center;
  padding: 40px 20px;
  color: #8e8e93;
}

.empty-state svg {
  width: 48px;
  height: 48px;
  margin: 0 auto 12px;
  opacity: 0.3;
}

.empty-state p {
  font-size: 13px;
}

.toast {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0, 0, 0, 0.85);
  color: white;
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 13px;
  z-index: 10000;
  animation: fadeInOut 1.5s ease-in-out;
}

@keyframes fadeInOut {

  0%,
  100% {
    opacity: 0;
  }

  10%,
  90% {
    opacity: 1;
  }
}

/* 折叠展开相关样式 */
.rule-data-wrapper {
  position: relative;
}

.rule-data {
  font-family: 'SF Mono', Monaco, Consolas, monospace;
  font-size: 11px;
  color: #6b7280;
  background: #f9fafb;
  padding: 6px 8px;
  border-radius: 5px;
  overflow: hidden;
  white-space: pre-wrap;
  word-break: break-all;
  max-height: 60px;
  transition: max-height 0.3s ease;
}

.rule-data.expanded {
  max-height: none;
}

.btn-toggle-data {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-top: 6px;
  padding: 4px 8px;
  background: #f5f5f7;
  color: #6b7280;
  border: none;
  border-radius: 5px;
  font-size: 11px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-toggle-data:hover {
  background: #e8e8ea;
  color: #1d1d1f;
}

.btn-toggle-data svg {
  width: 12px;
  height: 12px;
  transition: transform 0.2s;
}

.btn-toggle-data.expanded svg {
  transform: rotate(180deg);
}

/* 渐变遮罩效果 */
.rule-data:not(.expanded)::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 20px;
  background: linear-gradient(to bottom, transparent, #f9fafb);
  pointer-events: none;
}

3. popup.js - 界面逻辑

作用:处理用户配置mock操作

功能:

  • 点击"添加规则" → 保存到 chrome.storage
  • 点击"删除" → 从 chrome.storage 移除
  • 渲染规则列表
js 复制代码
let rules = [];

const openTabBtn = document.getElementById('openTab');

// 在新标签页中打开
openTabBtn.addEventListener('click', function () {
  window.open(window.location.href);
});

// 恢复输入框内容
chrome.storage.local.get(['mockRules', 'draftInput'], (result) => {
  rules = result.mockRules || [];
  rules = rules.map((rule) => ({
    ...rule,
    matchMode: rule.matchMode || 'contains',
    enabled: rule.enabled !== false, //  默认启用
  }));
  renderRules();

  // 恢复草稿
  const draft = result.draftInput || {};
  if (draft.url) document.getElementById('url').value = draft.url;
  if (draft.method) document.getElementById('method').value = draft.method;
  if (draft.matchMode)
    document.getElementById('matchMode').value = draft.matchMode;
  if (draft.data) document.getElementById('data').value = draft.data;
});

// 监听输入框变化,自动保存草稿
function saveDraft() {
  const draft = {
    url: document.getElementById('url').value.trim(),
    method: document.getElementById('method').value,
    matchMode: document.getElementById('matchMode').value,
    data: document.getElementById('data').value.trim(),
  };
  chrome.storage.local.set({ draftInput: draft });
}

document.getElementById('url').addEventListener('input', saveDraft);
document.getElementById('method').addEventListener('change', saveDraft);
document.getElementById('matchMode').addEventListener('change', saveDraft);
document.getElementById('data').addEventListener('input', saveDraft);

document.getElementById('add').onclick = () => {
  const url = document.getElementById('url').value.trim();
  const method = document.getElementById('method').value;
  const matchMode = document.getElementById('matchMode').value;
  const data = document.getElementById('data').value.trim();

  if (!url || !data) {
    showToast('请填写完整信息');
    return;
  }

  try {
    JSON.parse(data);
    //  添加 enabled: true
    rules.push({ url, method, matchMode, data, enabled: true });
    chrome.storage.local.set({ mockRules: rules });

    // 清空输入框和存储
    document.getElementById('url').value = '';
    document.getElementById('data').value = '';
    document.getElementById('method').value = 'ALL';
    document.getElementById('matchMode').value = 'contains';
    chrome.storage.local.remove('draftInput');

    renderRules();
    showToast('✓ 添加成功');
  } catch (e) {
    showToast('JSON 格式错误');
  }
};

document.getElementById('clear').onclick = () => {
  if (rules.length === 0) return;
  if (confirm('确定清空所有规则?')) {
    rules = [];
    chrome.storage.local.set({ mockRules: [] });
    renderRules();
    showToast('✓ 已清空');
  }
};

//  添加开关规则函数
window.toggleRule = (index) => {
  rules[index].enabled = !rules[index].enabled;
  chrome.storage.local.set({ mockRules: rules });
  renderRules();
  const status = rules[index].enabled ? '已启用' : '已禁用';
  showToast(`✓ ${status}`);
};

function renderRules() {
  const container = document.getElementById('rules');
  const count = document.getElementById('count');
  count.textContent = `${rules.length} 条`;

  if (rules.length === 0) {
    container.innerHTML = `
      <div class="empty-state">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
          <rect x="3" y="3" width="18" height="18" rx="2"/>
          <path d="M9 9h6M9 15h6"/>
        </svg>
        <p>暂无 Mock 规则</p>
      </div>
    `;
    return;
  }

  const matchModeText = {
    contains: '包含',
    exact: '完整',
  };

  container.innerHTML = rules
    .map(
      (rule, i) => `
    <div class="rule-item ${rule.enabled === false ? 'disabled' : ''}">
      <div class="rule-header">
        <div class="rule-url">
          <span class="method-badge method-${(rule.method || 'ALL').toLowerCase()}">${rule.method || 'ALL'}</span>
          <span class="match-mode-badge match-mode-${rule.matchMode || 'contains'}">${matchModeText[rule.matchMode] || '包含'}</span>
          <span>${escapeHtml(rule.url)}</span>
        </div>
        <div style="display: flex; gap: 6px; align-items: center;">
          <div class="rule-toggle-wrapper">
            <input 
              type="checkbox" 
              class="rule-toggle-checkbox" 
              id="toggle-${i}" 
              data-index="${i}"
              ${rule.enabled !== false ? 'checked' : ''}
            >
            <label for="toggle-${i}" class="toggleSwitch"></label>
          </div>
          <button class="btn-delete" data-index="${i}">删除</button>
        </div>
      </div>
      <div class="rule-data-wrapper">
        <div class="rule-data" data-index="${i}">${escapeHtml(rule.data)}</div>
        ${rule.data.length > 100 ? `
          <button class="btn-toggle-data" data-index="${i}">
            <span class="toggle-text">展开</span>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <polyline points="6 9 12 15 18 9"></polyline>
            </svg>
          </button>
        ` : ''}
      </div>
    </div>
  `
    )
    .join('');

  // 开关按钮事件(替换原来的 .btn-toggle 事件)
  container.querySelectorAll('.rule-toggle-checkbox').forEach((checkbox) => {
    checkbox.addEventListener('change', (e) => {
      const index = Number(e.target.getAttribute('data-index'));
      if (!Number.isNaN(index)) {
        window.toggleRule(index);
      }
    });
  });

  // 删除按钮事件
  container.querySelectorAll('.btn-delete').forEach((btn) => {
    btn.addEventListener('click', () => {
      const index = Number(btn.getAttribute('data-index'));
      if (!Number.isNaN(index)) {
        rules.splice(index, 1);
        chrome.storage.local.set({ mockRules: rules });
        renderRules();
        showToast('✓ 已删除');
      }
    });
  });

  // 折叠展开按钮事件
  container.querySelectorAll('.btn-toggle-data').forEach((btn) => {
    btn.addEventListener('click', () => {
      const index = Number(btn.getAttribute('data-index'));
      const dataElement = container.querySelector(`.rule-data[data-index="${index}"]`);
      const isExpanded = dataElement.classList.contains('expanded');

      dataElement.classList.toggle('expanded');
      btn.classList.toggle('expanded');
      btn.querySelector('.toggle-text').textContent = isExpanded ? '展开' : '收起';
    });
  });
}



function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

function showToast(message) {
  const toast = document.createElement('div');
  toast.className = 'toast';
  toast.textContent = message;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 1500);
}

核心层(拦截逻辑)

4. content.js - "中间人"

作用:连接插件和网页的桥梁

能力:

  • 可以访问 Chrome API(读取 chrome.storage)
  • 可以访问网页 DOM
  • 不能访问网页的 JavaScript 环境(window.fetch)

职责:

  1. 把 injected.js 注入到网页
  2. 读取用户配置的 Mock 规则
  3. 通过 postMessage 与 injected.js 通信
js 复制代码
/**
 * 在页面中注入扩展的 injected.js 脚本。
 * 使用 chrome.runtime.getURL 获取扩展内资源的绝对 URL。
 */
function init() {
  const script = document.createElement('script');
  script.src = chrome.runtime.getURL('injected.js');
  (document.head || document.documentElement).appendChild(script);
}

init();

/**
 * 安全读取本地存储中的 mock 规则。
 * 若扩展未加载或 API 不可用、或读取失败,返回空数组。
 * @returns {Array}  - mock 规则数组。
 */
async function getMockRulesSafely() {
  try {
    if (!chrome?.runtime?.id || !chrome?.storage?.local) return [];
    const result = await chrome.storage.local.get('mockRules');
    if (chrome.runtime.lastError) {
      console.warn('[Mock] 读取存储失败:', chrome.runtime.lastError);
      return [];
    }
    const rules = result.mockRules || [];
    return Array.isArray(rules) ? rules : [];
  } catch (err) {
    console.warn('[Mock] 读取存储异常:', err);
    return [];
  }
}

/**
 * 根据匹配模式对 URL 进行匹配。
 * @param {*} url  - 请求的 URL。
 * @param {*} pattern  - 匹配规则。
 * @param {*} mode  - 匹配模式
 * @returns {boolean}  - 匹配结果。
 */
function matchUrl(url, pattern, mode) {
  try {
    switch (mode) {
      case 'exact':
        return url === pattern;
      case 'contains':
      default:
        return url.includes(pattern);
    }
  } catch (e) {
    console.warn('[Mock] URL 匹配失败:', e);
    return false;
  }
}

/**
 * 监听来自 content-script 的消息。
 * 若消息类型为 MOCK_REQUEST,则根据 URL 和 method 匹配规则,返回对应的 mock 数据。
 */
window.addEventListener('message', async (event) => {
  if (event.data.type !== 'MOCK_REQUEST') return;

  const { url, method, id } = event.data;
  const rules = await getMockRulesSafely();

  for (let rule of rules) {
    try {
      // 添加启用状态检查
      if (rule.enabled === false) continue;

      const matchMode = rule.matchMode || 'contains';
      const urlMatch = matchUrl(url, rule.url, matchMode);
      const methodMatch = rule.method === 'ALL' || rule.method === method;

      if (urlMatch && methodMatch) {
        let mockData = rule.data;
        try {
          mockData = JSON.parse(rule.data);
        } catch (e) {
          console.warn('[Mock] JSON 解析失败,使用原始字符串');
        }

        window.postMessage(
          {
            type: 'MOCK_RESPONSE',
            id,
            shouldMock: true,
            mockData,
            status: 200,
            headers: { 'Content-Type': 'application/json' },
          },
          '*'
        );
        return;
      }
    } catch (e) {
      console.warn('[Mock] 规则处理失败:', e);
      continue;
    }
  }

  window.postMessage({ type: 'MOCK_RESPONSE', id, shouldMock: false }, '*');
});

5. injected.js - "劫匪"

作用:真正执行拦截的代码

能力:

  • 可以访问网页的 JavaScript 环境(window.fetch)

  • 可以重写 fetch 和 XMLHttpRequest

  • 不能访问 Chrome API(chrome.storage)

职责:

  1. 重写 window.fetch
  2. 重写 XMLHttpRequest.prototype.open/send
  3. 拦截请求,询问 content.js 是否需要 Mock
  4. 根据回复决定返回假数据还是真实请求
js 复制代码
(function () {
  //  保存原始方法
  const originalFetch = window.fetch;
  const originalXHROpen = XMLHttpRequest.prototype.open;
  const originalXHRSend = XMLHttpRequest.prototype.send;
  const pendingRequests = new Map(); // 存储等待响应的请求(key: id, value: resolve 函数)
  let requestId = 0; // 每个请求ID

  /**
   * 监听来自 content-script 的 mock 响应。
   * 若消息类型为 MOCK_RESPONSE,根据请求ID匹配并调用对应的 resolve 函数。
   */
  window.addEventListener('message', (event) => {
    if (event.data.type !== 'MOCK_RESPONSE') return;

    const { id, shouldMock, mockData, status, headers } = event.data;
    const resolve = pendingRequests.get(id);

    if (resolve) {
      resolve({ shouldMock, mockData, status, headers });
      pendingRequests.delete(id);
    }
  });

  /**
   * 规范化 URL。
   * 若输入为字符串,直接返回;
   * 若输入为对象且包含 url 属性,返回该属性值;否则转换为字符串。
   * @param {*} input  - 请求的 URL 或包含 URL 的对象。
   * @returns {string}  - 规范化后的 URL。
   */
  function normalizeUrl(input) {
    try {
      if (typeof input === 'string') return input;
      if (input && typeof input === 'object' && 'url' in input) {
        return input.url;
      }
    } catch (_) {}
    return String(input);
  }

  /**
   * 获取请求方法
   * @param {*} url  - 请求的 URL 或包含 URL 的对象。
   * @param {*} options  - 请求的选项。
   * @returns
   */
  function getMethod(url, options) {
    if (url && typeof url === 'object' && 'method' in url) {
      return url.method.toUpperCase();
    }
    return (options?.method || 'GET').toUpperCase();
  }

  /**
   *  发送 Mock 请求到 content-script。
   * @param {*} url - 请求的 URL 或包含 URL 的对象。
   * @param {*} method - 请求的方法。
   * @returns
   */
  function sendMockRequest(url, method) {
    return new Promise((resolve) => {
      const id = requestId++;
      pendingRequests.set(id, resolve);

      const safeUrl = normalizeUrl(url);

      try {
        window.postMessage(
          {
            type: 'MOCK_REQUEST',
            url: safeUrl,
            method,
            id,
          },
          '*'
        );
      } catch (e) {
        if (pendingRequests.has(id)) {
          resolve({ shouldMock: false });
          pendingRequests.delete(id);
        }
        return;
      }
      // 超时保护:100ms 内没收到响应,自动放行(发送真实请求)
      setTimeout(() => {
        if (pendingRequests.has(id)) {
          resolve({ shouldMock: false });
          pendingRequests.delete(id);
        }
      }, 100);
    });
  }

  // ========== Fetch 拦截 ==========
  window.fetch = async function (url, options = {}) {
    const method = getMethod(url, options);
    const response = await sendMockRequest(url, method);

    if (response.shouldMock) {
      return new Response(JSON.stringify(response.mockData), {
        status: response.status,
        headers: response.headers,
      });
    }

    return originalFetch.apply(this, arguments);
  };

  // ========== XMLHttpRequest 拦截 ==========
  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._mockMethod = method.toUpperCase();
    this._mockUrl = url;
    return originalXHROpen.apply(this, [method, url, ...rest]);
  };

  XMLHttpRequest.prototype.send = async function (body) {
    const method = this._mockMethod || 'GET';
    const url = this._mockUrl;

    if (!url) {
      return originalXHRSend.apply(this, arguments);
    }

    const response = await sendMockRequest(url, method);

    if (response.shouldMock) {
      // 模拟 XHR 响应
      Object.defineProperty(this, 'readyState', { writable: true, value: 4 });
      Object.defineProperty(this, 'status', {
        writable: true,
        value: response.status,
      });
      Object.defineProperty(this, 'statusText', {
        writable: true,
        value: 'OK',
      });
      Object.defineProperty(this, 'responseText', {
        writable: true,
        value: JSON.stringify(response.mockData),
      });
      Object.defineProperty(this, 'response', {
        writable: true,
        value: JSON.stringify(response.mockData),
      });

      // 触发事件
      setTimeout(() => {
        if (this.onreadystatechange) {
          this.onreadystatechange();
        }
        if (this.onload) {
          this.onload();
        }
      }, 0);

      return;
    }

    return originalXHRSend.apply(this, arguments);
  };
})();

配置层

6. manifest.json - 插件的"身份证"

json 复制代码
{
  "manifest_version": 3,
  "name": "API Mock Tool",
  "version": "1.0",
  "permissions": ["storage", "activeTab"],
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_start"
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["injected.js"],
      "matches": ["<all_urls>"]
    }
  ]
}

安装扩展

  1. 打开Chrome浏览器
  2. 地址栏输入:chrome://extensions/
  3. 打开右上角"开发者模式"
  4. 点击"加载已解压的扩展程序"
  5. 选择刚才的文件夹
  6. 搞定!扩展安装完成!

安装操作演示:

文件协作流程(完整链路)

第一阶段:用户配置规则

c 复制代码
┌─────────────┐
│  用户操作    │ 在 popup.html 输入 Mock 规则
└──────┬──────┘
       │
       ↓
┌─────────────┐
│  popup.js   │ 点击"添加"按钮
└──────┬──────┘
       │
       │ chrome.storage.local.set({ mockRules: [...] })
       ↓
┌─────────────┐
│Chrome Storage│ 持久化存储规则(关闭浏览器也不丢失)
└─────────────┘

第二阶段:页面加载时注入脚本

ini 复制代码
用户打开网页(如 https://example.com)
       ↓
┌─────────────┐
│manifest.json│ 检测到匹配的 URL
└──────┬──────┘
       │
       │ 自动注入
       ↓
┌─────────────┐
│ content.js  │ 在网页上下文运行(但在隔离沙箱)
└──────┬──────┘
       │
       │ 创建 <script> 标签
       │ script.src = chrome.runtime.getURL('injected.js')
       │ document.head.appendChild(script)
       ↓
┌─────────────┐
│ injected.js │ 在网页真实环境运行
└──────┬──────┘
       │
       │ window.fetch = 我们的假fetch;
       ↓
    拦截就绪!

第三阶段:拦截请求(实时通信)

php 复制代码
网页代码执行:fetch('/api/user')
       ↓
┌─────────────────────┐
│ injected.js         
│ 我们的假 fetch 被调用 
└──────┬──────────────┘
       │
       │ window.postMessage({
       │   type: 'MOCK_REQUEST',
       │   url: '/api/user',
       │   method: 'GET'
       │ })
       ↓
┌─────────────────────┐
│ content.js          │ 监听 message 事件
└──────┬──────────────┘
       │
       │ 1. 读取 chrome.storage.local
       │ 2. 遍历规则,检查是否匹配
       │ 3. 找到匹配规则
       ↓
       │ window.postMessage({
       │   type: 'MOCK_RESPONSE',
       │   shouldMock: true,
       │   mockData: { name: 'Mock User' }
       │ })
       ↓
┌─────────────────────┐
│ injected.js         │ 收到回复
└──────┬──────────────┘
       │
       │ if (shouldMock) {
       │   return new Response(JSON.stringify(mockData));
       │ } else {
       │   return 真fetch(url); // 调用原始方法
       │ }
       ↓
网页代码收到响应:{ name: 'Mock User' }

为什么要分 content.js 和 injected.js?

只用 Content Script 行不行?

不行! 因为 Content Script 运行在隔离的沙箱中,无法访问网页的 window.fetch

js 复制代码
// content.js 中这样做是无效的!
window.fetch = function() { 
  console.log('拦截失败!'); // 网页看不到这个修改
}

只用 Injected Script 行不行?

不行! 因为 Injected Script 无法访问 chrome.storage 等 Chrome API,无法读取用户配置的 Mock 规则。

scss 复制代码
### 正确方案:两者配合

用户配置 Mock 规则
↓
存储到 chrome.storage (Popup)
↓
读取规则 (Content Script) ← 可以访问 Chrome API
↓
通过 postMessage 通信
↓
拦截 fetch (Injected Script) ← 可以修改 window.fetch

通俗比喻

markdown 复制代码
content.js = 银行金库管理员
  - 有钥匙(Chrome API 权限)
  - 能读取保险箱(chrome.storage)
  - 但不能直接接触客户(网页 JavaScript)

injected.js = 银行大堂经理
  - 直接面对客户(网页代码)
  - 能拦截客户请求(重写 fetch)
  - 但没有金库钥匙(无法访问 chrome.storage)

解决方案:两人用对讲机(postMessage)通信
  客户发起请求 → 大堂经理拦截 → 对讲机问管理员"要不要放行"
  → 管理员查保险箱 → 回复"不放行,给假钞" → 大堂经理返回假钞

关键技术点总结

1. 为什么要用 run_at: "document_start"

json 复制代码
// manifest.json
"run_at": "document_start"  // 在 HTML 解析前运行

原因: 如果网页在插件加载前就执行了 fetch('/api/data'),我们就拦截不到了。

2. 为什么要用 web_accessible_resources

json 复制代码
// manifest.json
"web_accessible_resources": [{
  "resources": ["injected.js"],
  "matches": ["<all_urls>"]
}]

原因: 默认情况下,网页无法加载插件内部的文件(跨域限制)。这个配置相当于给 injected.js 开了"绿色通道"。

3. 为什么用 postMessage 而不是全局变量?

javascript 复制代码
// ❌ 错误做法
window.mockRules = [...];  // content.js 设置
console.log(window.mockRules);  // injected.js 读取(读不到!)

// ✅ 正确做法
window.postMessage({ type: 'MOCK_REQUEST' }, '*');  // injected.js 发送
window.addEventListener('message', (e) => { ... });  // content.js 接收

原因: content.js 和 injected.js 虽然在同一个网页,但 JavaScript 环境是隔离的,就像两个平行世界,只能通过 postMessage 这个"传送门"通信。

4. 为什么要保存原始 fetch、xhr?

javascript 复制代码
const originalFetch = window.fetch;  //  必须先保存

window.fetch = async function(url) {
  if (needMock) {
    return mockResponse;
  }
  return originalFetch(url);  //  不 Mock 时调用原始方法
};

原因: 如果不保存,所有请求都会被拦截,无法发送真实请求。

总结

核心要点

  1. 根本原理 :重写 window.fetchXMLHttpRequest,在网页代码执行前劫持请求
  2. 为什么分两个脚本:content.js 能读插件配置,injected.js 能拦截请求
  3. 怎么通信postMessage(唯一方式)
  4. 什么时候注入document_start(越早越好)
  5. 为什么能拦截:在网页代码执行前就替换了原生方法

适用场景

  • 前端开发时,后端接口还没好
  • 调试线上 Bug,想临时改返回数据
  • 演示 Demo,不想依赖真实服务器
  • 自动化测试,需要稳定的 Mock 数据
  • 接口文档不完善,想自己造数据测试

最后

如果这个插件帮到了你,欢迎:

  • 如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!
  • GitHub Star 支持 github.com/Teernage/qu...
  • 💬 评论区聊聊你的使用场景

这个插件会持续优化,支持更丰富的配置项、更好的交互等

如果你有好的想法,欢迎在评论区或 GitHub Issue 提出!

相关推荐
IT_陈寒2 小时前
JavaScript 性能优化实战:我通过这7个技巧将页面加载速度提升了65%
前端·人工智能·后端
JIngJaneIL2 小时前
数码商城系统|电子|基于SprinBoot+vue的商城推荐系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·数码商城系统
GISer_Jing2 小时前
Flutter架构解析:从引擎层到应用层
前端·flutter·架构
GISer_Jing2 小时前
Flutter开发全攻略:从入门到精通
android·前端·flutter
艾小码2 小时前
Vue组件通信不再难!这8种方式让你彻底搞懂父子兄弟传值
前端·javascript·vue.js
lcc1872 小时前
Vue 数据代理
前端·javascript·vue.js
Moment2 小时前
为什么我们从 Python 迁移到 Node.js
前端·后端·node.js
excel2 小时前
📘 全面解析:JavaScript 时间格式化 API 实战指南
前端
咖啡の猫3 小时前
Vue基本路由
前端·vue.js·状态模式