又是一篇关于 Typescript 的分享,年底了,请允许我沉淀一下。上次用 Typescript 重构 Vconsole 的项目 埋下了对 Axios 源码解析的梗。于是,这次分享的主题就是 如何从零用 Typescript 重构 Axios 以及为什么我要这么做。
笔者在用 Typescript 重复造轮子的时候目的还是很明确的,不仅是为了用 Typescript 养成一种好的开发习惯,更重要的是了解工具库关联的基础知识。 只有更多地注重基础知识,才能早日摆脱文档工程师的困扰。
本次分享包括以下内容:
- 工程简介 & 开发技巧
- API 实现
- XHR,XHR,XHR
- HTTP,HTTP,HTTP
- 单元测试
项目源码,分享可能会错过某些细节实现,需要的可以看源码,测试用例基本跑通了。想想,5w star 的库,就这样自己实现了一遍。
工程简介Axios 是什么
Promise based HTTP client for the browser and node.js
axios 是基于 Promise 用于浏览器和 nodejs 的 HTTP 客户端,它本身具有以下特性 :
- √ 从浏览器创建 XMLHttpRequest => XHR 实现
- √ 支持 Promise API => XHR 实现
- √ 拦截请求和响应 => 请求拦截
- √ 转换请求和响应数据 => 对应项目目录
/src/core/dispatchRequest.ts
- √ 取消请求 取消请求
- √ 自动转换 JSON 数据 => 对应项目目录
/src/core/dispatchRequest.ts
- √ 客户端支持防止 CSRF/XSRF => CSRF
- × 从 node.js 发出 http 请求
这里主要讲解浏览器端的 XHR 实现,限于篇幅不会涉及 node 下的 http 。如果你愿意一层一层了解它,你会发现实现 axios 还是很简单的,来一起探索吧!
目录说明首先来看下目录。
Typescript 整体配置和规范检测参考如下:
- tsconfig.json
- tslint
强烈建议开启 tslint
,安装 vscode tslint 插件 并在 .vscode
目录下的 .setting
配置如下格式:
{ "editor.tabSize": 2, "editor.rulers": [120], "files.trimTrailingWhitespace": true, "files.insertFinalnewline": true, "files.exclude": { "**/.git": true, "**/.DS_Store": true }, "eslint.enable": false, "tslint.autoFixOnSave": true, "typescript.format.enable": true, "typescript.tsdk": "node_modules/typescript/lib"}
如果有安装 Prettier需注意两者风格冲突,无论格式化代码的插件是什么,我们的目的只有一个,就是 保证代码格式化风格统一。( 最好遵循 lint 规范 )。
ps:.vscode
目录可随 git 跟踪进版本管理,这样可以让 clone 仓库的使用者更友好。
另外可以通过,vscode 的 控制面板中的问题 tab 迅速查看当前项目问题所在。
- 如果需要导入其他库可参考quokka 配置
- 希望引入浏览器环境,可在 quokkajs 项目目录全局安装jsdom-quokka-plugin插件
接着像这样
;const testDiv = document.getElementById;console.log;
总得下来就是五类 API,比葫芦娃还少。有信心了吧,我们来一个个"送人头"。
Axios 类这些 API 可以统称为实例方法,有实例,就肯定有类。所以在讲 API 实现之前,先让我们来看一下 Axios 类。
对于这种情况,使用 Typescript 可以在开发阶段规避这些问题。但如果是动态赋值,需要给值判断下类型,必要时可抛出错误或转换为其他想要的值。
接着来看下 axios url 相关,主要提供了 baseURL 的支持,可以通过 axios.defaults.baseURL
或 axios
const isAbsoluteURL = : boolean => { // 1、判断是否为协议形式比如 http:// return /^ ///i.test;};const combineURLs = : string => { return relativeURL baseURL.replace + '/' + relativeURL.replace : baseURL;};const suportbaseURL = => { // 2、baseURL 处理 return baseURL && !isAbsoluteURL combineURLs : url;};
在 axios 中 发送请求时 params 和 data 的区别在于:
- params 是添加到 url 的请求字符串中的,用于 get 请求。
- data 是添加到请求体(body)中的, 用于 post 请求。
axios 对 params 的处理分为赋值和序列化
请求头可以被定义为:被用于 http 请求中并且和请求主体无关的那一类 HTTP header。某些请求头如Accept
,Accept-*
,If-*``允许执行条件请求。某些请求头如:cookie
,User-Agent
和Referer
描述了请求本身以确保服务端能返回正确的响应。
并非所有出现在请求中的 http 首部都属于请求头,例如在 POST 请求中经常出现的 Content-Length
实际上是一个代表请求主体大小的 entity header,虽然你也可以把它叫做请求头。
消息头列表
axios 根据请求方法 设置了不同的 Content-Type
和 Accpect
请求头。
XMLHttpRequest 对象提供的 XMLHttpRequest对象提供的.setRequestHeader
方法为开发者提供了一个操作这两种头部信息的方法,并允许开发者自定义请求头的头部信息。
XMLHttpRequest.setRequestHeader 是设置 HTTP 请求头部的方法。此方法必须在 open 方法和 send 之间调用。如果多次对同一个请求头赋值,只会生成一个合并了多个值的请求头。
如果没有设置 Accept 属性,则此发送出 send 的值为此属性的默认值/ 。
安全起见,有些请求头的值只能由 user agent 设置:forbidden header names 和 forbidden response header names.
默认情况下,当发送 AJAX 请求时,会附带以下头部信息:
axios 设置代码如下:
// 在 adapters 目录下的 xhr.ts 文件中:if { // 通过 XHR 的 setRequestHeader 方法设置请求头信息 for { if ) { const val = requestHeaders[key]; if === 'content-type' ) { delete requestHeaders[key]; } else { request.setRequestHeader; } } }}
至于能不能修改 http header,我的建议是当然不能随便修改任何字段。
- 有一些字段是绝对不能修改的,比如最重要的 host 字段,如果没有 host 值,http1.1 协议会认为这是一个不规范的请求从而直接丢弃。同样的如果随便修改这个值,那目的网站也返回不了正确的内容
- user-agent 也不建议随便修改,有很多网站是根据这个字段做内容适配的,比如 PC 和手机肯定是不一样的内容。
- 有一些字段能够修改,比如
connection
,cache-control
等。不会影响你的正常访问,但有可能会慢一点。
- 还有一些字段可以删除,比如你不希望网站记录你的访问行为或者历史信息,你可以删除 cookie,referfer 等字段。
- 当然你也可以自定义构造任意你想要的字段,一般没什么影响,除非 header 太长导致内容截断。通常自定义的字段都建议 X-开头。比如 X-test: lance。
只要是用户主动输入网址访问时发送的 http 请求,那这些头部字段都是浏览器自动生成的,比如 host,cookie,user-agent, Accept-Encoding 等。JS 能够控制浏览器发起请求,也能在这里增加一些 header,但是考虑到安全和性能的原因,对 JS 控制 header 的能力做了一些限制,比如 host 和 cookie, user-agent 等这些字段,JS 是无法干预的禁止修改的消息首部。关于 HTTP 的知识实在多,这里简单谈到相关联的知识。这里埋下伏笔,后续若有更适合讲 HTTP 的例子,再延伸。
接下来的 CSRF,就会修改 headers。
CSRF与 CSRF 相关的配置属性有这三个:
export interface AxiosRequestConfig { xsrfcookieName : string xsrfHeaderName : string withCredentials : boolean;}// 默认配置为{ xsrfcookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN', withCredentials: false}
那么,先来简单了解 CSRF
跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。什么是 CSRF 攻击
你这可以这么理解 CSRF 攻击:攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF 能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账。造成的问题包括:个人隐私泄露以及财产安全。
CSRF 原理在他们的钓鱼站点,攻击者可以通过创建一个 AJAX 按钮或者表单来针对你的网站创建一个请求:
<form action="https://my.site.com/me/something-destructive" method="POST"> <button type="submit">Click here for free money!button>form>
要完成一次 CSRF 攻击,受害者必须依次完成两个步骤:
1.登录受信任网站 A,并在本地生成 cookie。
2.在不登出 A 的情况下,访问危险网站 B。
如果减轻 CSRF 攻击?只使用 JSON api使用 Javascript 发起 AJAX 请求是限制跨域的。 不能通过一个简单的 来发送
JSON
, 所以,通过只接收 JSON,你可以降低发生上面那种情况的可能性。
第一种减轻 CSRF 攻击的方法是禁用 cross-origin requests。如果你希望允许跨域请求,那么请只允许 OPTIONS, HEAD, GET
方法,因为他们没有副作用。不幸的是,这不会阻止上面的请求由于它没有使用 Javascript。
HTTP 头中有一个 Referer 字段,这个字段用以标明请求来源于哪个地址。在处理敏感数据请求时,通常来说,Referer 字段应和请求的地址位于同一域名下。这种办法简单易行,工作量低,仅需要在关键访问处增加一步校验。但这种办法也有其局限性,因其完全依赖浏览器发送正确的 Referer 字段。虽然 http 协议对此字段的内容有明确的规定,但并无法保证来访的浏览器的具体实现,亦无法保证浏览器没有安全漏洞影响到此字段。并且也存在攻击者攻击某些浏览器,篡改其 Referer 字段的可能。
CSRF Tokens最终的解决办法是使用 CSRF tokens。 CSRF tokens 是如何工作的呢?
- 服务器发送给客户端一个 token。
- 客户端提交的表单中带着这个 token。
- 如果这个 token 不合法,那么服务器拒绝这个请求。
攻击者需要通过某种手段获取你站点的 CSRF token, 他们只能使用 Javascript 来做。 所以,如果你的站点不支持 CORS, 那么他们就没有办法来获取 CSRF token, 降低了威胁。
确保 CSRF token 不能通过 AJAX 访问到!
不要创建一个/CSRF
路由来获取一个 token, 尤其不要在这个路由上支持 CORS!
token 需要是不容易被猜到的, 让它很难被攻击者尝试几次得到。 它不需要是密码安全的。 攻击来自从一个未知的用户的一次或者两次的点击, 而不是来自一台服务器的暴力攻击。
axios 中的 CSRF Tokens这里有个 withCredentials
,先来了解下。
XMLHttpRequest.withCredentials 属性是一个 Boolean 类型,它指示了是否该使用类似 cookies,authorization headers或者 TLS 客户端证书这一类资格证书来创建一个跨站点访问控制(cross-site Access-Control)请求。在同一个站点下使用 withCredentials 属性是无效的。
如果在发送来自其他域的 XMLHttpRequest 请求之前,未设置 withCredentials 为 true,那么就不能为它自己的域设置 cookie 值。而通过设置 withCredentials 为 true 获得的第三方 cookies,将会依旧享受同源策略,因此不能被通过 document.cookie 或者从头部相应请求的脚本等访问。
// 在标准浏览器环境下 则添加 xsrf 头if ) { // 必须在 withCredentials 或 同源的情况,才设置 xsrfHeader 头 const xsrfValue = ) && xsrfcookieName cookies.read : undefined; if { requestHeaders[xsrfHeaderName] = xsrfValue; }}
对于 CSRF,需要让后端同学,敏感的请求不要使用类似 get 这种幂等的,但是由于 Form 表单发起的 POST 请求并不受 CORS 的限制,因此可以任意地使用其他域的 cookie 向其他域发送 POST 请求,形成 CSRF 攻击。
这时,如果有涉及敏感信息的请求,需要跟后端同学配合,进行 XSRF-Token 认证。此时,我们用 axios 请求的时候,就可以通过设置 XMLHttpRequest.withCredentials=true
以及设置 axios
,不使用则会用默认的 XSRF-TOKEN
和 X-XSRF-TOKEN
。
所以,axios 特性中,客户端支持防止 CSRF/XSRF。只是方便设置 CORF-TOKEN ,关键还是要后端同学的接口支持。(PS:前后端相亲相爱多重要,所以作为前端的我们还是尽可能多了解这方面的知识)
XHR 实现axios 通过适配器模式,提供了支持 node.js 的 http 以及客户端的 XMLHttpRequest 的两张实现,本文主要讲解 XHR 实现。
大概的实现逻辑如下:
const xhrAdapter = : AxiosPromise => { return new Promise => { let request: XMLHttpRequest | null = new XMLHttpRequest; setHeaders; openXHR; setXHR; sendXHR; });};
如果逐行讲解,不如录个教程视频,建议大家直接看 adapters 目录下的 xhr.ts
,在关键地方都有注释!
- xhrAdapter 接受 config 参数
- 设置请求头,比如根据传入的参数
data
,auth
,xsrfHeaderName
设置对应的 headers setXHR
主要是在request.readyState === 4
的时候对响应数据作处理以及错误处理- 最后执行
XMLHttpRequest.send
方法
返回的是一个 Promise 对象,所以支持 Promise 的所有特性。
请求拦截请求拦截在 axios 应该算是一个比较骚的操作,实现非常简单。有点像一系列按顺序执行的 Promise。
直接看代码实现:
// interceptors 分为 request 和 response。 interface interceptors { request: InterceptorManager; response: InterceptorManager; } request { const { method } = config const newConfig: AxiosRequestConfig = { ...this.defaults, ...config, method: method method.toLowerCase : 'get' } // 拦截器原理:[请求拦截器,发送请求,响应拦截器] 顺序执行 // 1、建立一个存放 [ resolve , reject ] 的数组, // 这里如果没有拦截器,则执行发送请求的操作。 // 由于之后都是 resolve 和 reject 的组合,所以这里默认 undefined。真是骚操作! const chain = [ dispatchRequest, undefined ] // 2、Promise 成功后会往下传递参数,于是这里先传入合并后的参数,供之后的拦截器使用 。 let promise: any = Promise.resolve // 3、又是一波骚操作,完美的运用了数组的方法。咋不用 reduce 实现 promise 顺序执行呢 // request 请求拦截器肯定需要 `dispatchRequest` 在前面,于是 [interceptor.fulfilled, interceptor.rejected, dispatchRequest, undefined] this.interceptors.request.forEach => { chain.unshift }) // response 响应拦截器肯定需要在 `dispatchRequest` 后面,于是 [dispatchRequest, undefined,interceptor.fulfilled, interceptor.rejected] this.interceptors.response.forEach => { chain.push }) // 4、依次执行 Promise while { promise = promise.then, chain.shift) } return promise }
又是对基础知识的完美运用,无论是 Promise 还是数组的变异方法都算巧妙运用。
当然,Promise 的顺序执行还可以这样:
function sequenceTasks { function recordValue { results.push; return results; } var pushValue = recordValue.bind; return tasks.reduce { return promise.then.then; }, Promise.resolve);}
如果不知道 XMLHttpRequest 有 absort 方法,肯定会觉得取消请求这种秀操作的怎么可能呢!
const { cancelToken } = config;const request = new XMLHttpRequest;if { cancelToken.promise .then { return; } request.abort; reject; request = null; }) .catch; });}
至于 CancelToken
就不讲了,好奇怪的实现。没有感悟到原作者的设计真谛!
最后到了单元测试的环节,先来看下相关依赖。
执行命令:
yarn test
本项目是基于 jasmine
来写测试用例,还是比较简单的。
karma 会跑 test 目录下的所有测试用例,感觉测试用例用 Typescript 来写,有点难受。因为测试本来就是要让参数多样化,然而 Typescript 事先规定了数据类型。虽然可以使用泛型来解决,但是总觉得有点变扭。
不过,整个测试用例跑下来,代码强壮了很多。对于这种库来说,还是很有必要的。如果需要二次重构,基于 Typescript 和 有覆盖大部分函数的单元测试支持,应该会容易很多。
总结感谢能看到这里的朋友,想必也是 Typescript 或 Axios 的粉丝,不妨相互认识一下。
还是那句话,Typescript 确实好用。短时间内就能将 Axios 大致重构了一遍,感兴趣的可以跟着一起。老规矩,在分享中不会具体讲库怎么用 ,更多的是从广度拓展大家的知识点。如果对某个关键词比较陌生,这就是进步的时候了。比如笔者接下来要去深入涉略 HTTP 了。虽然,感觉目前 Typescript 的热度好像好不是很高。好东西,总是那些不容易变的。哈,别到时候打脸了。
我变强了吗 不扯了,听杨宗纬的 "我变了,我没变" 了。
切记,没有什么是看源码解决不了的 bug。
参考- 跨站请求伪造
- 跨站脚本
- HTTP 请求
- HTTP 缓存头部 - 完全指南
- XMLHttpRequest