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

doodlewind發表於2019-01-09

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

插樁基礎概念

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

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

  1. 封裝一個「先執行自定義邏輯,然後執行原有 log 方法的函式」。
  2. 將原生 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 - 為函式新增 before 鉤子
  • withHookAfter - 為函式新增 after 鉤子
  • hookArgs - 魔改函式引數
  • hookOutput - 魔改函式返回值

歡迎在 GitHub 上嚐鮮我司這一開源專案,也歡迎大家關注這個前端專欄噢 :)

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

相關文章