跳到主要内容

Hook 教程

危险地带...

什么是 Hook?

"Hook" 是一种通用的编程概念,指的是允许开发者插入自定义代码或逻辑的机制。在不同的编程框架、工具或系统中,"Hook" 可能具有不同的实现和用途。

generate by GPT-3.5

Hook(钩子)在 SEAC 中有两个含义:SEAC 内置的 HookPoint,或者本页面介绍的用于修改游戏内容的 Hook。

具体来说,SEAC 中的 hook 指的是替换对象上目标函数的实现,从而改变游戏的运行逻辑,实现一些高级功能的过程。为了方便的实现这一点,SEAC 提供了一组工具对应的函数。

信息

阅读这一节之前,确保你了解以下 js 知识点:

  • 原型链
  • this 指针
  • iife
  • 全局命名空间

为什么需要 hook?

举个简单的例子,比如说现在全局命名空间下有一个 class A,里面有一个静态成员函数和一个成员函数:

class A {
static sendMsg(arg: string): Promise<string>;
plus(a: number, b: number): number;
}

现在你作为一个模组作者,你觉得这个 A 类不满足你的需要。具体来说,你可能的需求有:

  • 跟踪这个函数的调用情况
  • 获取调用参数
  • 获取返回值
  • 给这个函数附加其他副作用
  • 甚至修改这个函数的运行逻辑

究其根本,是因为这个原来的函数就是不是设计给我们用的,它没有暴露我们需要的接口,我们需要自己动手来从原来的函数中打开一个“口子”。

如何做到这些呢?如果你能定位到这个函数所在的对象,并知道函数的名称,那么替换实现可以简单的做到这一点。

具体来说,我们直接替换目标对象对这个函数的引用为一个签名完全相同,但是实际上不同的一个函数(类比测试中的 mock)。这样,只要其他使用这个函数的地方是通过对象引用的方式来获得这个函数的,那么这些地方调用的实际上就是被替换的函数了。

// 替换原型上的函数,会影响所有实例
A.prototype.plus = function (a, b) {
//相当于拦截并获取了参数
// this instanceof A === true 不出意外的话
return a * b;
};

// 替换静态方法
const sendMsg = A.sendMsg.bind(A);
A.sendMsg = async (...args) => {
const r = await sendMsg(...args); // 甚至不需要知道具体的参数列表
// 只要事先保存了原实现并正确绑定this,就能获得拿到原始返回值
console.log(`sendMsg result: ${r}`);
};

// 某个用到A的模块
const a = new A();
const r = a.plus(1, 2); // r = 2
A.sendMsg('psycho-pass'); // 输出: "sendMsg result: xxx"

如果你能理解以上内容,那么就可以正式开始介绍 SEAC 内置的 hook 工具函数了。

信息

这种 hook 方式具有一定的局限性,但是在目前足够应对绝大多数情况。

幸运的是,正因为 SeerH5 的模块机制是全局作用域 IIFE,且实现基于面向对象,这种替换是完全可行的。

PS:如果有一个裸的全局作用域函数,那么它的挂载对象是window

信息

如果你不知道怎么定位到目标函数,你可以再仔细阅读一下从零开始中不方便展开的内容。

hookFn 与 wrapper

SEAC 做的事情基本上和上面的替换实现思路是一致的,只不过帮你做了一些处理,简化了样板代码,同时帮你理顺了这里面潜在的冲突问题。

两者的详细使用方法与细节参见 hookFnwrapper

hookFn

hookFn 是一个就地替换目标对象上的目标函数来实现 hook 的函数。

你需要将所属对象,目标函数的名称以及override(修改后的函数)传入。override中你可以获得正确绑定 this 的原函数。

改写一下上面的第一个例子:

import { hookFn } from '@sea/core';

// 对于所属对象的直观理解: 'A.prototype' .plus
hookFn(A.prototype, 'plus', function (f, a, b) {
return f(a, b) + 1;
});

// 某个用到A的模块
const a = new A();
const r = a.plus(1, 2); // r = 4!

wrapper

wrapper 是一个给目标函数附加 afterbefore 钩子的函数。

使用 wrapper 包装一个函数,返回的函数可以通过链式调用 afterbefore 方法来附加装饰器。这个函数不执行副作用,你需要手动替换目标对象上的引用。

改写上面的第二个例子:

import { wrapper } from '@sea/core';

A.sendMsg = wrapper(A.sendMsg).after((r) => {
console.log(`sendMsg result: ${r}`);
});

// 某个用到A的模块
A.sendMsg('first-inspector'); // 输出: "sendMsg result: xxx"

hookFn vs wrapper

简单来说,遵守以下原则:

  1. 尽可能用wrapper
  2. 需要修改原函数入参和返回值时,只能用hookFn
  3. 需要替换原函数副作用行为时,只能用hookFn
  4. 在原函数基础上引发副作用行为,建议用wrapper

hookFn 具有 wrapper 不具备的能力,那就可以介入函数的实现逻辑,而后者能做的只有附加,这是两者最本质的区别。这也意味着 wrapper 更加安全,行为更加可预测。

wrapper 一定是安全的吗?

不一定,如果你在装饰器中执行了对原函数调用过程造成影响的副作用,这种情况下,装饰器之间可能产生冲突。

互操作性

这一节非常重要!

设计原则

你可能会问,既然都是基于替换实现这种简单的方式实现 hook,那么 SEAC 为什么要大张旗鼓给出两套 api 呢?

首先,我们其实并不希望修改底层代码,这显然具有风险和不确定性。而实际使用中,有时候我们可能并不需要干扰原逻辑的执行,只是需要附加逻辑。

基于这个想法,两套 api 具有明显的区别,对于 wrapper 我们希望不论这个函数是什么情况,只要接口不变,能运行对应的钩子就行。而对于 hookFn ,我们可能明确要求:

  • override 中拿到的是原函数。
  • 最终在游戏中执行的是修改后的逻辑。

这里面的一个关键点是原函数,或者说原实现。这个东西就像幻想杀手一样,是一个基准点,只要有了它,我们就能放心的将这个函数变更,因为我们还保有复原的能力。正因如此,你才应该始终使用 SEAC 提供的 hook api 来进行 hook。通过这个基准点,虽然不能完全解决问题,但是我们可以精准定义冲突发生的情况以及处理思路。

信息

众所周知,js 中的函数也是对象。因此内部实现中,在进行 hook 操作的时候,会通过一个特殊的 symbol 作为键,在修改后的函数上挂载原函数的引用,从而支持hook类型的断言以及恢复。但是在实际使用中,这个机制对使用者应该是透明的。

冲突问题

一般来说,一个具有出色功能的模组,还是不可避免的要进行 hook。如果多个模组之间 hook 了同一个函数会怎么样呢?

另外,SEAC 和 SEAL 内部也进行了一些 hook 操作,如果模组 hook 了 SEAC 和 SEAL hook 过的函数,甚至 SEAC 和 SEAL 也分别 hook 了同一个函数呢?

这时候就会有问题,这实际上涉及到一个幂等性的问题,最简单的两种思路是:

  1. 叠盒子,修改后的函数中,对原函数的引用指向的是之前那个被修改的函数,就像链表一样。
  2. 幂等,永远只允许一层修改,每次修改永远基于最初的原函数。

这样真的好吗?实际上两者都有一定局限性。如果叠盒子的话,多次 hookFn 会造成完全不可预料的结果,幂等的话,相当于直接不允许在不同位置修改同一个函数了。

幸运的是,我们是可以精确知道这个函数是被hookFn还是被wrapper修改的(见 common 模块中的 hook 断言函数),因此实际上我们可以定义这两者之间的互操作性,来达到一个中间的平衡。

使用 hookFn 修改 hookedFunction 和 使用 hookFn 修改 wrappedFunction

如果使用 hookFn 去修改一个已经被修改的函数,那么这个函数会丢弃之前的所有更改(SEAC 会在出现丢弃修改行为的时候发出警告),在你的override中传入的,保证一定是最初的原函数。换而言之,hookFn是完全幂等的。

原因如下:使用 hookFn 意味着你希望这个函数只允许执行你(顶多再加上原来)的逻辑,因此对同一个函数进行重复的 hookFn 应该是不被允许的。不过,显式的覆盖行为或许比抛出异常更能接受。

对于先使用 wrapper 的情况,可能有些微妙:前面已经说了 wrapper 不允许直接修改原函数的逻辑,那么是否意味着一个 WrappedFunctionhookFn 修改后应该仍然是一个保留了原来装饰器的 WrappedFunction?

但是请注意到这样一个事实:如果我们允许这件事存在的话,先 wrapper 还是先 hookFn 是没有差别的。于是每次当我们交替使用这两个函数去修改一个函数的时候,我们仍然会得到一个原函数的覆盖链:

(箭头代表对原函数的引用)

原函数 <- hooked <- wrapped <- hooked <- wrapped <- ...

这是和第一条就没有本质区别了,因此这种情况也不被允许,而是从最初的原函数上进行修改。

使用 wrapper 修改 wrappedFunction

这是一个很简单的情况,在这种情况下,新的函数应该继承原来的装饰器,并且原函数的指向不变。注意我们期望装饰器之间是相互独立的,也有这一层原因。

使用 wrapper 修改 wrappedFunction

这个情况看起来好像被上面的 wrapper -> hookFn 否决了,但是恰恰因为否决了上面那种情况,反而可以允许这种情况的发生:因为这时候我们只会从 HookedFunction 转移到 WrappedFunction,而不是反过来。

我们可以总结一下,如果以这样的互操作性逻辑来进行多次 hook,最终只会得到以下情况:

(箭头代表对原函数的引用)

原函数 <- hooked <- wrapped (只有最后一个 hookFn 生效,这之后的 wrapper 都生效,但是没有嵌套的指向,只是扩充装饰器数组)

原函数 <- hooked (只有最后一个 hookFn 生效)

原函数 <- wrapped (wrapper 都生效,但是没有嵌套的指向,只是扩充装饰器数组)

如果你觉得上面的互操作性处理不够完善,可以提一个 issue 来讨论这种情况。