ShapeLei

ShapeLei 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

ShapeLei 赞了文章 · 1月5日

作为一个前端开发到底要不要写测试?来看看《前端要写单元测试?不存在的!那e2e呢?》

故事背景

Up所在的开发团队,由于测试人员(以下简称QA)的资源匮乏,较难保出品质量,穷则思变,近年来Up尝试和实践了前端的各类测试方法,今天写出来与大家分享,讨论

前端测试的种类

简单过一下目前前端测试的种类,这不是重点,了解的同学可以跳过不看

网上介绍各类前端测试的文章不少,光SF就有不少好文,我大致归类了一下,主要分为2类

 1. 前端单元测试  (Unit Test以下简称 - UT)
 2. 前端e2e测试

第1点单元测试,大家应该不陌生,就算你没写过前端的UT,那你肯定也听过什么mocha,jasmine,jest这类测试框架,UT的意义在于比较细粒度的去测试我们业务代码中写的function,测试function里提供的method是否可靠。

就好比造房子的时候我们对每块砖的密度,重量,长宽高是否符合规范等数据进行测试,以确保每块砖都是ok的,不会出现空心砖等情况,从而保证最后造出来的房子没有安全隐患

第2点e2e测试
e2e测试就是端对端测试,简而言之,就是利用一些工具库提供的API使用代码来模拟终端用户在UI界面上的操作,比如输入,点击等等。目前常用的工具有,selenium, puppeteer,phantom,protractor(angular), Nightwatch(Vue)等等

重点来了!我的团队到底需不要前端开发写测试?

根据上一段的内容,我们分为2块来讨论

需不需要写“单元测试”?

上一段举了一个砖头与房子的例子来简单阐述了单元测试的重要性,但是这也恰恰说明了另外一个问题,也就是越是基础的,底层的代码越是需要进行完备的单元测试,以保证它是可靠的,从而保证依赖它的其它模块不会受到影响。前后端分离流行后,前端开发从系统架构层面来看,实际上已经是站在食物链顶端的男人。

第一个金字塔模型

clipboard.png

从这个模型可以看出,前端作为消费者是站在最顶层的,它是Service层的消费者,所以只有保证Service层是健壮可靠,才能保证UI层的可靠。但是前端并不被其它层所依赖,因为它已经站在了顶点。所以除了最终用户我并不需要对其它层面负责,不用负责我凭什么还要写UT? 持不同意见的同学,稍安勿躁,我们继续深入分析

第二个金字塔模型

clipboard.png

前端也有自己的金字塔模型,我们在写UI的时候也会对底层代码有依赖,比如引入了mvvm framework, 要使用lodash之类的util库,要用到公共组件,当然我们可以自己实现这些前端较为底层的库,组件,但是绝大部分场景下,都是拿来主义,从github上找一个星星多的,并且默认认为这些开源库都已经做了充分的UT,是可靠的。而我们90%的精力都花在了更上层更顶端的业务代码上,我们依旧是站在食物链顶端的顶端,地位无可撼动,得出的结论是依旧不需要写UT

哪些情况下你可能需要写前端UT? 来做一组判断题

1. 你写的是个util类,是会被其他类调用的那种?
2. 你写的是一个公共component,是会被其他工程调用的那种?
3. 你写的是一个开源项目

如果以上3个问题有一个肯定回答,你都应该考虑写UT了。所以说,UT对于前端来讲,重不重要?重要!要不要写?看情况

需不需要写E2E测试?

看了上面的结论,严谨的同学可能已经要跳起来了,指着Up的鼻子说:“啊!谁说那90%的业务代码不被依赖的,我们并没有站在最顶端,我们上面还有客户还有上帝,这业务代码是要对客户负责的,所以我们还是要为这业务代码的健壮可靠性写UT”

能质疑这个问题的同学,非常好,非常秀,你的出发点是很好的,但是我们不妨换个角度来思考一下这个问题。首先,单元测试的存在有个前提,就是提供者和它上层的消费者需要在同一个特定的消费体系里,只有在同一个体系里,对提供者写UT才有意义,否则这是不牢靠的。要证明这一点很容易,比如我java写的一个util类,我肯定会在java这个特定的消费环境里来对这个util进行UT,因为它的上层消费者肯定也是java环境。一个前端的mvvm framework的UT肯定是基于JavaScript环境来写的,而不可能是别的语言,因为消费你的上层建筑也是基于JavaScript来调用的,当然有人说提供restful api的service层,虽然跟语言环境无关,但它都是基于http这个特定的消费环境的,或者也可以说接口层面的test已经属于整合测试的范畴。

但是,前端的业务代码却和上层的消费者——用户,不在同一个特定环境里,是脱节的,展现在用户面前的是GUI,用户通过一系列的用户事件,点击,输入,拖动,肉眼观测实现顶层消费。而不是呼出F12,在console里把前端的业务代码把调用一遍。所以说即使你的前端业务代码的UT做得再棒,理论上来说也是不可靠的。

那么,既然如此,如何保证业务代码是可靠的,能对消费者——用户负责呢?
说了这么多,终于引出了本文的重点——e2e自动化测试(以下简称Automation Test——AT),我可以使用各类e2e工具,模拟用户的操作行为来进行最终的验收测试,这样我不就跟用户是在同一个体系里了么?!且慢,同样先根据你项目的实际情况做几道是非题

1.测试团队是否兵强马壮  (基于人海战术的人肉e2e测试)
2.产品UI是否相对不稳定,经常大改 (改e2e case都来不及)
3.测试团队是否已经熟练掌握自动化测试技术,并已经运用起来  (QA来写e2e自动测试,理想国,前端就可以甩手了)
4.每一个迭代周期,留给QA测试时间是否充裕  (人肉e2e测试时间充足)
5.Service接口的测试覆盖率是否很高,后端UT的覆盖率是否很高 (底层建筑稳,隐患少)
6.每一个迭代周期,留给前端开发的时间是否很紧张 (前端写完业务代码,也要有时间写e2e代码)

如果你的答案里有2个以上的yes,可能e2e AT并不是现阶段必须要引入的,因为你背后有足够强大的测试团队支撑或者充裕的时间来保证产品被交付给用户前有足够的可靠性,或者是由于产品的特殊性,已经项目时间安排的不合理导致无法实施e2e AT

第三个金字塔模型

clipboard.png
e2e测试(不管是人肉的也好,自动化的也好)e2e测试在整个系统测试的贯通性覆盖率上来说是最大的,它可以覆盖从地基到顶端的这一长串的范围。所以如果说UT是用来保证保证成品各个层级可靠性的话,那e2e就是用来验证这种可靠性的,并且它的作用范围运不止此。

引入e2e AT

团队中,如果QA资源匮乏,且UI变化的频率不是很频繁的情况下,为了提高工作效率,提升产品质量了,我们就要考虑引入是该引入e2e自动化测试了来替代一部分的QA人肉工作量。谁来写e2e测试? 第一顺位继承人,前端当仁不让!

理由有3

1. 前端需要对自己写的业务代码负责,写UT又不可靠,那就只有写基与功能模块的AT了
2. 模拟用户操作,AT需要对各个DOM节点进行操作,前端对这个再熟悉不过
3. 有数款基于JavaScript的AT工具,学习成本低

e2e AT框架的选择

Selenium
目前市面上AT工具琳琅满目,种类繁多,up目前使用的是selenium + jest, selenium一般来说是雷打不动的,虽然我们主要使用的是它的webdriver功能,选择此类驱动型工具,up有个建议就是不要选择针对某种前端框架自带的自动化工具,诸如以前angular1时代up用过的protractor,它的写法对UI框架是强耦合的,且封装的API并不比比较原生的selenium高明多少,如若他日项目技术升级为Vue或者React之前写的AT case基本就废了,所以还是选用比较common的AT工具会比较好。

Jest
之前,up用的是mocha,jest虽然是为react量身打造,但是把它当成一个common的测试框架也挺好用,它有个比较有用的功能就是快照snapshot,为什么说快照很好用,是因为,亲手写过AT的朋友大多有些体会就是——AT写操作容易做判断却难,有了快照之后,就可以用2张快照进行比较,从而大大节省了取dom节点的text的代码,直接两张图一对比就玩事儿了。当然jest的快照功能你要自己实现也很方便,若想要保持现有的mocha或者jasmine一样可以引入快照功能。

一个e2e AT case的测试范围

根据Up的经验,我认为一个e2e case的范围最好以一个功能模块为最小单位,比如用户管理,一个case里我需要覆盖到:创建用户,查询用户,编辑用户,删除用户等4个基本操作。

//user.e2e.spec.js
const UserModule = require('UserModule');
const AuthModule = require('AuhtModule');
let userModule;
const userName = 'at_test_user';

beforeAll( ()=>{
    new AuthModule().login();
    userModule = new UserModule();
})

test('user:' + userName +'should not be exited', ()=> {
    userModule.find(userName);
    const image = webdriver.takeSnapshot();
    expect(image).toMatchSnapshot();
})

test('user:' + userName +'should be created successfully', ()=> {
    userModule.create(userName,pwd);
    const image = webdriver.takeSnapshot();
    expect(image).toMatchSnapshot();
})

//... other cases....

afterAll(()=>{
    webdriver.quit();
})

为什么不直接在spec里使用webdriver操作dom?

建议大家都给测试的功能对象封装一个类,比如上面伪代码里的UserModule

//UserModule.js
module.exports = function UserModule(){

    this.find = function(username){
        //...
        webdriver.findElement(xxxx).click();
    }
    this.create = function(user,pwd){
        //...
         webdriver.findElement(xxxx).input(xxxx);
        //...
        webdriver.findElement(xxxx).click();
    }
    // other operations
    

如果日后写其他case依赖于User的数据,那么就在before之类的地方进行userModule的调用来简历基础数据。这里不赘述了,OO的东西大家掌握的都比我好。

e2e AT的覆盖率

在人员有限的情况下,什么case需要些AT,那些不需要,或者不急着写,可以日后慢慢补

1. 新功能不需要急着写AT,应该交给QA人肉测试,待功能上线后再慢慢补齐
2. 反28原则: 28原则是指最重要的只占其中一小部分,约20%,其余80%尽管是多数却不重要,但是写AT需要反过来,我们优先写那80%不常用到的功能,至于那重要的20%,由于经常被使用,它能不能工作一目了然,其实也是最健壮的,需要写AT来覆盖的优先级就不显得这么高了,反而那不常用的80%功能往往没有经过大量的用户测试,很容易在某次迭代中产生新的bug。
3. 优先写happy path,优先保证一个功能模块的主线畅通,再写边界值测试。

e2e AT的重要性

先说说Regression Test即回归测试,当有新功能上线后,我们需要对产品老的功能进行回归测试,以确保新代码的加入没有引入新的bug。通常一次迭代中,QA会花费大约20~30%的时间进行回归测试,可见回归测试的重要性,但是很多情况下,由于项目时间紧迫,或者紧急发布等情况,被压缩和牺牲的往往是回归测试,而e2e AT正好可以覆盖一部分的回归测试,如果你的AT case覆盖率越高,则回归测试的覆盖率也越高,出品也就越稳定,如果AT能覆盖绝大部分的回归测试,而AT的执行效率又是人肉执行的数倍,那QA的工作量就被大大的降低。

e2e AT在开发流程中的位置(何时触发AT)

当新的代码合并到主线并部署到测试环境后,进入QA人肉测试环节前,是触发AT的最佳时机。bug是越早发现越好,AT与jenkins等CI工具可以很好的整合,也不依赖于什么特殊插件,跑完AT后自动生成report,若有失败则发送邮箱第一时间暴露问题,岂不美哉

几句话总结

关于前端UT:业务代码可以不写UT
关于前端e2e: 推荐用e2e AT来覆盖前端的业务代码,并纳入开发流程
另外,欢迎有持不同意见的同学参与讨论! 如果你有好的建议欢迎留言,毕竟是我一家之言,期待能碰撞出技术火花

查看原文

赞 65 收藏 36 评论 16

ShapeLei 赞了文章 · 2020-08-06

React Fiber 原理介绍

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

在 React Fiber 架构面世一年多后,最近 React 又发布了最新版 16.8.0,又一激动人心的特性:React Hooks 正式上线,让我升级 React 的意愿越来越强烈了。在升级之前,不妨回到原点,了解下人才济济的 React 团队为什么要大费周章,重写 React 架构,而 Fiber 又是个什么概念。

二、React 15 的问题

在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象。请看以下例子:
https://claudiopro.github.io/...

clipboard.png

其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。

针对这一问题,React 团队从框架层面对 web 页面的运行机制做了优化,得到很好的效果。

clipboard.png

三、解题思路

解决主线程长时间被 JS 运算占用这一问题的基本思路,是将运算切割为多个步骤,分批完成。也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

旧版 React 通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。官方的解释是这样的:

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

有了解题思路后,我们再来看看 React 具体是怎么做的。

四、React 的答卷

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

这次改动最大的当属 Reconciler 层了,React 团队也给它起了个新的名字,叫Fiber Reconciler。这就引入另一个关键词:Fiber。

Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:

const fiber = {
    stateNode,    // 节点实例
    child,        // 子节点
    sibling,      // 兄弟节点
    return,       // 父节点
}

为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:

clipboard.png

而 Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行:

clipboard.png

为了达到这种效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

Fiber Reconciler 在执行过程中,会分为 2 个阶段。

clipboard.png

  • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

五、Fiber 树

Fiber Reconciler 在阶段一进行 Diff 计算的时候,会生成一棵 Fiber 树。这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表。

clipboard.png

Fiber 树在首次渲染的时候会一次过生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:

clipboard.png

如果过程中有优先级更高的任务需要进行,则 Fiber Reconciler 会丢弃正在生成的树,在空闲的时候再重新执行一遍。

在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。

六、总结

本文从 React 15 存在的问题出发,介绍 React Fiber 解决问题的思路,并介绍了 Fiber Reconciler 的工作流程。从Stack ReconcilerFiber Reconciler,源码层面其实就是干了一件递归改循环的事情,日后有机会的话,我再结合源码作进一步的介绍。

查看原文

赞 150 收藏 75 评论 10

ShapeLei 回答了问题 · 2020-06-16

ES6箭头函数作用域的问题

普通函数执行时this的指向,和它的调用者有关。而 function () { this.s2++; } 没有调用者,this会默认指向全局对象,也就是window。

而箭头函数没有this,所以使用的是父作用域中的this,Timer作为构造函数,此时作用域中的this就是实例timer

关注 9 回答 8

ShapeLei 回答了问题 · 2020-06-16

解决js闭包问题?这段代码如何理解呢?为什么第二次调用result()变成1000了?

第二次调用result的时候还是会走var n=999;

var n=999 是不会执行第二次的.

关注 6 回答 5

ShapeLei 关注了问题 · 2020-04-06

解决浏览器和服务器关闭TCP连接在什么时候?

一般说来,浏览器输入一个URL之后会:

  1. 进行DNS查询,找到对应服务器的IP,然后和服务器建立TCP连接;
  2. 然后基于TCP连接;
  3. 浏览器发送HTTP请求,服务器接受请求、处理请求、返回响应;
    那么接下来是直接断开TCP连接,还是等浏览器渲染完页面再断开连接呢?

关注 9 回答 5

ShapeLei 赞了文章 · 2020-03-20

CSS 故障艺术

v2-c7a2db16babbdefec756d3eed6bc0fa4_hd

本文的主题是 Glitch Art,故障艺术。

什么是故障艺术?我们熟知的抖音的 LOGO 正是故障艺术其中一种表现形式。它有一种魔幻的感觉,看起来具有闪烁、震动的效果,很吸引人眼球。

故障艺术它模拟了画面信号出现故障导致成像错误的感觉。青色色块与红色色块无法重合就是这种故障的体现。从胶片时代开始到今天的数码时代,这种故障一直是观众非常熟悉的现象。即使抖音的 LOGO 只是静态的,大脑也会自己补完整个效果,甚至还会自己脑补信号干扰的噪音。

当然,广义的故障艺术不仅仅指这种效果,我觉得是很宽泛的,本文将介绍一些 CSS 能够模拟完成的故障艺术效果。

使用混合模式实现抖音 LOGO

首先从静态的开始,抖音的 LOGO 就是很好的一个例子。

image

它看着像是 3 个 J 形重叠在一起。而实际上,是两个 J 形重叠在一起,重叠部分表现为白色,这个特性,使用 CSS 的混合模式 mix-blend-mode 非常好实现,而单个 J 形示意图如下:

image

图片来源于知乎:为什么抖音的标志,看起来具有“电”“闪烁”“震动”的感觉?

单个 J 形其实是由 3/4圆 + 竖线 + 1/4圆组成,使用一个标签即可完成(加上两个伪元素)。

关键点

  • 主要借助伪元素实现了整体 J 结构,借助了 mix-blend-mode 实现融合效果
  • 利用 mix-blend-mode: lighten 混合模式实现两个 J 形结构重叠部分为白色

所以整个效果只需要两个标签:

<div class="j"></div>
<div class="j"></div>

简易 SASS 代码:

// 实现第一个 J
.j {
    position: absolute;

    &::before {
        content: "";
        ...
    }

    &::after {
        content: "";
        ...
    }
}

// 实现第二个 J,加上混合模式
.j:last-child {
    position: absolute;
    mix-blend-mode: lighten;

    &::before {
        content: "";
        ...
    }
    &::after {
        content: "";
        ...
    }
}

示意图如下(为了更好理解,加上了动画):

tiktok

完整的 DEMO:

使用 mix-blend-mode 实现抖音 LOGO

图片的 Glitch Art 风

当然,上面实现的是我们实现的 J 形的叠加,理解了这种技巧之后,我们可以把它运用到图片之上。

这里我们会运用到 background-blend-modemix-blend-mode

假设,我们有这样一张图:

image

只需要一个标签即可

<div class="mix"></div>

给两张同样的图片,叠加上 青色#0ff 和 红色#f00,并且错开一定的距离,两张图都要加上 background-blend-mode: lighten,其中一张再加上 mix-blend-mode: darken

.mix {
    width: 400px;
    height: 400px;
    background: url($img), #0ff;
    background-blend-mode: lighten;

  &::after {
    content: '';
    position: absolute;
    margin-left: 10px;
    width: 400px;
    height: 400px;
    background: url($img), #f00;
    background-blend-mode: lighten;
    mix-blend-mode: darken;
  }
}

得到如下效果:

image

这里与上述抖音 LOGO 的处理是有点不一样的,使用的混合模式也不止一种,简单解释下。

  1. 因为图片本身不是红色和青色的,所以需要通过 background-image 叠加上这两种颜色,并通过 background-blend-mode: lighten 让其表现出来
  2. 为了保持中间叠加部分的原色,需要再叠加一个 mix-blend-mode: darken 反向处理一下。(不理解的同学可以打开调试,手动关掉几个混合模式,自己感受感受即可)

完整的 DEMO:

图片的类抖音 LOGO Glitch 效果

动态类抖音风格 Glitch 效果

OK,有了上面的铺垫,我们接下来可以给这种效果加上动画。

关键点

  • 利用 mix-blend-mode: lighten 混合模式实现两段文字结构重叠部分为白色
  • 利用元素位移完成错位移动动画,形成视觉上的冲击效果

看看效果:

textglitch

本文篇幅有点长,代码就不上了,完整 DEMO 在这里:

类抖音 LOGO 文字故障效果

当然,我们也不是一定要使用混合模式去使得融合部分为白色,可以仅仅是使用这个配色效果,基于上面效果的另外一个版本,没有使用混合模式。

关键点

  • 利用了伪元素生成了文字的两个副本
  • 视觉效果由位移、遮罩、混合模式完成
  • 配色借鉴了抖音 LOGO 的风格

textglitch2

完整 DEMO 在这里:

CSS文字故障效果

仅仅使用配色没有使用混合模式的好处在于,对于每一个文字的副本,有了更大的移动距离和可以处理的空间。

Glitch Art 风格的 404 效果

稍微替换一下文本文案为 404,再添加上一些滤镜效果(hue-rotate()blur())嘿嘿,找到了一个可能实际可用的场景:

效果一:

404

效果二:

404

两个 404 效果的 Demo 如下:

其他配色效果

当然,不仅仅只有这一种红 + 青的配色效果。还有一些其他的配色及混合模式的搭配,如 黄 + 粉红 + 蓝配合 mix-blend-mode: multiply

然后,有的时候,效果不希望和背景混合在一起,可以使用 isolation: isolate 进行隔离。


好,上述效果可以归类为一个分类。接下来开启下一个分类

clip-path 登场

下半篇幅的主角主要是 clip-path

clip-path 一个非常有意思的 CSS 属性。

clip-path CSS 属性可以创建一个只有元素的部分区域可以显示的剪切区域。区域内的部分显示,区域外的隐藏。剪切区域是被引用内嵌的URL定义的路径或者外部 SVG 的路径。

也就是说,使用 clip-path 可以将一个容器切成我们想要的样子。

例如这样:

<div>TXET</div>
div  {
    margin: auto;
    padding: 10px;
    line-height: 1.2;
    font-size: 60px;
    background: #ddd;
}

正常是这样的:

image

使用 clip-path 剪裁为一个平行四边形:

div  {
    margin: auto;
    padding: 10px;
    line-height: 1.2;
    font-size: 60px;
    background: #ddd;
+   clip-path: polygon(35% 0, 85% 0, 75% 100%, 25% 100%);
}

结果如下:

image

那么,思路就有了,我们可以将一个文字复制几个副本,重叠在一起,再分别裁剪这几个副本进行位移动画即可。

使用 clip-path 实现文字断裂动画

我们还是使用元素的 ::before::after 两个伪元素复制两份副本,再分别使用 clip-path 进行剪裁,再使用 transform 进行控制。

核心代码:

<div data-text="Text Crack">
    <span>Text Crack</span>
</div>
div {
    position: relative;
    animation: shake 2.5s linear forwards;
}

div span {
    clip-path: polygon(10% 0%, 44% 0%, 70% 100%, 55% 100%);
}

div::before,
div::after {
    content: attr(data-text);
    position: absolute;
    top: 0;
    left: 0;
}

div::before {
    animation: crack1 2.5s linear forwards;
    clip-path: polygon(0% 0%, 10% 0%, 55% 100%, 0% 100%);
}

div::after {
    animation: crack2 2.5s linear forwards;
    clip-path: polygon(44% 0%, 100% 0%, 100% 100%, 70% 100%);
}

// 元素晃动,断裂前摇
@keyframes shake {    
    ...
}

@keyframes crack1 {
    0%,
    95% {
        transform: translate(-50%, -50%);
    }

    100% {
        transform: translate(-55%, -45%);
    }
}

@keyframes crack2 {
    0%,
    95% {
        transform: translate(-50%, -50%);
    }

    100% {
        transform: translate(-45%, -55%);
    }
}

可以得到这样的效果:

textcrack

完整的 Demo:

clip-path 实现文字断裂效果

这个效果,最早的版本见于这位作者:George W. Park

clip-path 的 Glitch Art

OK,继续,有了上面的铺垫之后,接下来,我们把这个效果作用于图片之上,并且再添加上动画。

随便选一张图片:

image

哇哦,非常的赛博朋克。

实现动画的关键在于:

  • 使用元素的两个伪元素,生成图片的两个副本
  • 使用 clip-path 对两个副本图片元素进行裁剪,然后进行位移、transform变换、添加滤镜等一系列操作。

简单贴一下伪代码:

<div></div>
$img: "https://mzz-files.oss-cn-shenzhen.aliyuncs.com///uploads/U1002433/0cb5e044a1f0f7fc15f61264ee97ac1f.png";

div {
    position: relative;
    width: 658px;
    height: 370px;
    background: url($img) no-repeat;
    animation: main-img-hide 16s infinite step-end;
}

div::before,
div::after {
    position: absolute;
    width: 658px;
    height: 370px;
    top: 0;
    left: 0;
    background: inherit;
}

div::after {
    content: "";
    animation: glitch-one 16s infinite step-end;
}

div::before {
    content: "";
    animation: glitch-two 16s infinite 1s step-end;
}

@keyframes glitch-one {
    @for $i from 20 to 30 {
        #{$i / 2}% {
            left: #{randomNum(200, -100)}px;
            clip-path: inset(#{randomNum(150, 30)}px 0 #{randomNum(150, 30)}px);
        }
    }

    15.5% {
        clip-path: inset(10px 0 320px);
        left: -20px;
    }
    16% {
        clip-path: inset(10px 0 320px);
        left: -10px;
        opacity: 0;
    }
    ....
}

@keyframes glitch-two {
    @for $i from 40 to 50 {
        #{$i / 2}% {
            left: #{randomNum(200, -100)}px;
            clip-path: inset(#{randomNum(180)}px 0 #{randomNum(180)}px);
        }
    }

    25.5% {
        clip-path: inset(10px 0 320px);
        left: -20px;
    }
    26% {
        clip-path: inset(10px 0 320px);
        left: -10px;
        opacity: 0;
    }
   ...
}

@keyframes main-img-hide {
    5% {
        filter: invert(1);
    }
    ...
}

由于动画部分代码量太多,所以使用了 SASS 循环函数随机生成了部分。如果手动控制,效果其实还会更好,当然,调试动画消耗的时间会更多。

看看效果,虽然 CSS 能力有限,但实际的效果也不是说那么的差:

imgglitch

GIF 图太大,掉帧太多,效果大打折扣。完整的 Demo 及效果,你可以戳这里:

clip-path 实现图片的故障艺术风格动画

总结

本文重点介绍了纯 CSS 下使用混合模式和 clip-path 实现的一些故障艺术(Glitch Art),当然,上述的几个效果都不仅仅是靠这两个属性单打独斗就能完成的。

在其中,transformfilter 也在其中发挥了很重要的作用。当然仅仅使用 transformfilter 也能够实现一些基础的故障艺术效果,这个读者们感兴趣的可以自己多加尝试。如果想使用于生产环境,需要考虑 mix-blend-modeclip-path 的兼容性问题。

我自己对 Glitch Art 的理解其实也比较浅显,只是觉得使用 CSS 去实现这样一些类似的效果比较有意思,就动手尝试实践了一番,相关术语或者名词理解错误烦请各位谅解指出。

最后

好了,本文到此结束,希望对你有帮助 :)

更多精彩 CSS 技术文章汇总在我的 Github -- iCSS ,持续更新,欢迎点个 star 订阅收藏。

更多精彩有趣的 CSS 效果,欢迎来这里看看 CSS 灵感 -- 在这里找到写 CSS 的灵感

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

查看原文

赞 54 收藏 35 评论 3

ShapeLei 收藏了文章 · 2019-12-30

学习 axios 源码整体架构,打造属于自己的请求库

前言

你好,我是若川。这是学习源码整体架构系列第六篇。整体架构这词语好像有点大,姑且就算是源码整体结构吧,主要就是学习是代码整体结构,不深究其他不是主线的具体函数的实现。本篇文章学习的是实际仓库的代码。
要是有人说到怎么读源码,正在读文章的你能推荐我的源码系列文章,那真是太好了

学习源码整体架构系列文章如下:

1.学习 jQuery 源码整体架构,打造属于自己的 js 类库
2.学习 underscore 源码整体架构,打造属于自己的函数式编程类库
3.学习 lodash 源码整体架构,打造属于自己的函数式编程类库
4.学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK
5.学习 vuex 源码整体架构,打造属于自己的状态管理库
6.学习 axios 源码整体架构,打造属于自己的请求库
7.学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理
8.学习 redux 源码整体架构,深入理解 redux 及其中间件原理
感兴趣的读者可以点击阅读。

其他源码计划中的有:expressvue-rotuerreact-redux 等源码,不知何时能写完(哭泣),欢迎持续关注我(若川)。

源码类文章,一般阅读量不高。已经有能力看懂的,自己就看了。不想看,不敢看的就不会去看源码。

所以我的文章,尽量写得让想看源码又不知道怎么看的读者能看懂。

本文比较长,手机上阅读,可以划到有图的地方直接看文中的几张图即可。建议点赞或收藏后在电脑上阅读,按照文中调试方式自己调试或许更容易吸收消化。

导读
文章详细介绍了axios调试方法。详细介绍了axios构造函数,拦截器,取消等功能的实现。最后还对比了其他请求库。

本文学习的版本是v0.19.0。克隆的官方仓库的master分支。 截至目前(2019年12月14日),最新一次commit2019-12-09 15:52 ZhaoXC`dc4bc49673943e352fix: fix ignore set withCredentials false (#2582)`。

本文仓库在这里若川的 axios-analysis github 仓库。求个star呀。

如果你是求职者,项目写了运用了axios,面试官可能会问你:

1.为什么axios既可以当函数调用,也可以当对象使用,比如axios({})axios.get
2.简述axios调用流程。
3.有用过拦截器吗?原理是怎样的?
4.有使用axios的取消功能吗?是怎么实现的?
5.为什么支持浏览器中发送请求也支持node发送请求?
诸如这类问题。

chrome 和 vscode 调试 axios 源码方法

前不久,笔者在知乎回答了一个问题一年内的前端看不懂前端框架源码怎么办?推荐了一些资料,阅读量还不错,大家有兴趣可以看看。主要有四点:

1.借助调试
2.搜索查阅相关高赞文章
3.把不懂的地方记录下来,查阅相关文档
4.总结

看源码,调试很重要,所以笔者详细写下axios源码调试方法,帮助一些可能不知道如何调试的读者。

chrome 调试浏览器环境的 axios

调试方法

axios打包后有sourcemap文件。

# 可以克隆笔者的这个仓库代码
git clone https://github.com/lxchuan12/axios-analysis.git
cd axios-analaysis/axios
npm install
npm start
# open [http://localhost:3000](http://localhost:3000)
# chrome F12 source 控制面板  webpack//   .  lib 目录下,根据情况自行断点调试

本文就是通过上述的例子axios/sandbox/client.html来调试的。

顺便简单提下调试example的例子,虽然文章最开始时写了这部分,后来又删了,最后想想还是写下。

找到文件axios/examples/server.js,修改代码如下:

server = http.createServer(function (req, res) {
  var url = req.url;
  // 调试 examples
  console.log(url);
  // Process axios itself
  if (/axios\.min\.js$/.test(url)) {
    // 原来的代码 是 axios.min.js
    // pipeFileToResponse(res, '../dist/axios.min.js', 'text/javascript');
    pipeFileToResponse(res, '../dist/axios.js', 'text/javascript');
    return;
  }
  // 原来的代码 是 axios.min.map
  // if (/axios\.min.map$/.test(url)) {
  if (/axios\.map$/.test(url)) {
    // 原来的代码 是 axios.min.map
    // pipeFileToResponse(res, '../dist/axios.min.map', 'text/javascript');
    pipeFileToResponse(res, '../dist/axios.map', 'text/javascript');
    return;
  }
}
# 上述安装好依赖后
# npm run examples 不能同时开启,默认都是3000端口
# 可以指定端口 5000
# npm run examples ===  node ./examples/server.js
node ./examples/server.js -p 5000

打开http://localhost:5000,然后就可以开心的在Chrome浏览器中调试examples里的例子了。

axios是支持node环境发送请求的。接下来看如何用vscode调试node环境下的axios

vscode 调试 node 环境的 axios

在根目录下axios-analysis/创建.vscode/launch.json文件如下:

{
    // 使用 IntelliSense 了解相关属性。
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "program": "${workspaceFolder}/axios/sandbox/client.js",
            "skipFiles": [
                "<node_internals>/**"
            ]
        },
    ]
}

F5开始调试即可,按照自己的情况,单步跳过(F10)、单步调试(F11)断点调试。

其实开源项目一般都有贡献指南axios/CONTRIBUTING.md,笔者只是把这个指南的基础上修改为引用sourcemap的文件可调试。

先看 axios 结构是怎样的

git clone https://github.com/lxchuan12/axios-analysis.git
cd axios-analaysis/axios
npm install
npm start

按照上文说的调试方法,npm start后,直接在chrome浏览器中调试。 打开http://localhost:3000,在控制台打印出axios,估计很多人都没打印出来看过。

console.log({axios: axios});

层层点开来看,axios的结构是怎样的,先有一个大概印象。

笔者画了一张比较详细的图表示。

axios 结构关系图

看完结构图,如果看过jQueryunderscorelodash源码,会发现其实跟axios源码设计类似。

jQuery 别名$underscoreloadsh别名 _ 也既是函数,也是对象。比如jQuery使用方式。$('#id'),$.ajax

接下来看具体源码的实现。可以跟着断点调试一下。

断点调试要领:
赋值语句可以一步跳过,看返回值即可,后续详细再看。
函数执行需要断点跟着看,也可以结合注释和上下文倒推这个函数做了什么。

axios 源码 初始化

看源码第一步,先看package.json。一般都会申明main主入口文件。

// package.json
{
  "name": "axios",
  "version": "0.19.0",
  "description": "Promise based HTTP client for the browser and node.js",
  "main": "index.js",
  // ...
}

主入口文件

// index.js
module.exports = require('./lib/axios');

lib/axios.js主文件

axios.js文件 代码相对比较多。分为三部分展开叙述。

  1. 第一部分:引入一些工具函数utilsAxios构造函数、默认配置defaults等。
  2. 第二部分:是生成实例对象axiosaxios.Axiosaxios.create等。
  3. 第三部分取消相关API实现,还有allspread、导出等实现。

第一部分

引入一些工具函数utilsAxios构造函数、默认配置defaults等。

// 第一部分:
// lib/axios
// 严格模式
'use strict';
// 引入 utils 对象,有很多工具方法。
var utils = require('./utils');
// 引入 bind 方法
var bind = require('./helpers/bind');
// 核心构造函数 Axios
var Axios = require('./core/Axios');
// 合并配置方法
var mergeConfig = require('./core/mergeConfig');
// 引入默认配置
var defaults = require('./defaults');

第二部分

是生成实例对象axiosaxios.Axiosaxios.create等。

/**
 * Create an instance of Axios
 *
 * @param {Object} defaultConfig The default config for the instance
 * @return {Axios} A new instance of Axios
 */
function createInstance(defaultConfig) {
  // new 一个 Axios 生成实例对象
  var context = new Axios(defaultConfig);
  // bind 返回一个新的 wrap 函数,
  // 也就是为什么调用 axios 是调用 Axios.prototype.request 函数的原因
  var instance = bind(Axios.prototype.request, context);
  // Copy axios.prototype to instance
  // 复制 Axios.prototype 到实例上。
  // 也就是为什么 有 axios.get 等别名方法,
  // 且调用的是 Axios.prototype.get 等别名方法。
  utils.extend(instance, Axios.prototype, context);
  // Copy context to instance
  // 复制 context 到 intance 实例
  // 也就是为什么默认配置 axios.defaults 和拦截器  axios.interceptors 可以使用的原因
  // 其实是new Axios().defaults 和 new Axios().interceptors
  utils.extend(instance, context);
  // 最后返回实例对象,以上代码,在上文的图中都有体现。这时可以仔细看下上图。
  return instance;
}

// Create the default instance to be exported
// 导出 创建默认实例
var axios = createInstance(defaults);
// Expose Axios class to allow class inheritance
// 暴露 Axios class 允许 class 继承 也就是可以 new axios.Axios()
// 但  axios 文档中 并没有提到这个,我们平时也用得少。
axios.Axios = Axios;

// Factory for creating new instances
// 工厂模式 创建新的实例 用户可以自定义一些参数
axios.create = function create(instanceConfig) {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
};

这里简述下工厂模式。axios.create,也就是用户不需要知道内部是怎么实现的。
举个生活的例子,我们买手机,不需要知道手机是怎么做的,就是工厂模式。
看完第二部分,里面涉及几个工具函数,如bindextend。接下来讲述这几个工具方法。

工具方法之 bind

axios/lib/helpers/bind.js

'use strict';
// 返回一个新的函数 wrap
module.exports = function bind(fn, thisArg) {
  return function wrap() {
    var args = new Array(arguments.length);
    for (var i = 0; i < args.length; i++) {
      args[i] = arguments[i];
    }
    // 把 argument 对象放在数组 args 里
    return fn.apply(thisArg, args);
  };
};

传递两个参数函数和thisArg指向。
把参数arguments生成数组,最后调用返回参数结构。
其实现在apply支持arguments这样的类数组对象了,不需要手动转数组。
那么为啥作者要转数组,为了性能?当时不支持?抑或是作者不知道?这就不得而知了。有读者知道欢迎评论区告诉笔者呀。

关于applycallbind等不是很熟悉的读者,可以看笔者的另一个面试官问系列
面试官问:能否模拟实现JS的bind方法

举个例子

function fn(){
  console.log.apply(console, arguments);
}
fn(1,2,3,4,5,6, '若川');
// 1 2 3 4 5 6 '若川'

工具方法之 utils.extend

axios/lib/utils.js

function extend(a, b, thisArg) {
  forEach(b, function assignValue(val, key) {
    if (thisArg && typeof val === 'function') {
      a[key] = bind(val, thisArg);
    } else {
      a[key] = val;
    }
  });
  return a;
}

其实就是遍历参数b对象,复制到a对象上,如果是函数就是则用bind调用。

工具方法之 utils.forEach

axios/lib/utils.js

遍历数组和对象。设计模式称之为迭代器模式。很多源码都有类似这样的遍历函数。比如大家熟知的jQuery`$.each`。

/**
 * @param {Object|Array} obj The object to iterate
 * @param {Function} fn The callback to invoke for each item
 */
function forEach(obj, fn) {
  // Don't bother if no value provided
  // 判断 null 和 undefined 直接返回
  if (obj === null || typeof obj === 'undefined') {
    return;
  }

  // Force an array if not already something iterable
  // 如果不是对象,放在数组里。
  if (typeof obj !== 'object') {
    /*eslint no-param-reassign:0*/
    obj = [obj];
  }

  // 是数组 则用for 循环,调用 fn 函数。参数类似 Array.prototype.forEach 的前三个参数。
  if (isArray(obj)) {
    // Iterate over array values
    for (var i = 0, l = obj.length; i < l; i++) {
      fn.call(null, obj[i], i, obj);
    }
  } else {
    // Iterate over object keys
    // 用 for in 遍历对象,但 for in 会遍历原型链上可遍历的属性。
    // 所以用 hasOwnProperty 来过滤自身属性了。
    // 其实也可以用Object.keys来遍历,它不遍历原型链上可遍历的属性。
    for (var key in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        fn.call(null, obj[key], key, obj);
      }
    }
  }
}

如果对Object相关的API不熟悉,可以查看笔者之前写过的一篇文章。JavaScript 对象所有API解析

第三部分

取消相关API实现,还有allspread、导出等实现。

// Expose Cancel & CancelToken
// 导出 Cancel 和 CancelToken
axios.Cancel = require('./cancel/Cancel');
axios.CancelToken = require('./cancel/CancelToken');
axios.isCancel = require('./cancel/isCancel');

// Expose all/spread
// 导出 all 和 spread API
axios.all = function all(promises) {
  return Promise.all(promises);
};
axios.spread = require('./helpers/spread');

module.exports = axios;

// Allow use of default import syntax in TypeScript
// 也就是可以以下方式引入
// import axios from 'axios';
module.exports.default = axios;

这里介绍下spread,取消的API暂时不做分析,后文再详细分析。

假设你有这样的需求。

function f(x, y, z) {}
var args = [1, 2, 3];
f.apply(null, args);

那么可以用spread方法。用法:

axios.spread(function(x, y, z) {})([1, 2, 3]);

实现也比较简单。源码实现:

/**
 * @param {Function} callback
 * @returns {Function}
 */
module.exports = function spread(callback) {
  return function wrap(arr) {
    return callback.apply(null, arr);
  };
};

上文var context = new Axios(defaultConfig);,接下来介绍核心构造函数Axios

核心构造函数 Axios

axios/lib/core/Axios.js

构造函数Axios

function Axios(instanceConfig) {
  // 默认参数
  this.defaults = instanceConfig;
  // 拦截器 请求和响应拦截器
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}
Axios.prototype.request = function(config){
  // 省略,这个是核心方法,后文结合例子详细描述
  // code ...
  var promise = Promise.resolve(config);
  // code ...
  return promise;
}
// 这是获取 Uri 的函数,这里省略
Axios.prototype.getUri = function(){}
// 提供一些请求方法的别名
// Provide aliases for supported request methods
// 遍历执行
// 也就是为啥我们可以 axios.get 等别名的方式调用,而且调用的是 Axios.prototype.request 方法
// 这个也在上面的 axios 结构图上有所体现。
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
  /*eslint func-names:0*/
  Axios.prototype[method] = function(url, config) {
    return this.request(utils.merge(config || {}, {
      method: method,
      url: url
    }));
  };
});

utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
  /*eslint func-names:0*/
  Axios.prototype[method] = function(url, data, config) {
    return this.request(utils.merge(config || {}, {
      method: method,
      url: url,
      data: data
    }));
  };
});

module.exports = Axios;

接下来看拦截器部分。

拦截器管理构造函数 InterceptorManager

请求前拦截,和请求后拦截。
Axios.prototype.request函数里使用,具体怎么实现的拦截的,后文配合例子详细讲述。

axios github 仓库 拦截器文档

如何使用:

// Add a request interceptor
// 添加请求前拦截器
axios.interceptors.request.use(function (config) {
  // Do something before request is sent
  return config;
}, function (error) {
  // Do something with request error
  return Promise.reject(error);
});

// Add a response interceptor
// 添加请求后拦截器
axios.interceptors.response.use(function (response) {
  // Any status code that lie within the range of 2xx cause this function to trigger
  // Do something with response data
  return response;
}, function (error) {
  // Any status codes that falls outside the range of 2xx cause this function to trigger
  // Do something with response error
  return Promise.reject(error);
});

如果想把拦截器,可以用eject方法。

const myInterceptor = axios.interceptors.request.use(function () {/*...*/});
axios.interceptors.request.eject(myInterceptor);

拦截器也可以添加自定义的实例上。

const instance = axios.create();
instance.interceptors.request.use(function () {/*...*/});

源码实现:

构造函数,handles用于存储拦截器函数。

function InterceptorManager() {
  this.handlers = [];
}

接下来声明了三个方法:使用、移除、遍历。

InterceptorManager.prototype.use 使用

传递两个函数作为参数,数组中的一项存储的是{fulfilled: function(){}, rejected: function(){}}。返回数字ID,用于移除拦截器。

/**
 * @param {Function} fulfilled The function to handle `then` for a `Promise`
 * @param {Function} rejected The function to handle `reject` for a `Promise`
 *
 * @return {Number} 返回ID 是为了用 eject 移除
 */
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected
  });
  return this.handlers.length - 1;
};

InterceptorManager.prototype.eject 移除

根据use返回的ID移除 拦截器。

/**
 * @param {Number} id The ID that was returned by `use`
 */
InterceptorManager.prototype.eject = function eject(id) {
  if (this.handlers[id]) {
    this.handlers[id] = null;
  }
};

有点类似定时器setTimeoutsetInterval,返回值是id。用clearTimeoutclearInterval来清除定时器。

// 提一下 定时器回调函数是可以传参的,返回值 timer 是数字
var timer = setInterval((name) => {
  console.log(name);
}, 1000, '若川');
console.log(timer); // 数字 ID
// 在控制台等会再输入执行这句,定时器就被清除了
clearInterval(timer);

InterceptorManager.prototype.forEach 遍历

遍历执行所有拦截器,传递一个回调函数(每一个拦截器函数作为参数)调用,被移除的一项是null,所以不会执行,也就达到了移除的效果。

/**
 * @param {Function} fn The function to call for each interceptor
 */
InterceptorManager.prototype.forEach = function forEach(fn) {
  utils.forEach(this.handlers, function forEachHandler(h) {
    if (h !== null) {
      fn(h);
    }
  });
};

实例结合

上文叙述的调试时运行npm start是用axios/sandbox/client.html路径的文件作为示例的,读者可以自行调试。

以下是一段这个文件中的代码。

axios(options)
.then(function (res) {
  response.innerHTML = JSON.stringify(res.data, null, 2);
})
.catch(function (res) {
  response.innerHTML = JSON.stringify(res.data, null, 2);
});

先看调用栈流程

如果不想一步步调试,有个偷巧的方法。
知道axios使用了XMLHttpRequest
可以在项目中搜索:new XMLHttpRequest
定位到文件axios/lib/adapters/xhr.js
在这条语句var request = new XMLHttpRequest();
chrome浏览器中 打个断点调试下,再根据调用栈来细看具体函数等实现。

Call Stack

dispatchXhrRequest (xhr.js:19)
xhrAdapter (xhr.js:12)
dispatchRequest (dispatchRequest.js:60)
Promise.then (async)
request (Axios.js:54)
wrap (bind.js:10)
submit.onclick ((index):138)

简述下流程:

  1. Send Request按钮点击submit.onclick
  2. 调用axios函数实际上是调用Axios.prototype.request函数,而这个函数使用bind返回的一个名为wrap的函数。
  3. 调用Axios.prototype.request
  4. (有请求拦截器的情况下执行请求拦截器),中间会执行dispatchRequest方法
  5. dispatchRequest之后调用adapter (xhrAdapter)
  6. 最后调用Promise中的函数dispatchXhrRequest,(有响应拦截器的情况下最后会再调用响应拦截器)

如果仔细看了文章开始的axios 结构关系图,其实对这个流程也有大概的了解。

接下来看Axios.prototype.request具体实现。

Axios.prototype.request 请求核心方法

这个函数是核心函数。 主要做了这几件事:

1.判断第一个参数是字符串,则设置 url,也就是支持axios('example/url', [, config]),也支持axios({})
2.合并默认参数和用户传递的参数
3.设置请求的方法,默认是是get方法
4.将用户设置的请求和响应拦截器、发送请求的dispatchRequest组成Promise链,最后返回还是Promise实例。
也就是保证了请求前拦截器先执行,然后发送请求,再响应拦截器执行这样的顺序。
也就是为啥最后还是可以thencatch方法的缘故。
Axios.prototype.request = function request(config) {
  /*eslint no-param-reassign:0*/
  // Allow for axios('example/url'[, config]) a la fetch API
  // 这一段代码 其实就是 使 axios('example/url', [, config])
  // config 参数可以省略
  if (typeof config === 'string') {
    config = arguments[1] || {};
    config.url = arguments[0];
  } else {
    config = config || {};
  }

  // 合并默认参数和用户传递的参数
  config = mergeConfig(this.defaults, config);

  // Set config.method
  // 设置 请求方法,默认 get 。
  if (config.method) {
    config.method = config.method.toLowerCase();
  } else if (this.defaults.method) {
    config.method = this.defaults.method.toLowerCase();
  } else {
    config.method = 'get';
  }
  // Hook up interceptors middleware
  // 组成`Promise`链 这段拆开到后文再讲述
};

组成Promise链,返回Promise实例

这部分:用户设置的请求和响应拦截器、发送请求的dispatchRequest组成Promise链。也就是保证了请求前拦截器先执行,然后发送请求,再响应拦截器执行这样的顺序
也就是保证了请求前拦截器先执行,然后发送请求,再响应拦截器执行这样的顺序
也就是为啥最后还是可以thencatch方法的缘故。

如果读者对Promise不熟悉,建议读阮老师的书籍《ES6 标准入门》。阮一峰老师 的 ES6 Promise-resolveJavaScript Promise迷你书(中文版)

  // 组成`Promise`链
  // Hook up interceptors middleware
  // 把 xhr 请求 的 dispatchRequest 和 undefined 放在一个数组里
  var chain = [dispatchRequest, undefined];
  // 创建 Promise 实例
  var promise = Promise.resolve(config);

 // 遍历用户设置的请求拦截器 放到数组的 chain 前面
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

 // 遍历用户设置的响应拦截器 放到数组的 chain 后面
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });

 // 遍历 chain 数组,直到遍历 chain.length 为 0
  while (chain.length) {
    // 两两对应移出来 放到 then 的两个参数里。
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
var promise = Promise.resolve(config);

解释下这句。作用是生成Promise实例。

var promise = Promise.resolve({name: '若川'})
// 等价于
// new Promise(resolve => resolve({name: '若川'}))
promise.then(function (config){
  console.log(config)
});
// {name: "若川"}

同样解释下后文会出现的Promise.reject(error);

Promise.reject(error);
var promise = Promise.reject({name: '若川'})
// 等价于
// new Promise(reject => reject({name: '若川'}))

// promise.then(null, function (config){
//   console.log(config)
// });
// 等价于
promise.catch(function (config){
  console.log(config)
});
// {name: "若川"}

接下来结合例子,来理解这段代码。
很遗憾,在example文件夹没有拦截器的例子。笔者在example中在example/get的基础上添加了一个拦截器的示例。axios/examples/interceptors,便于读者调试。

node ./examples/server.js -p 5000

promise = promise.then(chain.shift(), chain.shift());这段代码打个断点。

会得到这样的这张图。

request方法中promise链

特别关注下,右侧,local中的chain数组。也就是这样的结构。

var chain = [
  '请求成功拦截2', '请求失败拦截2',  
  '请求成功拦截1', '请求失败拦截1',  
  dispatch,  undefined,
  '响应成功拦截1', '响应失败拦截1',
  '响应成功拦截2', '响应失败拦截2',
]

这段代码相对比较绕。也就是会生成如下类似的代码,中间会调用dispatchRequest方法。

// config 是 用户配置和默认配置合并的
var promise = Promise.resolve(config);
promise.then('请求成功拦截2', '请求失败拦截2')
.then('请求成功拦截1', '请求失败拦截1')
.then(dispatchRequest, undefined)
.then('响应成功拦截1', '响应失败拦截1')
.then('响应成功拦截2', '响应失败拦截2')

.then('用户写的业务处理函数')
.catch('用户写的报错业务处理函数');

这里提下promise`thencatch`知识:
Promise.prototype.then方法的第一个参数是resolved状态的回调函数,第二个参数(可选)是rejected状态的回调函数。所以是成对出现的。
Promise.prototype.catch方法是.then(null, rejection).then(undefined, rejection)的别名,用于指定发生错误时的回调函数。
then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。

结合上述的例子更详细一点,代码则是这样的。

var promise = Promise.resolve(config);
// promise.then('请求成功拦截2', '请求失败拦截2')
promise.then(function requestSuccess2(config) {
  console.log('------request------success------2');
  return config;
}, function requestError2(error) {
  console.log('------response------error------2');
  return Promise.reject(error);
})

// .then('请求成功拦截1', '请求失败拦截1')
.then(function requestSuccess1(config) {
  console.log('------request------success------1');
  return config;
}, function requestError1(error) {
  console.log('------response------error------1');
  return Promise.reject(error);
})

// .then(dispatchRequest, undefined)
.then( function dispatchRequest(config) {
  /**
    * 适配器返回的也是Promise 实例
      adapter = function xhrAdapter(config) {
            return new Promise(function dispatchXhrRequest(resolve, reject) {})
      }
    **/
  return adapter(config).then(function onAdapterResolution(response) {
    // 省略代码 ...
    return response;
  }, function onAdapterRejection(reason) {
    // 省略代码 ...
    return Promise.reject(reason);
  });
}, undefined)

// .then('响应成功拦截1', '响应失败拦截1')
.then(function responseSuccess1(response) {
  console.log('------response------success------1');
  return response;
}, function responseError1(error) {
  console.log('------response------error------1');
  return Promise.reject(error);
})

// .then('响应成功拦截2', '响应失败拦截2')
.then(function responseSuccess2(response) {
  console.log('------response------success------2');
  return response;
}, function responseError2(error) {
  console.log('------response------error------2');
  return Promise.reject(error);
})

// .then('用户写的业务处理函数')
// .catch('用户写的报错业务处理函数');
.then(function (response) {
  console.log('哈哈哈,终于获取到数据了', response);
})
.catch(function (err) {
  console.log('哎呀,怎么报错了', err);
});

仔细看这段Promise链式调用,代码都类似。then方法最后返回的参数,就是下一个then方法第一个参数。
catch错误捕获,都返回Promise.reject(error),这是为了便于用户catch时能捕获到错误。

举个例子:

var p1 = new Promise((resolve, reject) => {
 reject(new Error({name: '若川'}));
});

p1.catch(err => {
    console.log(res, 'err');
    return Promise.reject(err)
})
.catch(err => {
 console.log(err, 'err1');
})
.catch(err => {
 console.log(err, 'err2');
});

err2不会捕获到,也就是不会执行,但如果都返回了return Promise.reject(err),则可以捕获到。

最后画个图总结下Promise链式调用。

axios promise 链式调用

小结:1. 请求和响应的拦截器可以写Promise
2. 如果设置了多个请求响应器,后设置的先执行。
3. 如果设置了多个响应拦截器,先设置的先执行。

dispatchRequest(config)这里的config是请求成功拦截器返回的。接下来看dispatchRequest函数。

dispatchRequest 最终派发请求

这个函数主要做了如下几件事情:

1.如果已经取消,则throw原因报错,使Promise走向rejected
2.确保config.header存在。
3.利用用户设置的和默认的请求转换器转换数据。
4.拍平config.header
5.删除一些config.header
6.返回适配器adapterPromise实例)执行后then执行后的Promise实例。返回结果传递给响应拦截器处理。
'use strict';
// utils 工具函数
var utils = require('./../utils');
// 转换数据
var transformData = require('./transformData');
// 取消状态
var isCancel = require('../cancel/isCancel');
// 默认参数
var defaults = require('../defaults');

/**
 * 抛出 错误原因,使`Promise`走向`rejected`
 */
function throwIfCancellationRequested(config) {
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
}

/**
 * Dispatch a request to the server using the configured adapter.
 *
 * @param {object} config The config that is to be used for the request
 * @returns {Promise} The Promise to be fulfilled
 */
module.exports = function dispatchRequest(config) {
  // 取消相关
  throwIfCancellationRequested(config);

  // Ensure headers exist
  // 确保 headers 存在
  config.headers = config.headers || {};

  // Transform request data
  // 转换请求的数据
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );

  // Flatten headers
  // 拍平 headers
  config.headers = utils.merge(
    config.headers.common || {},
    config.headers[config.method] || {},
    config.headers || {}
  );

  // 以下这些方法 删除 headers
  utils.forEach(
    ['delete', 'get', 'head', 'post', 'put', 'patch', 'common'],
    function cleanHeaderConfig(method) {
      delete config.headers[method];
    }
  );
  // adapter 适配器部分 拆开 放在下文讲
};

dispatchRequest 之 transformData 转换数据

上文的代码里有个函数transformData,这里解释下。其实就是遍历传递的函数数组 对数据操作,最后返回数据。

axios.defaults.transformResponse数组中默认就有一个函数,所以使用concat链接自定义的函数。

使用:

文件路径axios/examples/transform-response/index.html

这段代码其实就是对时间格式的字符串转换成时间对象,可以直接调用getMonth等方法。

var ISO_8601 = /(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})Z/;
function formatDate(d) {
  return (d.getMonth() + 1) + '/' + d.getDate() + '/' + d.getFullYear();
}

axios.get('https://api.github.com/users/mzabriskie', {
  transformResponse: axios.defaults.transformResponse.concat(function (data, headers) {
    Object.keys(data).forEach(function (k) {
      if (ISO_8601.test(data[k])) {
        data[k] = new Date(Date.parse(data[k]));
      }
    });
    return data;
  })
})
.then(function (res) {
  document.getElementById('created').innerHTML = formatDate(res.data.created_at);
});

源码:

就是遍历数组,调用数组里的传递dataheaders参数调用函数。

module.exports = function transformData(data, headers, fns) {
  /*eslint no-param-reassign:0*/
  utils.forEach(fns, function transform(fn) {
    data = fn(data, headers);
  });

  return data;
};

dispatchRequest 之 adapter 适配器执行部分

适配器,在设计模式中称之为适配器模式。讲个生活中简单的例子,大家就容易理解。

我们常用以前手机耳机孔都是圆孔,而现在基本是耳机孔和充电接口合二为一。统一为typec

这时我们需要需要一个typec转圆孔的转接口,这就是适配器。

  // adapter 适配器部分
  var adapter = config.adapter || defaults.adapter;

  return adapter(config).then(function onAdapterResolution(response) {
    throwIfCancellationRequested(config);

    // Transform response data
    // 转换响应的数据
    response.data = transformData(
      response.data,
      response.headers,
      config.transformResponse
    );

    return response;
  }, function onAdapterRejection(reason) {
    if (!isCancel(reason)) {
      // 取消相关
      throwIfCancellationRequested(config);

      // Transform response data
      // 转换响应的数据
      if (reason && reason.response) {
        reason.response.data = transformData(
          reason.response.data,
          reason.response.headers,
          config.transformResponse
        );
      }
    }

    return Promise.reject(reason);
  });

接下来看具体的adapter

adapter 适配器 真正发送请求

var adapter = config.adapter || defaults.adapter;

看了上文的adapter,可以知道支持用户自定义。比如可以通过微信小程序wx.request按照要求也写一个adapter
接着来看下defaults.ddapter
文件路径:axios/lib/defaults.js

根据当前环境引入,如果是浏览器环境引入xhr,是node环境则引入http
类似判断node环境,也在sentry-javascript源码中有看到。

function getDefaultAdapter() {
  var adapter;
  // 根据 XMLHttpRequest 判断
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
    // 根据 process 判断
  } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}
var defaults = {
  adapter: getDefaultAdapter(),
  // ...
};

xhr

接下来就是我们熟悉的XMLHttpRequest对象。

可能读者不了解可以参考XMLHttpRequest MDN 文档

主要提醒下:onabort是请求取消事件,withCredentials是一个布尔值,用来指定跨域Access-Control请求是否应带有授权信息,如cookie或授权header头。

这块代码有删减,具体可以看若川的axios-analysis仓库,也可以克隆笔者的axios-analysis仓库调试时再具体分析。

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    // 这块代码有删减
    var request = new XMLHttpRequest();
    request.open()
    request.timeout = config.timeout;
    // 监听 state 改变
    request.onreadystatechange = function handleLoad() {
      if (!request || request.readyState !== 4) {
        return;
      }
      // ...
    }
    // 取消
    request.onabort = function(){};
    // 错误
    request.onerror = function(){};
    // 超时
    request.ontimeout = function(){};
    // cookies 跨域携带 cookies 面试官常喜欢考这个
    // 一个布尔值,用来指定跨域 Access-Control 请求是否应带有授权信息,如 cookie 或授权 header 头。
    // Add withCredentials to request if needed
    if (!utils.isUndefined(config.withCredentials)) {
      request.withCredentials = !!config.withCredentials;
    }

    // 上传下载进度相关
    // Handle progress if needed
    if (typeof config.onDownloadProgress === 'function') {
      request.addEventListener('progress', config.onDownloadProgress);
    }

    // Not all browsers support upload events
    if (typeof config.onUploadProgress === 'function' && request.upload) {
      request.upload.addEventListener('progress', config.onUploadProgress);
    }

    // Send the request
    // 发送请求
    request.send(requestData);
  });
}

而实际上现在fetch支持的很好了,阿里开源的umi-request请求库,就是用fetch封装的,而不是用XMLHttpRequest。 文章末尾,大概讲述下umi-requestaxios的区别。

http

http这里就不详细叙述了,感兴趣的读者可以自行查看,若川的axios-analysis仓库

module.exports = function httpAdapter(config) {
  return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
  });
};

上文dispatchRequest有取消模块,我觉得是重点,所以放在最后来细讲:

dispatchRequest 之 取消模块

可以使用cancel token取消请求。

axios cancel token API 是基于撤销的promise取消提议。

The axios cancel token API is based on the withdrawncancelable promises proposal.

axios 文档 cancellation

文档上详细描述了两种使用方式。

很遗憾,在example文件夹也没有取消的例子。笔者在example中在example/get的基础上添加了一个取消的示例。axios/examples/cancel,便于读者调试。

node ./examples/server.js -p 5000

request中的拦截器和dispatch中的取消这两个模块相对复杂,可以多调试调试,吸收消化。

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/get/server', {
  cancelToken: source.token
}).catch(function (err) {
  if (axios.isCancel(err)) {
    console.log('Request canceled', err.message);
  } else {
    // handle error
  }
});

// cancel the request (the message parameter is optional)
// 取消函数。
source.cancel('哎呀,我被若川取消了');

取消请求模块代码示例

结合源码取消流程大概是这样的。这段放在代码在axios/examples/cancel-token/index.html

参数的config.cancelToken是触发了source.cancel('哎呀,我被若川取消了');才生成的。

// source.cancel('哎呀,我被若川取消了');
// 点击取消时才会 生成 cancelToken 实例对象。
// 点击取消后,会生成原因,看懂了这段在看之后的源码,可能就好理解了。
var config = {
  name: '若川',
  // 这里简化了
  cancelToken: {
        promise: new Promise(function(resolve){
            resolve({ message: '哎呀,我被若川取消了'})
        }),
        reason: { message: '哎呀,我被若川取消了' }
  },
};
// 取消 抛出异常方法
function throwIfCancellationRequested(config){
  // 取消的情况下执行这句
  if(config.cancelToken){
    //   这里源代码 便于执行,我改成具体代码
    // config.cancelToken.throwIfRequested();
    // if (this.reason) {
    //     throw this.reason;
    //   }
    if(config.cancelToken.reason){
        throw config.cancelToken.reason;
    }
  }
}

function dispatchRequest(config){
  // 有可能是执行到这里就取消了,所以抛出错误会被err2 捕获到
  throwIfCancellationRequested(config);
  //  adapter xhr适配器
  return new Promise((resovle, reject) => {
    var request = new XMLHttpRequest();
    console.log('request', request);
    if (config.cancelToken) {
        // Handle cancellation
        config.cancelToken.promise.then(function onCanceled(cancel) {
            if (!request) {
                return;
            }

            request.abort();
            reject(cancel);
            // Clean up request
            request = null;
        });
    }
  })
  .then(function(res){
    // 有可能是执行到这里就才取消 取消的情况下执行这句
    throwIfCancellationRequested(config);
    console.log('res', res);
    return res;
  })
  .catch(function(reason){
    // 有可能是执行到这里就才取消 取消的情况下执行这句
    throwIfCancellationRequested(config);
    console.log('reason', reason);
    return Promise.reject(reason);
  });
}

var promise = Promise.resolve(config);

// 没设置拦截器的情况下是这样的
promise
.then(dispatchRequest, undefined)
// 用户定义的then 和 catch
.then(function(res){
  console.log('res1', res);
  return res;
})
.catch(function(err){
  console.log('err2', err);
  return Promise.reject(err);
});
// err2 {message: "哎呀,我被若川取消了"}

接下来看取消模块的源码

看如何通过生成config.cancelToken

文件路径:

axios/lib/cancel/CancelToken.js

const CancelToken = axios.CancelToken;
const source = CancelToken.source();
source.cancel('哎呀,我被若川取消了');

由示例看CancelToken.source的实现,

CancelToken.source = function source() {
  var cancel;
  var token = new CancelToken(function executor(c) {
    cancel = c;
  });
  // token
  return {
    token: token,
    cancel: cancel
  };
};

执行后source的大概结构是这样的。

{
    token: {
    promise: new Promise(function(resolve){
      resolve({ message: '哎呀,我被若川取消了'})
    }),
    reason: { message: '哎呀,我被若川取消了' }
  },
  cancel: function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      // 已经取消
      return;
    }
    token.reason = {message: '哎呀,我被若川取消了'};
  }
}

接着看new CancelToken

// CancelToken
// 通过 CancelToken 来取消请求操作
function CancelToken(executor) {
  if (typeof executor !== 'function') {
    throw new TypeError('executor must be a function.');
  }

  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      // 已经取消
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

module.exports = CancelToken;

发送请求的适配器里是这样使用的。

// xhr
if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (!request) {
      return;
    }

    request.abort();
    reject(cancel);
    // Clean up request
    request = null;
  });
}

dispatchRequest中的throwIfCancellationRequested具体实现:throw 抛出异常。

// 抛出异常函数
function throwIfCancellationRequested(config) {
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
}
// 抛出异常 用户 { message: '哎呀,我被若川取消了' }
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
  if (this.reason) {
    throw this.reason;
  }
};

取消流程调用栈

1.source.cancel()
2.resolvePromise(token.reason);
3.config.cancelToken.promise.then(function onCanceled(cancel) {})

最后进入request.abort();`reject(cancel);`

到这里取消的流程就介绍完毕了。主要就是通过传递配置参数cancelToken,取消时才会生成cancelToken,判断有,则抛出错误,使Promise走向rejected,让用户捕获到消息{message: '用户设置的取消信息'}。

文章写到这里就基本到接近尾声了。

能读到最后,说明你已经超过很多人啦^_^

axios是非常优秀的请求库,但肯定也不能满足所有开发者的需求,接下来对比下其他库,看看其他开发者有什么具体需求。

对比其他请求库

KoAjax

FCC成都社区负责人水歌开源的KoAJAX

如何用开源软件办一场技术大会?以下这篇文章中摘抄的一段。

前端请求库 —— KoAJAX 国内前端同学最常用的 HTTP 请求库应该是 axios 了吧?虽然它的 Interceptor(拦截器)API 是 .use(),但和 Node.js 的 Express、Koa 等框架的中间件模式完全不同,相比 jQuery .ajaxPrefilter()、dataFilter() 并没什么实质改进;上传、下载进度比 jQuery.Deferred() 还简陋,只是两个专门的回调选项。所以,它还是要对特定的需求记忆特定的 API,不够简洁。

幸运的是,水歌在研究如何用 ES 2018 异步迭代器实现一个类 Koa 中间件引擎的过程中,做出了一个更有实际价值的上层应用 —— KoAJAX。它的整个执行过程基于 Koa 式的中间件,而且它自己就是一个中间件调用栈。除了 RESTful API 常用的 .get()、.post()、.put()、.delete() 等快捷方法外,开发者就只需记住 .use() 和 next(),其它都是 ES 标准语法和 TS 类型推导。

umi-request 阿里开源的请求库

umi-request github 仓库

umi-requestfetch,axios异同。
codeumi-request/code 与 codefetch/code, codeaxios/code 异同

不得不说,umi-request确实强大,有兴趣的读者可以阅读下其源码。

看懂axios的基础上,看懂umi-request源码应该不难。

比如umi-request取消模块代码几乎与axios一模一样。

总结

文章详细介绍了axios调试方法。详细介绍了axios构造函数,拦截器,取消等功能的实现。最后还对比了其他请求库。

最后画个图总结一下axios的总体大致流程。

axios的总体大致流程

解答下文章开头提的问题:

如果你是求职者,项目写了运用了axios,面试官可能会问你:

1.为什么axios既可以当函数调用,也可以当对象使用,比如axios({})axios.get
答:axios本质是函数,赋值了一些别名方法,比如getpost方法,可被调用,最终调用的还是Axios.prototype.request函数。
2.简述axios调用流程。
答:实际是调用的Axios.prototype.request方法,最终返回的是promise链式调用,实际请求是在dispatchRequest中派发的。
3.有用过拦截器吗?原理是怎样的?
答:用过,用axios.interceptors.request.use添加请求成功和失败拦截器函数,用axios.interceptors.response.use添加响应成功和失败拦截器函数。在Axios.prototype.request函数组成promise链式调用时,Interceptors.protype.forEach遍历请求和响应拦截器添加到真正发送请求dispatchRequest的两端,从而做到请求前拦截和响应后拦截。拦截器也支持用Interceptors.protype.eject方法移除。
4.有使用axios的取消功能吗?是怎么实现的?
答:用过,通过传递config配置cancelToken的形式,来取消的。判断有传cancelToken,在promise链式调用的dispatchRequest抛出错误,在adapterrequest.abort()取消请求,使promise走向rejected,被用户捕获取消信息。
5.为什么支持浏览器中发送请求也支持node发送请求?
答:axios.defaults.adapter默认配置中根据环境判断是浏览器还是node环境,使用对应的适配器。适配器支持自定义。

回答面试官的问题,读者也可以根据自己的理解,组织语言,笔者的回答只是做一个参考。

axios源码相对不多,打包后一千多行,比较容易看完,非常值得学习。

建议clone若川的 axios-analysis github 仓库,按照文中方法自己调试,印象更深刻。

基于Promise,构成Promise链,巧妙的设置请求拦截,发送请求,再试试响应拦截器。

request中的拦截器和dispatch中的取消这两个模块相对复杂,可以多调试调试,吸收消化。

axios既是函数,是函数时调用的是Axios.prototype.request函数,又是对象,其上面有getpost等请求方法,最终也是调用Axios.prototype.request函数。

axios源码中使用了挺多设计模式。比如工厂模式、迭代器模式、适配器模式等。如果想系统学习设计模式,一般比较推荐豆瓣评分9.1的JavaScript设计模式与开发实践

如果读者发现有不妥或可改善之处,再或者哪里没写明白的地方,欢迎评论指出。另外觉得写得不错,对您有些许帮助,可以点赞、评论、转发分享,也是对笔者的一种支持,非常感谢呀。

推荐阅读

官方axios github 仓库

写文章前,搜索了以下几篇文章泛读了一下。有兴趣再对比看看以下这几篇,有代码调试的基础上,看起来也快。

一直觉得多搜索几篇文章看,对自己学习知识更有用。有个词语叫主题阅读。大概意思就是一个主题一系列阅读。

@叫我小明呀:Axios 源码解析
@尼库尼库桑:深入浅出 axios 源码
@小贼先生_ronffy:Axios源码深度剖析 - AJAX新王者
逐行解析Axios源码
[译]axios 是如何封装 HTTP 请求的
知乎@Lee : TypeScript 重构 Axios 经验分享

笔者另一个系列

面试官问:JS的继承
面试官问:JS的this指向
面试官问:能否模拟实现JS的call和apply方法
面试官问:能否模拟实现JS的bind方法
面试官问:能否模拟实现JS的new操作符

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。
若川的博客,使用vuepress重构了,阅读体验可能更好些
掘金专栏,欢迎关注~
segmentfault前端视野专栏,欢迎关注~
知乎前端视野专栏,欢迎关注~
github blog,相关源码和资源都放在这里,求个star^_^~

欢迎加微信交流 微信公众号

可能比较有趣的微信公众号,长按扫码关注。欢迎加笔者微信ruochuan12(注明来源,基本来者不拒),拉您进【前端视野交流群】,长期交流学习~

若川视野

查看原文

ShapeLei 关注了专栏 · 2019-12-23

网易云音乐大前端团队

网易云音乐大前端技术团队专栏

关注 1631

ShapeLei 收藏了文章 · 2019-12-23

前端工程化 - 剖析npm的包管理机制

导读

现如今,前端开发的同学已经离不开 npm 这个包管理工具,其优秀的包版本管理机制承载了整个繁荣发展的NodeJS社区,理解其内部机制非常有利于加深我们对模块开发的理解、各项前端工程化的配置以加快我们排查问题(相信不少同学收到过各种依赖问题的困扰)的速度。

本文从三个角度:package.json、版本管理、依赖安装结合具体实例对 npm 的包管理机制进行了详细分析。

一、剖析 package.json

Node.js 中,模块是一个库或框架,也是一个 Node.js 项目。Node.js 项目遵循模块化的架构,当我们创建了一个 Node.js 项目,意味着创建了一个模块,这个模块必须有一个描述文件,即 package.json。它是我们最常见的配置文件,但是它里面的配置你真的有详细了解过吗?配置一个合理的 package.json 文件直接决定着我们项目的质量,所以首先带大家分析下 package.json 的各项详细配置。

1.1 必备属性

package.json 中有非常多的属性,其中必须填写的只有两个:nameversion ,这两个属性组成一个 npm 模块的唯一标识。

npm包命名规则

name 即模块名称,其命名时需要遵循官方的一些规范和建议:

  • 包名会成为模块url、命令行中的一个参数或者一个文件夹名称,任何非url安全的字符在包名中都不能使用,可以使用 validate-npm-package-name 包来检测包名是否合法。
  • 语义化包名,可以帮助开发者更快的找到需要的包,并且避免意外获取错误的包。
  • 若包名称中存在一些符号,将符号去除后不得与现有包名重复

例如:由于react-native已经存在,react.nativereactnative都不可以再创建。

  • 如果你的包名与现有的包名太相近导致你不能发布这个包,那么推荐将这个包发布到你的作用域下。

例如:用户名 conard,那么作用域为 @conard,发布的包可以是@conard/react

查看包是否被占用

name 是一个包的唯一标识,不得和其他包名重复,我们可以执行 npm view packageName 查看包是否被占用,并可以查看它的一些基本信息:

若包名称从未被使用过,则会抛出 404 错误:

另外,你还可以去 https://www.npmjs.com/ 查询更多更详细的包信息。

1.2描述信息

基本描述

{
  "description": "An enterprise-class UI design language and React components implementation",
  "keywords": [
    "ant",
    "component",
    "components",
    "design",
    "framework",
    "frontend",
    "react",
    "react-component",
    "ui"
  ]
}

description用于添加模块的的描述信息,方便别人了解你的模块。

keywords用于给你的模块添加关键字。

当然,他们的还有一个非常重要的作用,就是利于模块检索。当你使用 npm search 检索模块时,会到descriptionkeywords 中进行匹配。写好 descriptionkeywords 有利于你的模块获得更多更精准的曝光:

开发人员

描述开发人员的字段有两个:authorcontributorsauthor 指包的主要作者,一个 author 对应一个人。 contributors 指贡献者信息,一个 contributors 对应多个贡献者,值为数组,对人的描述可以是一个字符串,也可以是下面的结构:

{ 
    "name" : "ConardLi", 
    "email" : "lisqPersion@163.com", 
    "url" : "https://github.com/ConardLi"
}

地址

{
  "homepage": "http://ant.design/",
  "bugs": {
    "url": "https://github.com/ant-design/ant-design/issues"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/ant-design/ant-design"
  },
}

homepage 用于指定该模块的主页。

repository 用于指定模块的代码仓库。

bugs 指定一个地址或者一个邮箱,对你的模块存在疑问的人可以到这里提出问题。

1.3 依赖配置

我们的项目可能依赖一个或多个外部依赖包,根据依赖包的不同用途,我们将他们配置在下面几个属性下:dependencies、devDependencies、peerDependencies、bundledDependencies、optionalDependencies

配置规则

在介绍几种依赖配置之前,首先我们来看一下依赖的配置规则,你看到的依赖包配置可能是下面这样的:

 "dependencies": {
      "antd": "ant-design/ant-design#4.0.0-alpha.8",
      "axios": "^1.2.0",
      "test-js": "file:../test",
      "test2-js": "http://cdn.com/test2-js.tar.gz",
      "core-js": "^1.1.5",
 }

依赖配置遵循下面几种配置规则:

  • 依赖包名称:VERSION

    • VERSION是一个遵循SemVer规范的版本号配置,npm install 时将到npm服务器下载符合指定版本范围的包。
  • 依赖包名称:DWONLOAD_URL

    • DWONLOAD_URL 是一个可下载的tarball压缩包地址,模块安装时会将这个.tar下载并安装到本地。
  • 依赖包名称:LOCAL_PATH

    • LOCAL_PATH 是一个本地的依赖包路径,例如 file:../pacakges/pkgName。适用于你在本地测试一个npm包,不应该将这种方法应用于线上。
  • 依赖包名称:GITHUB_URL

    • GITHUB_URLgithubusername/modulename 的写法,例如:ant-design/ant-design,你还可以在后面指定 tagcommit id
  • 依赖包名称:GIT_URL

    • GIT_URL 即我们平时clone代码库的 git url,其遵循以下形式:
<protocol>://[<user>[:<password>]@]<hostname>[:<port>][:][/]<path>[#<commit-ish> | #semver:<semver>]

其中 protocal 可以是以下几种形式:

  • git://github.com/user/project.git#commit-ish
  • git+ssh://user@hostname:project.git#commit-ish
  • git+ssh://user@hostname/project.git#commit-ish
  • git+http://user@hostname/project/blah.git#commit-ish
  • git+https://user@hostname/project/blah.git#commit-ish

dependencies

dependencies 指定了项目运行所依赖的模块,开发环境和生产环境的依赖模块都可以配置到这里,例如

 "dependencies": {
      "lodash": "^4.17.13",
      "moment": "^2.24.0",
 }

devDependencies

有一些包有可能你只是在开发环境中用到,例如你用于检测代码规范的 eslint ,用于进行测试的 jest ,用户使用你的包时即使不安装这些依赖也可以正常运行,反而安装他们会耗费更多的时间和资源,所以你可以把这些依赖添加到 devDependencies 中,这些依赖照样会在你本地进行 npm install 时被安装和管理,但是不会被安装到生产环境:

 "devDependencies": {
      "jest": "^24.3.1",
      "eslint": "^6.1.0",
 }

peerDependencies

peerDependencies 用于指定你正在开发的模块所依赖的版本以及用户安装的依赖包版本的兼容性。

上面的说法可能有点太抽象,我们直接拿 ant-design 来举个例子,ant-designpackage.json 中有如下配置:

  "peerDependencies": {
    "react": ">=16.0.0",
    "react-dom": ">=16.0.0"
  }

当你正在开发一个系统,使用了 ant-design ,所以也肯定需要依赖 React。同时, ant-design 也是需要依赖 React 的,它要保持稳定运行所需要的 React 版本是16.0.0,而你开发时依赖的 React 版本是 15.x

这时,ant-design 要使用 React,并将其引入:

import * as React from 'react';
import * as ReactDOM from 'react-dom';

这时取到的是宿主环境也就是你的环境中的 React 版本,这就可能造成一些问题。在 npm2 的时候,指定上面的 peerDependencies 将意味着强制宿主环境安装 react@>=16.0.0和react-dom@>=16.0.0 的版本。

npm3 以后不会再要求 peerDependencies 所指定的依赖包被强制安装,相反 npm3 会在安装结束后检查本次安装是否正确,如果不正确会给用户打印警告提示。

  "dependencies": {
    "react": "15.6.0",
    "antd": "^3.22.0"
  }

例如,我在项目中依赖了 antd 的最新版本,然后依赖了 react15.6.0版本,在进行依赖安装时将给出以下警告:

optionalDependencies

某些场景下,依赖包可能不是强依赖的,这个依赖包的功能可有可无,当这个依赖包无法被获取到时,你希望 npm install 继续运行,而不会导致失败,你可以将这个依赖放到 optionalDependencies 中,注意 optionalDependencies 中的配置将会覆盖掉 dependencies 所以只需在一个地方进行配置。

当然,引用 optionalDependencies 中安装的依赖时,一定要做好异常处理,否则在模块获取不到时会导致报错。

bundledDependencies

和以上几个不同,bundledDependencies 的值是一个数组,数组里可以指定一些模块,这些模块将在这个包发布时被一起打包。

  "bundledDependencies": ["package1" , "package2"]

1.4 协议

{
    "license": "MIT"
}

license 字段用于指定软件的开源协议,开源协议里面详尽表述了其他人获得你代码后拥有的权利,可以对你的的代码进行何种操作,何种操作又是被禁止的。同一款协议有很多变种,协议太宽松会导致作者丧失对作品的很多权利,太严格又不便于使用者使用及作品的传播,所以开源作者要考虑自己对作品想保留哪些权利,放开哪些限制。

软件协议可分为开源和商业两类,对于商业协议,或者叫法律声明、许可协议,每个软件会有自己的一套行文,由软件作者或专门律师撰写,对于大多数人来说不必自己花时间和精力去写繁长的许可协议,选择一份广为流传的开源协议就是个不错的选择。

以下就是几种主流的开源协议:

  • MIT:只要用户在项目副本中包含了版权声明和许可声明,他们就可以拿你的代码做任何想做的事情,你也无需承担任何责任。
  • Apache:类似于 MIT,同时还包含了贡献者向用户提供专利授权相关的条款。
  • GPL:修改项目代码的用户再次分发源码或二进制代码时,必须公布他的相关修改。

如果你对开源协议有更详细的要求,可以到 https://choosealicense.com/ 获取更详细的开源协议说明。

1.5 目录、文件相关

程序入口

{
  "main": "lib/index.js",
}

main 属性可以指定程序的主入口文件,例如,上面 antd 指定的模块入口 lib/index.js ,当我们在代码用引入 antd 时:import { notification } from 'antd'; 实际上引入的就是 lib/index.js 中暴露出去的模块。

命令行工具入口

当你的模块是一个命令行工具时,你需要为命令行工具指定一个入口,即指定你的命令名称和本地可指定文件的对应关系。如果是全局安装,npm 将会使用符号链接把可执行文件链接到 /usr/local/bin,如果是本地安装,会链接到 ./node_modules/.bin/

{
    "bin": {
    "conard": "./bin/index.js"
  }
}

例如上面的配置:当你的包安装到全局时:npm 会在 /usr/local/bin 下创建一个以 conard 为名字的软链接,指向全局安装下来的 conard 包下面的 "./bin/index.js"。这时你在命令行执行 conard 则会调用链接到的这个js文件。

这里不再过多展开,更多内容在我后续的命令行工具文章中会进行详细讲解。

发布文件配置

{
    "files": [
      "dist",
      "lib",
      "es"
    ]
}

files 属性用于描述你 npm publish 后推送到 npm 服务器的文件列表,如果指定文件夹,则文件夹内的所有内容都会包含进来。我们可以看到下载后的包是下面的目录结构:

另外,你还可以通过配置一个 .npmignore 文件来排除一些文件, 防止大量的垃圾文件推送到 npm, 规则上和你用的 .gitignore 是一样的。.gitignore 文件也可以充当.npmignore 文件。

man

man 命令是 Linux 下的帮助指令,通过 man 指令可以查看 Linux 中的指令帮助、配置文件帮助和编程帮助等信息。

如果你的 node.js 模块是一个全局的命令行工具,在 package.json 通过 man 属性可以指定 man 命令查找的文档地址。

man 文件必须以数字结尾,或者如果被压缩了,以 .gz 结尾。数字表示文件将被安装到 man 的哪个部分。如果 man 文件名称不是以模块名称开头的,安装的时候会给加上模块名称前缀。

例如下面这段配置:

{ 
  "man" : [ 
    "/Users/isaacs/dev/npm/cli/man/man1/npm-access.1",
    "/Users/isaacs/dev/npm/cli/man/man1/npm-audit.1"
  ]
}

在命令行输入 man npm-audit

规范项目目录

一个 node.js 模块是基于 CommonJS 模块化规范实现的,严格按照 CommonJS 规范,模块目录下除了必须包含包描述文件 package.json 以外,还需要包含以下目录:

  • bin:存放可执行二进制文件的目录
  • lib:存放js代码的目录
  • doc:存放文档的目录
  • test:存放单元测试用例代码的目录
  • ...

在模块目录中你可能没有严格按照以上结构组织或命名,你可以通过在 package.json 指定 directories 属性来指定你的目录结构和上述的规范结构的对应情况。除此之外 directories 属性暂时没有其他应用。

{
  "directories": {
    "lib": "src/lib/",
    "bin": "src/bin/",
    "man": "src/man/",
    "doc": "src/doc/",
    "example": "src/example/"
  }
}
不过官方文档表示,虽然目前这个属性没有什么重要作用,未来可能会整出一些花样出来,例如:doc 中存放的 markdown 文件、example 中存放的示例文件,可能会友好的展示出来。

1.6 脚本配置

script

{
  "scripts": {
    "test": "jest --config .jest.js --no-cache",
    "dist": "antd-tools run dist",
    "compile": "antd-tools run compile",
    "build": "npm run compile && npm run dist"
  }
}

scripts 用于配置一些脚本命令的缩写,各个脚本可以互相组合使用,这些脚本可以覆盖整个项目的生命周期,配置后可使用 npm run command 进行调用。如果是 npm 关键字,则可以直接调用。例如,上面的配置制定了以下几个命令:npm run testnpm run distnpm run compilenpm run build

config

config 字段用于配置脚本中使用的环境变量,例如下面的配置,可以在脚本中使用process.env.npm_package_config_port进行获取。

{
  "config" : { "port" : "8080" }
}

1.7 发布配置

preferGlobal

如果你的 node.js 模块主要用于安装到全局的命令行工具,那么该值设置为 true ,当用户将该模块安装到本地时,将得到一个警告。这个配置并不会阻止用户安装,而是会提示用户防止错误使用而引发一些问题。

private

如果将 private 属性设置为 true,npm将拒绝发布它,这是为了防止一个私有模块被无意间发布出去。

publishConfig

  "publishConfig": {
    "registry": "https://registry.npmjs.org/"
  },

发布模块时更详细的配置,例如你可以配置只发布某个 tag、配置发布到的私有 npm 源。更详细的配置可以参考 npm-config

os

假如你开发了一个模块,只能跑在 darwin 系统下,你需要保证 windows 用户不会安装到你的模块,从而避免发生不必要的错误。

使用 os 属性可以帮助你完成以上的需求,你可以指定你的模块只能被安装在某些系统下,或者指定一个不能安装的系统黑名单:

"os" : [ "darwin", "linux" ]
"os" : [ "!win32" ]

例如,我把一个测试模块指定一个系统黑名单:"os" : [ "!darwin" ],当我在此系统下安装它时会爆出如下错误:

在node环境下可以使用 process.platform 来判断操作系统。

cpu

和上面的 os 类似,我们可以用 cpu 属性更精准的限制用户安装环境:

"cpu" : [ "x64", "ia32" ]
"cpu" : [ "!arm", "!mips" ]
在node环境下可以使用 process.arch 来判断 cpu 架构。

二、剖析包版本管理机制

Nodejs成功离不开 npm 优秀的依赖管理系统。在介绍整个依赖系统之前,必须要了解 npm如何管理依赖包的版本,本章将介绍 npm包 的版本发布规范、如何管理各种依赖包的版本以及一些关于包版本的最佳实践。

2.1 查看npm包版本

你可以执行 npm view package version 查看某个 package 的最新版本。

执行 npm view conard versions 查看某个 package 在npm服务器上所有发布过的版本。

执行 npm ls 可查看当前仓库依赖树上所有包的版本信息。

2.2 SemVer规范

npm包 中的模块版本都需要遵循 SemVer规范——由 Github 起草的一个具有指导意义的,统一的版本号表示规则。实际上就是 Semantic Version(语义化版本)的缩写。

SemVer规范官网: https://semver.org/

标准版本

SemVer规范的标准版本号采用 X.Y.Z 的格式,其中 X、Y 和 Z 为非负的整数,且禁止在数字前方补零。X 是主版本号、Y 是次版本号、而 Z 为修订号。每个元素必须以数值来递增。

  • 主版本号(major):当你做了不兼容的API 修改
  • 次版本号(minor):当你做了向下兼容的功能性新增
  • 修订号(patch):当你做了向下兼容的问题修正。

例如:1.9.1 -> 1.10.0 -> 1.11.0

先行版本

当某个版本改动比较大、并非稳定而且可能无法满足预期的兼容性需求时,你可能要先发布一个先行版本。

先行版本号可以加到“主版本号.次版本号.修订号”的后面,先加上一个连接号再加上一连串以句点分隔的标识符和版本编译信息。

  • 内部版本(alpha):
  • 公测版本(beta):
  • 正式版本的候选版本rc: 即 Release candiate

React的版本

下面我们来看看 React 的历史版本:

可见是严格按照 SemVer 规范来发版的:

  • 版本号严格按照 主版本号.次版本号.修订号 格式命名
  • 版本是严格递增的,:16.8.0 -> 16.8.1 -> 16.8.2
  • 发布重大版本或版本改动较大时,先发布alphabetarc等先行版本

发布版本

在修改 npm 包某些功能后通常需要发布一个新的版本,我们通常的做法是直接去修改 package.json 到指定版本。如果操作失误,很容易造成版本号混乱,我们可以借助符合 Semver 规范的命令来完成这一操作:

  • npm version patch : 升级修订版本号
  • npm version minor : 升级次版本号
  • npm version major : 升级主版本号

2.3 版本工具使用

在开发中肯定少不了对一些版本号的操作,如果这些版本号符合 SemVer规范 ,我们可以借助用于操作版本的npm包semver来帮助我们进行比较版本大小、提取版本信息等操作。

Npm 也使用了该工具来处理版本相关的工作。
npm install semver
  • 比较版本号大小
semver.gt('1.2.3', '9.8.7') // false
semver.lt('1.2.3', '9.8.7') // true
  • 判断版本号是否符合规范,返回解析后符合规范的版本号。
semver.valid('1.2.3') // '1.2.3'
semver.valid('a.b.c') // null
  • 将其他版本号强制转换成semver版本号
semver.valid(semver.coerce('v2')) // '2.0.0'
semver.valid(semver.coerce('42.6.7.9.3-alpha')) // '42.6.7'
  • 一些其他用法
semver.clean('  =v1.2.3   ') // '1.2.3'
semver.satisfies('1.2.3', '1.x || >=2.5.0 || 5.0.0 - 7.2.3') // true
semver.minVersion('>=1.0.0') // '1.0.0'

以上都是semver最常见的用法,更多详细内容可以查看 semver文档:https://github.com/npm/node-s...

2.4 依赖版本管理

我们经常看到,在 package.json 中各种依赖的不同写法:

  "dependencies": {
    "signale": "1.4.0",
    "figlet": "*",
    "react": "16.x",
    "table": "~5.4.6",
    "yargs": "^14.0.0"
  }

前面三个很容易理解:

  • "signale": "1.4.0": 固定版本号
  • "figlet": "*": 任意版本(>=0.0.0
  • "react": "16.x": 匹配主要版本(>=16.0.0 <17.0.0
  • "react": "16.3.x": 匹配主要版本和次要版本(>=16.3.0 <16.4.0

再来看看后面两个,版本号中引用了 ~^ 符号:

  • ~: 当安装依赖时获取到有新版本时,安装到 x.y.zz 的最新的版本。即保持主版本号、次版本号不变的情况下,保持修订号的最新版本。
  • ^: 当安装依赖时获取到有新版本时,安装到 x.y.zyz 都为最新版本。 即保持主版本号不变的情况下,保持次版本号、修订版本号为最新版本。

package.json 文件中最常见的应该是 "yargs": "^14.0.0" 这种格式的 依赖, 因为我们在使用 npm install package 安装包时,npm 默认安装当前最新版本,然后在所安装的版本号前加 ^ 号。

注意,当主版本号为 0 的情况,会被认为是一个不稳定版本,情况与上面不同:

  • 主版本号和次版本号都为 0: ^0.0.z~0.0.z 都被当作固定版本,安装依赖时均不会发生变化。
  • 主版本号为 0: ^0.y.z 表现和 ~0.y.z 相同,只保持修订号为最新版本。
1.0.0 的版本号用于界定公共 API。当你的软件发布到了正式环境,或者有稳定的API时,就可以发布1.0.0版本了。所以,当你决定对外部发布一个正式版本的npm包时,把它的版本标为1.0.0。

2.5 锁定依赖版本

lock文件

实际开发中,经常会因为各种依赖不一致而产生奇怪的问题,或者在某些场景下,我们不希望依赖被更新,建议在开发中使用 package-lock.json

锁定依赖版本意味着在我们不手动执行更新的情况下,每次安装依赖都会安装固定版本。保证整个团队使用版本号一致的依赖。

每次安装固定版本,无需计算依赖版本范围,大部分场景下能大大加速依赖安装时间。

使用 package-lock.json 要确保npm的版本在5.6以上,因为在5.0 - 5.6中间,对 package-lock.json的处理逻辑进行过几次更新,5.6版本后处理逻辑逐渐稳定。

关于 package-lock.json 详细的结构,我们会在后面的章节进行解析。

定期更新依赖

我们的目的是保证团队中使用的依赖一致或者稳定,而不是永远不去更新这些依赖。实际开发场景下,我们虽然不需要每次都去安装新的版本,仍然需要定时去升级依赖版本,来让我们享受依赖包升级带来的问题修复、性能提升、新特性更新。

使用 npm outdated 可以帮助我们列出有哪些还没有升级到最新版本的依赖:

  • 黄色表示不符合我们指定的语意化版本范围 - 不需要升级
  • 红色表示符合指定的语意化版本范围 - 需要升级

执行 npm update 会升级所有的红色依赖。

2.6 依赖版本选择的最佳实践

版本发布

  • 对外部发布一个正式版本的npm包时,把它的版本标为1.0.0
  • 某个包版本发行后,任何修改都必须以新版本发行。
  • 版本号严格按照 主版本号.次版本号.修订号 格式命名
  • 版本号发布必须是严格递增的
  • 发布重大版本或版本改动较大时,先发布alpha、beta、rc等先行版本

依赖范围选择

  • 主工程依赖了很多子模块,都是团队成员开发的npm包,此时建议把版本前缀改为~,如果锁定的话每次子依赖更新都要对主工程的依赖进行升级,非常繁琐,如果对子依赖完全信任,直接开启^每次升级到最新版本。
  • 主工程跑在docker线上,本地还在进行子依赖开发和升级,在docker版本发布前要锁定所有依赖版本,确保本地子依赖发布后线上不会出问题。

保持依赖一致

  • 确保npm的版本在5.6以上,确保默认开启 package-lock.json 文件。
  • 由初始化成员执行 npm inatall 后,将 package-lock.json 提交到远程仓库。不要直接提交 node_modules到远程仓库。
  • 定期执行 npm update 升级依赖,并提交 lock 文件确保其他成员同步更新依赖,不要手动更改 lock 文件。

依赖变更

  • 升级依赖: 修改 package.json文件的依赖版本,执行 npm install
  • 降级依赖: 直接执行 npm install package@version(改动package.json不会对依赖进行降级)
  • 注意改动依赖后提交lock文件

三、剖析 npm install 原理

npm install 大概会经过上面的几个流程,这一章就来讲一讲各个流程的实现细节、发展以及为何要这样实现。

3.1 嵌套结构

我们都知道,执行 npm install 后,依赖包被安装到了 node_modules ,下面我们来具体了解下,npm 将依赖包安装到 node_modules 的具体机制是什么。

npm 的早期版本, npm 处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中。直到有子依赖包不在依赖其他模块。

举个例子,我们的模块 my-app 现在依赖了两个模块:bufferignore

{
  "name": "my-app",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4",
  }
}

ignore是一个纯 JS 模块,不依赖任何其他模块,而 buffer 又依赖了下面两个模块:base64-jsieee754

{
  "name": "buffer",
  "dependencies": {
    "base64-js": "^1.0.2",
    "ieee754": "^1.1.4"
  }
}

那么,执行 npm install 后,得到的 node_modules 中模块目录结构就是下面这样的:

这样的方式优点很明显, node_modules 的结构和 package.json 结构一一对应,层级结构明显,并且保证了每次安装目录结构都是相同的。

但是,试想一下,如果你依赖的模块非常之多,你的 node_modules 将非常庞大,嵌套层级非常之深:

  • 在不同层级的依赖中,可能引用了同一个模块,导致大量冗余。
  • Windows 系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题。

3.2 扁平结构

为了解决以上问题,NPM3.x 版本做了一次较大更新。其将早期的嵌套结构改为扁平结构:

  • 安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在 node_modules 根目录。

还是上面的依赖结构,我们在执行 npm install 后将得到下面的目录结构:

此时我们若在模块中又依赖了 base64-js@1.0.1 版本:

{
  "name": "my-app",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4",
    "base64-js": "1.0.1",
  }
}
  • 当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。

此时,我们在执行 npm install 后将得到下面的目录结构:

对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:

  • 在当前模块路径下搜索
  • 在当前模块 node_modules 路径下搜素
  • 在上级模块的 node_modules 路径下搜索
  • ...
  • 直到搜索到全局路径中的 node_modules

假设我们又依赖了一个包 buffer2@^5.4.3,而它依赖了包 base64-js@1.0.3,则此时的安装结构是下面这样的:

所以 npm 3.x 版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。

试想一下,你的APP假设没有依赖 base64-js@1.0.1 版本,而你同时依赖了依赖不同 base64-js 版本的 bufferbuffer2。由于在执行 npm install 的时候,按照 package.json 里依赖的顺序依次解析,则 bufferbuffer2package.json 的放置顺序则决定了 node_modules 的依赖结构:

先依赖buffer2

先依赖buffer

另外,为了让开发者在安全的前提下使用最新的依赖包,我们在 package.json 通常只会锁定大版本,这意味着在某些依赖包小版本更新后,同样可能造成依赖结构的改动,依赖结构的不确定性可能会给程序带来不可预知的问题。

3.3 Lock文件

为了解决 npm install 的不确定性问题,在 npm 5.x 版本新增了 package-lock.json 文件,而安装方式还沿用了 npm 3.x 的扁平化的方式。

package-lock.json 的作用是锁定依赖结构,即只要你目录下有 package-lock.json 文件,那么你每次执行 npm install 后生成的 node_modules 目录结构一定是完全相同的。

例如,我们有如下的依赖结构:

{
  "name": "my-app",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4",
    "base64-js": "1.0.1",
  }
}

在执行 npm install 后生成的 package-lock.json 如下:

{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "base64-js": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
      "integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg="
    },
    "buffer": {
      "version": "5.4.3",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",
      "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",
      "requires": {
        "base64-js": "^1.0.2",
        "ieee754": "^1.1.4"
      },
      "dependencies": {
        "base64-js": {
          "version": "1.3.1",
          "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
          "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
        }
      }
    },
    "ieee754": {
      "version": "1.1.13",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
      "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
    },
    "ignore": {
      "version": "5.1.4",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz",
      "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A=="
    }
  }
}

我们来具体看看上面的结构:

最外面的两个属性 nameversionpackage.json 中的 nameversion ,用于描述当前包名称和版本。

dependencies 是一个对象,对象和 node_modules 中的包结构一一对应,对象的 key 为包名称,值为包的一些描述信息:

  • version:包版本 —— 这个包当前安装在 node_modules 中的版本
  • resolved:包具体的安装来源
  • integrity:包 hash 值,基于 Subresource Integrity 来验证已安装的软件包是否被改动过、是否已失效
  • requires:对应子依赖的依赖,与子依赖的 package.jsondependencies的依赖项相同。
  • dependencies:结构和外层的 dependencies 结构相同,存储安装在子依赖 node_modules 中的依赖包。

这里注意,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。

例如,回顾下上面的依赖关系:

我们在 my-app 中依赖的 base64-js@1.0.1 版本与 buffer 中依赖的 base64-js@^1.0.2 发生冲突,所以 base64-js@1.0.1 需要安装在 buffer 包的 node_modules 中,对应了 package-lock.jsonbufferdependencies 属性。这也对应了 npm 对依赖的扁平化处理方式。

所以,根据上面的分析, package-lock.json 文件 和 node_modules 目录结构是一一对应的,即项目目录下存在 package-lock.json 可以让每次安装生成的依赖目录结构保持相同。

另外,项目中使用了 package-lock.json 可以显著加速依赖安装时间。

我们使用 npm i --timing=true --loglevel=verbose 命令可以看到 npm install 的完整过程,下面我们来对比下使用 lock 文件和不使用 lock 文件的差别。在对比前先清理下npm 缓存。

不使用 lock 文件:

使用 lock 文件:

可见, package-lock.json 中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。

使用建议

开发系统应用时,建议把 package-lock.json 文件提交到代码版本仓库,从而保证所有团队开发者以及 CI 环节可以在执行 npm install 时安装的依赖版本都是一致的。

在开发一个 npm包 时,你的 npm包 是需要被其他仓库依赖的,由于上面我们讲到的扁平安装机制,如果你锁定了依赖包版本,你的依赖包就不能和其他依赖包共享同一 semver 范围内的依赖包,这样会造成不必要的冗余。所以我们不应该把package-lock.json 文件发布出去( npm 默认也不会把 package-lock.json 文件发布出去)。

3.4 缓存

在执行 npm installnpm update命令下载依赖后,除了将依赖包安装在node_modules 目录下外,还会在本地的缓存目录缓存一份。

通过 npm config get cache 命令可以查询到:在 LinuxMac 默认是用户主目录下的 .npm/_cacache 目录。

在这个目录下又存在两个目录:content-v2index-v5content-v2 目录用于存储 tar包的缓存,而index-v5目录用于存储tar包的 hash

npm 在执行安装时,可以根据 package-lock.json 中存储的 integrity、version、name 生成一个唯一的 key 对应到 index-v5 目录下的缓存记录,从而找到 tar包的 hash,然后根据 hash 再去找缓存的 tar包直接使用。

我们可以找一个包在缓存目录下搜索测试一下,在 index-v5 搜索一下包路径:

grep "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz" -r index-v5

然后我们将json格式化:

{
  "key": "pacote:version-manifest:https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz:sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",
  "integrity": "sha512-C2EkHXwXvLsbrucJTRS3xFHv7Mf/y9klmKDxPTE8yevCoH5h8Ae69Y+/lP+ahpW91crnzgO78elOk2E6APJfIQ==",
  "time": 1575554308857,
  "size": 1,
  "metadata": {
    "id": "base64-js@1.0.1",
    "manifest": {
      "name": "base64-js",
      "version": "1.0.1",
      "engines": {
        "node": ">= 0.4"
      },
      "dependencies": {},
      "optionalDependencies": {},
      "devDependencies": {
        "standard": "^5.2.2",
        "tape": "4.x"
      },
      "bundleDependencies": false,
      "peerDependencies": {},
      "deprecated": false,
      "_resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
      "_integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",
      "_shasum": "6926d1b194fbc737b8eed513756de2fcda7ea408",
      "_shrinkwrap": null,
      "bin": null,
      "_id": "base64-js@1.0.1"
    },
    "type": "finalized-manifest"
  }
}

上面的 _shasum 属性 6926d1b194fbc737b8eed513756de2fcda7ea408 即为 tar 包的 hashhash的前几位 6926 即为缓存的前两层目录,我们进去这个目录果然找到的压缩后的依赖包:

以上的缓存策略是从 npm v5 版本开始的,在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是{cache}/{name}/{version}。

npm 提供了几个命令来管理缓存数据:

  • npm cache add:官方解释说这个命令主要是 npm 内部使用,但是也可以用来手动给一个指定的 package 添加缓存。
  • npm cache clean:删除缓存目录下的所有数据,为了保证缓存数据的完整性,需要加上 --force 参数。
  • npm cache verify:验证缓存数据的有效性和完整性,清理垃圾数据。

基于缓存数据,npm 提供了离线安装模式,分别有以下几种:

  • --prefer-offline: 优先使用缓存数据,如果没有匹配的缓存数据,则从远程仓库下载。
  • --prefer-online: 优先使用网络数据,如果网络数据请求失败,再去请求缓存数据,这种模式可以及时获取最新的模块。
  • --offline: 不请求网络,直接使用缓存数据,一旦缓存数据不存在,则安装失败。

3.5 文件完整性

上面我们多次提到了文件完整性,那么什么是文件完整性校验呢?

在下载依赖包之前,我们一般就能拿到 npm 对该依赖包计算的 hash 值,例如我们执行 npm info 命令,紧跟 tarball(下载链接) 的就是 shasum(hash) :

用户下载依赖包到本地后,需要确定在下载过程中没有出现错误,所以在下载完成之后需要在本地在计算一次文件的 hash 值,如果两个 hash 值是相同的,则确保下载的依赖是完整的,如果不同,则进行重新下载。

3.6 整体流程

好了,我们再来整体总结下上面的流程:

  • 检查 .npmrc 文件:优先级为:项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件
  • 检查项目中有无 lock 文件。
  • lock 文件:

    • npm 远程仓库获取包信息
    • 根据 package.json 构建依赖树,构建过程:

      • 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在 node_modules 根目录。
      • 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下放置该模块。
      • 注意这一步只是确定逻辑上的依赖树,并非真正的安装,后面会根据这个依赖结构去下载或拿到缓存中的依赖包
    • 在缓存中依次查找依赖树中的每个包

      • 不存在缓存:

        • npm 远程仓库下载包
        • 校验包的完整性
        • 校验不通过:

          • 重新下载
        • 校验通过:

          • 将下载的包复制到 npm 缓存目录
          • 将下载的包按照依赖结构解压到 node_modules
      • 存在缓存:将缓存按照依赖结构解压到 node_modules
    • 将包解压到 node_modules
    • 生成 lock 文件
  • lock 文件:

    • 检查 package.json 中的依赖版本是否和 package-lock.json 中的依赖有冲突。
    • 如果没有冲突,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同

上面的过程简要描述了 npm install 的大概过程,这个过程还包含了一些其他的操作,例如执行你定义的一些生命周期函数,你可以执行 npm install package --timing=true --loglevel=verbose 来查看某个包具体的安装流程和细节。

3.7 yarn

yarn 是在 2016 年发布的,那时 npm 还处于 V3 时期,那时候还没有 package-lock.json 文件,就像上面我们提到的:不稳定性、安装速度慢等缺点经常会受到广大开发者吐槽。此时,yarn 诞生:

上面是官网提到的 yarn 的优点,在那个时候还是非常吸引人的。当然,后来 npm 也意识到了自己的问题,进行了很多次优化,在后面的优化(lock文件、缓存、默认-s...)中,我们多多少少能看到 yarn 的影子,可见 yarn 的设计还是非常优秀的。

yarn 也是采用的是 npm v3 的扁平结构来管理依赖,安装依赖后默认会生成一个 yarn.lock 文件,还是上面的依赖关系,我们看看 yarn.lock 的结构:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


base64-js@1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.0.1.tgz#6926d1b194fbc737b8eed513756de2fcda7ea408"
  integrity sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=

base64-js@^1.0.2:
  version "1.3.1"
  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==

buffer@^5.4.3:
  version "5.4.3"
  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
  dependencies:
    base64-js "^1.0.2"
    ieee754 "^1.1.4"

ieee754@^1.1.4:
  version "1.1.13"
  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==

ignore@^5.1.4:
  version "5.1.4"
  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf"
  integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==

可见其和 package-lock.json 文件还是比较类似的,还有一些区别就是:

  • package-lock.json 使用的是 json 格式,yarn.lock 使用的是一种自定义格式
  • yarn.lock 中子依赖的版本号不是固定的,意味着单独又一个 yarn.lock 确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合。而 package-lock.json 只需要一个文件即可确定。

yarn 的缓策略看起来和 npm v5 之前的很像,每个缓存的模块被存放在独立的文件夹,文件夹名称包含了模块名称、版本号等信息。使用命令 yarn cache dir 可以查看缓存数据的目录:

yarn 默认使用 prefer-online 模式,即优先使用网络数据,如果网络数据请求失败,再去请求缓存数据。

参考

小结

希望阅读完本篇文章能对你有如下帮助:

  • 了解 pacakge.json 中的各项详细配置从而对项目工程化配置有更进一步的见解
  • 掌握 npm 的版本管理机制,能合理配置依赖版本
  • 理解 npm install 安装原理,能合理运用 npm缓存、package-lock.json

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。

想阅读更多优质文章、可关注我的github博客,你的star✨、点赞和关注是我持续创作的动力!

推荐关注我的微信公众号【code秘密花园】,每天推送高质量文章,我们一起交流成长。

查看原文

ShapeLei 关注了专栏 · 2019-12-23

code秘密花园

基础知识、算法、原理、项目、面试。公众号code秘密花园

关注 5211

认证与成就

  • 获得 1 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-02-22
个人主页被 195 人浏览