markdown
# JS 中"复写函数"的 10 种姿势(附完整可运行 Demo)
在前端工程里,"复写一个函数"(Hook / Patch / Override)是非常常见的需求,尤其是在:
- 调试黑盒 SDK
- 做监控埋点
- 改第三方库行为但又不能直接改源码
本文把常见的 10 种手法整理成一个"武器清单",**每种都给出可直接跑的 Demo**,方便你复制到控制台 / Node 里试。
---
## 🟦 一、直接覆写:最简单粗暴
**思路**:如果函数本来就是某个对象上的方法,直接重新赋值即可。
### 示例场景
已有一个业务对象:
```js
const userService = {
getUserName(id) {
console.log('[orig] getUserName', id);
return 'Alice';
}
};
// 原始调用
console.log('--- 原始调用 ---');
console.log(userService.getUserName(1));
现在想完全替换 掉 getUserName 的实现:
js
// 直接覆写
userService.getUserName = function (id) {
console.log('[new] getUserName', id);
return 'Bob';
};
console.log('--- 覆写后 ---');
console.log(userService.getUserName(1));
特点:
- 简单直接
- 不保留原逻辑(除非你自己先存一份引用)
🟩 二、Monkey Patch:保留原逻辑再"加一层壳"(最常用)
思路:保存原函数引用 → 用同名函数包装一层。
示例:给 SDK 方法加埋点
js
// 模拟一个 SDK
const sdk = {
track(eventName, payload) {
console.log('[orig track]', eventName, payload);
return { ok: true };
}
};
// 原始使用
console.log('--- 原始调用 ---');
sdk.track('page_view', { path: '/home' });
// 开始 Monkey Patch
const origTrack = sdk.track;
sdk.track = function (...args) {
console.log('[before track]', ...args);
const result = origTrack.apply(this, args);
console.log('[after track]', result);
return result;
};
console.log('--- Patch 之后 ---');
sdk.track('page_view', { path: '/detail' });
适用:
- 想在原逻辑前后"插入"自己的逻辑
- Hook 黑盒 SDK、监控、埋点等
🟧 三、Proxy:拦截整个对象(高级黑盒分析)
思路 :用 Proxy 代理整个对象,在 get 里统一拦截函数调用。
示例:拦截所有方法调用做日志
js
// 一个"黑盒"对象,方法名可能会变
const api = {
login(user, pwd) {
console.log('login...', user);
return { token: 'xxx' };
},
logout() {
console.log('logout...');
}
};
// 用 Proxy 包一层
const apiWithLog = new Proxy(api, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function') {
return function (...args) {
console.log(`[Proxy] call ${String(prop)} with`, args);
const result = value.apply(this, args);
console.log(`[Proxy] ${String(prop)} result`, result);
return result;
};
}
return value;
}
});
// 使用方式跟原来一样
console.log('--- 使用 Proxy 后 ---');
apiWithLog.login('alice', '123456');
apiWithLog.logout();
适用:
- 你不知道具体方法名(SDK 里一堆方法)
- 想统一拦截所有方法调用(sniff)
🟨 四、Object.defineProperty:强行改 getter / setter / 不可枚举属性
思路 :当属性不可写、或是 getter / setter,需要用 defineProperty 操作描述符。
示例 1:把只读属性改成可写函数
js
const obj = {};
// 初始定义:只读属性
Object.defineProperty(obj, 'foo', {
value: 'readonly',
writable: false,
configurable: true,
enumerable: true
});
console.log('原始 foo =', obj.foo);
// 直接赋值会失败(严格模式下报错,非严格模式下无效)
obj.foo = 'new';
console.log('直接赋值后 foo =', obj.foo); // 还是 readonly
// 用 defineProperty 强行改成函数
Object.defineProperty(obj, 'foo', {
configurable: true,
writable: true,
value: function () {
console.log('I am new foo()');
}
});
console.log('--- 改成函数之后 ---');
obj.foo();
示例 2:用 getter 做懒加载 Hook
js
const service = {};
Object.defineProperty(service, 'expensiveApi', {
configurable: true,
get() {
console.log('[getter] 初始化 expensiveApi');
// 真正创建对象
const realApi = {
call() {
console.log('real api called');
}
};
// 用普通属性替换掉 getter(只初始化一次)
Object.defineProperty(service, 'expensiveApi', {
value: realApi,
writable: false,
configurable: false
});
return realApi;
}
});
// 第一次访问会触发 getter
console.log('--- 第一次访问 ---');
service.expensiveApi.call();
// 第二次访问直接用缓存
console.log('--- 第二次访问 ---');
service.expensiveApi.call();
🟥 五、Hook 全局内置方法:网络、事件、路由等(非常强)
示例 1:拦截 fetch(浏览器)
js
// 保存原始 fetch
const origFetch = window.fetch;
// 覆写
window.fetch = async (...args) => {
console.log('[Hook fetch] 请求参数:', args);
const res = await origFetch(...args);
// 复制一份 Response 查看内容(注意:这里只是示意,实际需考虑流只能读一次)
const clone = res.clone();
clone.text().then(text => {
console.log('[Hook fetch] 响应文本截断:', text.slice(0, 100));
});
return res;
};
// 使用:之后所有 fetch 都会经过 Hook
// fetch('https://jsonplaceholder.typicode.com/todos/1');
示例 2:拦截所有 DOM 事件监听
js
const origAdd = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
console.log('[Hook addEventListener]', type, 'on', this);
// 也可以在这里包一层 listener,实现事件埋点
return origAdd.call(this, type, listener, options);
};
// Demo:添加一个 click 事件
document.body.addEventListener('click', () => {
console.log('body clicked');
});
还能 Hook:
XMLHttpRequest.prototype.sendWebSocket.prototype.sendhistory.pushState / replaceState(前端路由监控)console.log(调试场景)
🟪 六、代理模块加载:require Hook / 构建期 alias
6.1 Node:Hook require(CommonJS)
js
// hook-require.js
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function (path) {
console.log('[require]', path);
const exports = originalRequire.apply(this, arguments);
// 针对指定模块做 Monkey Patch
if (path === './sdk') {
const origDo = exports.doSomething;
exports.doSomething = function (...args) {
console.log('[patched sdk.doSomething]', args);
return origDo.apply(this, args);
};
}
return exports;
};
// 主入口
require('./hook-require');
const sdk = require('./sdk');
sdk.doSomething('hello');
js
// sdk.js
exports.doSomething = function (msg) {
console.log('[orig sdk.doSomething]', msg);
};
运行 node main.js 即可看到 require 日志 + patch 效果。
6.2 前端:通过 alias 替换整个模块
以 Vite 为例(Webpack 思路类似)。
步骤 1:配置 alias
js
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
'some-sdk': '/src/sdk-wrapped.js' // 用你自己的 wrapper 替换原 SDK
}
}
});
步骤 2:在 wrapper 里再 Monkey Patch
js
// src/sdk-wrapped.js
import * as realSdk from 'some-sdk-original'; // 假设这是原 SDK 包名
const sdk = { ...realSdk };
const origInit = sdk.init;
sdk.init = function (...args) {
console.log('[wrapped sdk.init]', args);
return origInit.apply(this, args);
};
export default sdk;
export * from 'some-sdk-original'; // 其余原样导出
业务代码:
js
// import sdk from 'some-sdk'; // 实际拿到的是 sdk-wrapped.js
// sdk.init(...);
🟫 七、ES6 class 继承:在可控框架里"正统扩展"
思路 :当你能控制实例创建时,直接用 extends 继承原类,覆写方法并按需 super.foo()。
示例:扩展一个 UI 组件类
js
class Button {
click() {
console.log('[Button] clicked');
}
}
// 原始使用
const btn = new Button();
btn.click();
// 新类:在原 click 前后加逻辑
class LogButton extends Button {
click() {
console.log('[LogButton] before click');
const result = super.click();
console.log('[LogButton] after click');
return result;
}
}
const logBtn = new LogButton();
logBtn.click();
适合:
- 你能自己 new 子类,而不是框架内部帮你 new
- 比如内部业务类、自己封装的组件体系
🟩 八、Proxy + construct:拦截 new 的构造行为
思路 :用 Proxy 代理"类本身",在 construct 里拦截所有实例创建。
示例:给每个实例自动打 Patch
js
class Service {
foo() {
console.log('[Service.foo]');
}
}
const PatchedService = new Proxy(Service, {
construct(target, args, newTarget) {
console.log('[Proxy construct] 创建 Service 实例,参数:', args);
// 创建原始实例
const instance = Reflect.construct(target, args, newTarget);
// 给实例打补丁
const origFoo = instance.foo;
instance.foo = function (...fooArgs) {
console.log('[patched foo] before', fooArgs);
const result = origFoo.apply(this, fooArgs);
console.log('[patched foo] after', result);
return result;
};
return instance;
}
});
// 之后统一用 PatchedService,而不是 Service
const s = new PatchedService();
s.foo();
适合:
- 第三方库暴露的是"类",你可以控制
new的地方 - 想让所有实例都应用同一套 Patch
🟦 九、Hook 原型:对所有实例生效
思路 :直接改 Class.prototype.xxx 或内置类型原型,例如 Array.prototype.push。
示例 1:改自定义类的原型方法
js
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function () {
console.log('Hi, I am', this.name);
};
const p1 = new Person('Alice');
p1.sayHi();
// Patch 原型
const origSayHi = Person.prototype.sayHi;
Person.prototype.sayHi = function () {
console.log('[patched] before sayHi');
origSayHi.call(this);
console.log('[patched] after sayHi');
};
const p2 = new Person('Bob');
console.log('--- Patch 之后 ---');
p1.sayHi(); // 旧实例也会受影响
p2.sayHi();
示例 2:给所有数组的 push 打日志(谨慎使用)
js
const origPush = Array.prototype.push;
Array.prototype.push = function (...items) {
console.log('[Array.push patched] this =', this, 'items =', items);
return origPush.apply(this, items);
};
const arr = [1, 2];
arr.push(3); // 会打印日志
注意:改内置原型影响范围非常大,要在可控环境下使用。
🟣 十、动态注入 <script>:在不能改源码时复写全局函数
思路:在浏览器通过脚本注入的方式覆盖某个全局函数(油猴脚本、Chrome 插件常用)。
示例:复写页面里已有的 window.someFn
假设目标页面有:
js
window.someFn = function () {
console.log('[page] someFn');
};
你在自己的脚本里注入一段:
js
(function inject() {
const script = document.createElement('script');
script.innerHTML = `
(function () {
const orig = window.someFn;
window.someFn = function (...args) {
console.log('[injected] before someFn', args);
const result = orig && orig.apply(this, args);
console.log('[injected] after someFn', result);
return result;
};
})();
`;
document.documentElement.appendChild(script);
script.remove();
})();
执行后,页面中所有对 someFn 的调用都会走你注入的版本。
适用:
- 你不能改页面源码,但能注入 JS(油猴脚本 / 浏览器扩展 / WebView 注入等)
🔥 总结:复写函数的"武器清单"
| 方法 | 能力/复杂度 | 典型场景 |
|---|---|---|
| 直接覆写 | 最简单 | 你能直接访问对象和方法名 |
| Monkey Patch | ⭐最常用 | Hook SDK、埋点、调试 |
| Proxy(拦截对象) | ⭐高级黑盒分析 | 不知道具体方法名,想全量 sniff |
defineProperty |
强力 | 只读属性、getter/setter 代理 |
| Hook 全局 API | ⭐非常强 | 网络、事件、路由、日志监控 |
| require Hook / alias | 构建期 | 替换整个 SDK 模块 |
class 继承 |
正统扩展 | 自己可控的类/组件 |
Proxy + construct |
高级 | 拦截所有实例的创建过程 |
| 原型 Hook | 全局影响 | 对所有实例生效(谨慎) |
| script 注入 | 浏览器插件/油猴场景 | 不能改源码、只能运行时注入 |
--