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 攔截器相關的文檔:
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 })
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 函數傳遞給它。
link 是 Link 的實例對象。
taro.request 是 link.request
taro.addInterceptor 是 link.addInterceptor
taro.cleanInterceptors 是 link.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 目錄,如下圖所示:
入口文件 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、interceptorify、interceptor/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.request,my.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 即可觸發斷點。調試如下圖所示:
Taro 源碼項目準備安裝依賴在第一篇文章中有詳細説明,這裏就不再贅述。
如果不太會調試,可參考我的文章新手向:前端程序員必學基本技能——調試 JS 代碼,或者據説 90%的人不知道可以用測試用例(Vitest)調試開源項目(Vue3) 源碼
我們調試完後,再去看 Taro 文檔和攔截器相關代碼就會豁然開朗,會有更深刻的理解。
這時不得不感慨一句:設計的很驚豔!
我們接着來看內置的攔截器,就比較簡單一些了,Taro 文檔中有説明。
7. 內置的兩個攔截器
Taro提供了兩個內置攔截器logInterceptor與timeoutInterceptor,分別用於打印請求的相關信息和在請求超時時拋出錯誤。
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 的具體實現和 Taro 的 request 請求和響應攔截器實現。
端平台插件運行時(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。
test1 是 Taro.request 傳入的參數,__exec 是最開始的參數。before、after 數字對應的是攔截器(async 函數 fn1, fn2, fn3)。
總之 Taro 攔截器設計的很驚豔,是設計模式中的職責鏈模式實現。和 koa-compose-文章、axios-文章 是實現的類似功能、只是實現方式不一樣。
如果看完有收穫,歡迎點贊、評論、分享、收藏支持。你的支持和肯定,是我寫作的動力。也歡迎提建議和交流討論。
作者:常以若川為名混跡於江湖。所知甚少,唯善學。若川的博客,github blog,可以點個 star 鼓勵下持續創作。
最後可以持續關注我@若川,歡迎關注我的公眾號:若川視野。從 2021 年 8 月起,我持續組織了好幾年的每週大家一起學習 200 行左右的源碼共讀活動,感興趣的可以點此掃碼加我微信 ruochuan02 參與。另外,想學源碼,極力推薦關注我寫的專欄《學習源碼整體架構系列》,目前是掘金關注人數(6k+人)第一的專欄,寫有幾十篇源碼文章。