如何讓 JS 代碼不可斷點

繞過斷點

調試 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)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章