偵錯 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)