JS 調用一個空函數有可能報錯嗎

前言

JS 思考題 —— 如何獲取閉包內 key 變量的值:

// 挑戰目標:獲取 key 的值
(function() {
  // 一個內部變量,外部無法獲取
  var key = Math.random()

  console.log('[test] key:', key)

  // 一個內部函數
  function internal(x) {
    return x
  }

  // 對外暴露的函數
  apiX = function(x) {
    try {
      return internal(x)
    } catch (err) {
      return key
    }
  }
})()

// 你的代碼寫在此處:
// ...

咋一看,似乎無解。畢竟 internal 函數裏啥也沒做,連報錯的機會都沒有,所以 apiX 也不可能泄露 key 的值。

既然 internal 函數不可能報錯,那麼在調用這個函數時,是否會報錯呢?

棧溢出

事實上 JS 中調用任何一個函數,都存在報錯的可能。例如:

F1()
function F1() { F2() }
function F2() { F3() }
function F3() { F4() }
...

只要層次足夠深,總會遇到一個函數 Fn 在調用 Fn+1 時報錯。原因很簡單,大量的調用消耗了棧空間,導致無法再往下調用。

因此,上述問題就有一種全新的解決思路:我們在調用接口前,先消耗大量棧空間,直到快飽和時再調接口,這樣接口函數就沒有足夠的棧空間了,執行 internal() 就會拋出棧溢出錯誤,從而進入異常流程!

當然實現要比想象的簡單。因爲該接口沒有限制調用次數,所以只需不斷遞歸測試,即可獲得我們想要的結果:

var key;

function F() {
  var ret = apiX(2);
  if (ret < 1) {
    key = ret;  // key 的範圍是 0~1
  }
  return F();   // 無限遞歸
}

try {
  F();
} catch (err) {}

console.log('got key:', key);

演示:https://www.etherdream.com/FunnyScript/stack-detect.html

這樣,我們就拿到了閉包中 key 的值!

延伸

類似的思路,還可以用在其他場景,例如檢測某個操作是否被 Proxy 攔截。

由於 Proxy 是透明的,常規手段很難檢測其存在:

// 給 Math 對象套一層代理
self.Math = new Proxy(Math, {
  get(obj, prop) {
    return obj[prop];
  }
});

Math.sin                // ƒ sin() { [native code] }
'sin' in Math           // true
Reflect.ownKeys(Math)   // ["abs", "acos", ...]
...

但既然使用 Proxy,多少會對某些操作進行攔截。例如上述攔截了 get 操作,因此讀取 Math 對象任何屬性時,都會先經過該函數。

這意味着,每次屬性讀取都會觸發一個 JS 函數,從而消耗額外的棧空間 —— 假如當前棧空間不足,那麼回調函數就無法觸發了!

而普通對象的屬性讀取,顯然不會消耗棧空間,因此在棧空間不足的情況下也能正常讀取。

根據這個思路,我們就可以實現 Proxy 的檢測:

演示: https://jsfiddle.net/4jrytL6s/

優化

考慮到頻繁讀取 Proxy 可能會影響性能,因此放棄之前那種不斷嘗試的方法,而是先算出 JS 引擎的棧大小,然後通過遞歸對棧進行填充,直到快飽和再檢測:

// 計算 JS 引擎棧大小
var max = 0;
function getStackMax() {
  max++;
  getStackMax();
}

try {
  getStackMax();
} catch (err) {
}
console.log('max:', max);

// 填充棧
var num = 0;
function fill() {
  if (++num === max - 1) {
    // 快飽和狀態
  }
  fill();
}
try {
  fill();
} catch (err) {
}

使用這種方案,在 Chrome 瀏覽器下只需 4ms 左右。事實上還可以再優化,例如 Chrome 的 JS 引擎會把函數的局部變量也存放在棧上,例如:

var max = 0;

function F() {
  max++;
  var a0,a1,a2,a3,a4,a5,a6,a7,a8,a9;
  return F();
}
try {
  F();
} catch (err) {}

console.log('deep:', max);

當我們在函數中定義 10 個變量之後,最大調用深度從原先的 15656 層降到了 6958 層。由於減少了調用次數,執行時間也降低了近一半。

如果將變量繼續增加到 100 個,那麼最大深度只有 1159 層,而耗時又減少了一大半。

局部變量 最大調用層數 調用耗時(ms)
0 15656 1.85
10 6958 0.76
100 1159 0.42
1000 124 0.71

(測試環境:Chrome/70 OSX/10.14 LPDDR3/2133MHz i7-7660U/2.50GHz)

演示:https://jsfiddle.net/pzhs1wg4/

不過不同的 JS 引擎細節都不一樣,例如 Safari 的測試一次棧大小需耗費幾十至上百 ms,而對於 FireFox,本文所有的案例甚至都無法得到正確結果,因爲它的棧容量是不固定的!

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