基於原型鏈劫持的前端代碼插樁實踐

收藏待读

基於原型鏈劫持的前端代碼插樁實踐

代碼插樁技術能夠讓我們在不更改已有源碼的前提下,從外部注入、攔截各種自定的邏輯。這為施展各種黑魔法提供了巨大的想像空間。下面我們將介紹瀏覽器環境中一些插樁技術的原理與應用實踐。

插樁基礎概念

前端插樁的基本理念,可以用這個問題來表達: 假設有一個被業務廣泛使用的函數,我們是否能夠在既不更改調用它的業務代碼,也不更改該函數源碼的前提下,在其執行前後注入一段我們自定義的邏輯呢?

舉個更具體的例子,如果業務邏輯中有許多 console.log 日誌代碼,我們能否在不改動這些代碼的前提下,將這些 log 內容通過網絡請求上報呢?一個簡單的思路是這樣的:

console.log

如果希望我們的解法具備通用性,那麼不難將第一步中的操作泛化為一個高階函數:

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)

這就是在瀏覽器中「偷天換日」的基本原理了。

對 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
withHookAfter
hookArgs
hookOutput

歡迎在 GitHub 上嘗鮮我司這一開源項目,也歡迎大家關注這個前端專欄噢 🙂

P.S. 我們 base 廈門的前端團隊活躍招人中,簡歷求砸 xuebi at gaoding.com 呀~

原文 : eWind the Blogger

相關閱讀

免责声明:本文内容来源于eWind the Blogger,已注明原文出处和链接,文章观点不代表立场,如若侵犯到您的权益,或涉不实谣言,敬请向我们提出检举。