博客 / 詳情

返回

Taro 源碼揭秘:7. Taro.request 請求和響應攔截器是如何實現的

1. 前言

大家好,我是若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。

截至目前(2024-09-18),taro 4.0 正式版已經發布,目前最新是 4.0.5,官方4.0正式版本的介紹文章暫未發佈。官方之前發過Taro 4.0 Beta 發佈:支持開發鴻蒙應用、小程序編譯模式、Vite 編譯等。

計劃寫一個 Taro 源碼揭秘系列,博客地址:https://ruochuan12.github.io/taro 可以加入書籤,持續關注若川。

  • [x] 1. 揭開整個架構的入口 CLI => taro init 初始化項目的秘密
  • [x] 2. 揭開整個架構的插件系統的秘密
  • [x] 3. 每次創建新的 taro 項目(taro init)的背後原理是什麼
  • [x] 4. 每次 npm run dev:weapp 開發小程序,build 編譯打包是如何實現的?
  • [x] 5. 高手都在用的發佈訂閲機制 Events 在 Taro 中是如何實現的?
  • [x] 6. 為什麼通過 Taro.xxx 能調用各個小程序平台的 API,如何設計實現的?
  • [x] 7. Taro.request 和請求響應攔截器是如何實現的
  • [x] 8. Taro 是如何使用 webpack 打包構建小程序的?
  • [x] 9. Taro 是如何生成 webpack 配置進行構建小程序的?
  • [x] 10. Taro 到底是怎樣轉換成小程序文件的?
  • [ ] 等等

前面 4 篇文章都是講述編譯相關的,CLI、插件機制、初始化項目、編譯構建流程。第 7 篇我們來講些相對簡單的,Taro.request 和請求響應攔截器是如何實現的?文章以微信小程序為例。

關於請求響應攔截器,我曾在 2019年 寫過 axios 源碼文章(575贊、761收藏、2.5w閲讀),還畫了 axios 攔截器的圖。雖然之前文章寫的版本是v0.19.x ,但是相比現在的源碼整體結構基本沒有太大變化,感興趣的可以看看。

關於克隆項目、環境準備、如何調試代碼等,參考第一篇文章-準備工作、調試。後續文章基本不再過多贅述。

學完本文,你將學到:

1. Taro.request 的實現
2. Taro.addInterceptor 請求和響應攔截器的使用和具體實現
等等

我們先來看文檔,熟悉 Taro.request 的使用。

2. Taro request 相關文檔

平常業務開發

import Taro from '@tarojs/taro'

Taro.request(url).then(function (res) {
  console.log(res)
})

我們來看下 Taro 攔截器相關的文檔:

添加攔截器.png

Taro.addInterceptor 示例代碼1

const interceptor = function (chain) {
  const requestParams = chain.requestParams
  const { method, data, url } = requestParams

  console.log(`http ${method || 'GET'} --> ${url} data: `, data)

  return chain.proceed(requestParams)
    .then(res => {
      console.log(`http <-- ${url} result:`, res)
      return res
    })
  }
Taro.addInterceptor(interceptor)
Taro.request({ url })

Taro.addInterceptor文檔 示例代碼2

Taro.addInterceptor(Taro.interceptors.logInterceptor)
Taro.addInterceptor(Taro.interceptors.timeoutInterceptor)
Taro.request({ url })

清除所有攔截器.png

Taro.cleanInterceptors 清除所有攔截器

Taro.cleanInterceptors()

@tarojs/taro 對應的源碼。

// packages/taro/index.js
const { hooks } = require('@tarojs/runtime')
const taro = require('@tarojs/api').default

if (hooks.isExist('initNativeApi')) {
  hooks.call('initNativeApi', taro)
}

module.exports = taro
module.exports.default = module.exports

@tarojs/api 源碼暫時先不講述。

我們來回顧下上篇文章中 Taro 源碼揭秘 - 6. 為什麼通過 Taro.xxx 能調用各個小程序平台的 API,如何設計實現的?

在端平台插件運行時 runtime 中,mergeReconciler(hostConfig) hooks.tap 註冊事件 initNativeApi
hostConfig 對象中有 initNativeApi 函數
initNativeApi 函數中調用了 processApis 函數。
processApis 中調用了 equipCommonApis 這個函數掛載常用的apis。

3. equipCommonApis 掛載公共 apis

// packages/shared/src/native-apis.ts
/**
 * 掛載常用 API
 * @param taro Taro 對象
 * @param global 小程序全局對象,如微信的 wx,支付寶的 my
 */
function equipCommonApis (taro, global, apis: Record<string, any> = {}) {
  // 省略若干代碼
  // 添加 request 和攔截器
  // request & interceptors
  const request = apis.request || getNormalRequest(global)
  function taroInterceptor (chain) {
    return request(chain.requestParams)
  }
  const link = new taro.Link(taroInterceptor)
  taro.request = link.request.bind(link)
  taro.addInterceptor = link.addInterceptor.bind(link)
  taro.cleanInterceptors = link.cleanInterceptors.bind(link)
  //   省略若干代碼
}

你可能會問:

我們接着來看 getNormalRequest 是怎麼實現的?

taro.Link 哪來的,為什麼把 taroInterceptor 函數傳遞給它。
linkLink 的實例對象。
taro.requestlink.request
taro.addInterceptorlink.addInterceptor
taro.cleanInterceptorslink.cleanInterceptors

我們接着先來看 getNormalRequest 的代碼實現,再看 Link 的代碼實現。尋找問題的答案。

3.1 getNormalRequest 獲取標準的 request

getNormalRequest 返回一個 request 函數,request 函數返回的是 promise

// packages/shared/src/native-apis.ts
function getNormalRequest (global) {
  return function request (options) {
    // 第一步:先是處理下 options,有值,如果是字符串傳入url,不是就直接返回 options
    // 沒有值賦值空對象
    options = options
      ? (
        isString(options)
          ? { url: options }
          : options
      )
      : {}

    const originSuccess = options.success
    const originFail = options.fail
    const originComplete = options.complete

    // 第二步:聲明 requestTask 和 promise
    let requestTask
    const p: any = new Promise((resolve, reject) => {
      options.success = res => {
        originSuccess && originSuccess(res)
        resolve(res)
      }
      options.fail = res => {
        originFail && originFail(res)
        reject(res)
      }

      options.complete = res => {
        originComplete && originComplete(res)
      }

      // 參數傳入 global.request ,global 是 wx、my 等
      requestTask = global.request(options)
    })

    // 第三步:將Task對象中的方法掛載到 promise 對象中,適配小程序 api 原生返回結果
    equipTaskMethodsIntoPromise(requestTask, p)

    // 第四步:取消,調用 task 的取消
    p.abort = (cb) => {
      cb && cb()
      if (requestTask) {
        requestTask.abort()
      }
      return p
    }
    // 最後返回 promise,也就是為什麼可以調用 Taro.request then、catch 方法
    return p
  }
}

getNormalRequest 的實現步驟:

  • 第一步:先是處理下 options,有值,如果是字符串傳入url,不是就直接返回 options
  • 第二步:聲明 requestTask 和 promise
  • 第三步:將 Task 對象中的方法掛載到 promise 對象中,適配小程序 api 原生返回結果
  • 第四步:取消,調用 task 的取消
  • 最後返回 promise,也就是為什麼可以調用 Taro.request then、catch 方法

其中調用了 equipTaskMethodsIntoPromise 方法,我們簡單看下這個方法的實現:

3.2 equipTaskMethodsIntoPromise 適配小程序 api 原生返回結果

equipTaskMethodsIntoPromise 方法的實現如下:

// packages/shared/src/native-apis.ts
/**
 * 將Task對象中的方法掛載到promise對象中,適配小程序api原生返回結果
 * @param task Task對象 {RequestTask | DownloadTask | UploadTask}
 * @param promise Promise
 */
function equipTaskMethodsIntoPromise (task, promise) {
  if (!task || !promise) return
  const taskMethods = ['abort', 'onHeadersReceived', 'offHeadersReceived', 'onProgressUpdate', 'offProgressUpdate', 'onChunkReceived', 'offChunkReceived']
  task && taskMethods.forEach(method => {
    if (method in task) {
      promise[method] = task[method].bind(task)
    }
  })
}

文檔 - wx.uploadFile |
文檔 - 返回值 UploadTask |
文檔 - wx.downloadFile |
文檔 - 返回值 DownloadTask |

看完了 getNormalRequest 的實現,我們接着來看 Link 的具體實現,我們可以知道 new taro.Link 必定是在 @tarojs/taro 實現的。 文章開頭沒有展開講述的 @tarojs/api

4. @tarojs/api 所有端的公共 API

暴露給 @tarojs/taro 的所有端的公有 API。@tarojs/api 會跨 node/瀏覽器/小程序/React Native 使用,不得使用/包含平台特有特性。

我們來看下 @tarojs/api 目錄,如下圖所示:

@tarojs/api 目錄.png

入口文件 packages/taro-api/src/index.ts

再就是攔截器實現的文件夾 packages/taro-api/src/interceptor/

  • packages/taro-api/src/interceptor/index.ts 入口
  • packages/taro-api/src/interceptor/interceptor/chain.ts 鏈
  • packages/taro-api/src/interceptor/interceptor/interceptors.ts 內置的攔截器

我們先來看 @tarojs/api 的入口文件。

// packages/taro-api/src/index.ts

/* eslint-disable camelcase */
// 省略若干代碼
import Link, { interceptorify } from './interceptor'
import * as interceptors from './interceptor/interceptors'

const Taro: Record<string, unknown> = {
  Link,
  interceptors,
  interceptorify,
}

export default Taro

interceptor 導出 Link、interceptorifyinterceptor/interceptors 導出所有內置攔截器,賦值到 Taro 上。

5. Link 構造函數

5.1 攔截器入口文件 src/interceptor/index.ts

// packages/taro-api/src/interceptor/index.ts
import Chain from './chain'

import type { IRequestParams, TInterceptor } from './chain'

export default class Link {
  taroInterceptor: TInterceptor
  chain: Chain

  constructor (interceptor: TInterceptor) {
    // 傳入的攔截器是返回一個 promise 的函數
    this.taroInterceptor = interceptor
    // 初始化 Chain 實例對象
    this.chain = new Chain()
  }

  request (requestParams: IRequestParams) {
    // 省略,拆開下方講述
  }

  // 添加攔截器
  addInterceptor (interceptor: TInterceptor) {
    this.chain.interceptors.push(interceptor)
  }

  // 清空攔截器
  cleanInterceptors () {
    this.chain = new Chain()
  }
}

// 轉換成 Taro 攔截器
export function interceptorify (promiseifyApi) {
  return new Link(function (chain) {
    return promiseifyApi(chain.requestParams)
  })
}

我們來看下 link.request 的實現:

export default class Link {
  request (requestParams: IRequestParams) {
    const chain = this.chain
    const taroInterceptor = this.taroInterceptor

    chain.interceptors = chain.interceptors
      .filter(interceptor => interceptor !== taroInterceptor)
      .concat(taroInterceptor)

    return chain.proceed({ ...requestParams })
  }
}

Link 實例對象的 request 方法代碼也不多,把傳入的攔截器,放到最後。
最後調用鏈的 proceed 方法 chain.proceed

你可能會問,chain 是如何實現的呢。

那麼我們來看 chain 的具體實現:

5.2 鏈 src/interceptor/chain.ts

Taro文檔:在調用 Taro.request 發起請求之前,調用 Taro.addInterceptor 方法為請求添加攔截器,攔截器的調用順序遵循洋葱模型。 攔截器是一個函數,接受 chain 對象作為參數。chain 對象中含有 requestParmas 屬性,代表請求參數。攔截器內最後需要調用 chain.proceed(requestParams) 以調用下一個攔截器或發起請求。
// packages/taro-api/src/interceptor/chain.ts

import { isFunction } from '@tarojs/shared'

export type TInterceptor = (c: Chain) => Promise<void>

export interface IRequestParams {
  timeout?: number
  method?: string
  url?: string
  data?: unknown
}

export default class Chain {
  index: number
  requestParams: IRequestParams
  interceptors: TInterceptor[]

  constructor (requestParams?: IRequestParams, interceptors?: TInterceptor[], index?: number) {
    // 初始化三個參數,索引值,請求參數和存放攔截器的數組
    this.index = index || 0
    this.requestParams = requestParams || {}
    this.interceptors = interceptors || []
  }

  proceed (requestParams: IRequestParams = {}) {
    // 省略,拆開放到下方單獨講述
  }

  // 內部方法,取下一個攔截器
  _getNextInterceptor () {
    return this.interceptors[this.index]
  }

  // 內部方法,取下一個鏈
  _getNextChain () {
    return new Chain(this.requestParams, this.interceptors, this.index + 1)
  }
}

我們來看 chain.proceed 方法的實現:

export default class Chain {
  proceed (requestParams: IRequestParams = {}) {
    // 第一步:調用這個方法的參數賦值為請求參數
    this.requestParams = requestParams
    // 第二步:攔截器數量不對拋出錯誤
    if (this.index >= this.interceptors.length) {
      throw new Error('chain 參數錯誤, 請勿直接修改 request.chain')
    }
    // 第三步:取出下一個攔截器
    const nextInterceptor = this._getNextInterceptor()
    // 第四步:取出下一個鏈
    const nextChain = this._getNextChain()
    // 第六步:把下一個鏈作為參數傳入到下一個攔截器調用,返回 promise
    const p = nextInterceptor(nextChain)
    // 第六步:捕獲錯誤
    const res = p.catch(err => Promise.reject(err))
    // 第七步:遍歷實例對象 promise,如果是函數就賦值到 res[k] = p[k];
    // 這裏其實是兼容小程序 api 原生返回結果 promise 對象上還有 abort 等函數方法。
    Object.keys(p).forEach(k => isFunction(p[k]) && (res[k] = p[k]))
    // 第八步:返回 res promise
    return res
  }
}

chain.proceed 簡單來説就是調用下一個攔截器函數(async)。

6. 藉助項目提供的 jest 測試用例調試攔截器

chain.proceed 函數比較抽象,第一次看沒有看懂很正常。

我們可以藉助項目中提供的測試用例進行調試。

我們很容易就找到了攔截器的測試用例文件:packages/taro-api/__tests__/interceptorify.test.ts。我們來看測試用例的具體代碼:

// packages/taro-api/__tests__/interceptorify.test.ts
import Taro from '@tarojs/taro'

describe('taro interceptorify', () => {
  it('onion competency model', async () => {
    interface IParams {
      msg: string
    }
    // 轉為攔截器,相當於是 wx.request
    const execLink = Taro.interceptorify<IParams, IParams>(async function (requestParams) {
      requestParams.msg += '__exec'
      return requestParams
    })
    // 添加攔截器1
    execLink.addInterceptor(async function (chain) {
      chain.requestParams.msg += '__before1'
      const params = await chain.proceed(chain.requestParams)
      params.msg += '__after1'
      return params
    })
    // 添加攔截器2
    execLink.addInterceptor(async function (chain) {
      chain.requestParams.msg += '__before2'
      const params = await chain.proceed(chain.requestParams)
      params.msg += '__after2'
      return params
    })
    // 添加攔截器3
    execLink.addInterceptor(async function (chain) {
      chain.requestParams.msg += '__before3'
      const params = await chain.proceed(chain.requestParams)
      params.msg += '__after3'
      return params
    })
    // 執行 request 把攔截器串聯起來
    const res1 = await execLink.request({ msg: 'test1' })
    expect(res1.msg).toBe('test1__before1__before2__before3__exec__after3__after2__after1')

    // 清空攔截器
    execLink.cleanInterceptors()
    // 再執行結果
    const res2 = await execLink.request({ msg: 'test2' })
    expect(res2.msg).toBe('test2__exec')
  })
})

雖然看起來代碼比較多,但我們可以看到這部分代碼做的事情相對簡單:

  • Taro.interceptorify 類似於是封裝 wx.requestmy.request
// 前文提到的
// packages/shared/src/native-apis.ts
const request = apis.request || getNormalRequest(global)
function taroInterceptor (chain) {
    return request(chain.requestParams)
}
const link = new taro.Link(taroInterceptor)
  • execLink.addInterceptor 添加攔截器 1、2、3。
  • 執行 await execLink.request() 把攔截器從第 0 個開始串聯起來。
  • 輸出結果,符合期望。
  • execLink.cleanInterceptors() 清空攔截器。
  • 再執行 await execLink.request()
  • 輸出結果,符合期望。
  • 測試用例通過。

msg 結果是這樣:test1__before1__before2__before3__exec__after3__after2__after1,就能很好的反應出攔截器(async 函數)的執行順序。熟悉的小夥伴會知道這是典型的洋葱模型。Taro 文檔也有説明:攔截器的調用順序遵循洋葱模型。

如果瞭解 koa-compose,就會發現 chain.proceed 其實就是 koa-compose 中的 next 方法。只不過實現方式不一樣而已。
我曾經寫過 50行代碼串行Promise,koa洋葱模型原來是這麼實現?,可以對比學習。

關於如何調試,貢獻文檔-單元測試中有提到:

package.json 中設置了 test:ci 命令的子包都配備了單元測試。
開發者在修改這些包後,請運行 pnpm --filter [package-name] run test:ci,檢查測試用例是否都能通過。

我們提前在 packages/taro-api/__tests__/interceptorify.test.ts 文件中根據自己情況打好斷點,再新建一個終端JavaScript debug Termial,執行 pnpm --filter @tarojs/api run test:ci 即可觸發斷點。調試如下圖所示:

調試攔截器圖.png

Taro 源碼項目準備安裝依賴在第一篇文章中有詳細説明,這裏就不再贅述。

如果不太會調試,可參考我的文章新手向:前端程序員必學基本技能——調試 JS 代碼,或者據説 90%的人不知道可以用測試用例(Vitest)調試開源項目(Vue3) 源碼

我們調試完後,再去看 Taro 文檔和攔截器相關代碼就會豁然開朗,會有更深刻的理解。

這時不得不感慨一句:設計的很驚豔!

我們接着來看內置的攔截器,就比較簡單一些了,Taro 文檔中有説明。

7. 內置的兩個攔截器

Taro 提供了兩個內置攔截器 logInterceptortimeoutInterceptor,分別用於打印請求的相關信息和在請求超時時拋出錯誤。

Taro.addInterceptor文檔 示例代碼2

Taro.addInterceptor(Taro.interceptors.logInterceptor)
Taro.addInterceptor(Taro.interceptors.timeoutInterceptor)
Taro.request({ url })

import * as interceptors from './interceptor/interceptors'

@tarojs/api 入口文件也有導出內置的攔截器掛載到 Taro 上。

我們接着來學習它們的具體實現。

7.1 timeoutInterceptor 超時攔截器

// packages/taro-api/src/interceptor/interceptors.ts
import { isFunction, isUndefined } from '@tarojs/shared'

import type Chain from './chain'

export function timeoutInterceptor (chain: Chain) {
  const requestParams = chain.requestParams
  let p: Promise<void>
  const res = new Promise<void>((resolve, reject) => {
    const timeout: ReturnType<typeof setTimeout> = setTimeout(() => {
      clearTimeout(timeout)
      reject(new Error('網絡鏈接超時,請稍後再試!'))
    }, (requestParams && requestParams.timeout) || 60000)

    p = chain.proceed(requestParams)
    p
      .then(res => {
        if (!timeout) return
        clearTimeout(timeout)
        resolve(res)
      })
      .catch(err => {
        timeout && clearTimeout(timeout)
        reject(err)
      })
  })
  // @ts-ignore
  // 這裏是兼容 小程序 原生 api 返回結果,不過感覺是不是少兼容了
  if (!isUndefined(p) && isFunction(p.abort)) res.abort = p.abort

  return res
}

簡言之:超時攔截器。

7.2 logInterceptor 日誌攔截器

// packages/taro-api/src/interceptor/interceptors.ts
export function logInterceptor (chain: Chain) {
  const requestParams = chain.requestParams
  const { method, data, url } = requestParams

  // eslint-disable-next-line no-console
  console.log(`http ${method || 'GET'} --> ${url} data: `, data)

  const p = chain.proceed(requestParams)
  const res = p
    .then(res => {
      // eslint-disable-next-line no-console
      console.log(`http <-- ${url} result:`, res)
      return res
    })
  // @ts-ignore
  // 這裏是兼容 小程序 原生 api 返回結果,不過感覺是不是少兼容了
  if (isFunction(p.abort)) res.abort = p.abort

  return res
}

簡言之:就是輸出下日誌。

8. 總結

我們從文檔出發 Taro.request 的使用和文檔中攔截器的使用,分析了 Taro.request 的具體實現和 Tarorequest 請求和響應攔截器實現。
端平台插件運行時(runtime)掛載 global.request
@tarojs/taro@tarojs/api 中實現的 Chain 鏈和 Link request 構造函數。

最後,我們再回顧下:

Taro文檔:在調用 Taro.request 發起請求之前,調用 Taro.addInterceptor 方法為請求添加攔截器,攔截器的調用順序遵循洋葱模型。 攔截器是一個函數,接受 chain 對象作為參數。chain 對象中含有 requestParmas 屬性,代表請求參數。攔截器內最後需要調用 chain.proceed(requestParams) 以調用下一個攔截器或發起請求。

攔截器的調用順序遵循洋葱模型,測試用例中輸出順序:test1__before1__before2__before3__exec__after3__after2__after1

test1Taro.request 傳入的參數,__exec 是最開始的參數。before、after 數字對應的是攔截器(async 函數 fn1, fn2, fn3)。

總之 Taro 攔截器設計的很驚豔,是設計模式中的職責鏈模式實現。和 koa-compose-文章、axios-文章 是實現的類似功能、只是實現方式不一樣。


如果看完有收穫,歡迎點贊、評論、分享、收藏支持。你的支持和肯定,是我寫作的動力。也歡迎提建議和交流討論

作者:常以若川為名混跡於江湖。所知甚少,唯善學。若川的博客,github blog,可以點個 star 鼓勵下持續創作。

最後可以持續關注我@若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.