项目:my-ts-axios | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
前 16 篇把系统拆成了模块、接口、流程与决策;本篇反其道而行——逐段带码走读三个核心文件,每个片段都直接引用真实源码(四反引号围栏包裹,代码与解读交替出现),目标是让读者体验"以源码为第一手材料"的阅读过程,而不是消费二手结论。选中的三个文件是:src/axios.ts(55 行,混合对象的诞生地,全库依赖图顶端)、src/core/dispatchRequest.ts(58 行,Promise 链的中心环节)、src/core/mergeConfig.ts(73 行,策略模式合并)。三份合计不到 200 行,却串起了"实例怎么造、请求怎么发、配置怎么合"三条主线——它们是《00-项目总览》第 6 节推荐阅读路线的前三步,本篇相当于把那三步走成全程实录。
走读方法有两条约定。其一,代码以当前版本为准,行号引用精确到源码文件(与《07-模块详解》的行号口径一致,重构后请以函数名为锚重新定位);引用做了必要的省略号折叠时,省略处会明确标注。其二,每一段走读以「这段代码教会我们什么」收束——它不是内容复述,而是从该段提炼的可迁移工程经验。如果想看这三个文件在更大结构中的位置,请配合《01-系统架构》第 1、2 节阅读;如果想看逐函数的完整机制说明(含未走读的字段),《07-模块详解》2.2、3.2、3.3 节是本篇的姊妹篇。
// src/axios.ts 第 15~26 行
const VERSION = '0.2.1'
// 混合对象实现思路很简单,首先这个对象是一个函数,其次这个对象要包括 Axios 类的所有原型属性和实例属性
function createInstance(config: AxiosRequestConfig): AxiosStatic {
const context = new Axios(config)
const instance = Axios.prototype.request.bind(context)
extend(instance, context)
return instance as AxiosStatic
}
// 添加默认配置
const axios = createInstance(defaults)第一行注释值得逐字读:"首先这个对象是一个函数,其次这个对象要包括 Axios 类的所有原型属性和实例属性"——这句话精确描述了后面三行代码。new Axios(config) 得到的是完整实例 context(它自带 defaults 与 interceptors 两个数据属性,见 src/core/Axios.ts 第 29~35 行的构造器);Axios.prototype.request.bind(context) 得到一个绑定了 this 的裸函数——注意绑定的是原型上的 request,而不是实例的任何东西,这保证了函数体内部 this.defaults、this.interceptors 的解析路径与类实例完全一致;extend(instance, context) 再把 context 的实例属性(defaults/interceptors)与原型方法(get/post/...)拷贝到函数上。
这里有一个极容易被忽略的隐式契约:拷贝上去的 get/post 等方法内部持有 this,当调用方执行 axios.get(...) 时,this 是 axios 函数对象本身——它能正常工作,仅仅因为函数对象上恰好也存在拷来的 defaults 与 interceptors。换句话说,方法族的正确性不依赖绑定,而依赖"拷贝的完整性"。这也解释了为什么 axios.create 必须写在本文件里(第 28~30 行):createInstance(mergeConfig(defaults, config)) 总是以全局 defaults 为底再叠加配置,新实例才能拿到完整默认值。
这段代码教会我们什么:当类型系统允许你描述"可调用的对象"(AxiosInstance 接口的调用签名,src/types/index.ts 第 207~211 行),运行时的对应物不是 Proxy 而是绑定函数加属性拷贝——三个原语(bind、for...in、getOwnPropertyNames)就够。为"未来可能的需求"引入 Proxy 转发层是典型的过早设计;反过来,拷贝方案留下的隐式契约(拷贝完整性)必须有人守——本项目的守门人是 scripts/smoke-dist.cjs 的导出断言,以及 ADR-001 里那段沿原型链的 extend(马上看到)。
// src/helpers/util.ts 第 21~38 行
export function extend<T, U>(to: T, from: U): T & U {
for (const key in from) {
;(to as T & U)[key] = from[key] as any
}
// ES6 class 方法是 non-enumerable,for...in 无法遍历到,
// 需要额外沿原型链拷贝(兼容 Vite/esbuild 保留原生 class 语义的场景)。
// 注意:回调里的 key 不能像 for...in 那样收窄为 keyof U,需用 any 索引
let proto = Object.getPrototypeOf(from)
while (proto && proto !== Object.prototype) {
;(Object.getOwnPropertyNames(proto) as Array<keyof U & string>).forEach((key) => {
if (key !== 'constructor' && !(key in (to as any))) {
;(to as any)[key] = (from as any)[key]
}
})
proto = Object.getPrototypeOf(proto)
}
return to as T & U
}函数分两段。第一段 for...in 覆盖可枚举自有属性——对 context 而言只有 defaults 和 interceptors 两个数据属性。第二段是事故的化石:ES6 class 的方法定义在原型上且不可枚举,当构建链是 Vite/esbuild(保留原生 class 语义,不把方法降级为可枚举赋值)时,for...in 连一个方法都遍历不到,axios.get 直接 undefined——这段注释记录的正是示例工程实际踩过的坑。解法是沿原型链逐层 getOwnPropertyNames(该方法不受 enumerable 限制,只列自有属性),跳过 constructor 与已存在键后拷贝。
三个细节的功力都在边界上。其一,循环终止条件是 proto && proto !== Object.prototype——拷到 Axios.prototype 这一层就停,不把 Object.prototype 的方法带上去。其二,!(key in to) 用的是 in 而不是 hasOwnProperty,即"目标已有(含继承)就不覆盖"——这保证了 axios.create 之前手工挂载的静态成员(如果挂载顺序不同)不会被拷贝覆盖。其三,类型层注释"回调里的 key 不能像 for...in 那样收窄为 keyof U"——for...in 的 key 在 TS 里被收窄为字面量联合,而 getOwnPropertyNames 的返回值没有这个保障,用 any 索引是诚实的类型让步而非偷懒。
这段代码教会我们什么:可枚举性是 JavaScript 里最常被忽略的元属性,任何"遍历属性做拷贝/合并/序列化"的代码都必须回答"我要不要不可枚举的自有属性"。更普适的经验是:环境差异(tsc 降级 vs esbuild 保留原生语义)会把"理论等价"的实现变成"实际不等价"——防御的方式不是抽象,而是在事故发生的确切位置留一段注释加一个更稳健的实现。
// src/axios.ts 第 28~53 行(节选,省略号处为同构的连续挂载)
axios.create = function create(config) {
return createInstance(mergeConfig(defaults, config))
}
axios.CancelToken = CancelToken
axios.Cancel = Cancel
axios.isCancel = isCancel
// ... axios.all / axios.spread / axios.Axios / axios.AxiosHeaders / axios.VERSION
axios.mergeConfig = mergeConfig
axios.getAdapter = getAdapter
axios.isAxiosError = isAxiosError
axios.CanceledError = CanceledError
axios.AxiosError = AxiosError
axios.toFormData = toFormData
axios.formToJSON = formToJSON这一段毫无技巧,却有两个值得驻足的设计决定。第一,create 没有箭头函数化也没有 bind,而是普通函数属性——它内部不用 this(直接引用模块级的 defaults 与 createInstance),这规避了"create 被解构使用时 this 丢失"的经典坑。第二,axios.all 直接就是 Promise.all 的别名(第 3436 行)、41 行)——历史上并发辅助存在于 Promise.all 普及之前,如今它们的存在更多是 API 兼容面,源码用最短的实现承认了这一点。挂载清单的完整性由 smoke-dist.cjs 第 15~33 行的 16 项断言点名兜底,任何一项缺失都会让发布失败。axios.spread 是数组展开回调的柯里化(第 37
这段代码教会我们什么:手工清单(这里的挂载区、smoke 的断言数组、类型面的 AxiosStatic)看似重复,实为三份互相校验的合同——运行时、冒烟、类型三方对齐才是完整的契约。能被机器校验的重复不是坏味道;不能被校验的才是。
// src/core/dispatchRequest.ts 第 8~22 行(全文)
export default function dispatchRequest(config: AxiosRequestConfig): AxiosPromise {
throwIfCancellationRequested(config)
processConfig(config)
return getAdapter(config)(config).then(
(res) => {
return transformResponseData(res)
},
(e) => {
if (e && e.response) {
e.response = transformResponseData(e.response)
}
return Promise.reject(e)
},
)
}58 行的文件里,主函数只有 15 行,却把"链上真正发请求的一环"的全部职责压进去了:预检(throwIfCancellationRequested——取消发生在发请求之前的时序在这里被拦截)、加工(processConfig——url/data/headers 三连)、分发(getAdapter(config)(config)——先解析适配器再调用)、收尾(成功与失败两条分支上的响应转换)。失败的分支是全函数最精彩的三行:错误携带 response 时(即 settle 判定的 4xx/5xx 状态错误),先对 e.response 补跑一遍响应转换再继续 reject——注意最后一句是 Promise.reject(e) 而不是 throw e,两者在这里语义等价,但显式 reject 让"错误只是被加工、没有被吞"的意图一目了然。
这三行解决的问题是同构库的体验一致性:settle 在适配器层 reject 时,response.data 还是原始字节(字符串/Buffer),如果直接上抛,调用方拿到的 error.response.data 无法直接使用。把"错误数据的补全"锚定在链路中层(而不是三个适配器各写一遍),保证了无论哪端,4xx/5xx 的 error.response.data 都是解析后的对象——这正是《01-系统架构》第 2 节"横切关注点收敛到链路中层"决策的物化点。
这段代码教会我们什么:Promise 链的中心环节应该像海关——所有货物过同一道检查,只做登记与加工,不改变货物的归属。then(成功, 失败) 双参形态在这里比两个 .then 链更合适:失败分支必须附着在适配器调用的同一环上,若写成 .then(成功).then(undefined, 失败),失败分支会额外捕获成功分支 transformResponseData 抛出的错误,语义就走样了。
// src/core/dispatchRequest.ts 第 24~37 行
export function processConfig(config: AxiosRequestConfig): void {
config.url = transformURL(config)
config.data = transform(config.data, config.headers, config.transformRequest)
config.headers = flattenHeaders(config.headers, config.method!)
}
export function transformURL(config: AxiosRequestConfig): string {
const { params, paramsSerializer, baseURL, allowAbsoluteUrls } = config
let { url } = config
if (baseURL && (allowAbsoluteUrls === undefined || allowAbsoluteUrls) && !isAbsoluteURL(url!)) {
url = combineURL(baseURL, url)
}
return buildURL(url!, params, paramsSerializer)
}processConfig 的三行有一个不可交换的顺序约束。url 必须最先:transformURL 只依赖 url/baseURL/params,与 data、headers 无关,先定形它不会影响后两步;data 必须在 headers 拍平之前:默认请求转换器(src/defaults.ts 第 37~55 行)要在分层 headers上判断 multipart 意图(getContentType 大小写不敏感地找 content-type),并把补写的 Content-Type 写进 headers——如果先 flatten,分层结构(common/method/顶层)已经被折平并删除,转换器看到的就不再是同一个对象;headers 拍平必须最后:flattenHeaders 把 common + method + 顶层 deepMerge 成平面对象并删除分层键,此后适配器拿到的就是最终形态。
transformURL 的条件式同样值得逐项读:baseURL && 先短路(无 baseURL 不拼接);allowAbsoluteUrls === undefined || allowAbsoluteUrls 表达"缺省视为允许,显式 false 才禁止"——与官方 v1.x 语义一致;!isAbsoluteURL(url!) 保证用户给的绝对地址不被 baseURL 污染。三个条件的合取使"baseURL + 绝对 url + allowAbsoluteUrls: false"这个组合的每种排列都有确定行为。注意所有加工都是对 config 的就地写(config.url = ...)——这与《04-数据模型》第 2 节"全程只有一个 config 对象被逐步加工"的结论互证。
这段代码教会我们什么:当多个加工步骤作用于同一个可变对象时,顺序本身就是接口——它不会出现在类型签名里,只存在于正确性中。把这种顺序写成注释容易,守住它难:本项目的守法是把三步各自拆成可独立测试的函数(transformURL 被 Axios.getUri 复用即一例),让"顺序错误"在单元测试里就现形,而不是在某个适配器的集成测试里玄学失败。
// src/core/dispatchRequest.ts 第 39~58 行(全文剩余部分)
function transformResponseData(res: AxiosResponse): AxiosResponse {
// transitional.forcedJSONParsing 为 false 时不强制 JSON 解析响应体
const transitional = res.config.transitional
const fns =
transitional && transitional.forcedJSONParsing === false
? undefined
: res.config.transformResponse
res.data = transform(res.data, res.headers, fns)
return res
}
function throwIfCancellationRequested(config: AxiosRequestConfig): void {
// 与官方一致:cancelToken 与 signal 双轨检查,已中止的请求在 dispatch 阶段直接拒绝
if (config.cancelToken) {
config.cancelToken.throwIfRequested()
}
if (config.signal && config.signal.aborted) {
throw new CanceledError('canceled', config)
}
}transformResponseData 是 transitional.forcedJSONParsing 全库唯一的消费点:置 false 时传给 transform 的转换器数组为 undefined,而 src/core/transform.ts 对空 fns 原样返回——于是响应体保持原始字符串。一个"跳过解析"的需求用"传空数组"实现而不是加 if 分支,是因为 transform 的空值短路本就是它的既有契约,不需要新语法。throwIfCancellationRequested 则是双轨取消在链路上的交汇点:cancelToken 轨同步 throw 一个 Cancel 实例(CancelToken.ts 第 26~30 行),signal 轨抛 new CanceledError('canceled', config)——两轨的错误形态不同(Cancel 是普通类、CanceledError 是 AxiosError 子类),但 isCancel 对两者都返回 true(ADR-006)。"预检"的价值在时序:如果用户在拿到 promise 之后、适配器启动之前就 abort,没有预检的话请求会照常发出;预检把"已取消"的判定提前到 dispatch 阶段。
这段代码教会我们什么:防御性代码有两种质地——throwIfCancellationRequested 是拒绝重复劳动(取消了的请求不再发),transformResponseData 是拒绝错误加工(不该解析的体不解析)。两者共同的实现哲学是:尽量复用已有原语(Promise 链的 reject、transform 的空数组短路),让新需求表现为既有机制的参数,而不是新机制。
// src/core/mergeConfig.ts 第 4~43 行
const strats = Object.create(null)
// 默认合并策略,优先去取val2的值
function defaultStrat(val1: any, val2: any): any {
return typeof val2 !== 'undefined' ? val2 : val1
}
// 只取val2的值,忽略val1
function fromVal2Strat(val1: any, val2: any): any {
if (typeof val2 !== 'undefined') {
return val2
}
}
const stratKeysFromVal2 = ['url', 'params', 'data']
// 将每一个key指向策略函数
stratKeysFromVal2.forEach((key) => {
strats[key] = fromVal2Strat
})
function deepMergeStrat(val1: any, val2: any): any {
if (isPlainObject(val2)) {
return deepMerge(val1, val2)
} else if (typeof val2 !== 'undefined') {
return val2
} else if (isPlainObject(val1)) {
// 深拷贝返回一个副本
return deepMerge(val1)
} else {
return val1
}
}
// 因为 auth 也是一个对象格式,所以它的合并规则是 deepMergeStrat
const stratKeysDeepMerge = ['headers', 'auth']
// 将每一个key指向策略函数
stratKeysDeepMerge.forEach((key) => {
strats[key] = deepMergeStrat
})三个策略函数的签名统一为 (val1, val2) => any,差异全部在语义上。defaultStrat:本次给值用本次,否则回退默认——覆盖所有未注册字段,这是"新增配置项零成本获得合理行为"的机制基础。fromVal2Strat:只认本次(注意 config2 未给时返回 undefined 而不是 val1)——url/params/data 属于"每次请求的载荷",继承实例默认值只会制造混乱(默认带个 data 是危险语义)。deepMergeStrat 四分支:val2 是普通对象→深合并;val2 非 undefined→直接用(headers 被整体替换的口子);val1 是普通对象→deepMerge(val1) 深拷贝默认值返回副本——这个分支是防污染的关键:若直接返回 val1 引用,后续对请求 headers 的就地修改(如 xhr 适配器删 Content-Type)会写穿到实例 defaults;否则→原样返回 val1。
表用 Object.create(null) 建而不用字面量,理由在 ADR-012 已述:字面量表继承 Object.prototype,strats[key] || defaultStrat 在 key 为 constructor/toString 时会误命中原型方法。注册方式用"键数组 + forEach 挂表"而不是逐字面量赋值,让"哪些字段用哪个策略"读起来是一行数组而不是散落的赋值——策略与字段的映射被压缩成了数据的形状。
这段代码教会我们什么:策略模式最被低估的价值不是"可扩展",而是把决策从控制流里挪进数据里——新增一个策略键改的是数组,不是函数体。而 deepMergeStrat 的深拷贝分支提醒我们:任何"返回默认值"的代码路径都要问一句"返回的是引用还是副本",在可变对象的世界里,这是隔离性与正确性的分界线。
// src/core/mergeConfig.ts 第 45~73 行(全文剩余部分)
export default function mergeConfig(
config1: AxiosRequestConfig,
config2?: AxiosRequestConfig,
): AxiosRequestConfig {
if (!config2) {
config2 = {}
}
const config = Object.create(null)
for (const key in config2) {
mergeField(key)
}
// 只有默认的配置中有字段(config2 已显式给出——含 falsy 值——的字段不再重复合并)
for (const key in config1) {
if (typeof config2[key] === 'undefined') {
mergeField(key)
}
}
function mergeField(key: string): void {
// strat是一个策略函数
const strat = strats[key] || defaultStrat
config[key] = strat(config1[key], config2![key])
}
return config
}主函数的骨架是两轮遍历。第一轮遍历 config2 的所有键——本次配置里出现过的字段(含显式 undefined,因为 for...in 只看键存在性)全部走一次 mergeField。第二轮遍历 config1,但只处理 config2 中值为 undefined 的键——注释精确点出语义:"已显式给出(含 falsy 值)的字段不再重复合并"。这带来一个必须理解的推论:config2.timeout = 0 会阻止 defaults 的 timeout 参与(0 是显式值),而 fromVal2Strat 下 config2.data = null 会以 null 为准而非继承——显式即生效,哪怕显式给的是 falsy。这与很多"默认值合并"实现里 || 式的 falsy 回退(0 被当作未设置)截然不同,axios 的 timeout: 0 表示"永不超时"正是依赖这一语义。
两轮遍历的产出写入 Object.create(null) 的结果对象——调用方拿到的配置没有原型,config.constructor 之类的原型键解析不发生,经由配置对象的原型链把戏被封死(《08-安全与权限设计》2.3 节)。还有一处不起眼的健壮性:config2 = {} 兜底让 mergeConfig(defaults) 单参调用合法——axios.create() 不传参也能拿到完整默认配置的独立实例,靠的就是这一行。
这段代码教会我们什么:合并算法的正确性藏在"什么算已设置"的定义里——typeof x !== 'undefined' 与 x != null 与 !!x 三种判据产出三种不同的库。这个库选择最严格的 typeof 判据,把"显式给 0/null/空串"都当作承诺。写合并逻辑前先写清楚这张语义表,比任何实现细节都重要。
三个文件走读下来,有几条贯穿性的观察值得收拢。其一,短文件是设计出来的:axios.ts 55 行、dispatchRequest.ts 58 行、mergeConfig.ts 73 行,三个文件没有一个超过 100 行,它们的短不是因为功能少,而是因为职责切得准——工厂只管造、分发只管加工与转发、合并只管策略,任何越界的冲动(比如把适配器选择塞进 dispatchRequest、把默认值逻辑塞进 mergeConfig)都会让行数与耦合同步膨胀。其二,注释是决策的存储介质:extend 的 esbuild 注释、deepMergeStrat 的"深拷贝返回一个副本"、throwIfCancellationRequested 的"与官方一致"——这些注释不是代码的复述,而是"为什么这样写"的唯一记录处,与《13-技术债务与演进建议》第 4 节总结的"债务自白传统"一脉相承。其三,就地修改是全库的统一风格但有边界:processConfig 对 config 就地写、mergeField 对结果对象就地写,但 deepMergeStrat 对默认值却刻意返回副本——"改自己链路上的东西可以就地,改共享状态必须拷贝",这条线画得非常清楚。若读者想继续走读,建议顺着本篇的末尾自然延伸:dispatchRequest 的下一站是三个适配器(src/core/adapters/xhr.ts 最短,205 行,适合先读),mergeConfig 的下一站是它消费的 deepMerge(src/helpers/util.ts 第 40~62 行)——两条路《07-模块详解》都有逐函数地图。