上周四下午,我正在调样式,突然电脑风扇开始咆哮。
打开任务管理器:内存占用 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 数据时的演示:

插件功能预览
- 拦截页面的
fetch、xhr请求 - 规则支持模糊匹配和完全匹配
- 支持按 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 同理,重写
open和send方法
项目结构
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)
职责:
- 把 injected.js 注入到网页
- 读取用户配置的 Mock 规则
- 通过 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)
职责:
- 重写 window.fetch
- 重写 XMLHttpRequest.prototype.open/send
- 拦截请求,询问 content.js 是否需要 Mock
- 根据回复决定返回假数据还是真实请求
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>"]
}
]
}
安装扩展
- 打开Chrome浏览器
- 地址栏输入:
chrome://extensions/ - 打开右上角"开发者模式"
- 点击"加载已解压的扩展程序"
- 选择刚才的文件夹
- 搞定!扩展安装完成!
安装操作演示:

文件协作流程(完整链路)
第一阶段:用户配置规则
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 时调用原始方法
};
原因: 如果不保存,所有请求都会被拦截,无法发送真实请求。
总结
核心要点
- 根本原理 :重写
window.fetch和XMLHttpRequest,在网页代码执行前劫持请求 - 为什么分两个脚本:content.js 能读插件配置,injected.js 能拦截请求
- 怎么通信 :
postMessage(唯一方式) - 什么时候注入 :
document_start(越早越好) - 为什么能拦截:在网页代码执行前就替换了原生方法
适用场景
- 前端开发时,后端接口还没好
- 调试线上 Bug,想临时改返回数据
- 演示 Demo,不想依赖真实服务器
- 自动化测试,需要稳定的 Mock 数据
- 接口文档不完善,想自己造数据测试
最后
如果这个插件帮到了你,欢迎:
- 如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!
- ⭐ GitHub Star 支持 github.com/Teernage/qu...
- 💬 评论区聊聊你的使用场景
这个插件会持续优化,支持更丰富的配置项、更好的交互等
如果你有好的想法,欢迎在评论区或 GitHub Issue 提出!