前端基础建设与架构 - 前百度资深前端开发工程师 - 拉勾教育

从这一讲开始,我们将进入核心框架原理与代码设计模式的学习。任何一个动态应用的实现,都离不开前后端的互动配合。前端发送请求获取数据是开发者必不可少的场景。正因为如此,每一个前端项目都有必要接入一个请求库。

那么请求库如何设计,才能保证使用者的顺畅?请求逻辑如何抽象成统一请求库,才能避免出现代码混乱堆积,难以维护的现象呢?下面我们就进入正题。

一个请求库需要考虑哪些问题

一个请求,纵向向前承载了数据的发送,向后链接了数据的接收和消费,横向还需要处理网络环境和宿主能力,以及业务的扩展需求。因此设计一个好的请求库,首先需要预见可能会发生的问题。下面我们将重点展开几个关键问题。

适配浏览器 or Node.js 环境

如今,前端开发不再局限于浏览器层面,Node.js 环境的出现,使得请求库的适配需求变得更加复杂。Node.js 基于 V8 JavaScript Engine,顶层对象是 global,不存在 Window 对象和浏览器宿主,因此使用传统的 XMLHttpRequest/Fetch 在 Node.js 上发送请求是行不通的。对于搭建了 Node.js 环境的前端来说,请求库的设计实现需要考虑是否同时支持在浏览器和 Node.js 两种环境发送请求。在同构的背景下,如何使不同环境的请求库使用体验趋于一致呢?下面我们将会对这部分内容进一步讲解。

XHR or Fetch

单就浏览器环境发送请求来说,一般存在两种技术方法:

我们先简要对比两种技术的使用方式。

使用 XMLHttpRequest 发送请求:

  1. function success() {
  2. var data = JSON.parse(this.responseText);
  3. console.log(data);
  4. }
  5. function error(err) {
  6. console.log('Error Occurred :', err);
  7. }
  8. var xhr = new XMLHttpRequest();
  9. xhr.onload = success;
  10. xhr.onerror = error;
  11. xhr.open('GET', 'https://xxx');
  12. xhr.send();

简单来说,XMLHttpRequest 存在一些缺点,比如:

  • 配置和使用方式较为烦琐;
  • 基于事件的异步模型不够友好。

而 Fetch 的推出,主要也是为了解决上述问题。

使用 Fetch 发送一个请求:

  1. fetch('https://xxx')
  2. .then(function (response) {
  3. console.log(response);
  4. })
  5. .catch(function (err) {
  6. console.log("Something went wrong!", err);
  7. });

我们可以看到,Fetch 基于 Promise,语法更加简洁,语义化更加突出,但兼容性不如 XMLHttpRequest

对于一个请求库来说,在浏览器端使用 XMLHttpRequest 还是 Fetch?这是一个问题。下面我们通过 axios 的实现具体展开讲解。

功能设计与抽象粒度

无论是基于 XMLHttpRequest 还是 Fetch,实现一层封装,屏蔽一些基础能力并暴露给业务方使用,即实现一个请求库,这并不困难。我认为,真正难的是请求库的功能设计和抽象粒度。如果功能设计分层不够清晰,抽象方式不够灵活,很容易产出 “屎山代码”。

比如,对于请求库来说,是否要处理以下看似通用,但又具有定制性的功能呢?你需要考虑以下功能点:

  • 自定义 headers 添加
  • 统一断网 / 弱网处理
  • 接口缓存处理
  • 接口统一错误提示
  • 接口统一数据处理
  • 统一数据层结合
  • 统一请求埋点

这些设计问题如果初期不考虑清楚,那么在业务层面,一旦真正使用了设计不良的请求库,很容易遇到不满足业务需求的场景,而沦为手写 Fetch,势必导致代码库中请求方式多种多样,风格不一。

这里我们稍微展开,以一个请求库的分层封装为例,其实任何一种通用能力的封装都可以参考下图:

17 | 学习 axios:封装一个结构清晰的 Fetch 库 - 图1

请求库分层封装示例图

如图所示,底层能力部分,对应请求库中宿主提供的 XMLHttpRequest 或 Fetch 能力,以及项目中已经内置的框架 / 类库能力。这一部分对于一个已有项目来说,往往是较难改变或重构的,也是不同项目中可以复用的;而业务层,比如依赖 axios 请求库的更上层封装,我们一般可以分为:

  • 项目层
  • 页面层
  • 组件层

三个方面,它们依次递进,完成最终业务消费。底层能力部分,对许多项目来说都可以使用,而让不同项目之间的代码质量和开发效率产生差异的,恰好是容易被轻视的业务级别的封装设计。

比如设计者在项目层的封装上,如果做了几乎所有事情,囊括了所有请求相关的规则,很容易使封装复杂,过度设计。不同层级的功能和职责是不同的,错位的使用和设计,是让项目变得更加混乱的诱因之一

合理的设计是,底层部分保留对全局封装的影响范围,而项目层保留对页面层的影响能力,页面层保留对组件层的影响能力。

17 | 学习 axios:封装一个结构清晰的 Fetch 库 - 图2

比如,我们在项目层提供一个基础请求库封装,在这一层可以提供默认发送 cookie 等(一定需要存在)的行为,同时通过配置 options.fetch 保留覆盖 globalThis.fetch 的能力,这样可以在 Node 等环境中,通过注入一个 node-fetch npm 库的方式,支持 SSR 能力。

这里需要注意的是,我们一定要避免设计一个特别大的 Fetch 方法,通过拓展 options 把所有事情都做了,用 options 驱动一切行为,这比较容易让 Fetch 代码和逻辑变得复杂、难以理解,而且不利于 tree-shaking 和 code-spliting。

那么如何做到这种层次清晰的基础库呢?接下来,我们就从 axios 的设计分析寻找答案。

axios 设计之美

axios 是一个被前端广泛使用的请求库,对应上述分层结构中,属于框架 / 类库层,我们来总结一下它的功能特点:

  • 在浏览器端,使用 XMLHttpRequest 发送请求;
  • 支持 Node.js 端发送请求;
  • 支持 Promise API,使用 Promise 风格语法;
  • 支持请求和响应拦截;
  • 支持自定义修改请求和返回内容;
  • 支持请求取消;
  • 默认支持 XSRF 防御。

下面,我们主要从拦截器思想、适配器思想、安全思想三方面展开,分析 axios 设计的可取之处。

拦截器思想

拦截器思想是 axios 带来的最具启发性的思想之一。它赋予了分层开发时借助拦截行为,注入自定义能力的功能。简单来说,axios 的拦截器主要由:任务注册 → 任务编排 → 任务调度(执行)三步组成。

我们先看任务注册,在请求发出前,可以使用axios.interceptors.request.use方法注入拦截逻辑,比如:

  1. axios.interceptors.request.use(function (config) {
  2. return config;
  3. }, function (error) {
  4. return Promise.reject(error);
  5. });

在请求返回后,用axios.interceptors.response.use方法注入拦截逻辑,比如:

  1. axios.interceptors.response.use(function (response) {
  2. return response;
  3. }, function (error) {
  4. return Promise.reject(error);
  5. });

任务注册部分的源码实现也不复杂:

  1. function Axios(instanceConfig) {
  2. this.defaults = instanceConfig;
  3. this.interceptors = {
  4. request: new InterceptorManager(),
  5. response: new InterceptorManager()
  6. };
  7. }
  8. function InterceptorManager() {
  9. this.handlers = [];
  10. }
  11. InterceptorManager.prototype.use = function use(fulfilled, rejected) {
  12. this.handlers.push({
  13. fulfilled: fulfilled,
  14. rejected: rejected
  15. });
  16. return this.handlers.length - 1;
  17. };

如上代码,我们定义的请求 / 响应拦截器,会在每一个 axios 实例的 Interceptors 属性中维护,this.interceptors.requestthis.interceptors.response也都是一个 InterceptorManager 实例,该实例的handlers属性以数组的形式存储了使用方定义的一个个拦截器逻辑。

注册了任务,我们再来看看任务编排时是如何将拦截器串联起来,并在任务调度阶段执行各个拦截器的。如下源码:

  1. Axios.prototype.request = function request(config) {
  2. config = mergeConfig(this.defaults, config);
  3. var chain = [dispatchRequest, undefined];
  4. var promise = Promise.resolve(config);
  5. this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
  6. chain.unshift(interceptor.fulfilled, interceptor.rejected);
  7. });
  8. this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
  9. chain.push(interceptor.fulfilled, interceptor.rejected);
  10. });
  11. while (chain.length) {
  12. promise = promise.then(chain.shift(), chain.shift());
  13. }
  14. return promise;
  15. };

我们通过chain数组来编排调度任务,dispatchRequest方法实际执行请求的发送,编排过程实现:在实际发送请求的方法dispatchRequest前插入请求拦截器,在dispatchRequest方法后,插入响应拦截器。

任务调度其实就是通过一个 While 循环,通过一个 Promise 实例,遍历迭代chain数组方法,并基于 Promise 回调特性,将各个拦截器串联执行起来。

我们通过下图,来加深理解:

17 | 学习 axios:封装一个结构清晰的 Fetch 库 - 图3

适配器思想

前文提到了 axios 同时支持 Node.js 环境和浏览器环境发送请求,在浏览器中我们可以选用 XMLHttpRequest 或 Fetch 方法发送请求,但是在 Node.js 中,需要通过 HTTP 模块发送请求。对此,axiso 是如何设计实现的呢?

为了支持适配不同环境,axios 实现了适配器:Adapter,具体实现在dispatchRequest方法中:

  1. module.exports = function dispatchRequest(config) {
  2. var adapter = config.adapter || defaults.adapter;
  3. return adapter(config).then(function onAdapterResolution(response) {
  4. return response;
  5. }, function onAdapterRejection(reason) {
  6. return Promise.reject(reason);
  7. });
  8. };

如上代码,axios 支持使用方实现自己的 Adapter,自定义不同环境中的请求实现方式,也提供了默认的 Adapter。默认 Adapter 逻辑代码如下:

  1. function getDefaultAdapter() {
  2. var adapter;
  3. if (typeof XMLHttpRequest !== 'undefined') {
  4. adapter = require('./adapters/xhr');
  5. } else if (typeof process !== 'undefined' &&
  6. Object.prototype.toString.call(process) === '[object process]') {
  7. adapter = require('./adapters/http');
  8. }
  9. return adapter;
  10. }

一个 Adapter 需要返回一个 Promise 实例(这是因为axios 内部通过 Promise 链式调用完成请求调度),我们分别看看在浏览器端和 Node.js 端具体 Adapter 实现逻辑:

  1. module.exports = function xhrAdapter(config) {
  2. return new Promise(function dispatchXhrRequest(resolve, reject) {
  3. var requestData = config.data;
  4. var requestHeaders = config.headers;
  5. var request = new XMLHttpRequest();
  6. var fullPath = buildFullPath(config.baseURL, config.url);
  7. request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);
  8. request.onreadystatechange = function handleLoad() {
  9. };
  10. request.onabort = function handleAbort() {
  11. };
  12. request.onerror = function handleError() {
  13. };
  14. request.ontimeout = function handleTimeout() {
  15. };
  16. request.send(requestData);
  17. });
  18. };

如上代码,就是一个典型的使用 XMLHttpRequest 发送请求的实现内容。在 Node.js 端的实现,精简后代码如下:

  1. var http = require('http');
  2. module.exports = function httpAdapter(config) {
  3. return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
  4. var resolve = function resolve(value) {
  5. resolvePromise(value);
  6. };
  7. var reject = function reject(value) {
  8. rejectPromise(value);
  9. };
  10. var data = config.data;
  11. var headers = config.headers;
  12. var options = {
  13. };
  14. var transport = http;
  15. var req = http.request(options, function handleResponse(res) {
  16. });
  17. req.on('error', function handleRequestError(err) {
  18. });
  19. if (utils.isStream(data)) {
  20. data.on('error', function handleStreamError(err) {
  21. reject(enhanceError(err, config, null, req));
  22. }).pipe(req);
  23. } else {
  24. req.end(data);
  25. }
  26. });
  27. };

上述代码主要是调用 Node.js HTTP 模块,进行请求的发送和处理,当然,真实源码实现还需要考虑 HTTPS 以及 Redirect 等问题,这里我们不再展开。

讲到这里,可能你会问,什么场景下,才会需要自定义 Adapter 进行请求发送呢?比如在测试阶段或特殊环境中,我们可以 mock 请求:

  1. if (isEnv === 'ui-test') {
  2. adapter = require('axios-mock-adapter')
  3. }

实现一个自定义的 Adapter 也并不困难,说到底它也只是一个 Node.js 模块,导出一个 Promise 实例即可:

  1. module.exports = function myAdapter(config) {
  2. return new Promise(function(resolve, reject) {
  3. sendRequest(resolve, reject, response);
  4. });
  5. }

相信你学会了这些内容,就对 axios-mock-adapter 这个库的实现原理了然于胸了。

安全思想

说到请求,自然关联着安全问题。在本小节最后部分,我们对 axios 中的一些安全机制进行解析,涉及相关攻击手段:CSRF。

Cross—Site Request Forgery,攻击者盗用了你的身份,以你的名义发送恶意请求,对服务器来说,这个请求是完全合法的,但是却完成了攻击者期望的一个操作,比如以你的名义发送邮件、发消息,盗取你的账号,添加系统管理员,甚至购买商品、虚拟货币转账等。

在 axios 中,主要依赖双重 cookie 的方式防御 CSRF。具体来说,对于攻击者,获取用户 cookie 是比较困难的,因此,我们可以在请求中携带一个 cookie 值,来保证请求的安全性。这里我们将相关流程梳理为:

  • 用户访问页面,后端向请求域中注入一个 cookie,一般该 cookie 值为加密随机字符串;
  • 在前端通过 Ajax 请求数据时,取出上述 cookie,添加到 URL 参数或者请求 header 中;
  • 后端接口验证请求中携带的 cookie 值是否合法,不合法(不一致),则拒绝请求。

我们看 axios 源码:

  1. var defaults = {
  2. adapter: getDefaultAdapter(),
  3. xsrfCookieName: 'XSRF-TOKEN',
  4. xsrfHeaderName: 'X-XSRF-TOKEN',
  5. };

在这里,axios 默认配置了默认xsrfCookieNamexsrfHeaderName,实际开发中可以按具体情况传入配置。在具体请求时,以lib/adapters/xhr.js为例:

  1. if (utils.isStandardBrowserEnv()) {
  2. var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
  3. cookies.read(config.xsrfCookieName) :
  4. undefined;
  5. if (xsrfValue) {
  6. requestHeaders[config.xsrfHeaderName] = xsrfValue;
  7. }
  8. }

由此可见,对一个成熟请求库的设计来说,安全防范这个话题永不过时。

总结

本讲我们在开篇分析了代码设计、代码分层的方方面面,一个好的设计一定是层次明晰,各司其职的,一个好的设计也会直接帮助业务开发提升效率。封装和设计,是编程领域亘古不变的经典话题,需要每名开发者下沉到业务开发中体会、思考。

本小节的后半部分,我们从源码入手,分析了 axios 的优秀设计思想。即便你在业务中没有使用过 axios,但对于 axios 的学习始终是必要且重要的。

主要内容总结如下:

17 | 学习 axios:封装一个结构清晰的 Fetch 库 - 图4

最后,给大家布置一个思考题:axios 支持请求取消能力,这是如何实现的呢?欢迎在留言区和我分享你的观点。下一讲,我们将继续学习代码设计这一话题,通过对比 Koa 和 Redux,聚焦中间件化和插件化理念。我们下一讲再见。