如何讓 JS 程式碼不可斷點

2022-08-04 21:01:10

繞過斷點

偵錯 JS 程式碼時,單步執行(F11)可跟蹤所有操作。例如這段程式碼,每次呼叫 alert 時都會被斷住:

debugger
alert(11)
alert(22)
alert(33)
alert(44)

有沒有什麼辦法能讓單步執行失效,一次執行多個操作?

事實上有一些巧妙的辦法。例如通過陣列回撥執行這些 alert 函數:

debugger
[11, 22, 33, 44].forEach(alert)

這樣只有 forEach 之前和之後會被斷住,中間所有 alert 呼叫都不會被斷住。

由此可見,通過 內建回撥 執行 原生函數,偵錯程式是無法斷住的!

利用這個特性,我們可將一些重要的操作隱藏起來,從而能在偵錯者眼皮下悄悄執行。

應用案例

主流瀏覽器的偵錯程式允許攔截特定事件,例如觸發 mousemove 時斷點;

addEventListener('mousemove', e => {
  console.log(e)
})

因此偵錯者很容易找到事件回撥函數,從而分析相應的處理邏輯。

如何防止事件回撥被斷點?這就需要前面講解的黑科技了。我們對上述程式碼稍微修改,將自己的回撥函數改成原生函數:

addEventListener('mousemove', console.log)

這時,每次觸發 mousemove 事件都不會被斷住!

然而現實中的回撥邏輯遠比 console.log 複雜,又該如何應用?

事實上我們可以做一些調整,將事件的回撥邏輯變得足夠簡單,簡單到只需一個操作 —— 儲存結果:

const Q = []
addEventListener('mousemove', Q.push.bind(Q))

由於呼叫函數 bind 方法後返回的新函數,其實是原生的:

function A() {}
A.bind(window) + ''   // "function () { [native code] }"

而 Q.push 本身也是原生函數,因此它們兩都是原生函數。

同時 addEventListener 執行回撥也屬於內建行為,因此整個操作都是原生函數在執行,沒有任何自己的程式碼可供偵錯程式斷點!

現在觸發 mousemove 事件不僅不會被斷住,而且還能將結果追加到陣列 Q 中。

至於讀取則有很多辦法,例如渲染事件、空閒事件、定期輪詢等。

setInterval(() => {
  for (const v of Q) {
    console.log(v)
  }
  Q.length = 0
}, 20)

如果 JS 只是採集資訊而沒有互動,可用更低的讀取頻率。

屬性存取

前面的案例都是函數呼叫,例如 alert 函數、陣列 push 函數。但屬性讀寫又該如何實現?例如:

window.onclick = function() {
  document.title = 'hello'
}

其實也不難。屬性讀寫本質上是 getter 和 setter 函數的呼叫。例如:

const setter = Object.getOwnPropertyDescriptor(Document.prototype, 'title').set
setter.call(document, 'hello')

當然這樣會立即執行,而不是在 onclick 事件時執行。

因此我們可以給 setter 柯里化,建立一個已係結引數的新函數,作為事件回撥:

const setter = Object.getOwnPropertyDescriptor(Document.prototype, 'title').set
window.onclick = setter.bind(document, 'hello')

這樣只有在點選時才會執行。並且偵錯程式的 click 事件斷點不會觸發。

物件屬性

除了原型上的屬性,普通物件的屬性又該如何存取?例如:

const obj = {}
window.onclick = function() {
  obj.name = 'jack'
}

事實上 JS 基本操作都可通過 Reflect API 實現。例如:

const obj = {}
Reflect.set(obj, 'name', 'jack')

不過需注意的是,Reflect.set 的引數必須是 3 個,多一個也不行。例如:

const obj = {}
Reflect.set(obj, 'age', 20, {})
obj.age   // undefined

這樣將其柯里化成事件回撥函數是有問題的,因為事件回撥還會加上一個 event 引數。

不過 Reflect.apply 方法倒沒有這個限制,往後再加幾個引數也不影響執行:

Reflect.apply(alert, null, ['hello'],   /* 無用的引數 */ 100, 200, 300)

因此我們可通過 Reflect.apply 執行 Reflect.set,從而過濾多餘的引數:

const obj = {}
Reflect.apply(Reflect.set, null, [obj, 'age', 20])
obj.age   // 20

然後將其柯里化成事件回撥函數:

const obj = {}
window.onclick = Reflect.apply.bind(null, Reflect.set, null, [obj, 'age', 20])

這樣即可通過原生函數執行 obj.age = 20,並且 click 事件斷點依然不會觸發。

多個操作

前面講解的都是單個操作,是否可以一次執行多個操作?例如:

console.log('hello')
console.log('world')
alert(123)

最容易想到的辦法,就是將每個操作放入陣列,然後通過 forEach 回撥 Reflect.apply 執行每個操作:

[
  Reflect.apply.bind(null, console.log, null, ['hello']),
  Reflect.apply.bind(null, console.log, null, ['world']),
  Reflect.apply.bind(null, alert, null, [123]),
].forEach(Reflect.apply)

幸運的是 forEach 的回撥函數和 Reflect.apply 函數都是 3 個引數,並且第 3 個都是陣列型別:

forEach_callback(element, index, array)

Reflect.apply(target, thisArgument, argumentsList)

這樣通過 forEach 回撥 Reflect.apply 是完全沒問題的。於是可以一次執行多個操作,並且都無法斷住!

除了上述提到的,其實還有更多玩法,大家可發揮想象~

(2021/11/01)