jsonp 的原理和採用 Promise API 的實現

Wendell_Hu發表於2018-03-29

我已經將這個專案重新命名為 pjsonp 並且在 npm 上釋出啦,歡迎在你的專案中使用,並在 GitHub 提交 issue 和 pull request。

npm install pjsonp --save
複製程式碼

這篇文章通過實現一個生產環境中可用的,Promise API 封裝的 jsonp 來講解 jsonp 的原理。

由於瀏覽器跨域限制的存在,通常情況下,我們不可以通過 AJAX 發起跨域請求。但考慮如下事實:

  • img link script 標籤是可以跨域的
  • script 標籤裡的程式碼在下載完成後就會被解析執行
  • script 標籤請求的不一定是一個靜態檔案,也可以是服務端根據 URL 生成的
  • JavaScript 支援閉包

這樣我們就找到一種跨域方案了:

  1. 在請求跨域資源的時候,我們生成一個 script 標籤,將它的 src 設定為請求引數,插入 DOM 中發起請求
  2. 要求伺服器返回一個 .js 檔案,格式為 function_name(data), data 就是我們想要獲得的資料,一般是 JSON 格式
  3. 在全域性作用域繫結一個函式,函式名就是上面的 function_name,這個函式是一個閉包,記住了呼叫位置的作用域鏈,這樣我們就可以在這個閉包裡呼叫業務程式碼
  4. 收到伺服器返回的檔案後瀏覽器自動解析執行,執行這個閉包

下面來看實現。

我們要求呼叫者這樣呼叫 pjsonp(url, params, options),傳入三個引數:

  • url:請求的 URL,應該像這樣:http://somehostname[:someport]/to/some/path[?with=true&orWithoutQueries=false]
  • params:可選,請求引數。這是一個簡單的 object,包含請求的引數。因為 jsonp 只能用於 GET 請求,所以引數都要寫在 URL 中,而支援這個引數可以給使用者帶來便利。
  • options:可選,jsonp 的配置資訊。
    • prefix:回撥函式的字首,用於生成回撥函式名
    • timeout:超時事件,超時後請求會被撤銷,並向呼叫者報錯
    • name:特別指定的回撥函式名
    • param:在請求的 URL 中,回撥函式名引數的 key
if (!options) {
  options = params
  params = {}
}

if (!options) options = {}

// merge default and user provided options
options = Object.assign({}, defaultOptions, options)
const callbackName = options.name || options.prefix + uid++
複製程式碼

首先是對引數的處理。由於 params 只是個添頭功能,所以我們允許使用者不傳入params 而只傳入 options,這時就要進行處理。然後我們將預設的 options 和使用者指定的 options 合併起來(你會發現用 Object.assign 比傳統的 || 更加簡單!)。最後,產生一個回撥函式名。

然後,我們需要準備一些引用:

let timer
let script
let target
複製程式碼

分別指向超時計時器,插入 DOM 中的 script 標籤和插入的位置。

然後幫助呼叫者準備引數。注意,我們還要將 &${enc(options.param)}=${enc(callbackName)} 插入到 URL 的末尾,要求伺服器在返回的 js 檔案中,以 callbackName 作為回撥函式名。

// prepare url
url += url.indexOf('?') > 0 ? '' : '?'
for (let key in params) {
  let value = params[key] || ''
  url += `&${enc(key)}=${enc(value)}`
}
url += `&${enc(options.param)}=${enc(callbackName)}`
url = url.replace('?&', '?')
複製程式碼

接下來,我們在 DOM 中插入 script 標籤。

// insert the script to DOM and here we go!
target = document.getElementsByTagName('script')[0] || document.head
script = document.createElement('script')
script.src = url
target.parentNode.appendChild(script, target)
複製程式碼

最後我們返回一個 Promise 物件,為了簡單起見,我們只在 Promise 裡寫絕對必要的程式碼。我們在 window[callbackName] 上賦值了一個函式(的引用),從而構成了一個閉包。可以看到這個函式在被呼叫的時候,一是會 resolve 收到的 data,這樣呼叫者就可以用獲取到的資料來執行他們的程式碼了;二是會呼叫 clean 函式。除了繫結這個函式之外,我們還設定了一個定時器,超時之後,就會 reject 超時錯誤,同時也呼叫 clean 函式。

return new Promise((resolve, reject) => {
  /**
   * bind a function on window[id] so the scripts arrived, this function could be.  triggered
   * data would be a JSON object from the server
   */
  window[callbackName] = function(data) {
    clean()
    resolve(data)
  }

  if (options.timeout) {
    timer = setTimeout(() => {
      clean()
      reject('[ERROR] Time out.')
    }, options.timeout)
  }
})
複製程式碼

clean 函式非常重要,它負責回收資源。它會去 DOM 中移除這個 script 標籤,清除超時定時器,並且將 window[callbackName] 設定成一個什麼都不做的函式(為了防止呼叫非 function 報錯),這樣原來引用的那個閉包就會被垃圾回收掉了,避免了閉包帶來的記憶體洩露問題。

function clean() {
  script.parentNode && script.parentNode.removeChild(script)
  timer && clearTimeout(timer)
  window[callbackName] = doNothing // use nothing function instead of null to avoid crash
}
複製程式碼

以上就是全部的程式碼了,結合文章開頭說的 jsonp 的執行原理,很容易就能讀懂。完整程式碼:

/**
 * This module uses Promise API and make a JSONP request.
 *
 * @copyright MIT, 2018 Wendell Hu
 */

let uid = 0
const enc = encodeURIComponent
const defaultOptions = {
  prefix: '__jp',
  timeout: 60000,
  param: 'callback'
}

function doNothing() {}

/**
 * parameters:
 * - url: like http://somehostname:someport/to/some/path?with=true&orWithoutParams=false
 * - params: a plain object so we can help to parse them into url
 * - options: options to promise-jsonp
 *   - prefix {String}
 *   - timeout {Number}
 *   - name {String}: you can assign the callback name by your self, if provided, prefix would be invalid
 *   - param {String}: the key of callback function in request string
 *
 * thanks to Promise, you don't have to pass a callback or error handler
 *
 * @param {String} url
 * @param {Object} options
 * @param {Object} params
 * @returns {Promise}
 */
function pjsonp(url, params = {}, options) {
  if (!options) {
    options = params
    params = {}
  }

  if (!options) options = {}

  // merge default and user provided options
  options = Object.assign({}, defaultOptions, options)
  const callbackName = options.name || options.prefix + uid++

  let timer
  let script
  let target

  // remove a jsonp request, the callback function and the script tag
  // this is important for performance problems caused by closure
  function clean() {
    script.parentNode && script.parentNode.removeChild(script)
    timer && clearTimeout(timer)
    window[callbackName] = doNothing // use nothing function instead of null to avoid crash
  }

  // prepare url
  url += url.indexOf('?') > 0 ? '' : '?'
  for (let key in params) {
    let value = params[key] || ''
    url += `&${enc(key)}=${enc(value)}`
  }
  url += `&${enc(options.param)}=${enc(callbackName)}`
  url = url.replace('?&', '?')

  // insert the script to DOM and here we go!
  target = document.getElementsByTagName('script')[0] || document.head
  script = document.createElement('script')
  script.src = url
  target.parentNode.appendChild(script, target)

  /**
   * returns a Promise to tell user if this request succeeded or failed
   * as less code as possible here for clarity
   */
  return new Promise((resolve, reject) => {
    /**
     * bind a function on window[id] so the scripts arrived, this function could be triggered
     * data would be a JSON object from the server
     */
    window[callbackName] = function(data) {
      clean()
      resolve(data)
    }

    if (options.timeout) {
      timer = setTimeout(() => {
        clean()
        reject('[ERROR] Time out.')
      }, options.timeout)
    }
  })
}

module.exports = pjsonp
複製程式碼

這篇文章就到這裡,希望你已經完全理解了 jsonp 並且會實現它了。歡迎和我交流。

相關文章