今天一位學弟“哭着”來找我,説他面試美團(實習崗)的時候,被問到了跨域問題的解決方案,回答的並不好。我還正想着,這麼常見的問題回答不出來,這不就是基礎不過關。但誰知道,面試官讓他封裝 JSONP 來實現跨域!怪我當時還告訴他們這種方案現在很少用,看一眼八股瞭解即可,現在可謂是啪啪打臉!

既然都問到了這個問題,那這篇文章就來詳細講講跨域解決方案的棄子——JSONP!

  1. 為什麼 JSONP 可以跨域?

之前的文章《跨域問題解決方案彙總》中詳細闡述了跨域問題的由來和其他解決方案,這裏就不再贅述了。

JSONP 可以實現跨域主要是利用了 <script src="https://api.example.com/data?callback=cb"> 腳本被瀏覽器加載,不受 SOP 限制(script、img、link 等資源請求是允許跨域的)。服務端返回 cb({...}) 回調函數,瀏覽器執行這段 JS,從而把數據傳回頁面。也就是説,JSONP 不是“繞過”安全策略,而是利用瀏覽器允許跨域 加載並執行腳本 的特性把數據“嵌回”到當前頁執行。

  1. JSONP 存在的安全隱患

  • 任意腳本執行​:服務端返回的內容會作為腳本執行,若服務器被劫持或返回惡意代碼,會導致 XSS。如果不驗證 callback 參數,攻擊者可以傳入 callback=alert(1);evil 等惡意字符串導致執行。
  • 只能使用 GET 請求,存在 CSRF 風險​。
  • 無響應體讀取控制​:無法控制響應頭,自帶所有風險。

至於什麼是 XSS 和 CSRF 攻擊,我們下篇文章再細嗦~

  1. 回到面試題——自己實現 JSONP

現在初入行的很多同學可能都沒有自己實現過 JSONP,甚至因為考察較少,都沒有聽過 JSONP 。下面我將帶着大家一步一步分析一下 JSONP 應該如何解決跨域。

我們需要考慮一下常見的網絡請求,再結合 JSONP 實現:

  • 客户端:
    • callback 回調函數名
    • 支持 Promise(成功/失敗/超時)
    • 動態插入 <script>,設置 onerror、超時處理、清理 DOM 與全局回調
  • 服務端:
    • 只允許可信 callback 名(白名單校驗)
    • 返回 application/javascript,並輸出 callback(data)

完整示例

客户端,我們封裝一個 jsonp 函數作為統一調用。

/**
 * jsonp(url, opts) -> { promise, cancel }
 *
 * opts:
 *  - param: cb作為查詢參數名稱 (默認: 'callback')
 *  - timeout: ms (默認: 10000)
 *  - prefix: 參數名的前綴-保證唯一性(默認: '__jp')
 *  - name: 可選,顯式的 callback 名稱
 *
 * 返回:
 *  {
 *    promise: Promise<any>,
 *    cancel: () => void
 *  }
 *
 * Promise 成功:resolve(data)
 * Promise 失敗:reject(Error)
 */
function jsonp(url, opts = {}) {
  const {
    param = 'callback',
    timeout = 10000,
    prefix = '__jp',
    name = null
  } = opts;

  return new Promise((resolve, reject) => {
    const callbackName = name || `${prefix}_${Date.now()}}`;

    const script = document.createElement('script');
    // 插入 callback 參數到 url
    script.src = `${url}${encodeURIComponent(param)}=${encodeURIComponent(callbackName)}`;
    script.async = true;

    let timer = null;

    // 成功回調:全局函數
    window[callbackName] = (data) => {
      cleanup();
      resolve(data);
    };

    // 處理錯誤與超時
    script.onerror = () => {
      cleanup();
      reject(new Error('JSONP script error'));
    };

    // 超時
    timer = setTimeout(() => {
      cleanup();
      reject(new Error('JSONP timeout'));
    }, timeout);

    // 清除掉 script 節點
    function cleanup() {
      if (script.parentNode) script.parentNode.removeChild(script);
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      try {
        // 刪除全局回調(避免泄露)
        delete window[callbackName];
      } catch (e) {
        window[callbackName] = undefined;
      }
    }

    // 插入到 document.head(或 body)
    (document.head || document.body).appendChild(script);
  });
}

客户端使用示例:

jsonp('https://api.example.com/get-article?id=1111')
  .then(data => {
    console.log('jsonp data', data);
  })
  .catch(err => {
    console.error('jsonp error', err);
  });

服務端示例(Node.js + Express)

// server.js (Express)
const express = require('express');
const app = express();

app.get('/api/jsonp/article', (req, res) => {
  const callback = req.query.callback;

  if (!callback) {
    res.status(400).type('text/plain').send('Invalid callback');
    return;
  }

  // 例如查詢 DB 獲得數據
  const data = {
    id: 111,
    title: '跨域解決方案的棄子——JSONP',
    author: 'Heo',
    content: '...'
  };

  // Content-Type 應設置為 application/javascript
  res.type('application/javascript');
  res.send(`${callback}(data);`);
});

// 啓動
app.listen(3000, () => console.log('JSONP server on 3000'));

這樣看來,其實並不難,只是同學們可能對於 jsonp 的接觸都僅限於八股,還是就是面試時候的緊張,導致自己想不到這一系列的封裝過程,最終被問到實現細節只能發呆,白白錯過了 offer。