基于原型链劫持的前端代码插桩实践

代码插桩技术可以让咱们在不更改已有源码的前提下,从外部注入、拦截各类自定的逻辑。这为施展各类黑魔法提供了巨大的想象空间。下面咱们将介绍浏览器环境中一些插桩技术的原理与应用实践。前端

插桩基础概念

前端插桩的基本理念,能够用这个问题来表达:假设有一个被业务普遍使用的函数,咱们是否可以在既不更改调用它的业务代码,也不更改该函数源码的前提下,在其执行先后注入一段咱们自定义的逻辑呢?vue

举个更具体的例子,若是业务逻辑中有许多 console.log 日志代码,咱们可否在不改动这些代码的前提下,将这些 log 内容经过网络请求上报呢?一个简单的思路是这样的:git

  1. 封装一个「先执行自定义逻辑,而后执行原有 log 方法的函数」。
  2. 将原生 console.log 替换为该函数。

若是但愿咱们的解法具有通用性,那么不难将第一步中的操做泛化为一个高阶函数:github

function withHookBefore (originalFn, hookFn) {
  return function () {
    hookFn.apply(this, arguments)
    return originalFn.apply(this, arguments)
  }
}
复制代码

因而,咱们的插桩代码就很简洁了。只须要形如这样:浏览器

console.log = withHookBefore(console.log, (...data) => myAjax(data))
复制代码

原生的 console.log 会在咱们插入的逻辑以后继续。下面考虑这个问题:咱们可否从外部阻断 console.log 的执行呢?有了高阶函数,这一样是小菜一碟:前端框架

function withHookBefore (originalFn, hookFn) {
  return function () {
    if (hookFn.apply(this, arguments) === false) {
      return
    }
    return originalFn.apply(this, arguments)
  }
}
复制代码

只要钩子函数返回 false,那么原函数就不会被执行。例以下面就给出了一种清爽化控制台的骚操做:网络

console.log = withHookBefore(console.log, () => false)
复制代码

这就是在浏览器中「偷天换日」的基本原理了。app

对 DOM API 的插桩

单纯的函数替换还不足以完成一些较为 HACK 的操做。下面让咱们考虑一个更有意思的场景:如何捕获浏览器中全部的用户事件?框架

你固然能够在最顶层的 document.body 上添加各类事件 listener 来达成这一需求。但这时的问题在于,一旦子元素中使用 e.stopPropagation() 阻止了事件冒泡,顶层节点就没法收到这一事件了。难道咱们要遍历全部 DOM 中元素并魔改其事件监听器吗?比起暴力遍历,咱们能够选择在原型链上作文章。函数

对于一个 DOM 元素,使用 addEventListener 为其添加事件回调是再正常不过的操做了。这个方法其实位于公共的原型链上,咱们能够经过前面的高阶插桩函数,这样劫持它:

EventTarget.prototype.addEventListener = withHookBefore(
  EventTarget.prototype.addEventListener,
  myHookFn // 自定义的钩子函数
)
复制代码

但这还不够。由于经过这种方式,真正添加的 listener 参数并无被改变。那么,咱们可否劫持 listener 参数呢?这时,咱们实际上须要这样的高阶函数:

  1. 把原函数的参数传入自定义的钩子中,返回一系列新参数。
  2. 用魔改后的新参数来调用原函数。

这个函数大概长这样:

function hookArgs (originalFn, argsGetter) {
  return function () {
    var _args = argsGetter.apply(this, arguments)
    // 在此魔改 arguments
    for (var i = 0; i < _args.length; i++) arguments[i] = _args[i]
    return originalFn.apply(this, arguments)
  }
}
复制代码

结合这个高阶函数和已有的 withHookBefore,咱们就能够设计出完整的劫持方案了:

  • 使用 hookArgs 替换掉传入 addEventListener 的各个参数。
  • 被替换的参数中,第二个参数就是真正的 listener 回调。将这个回调替换为 withHookBefore 的定制版本。
  • 在咱们为 listener 添加的钩子中,执行咱们定制的事件采集代码。

这个方案的基本逻辑结构大体形如这样:

EventTarget.prototype.addEventListener = hookArgs(
  EventTarget.prototype.addEventListener,
  function (type, listener, options) {
    const hookedListener = withHookBefore(listener, e => myEvents.push(e))
    return [type, hookedListener, options]
  }
)
复制代码

只要保证上面这段代码在全部包含 addEventListener 的实际业务代码以前执行,咱们就能超越事件冒泡的限制,采集到全部咱们感兴趣的用户事件了 :)

对前端框架的插桩

在咱们理解了对 DOM API 插桩的原理后,对于前端框架的 API,就能够照猫画虎地搞起来了。好比,咱们可否在 Vue 中收集甚至定制全部的 this.$emit 信息呢?这一样能够经过原型链劫持来简单地实现:

import Vue from 'vue'

Vue.prototype.$emit = withHookBefore(Vue.prototype.$emit, (name, payload) => {
  // 在此发挥你的黑魔法
  console.log('emitting', name, payload)
})
复制代码

固然了,对于已经封装出一套完善 API 接口的框架,经过这种方式定制它,极可能有违其最佳实践。但在须要开发基础库或开发者工具的时候,相信这一技术是有其用武之地的。举几个例子:

  • 基于对 console.log 的插桩,可让咱们实现跨屏的日志收集(好比在你的机器上实时查看其余设备的操做日志)
  • 基于对 DOM API 的插桩,可让咱们实现对业务无侵入的埋点,以及用户行为的录制与回放。
  • 基于对组件生命周期钩子的插桩,可让咱们实现更精确而无痛的性能收集与分析。
  • ……

总结

到此为止,咱们已经介绍了插桩技术的基本概念与若干实践。若是你感兴趣,一个好消息是咱们已经将经常使用的插桩高阶函数封装为了开箱即用的 NPM 基础库 runtime-hooks,其中包括了这些插桩函数:

  • withHookBefore - 为函数添加 before 钩子
  • withHookAfter - 为函数添加 after 钩子
  • hookArgs - 魔改函数参数
  • hookOutput - 魔改函数返回值

欢迎在 GitHub 上尝鲜我司这一开源项目,也欢迎你们关注这个前端专栏噢 :)

P.S. 咱们 base 厦门的前端团队活跃招人中,简历求砸 xuebi at gaoding.com 呀~

相关文章
相关标签/搜索