区块链技术起源于2008年10月31日中本聪发表的比特币论文:《Bitcoin: A Peer to Peer Electronic Cash System》,即比特币白皮书,论文论述了基于P2P网络的电子现金系统的设计,系统中产生和流通的现金就是比特币(Bitcoin)。
比特币:一种点对点的电子现金系统
一个完全的点对点版本的电子现金将允许一方不通过金融机构直接在线支付给另一方。电子签名提供了部分解决方案,但是如果还需要一个可信任的第三方来防止双花,那么这个最大的好处也就没有意义。我们提出一个用点对点网络来解决双花的方案。这个网络给每笔交易打上时间戳,并进行哈希计算,放进一条基于哈希工作量证明的链,这形成了一个不可改变的记录,除非重做这些工作量。最长的链不仅是见证序列的证明,还证明了它来自最大的CPU算力池。因为大部分的算力由诚实的节点控制,他们将会产生一条比攻击者要长的链。网络本身需要极小化结构。消息被尽力广播,并且节点可以随意离开或重新加入网络,接受最长的工作量证明的链作为它离开这段时间发生事情的证明
互联网上的商业几乎完全依赖信任的第三方金融机构来处理电子支付。对于大多数交易来说,这套系统工作的足够好了,但是依然受到了基于信任模型的天然缺点的困扰。完全不能撤销的交易是不可能的,因为第三方金融机构不可避免的要调解纠纷。调解的代价增加了交易的成本,限制了最小实际交易的大小,切断了临时交易的可能性,丧失了对不可撤销服务提供不可撤销支付的可能性,这又是一个广义成本。因为撤销的可能性,信任的需求不断蔓延开来。商户必须提防他们的客户,越来越多的他们本不该需要的信息困扰他们。不得不接受一定比例的骗子。这些成本和支付的不确定问题可以用面对面使用现金避免,但是还没有机制存在使得通过通信信道支付而不需要信任的第三方。
需要的是一个电子支付系统,这个系统建立在密码学证明基础上而不是信任,运行任意有这个意愿的双方直接互相转账而不需要一个可信任的第三方。交易从计算上不可撤销的,这将保护卖方权利防止被骗,并且常规的托管机制很容易实现来保护买方权益。下面提出一个防止双花的解决方案,使用点对点分布式时间戳服务器来产生按时间排序的交易的计算证明。只要城市节点控制的CPU的算力大于攻击者节点的算力,这个系统就是安全的。
我们把一种电子币定义成一条数字签名链。每一个所有者把币转给下一个人的时候,是通过将前一个交易的哈希和下一个所有者的公钥进行数字签名,并把这些追加在币的后面。收款人可以通过验证签名来确定链的所有者。
当然,问题是收款人无法验证其中一个所有者是否同一个币花了两次。一个普遍的做法是引入一个信任的中央机关,或铸币厂,他们可以检查每一笔交易来防止双花问题。每次交易后,这个币必须返回到铸币厂,这样才能发行新币,只有直接从铸币厂发行的币才被相信是没有被双花的。这个方案的问题是,整个金钱系统的命运掌握在经营铸币厂的公司,每一笔交易都要经他们,就像银行一样。
我们需要一种方法,这种方法让收款人知道上一个所有者没有签署任何以前的交易。我们的目的是让最早的交易是可信的,我们不关心后面是不是有人企图进行双花。仅有的可以确认某一个交易存在的办法是要知道所有交易。在基于铸币厂的模型中,铸币厂知道所有交易,并且可以确定哪个交易先发生。为了在无信任第三方的情况下达到共识,这个共识就是认同同一个按接收的交易顺序排列的历史记录。收款人需要证据来说明在每一笔交易的时候,大多数节点一致认为这个交易是第一时间到达的。
我们提出的解决方案是从一个时间戳服务开始,这个服务器工作的方式是,对条目所在的区块的哈希加盖时间戳,并且广泛地公开这些哈希,比如通过报纸或者新闻组邮件。显然,为了能进入这个哈希序列,时间戳证明的数据在那个时间必须存在。每一个时间戳和以前的时间戳,形成了一条链,每一个追加的时间戳都是对前一个时间戳进行加强。
为了基于点对点的基础实现一个分布式的时间戳服务器,我们将需要一个工作量证明的系统,这个系统和亚当贝克创造的“哈希现金”类似,而不是报纸或新闻组邮件。这个工作量包含寻找一个哈希值,比如用哈希算法SHA-256,这个哈希值以若干0开头。平均工作量和开头的0的个数是指数关系,并且验证很简单,只需要执行一次单独的哈希计算。
在我们的时间戳网络里,是这样实现工作量证明的,就是不断增加区块里的一个临时的数值,直到找到一个值使得区块的哈希值满足开头0的个数要求。一旦CPU花费计算满足了工作量证明的要求,这个区块链就无法修改,除非重新计算。随着后面的区块不断产生,想要改变这个区块,需要重做它后面所有区块的工作量。
工作量证明还解决了在多数决策法中决定展示的问题。如果大多数是基于一个IP一票,这可能会被可以支配多个IP的人破坏。工作量证明本质上也是一个IP一票。最长的链代表了大多数人的决定,这个链投入了最大的工作量。如果大多数的CPU算力由诚实节点控制,诚实的链就会增长的很快,超过任何竞争链。为了修改过去的一个块,攻击者需要重新投入算力完成这个却快和它以后的区块的工作量,然后追上并超越诚实节点的工作量。我们后面讨论落后的攻击者追上的概率,这个概率随着后面区块的增加呈现指数级减少。
硬件的速度越来越快,参与运行的节点随着时间兴趣也随着经常变化,为了抵消这些因素的影响,工作量的难度是由每小时区块产生的数量的浮点型来决定的,如果区块产生的太快,难度就相应的增加。
运行这个网络的步骤如下:
节点总是认为最长的链是正确的,并且不断的工作去延长它。如果两个节点同时广播下一个区块,一些节点可能接受其中一个,也可能是另一个。这种情况下,他们在最先接收到的区块上工作,但是也会保存另外一个分支以防止它会变得更长。当下一个工作量被找到并且一个分支变得更长时,这种情况就会被打破,在另外一个分支上工作的节点就会切换到这个长的链上。
新交易广播不一定要广播到所有节点。只要他们能到达很多节点,这个交易很快就会进入下一个块。区块广播也能接受消息丢失。如果一个节点没有收到区块,当它收到下一个块时会发现自己少了一个区块,它就会请求来获得少的这个区块。
按照惯例,区块的第一个交易是一个特别的交易,这个交易会发行新币并且所有者是这个区块的创建者。这为节点支持网络引入了激励机制,并且这提供了一个初始发行货币进入流通的方式,因为没有一个中央机构去发行他们。不断增加新货币的过程类似于黄金矿工消耗资源来增加黄金的流通。在这里,消耗的是CPU的时间和电费。
激励还包括提供交易手续费。如果交易中输出值比输入值小,这个差值就是手续费,它被加入到这个区块激励值里。一旦预定数量的币全部进入流通,激励就全部转为交易手续费,完全没有通货膨胀。
激励有助于鼓励节点保持诚实,如果一个贪婪的攻击者掌握比所有诚实节点还要大的算力,他将面临一个选择,是通过偷回他支付的钱来诈骗人,还是用算力产生新的币。他应该会发现遵守规则有更多的好处,这个规则可以让他比其他人组合得到更多的新币,比破坏这个系统得到的更多,而且财产合法。
一旦一个币最新的交易被足够多的区块埋没,它之前的花费的交易就可以丢掉来节省空间。为了促成这个而不破坏区块的哈希,用这些交易生成一个默克尔树,仅仅根保存在区块的哈希里。那么旧区块可以通过去除树的一些分支进行压缩。这些内部的哈希就不用保存了。
一个区块头大概80字节,如果我们假设每十分钟产生一个区块,一年就是80字节x6x24x365=4.2兆字节。2008年出售的电脑典型的配置是2G内存,根据摩尔定律预测,每年增加1.2G,即使区块头全部放在内存里,存储也不是问题。
即使不允许网络节点,验证支付也是可能的。用户仅需要保存最长工作量证明链的区块头的拷贝,他通过查询网络节点直到确信它有最长的链来获取区块头,并且可以得到默尔克分支,分支连接了交易和这个打了时间戳的区块。他本身不能验证交易,但是通过连接到链上的一个地方,他可以看到网络节点已经接受了它,后面的区块进一步确定网络接受了它。
因此,如果诚实节点控制着网络,验证就是可靠的,如果网络被攻击者控制,验证就是很弱的。虽然网络节点本身可以验证交易,但这种简化验证的方法会被攻击者编造的交易欺骗,因为攻击者可能持续控制网络。防止这种情况的一种策略是接收网络节点的告警,当他们检测到无效块的时候,提示用户软件下载整个区块,并且提醒确认交易的一致性。频繁接收支付的企业可能仍然想运行他们自己的节点,为了更独立的安全性和更快的验证。
虽然可以单独处理硬币,为转账的每一分单独交易是很不方便的。为了能是价值分割和组合,交易包含多个输入和输出。正常的会有一个单独的以前交易来的大额输入或多个小额输入组合在一起,最多两个输出:一个用来支付,一个用来找零,有零钱的话会返回给发送者。
应该注意的是,一个交易依赖几个交易,这些交易依赖更多的交易,看起来很分散,但在这里不是一个问题,从不需要提取一个交易全部历史的独立的拷贝
传统的银行通过限制向有关方和信任的第三方提供信息来达到一个保护隐私的目的。向公众广播所有交易的必须性将这个方法排除了。通过打破信息在其他地方流动性仍然可以保护隐私:通过保持公钥匿名性。公众可以看到一个人给其他人转钱了,但是没有信息可以把交易和某人联系起来。这类似于证券交易所公布的信息水平,交易时间和个人交易的规模是可以公开的,但不告诉当事人是谁。
作为一个附加的防火墙,每次交易都是使用一个新的密钥对,防止和一个共同的所有者联系起来。对于多输入交易来说,这个联系无法避免,所有的输入必须表明由同一个人所有。风险是如果表明了某一个秘钥的所有者,这种联系将表明其他交易也属于同一个人。
我们想象一个场景,攻击者想要攻击用比诚实节点更快的速度产生一个替代链,即使成功了,也不会让系统能任意被修改,比如凭空产生价值或拿到不属于攻击者的钱。节点将不会接受一个无效的支付,并且诚实节点绝不会接受包含这种支付的区块。攻击者只能试着改变自己的交易来拿回本该花出去的钱。
诚实的链和攻击链的竞争可以说是二项式随机走动。成功事件是诚实链延长一个区块,领先优势加一,失败事件是攻击链延长一个区块,缩小一个差距。
一个攻击者从一个给定的赤字中追上的概率类似于赌徒破产问题。假设一个信用无限的赌徒从赤字开始,开始进行潜在次数无数的赌博,试图达到盈亏平衡。我们可以计算他达到盈亏平衡的概率,或者说从下次攻击链赶上诚实链的概率,如下:
p=诚实节点发现下一个区块的概率
q=攻击者找到下一个区块的概率
qz=攻击者在落后z个区块的情况下,追上的概率
假设p>q,随着落后区块数量z增加,攻击者追上的概率呈指数下降。这个概率情况对攻击者不利,如果他没有幸运的提前向前冲刺,落后越多希望越渺茫。
我们现在考虑接收者在收到新的交易的时候,需要等待多长时间才能完全确定交易不能被发送者修改。我们假设发送者是攻击者,他想让接收者暂时相信他已经付款了,然后过了一段时间又换成是支付给自己。这事发生的时候接收者会受到警告,但是发送者希望一起都晚了。
接收者创建一个新的密钥对,签名之前很短的时间把公钥给发送者。这防止发送者提前准备一条链,持续在上面工作,直到他足够幸运达到了领先的程度,正好执行到这条交易。一旦这个交易发送了,不诚实的发送者开始在一个并行的链上秘密工作,这条链包含他的交易的另一个版本。
接收者一直等到知道交易被添加到一个区块中,并且后面已经追加了z个区块了。他并不知道攻击者的准确进展,但是可以假设诚实区块每个区块花费的时间是平均期望时间,攻击者潜在的进展将服从泊松分布,期望值:
为了得到目前的攻击者仍能追上的概率,我们将他所取得的每一步进展的泊松密度乘以他可能从那一点追上的概率。
变换一下避免对分布的无穷尾部求和…
转换为c代码
1 |
|
运行结果, 可以看到概率随着z的增加呈指数下降
1 | q=0.1 |
我们为无信任电子交易提出了一个系统,我们从数字签名币的常用框架开始,它对所有者有很强的控制,但是因为不能避免双花,所以还不完整。为了解决双花,我们提出了一个点对点网络,这个网络使用工作量证明记录一个公共的交易历史,只要诚实节点控制大部分的CPU算力,很快使得攻击者无法通过计算来改变交易历史。该网络的非结构化简单性使得它很稳健。节点同时工作,很少需要相互协调。他们不需要被识别,因为消息不需要路由到任何特定的位置,只需尽力传递就好。节点可以离开网络,也可以需要的时候重新加入网络,接受工作量链作为他离开的时候发生了什么的证据。他们用CPU算力投票,通过在有效的区块上工作并延续它来表达对区块的接受,通过不在新区块上工作表示拒绝无效区块。任何需要的规则和激励都可以在这种共识机制下进行。
基于论文的比特币网络于2009年初正式上线,其主要目的是存储该电子现金系统里所有账户间的转账交易,并且确保这些交易记录无法被篡改。
比特币网络的功能仅限于让用户进行财务交易Finanicial Transaction,之后没有多久,很多就意识到可以用比特币创意中的区块链技术来进行金钱之外的价值交换,Vatalik Buterin 于 2013 年末发布了以太坊的白皮书:《Ethereum: The Ultimate Smart Contract and Decentralized Application Platform》
相比比特币网络的简洁,V提出了基于区块链技术创建更加复杂的去中心化应用Decentralized Application,简称为DApp的技术设计,典型的去中心化例子:
这些功能的实现都依赖于以太坊内建的智能合约Smart Contract,智能合约是存储在以太坊网络上的可以通过发送消息与之交互的代码片段。
基于以太坊智能合约可以方便的发行遵循ERC20规范的数字货币,这个过程常常被称为首次代币发行Initial Coin Offering,简称ICO,这给区块链企业融资提供了极大的便利,但由于成本极低被很多人滥用了,因为ICO过程是不受监管、且完全中心化的,项目方拿到募集来的以太坊随便花,而投资者拿到代币之后对项目运作和资金开销没有任何约束力。
2017年底V提出了 DAICO 的理念,即筹集的资金会被智能合约锁定在账户里面,区块链创业团队在资金支出时先提出请求,多数投资者投票同意后,募集来的资金才可以被花出去,这在很大程度上保障了投资者的利益,也是在鼓励创业团队踏实做事。实战小册灵感源于此,不算是严格意义上的ICO App,因此不涉及发币过程,但现有众筹版本。
如果回顾区块链的发展历史,业内通常把区块链技术的发展分成3个阶段,作为前端工程师,我们可以将其和WEB技术的发展历史做如下类比:
如果说比特币是最早最成功的区块链应用,即基于区块链技术实现的银行系统,负责记账和发行货币,那么银行系统中存在的概念在区块链系统中也会存在,可以用银行账本中的概念来类比理解区块链的概念:
区块链技术通常被简单描述为:公开的、分布式、不可篡改的数据库技术或记账技术
长时间记录的账本都会是多页的,在区块链世界也是如此,长时间运行的区块链网络记录下来的数据量也是巨大的。
块高度可以理解为账本的页数,在区块链世界里,块高度可以理解为自该区块链开始运行到现在共产生了多少区块,换个角度,我们可以认为块高度就是区块链时间的时间,每产生一个新的区块,快高度就会加1,比特币最新块高度,以太坊最新快高度
出块时间可以类比为记完每页账户并在上面按完手印需要花多少时间,在区块链世界里,即区块链上相邻两个块产生出来的时间间隔,或者常说的交易确认时间Transaction Confirm time,这里说的交易确认时间指某笔交易从发起到被打包进区块链的时间,和部分钱包、交易所的交易确认时间小很多,比如比特币的区块时间通常控制在10分钟,而以太坊则是15秒左右。任何打包的交易在分布式网络上达成共识都需要时间,共识算法决定了时间的长短。POW、POS、DPOS
分两个视角来看:
以太坊网络本质是P2P网络系统,其用途是发起交易、存储交易历史,这里的交易可以是转账或者是调用智能合约中的方法,而以太坊区块链是存储了以太坊网络上发生过的每笔交易的数据库。以太坊网络通常情况下是指主网,实际上社区中存在很多用途各异的以太坊网络,类比到Web工程里面3套环境,可以将其归类如下:
P2P网络通常包含多个节点,每个节点都需要运行以太坊客户端,而任何人都可以运行以太坊节点,每个以太坊网络上的节点都包含了以太坊区块链数据库的整体副本,每个以太坊网络节点都可以接收RPC交易请求并将请求广播给网络中的其他节点,每个以太坊节点都会尝试进行交易的校验、打包(常说的挖坑),即区块生产的任务,生产出的区块也会被广播给其他网络节点。
以太坊不同网络之间的账户可以完全相同,就好比我们可以把线上数据库的数据全部同步到我们测试环境数据库一样,但是不同测试网络之间、测试网络和主网之间、本地开发网络和主网之间是完全隔离的,即无法进行转账和智能合约功能调用
交互的具体定义是:向以太坊网络发送转账请求,或调用智能合约函数,用我们熟悉的计算术语来说,就是发起读取或者修改以太坊区块链上数据的请求,并拿到反馈的过程。
类似于现有世界中支持开发者自行开发应用的平台,比如微博开发平台、微信小程序等,我们可以把与平台交互的用户分成两大类:开发者、普通用户,两者与平台本身的交互方式、交互途径不同。
比如微信小程序生态下,开发者。普通用户、平台之间的交互方式可以简化如下:
类似的,以太坊生态中,开发者、普通用户、平台的交互方式可简化为:
其中,web3.js是前端工程师通过代码和以太坊网络交互的桥梁,而实际的DApp测试离不开使用钱包。
不论是开发者,还是普通用户,与以太坊网络的交互都会落地到具体的网络节点上,因为只有节点可以接收RPC请求并将其广播给其他节点,任何代码或者应用如果需要同以太坊网络交互,都需要通过某个具体的节点。
智能合约指以太坊网络上被代码控制的一个账户,不同于我们使用各种钱包软件创建的账户(由创建账户的用户来控制),智能合约对应的账户是由代码控制的,其他账户(包括智能合约账户、普通用户账户)可以通过交易Transcation的方式与智能合约账户交互,社区中也会把智能合约账户称为内部账户,而普通用户称为外部账户。这两种账户的关系可以用下图描述:
此外还需要注意的是,我们也常常用智能合约指代智能合约源代码或部署在以太坊网络上的智能合约账户,但本质上两者是有明显的区别的:如果把智能合约源代码比如设计图,那么智能合约账户就是根据蓝图造出来的车或者建筑。相同的源代码可以部署到不同的网络上,或者在相同的网络上部署很多次,都会产生不同的合约地址。整个过程可以用下图示意:
无论部署到哪个网络,源代码是完全相同的,而部署所产生的智能合约账户只存在于其被部署到的那个网络,比如部署到Mainnet主网的智能合约不能通过Rinkeby测试网络去访问,反过来亦然,普通账户则在不同网络间是通用的。
每个智能合约会有下面几个关键属性:
接触任何区块链网络都需要我们有自己的账户,管理账户的软件可称之为钱包,在创建钱包和账户之前,我们有必要了解以太坊网络中账户的组成:
如上图,以太坊网络中的账户和典型的区块链账户没有太大区别,都由地址、公钥、私钥3部分构成,不论使用何种钱包构建的以太坊账户,在不同的以太网网络之间都是可以通用的,比如我在主网构建了钱包账户,而切换到Rinkeby测试网络时依然可以使用同样的账户,这和传统的Web应用有很大的区别,比如我们在微信创建的账户就不能到百度上。这种跨网络的账户机制实际上是内置在以太坊客户端之内的,无需关心细节。
不论是在以太坊网络上发起转账交易,还是部署智能合约,亦或是调用智能合约中的函数,我们都需要账户,方便以太坊记录和验证谁、在什么时间、做了什么。
区块链世界里面的钱包其实借鉴自现实世界的钱包,区块链世界里面,每张银行卡对应一个账户,每家银行对应一个区块链网络,而能管理你所有银行卡的软件叫做钱包,对应关系如下:
Metamask是浏览器插件,虽然能运行在多个浏览器,但考虑到Solidity调试工具运行环境、前端调试工具的丰富程度建议在Google浏览器进行
Metamask 为我们提供了非常便捷的以太坊浏览器钱包插件,Google Chrome WebStore 的下载地址猛击这里
助记词是用来生成账户的公钥和私钥的,也就是用来恢复钱包里面所有的账户的。如果创建的是存在真实资产的账户,不要泄露助记词。
从密码学角度,地址、公钥、私钥本质都是非常大的数字,不论是转换为16进制或者base58格式,对普通用户来说看起来都是杂乱无章的随机字符串,如果区块链用户非要记住这些数字才能使用区块链的话,会非常麻烦。于是比特币社区提出了BIP39提议,技术上该提议可以在任意区块链中实现,比如使用完全相同的助记词在比特币和区块链上生成的地址可以是不同的,用户只需要记住满足一定规则的词组,钱包软件就可以基于该词组创建一些列的账户,并且保障无论是在什么硬件、什么时间创建出来的账户、公钥、私钥都完全相同,这样解决了账户识住的问题,也吧账户恢复的门槛降低了很多。支持BIP提议的钱包也可以归并为HD钱包,Hierarchical Deterministic Wallet,Metamask当属此类。
从测试网络的充值不会马上成功,而是有15秒左右的延迟,正确理解区块链网络的交易Transaction是做区块链开发必要条件,正确理解以太坊网络上的交易是设计、开发出合理智能合约+DApp的基础条件。
交易详情中的几组关键信息:
以太坊交易中的汽油和现实中的汽油有异曲同工之妙,如果把以太坊交易比作开车去长途旅行,不难得出结论:
这里说的Ether就是在各种数字货币交易市场上的以太坊的交易单位,即ETH,以太坊的货币单位精度很高,所以不同精度货币单位的列表也很长,不同单位之间的换算可以参考:etherconverter.online
为什么以太坊选择使用Gas去调节手续费,而没有选择像比特币那样使用比特币本身作为交易手续费
Gas是以太坊虚拟机EVM内部流通的货币,以太坊虚拟机用Gas来对交易打包、智能合约执行等操作收取费用。
调用某智能合约的接口需要执行如下操作,合约源代码编译出来的opcode就对应EVM中的操作,每个操作会产生特定的费用:
上会举例,完整的操作、手续费表在这里
在这种机制下,以太坊就可以对更加复杂的智能合约收取更多的费用,这样做很公平合理,因为需要更多存储和计算的智能合约消耗的资源更多,跟比特币不同的地方在于,比特币本身不支持在交易中包含复杂的计算逻辑,比特币是不支持智能合约的。
向以太坊提交一笔交易时,我们需要在交易中设定下面两个参数,虽然大多数时候只需要设定Gas Limit:
假设有家家政服务公司,主营业务是个客户提供清洁服务,运行让雇员在开展业务的收入随着市场供需的变化而变化,为了实现这种灵活性,家政服务公司发行了一种代币叫做清洁币QJB,客户在支付清洁费用时不实际支付人民币,而是支付代币,而清洁不同类型的房间收取不同的QJB,数量由家政服务公司设定,并且不允许修改:
客户只需要支付家政公司QJB,而雇员收入的是QJB,QJB可以随时换成发币
这种机制下,家政公司的雇员实际上就客户愿意为每个QJB支付多少钱进行谈判,选择对自己有利的需求去满足,比如雇员希望每个QJB能换算为100人民币,而客户却只愿意支付80元,这时雇员就可以选择放弃为该客户服务,而且服务愿意支付更高价格的客户,自由度和灵活性就这样产生了。
以太坊中的Gas Price有异曲同工之妙,雇员扮演矿工的角色,客户的清洁需求好比提交到网络中的转账交易,如果交易的Gas Price设置过低,矿工可以选择不理会这笔交易,只打包Gas Price满足自己设定的最低值交易。实际上以太坊网络上的矿工也是这么做的,他们在启动节点时会设置能接受的最小Gas Price,低于设定值的交易都会被过滤掉,不同的矿工对于把这个值设为多少是有自由的。
Gas Limit在家政服务中是这样的:
假如你租了个别墅,里面房间特别多,生日那天开了一个Party,结束后需要有人来清理,但是因为办完派对,能支付清洁费用的资金有限,这时会跟说家政公司雇员说,我最多愿意为整个清洁支付50QJB,每个QJB价值是100元,如果费用不够打扫整个别墅,扫到哪里是哪里吧,剩下的我自己搞定。家政公司雇员开始工作后会多退少就停止。
这就好比执行复杂智能合约时,填写了如下的参数:
EVM在执行你的交易时如果实际消耗的费用在50个单位以内,交易就会成功被执行和打包,如果不够,消耗到50个单位汽油时就会停止并回滚交易,这点和家政服务不同,清洁工作停止的时候,已经打扫过的房间会保持干净的状态,但是在EVM里面,不存在执行一半的交易,要么成功,要么回滚,即使Gas不够,不成功的执行也是消耗了计算资源。
Gas是EVM对各种操作收取费用的机制,如果你需要发起交易,Gas Price是你实际报给矿工的Gas单价,而Gas Limit表示你最多愿意为这笔交易支出多少个单位的Gas。
EVM执行每笔交易时实际要消耗多个Gas计算过程相关文章在此
以太坊纯转账交易的Gas消耗是21000个单位,这里的纯转账指转的是ETH,不包括各种ERC20的代币,因为代币本身是智能合约,转账的时候需要调用智能合约的接口。
如果把Gas Limit设置过高,会直接抛出错误:Exceeds block gas limit,这是以太坊从经济角度的独特设计,如果大家的 Gas Limit都可以设置得无限大,那么最后需要回退的 Gas 就很多了。
etherscan.io 上有个实时变动的数据说明交易中合理的 Gas Price 应该设置为多少,猛击链接 [https://etherscan.io/gastracker) 查看,如果想查看历史变化趋势,有下面两个链接供参考:
Solidity官方文档如是说:
Solidity 是一种面向对象的高级语言,用于实现智能合约。智能合约是管理以太坊状态内账户行为的程序。
Solidity 是一种花括号语言。它受 C++、Python 和 JavaScript 的影响,旨在针对以太坊虚拟机 (EVM)。您可以在语言影响部分找到有关 Solidity 受到哪些语言启发的更多详细信息。
Solidity 是静态类型的,支持继承、库和复杂的用户定义类型等特性。
使用 Solidity,您可以创建用于投票、众筹、盲拍和多重签名钱包等用途的合约。
Solidity属于强类型语言,内涵的类型除了常见的编程语言中的标准类型,还包括address等以太坊独有的类型,Solidity源码文件通常以.sol作为扩展名,
Solidity源代码要成为可以运行在以太坊的智能合约需要经历如下的步骤:
而使用JavaScript编写的DApp通常通过web3.js+ABI去调用智能合约中的函数来实现数据的读取和修改,整个过程可以用下图示意:
Bytecode和ABI可以认为是智能合约源代码的两种外在表现形式,其中Bytecode是给机器执行的,而ABI是给DApp开发者用的自然语言描述的合约接口规范。
从整体上来看,开发智能合约+DApp 不需要我们精通Solidity,但需要理解他和我们所熟知的语言的最大区别,就可以顺利开发出正确的智能合约。
Remix是以太坊社区开发出来的在线智能合约集成开发环境,包含开发、部署、调试支持,官方还提供了桌面版,但桌面版仍然需要网络才可以正确运行
在前端开发环境中构建自己的智能合约的工作流,则需要组合使用现有的工具实现智能合约的编写、编译、部署、测试等环节。
使用Remix开发和调试第一个智能合约,浏览器打开https://remix.ethereum.org
1 | pragma solidity ^0.4.17; |
Solidity遇到public变量时,会认为其实需要持久化存储在以太坊区块链上的合约数据,即不仅仅是public variable,还是storage variable:
Car合约中没有任何复杂的逻辑控制结构比如条件、循环,典型的函数声明格式如下:
1 | function getBrand() public view returns (string){ |
其中典型的函数类型有以下几种:
合约部署后,最下面能看到新的合约账户,账户下面有按钮,每个按钮代表可以调用合约函数,如getBrand,如果按钮旁边有输入框,表示调用该函数的时候需要输入参数,如setBrand。我们直接点击brand或者getBrand按钮,会看到按钮右边立即显示了部署合约时设定的参数,点击了setBrand,没有输入内容则置空。这也是Remix的一个坑:不做参数校验。
我们合约没有声明brand方法,但部署后的合约里面有,这就是Solidity为公共访问的存储变量生成的getter方法,实际上源码的getBrand方法可以直接删除
合约部署Create和合约实例Contract Instance有几个问题需要理解:
合约部署实际上是发起了一笔交易,交易的接受者为空,以太坊将接受者为空的交易默认认为是合约创建请求,这类交易中会修改当前部署合约的机器码ByteCode,部署成功的话会返回新建的合约账户,合约部署本身需要消耗Gas,这个费用是发起者支付的。合约部署的交易细节在Remix上通过调试日志可以看到,打开Remix调试日志区域,通过点击合约创建右上角的Details按钮可以看到
1 |
|
input是智能合约字节码bytecode 跟在car.json 里面的 data bytecode object一样
部署交易中的form字段跟JavaScript VM下出现的Account列表中的第一个账户完全相同,部署完之后选中的账户余额互少一点,少掉的这部分就是使用该账户部署智能合约时消耗的汽油
合约部署的直观结果是我们得到一个账户,但是我们仅仅有这个账户的地址,而没有账户的公钥、私钥、这就是合约的定义,contract instance is an account controlled by code 的准确含义,后续和合约的交互也只能通过代码,即智能合约的函数调用。
因为在测试时以太坊网络运行在内存中,并且是单节点,速度自然很快,但是缺陷是每次页面加载,这个测试网络中的合约实例、合约中的数据都会丢失,网络中的账户及余额也会重建
然后实际的以太坊网络,不论是测试网络还是主网,都不会那么快,在分布式网络上达成共识是个很复杂的过程,由于是对于使用POW共识算法的以太坊来说
如果智能合约的源代码被修改,同样需要部署智能合约的示例,并测试这个全新的合约实例。与前端页面开发不同是,浏览器刷新后,老的页面样式、DOM接口、应用状态都被销毁了,而以太坊老的合约实例是不会被销毁 的,即使部署了新的版本,如果愿意也可以和老版本继续交互。
Car合约自动生成的brand函数可能会消耗无限的汽油,这是在智能合约设计时需要避免的,因为智能合约中的任何代码都是需要消耗汽油的,如果不加限制,很容易出现汽油不够而操作被回滚、调用失败的情况。具体到例子:brand属性为字符串,在静态类型语言中字符串本质是动态长度的字节数组dynamically-sized byte array,也就是说我们的brand属性可能是非常长的字符数组,如果长度超过某个临界值,就会消耗超出预期的汽油,只需要把brand类型从string设置byte32即可:
1 | pragma solidity ^0.4.17; |
setBrand被标记为红色,调用它实际发起的是transaction,任何交易都是异步的。getBrand被标记为蓝色,调用它就是简单的函数调用 call
智能合约中的函数要么是transaction类型,要么是call类型,两种类型函数的本质都是接口请求,但是又存在很大的区别,对比如下表:
函数类型 | call纯粹函数调用 | transaction通过交易调用 |
---|---|---|
是否能修改合约数据 | 不能修改 | 可以修改 |
是否能返回数据 | 能返回数据 | 只能返回交易地址 TxHash |
调用耗时长短 | 通常比较短 | 通常需要15s |
是否需要花钱 | 免费调用 | 要花钱 |
对于前端工程师,如果用同步、异步函数来类比则很好理解
Truffles对自己的定位是以太坊开发的瑞士军刀,就好比前端领域的各种全家桶种子项目create-app,vue-cli等,帮你把各种关键模块react/webpack/vue/eslint组装好,只需要在上面开发应用逻辑即可,但对于刚入门区块链和以太坊的人来说,接触到的新概念、术语会比较多,不利于入门
在Node开发环境使用几个最少的必要工具实现自己的智能合约编译、部署、测试工作流,整个工作流过程可以用下图:
单元测试中和部署完的智能合约实例交互,需要组合使用web3.js和mochajs
其中ganache-cli是Truffle框架的一部分,能够让开发者快速启动本地测试网络,不需要在本地运行以太坊节点,web3.js在合约智能部署和自动化测试时会被大量使用,这也为后面开发DApp打下良好的基础。
做完上面的事情后,重新Remix,看看我们如何在Remix中加载和测试使用脚本部署的智能合约实例
scripts/compile.js
1 | const fs = require('fs'); |
运行Car.sol后得出下面的输出:
1 | { |
contracts属性包含了所有找到的合约,源代码中只有Car合约,每个合约下面都包括了assembly、bytecode、interface、metadata、opcodes等字段,目前阶段仅需要关心的字段有:
interface
1 | [ |
可以看到是内含三个元素的大数组:
使用 fs-extra,可以方便后续的部署和测试过程直接使用编译结果,需要把编译结果保存在文件系统中:
1 | const fs = require('fs-extra'); |
然后重新编译脚本,确保complied目录下包含了新生成的Car.json
类似于前端构建流程中编译步骤,编译之前需要把之前的结果清空,然后把最新的编译结果保存下来,对编译脚本作如下改动:
1 | const fs = require('fs-extra'); |
是把我们的前端世界和区块链世界连接起来的桥梁,web3js里面包含了多个以太坊生态中不同的模块,具体包含:
以太坊的主网、测试网络、本地私有网络非常多,以太坊的钱包应用也非常多,web3.js没有选择兼容所有的情况,而是自己定制了一个接口规范,让社区开发者为之贡献插件,插件在web3.js体系叫做provider,可以理解为webpack生态的plugin.
web3js通过插件机制和以太坊不通过网络通信的模式可以用下面的图示:
客户端和以太坊网络的任何交互都可以定性为接口调用和介意,智能部署属于后者,部署时除了必须要有bytecode。发起靠谱哦的必要条件也应算在内,具体来说包含如下几个方面:
以太坊的任何交易都需要账户发起,账户中必须要有足够多的余额来支付手续费Transcation Fee,我们之前创建并充值的Metamask账户可以派上用场
区块链上的任何交易都会被发送到某个网络,并被这个网络中的节点打包确认,可以考虑把智能合约部署到Rinkeby网络中,这样我们的交易通过自己运行的节点广播给Rinkeby测试网络中的其他节点,就能被打包确认,但是实际上前端自己跑节点成本还是很高的,还在社区有人做了各种以太坊的入口节点,为广发开发者提供接口可以直接调用。
Infura就提供了这样的服务
scripts/deploy.js
1 | const fs = require('fs-extra'); |
Configuration Provider 初始化了 Infura 提供的 HTTP 接口的测试网入口以及助记词钱包的provider,钱包助记词是 Metamask的钱包助记词
接着使用配置好的插件实例生成新的web3 实例
调用 web3.eth.getAccounts 方法解锁助记词钱包里面的第1个账户作为部署合约的账户,我们充值的也是Metamask钱包的第一个账户,而实际上这个助记词是可以派生出很多账户的,其他账户没有余额,也就无法使用
Create Contract Instance And Deploy 的代码也可以分开来写,链式的代码调用实际上做了合约初始化、交易初始化、交易发送3件事情:
1 | const contract = new web3.eth.Contract(JSON.parse(interface)); |
智能合约实例是存储在以太坊网络上被代码控制的账户,部署完之后,可以通过echerscan/remix去访问它、和它交互
用什么网络就用xxtestnet.etherscan.io/tx/contractAddress,点击列表最左侧的TxHash字段,可以看到合约部署的交易细节:
可以使用Remix加载测试网络上特定的合约实例,并与之交互,下面是具体步骤
run transactions里面环境选择你的测试网络,编译原来的源码,At Address输入你的合约地址,然后进行调试,getbrand可以很快拿到值,但是setBrand却是处于pending状态,在账户那里会显示可能需要花费的汽油,确定后15s内返回结果,并消耗了汽油
1 | statustrue Transaction mined and execution succeed |
再次点击brand,就是返回刚刚输入的”Test”了
针对部署后的合约实例进行测试,而不是合约源代码,可以看为端到端测试
自动化测试可以跟测试网络上的合约实例交互,但是每次跑测试都需要花点几分钟时间,此外还要自己进行复杂的账户管理。
因为区块链网络交易确认的异步性,在工具选择时需要做些权衡,ganache-cli就像是Remix中运行在内容中的JavaScript VM测试网络,其方法调用,交易确认速度非常快,为开发者提供了成本极低的本地测试网络,很适合用来测试。
而ganache-cli为web3.js提供了兼容的provider,这样就可以通过web3.js把合约部署到ganache-cli提供的本地测试网络上,并且跟合约实例交互。
测试时我们通常会把每次测试运行的环境隔离开,对应到智能合约测试,每次测试需要部署新的合约实例,然后针对新的实例做功能测试
Car合约的功能比较简答,设计如下2个测试用例:
测试代码:
1 | const path = require('path'); |
代码使用的断言库是nodejs内置的assert模块,ganache-cli的provider自己在内部管理了一些账户。web3.js智能合约实例交互的方法,在DApp开发时会大量使用:
send必须指定发起的账户地址,而call可以直接调用
1 | $ mocha tests |
1 | "scripts": { |
RxJS 是一个库,通过使用 observable 序列来编写异步和基于事件的程序,提供一个核心的类型 Observable,附属类型(Observer/Schedulers/Subjects)和受[Array#extras]启发的操作符(map,filter,reduce,every等等),这些数组操作符可以把异步事件作为集合来处理。
可以把 Rxjs当做是用来处理事件的 Lodash
ReactiveX 结合了观察者模式、迭代器模式和使用集合的函数式编程,以满足以一种理想方式来管理序列所需要的一切。
在 RxJS中用来解决异步事件管理的基本概念是:
注册事件监听器常规写法与 RxJS写法
1 | const button = document.querySelector('button'); |
RxJS 强大的正是它使用纯函数来产生值的能力,这意味这代码不容易出错。
通常会创建一个非纯函数,在这个函数之外也使用了共享变量的代码,使得应用状态一团糟
1 | let count = 0; |
scan
操作符的工作原理与数组的 reduce类似,它需要一个暴露给回调函数当参数的初始值,每次回调函数运行后的返回值会作为下次回调函数运行时的参数。
RxJS 提供一整套操作符来帮助控制事件如何流经 observables.
下面的代码展示的是如何控制一秒钟内最多点击一次:
1 | const count = 0; |
对于流经 observables 的值,可以对其进行转换
下面代码展示的是如何累加每次点击的鼠标x坐标
1 | button.addEventListener('click',(event)=>{ |
Observable 是多个值的惰性推送集合,填补了下面表格的空白:
单个值 | 多个值 | |
---|---|---|
拉取 | Function | Iterator |
推送 | Promise | Observable |
实例:当订阅下面代码中的 Observable的时候会立即(同步地)推送值1,2,3,然后1秒后会推送值4,再然后是完成流:
1 | const { Observable } = require('rxjs'); |
拉取和推送是两种不同的协议,用来描述数据生产者(Producer)如何与数据消费者(Consumer)进行通信的。
拉取-在拉取体系中,由消费者来决定何时从生产者那里接收数据。生产者本身不知道数据是何时交付到消费者手中的。
每个JS函数都是拉取体系,函数是数据的生产者,调用该函数的代码通过从函数调用中取出一个单个返回值来对该函数进行消费。
ES2015引入了 generator 函数和 iterators(function*) 这是另外一种类型的拉取体系。调用 iterator.next() 的代码是消费者,它会从 iterator(生产者)取出多个值
生产者 | 消费者 | |
---|---|---|
拉取 | 被动的:当被请求时产生数据 | 主动的:决定何时请求数据 |
推送 | 主动的:按自己的节奏产生数据 | 被动的:对收到的数据做出反应 |
推送-在推送体系中,由生产者来决定何时把数据发送给消费者,消费者本身不知道何时会接受到数据
Promise是最常见的推送体系类型。由Promise(生产者)将一个解析过的值传递给已注册的回调函数(消费者),但不同于函数的是,由Promise来决定何时把值推送给回调函数。
RxJS引入了 Observables 一个新的推送体系。Observable 是多个值的生存者,并将值推送给观察者(消费者)。
与流行的说法相反,Observable 既不像 EventEmitters,也不像多个值的Promises,在某些情况下,即当使用RxJS的Subjects进行多播时,Observables的行为可能会比较像EventEmitters,但通常情况下Observables的行为并不像EventEmitters.
Observables 像是没有参数,但可以泛化为多个值的函数
1 | function foo(){ |
因为函数和 Observables 都是惰性运算,如果不调用函数,不会执行console.log('Hello')
。Observable 也是如此,不调用(使用 subscribe
),console.log('Hello')
也不会执行。此外,调用或订阅是独立的操作;两个函数调用会触发两个单独的副作用,两个Observable订阅同样也是触发两个单独的副作用。EventEmitters共享副作用并且无论是否存在订阅者都会尽早执行,Observables与之相反,不会共享副作用并且是延迟执行。
订阅 Observable 类似于调用函数
一些人声称Observables是异步的,那不是真的:
1 | console.log('before'); |
那么 Observable 和函数的区别是什么?Observable可以随着时间的推移返回多个值,这是函数所做不到的
1 | function foo(){ |
函数只能返回一个值,但 Observables 可以这样:
1 | const foo = new Observable(observer => { |
也可以异步地返回值
1 | const foo = new Observable(observer => { |
结论:
Observables使用观察者来订阅,然后执行并发送 next/error/complete
给观察者,而且执行可能会被清理。这四个方面全部编码在Observables实例中,但某些方面是与其他类型相关的,像 Observer观察者和Subscription订阅
Observable的核心关注点:
1 | const observable = new Observable(observer => { |
1 | const observable = new Observable(observer => { |
subscribe调用在同一 Observable 的多个观察者之间是不共享的,当使用一个观察者调用 observable.subscribe时,new Observable(subscrive)中的 subscribe函数只服务给定的观察者,对于observable.subscribe的每次调用都会触发针对给定观察者的独立设置
订阅 Observable 像是调用函数,并提供接收数据的回调函数
这与像 addEventListener/removeEventListener 这样的事件处理方法API是完全不同的。使用 observable.subscribe 在 Observable中不会将给定的 观察者注册为监听器。Observable甚至不会去维护一个附加的观察者列表
subscribe 调用是启用Observable执行的一种简单方式,并将值或事件传递给本次执行的观察者
Observable 是惰性运算,只有在每个观察者订阅后才会执行,随着时间的推移,执行会以同步或异步的方式产生值
Observable执行可以传递三种类型的值:
Next通知是最重要,也是最常见的类型,它们表示传递给观察者的实际数据,Error和Complete通知只会在Observable执行期间发生一次,并且只会执行其中的一个
这些约束用所谓的 Observable语法或合约表达最好,写为正则是这样的:
1 | next*(error|complete)? |
三个next然后是complete
1 | const observable = new Observable(observer => { |
try/catch可以捕获异常
1 | const observable = new Observable(observer => { |
因为Observable执行可能是无限的,并且观察者通常希望能在有限的时间内中止执行,所以我们需要一个API来取消执行,因为每个执行都是其对应观察者专属的,一旦观察者完成接收值,它必须要一种方法来停止执行,以避免浪费计算能力和内存资源。
当调用了 observable.subcribe 观察者会被附加到新创建的 Observable 执行中,这个调用还返回了一个对象,即 Subscription(订阅):
1 | const subscription = observable.subscribe(x=>console.log(x)) |
Subscription表示进行中的执行,有最小化的API以允许你取消执行
1 | const { from } = require('rxjs'); |
当订阅了 Observable会得到一个 Subscription,它表示进行中的执行,只要调用 unsubscribe方法就可以取消执行
Observable可以通过返回一个自定义 unsubscribe函数
1 | const observable = new Observable(observer => { |
观察者是由 Observable发送的值的消费者,观察者只是一组回调函数的集合,每个回调函数对应一种 Observable发送的通知类型:next/error和complete,下面是一个典型的观察者对象:
1 | const observable = new Observable(observer => { |
观察者只是有三个回调函数的对象,每个回调函数对应一种 Observable发送的通知类型
RxJS的观察者也可能是部分的,如果没有提供某个回调函数,Observable的执行也会正常执行,只是某些通知类型可能会被忽略,因为观察者中没有相对应的回调函数
Subscription是表示可清理资源的对象,通常是 Observable的执行,Subscription中有一个重要的方法,即 unsubscribe,不需要任何参数,只是用来清理 Subscription占用的资源,上个版本又叫做 Disposable(可清理对象)
Subscription基本上只有一个 unsubscribe() 函数,这个函数用来释放资源或去取消 Observable执行
Subscription还可以合在一起,这样一个 Subscription调用 unsubscribe 方法,可能会有多个 Subscription取消订阅,可以通过把一个Subscription添加到另外一个来做这件事
1 | const observable1 = interval(400); |
Subject是一种特殊类型的 Observable,它允许将值多播给多个观察者,所以Subject是多播,而普通的 Observable是单播的(每个已订阅的观察者都拥有 Observable的独立执行)
Subject 像是 Observable,但是可以多播给多个观察者,Subject还像是 EventEmitters,维护着多个监听器的注册表
每个 Subject都是 Observable,对于 Subject,可以提供一个观察者并使用 subscribe方法,就可以开始正常接收值,从观察者角度,无法判断 Observable执行是普通的 Observable还是 Subject
在Subject内部,subscribe 不会调用发送值的新执行,它只是将给定的观察者注册到观察者列表中,类似于其他库或语言中的 addListener的工作方式
每个 Subject都是观察者,Subject是一个有如下方法的对象:next(v)/error(e)/complete(),要给 Subject提供新值,只要调用 next(theValue),它会将值多播给已注册监听该Subject的观察者们。
下面的例子给 Subject添加两个观察者,然后给 Subject提供一些值:
1 | const subject = new Subject(); |
因为 Subject是观察者,这也就意味着可以把 Subject作为参数传给任何 Observable的 subscribe方法,如下:
1 | const subject = new Subject(); |
使用上面的方法基本上只是通过 Subject将单播的 Observable执行转换为多播的,也说明了Subjects是将任意的 Observable 执行共享给多个观察者的唯一方式。
还有一些特殊的类型的Subject: BehaviorSubject/ReplaySubject和AsyncSubject
多播的Observable通过Subject来发送通知,这个Subject可能有多个订阅者,然后普通的单播 Observable值发送通知给单个观察者。
多播 Observable在底层是通过使用 Subject使得多个观察者可以看到同一个 Observable执行
在低层,这是 multicast操作符的工作原理:观察者订阅一个基础的Subject,然后Subject订阅源Observable
1 | const source = from([1, 2, 3]); |
multicast操作符返回一个Observable,看起来和普通的Observable没有什么区别,但当订阅就像是Subject,multicast返回的是ConnectableObservable,它只是一个有connect方法的 Observable.
connect决定了何时启动共享的Observable执行,因为connect在底层执行了source.subscribe(subject)。它返回的是Subscription,可以取消订阅以取消共享的Observable执行
手动调用 connect并处理 Subscription过于笨重,当第一个观察者到达时我们要自动连接,而当最后一个观察者取消订阅时我们想要自动地取消共享执行。
要实现这点,需要显式调用 connect:
1 | const { interval, Subject } = require('rxjs'); |
如果不想显式调用 connect ,可以使用 ConnectableObservable的 refCount 方法引用计数,这个方法返回 Observable,这个 Observable会追踪多个订阅者。当订阅者的数量从0变成1,它会调用connect以开启共享的执行,当订阅者数量从1变成0时,它会完全取消订阅,停止进一步的执行。
refCount的作用是,当有第一个订阅者时,多播 Observable会自动地启动执行,当最后一个订阅者离开时,多播 Observable会自定地停止执行。
1 | const { interval, Subject } = require('rxjs'); |
Subject的其中一个变体就是 BehaviorSubject,它有一个当前值的概念,它保存了发送给消费者的最新值,并且当有新的观察者订阅时,会立即从 BehaviorSubject那接收到当前值
BehaviorSubjects适合用来表示随着时间推移的值,例如生日的流是一个Subject,但年龄的流应该是一个BehaviorSubject
下面例子中,BehaviorSubject使用值0进行初始化,当第一个观察者订阅时会得到0,第二个观察者订阅时会得到值2,尽管它是在值2发送之后订阅的。
1 | const beSubject = new BehaviorSubject(0); |
ReplaySubject 类似于 BehaviorSubject,它可以发送旧值给新的订阅者,但它还可以记录Observable执行的一部分
ReplaySubject 记录 Observable 执行中的多个值并将其回放给新的订阅者
当创建 ReplaySubject时,可以指定回放多个值:
1 | const reSubject = new ReplaySubject(3); |
除了缓冲数量,还可以指定 window time(以毫秒为单位)来确定多久之前的值可以记录,在下面的示例中,使用了较大的缓存数量100,但 window time 参数只设置了500毫秒。
1 | const reSubject = new ReplaySubject(100, 500); |
从下面的输出可以看出,第二个观察者得到的值是3
、4
,这两个值是订阅发生前的500
毫秒内发生的:
1 | observerA: 1 |
只当 Observable 执行完成时(执行complete)它才会将执行的最后一个值发送给观察者
1 | const asSubject = new AsyncSubject(100, 500); |
AsyncSubject和last操作符类似,因为它也是等待complete通知,一发送一个单一值
操作符是运行复杂的异步代码以声明的方式进行轻松组合的基础代码单元
操作符是Observable类似上的方法。当操作符被调用时,它们不会改变已经存在的 Observable实例。相反,它们返回一个新的 observable,它的 subscription逻辑基于第一个 Observable.
操作符是函数,它基于当前的 Observable 创建一个新的 Observable,这是一个无副作用的操作:前面的 Observable保持不变
操作符本质上是一个纯函数,它接收一个 observable 作为输入,并生成一个新的 observable 作为输出。订阅输出 Observable 同样会订阅输入 Observable。下面实例中,我们创建一个自定义操作符函数,它将输入 Observable 接受的每个值都乘以10
1 | function multiplyByTen(input) { |
注意,订阅 output
会导致 input
Observable 也被订阅。我们称之为“操作符订阅链”。
通常提到的操作符是实例操作符,是 Observable 实例上的方法,如果上面的 multiplyByTen
是官方提供的实例操作符,看起来大概是这样的
1 | Rx.Observable.prototype.multiplyByTen = function multiplyByTen() { |
实例运算是使用 this 关键字来指代输入的 Observable 函数
注意,这里的 input
Observable 不再是一个函数参数,它现在是 this
对象。下面是我们如何使用这样的实例运算符:
1 | const observable = from([1, 2, 3, 4]).multiplyByTen(); |
而静态操作符是直接附加到 Observable类上的。静态操作符在内部不使用 this关键字,而是完全依赖于它的参数
静态操作符是附加到 Observable 类上的纯函数,通常用来从头开始创建 Observable
最常见的 静态操作符是所谓的创建操作符,它们只接收非 Observable参数,比如数字,然后创建一个新的Observable,而不是将一个输入 Observable转换为输出 Observable
一个典型的静态操作符例子就是 interval函数,它接收一个数字非Observable作为参数,并生产一个 Observable作为输出:
1 | const observable = interval(1000); |
然而,有些静态操作符可能不同于简单的创建。一些组合操作符可能是静态的,比如 merge
、combineLatest
、concat
,等等。这些作为静态运算符是有道理的,因为它们将多个 Observables 作为输入,而不仅仅是一个,例如:
1 | const { interval, merge } = require('rxjs'); |
可分类:创建、转换、过滤、组合、错误处理、工具等等。
OAuth2.0是一个授权协议,允许软件应用代表资源拥有者去访问资源拥有者的资源。应用向资源拥有者请求授权,然后取得令牌token并用它来访问资源。这一切都不需要应用去充当资源拥有者的身份,因为令牌明确表示了被授予的访问权,限制客户端只能执行资源拥有者授权的操作,或者OAuth2.0是一个安全协议,能让第三方应用以有限的权限访问HTTP服务,可以通过构建资源拥有者与HTTP服务间的许可交互机制,让第三方应用代表资源拥有者访问服务,或者通过授权权限给第三方应用,让其代表自己访问服务。
作为一个授权框架,OAuth关注的是如何让一个系统组件获取对另一个系统㢟的访问权限,最常见的情形是客户端应用代表资源拥有者(通常是最终用户)访问受保护资源。三个角色:
资源拥有者有权访问API,并将API访问权限委托出去。资源拥有者一般是能够使用浏览器的人
受保护资源是资源拥有者有限权限访问的组件。这样的组件有多种形式,一般是某种形式的Web API,资源听起来像是某种能下载的东西,但其实这些API支持读、写和其他操作。
客户端是代表资源拥有者访问受保护资源的软件。在OAuth中,只要软件使用了受保护的资源的API,就是客户端。
例子:假设度假拍的照片上传到了照片存储网站,现在想将它们打印出来。照片存储网络的API就是资源,打印服务则是那个API的客户端。作为资源拥有者,需要将一部分权利委托给照片打印服务,让它能读取照片。但你不想让打印服务读取所有照片也不想它有删除或者上传的权限。
之前流行的做法是复制用户的凭证并用它登录另一个服务。就是照片打印服务假设用户在照片存储服务商使用的凭证与在打印服务上的相同。当用户登录照片打印服务后,该服务使用用户的用户名和密码登录照片存储网络,获取用户的账号访问权,假装用户。
这种情况下,用户需要使用某种凭证与客户端进行身份认证,这些凭证通常是被集中控制的,并受客户端和受保护资源一致认可。客户端先得到用户的用户名和密码或者会话cookie,然后用它们访问受保护资源,假装是用户。受保护资源将客户端视为用户并直接通过身份认证,而实际上与受保护资源建立连接的是客户端。
这种方法要求用户在客户端和受保护资源端使用相同的凭据,使得这种凭据盗用技术只能在同一安全域内使用。也就是说,如果是一个公司控制着客户端、授权服务器和受保护资源,并且这些组件都使用相同的策略和网络控制下运作,这种方法才行得通。如果打印服务和存储服务是由同一个公司提供的,就能采用这种方法,因为用户可以在两个服务使用相同的账户凭据。
这一技术还会将用户的密码暴露给客户端应用,即使在单一安全域使用同一组凭据,这也基本上无法避免。但无论如何,客户端是在扮演用户,受保护资源无法区分资源拥有者和扮演资源拥有者的客户端,因此两者都用相同的用户名和密码。
如果两个服务处于不同的安全域中,如照片打印例子中的情况,不能复制用户提供的用于登录当前应用的密码了,因为这个密码对于另一个应用来说是无效的。对于这个问题可以采取另一种老套的手段来获取密码:向用户索取。
打印服务想要获取用户的照片,可以提示用户输入照片存储网络上的用户名和密码,打印服务用这些凭据访问受保护资源,扮演用户。在这种情况下,用户用于登录客户端的凭据和用于访问受保护资源的凭据可以不同。很多用户在实际中会运行这样的要求,特别是当使用受保护资源的是一个很有用的服务时。
因此这仍然是当前移动应用通过用户账号访问后端服务的最常见的方法之一:移动应用让用户输入用户名和密码,然后直接将这些凭证通过网络发送给后端API。为了可以持续访问API,客户端应用会保存用户的凭据,以便在必要的时候访问受保护资源,这种做法很危险,一旦任何一个正在使用中的客户端被攻破,这意味着用户在所有系统的账号都被攻破。
复制用户密码并不是一个好方法,如果授予打印服务全局的访问权限,使它能代表由它指定的任何用户并访问存储服务上的所有照片,常见的方式是为客户端颁发一个开发者密钥,让客户端使用该密钥直接调用受保护资源。
开发者密钥是一种全局的密钥,客户端可以用它来充当任意一个由其指定的用户,用户的指定很可能通过一个API参数来完成。这样做的好处是避免了向客户端暴露用户凭据,但代价是要向客户端提供功能强大的开发者密钥。有了这种密钥,打印服务随时都能任意地打印所有用户的所有照片,因此它实际上拥有了自由访问受保护资源的权利。这在一定程度上是可行,但前提是受保护资源充分了解并信任客户端。但是这样的关系几乎不可能存在于两个组织之间,例如照片打印例子中的两个服务。此外,如果客户端的密钥被盗,将对受保护资源造成灾难性损害,因为存储服务的所有用户都将受到影响,无论他们是否使用打印服务。
给用户一个特殊密码,此密码仅用于透露给第三方服务。用户自己不会使用这个密码来登录,只是将它粘贴到所使用的第三方应用里。用户不再需要想客户端透露登录密码,受保护资源也不在需要相信客户端都能代表所有用户执行正确的操作。但是这种系统的可用性不好。要求用户除了管理自己的主密码之外,还要创建、分发、管理特殊凭据。因为需要用户来管理这些凭据,一般来说,客户端与凭据本身并没有对应关系,这使得撤销某个应用的访问权限变得很困难。
如果能为每个客户端和每个用户的组合分别颁发这种受保护资源具有受限访问的凭据,就可以将受限访问分别与受限凭据绑定。如果有一个基于网络的协议,能够部署到整个互联网上,跨安全边界地生成安全分发这些首先呢的凭据,同时具有良好的用户体验。
OAuth协议的设计目的是:让最终用户通过OAuth将他们在受保护资源上的部分权限委托给客户端应用,使客户端应用代表他们执行操作。为实现这一点,OAuth在系统中引入了另外一个组件:授权服务器。
受保护资源依赖授权服务器向客户端颁发专用的安全凭据——OAuth访问令牌。为了获取令牌,客户端首先将资源拥有者引导至授权服务器,请求资源拥有者为其授权。授权服务器先对资源拥有者进行身份认证,然后一般会让资源拥有者选择是否对客户端授权。客户端可以请求授权功能或权限范围的子集,该子集可能会被资源拥有者进一步缩小。一旦授权请求被许可,客户端就可以向授权服务器请求访问令牌。按照资源拥有者的许可,客户端可以使用该令牌对受保护资源上的API进行访问。
在这个过程中,没有讲资源拥有者的凭据暴露给客户端:资源拥有者向授权服务器进去身份认证的过程中所用的信息是独立于客户端交互的。客户端没有功能强大的开发者密钥,无法随意访问任何资源,而是必须在得到有效的资源拥有者的授权之后才能访问受保护资源。虽然大多数OAuth客户端可以向授权服务器进行身份认证,但仍然需要得到授权后才能访问资源。
用户通常不必查看或者直接处理访问令牌,OAuth不需要由用户生成令牌并粘贴到客户端,而是简化了这一过程:客户端请求令牌,用户对客户端授权,然后由客户端管理令牌,用户管理客户端应用。
上面很多传统方法都是密码反模式的案例,通过共享机密信息(密码)来直接代表当事方(用户)。用户通过与应用共享密码,使应用能够访问受保护的API,然而这种方式非常不安全。
HTTP API最开始是如何引入密码保护功能的呢?可以从 HTTP 协议的历史及其安全手段入手,HTTP 协议制定了一个机制,用户可以凭借该机制在浏览器中使用用户名和密码向一个网页进行身份认证,这就是所谓的 HTTP 基本认证协议 (HTTP basic auth)。还有一种更安全的认证协议,叫做 HTTP 摘要认证(HTTP digest auth)。它们都假设用户在场,并且要求 HTTP 服务器呈现用户的用户名和密码,此外由于 HTTP 是一个无状态的协议,因此每一个 HTTP 事务都要呈现这些凭据。
HTTP不会区分一个事务是由用户通过浏览器发起的,还是通过其他软件发起的,这种基本的灵活性是HTTP协议得到普及的关键原因。这样有一个问题是除了面向用户的网页或服务之外,当 HTTP 用于直接访问 API时,现有的安全机制顺理成章被沿用到新的应用场景,为API和网页服务不断呈现 密码。虽然浏览器可以使用cookie技术或其他会话管理技术,但是访问Web API的HTTP客户端没有这样的机制可用。
OAuth从一开始就被设计成一个可用的API协议,其中主要的交互过程都是在浏览器之外进行的。OAuth的整个流程通常是由最终用户在浏览器中启动的,实际上这也正是委托模式的灵活性和优势所在。但是最终接收令牌、使用令牌访问受保护资源的步骤对用户是不可见的。实际上,OAuth的一些主要事务过程都是发生在用户不在场的情况下,客户端仍然能够代表用户执行操作。OAuth让我们摒弃HTTP基本协议中的观念和假设,将一种功能强大、安全的方式引入现如今的API体系
委托概念是OAuth强大功能的根基,虽然OAuth经常被称作授权协议,但它也是一个委托协议。通常,被委托的是用户权限的子集,但是OAuth本身并不承载或者传递权限。相反,它提供一种方法,让客户端可以请求用户将部分权限委托给自己,然后用户可以批准这个委托请求,被批准后,客户端就可以去执行那些操作了。
以照片打印为例子,照片打印服务可以询问用户:是否在这个存储服务存放了照片?如果是我可以帮打印出来。然后用户被引导至照片存储服务,存储服务也会问:打印服务想要获取照片,同意吗?用户可以决定是否同意,即决定是否将访问权限委托给打印服务。
委托协议和授权协议的区别很重要,因为OAuth令牌中携带的授权信息对系统中的大部分组件是不透明的。只有受保护资源需要了解授权信息,只要它能从令牌得知授权信息(既可以直接从令牌中获取,也可以通过某种服务来获取),就可以按照要求提供API服务
由于OAuth的委托过程需要资源拥有者的参与,因此它提供了一种在很多其他安全模型中不存在的可能性:重要的安全决策可以由最终用户来做。传统上,安全决策一直由集权机构负责。由集权机构决定谁可以使用服务、使用什么客户端以及以何种目的使用。OAuth则允许集权机构将某些决策权交到最终使用软件的用户手中。
OAuth系统常遵循TOFU原则:首次使用时信任(trust on first use)。在TOFU模型中,需要用户在第一次运行时进行安全决策,而且并不为安全决策预设任何先决条件或者配置,仅提示用户做出决策。这个过程可以简单到只是询问用户“要连接新的应用吗”。很多实现运行在这个步骤中进行更多控制。无论用户遇到的是哪种情况,只要具有对应的权限,就能做出安全决策。系统会记住用户的决策,以便以后使用。只要首次建立了授权关系,系统就会在后续的处理过程中继续信任用户的决策:首次使用时信任。
OAuth2.0非常善于获取用户的委托决策,并通过网络传递出去。运行多方参与安全决策过程,尤其是在运行期间让最终用户参与决策。由多个可移动的组件构成的协议,但是在很多方面都比其他方案更简单、更安全。
OAuth2.0设计中有一个重要的阶段,就是不受控的客户端总是比授权服务器或者受保护资源多出好几个数量级。因为单个授权服务器可以很轻松地保护多个资源服务器,并且很可能有许多不同类型的客户端想要访问特定的API。一台授权服务器甚至可以有多个不同的客户端信任等级。这样的架构决策导致的结果就是, 尽可能将复杂性从客户端转移到服务端。这对于客户端开发人员来说是好事,因为客户端成了系统最简单的部分。客户端人员不再需要像在先前的安全协议中,处理签名规范化以及解析复杂的安全策略文档,也不需要担心处理敏感的用户凭据。OAuth令牌提供了一种比密码略复杂的机制,如果使用得当,其安全性比密码高很多。
另一方面,授权服务和受保护资源要承担更多复杂性和安全性方面的责任。客户端只要保护好自身的客户端凭据和用户的令牌即可,单个客户端被攻破会造成损害,但只有该客户端的用户会受到影响。被攻破的客户端也不会泄露资源拥有者的凭据,因此客户端根本没有机会接触这些凭据。然而,授权服务器则需要管理和保护系统中所有客户端和用户的凭证和令牌。虽然这确实使它更容易称为攻击目标,但是保护单个授权服务器要比保护上千台由不同开发人员开发的客户端容易得多。
OAuth2.0的可拓展性和模块化是其最大的优势之一,因为这使得该协议适用于各种环境。然而正是这种灵活性导致不同的实现之间存在基本的兼容性问题。当开发人员想在不同的系统上实现OAuth时,它提供的众多自定义选项更让人困惑。
由于使用bearer令牌的OAuth2.0并不提供消息签名,因此不应该脱离HTTPS使用。机密信息需要在网络上传播,所以OAuth需要TLS这样的传输机制来保护这些信息。
OAuth本身并不透露关于用户的信息,本质上是一个部件,能用于在更宏大的技术方案中提供其他功能,另外OAuth 在多个地方用到了身份认证,最典型的就是资源拥有者和客户端软件要向授权服务器进行身份认证。但这种内嵌身份认证的行为并不会使OAuth自身成为身份认证协议
尽管它在根本上是一个用户向软件授权的协议。OAuth假设资源拥有者能够控制客户端。要使资源拥有者向另一个用户授权,仅适用OAuth是不行的。但这种授权并不罕见,User Managed Access协议就是为此而生,规定了如何使用OAuth 构建一个支持用户对用户授权的系统。
提供了一种传达授权委托已发生这一事实,但是它并不定义授权的内容。相反,由服务API定义使用权限范围、令牌之类的OAuth组件来定义一个给定的令牌适用于哪些操作
明确声明了令牌的内容对客户端是完全不透明的。颁发令牌的授权服务器和接收令牌的受保护资源需要理解令牌。这个层面的互操作性要求催发了 JSON Web Token(JWT)格式和令牌内省协议。虽然令牌本身对客户端还是不透明的,但现在它的格式能被其他组件理解。
没有新的加密机制,而是允许借用通用的加密机制,这些加密机制不止适用于OAuth。这种有意的遗漏催生了JSON对象签名和加密(JOSE)规范套件,该套件提供了一系列通用的加密机制,可以配合OAuth使用,也可以脱离OAuth使用。
规范被分成了多个定义和流程,每个定义和流程都有各自适用的场景。在某种程度上,可以将OAuth2.0视为一个安全协议生成器,因为它可用于许多不同的应用场景设计安全架构。
OAuth是一个应用广泛的安全标准,提供了一种安全访问受保护资源的方式,特别适用于Web API
OAuth是一个复杂的安全协议,需要不同的组件互相通信,从根本上说,OAuth事务中的两个重要步骤是颁发令牌和使用令牌。令牌表示授予客户端的访问权,在OAuth2的各个部分都起到了核心作用。尽管每个步骤的细节都会因多种因素而异,但是一个规范的OAuth事务包含以下事件
首先,资源拥有者访问客户端应用,并表明他希望客户端代表自己去使用某一受保护资源。例如,用户会在这一步示意打印服务去使用某个照片存储服务。该服务是个API,客户端知道如何调用它,并且还知道需要通过OAuth来调用。
当客户端发现需要获取一个新的OAuth访问令牌时,它会将资源拥有者重定向至授权服务器,并附带一个授权请求,表示它要向资源拥有者请求一些权限。例如,为了能读取照片,照片打印服务可以向照片存储服务请求访问权限。由于是的是Web客户端,因此采用HTTP重定向的方式将用户代理重定向至授权服务器的授权端点,客户端的响应如下:
1 | HTTP/1.1 302 Moved Temporarily |
这个重定向响应导致浏览器向授权服务器发送一个GET请求。
1 | GET /authorize?response_type=code&scope=foo&client_id=oauth-client-1&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcallback&state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1 HTTP/1.1 |
客户端通过在发送给用户的URL中包含参数,来标识自己的身份和要请求的授权详情,如权限范围等。虽然请求并不是由客户端直接发出的,但授权服务器会解析这些参数并做出适当反应。
然后授权服务器会要求用户进行身份认证,进一步对确认资源拥有者的身份以及能向客户端授予哪些权限来说至关重要。
用户身份认证直接在用户(和用户浏览器)与授权服务器之间进行,这个过程对客户端应用不可见。这一重要特性避免了用户将自己的凭据透露给客户端应用,对抗这种反模式发明OAuth的原因。
另外因为资源拥有者通过浏览器与授权端点交互,所有也要通过浏览器来完成身份认证,因此,有很多身份认证技术可以用于用户身份认证流程。OAuth没有规定应该使用哪种身份认证技术,授权服务器可以自由选择,例如用户名/密码、加密证书、安全令牌、联合单点登录或者其他方式。
这种隔离方案还得客户端不会因用户身份认证方式发生变化而受到影响,让简答的客户端应用也能受益于授权服务器使用的一些新兴技术,例如基于风险的启发式认证(risk-based heuristic authentication)技术。然而这种做法并没有向客户端传递任何有关认证用户的信息。
然后用户向客户端应用授权,这一步,资源拥有者选择将一部分权限授予客户端应用,授权服务器提供了很多不同的选项来实现这一点。客户端可以在授权请求中指明其想要获得哪些权限。授权服务器可以运行用户拒绝一部分或者全部权限范围,也可以让用户批准或者拒绝整个授权请求。
此外,很多授权服务器允许将授权决策保存下来,以便以后使用。如果使用了这种方式,那么未来同一个客户端请求同样的授权时,用户将不会得到提示。用户仍然会被重定向到授权端点,并且仍然需要登录,但是会跳过批准授权环节而沿用前一次的授权决策。授权服务器甚至可以通过像客户端白名单或者黑名单这样的内部策略来否决用户的决策。
然后,授权服务器将用户重定向到客户端应用。这一步采用HTTP重定向的方式,回到客户端的 redirect_uro
1 | HTTP 302 Found |
这又导致浏览器向客户端发送如下请求
1 | GET /callback?code=8V1pr0rJ&state=Lwt50DDQKUB8U7jtfLQCVGDL9cnmwHH1 |
这个HTTP请求是发送给客户端不是给授权服务器的
1 | User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0)Gecko/20100101 Firefox/39.0 |
由于使用的是授权码许可类型,因此该重定向链接中包含了一个特殊的查询参数code,这个参数的值被解析为授权码,它是一次性的凭据,表示用户授权决策的结果。客户端会在接收到请求之后解析该参数以获取授权码,并在下一次使用授权码。客户端还会检查state参数值是否与它在前一个步骤中发送的值匹配。
现在客户端已经得到授权码,将其发送给授权服务器的令牌端点。客户端发送一个POST请求,在HTTP主体中以表单的格式传递参数,并在HTTP基本认证头部设置client_id
和client_secret
。这个HTTP请求由客户端直接发送给授权服务器,浏览器或者资源拥有者不参与此过程。
1 | POST /token |
这种将不同的HTTP连接分开的做法保证了客户端能够直接进行身份认证,让其他组件无法查看或操作令牌请求。
授权服务器接收该请求,如果请求有效,则办法令牌。授权服务器需要执行多个步骤以确保请求是合法的。首先,它需要验证客户端凭据(通过Authorization头部传递)以确定是哪个客户端请求授权。然后,从请求主体中读取code参数的值,并从中读取关于该授权码的信息,包括发起初始授权请求的是哪个客户端,执行授权的是哪个用户,授权的内容是什么。如果授权码有效尚未使用过,而且发起该请求的客户端与最初发起授权请求的客户端相同,则授权服务器会生成一个新的访问令牌并返回到客户端。
该令牌以JSON对象的格式通过HTTP响应返回给客户端
1 | HTTP 200 OK |
然后客户端可以解析令牌响应并从中获取令牌的值来访问受保护资源。在这个案例,使用了 OAuth bearer令牌,这是通过响应中的 token_type字段描述的。令牌响应还可以包含一个刷新令牌(用于获取新的访问令牌而不必重新请求授权),以及一些关于访问令牌的附加信息,比如令牌的权限访问和过期时间。客户端可以将访问令牌存储在一个安全的地方,以便以后在用户不在场的时候也能够随时访问。
有了令牌,客户端就可以在访问受保护资源时出示令牌。客户端出示的令牌的方式有很多种,备受推崇的方式,使用Authorization头部
1 | GET /resource HTTP/1.1 |
受保护资源可以从头部解析出令牌,判断它是否有效,从中得知授权者是谁以及授权内容,然后返回响应。受保护资源检查令牌的方式有很多种,最简单的就是让授权服务器和资源服务器共享存储令牌信息的数据库。授权服务器在生成新的令牌时将其写入数据库,资源服务器在收到令牌时从数据库中读取它们。
这些组件分别负责OAuth协议的不同部分,并且相互协作使OAuth协议运转。
客户端:是代表资源拥有者访问受保护资源的软件,使用OAuth来获取访问权限,客户端通常是这系统中最简单的组件,它的职责主要是从授权服务器获取令牌以及在受保护资源上使用令牌。客户端不需要理解令牌,也不需要查看令牌的内容。相反,客户端只需要将令牌视为一个不透明的字符串即可。OAuth客户端可以是Web应用、原生应用,甚至是浏览器内的JavaScript应用。
受保护资源:通过HTTP服务进行访问,在访问时需要OAuth访问令牌,受保护资源需要验证收到的令牌,并决定是否响应以及如何响应请求,在OAuth架构中,受保护资源对是否认可令牌拥有最终决定权。在云打印的例子里面,照片存储网站就是属于受保护资源。
资源拥有者:有权将访问权限授权给客户端的主体,与OAuth系统中的其他组件不同,资源拥有者不是软件,在大多数情况下,资源拥有者是一个人,使用客户端软件访问受他控制的资源。至少在部分过程中,资源拥有者要使用Web浏览器(通常称为用户代理)与授权服务器交互。资源拥有者可能还会使用浏览器与客户端交互,但这完全取决于客户端性质。在云打印例子中,资源拥有者就是想要打印照片的最终用户。
OAuth授权服务器:是一个HTTP服务器,在OAuth系统中充当中央组件。授权服务器对资源拥有者和客户端进行身份认证,让资源拥有者向客户端授权、为客户端颁发令牌。某些授期服务器还会提供额外的功能,例如令牌内省、记忆授权决策。在云打印例子中,照片存储网络拥有自己的授权服务器,用户保护资源。
OAuth访问令牌,有时也被称为令牌,由授权服务器办法给客户端,表示客户端已被授予权限。OAuth没有定义令牌本身的格式和内容,但它总是代表着:客户端请求的访问权限、对客户端授予的资源拥有者,以及被授予的权限(通常包含一些受保护资源标志)。
令牌对客户端来说是不透明的,也就是说客户端不需要,通常也不能查看令牌内容。客户端要做的是持有令牌,向授权服务器请求令牌,并向受保护资源出示令牌。OAuth令牌并非对系统中所有组件都不透明:授权服务器的任务是颁发令牌,受保护资源的任务则是验证令牌。因此,它们都需要理解令牌本身,并知道其含义。然后客户端对这一切一无所知,这使得客户端简单地多,同时也使得授权服务器和受保护资源十分灵活地部署令牌。
表示一组访问受保护资源的权限,OAuth协议中使用字符串表示权限范围,可以用空格隔开的列表将它们合并成为一个集合。因此,权限范围的值不能包含空格,OAuth并没有规定权限范围值的格式和结构。
权限范围是一种重要机制,界定了客户端的权限范围。权限范围是由受保护资源根据其自身提供的API来定义的。客户端可以请求某些权限范围,授权服务器则运行资源拥有者在客户端发出请求时许可或者否决特定的权限范围。权限范围具有可叠加的特征。
云打印的例子,照片存储服务的API为照片定义了多种权限范围:read-photo/read-metadata/update-photo/update-metadata/create/delete。照片打印服务只要能读取照片就足以完成工作,所以它会请求read-photo权限范围。只要拥有一个该权限范围的令牌,就能读取并按要求打印出来。如果用户想要依据照片日期将照片打印成册的高级功能,则打印服务还需要read-metadata权限范围。由于这是一个额外的访问权限,照片打印服务则需要通过正常的OAuth流程来请求用户授予它这个额外的权限范围。只要照片打印服务拥有包含这两个权限范围的令牌,就能使用该令牌执行相应的操作。
OAuth刷新令牌在概念上与该令牌很相似,它也是由授权服务器颁发给客户端的令牌,客户端也不知道不关系该令牌的内容。但不同的是,该令牌从来不会被发送到保护资源。相反,客户端使用刷新令牌向授权服务器请新的访问令牌,而不需要用户参与。
为什么客户端需要刷新令牌?在OAuth中,访问令牌随时可能失效,令牌有可能被用户撤销,或者过期,或者其他系统导致令牌失效,客户端在使用时会受到错误响应。当然,客户端可以再次向资源拥有者请求权限,但是如果资源拥有者不在场呢?
在OAuth1.0客户端除了等资源拥有者回来重新授权之外别无他法。未避免这种情况,OAuth1.0的令牌玩玩会一直保持有效,直到被明确地撤销。这是有问题的,因此它增加了被盗令牌的攻击面:攻击者可以永远使用该令牌。OAuth2.0提供了让令牌自动过期的选项,但是需要在用户不在场的时候仍然能访问资源,现在刷洗令牌取代了永不过期的访问令牌,但它的作用不是访问令牌,而是获取新的访问令牌来访问资源。这种做法是一种独立但互补的方式,限制了刷新令牌和访问令牌的暴露范围。
刷新令牌还可以让客户端缩小它的权限范围,如果客户端被授予A/B/C三个权限范围,但是它知道某特定请求只需要A权限范围,则它可以使用刷新令牌重新获取一个仅包含A权限范围的访问令牌。这让足够智能的API可以遵循最小权限安全原则,但也不会给不那么智能的客户端带来负担,即无须查明某个API需要哪些权限。
如果刷新令牌失效了,用户在场,客户端可以随时劳烦用户再次授权,换句话说,客户端退回到了需要重新进行OAuth授权的状态。
授权许可是OAuth协议中的权限获取方法,OAuth客户端用它来获取受保护资源的访问权限,成功之后客户端会得到一个令牌。令牌表示用户授权所使用的特定方式,也表示授权这个行为本身。开发人员看到回传给客户端的授权码,有时候会误认为这个授权码就是授权许可,虽然授权码代表用户的授权决策,但不是授权许可本身。相反,整个OAuth流程才是授权许可:客户端将用户重定向至授权端点,然后接收授权码,最后用授权码换取令牌。
换句话说,授权许可就是获取令牌的方式。
OAuth是一个基于HTTP的协议,但是与大多数基于HTTP的协议不同,OAuth中的交互不总是简单的HTTP请求和响应来完成。
OAuth流程中的很多部分都是用标准的HTTP请求和响应格式来互相通信,由于这些请求通常都发生在资源拥有者和用户代理的可见范围之外,因此它们统称为后端信道通信。
这些请求和响应使用了所有常规的HTTP机制来通信:头部,查询参数、HTTP方法和HTTP主体都能承载对OAuth事务至关重要的信息。
授权服务器提供一个授权端点,供客户端请求访问令牌和刷新令牌。客户端直接向该端点发出请求,携带一组表单格式的参数,授权服务器解析并处理这些参数,然后授权服务器返回一个代表令牌的JSON对象
另外当客户端连接受保护资源的时候,它也是在后端信道上直接发出HTTP请求。这种连接的细节完全依赖受保护资源,因为OAuth能保护的API和系统种类繁多、风格各异。对于任何类型的受保护资源,都需要客户端出示令牌,并且受保护资源必须能理解令牌以及其代表的权限。
在标准HTTP通信中,HTTP客户端向服务器直接发送一个请求,其中包括头部、查询参数、主体以及其他信息。然后HTTP服务器可以查看这些信息,并决定如何响应请求,响应中包含头部、主体以及其他信息。然而,在OAuth中,在某些情况下两个组件是无法至二级相互发送请求和响应的。例如客户端与授权服务器的授权端点交互的时候。前端信道通信就是一种间接通信方法,它将Web浏览器作为媒介,使用HTTP请求实现两个系统间的间接通信。
这一技术隔离了浏览器两端的会话,实现了跨安全域工作。例如,如果用户需要向其中一个组件进行身份认证,并不需要将凭据暴露给另外一个系统。这样在保持信息隔离的情况下,仍然能让用户在通信中发挥作用。
两个互不交互的软件是如何实现通信的?前端信道通信是这样实现的:发起方在一个URL中附加参数并指示浏览器跳转到该URL。然后接收方可以解析该入站URL(由浏览器跳转来的),并使用其中包含的信息。之后,接收方可以将浏览器重定向至发起方托管的URL,并使用同样的方式在URL附加参数。这样两个软件就以Web浏览器为媒介,实现了间接通信。这样意味着每个前端信道的请求和响应实际上是一对HTTP请求/响应事务。
例如,授权码许可中,客户端需要将用户重定向至授权端点,但是也需要将其请求的内容信息传递给授权服务器。为此,客户端向浏览器发送一个HTTP重定向,这个重定向的目标是授权服务器的URL,并且其查询参数中附有特定参数。
1 | HTTP 302 Found |
授权服务器可以像处理一般的HTTP请求一样解析传入的URL,从参数中获取信息,这个环节中,授权服务器可以与资源拥有者进行交互,通过浏览器执行一系列HTTP事务,对资源拥有者进行身份认证并请求其授权。当需要给客户端返回授权码时,授权服务器也向浏览器返回重定向响应,但是这一次重定向的目标是客户端的redirect_uri。授权服务器也会在重定向的查询参数中附带信息。
1 | HTTP 302 Found |
浏览器执行这个重定向时,会向客户端应用发送一个HTTP请求,然后客户端可以解析请求中的参数,这样客户端和授权服务器就以浏览器为媒介实现了通信,而不用直接交互。
Web应用和原生应用都可以使用OAuth,但是都需要使用前端信道机制来接收授权端点返回的信息。前端信道通常需要使用到Web浏览器和HTTP重定向,但常规的Web服务器一般是不提供这些支持的。可以用内部Web服务器、应用专有的URI方案、使用后端服务向客户端推送通知等。只要能触发浏览器对该URI的调用即可。
所有通过前端信道传递的信息都可供浏览器访问,既能被读取,也可能在最终请求发出之前被篡改。OAuth协议已经考虑到了这一点,它限制了能通过前端信道传输的信息类别,并确保只要是通过前端信道传输的信息,就不能在授权任务中单独使用。上面的典型案例中,授权码不能被浏览器直接使用,相反它必须通过后端信道与客户端凭据一并出示。这有些协议中,比如OpenID Connect,要求客户端或授权服务器对前端信道中传输的消息签名,通过这样的安全机制增加一层保护。
虽然OAuth协议包含很多移动组件,但它将一些简单的操作组合起来,形成了一套安全的授权方法。
OAuth协议的焦点在于客户端如何获取令牌,以及如何使用令牌代表资源拥有访问受保护资源。在本章建立一个简单的OAuth客户端,使用授权码许可类型从授权服务器获取bearer令牌,并使用该令牌访问受保护资源。
OAuth客户端和授权服务器需要互相了解才能通信,OAuth协议本身不关心如何实现这一点。OAuth客户端由一个称为“客户端标识符”的特殊字符串来标识。OAuth协议的多个组件被称为client_id
,在给定一个授权服务器下,每个客户端的标识必须 唯一,因此,客户端标识几乎总是由授权服务器分配,这种分配可以通过开发者门户来完成,也可以使用动态客户端注册。
实例使用手动配置
1 | // client.js |
OAuth客户端要从授权服务器获取令牌,需要资源拥有者以某种形式授权。下面使用一种被称为授权许可类型的交互授权形式,由客户端将资源拥有者重定向至授权服务器的授权端点,然后,服务器通过redirect_uri
将授权码返回给客户端。最后,客户端将受到的授权码发送到授权服务器的令牌端点,换取OAuth访问令牌,再进行解析和存储。
1 | /** |
1 | const encodeClientCredentials = (clientId, clientSecret) => { |
每当有人访问 localhost:9000/callback
,客户端会接收收到的code值,并试图发送给授权服务器。这意味着攻击者有可能会用客户端向授权服务器暴力搜索有效授权码,浪费客户端和授权服务器资源,而且还有可能导致客户端获取一个从未请求过的令牌
可以使用名为state的可选OAuth参数缓解这个问题,将该参数设置为一随机值,应在应用中用变量保存,在丢弃旧的访问令牌后,创建一个state值
state = randomstring.generate();
需要将值保存起来,因为当通过回调访问 redirect_uri时,还要用到这个值。由于此阶段使用前端信道通信,因此重定向至授权端点的请求一旦发出,客户端应用就会放弃对OAuth协议流程的控制,直到该回调发生,还需要将state添加到通过授权端点URL发送的参数列表中
1 | const authorizeUrl = buildUrl(authServer.authorizationEndpoint, { |
当授权服务器收到一个带有state参数的授权请求时,必须总是该state参数和授权码一起原样返回客户端。意味着可以检查传入 redirect_uri
页面的state值,并与之前保存的值对比,如果不一致,则向最终用户提示错误
1 | const { |
如果state与所期望的值不一样,可能是会话固化攻击、授权码暴力搜索或者其他恶意行为。此时客户端会终止所有授权去请求处理,并向用户展示错误页面
受保护资源接收到有效的令牌后,会返回一些有用信息。客户端要做的就是使用令牌向保护资源发出调用请求,有3个合法的位置可用于携带令牌。在客户端中,使用HTTP Authorization头部来传递令牌。
得到的这种访问令牌叫做bearer令牌,它意味着无论是谁,只要持有就可以向保护资源出示,OAuth bearer令牌使用规范明确给出了发送令牌值的3种方法:
使用HTTP Authorization 头部、使用表单格式的请求体参数、使用URL编码的查询参数
由于另外两种方法存在一定的局限性,因此建议尽可能使用Authorization头部。在使用查询参数时,访问令牌的值有可能被无意地泄露到服务端日志中吗,因为查询参数是URL请求的一部分;使用表单的方式,会限制受保护资源只能接受表单格式的输入参数,并且要使用POST方法。如果要API已经按这样的限制运行了,那这种方法没有问题,毕竟不会面临与查询参数方法一样的安全局限。
使用Authorization头部是这3种方法中最灵活和最安全的。由于对客户端来说,使用起来很困难。一个健壮的OAuth客户端或者服务端库应该完整地提供这3种方式,以适应不同情况。
1 | app.get('/fetch_resource', (req, res) => { |
请求受保护资源,并将获取的响应数据渲染到页面,protectedResource变量设置了一个URL,将向该URL发送请求并期待返回JSON响应。使用OAuth定义的Authorization头发送令牌,将令牌设置为这个头部的值
1 | app.get('/fetch_resource', (req, res) => { |
OAuth2.0提供了一种无须用户参与的情况下最新访问令牌的方式:刷新令牌。用户在初次授权完成之后不会一直在场,而OAuth经常要在这样的情况下使用。
客户端如何才能知道自己的访问令牌是否有效,唯一的方法就是使用它,然后看结果。如果令牌具有预设的过期时间,授权服务器可以在令牌响应中使用一个可选的expires_in
字段来表示预设的有效期。这是一个从令牌发放到预设失效时间之间的秒数值,一个中规中矩的客户端应该会关注这个值,并将过期的令牌丢弃掉。
然后,仅仅指定过期时间还不足以让客户端掌握令牌的状态。很多OAuth实现中,资源拥有者可以在令牌过期之前将其撤销。一个设计良好的客户端应该始终能预料到访问令牌可能随时突然失效,并能做出反应。
可以提示用户重新授权并获取一个新的令牌,刷新令牌最初是与访问令牌在同一个JSON对象中返回给客户端的:
1 | { |
客户端将刷新令牌保存在 refresh_token
变量中。授权服务器启动前先清空数据库,再将刷新令牌自动插入数据库。没有插入对应的访问令牌,模拟访问令牌失效但刷新令牌仍有效
1 | let access_token = '987tghjkiu6trfghjuytrghj'; |
Oauth客户单是OAuth生态系统中使用最广泛的部分:
受保护资源,供客户端用访问令牌调用。对于大多数基于Web的API,增加OAuth安全层是一个轻量级的过程。资源服务器需要做的就是传入的HTTP请求中解析出OAuth令牌,验证该令牌,并确定它能用于哪些请求。
尽管受保护资源和授权服务器在概念上是OAuth系统的不同组件,但许多OAuth实现将二者放在一起。这种做法在两个系统耦合紧密下的情况下适用。下面的例子,会在同一台机器使用独立进程运行受保护资源,但是它能够访问授权服务器所用的数据库。
受保护资源接受OAuth bearer令牌,因为授权服务器生成的就是bearer令牌,OAuth bearer令牌使用规范定义了3种受保护传递 bearer令牌的方法:使用HTTP Authorization 头部、使用表单参数以及使用查询参数。首选 Authorization头部。
由于要在多个资源URL上执行此操作,会使用一个辅助函数来检查令牌。Express的方法第三个参数是 next,next是一个函数可以调用它来继续处理请求,使得可以使用多个函数处理单个请求,并把令牌检查功能添加到整个应用的请求处理流程中
1 | const getAccessToken = (req,res,next)=>{ |
OAuth bearer令牌使用规范规定,在使用HTTP Authorization头部传递令牌时,HTTP头的值以关键字 Bearer开头,后跟一个空格,再跟令牌值本身。而且,OAuth 规范还规定了 Bearer关键字不区分大小写。此外,HTTP规范还规定了Authorization头部关键字本身不区分大小写。这意味着下面的所有HTTP头都是等价的
1 | Authorization: Bearer xxx |
首先,尝试从请求中获取Authorization头部,然后检查是否包含OAuth bearer令牌,由于express自动将所有HTTP头名称转为小写,我们使用字符authorization检查传入的请求对象,还要在头部值转为小写后检查关键字 bearer
1 | let inToken = null; |
检查通过,将头部的bearer关键字和后面的空格去掉,获取令牌值。令牌值本身不区分大小写的,所以要从从初始字符串提取令牌,而不是转换之后的字符串中提取
处理通过表单参数传递令牌,表单参数在请求主体中。OAuth规范不推荐这种方法,因为它认为限制了API的输入只能是表单形式。如果API的本来输入载体是JSON格式,那么客户端就无法在请求主体中加入令牌。在这种情况下,使用Authorization头部才是首选。但是对于那些输入载体就是表单格式的API,这种方法既简单又能和API保持一致,而且不需要处理Authorization头部。
最后一种方法是通过查询参数传递令牌。建议在其他两种方法不能使用的时候才采用该方法。使用这种方法,访问令牌很可能被无意地记录在服务器访问日志中或者通过HTTP Referrer头泄露,它们会整体复制URL。然而,有时候客户端应用无法直接访问HTTP Authorization头部(受限于平台或库),也不能使用表单参数(比如HTTP GET 方法)。另外,这种方法不仅可以在URL中包含资源本身的定位符,而且还可以包含访问方法。在这些情况下,只要有适当的安全措施,OAuth运行客户端通过查询参数来传递令牌。
我们可以访问授权服务器用于存储令牌的数据库,这是小型OAuth系统中常用的配置方案,这样的系统将授权服务器与受保护的API放在一起。
例子的授权服务器使用了一个NoSQL数据库,它将数据库存储在磁盘上的文件中,通过一个简单Nodejs模块来访问。如果想实时查看程序运行时数据库的内容,可以监控联系目录的database.sql文件。
根据传入的令牌值执行简单的查找,从数据库中找出访问令牌,服务器将每一个访问令牌和刷新令牌分别作为单独的元素存储在数据中,所以只需要使用数据库的查询功能找出正确的令牌即可。查询函数的细节对于NoSQL数据库来说是特有的,但是其他数据库也会提供类似的查询方法
1 | nosql.one((token)=>{ |
传入的第一个函数将获取令牌与数据库中的访问令牌进行对比,如果发现匹配项,就会停止搜索并返回令牌。第二个函数会在发生匹配的令牌时或者数据库遍历到尽头被调用。如果在数据库找到令牌,它会被作为token参数传入,否则为null。无论找到什么,都将它赋值个req对象的access_token
成员,然后调用next函数,req对象会自动传递给处理函数的下一个处理步骤
返回的令牌对象与授权服务器在生成令牌时插入数据库的对象完全相同,例如,示例授权服务器会像下面这样将访问令牌以及权限范围保存在下一个JSON对象中
1 | { |
使用共享数据是一种非常常见的OAuth部署模式,但不是唯一的选择。有一个叫做令牌内省(token introspection)的Web协议,它可以有授权服务器提供接口,让资源服务器可以在运行时检查令牌的状态。这使得资源服务器可以像客户端那样将令牌本身视为不透明的。代价是使用而更加的网络流量。还有另一种方式:可以在令牌内包含受保护资源能够直接解析并理解的信息。JWT就是这样的一种数据结构,它可以使用受加密保护的JSON对象携带声明信息。
是否必须将令牌以原始值存储在数据中,虽然这是一种简单且常见的做法,也可以存储令牌的散列值,这种方式类似存储用户密码,在查询令牌时,要将令牌再次进行散列计算,并同数据库中内容进行比较。还可以将唯一标识符添加到令牌中,并使用服务器的密钥对它签名。在数据库中只存储这唯一的标识符。当需要查找这个令牌的时候,资源服务器可以验证签名,解析令牌得到标识符,然后在数据库中查找这个标识符对应的令牌信息。
接入服务,在Express应用中,有两个选择,一个是用于每个请求,二是只将它用于需要检查OAuth令牌的请求。为了将这一处理应用到每个请求,需要设置一个新的监听器。将令牌检查函数链接到处理流程中。令牌检查函数需要在路由中其他所有函数之前连接,因为这些函数是按照在代码中被添加的顺序来执行的。
1 | app.all('*',getAccessToken) |
另外可以将新函数插入已有的处理函数设置,让新函数先被调用。
1 | app.post('/resource',(req,res)=>{}) |
要让令牌处理函数先被调用,需要在路由的处理函数定义之前添加函数
1 | app.post('/resource',getAccessToken,(req,res)=>{}) |
当路由处理函数被调用时,请求对象会附加一个access_token
成员,如果令牌被找到,这个字段就会包含从数据库中取出的令牌对象。如果令牌未被找到,这个字段就会是null,需要根据情况判断
1 | if(req.access_token){ |
很多API设计中,不同的操作需要不同访问权限,还有一些API会根据授权者不同而返回不同的结果,或者根据不同权限返回某一部分信息。加一个 requireAccessToken
处理函数,会在令牌不存在时直接返回错误,在令牌存在时将控制权交给最终处理函数进行后续处理
1 | const requireAccessToken = (req,res,next)=>{ |
不同类型的操作需要不同的权限范围,才能使调用成功。这使得资源服务器可以根据客户端能执行的操作来划分功能。这也是单个授权服务器对应的多个资源服务器之间使用单个令牌的常用方法。
客户端有一个页面提供访问资源API的所有功能,读取显示并带上时间戳,用于资源服务器上添加新单词,删除功能等
应用中注册了三个路由,分别对应不同的动作,只要传入的令牌有效,无论什么类型,都会执行
1 | app.get('/words',getAccessToken,requireAccessToken,(req,res)=>{ |
现在,逐个修改它们,确保令牌中至少包含与各个功能对应的权限范围,鉴于在数据库中存储方式,需要获取令牌对应的scope
成员,对于GET功能,我们需要客户端拥有与之对一个的read权限范围,客户端还可以拥有其他权限范围
1 | app.post('/words', getAccessToken, requireAccessToken, (req, res) => { |
使用 WWW-Authenticate
头部返回错误,告诉客户端该资源需要接受一个OAuth bearer令牌,并且令牌中至少要包含read权限范围,才能调用成功。在另外两个函数中加入类似的代码,也会检查write,delete的权限范围,在任何情况下,即使令牌有效,只要权限范围不正确,就会返回错误:
1 | app.post('/words', getAccessToken, requireAccessToken, (req, res) => { |
这样一来,要为客户端指定不同的权限范围组合,需要重新对客户端应用授权
在这种风格的API设计中,同一个处理函数可以根据传入的令牌中包含的权限范围不同,而返回不同类别的信息。如果数据结构复杂,且希望通过同一个API端点为客户端提供多种信息子集的访问,这样设计就非常有用。
在受保护资源没有为不同的农产品类别提供多个独立的处理函数,而是在一个处理函数中处理对所有农产品的请求,这个处理函数返回的对象中包含所有种类的农产品列表
1 | app.get('/produce',getAccessToken,requireAccessToken,(req,res)=>{ |
在做修改之前,使用有效的令牌访问该API,会得到包含所有农产品的列表,如果对客户端授权让它得到访问令牌,但是不勾选任何权限范围,就会得到所有数据。
切分数据结构,将数据片段放入控制语句,检查每个农产品类别的权限范围
1 | const produce = { |
同一个处理函数可以根据授权客户端的用户不同的信息。这是一种常见的API设计方式,使得客户端应用在不知道用户是谁的情况下,调用同一个URL也能获取个性化的结果。虽然客户端与受保护资源之间建立的连接上并没有资源拥有者的登录或者身份认证信息,但是生成的令牌中包含资源拥有者的信息,资源拥有者需要在授权批准的环节进行身份认证。
授权服务器的批准页面会选择替哪个用户授权,通常这一步是通过授权服务器对资源拥有者进行身份认证来完成的,而且一般认为运行一个未经身份认证的用户随意冒充任何人是极不安全的做法。
我们要做的就是根据授权者是谁来返回对应的数据,授权服务器已经讲资源拥有者的用户名存在访问令牌记录的user字段中,根据这个字段确定返回内容
1 |
|
在授权服务器上以Alice或者Bob名义授权了客户端,就会在客户端上得到他们的个性化数据。
在OAuth处理流程中,客户端绝不知道与之交互的是Alice还是Bob或者其他的。客户端只是碰巧知道了Alice的名字,因为它调用的API的响应包含了她的名字,而这个人信息也很容易被去掉。这是一个重要的设计模式,因为它可以避免不必要的暴露资源拥有者的个人身份信息,从而保护隐私。如果与分享信息的API结合起来,可以用OAuth构建一个身份认证协议。
使用OAuth能对受保护资源实现的访问控制远不止上面那些,而且当今使用OAuth的受保护资源都有各自的应用模式。因此,OAuth并不插手授权决策的过程,而只通过使用令牌和权限范围充当授权信息的载体。这样的设计思路使得OAuth广泛应用于互联网上各种类型的API.
资源服务器可以根据令牌及其附属信息(如权限范围)直接作出授权决策。资源服务器还可以将访问令牌中的权限范围与其他访问控制信息结合起来,用于决定是否响应API调用已经响应什么内容。例如,资源服务器可以限制特定的客户端和用户只能在特定的时间段内访问资源,无论令牌是否有效。资源服务器甚至可以以令牌作为输入,调用外部策略引擎,以实现组织内对复杂授权规则的集中管理。
在任何情况下,资源服务器都对访问令牌的含义拥有最终决定权,不管资源服务器外包了多少决策过程,最终都由它来决定如何处理给定请求。
使用OAuth保护Web Api非常简单:
上面构建了OAuth客户端应用,可以从授权服务器获取令牌,并使用令牌访问受保护资源,还构建了一个供用户访问的受保护资源,下面构建了一个简单的授权服务器,支持授权码许可类型,这个组件要管理客户端,执行OAuth核心的授权操作,还要向客户端颁发令牌
授权服务器是OAuth生态系统中最复杂的组件,是整个OAuth系统的安全权威中心。只有授权服务器能够给用户进行身份认证,注册客户端,颁发令牌。在OAuth2.0规范的制定过程中,已经尽可能将复杂性从客户端和受保护资源转移到授权服务器。这很大程度是由各个组件的数量决定的:客户端的数量远多于受保护资源的数量,受保护资源的数量又远多于授权服务器的数量。
操作系统分为内核和用户空间,内核启动后,会挂载root文件系统为其提供用户空间支持。docker镜像就相当于是一个root文件系统。
是一个特殊文件系统,除了提供容器运行所需的程序、库、资源、配置等文件外,还包含一些为运行准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不会包含任何动态数据,其内容在构建之后也不会被改变
镜像包含操作系统完整的root文件系统,体积庞大,在设计docker时,将其设计为分层存储架构。严格来说,镜像并非像一个ISO那要的打包文件,镜像只是一个虚拟概念,实际体现并非由一个文件组成,而是由一组文件系统组成,或说是多层文件系统联合组成。
镜像构建时,会一层层构建,前一层是后一层的基础,每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己的这一层。例如删除前一层文件的操作并不是真的删除前一层文件,而是仅在当前层标记为该文件已珊瑚虫。在最终容器运行时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉
分层存储的特性还使得镜像的复用、定制变得更加容易,甚至可以使用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像
镜像和容器的关系,就像是面向对象程序中的类和实例一样,镜像是静态的定义,容器是镜像运行时的实体。容易可以被创建、启动、停止、删除、暂停等。
容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行属于自己的独立的命名空间。因此容器可以拥有自己的root文件系统、自己的网络配置、自己的进程空间,设置自己的用户ID空间。容器内的进程是运行在一个隔离的环境里面,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性使得容器封装的应用比直接在宿主运行更加安全。因为这种隔离特性,常常会混淆容器和虚拟机。
容易也是分层存储,每一个容器运行时,是以镜像为基础层,在其上创建一个当前容器的存储层,称这个为容器运行时读写而装备的存储层为容器存储层。
容器存储层的生存周期和容器一样,容器消亡时,容器存储层随之消亡。任何保存于容器存储层的信息都会随容器删除而丢失。
按照最佳实践的要求,容器不应该向其存储层内写入任何数据,容器存储层要保持无状态化。所有文件写入操作,都应该使用数据卷、或者绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高。
数据卷的生存周期独立容器,使用数据卷后,容器删除或者重新运行之后,数据不会丢失。
镜像构建后,容易在当前宿主机上运行,但是如果需要在其他服务器上使用这个镜像,就需要一个集中的存储、分发镜像的服务,docker register就是这样的服务。一个docker regisert中可以包含多个仓库,仓库可以包含多个标签Tag,每个标签对应一个镜像。
通常一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本。可以通过<仓库名>:<标签>
的格式指定具体这个软件哪个版本的镜像。如果不给出标签,将以lasest作为默认版本。
docker pull [选项] [Docker Register 地址[:端口号]/]仓库名[:标签]
可通过docker pull --help
命令看到。镜像名称格式:
<域名/IP>[:端口号]
默认地址是Docker Hub<用户名>/<软件名>
。对于Docker Hub不给出用户名,默认就是 library,也就是官方镜像例如:
1 | [1;32mdocker@default[0m:[1;34m~[0m |
上面命令没有给出docker镜像仓库地址,会从Docker Hub获取镜像,镜像名称是ubuntu:16.04
,因此获取官方镜像library/ubuntu
,仓库中标签为 16.04的镜像
下载过程中可以看出分层存储概念,镜像由多层存储所构成,下载也是一层层的去下载,并非单一文件。下载过程中给出每一层ID的前12位,并且下载结束后,给出该镜像sha256的完整摘要,以确保下载一致性
以镜像为基础启动并运行一个容器,启动里面的bash并镜像交互操作
1 | [1;32mdocker@default[0m:[1;34m~[0m$ [Kdocker run -it --rm ubuntu:16.04 bash |
docker run
是容器运行的命令:
-it
:这是两个参数,也给是-i
,交互式操作,一个是-t
终端。进入bash
执行一些命令并查看返回结果,因此需要交互式终端--rm
:容器退出后随之将其删除。默认情况下,为了排障需求,退出的容器并不会立即删除,除非手动docker rm
。这里使用 --rm
可以避免浪费空间,只是随便执行个命令,查看结果,不需要排障和保留结果ubuntu:16.64
:这是用这个镜像为基础来启动容器bash
:在镜像后的是命令,希望有个交互式的shell,使用的是bash
docker image ls
1 | [1;32mdocker@default[0m:[1;34m~[0m$ docker image ls |
列表包含了仓库名、标签、镜像ID、创建时间以及所占空间。
镜像ID是镜像的唯一标识,一个镜像可以对应多个标签。
这里标识所占的空间和在Docker Hub上看到的镜像大小不一样,这是因为Docker Hub中显示的体积是压缩后的体积。在镜像下载和上传过程中镜像总是保持着压缩状态,因此Docker Hub中显示的大小是网络传输中更关心的流量大小,而docker image ls
显示的是镜像下载到本地后展开的大小,是展开后的各层所占空间的总和,因此镜像到本地后,查看空间的时候,更关心的是本地磁盘空间所用的大小。
列表中的镜像体积总和并非是所有镜像实际硬盘消耗,由于docker 镜像是多层存储结构,并且可以继承、复用,因此不同镜像可能会因为使用相同的基础镜像,从而拥有共同的层。由于Docker使用UnionFS,相同层只需要保存一份即可,因此实际镜像硬盘占用空间很可能要比这个列表镜像大小的总和要小的多。
使用 docker system df
查看镜像、容器、数据卷所占用的空间
1 | [1;32mdocker@default[0m:[1;34m~[0m$ docker system df |
有时候会特殊镜像,既没有仓库名,也没有标签,为none
1 | <none> <none> 09089dhs989 5 days ago 23MB |
这些镜像原来是镜像名和标签的,随着官方镜像维护发布新版本,重新pull这个镜像,镜像名被转移到了新下载的镜像上,而旧的镜像上的名称则被取消,从而成为了<none>
。docker build也可能导致这样的问题。由于新旧镜像同名,旧镜像名称被取消,从而仓库名、标签均为<none>
。这类无标签镜像也被成为虚悬镜像(dangling image),可以用docker image ls -f dangling=true
显示这些镜像
虚悬镜像也是失去了存在价值,可以随意删除,用docker image prune
删除
为了加速镜像构建、重复利用资源,Docker会利用中间镜像,在使用一段时间后,会看到一些依赖的中间镜像。默认的docker image ls
列表只会显示顶层镜像,如果希望显示中间层镜像在内的所有镜像的话,用docker image ls -a
出现无标签镜像,与虚悬镜像不同,这些镜像都是中间层镜像,是其他镜像所依赖的镜像,删除这些镜像会导致上层镜像因为依赖丢失出错。删除那些依赖它们的镜像后,这些依赖的中间层镜像也会被连带删除
1 | 根据仓库名 |
1 | 将所有镜像的ID列出 |
1 | docker image rm [选项] <镜像1>[<镜像2>...] |
ID、镜像名、摘要删除镜像
镜像可以是短id,长id,镜像名或者镜像摘要,短id取前3个字符以上,能区分别的镜像就可以
1 | docker image rm 502 |
删除行为分为两种,一种是untagged,一种是deleted。镜像的唯一标识是其ID和摘要,而一个惊喜可以有多个标签。当我们删除指定标签后,可能还有别的标签指向这个镜像,那么delete行为不会发生,会显示untagged信息。
当该镜像所有标签都被取消了,改镜像可能就失去了存在的意义,因此会触发删除行为。镜像是多层存储结构,在删除的时候也是从上层向基础层方法依次进行判断删除。镜像的多层结构使得复用变动镜像容易,因此很有可能某个其他镜像正依赖当前镜像的某一层。这种情况不会触发删除该层行为。直到没有任何层依赖当前层时,才会真实删除当前层。
除了镜像依赖还有容器对镜像的依赖。如果有用这个镜像启动的容器存在,即使容器没有运行,那么同样不可以删除这个镜像。容器是以经为基础,再加一层容器存储层,组成这样的多层存储结构去运行。因此该镜像如果被这个容器所依赖,那么删除必然会导致故障。如果这些容器是不需要的,应该先将它们删除,然后来删除镜像。
镜像是容器的基础,每次执行docker run的时候都会指定哪个镜像作为容器运行的基础。当Docker Hub的镜像无法满足需求时,我们就需要定制镜像。
定制Web服务器为例子
1 | nginx镜像启用一个容器,命名为webserver,映射80端口,在浏览器可以访问这个nginx服务器 |
修改了容器文件,也就是改动了容器的存储层,通过 docker diff
可以看到具体的改变
定制好变化后,保存下来形成镜像。当我们运行一个容器(不使用卷),我们做的任何文件修改都会被记录于容器存储层里。而docker提供了docker commit命令,可以将容器的存储层保存下来成为镜像。在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像。以后我们运行这个新镜像的时候,就会拥有原来容器最后的文件变化
docker commit [选项] <容器ID或容器名> [<仓库名>[:<标签>]]
1 | 将容器保存为镜像 |
接着
1 | 查看新定制的镜像并使用,也可以使用docker history具体查看镜像内的历史记录 |
使用 docker commit 命令可以直观帮助理解镜像分层存储概念,但是实际环境不会这么做。
仔细观察docker diff webserver的结果,会发现除了修改的文件外,由于命令的执行,还有很多文件被改动或者添加了,这仅仅是最简单的操作,如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,如果不小心清理,将会导致镜像尤为臃肿。
此外,使用docker commit都是黑箱操作。只有制作者才知道做了什么,docker diff可以看出一些线索,但不能保证生成一致的镜像。
dockerfile是一个文本文件,其内包含了一天天的指令(Instruction),每一条指令构建一层,因此每一条指令的内容就是描述该层应当如何构建
在一个空白目录,建立一个文件夹,并命名为Dockerfile
1 | mkdir myngix |
定制镜像是以一个镜像为基础,在其上进行定制,FROM就是指定基础镜像,因此FROM是Dockerfile文件的第一条指令,除了docker hub现有的镜像,还有一个特殊的镜像 scratch
,虚拟镜像,表示一个空白的镜像
FROM scratch
以它为基础的话,意味着不以任何镜像为基础,接下来写的指令作为镜像的第一层开始存在。不以任何系统为基础,直接将可执行文件复制进镜像的做法不罕见,对于Linux下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接FROM scratch会让镜像体积更加小巧。使用GO语言开发的引用会使用这种方式,这也是为什么说Go是特别适合容器微服务架构的语言的原因之一
RUN指令是定制镜像最常见的指令之一。格式有两种:
shell格式:RUN<命令>
,就像直接在命令行输入的命令一样
exec格式:RUN["可执行命令","参数1","参数2"]
,这更像是函数调用中格式
1 | FROM debian:jessie |
dockerfile中每个指令都会建立一层,RUN也不例外,每一个RUN的行为都会新建一层,在其上执行结果后,commit一层的修改,构成新的镜像,上面这种写法创建了7个镜像,这是完全没有意义的,运行时很多不需要的东西都被装进了镜像,比如编译、更新的软件包等,结果就是产生臃肿、非常多层的镜像,不仅增加了构建时间,也容易出错。UnionFS是有最大层数限制的,比如AUFS,曾经是最大不超过42层,现在是不超过127层。
上面的Dockerfile正确写法应该是:
1 | FROM debian:jessie |
之前所有命令的目的只有一个,就是编译、安装redis可执行文件。因此没有必要建立多层,这里仅使用一个RUN,并使用 &&
将各个需要命令串联起来,将之前的7层变成1层。并且为了格式化还进行了换行。Dockerfile支持Shell类的行尾添加 \
的命令行,以及行首 #
进行注释的格式。良好的格式会让排障更加容易。
此外,上面的命令还进行了清理工作,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了apt缓存文件,在镜像构建过程要确保每一层只添加真正需要添加的东西,任何无关的东西都应该被清理掉。
在Dockerfile文件所在目录执行:
1 | docker@default:~/mynginx$ docker build -t nginx:v3 . |
docker build
格式:
docker build [选型] <上下文路径/URL/->
docker build 命令最后一个 .
,代表当前目录,而Dockerfile就在当前目录,所以会误以为这个路径是指定Dockerfile所在路径,这样的理解不准确,其实这是在指定上下文路径。
docker build的工作原理:Docker在运行时分为Docker引擎(也就是服务端守护进程)和客户端工具。Docker的引擎提供了一组REST API,被称为Docker Remote API,而docker命令这样的客户端工具,则是通过这组API与Dokcer引擎交互,从而完成各种功能。因此我们表面上好像在本机执行各种docker功能,实际上,一切都是使用的远程调用形式在服务端(Docker引擎)完成。也因为这种C/S设计,让我们操作远程服务器的Docker引擎变得轻而易举。
当我们进行镜像构建时,并非所有的定制都会通过RUN指令完成,经常会需要将一些本地文件复制进镜像,比如通过COPY指令,ADD指令。而docker build命令构建镜像,并非在本地构建,而是在服务端,也就是Docker引擎中构建。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?
这就引入了上下文概念,当构建的时候,用户会指定构建镜像上下文路径,docker build命令得知这个路径后,会将路径下的所有内容打包,然后上传给Docker引擎,这样Docker引擎收到这个上下文之后,展开就会获得构建镜像所需要的一切文件。
例如在Dockerfile这样写:
COPY ./package.json /app/
这并不是要复制执行docker build命令所在的目录下的package.json,也不是复制Dockerfile所在目录下的package.json,而是复制上下文context目录下的package.json
因此,COPY这类指令中的源文件的路径都是相对路径,如果真的需要那些文件,应该将它们复制到上下文目录中去。观察docker build输出,可以看到这个发送上下文的过程:
1 | docker@default:~/mynginx$ docker build -t nginx:v3 . |
理解构建上下文对于镜像构建很重要,避免犯一些不应该的错误,比如初学者在发现 Copy/opt/xxxx /app
不工作后,干脆把Dockerfile放到了硬盘跟目录去构建,结果发现docker build执行后,在发送一个几十GB的东西,极为缓慢而且容易构建失败,那是因为这种做法是让docker build打包整个硬盘,这显然是使用错误。
一般来说,应该会将Docker置于一个空目录,或者根目录下面,如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给Docker引擎,那么可以用.dockerignore
剔除不需要作为上下文传递给Docker引擎的。
在默认情况下,如果不额外指定Dockerfile的话,会将上下文目录下的名为Dockerfile的文件作为Dockerfile,实际上不要求名字必须为Dockerfile,并且不要求必须位于上下文目录中,比如用 -f ../Dockerfile.php
参数指定某个文件作为 Dockerfile
docker build还支持从URL构建,比如从 Git repo
1 | 指定构建所需要的git repo,并且指定默认的master分支,构建目录为/814/,然后docker就会自己去git clone这个项目、切换到指定分支,并进入到指定目录后开始构建 |
1 | docker build http://server/context.tar.gz |
如果给出的URL不是Git repo,而是个tar压缩包,那么Docker引擎会下载这个包,并自动解压,以其作为上下文,并且构建。
docker build - < Dockerfile
或者
cat Dockerfile | docker build -
如果标准输入传入的是文本文件,则将其视为Dockerfile,并开始构建。这种形式由于直接从标准输入中读取Dockerfile的内容,没有上下文,因此不可以像其他方法那样可以将本地文件COPY进镜像之类的事情
docker build - < context.tar.gz
如果发现标准输入的文件格式是 gzip/bzip2/xz的话,将会使其成为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。
格式:
COPY <源路径>...<目标路径>
COPY["<源路径1>",..."<目标路径>"]
和RUN指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用
COPY指令将从构建上下文目录中<源路径>
的文件/目录复制到新的一层的镜像内的<目标路径>
位置,比如:
1 | COPY package.json /usr/src/app/ |
源路径可以是多个,甚至是通配符,其通配符规则要满足GO的filepath.Match规则,如:
1 | COPY hom* /mydir/ |
<目标路径>
可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作路径可以用WORKDIR指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
此外,还有一点使用COPY指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用,特别是构建相关文件都在使用Git进行管理时。
ADD指令和COPY的格式和性质基本一致,但是在COPY基础上增加了一些功能。
比如 <源路径>
可以是一个URL,这种情况下Docker引擎会视图去下载这个链接的文件放到 <目标路径>
去。下载后的文件权限自动设置为600,如果这并不是想要的权限,还要增加额外的一层RUN进行权限调整。另外,如果下载但是个压缩包,需要解压缩,也一样需要额外的一层RUN指令进行解压缩。所以不如直接使用RUN,然后用wget或者curl工具下载,处理权限、解压缩、然后清理无用文件更合理。因此这个功能其实并不好用,而且不推荐使用
如果<源路径>
为一个tar压缩文件,压缩格式为gzip,bzip2以及xz的情况下,ADD指令将会自动解压缩这个压缩文件到 <目标路径>
去。
在某些情况下,这个自动解压功能非常有用,比如官方镜像ubuntu中
1 | FROM scratch |
在某些情况下,希望复制压缩文件而不解压就不用ADD命令了,在Dockerfile最佳实践文档中要求,尽可能使用COPY,因为它的语义很明确,就是复制文件而已,而ADD则包含了更复杂的功能,其行为也不一定很清晰。最适合用ADD的场合就是所提及的需要自动解压缩的场合。ADD指令会令镜像构建缓存失败,从而可能会令镜像构建变得比较缓慢。
因此COPY与ADD指令选择可以遵循这样的原则,所有文件复制均使用COPY指令,仅在需要自动解压缩的时候使用ADD
CMD 指令格式和 RUN相似,也是两种格式:
<命令>
Docker不是虚拟机,容器就是进程,在启动容器的时候,需要指定所运行的程序及参数。CMD指令就是用指定默认的容器主进程的启动命令。
在运行时可以指定新的命令来提点镜像设置中的这个默认命令,比如ubuntu镜像默认的CMD是/bin/bash
,如果我们直接docker run -it ubuntu的话,会直接进入bash,也可以在运行的时候指定运行别的命令,如 docker run -it ubuntn cat /etc/os-release
,这里就是使用cat /etc/os-release
命令替换了默认的 /bin/bash
命令,输出了系统版本信息。
在指令格式上,一般推荐 exec格式,这类格式在解析时会被解析为JSON数组,因此一定要使用双引号,而不是单引号。使用shell的话,实际的命令会被包装为sh-c的参数的形式进行执行,比如:
CMD echo \$HOME =》 CMD[“sh”,”-c”,”echo \$HOME”]
这就是为什么可以使用环境变量的原因,因此这些环境变量会被shell进行解析处理。
Docker不是虚拟机,容器的应用都应该是以前台运行,而不是像虚拟机、物理机用upstart/systemd
去启动后台服务,容器没有后台服务概念。一些人将CMD写为:
CMD service nginx start
然后发现容器执行后立即退出了,甚至在容器内去使用systemtcl命令结果却发现根本执行不了。
对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就是去了生存的意义,从而退出,其他辅助进程不是它需要关心的东西。
而是用 service nginx start命令,则是希望upstart来以后台守护进程形式启动nginx,CMD service nginx start实际上是 CMD["sh","-C","service nginx start"]
,因此主进程实际上是sh,当 service nginx start命令结束后,sh也就结束了,sh作为主进程退出,自然容器退出。
正确的做法是直接执行nginx可执行文件,并且要求前台形式运行:
CMD [“nginx”,”-g”,”daemon off”;]
与RUN指令一样,分为exec和shell格式。目的和CMD一样,都是在指定容器启动程序及参数。ENTRYPOINT在运行时也可以替代,不过比CMD要略繁琐,需要通过 docker run –entrypoint指定。
指定ENTRYPOINT后,CMD的含义就发生了变化,不再是直接的运行其命令,而是将CMD的内容作为参数传给ENTRYPOINT指令:
“ “
有了CMD后,为什么还要有ENTRYPOINT?
假设我们需要一个得知自己当前公网IP的镜像,可以使用CMD实现
1 | FROM ubuntu:16.04 |
使用 docker build -t myip .
来构建镜像的话,如果查询当前公网的ip,只需要执行
1 | docker run myip |
现在把镜像当做命令使用,如果命令有参数要怎么加上?CMD中实际的命令是curl,如果希望展示HTTP头部信息,就需要加上 -i
参数
1 | docker run myip -i |
可以看到执行文件找不到的报错,executable file not found
。在镜像后面的是 command,运行时会替换CMD的默认值,这里的-i
替换了CMD,而不是加在 curl -s http://ip.cn
后面。而 -i
不是命令,所以自然找不到。
如果希望加入 -i
这参数,就必须重新完整的输入这个命令
1 | docker run myip curl -s http://ipinfo.cn -i |
这显然不是很好的解决方案,使用ENTRYPOINT可以解决这个问题:
1 | FROM ubuntu:16.04 |
运行 docker run myip -i
1 | docker@default:~/myip$ docker run myip |
当存在ENTRYPOINT后,CMD的内容将会作为参数传给ENTRYPOINT,而 -i
就是新的CMD,因此会作为参数传给curl。
启动容器就是启动主进程,有时候在启动主进程前,需要一些准备工作。
例如mysql类的数据库,需要一些数据库配置、初始化工作,这些工作要在mysql服务器运行前解决。
另外,希望避免使用root用户去启动服务,从而提高安全性,而在启动前还需要以root身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其他命令依旧可以使用root身份执行,方便调试。
这些准备工作与容器CMD无关的,无论CMD为什么,都需要事先进行一个预处理的工作,这种情况可以写一个脚本,放入到ENTRYPOINT中去执行,而这个脚本也会将接收到的参数也就是CMD的命令作为命令,在脚本最后执行,官方镜像redis就是这样做的
1 | FROM alpine:3.4 |
可以看到为了redis服务创建redis用户,并在最后指定了ENTRYPOINT为docker-entrypoint.sh脚本
1 | !/bin/sh |
脚本的内容根据CMD的内容来判断如果是redis-server就切换到redis用户身份去启动服务器,否则使用root身份执行
1 | docker run -it redis id |
格式:
<key><value>
<key1>=<value> <key2>=<value2>
设置环境变量,无论是后面的其他指令RUN,还是运行时的引用,都可以直接使用这里定义的环境变量
1 | ENV VERSION=1.0 DEBUG=on \ |
定义了环境变量,在后续的指令里面就可以使用这个环境变量,比如官方node镜像Dockerfile中妈就有类似这样的代码
1 | ENV NODE_VERSION 7.2.0 |
定义了NODE_VERSION环境变量,其后RUN这层,多次使用$NODE_VERSION
来进行操作定制。可以看到,升级镜像构建版本的时候,只要更新7.2.0既可,构建维护变得更轻松了
下列的指令可以支持环境变量展开:
ADD/COPY/ENV/EXPOSE/LABEL/USER/WORKDIR/VOLUME/STOPSIGNAL/ONBUILD
格式:ARG<参数名>[=<默认值>]
构建参数和ENV的效果一样,都是设置环境变量,不同的是,ARG所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用ARG保存密码之类的信息,因为docker history还是可以看到所有值的。
Dockerfile中ARG指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令docker build中用 --build-arg<参数名>=<值>
来覆盖。
在1.13之前的版本,要求 --build-arg
中的参数名,必须在Dockerfile中用ARG定义过了,如果没有则会报错退出构建。在1.13之后的版本,只是显示警告信息,并继续构建。这对于使用CI系统,用同样的构建流程构建不同的Dockerfile的时候比较有帮助,避免构建命令必须根据每个Dockerfile的内容修改
格式为:
["<路径1>","路径2"...]
<路径>
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库需要保存动态数据的应用,其数据库文件应该保存于卷中,为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在Dockerfile中,我们实现指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据
VOLUME /data
这里的 /data
目录就会在运行时自动挂载为匿名卷,任何向 /data
中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。运行时也可以覆盖这个挂载设置:
docker run -d -v mydata:/data xxx
使用了 mydata这个命令卷挂载到了 /data
这个位置,替代了Dockerfile中定义的匿名卷的挂载配置
格式为:EXPOSE<端口1>[<端口2>...]
EXPOSE指令是声明运行时容器提供服务端口,在运行时并不会因为这个声明应用就会开启这个端口的服务,在Dockerfile中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个好处就是运行时使用随机端口映射时,也就是docker run -P时,会自动随机映射EXPOSE端口
在早期Docker版本中还有一个特殊用处,以前所有的容器都是运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个Docker引擎参数 --icc=false
,当指定该参数后,容器间将默认无法互访,除非互相使用了--links
参数的容器才可以互通,并且只有镜像中EXPOSE所声明的端口才可以被访问。这个 -icc=false
的用法在引入了docker network后已经基本不用了,通过自定义网络可以轻松实现容器间的互联与隔离。
要将 EXPOSE和在运行时使用 -p <宿主端口>:<容器端口>
区分开,-p
是映射宿主端口和容器端口,也就是将容器的对应端口服务公开给外界访问,而EXPOSE仅仅是声明容器打算使用什么端口而已,并不会自动在宿舍进行映射。
格式:WORKDIR<工作目录路径>
使用WORKDIR指令可以来指定工作目录(或者称为当前目录),以后各层的目录就被改成指定目录,如该目录不存在,WORKDIR就会帮你建立目录
错误例子:
RUN cd /app
RUN echo “hello” > world.txt
把Dockerfile当做Shell脚本来书写,构建镜像运行后,会发现找不到 /app/world.txt
文件,或者其内容不是hello.在shell中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响下一个命令;而在Dockerfile中,这两行RUN命令的执行环境根本不同,是两个完全不同的容器,这是对Dockerfile构建分层存储的概念不了解所导致的错误。
如果需要改变各层的工作目录位置,应该使用WORKDIR指令
格式:USER<用户名>
USER指令和WORKDIR相似,都是改变环境并影响以后的层,WORKDIR是改变工作目录,USER则是改变之后层的执行RUN,CMD以及ENTRYPOINT这类命令的身份
和WORKDIR一样,USER只是帮助切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换
1 | RUN groupadd -r redis && useradd -r -g redis redis |
如果以root执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程。不要使用su或者sudo,这些都需要比较麻烦的配置,而且在TTY缺失的环境下经常出错,建议使用 gosu
1 | # 建立 redis用户,并使用 gosu换另一个用户执行命令 |
格式:
[选项] CMD <命令>
,设置检查容器健康状态的命令在没有HEALTHCHECK指令前,Docker引擎只可以通过容器内主进程是否退出来判断容器是否状态异常,很多情况下没问题,但是如果程序进入死锁状态,或者死循环,应用程序不会退出,但是该容器已经无法提供服务了。在1.12前,Docker不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务却还在接受用户请求。
在1.12之后,Docker提供了这个指令,通过改指令指定一行命令,用这行命令来判断容器主进程服务状态是否还正常,从而比较真实的反应容器实际状态。
当在一个镜像指定了HEALTHCHECK指令后,用其启动容器,初始状态为starting,在HEALTHCHECK指令检查成功后变为healthy,如果连续一定次数失败,则会变为unhealthy
支持系列选项:
–interval=<间隔>:两次健康检查的间隔,默认为30s
–timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查视为失败,默认30s
–retries=<次数>:当连续失败指定次数后,则容器状态为unhealthy,默认3次
和CMD/ENTRYPOINT一样,HEALTHYCHECK只出现一次,如果写了多个,只有最后一个生效。
在HEALTHYCHECK [选项] CMD
后面命令,格式和ENTRYPOINT一样,分为shell,和exec。命令的返回值决定了该次健康检查的成功与否:0为成功1是失败,2保留,不要使用这个值
假设我们有个镜像简单的Web服务,希望增加健康检查判断Web服务是否正常工作,用curl判断,dockerfile可以这么写:
1 | FROM nginx |
设置了每5秒检查一次,如果健康检查超过3秒没响应就是视为失败。构建并启动容器
1 | docker build -t myweb:v1 |
格式:ONBUILD<其他指令>
ONBUILD是一个特殊的指令,后面跟着的是其他指令,例如RUN/COPY等,而这些指令,在当前镜像构建时不会被执行,只有当以当前镜像为基础,去构建下一个镜像才会执行
Dockerfile中的其他指令都是为了定制当前镜像而准备的,ONBUILD是为了帮助别人定制自己准备的。Nodejs使用npm管理,拿到程序后,一般会先安装依赖,然后启动,一般会这么写Dockerfile
1 | FROM node:slim |
把这个Dockerfile放到Nodejs的根目录,构建好镜像后,就可以直接拿来启动容器运行,但是如果有第二个第三个差不多的项目,随着文件附件越多,版本控制就越难。
如果在第一个Nodejs项目发现了Dockerfile存在的问题,比如敲错字,或者需要安装额外的包,然后开发人员修复了这个Docker,再次构建,问题解决。但是其他项目呢?虽然最初Dockerfile是复制、粘贴自第一个项目,但是并不会因为第一个项目修复了Dockerfile就会自动修复
那么我们可以做一个基础镜像,然后各个项目使用这个基础镜像。镜像更新后就不用同步Dockerfile的变化,重新构建后就继续了基础镜像的更新:
1 | FROM node:slim |
这里把项目相关的构建指令拿出来,放在子项目去。假设这个基础镜像为my-node,各个项目内的Dockerfile就变成:
1 | FROM my-node |
基础镜像变化后,各个项目都用这个Dockerfile重新构建镜像,会基础基础镜像的更新。
但是问题并没有完全解决,这个Dockerfile如果有东西要修改,又要重新一个一个修改。这样制作镜像只解决了原来Dockerfile的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理
ONBUILD可以解决这个问题:
1 | FROM node:slim |
在构建镜像的时候,这三行并不会被执行,然后各个项目的Dockerfile就变成
1 | FROM my-node |
当在各个项目目录中,用这个只有一行的Dockerfile构建镜像时,之前基础镜像的那三行ONBUILD就会执行,成功的将当前项目的代码复制进镜像,并且针对本项目进行npm install,生成对应镜像
在17.05之前,构建Docker镜像通常会采用两种方式
一种方式是将所有的构建过程编包含在一个Dockerfile中,包括项目以及依赖库的编译、测试、打包等流程,这里可能会有一些问题:
例如:编写app.go文件,输出Hello World!
1 | package main |
编写Dockerfile.one文件
1 | FROM golang:1.9-alpine |
构建镜像
1 | docker build -t go/helloworld:1 -f Dockerfile.one |
另一种方式,就是事先在一个Dockerfile将项目以及其依赖库编译测试打包好后,再将其拷贝到运行环境中,这种方式需要编写两个Dockerfile和一些编译脚本才能将两个阶段自动整合起来,这种方式虽然可以很好规避第一种方式存在的风险,但明显部署过程比较复杂
Dockerfile.build文件
1 | FROM golang:1.9-alpine |
Dockerfile.copy文件
1 | FROM alpine:latest |
新建build.sh
1 | !/bin/sh |
运行脚本即可构建镜像
1 | chmod +x build.sh |
对比两种生成的镜像大小
1 | docker image ls |
为了解决以上的问题,17.05后开始支持多阶段构建(multistage builds),使用多阶段构建可以解决上面的问题,并只需要编写一个Dockerfile:
例如:
编写Dockerfile文件:
1 | FROM golang:1.9-alpine as builder |
构建镜像
1 | docker build -t go/helloworld:3 . |
对比三个镜像
1 | docker image ls |
很明显使用多阶段构建的镜像体积小,同时也解决了上边的问题
使用as为某一阶段命令,例如:
1 | FROM golang:1.9-alpine as builder |
只构建builder阶段的镜像,可以在使用 docker build的时候加上--target
1 | docker build --target builder -t username/imagename:tag . |
上面的例子使用 COPY --from=0 /go/src/github.com/go/helloworld/app .
从上一阶段的镜像中复制文件,我们可以复制任意镜像中的文件
1 | COPY --from=nginx:latest /ect/nginx/nginx.conf /nginx.conf |
格式:docker import [选项] <文件>|<URL>|- [<仓库名>[:<标签>]]
压缩包可以是本地文件,远程Web文件,甚至是从标准输入中得出的,压缩包就会在镜像 /
目录展开,并直接作为镜像第一层提交
1 | 创建一个OpenVZ的Ubuntu14.04模板的镜像: |
这条命令自动下载了 ubuntu-14.04-x86_64-minimal.tar.gz
文件,并且作为根文件系统展开导入,并保存为镜像 openvz/ubuntu:14.04
导入成功后,使用 docker image ls可以看到这个导入的镜像
看到历史会看到描述中有导入的文件链接
1 | docker history openvz/ubuntu:14.04 |
将镜像保存为一个tar文件,然后传输到另外一个位置,再加载进来。这是在没有Docker Registry的做法,现在已不推荐。镜像迁移直接使用Docker Registry,无论是直接使用Docker Hub还是使用内网私有Registry都可以
保存镜像
docker save将镜像保存为归档文件
1 | docker save alpine | gzip > alpine-latest.tar.gz |
然后将它复制到另外一个机器上,用下面的命令加载镜像:
1 | docker load -i alpine-latest.tar.gz |
结合这两个命令和ssh甚至是pv,利用Linux管道可以写一个命令完成从一个机器将镜像迁移到另外一个机器,并且带进度条的功能:
1 | docker save <镜像名> | bzip2 | pv |ssh <用户名>@<主机名> 'cat|docker load' |
Docker镜像是怎么实现增量的修改和维护的?
每个镜像都由多层次构成,Docker使用Union FS将这些不同层结合到一个镜像中去。
通常Union FS有两个用途,一方面可以实现不借助LVM,RAID将多个disk挂在同一个目录下,另一个更常用的就是讲一个只读的分支和一个可写的分支联合在一起。
简单来说,容器似乎运行的一个或一组应用,以及它们的运行态环境,虚拟机可以理解为模拟运行的一整套操作系统(提供了运行态环境和其他系统环境)和跑在上面的应用
启动容器有两种方式,一种是基于镜像新建一个容器并启动,另外一个是将在终止状态(stopped)的容器重新启动。因为Docker的容器很轻级,用户可以随时删除和新建容器。
1 | 输出Hello World后终止容器 |
当利用 docker run来创建容器时,Docker在后台运行的标准操作包括:
docker container start命令可以将一个已经终止的容器启动运行。
容器的核心为所执行的应用程序,所需要的资源都是应用程序运行所必需的,除此之外没有其他资源。可以在伪终端中利用ps或top来查进程信息
更多时候,需要让Docker在后台运行而不是直接把执行命令的结果输出在当前宿主机下。可以通过添加 -d
参数实现。
不用 -d
参数运行容器:
1 | docker run ubuntu:17.10 /bin/sh -c "while true;do echo hello world;sleep 1;done" |
容器会把输出结果stdout打印到宿主主机上面
如果使用了 -d
参数运行容器
1 | docker run -d ubuntu:17.10 /bin/sh -c "while true;do echo hello world; sleep 1;done" |
此时容器会在后台运行并不会把输出结果打印到宿主机上面(输出结果可以用docker logs查看)。
注:容器是否会长久运行,和 docker run 指令的命令有关,和 -d
参数无关。
使用 -d
参数启动后返回一个唯一的id,可以通过 docker container ls命令来查看容器信息
1 | docker container ls |
使用 docker container stop 来终止一个运行中的容器
当Docker 容器中指定的应用终结时,容器也会自动终止。上面只启动了一个容器,用户可以通过exit或者ctrl+d来推出终端,所创建的容器立刻终止。
终止状态的容器可以使用 docker containers ls -a命令看到例如:
1 | docker container ls -a |
然后通过 docker run restart命令会让一个运行态的容器终止,然后再重新启动它。
使用 -d
参数时,容器启动后会进入后台。某些时候需要进入容器操作,包括docker attch命令或docker exec命令,推荐使用docker exec
docker attch是Docker自带命令
1 | docker run -dit ubuntu |
-i -t参数
docker exec可以跟多个参数
只用 -i
参数时,由于没有分配伪终端,界面没有Linux命令符,但命令执行结果仍然可以返回
当 -i
/-t
参数一起使用时,可以看到Linux命令提示符
1 | docker run -dit ubuntu |
如果从这个stdin中exit,不会导致容器的停止。这就是为什么推荐使用 docker exec的原因
1 | docker container ls -a |
1 | docker import 从容器快照文件中再导入为镜像 |
1 | docker container rm haha |
删除一个运行中的容器,可以添加 -f
,Docker会发送 SIGKILL
信号给容器
清理所有处于终止状态的容器:
1 | docker container prune |
仓库Repository是集中存放镜像的地方。
一个容易混淆的概念是注册服务器(Registry)。实际上注册服务器是管理仓库的具体服务器,每个服务器可以有多个仓库,而每个仓库下面可以有多个镜像。从这方面来说,仓库被认为是一个具体的项目或者目录。例如对于 仓库地址dl.dockerpool.com/ubuntu
来说,dl.dockerpool.com
是注册服务器地址,ubuntu
是仓库名。大部分时候并不需要严格区分这两者的概念
Docker官方维护了一个公共仓库Docker Hub,大部分需求可以在Docker Hub中直接下载镜像实现
在 https://cloud.docker.com 免费注册一个 Docker 账号,通过docker login命令交互式输入用户名和密码完成在命令行登录,docker logout退出登录。
docker search命令查找官方仓库镜像,利用 docker pull命令将它下载到本地
1 | centos为关键词 |
查找的时候通过 --filter=stars=N
参数可以指定仅显示收藏数量为N以上的镜像
用户登录后通过docker push推送自己的镜像到Docker Hub。
1 | username替换为Docker 账户用户名 |
Automated Builds功能对于需要经常升级镜像内程序来说,十分方便。
用户创建了镜像,安装了某个软件,如果软件发布新版本则需要手动更新镜像。而自动创建运行用户通过Docker Hub指定一个跟踪网站上的项目,Github/BitBucket等,一旦项目发生新的提交或者创建新的标签,Docker Hub会自动构建镜像并推送到 Docker Hub中
配置自动创建,有几个步骤:
之后可以在Docker Hub的自动创建页面中跟踪每次创建的状态
用户可以创建一个本地仓库供私人使用
docker-registry是官方提供的工具,用户构建私有镜像库
通过官方registry镜像来运行
1 | docker run -d -p 5000:5000 --restart=always --name registry registry |
这将使用官方的registry镜像来启动私有仓库,默认情况下,仓库会被创建在容器的 /var/lib/registry
目录下,可以通过-v
参数指定镜像文件存在本地的指定路径。例如下面的例子将上传的镜像放到本地的/opt/data/registry
1 | docker run -d \ |
创建好私有仓库后,可以使用docker tag来标记一个镜像,然后将它推送到仓库,例如私有仓库地址为 127.0.0.1:5000
,现在本机已有的镜像
1 | docker image ls |
docker tag将 ubuntu:latest镜像标记为 127.0.0.1:5000/ubuntu/:latest
,格式为:
docker tag IMAGE[:TAG] [REGISTRT_HOST[:REGISTRY_PROT]/]REGISTORY[:TAG]
1 | docker tag ubuntu:latest 127.0.0.1:5000/ubuntu:latest |
docker push上传标记的镜像
1 | docker push 127.0.0.1:5000/ubtuntu:latest |
curl查看仓库中的镜像
1 | curl 127.0.0.1:5000/v2/_catalog |
删除已有的镜像从私有仓库下载镜像
1 | docker image rm 127.0.0.1:5000/ubuntu:latest |
如果不想使用 127.0.0.1:5000
作为仓库地址,比如想让本网段的其他主机也能把镜像推送到私有仓库。得把例如 192.168.199.100:5000
这样的内网地址作为私有仓库地址,这时会发现无法成功推送镜像。
这是因为 Docker 默认不允许非 HTTPS
方式推送镜像。通过 Docker 的配置选项来取消这个限制.
对于使用 upstart
的系统而言,编辑 /etc/default/docker
文件,在其中的 DOCKER_OPTS
中增加如下内容:
1 | DOCKER_OPTS="--registry-mirror=https://registry.docker-cn.com --insecure-registries=192.168.199.100:5000" |
重新启动服务。
1 | $ sudo service docker restart |
对于使用 systemd
的系统,请在 /etc/docker/daemon.json
中写入如下内容(如果文件不存在新建该文件)
1 | { |
注意:该文件必须符合
json
规范,否则 Docker 将不能启动。
对于 Docker for Windows 、 Docker for Mac 在设置中编辑 daemon.json
增加和上边一样的字符串即可。
利用Docker Compose 搭建一个拥有权限认证、TLS的私有仓库。新建一个文件夹,以下步骤在该文件夹镜像
国内各大云服务商提供免费的站点证书,也可以使用openssl自行签发证书。假设要搭建的私有仓库地址为docker.domain.com
,使用openssl自行签发docker.domain.com的站点SSL证书
第一步创建CA私钥
openssl genrsa -out “root-ca.key” 4096
第二步利用私钥创建CA根证书请求文件
1 | openssl req \ |
第三步创建CA根证书新建root-ca.cnf
1 | [root_ca] |
第四步签发根证书
1 | openssl x509 -req -days 3650 -in "root-ca.csr" \ |
第五步生成站点SSL私钥
1 | openssl genrsa -out "docker.domain.com.key" 4096 |
第六步使用私钥生成证书请求文件
1 | openssl req -new -key "docker.domain.docm.key" -out "site.csr" -sha256 \ |
第七步配置证书,新建site.cnf文件
1 | [server] |
第八步签署站点SSL证书
1 | openssl x509 -req -days 750 -in "site.csr" -sha256 \ |
这样就拥有了docker.domain.com
的网站SSL私钥docker.domain.com.key
和SSL证书docker.domain.com.crt
以及CA根证书root-ca.crt
新建ssl文件夹将docker.domain.com.key
、docker.domain.com.crt
、root-ca.crt
这三个文件移入,删除其他文件
私有仓库默认的配置文件位于 /etc/docker/registry/config.yml
,现在本地编辑 config.yml
,之后挂载到容器中
1 | version: 0.1 |
1 | mkdir auth |
编辑 docker-compose.yml
1 | version: '3' |
编辑 /etc/hosts
1 | 127.0.0.1 docker.domain.com |
启动
1 | docker-compose up -d |
这样就搭建好了一个具有权限认证、TLS的私有仓库。
由于自行签发的CA根证书不被系统信任,需要将CA根证书 ssl/root-ca.crt
移入 etc/docker/certs.d/docker.domain.com
文件夹中
1 | sudo mkdir -p /etc/docker/certs.d/docker.domain.com |
关键在于退出登录,尝试推送镜像
1 | docker logout docker.domain.com |
数据卷是一个可供一个或者多个容器使用的特殊目录,它绕过UFS,可以提供很多有用的特征:
数据的使用,类似于Linux下对目录或者文件进行mount,镜像中的被指定为挂载点的目录中的文件会隐藏掉,能显示看的是挂载的数据卷
1 | docker volume create my-vol |
1 | 用 docker run命令的时候,使用 --mount标记将数据卷挂载容器里。在一次docker run 中可以挂载多个数据卷 |
主机里面使用以下命令可以查看web容器的信息
1 | docker inspect web |
1 | docker volume rm my-vol |
数据卷是被设计用来持久化数据的,生命周期独立于容器,Docker不会在容器被删除后自动删除容器,并且也不会存在垃圾回收这样的机制来处理没有任何容器引用的数据卷。如果需要在删除容器的同时移除数据卷,可以再删除容器的时候使用 docker rm -v
的命令
无主的数据卷可能会占据很多空间,清理可以使用:
1 | docker volume prune |
1 | 使用 --mount标记可以指定挂载一个本地主机的目录到容器中去 |
上面的命令加载主机的 /src/webapp
目录到容器的 /opt/webapp目录
,这个功能在进行测试比较方便,比如用户可以放置一些程序到本地目录中,来查看容器是否正常工作。本地目录的路径必须是绝对路径,使用 -v
参数时如果本地目录不存在Docker会自动创建一个文件夹,使用 --mount
采纳数如果本地目录不存在,Docker会报错
Docker挂载主机目录默认权限是读写,用户可以通过增加 readonly指定为只读
1 | docker run -d -p \ |
在主机里查看web容器的信息
1 | docker inspect web |
挂载一个本地主机文件作为数据卷
--mount
标记也可以从主机挂载单个文件到容器中
1 | docker run --rm -it \ |
容器中可以运行一些网络应用,让外部也可以访问,可以通过 -P/p
参数来指定端口映射
当使用-P
,Docker会随机映射一个 49000~49900
的端口到内部容器开放的端口
1 | docker run -d -p training/webapp python app.py |
-p
则可以指定要映射的端口,并且在一个指定端口上只可以绑定一个容器。支持的格式为:
ip:hostPort:containerPort|ip::containerPort|hostPort:containerPort
1 | 映射所有接口地址:使用 hostPort:containerPort格式本地的5000端口映射到容器的5000端口,此时会默认绑定本地所有接口上的所有地址 |
随着Docker网络的完善,将容器加入自定义Docker网络来连接多个容器的做法优于使用 --link
参数
1 | 创建一个新的Docker网络,-d参数指定Docker网络类型,有bridge overlay,其中overlay网络类型适用于Swarm mode |
1 | 运行一个容器并连接到新建的my net网络 |
如果有多个容器之间需要互相连接,推荐使用Docker compose
Docker利用虚拟文件来挂载容器的3个相关配置文件
在容器中使用 mount命令可以看到挂载信息。
这种机制可以让宿主主机DNS信息发生更新后,所有Docker容器的DNS配置通过 /etc/resolv.conf
文件立刻得到更新。
配置全部容器的DNS,可以在/etc/docker/daemon.json
文件中增加以下内容来设置
1 | { |
这样每次启动容器DNS自动配置为114.114.114.114
和 8.8.8.8
。证明已经生效:
1 | docker run -it --rm ubuntu:18.04 cat etc/resolv.conf |
如果用户想要手动指定容器的配置,可以在使用docker run的时候加入参数:
-h HOSTNAME
/--hostname=HOSTNAME
设定容器的主机名,它会被写到容器内的etc/hostname
和/etc/hosts
,在容器外部看不到,既不会在docker container ls中显示,也不会在其他容器 /etc/hosts看到。--dns=IP_ADDRESS
添加DNS服务器到容器的/etc/resolv.conf
中,让容器用这个服务器来解析所有不在 /etc/hosts
中的主机名--dns-search=DOMAIN
设定容器的搜索域,当设定搜索域为 .example.com
时,在搜索一个名为host的主机,DNS不仅搜索host,还会搜索host.example.com
如果在容器启动时没有指定最后两个参数,Docker会默认用主机上的 resolve.conf
配置容器
Docker Compose是Docker 官方编排(Orchestration)项目之一,负责快速的部署分布式应用。
Compose定位是定义和运行多个Docker容器的应用。
使用一个Dockerfile模板文件可以很方便定义一个单独的容器,但经常会碰到需要多个容器互相配合来完成某项任务的情况,要实现一个Web项目,除了Web服务容器本身,玩玩还要加上后端的数据库服务容器,甚至负载均衡容器等。
Compose运行用户通过一个单独的docker-cmpose.yml模板文件来定义一组相关联应用容器为一个项目
Compose中有两个重要的概念:
Compose的默认管理对象是项目,通过子命令对项目中的一组容器进行便捷的生命周期管理
Compose项目由Python编写,实际上调用了Docker服务提供的API来对容器进行管理,因此只要所操作的平台支持Docker Api,就可以在其上利用Compose进行编排管理
常见的Web网络,该项目包含web应用和缓存,下面使用Python记录页面次数的web网站
app.py
1 | # dockerTest/app.py |
Dockerfile
1 | FROM python:3.6-alpine |
docker-compose.yml
1 | version: '3' |
运行
1 | docker-compose up |
此时访问本地5000端口,每次刷新页面,计数就会增加1.
如果想要后台运行加 -d
对于Compose来说,大部分命令的对象既可以是项目本身,也可以指定为项目中的服务或者容器。如果没有特别的说明,命令对象将是项目,这意味着项目中所有服务都会受到命令影响。
docker-compose [COMMAND] --help
或者 docker-compose help [COMMAND]
可以查看具体某个命令的使用格式,基本的使用格式是:
1 | docker-compose [-f=<arg>...] [options] [COMMAND] [ARGS...] |
-f,--file FILE
指定使用的Compose模板文件,默认是docker-compose.yml
,可以多次指定-p,--project-name NAME
指定项目名称,默认将使用所在目录名称作为项目名--x-networking
使用Docker的可拔插网络后端特性--x-network-driver DRIVER
指定网络后端的驱动,默认为bridge
--verbose
输出更多调试信息-v,--version
打印版本并退出build
格式:
docker-compose build [options] [SERVICE…]
构建(重新构建)项目中的服务容器。服务容器一旦构建后,将会带上一个标记名,对于web项目中的一个db容器,可能是web_db
可以随时在项目目录下运行 docker-compose build
来重新构建服务
选项包括:
--force-rm
删除构建过程中的临时容器--no-cache
构建镜像过程中不使用cache(将加大构建过程)--pull
始终尝试通过pull来获取更新版本的镜像config
验证Compose 文件格式是否正确,若格式错误显示原因
down
此命令会停止up命令所启动的容器,并移除网络
exec
进入指定容器
help
获得命令的帮助
images
列出Compose文件中包含的镜像
kill
格式:
docker-compose kill [options] [SERVICE…]
通过发送SIGKILL信号来强制停止服务容器。支持通过-s
参数来指定发送信号:
1 | docker-compose kill -s SIGINT |
logs
格式:
docker-compose logs [options] [SERVICE…]
查看服务容器的输出。默认情况下,docker-compose将对不同的服务输出使用不同的颜色来区分,可以通过 --no-color
来关闭颜色
pause
格式:
docker-compose pause [SERVICE…]
暂停一个服务容器
port
格式:
docker-compose port [option] SERVICE PRIVIATE_PORT
打印某个容器端口所映射的公共端口
选项:
--protocol=proto
指定端口协议,tcp默认值或者udp--index=index
如果同一服务存在多个容器,指定命令对象容器的序号(默认为1)ps
格式:
docker-compose ps [options] [SERVICE…]
列出项目中目前的所有容器
选项:
-q
只打印容器的ID信息pull
格式:
docker-compose pull [options] [SERVICE…]
拉取服务依赖的镜像
选项:
--ignore-pull-failures
忽略拉取镜像过程中的错误push
推送服务依赖的镜像到Docker镜像仓库
restart
格式:
docker-compose restart [options] [SERVICE…]
重启项目中的服务。
选项:
-t,--timeout TIMEOUT
指定重启前停止容器的超时(默认为10秒)rm
格式
docker-compose rm [options] [SERVICE…]
删除所有停止状态服务容器,推荐先指定docker-compose stop命令来停止容器
选项:
-f,--force
强制直接删除,包括非停止状态的容器,一般尽量不要使用该选项-v
删除容器所挂载的数据卷run
格式:
docker-compose run [options] [-p PORT…] [-e KEY=VAL…] SERVICE [COMMAND] [ARGS…]
在指定服务上执行一个命令
1 | docker-compose run ubuntu ping docker.com |
将会启动一个ubuntu服务容器,并执行ping docker.com命令
默认情况下,如果存在关联,则所有关联的服务将会自动被启动,除非这些服务已经在运行中
该命令类似启动容器后执行指定的命令,相关卷、链接等等都会按照配置自动创建
两个不同点:给定命令将会覆盖原有的自动运行命令,不会自动创建端口,以避免冲突
如果不希望自动启动关联的容器,可以使用 --no-deps
选项,例如
1 | docker-compose run --no-deps web python manage.py shell |
将不会启动web容器所关联的其他容器
选项:
-d
后台运行容器--name NAME
为容器指定一个名字--entrypoint CMD
覆盖默认的容器启动指令-e KEY=VAL
设置环境变量值,可多次使用选项来设置多个环境变量-u,--user=""
指定运行容器的用户名或者uid--no-deps
不自动启动关联的服务容器--rm
运行命令后自动删除容器,d
默认下将忽略-p,--publish=[]
映射容器端口到本地主机--service-ports
配置服务端口并映射到本地主机-T
不分配伪tty,意味着依赖tty的指令将无法运行scale
格式:
docker-compose scale [options] [SERVICE=NUM…]
设置指定服务运行的容器个数
通过service-num的参数来设置数量。例如
1 | docker-compose scale web=3 db=2 |
start
格式:
docker-compose start [SERVIC…]
启动已经存在的服务器
stop
格式:
docker-compose stop [options] [SERVICE…]
停止已经处于运行状态的容器,但不删除它,通过 docker-compose start可以再次启动这些容器
top
查看各个服务容器内运行的内存
unpause
格式:
docker-compose unpause [SERVICE…]
恢复处于暂停状态的服务
up
格式:
docker-compose up [options] [SERVICE…]
尝试自动完成包括构建镜像,重新创建服务,启动服务,并关联服务相关容器的一系列操作。链接的服务器都会被自动启动,除非已经处于运行状态
大部分时候都可以通过该命令来启动一个项目
默认情况下,docker-compose up启动的容器在前台,控制台将会同时打印所有容器的信息,可以方便调试,使用 ctrl-c停止命令时,所有容器将会停止
生产环境推荐使用 docker-compose up -d,这将会在后台启动并运行所有容器。
默认情况下,如果服务容器已经存在,docker-compose up将会尝试停止容器,然后重新创建(保持使用volume-from
挂载的卷),以保证新的服务匹配docker-compose.yml文件的最新内容。如果不希望容器被停止并重新创建,可以使用 docker-compose up –no-recreate。这样只会启动处于停止状态的容器,而忽略已经运行的服务,如果用户只想重新部署某个服务,可以使用 docker-compose up --no-deps -d <SERVICE_NAME>
来重新创建服务并后台停止旧服务,启动新服务,并不会影响到期所依赖的服务。
选项:
-d
在后台运行服务容器--no-color
不使用颜色来区分不同的服务的控制台输出--no-deps
不启动服务所链接的容器--force-recreate
强制重新创建容器,不能与--no-recreate
同时使用--no-buld
不自动构建缺失的服务镜像
-t,timeout TIMEOUT`停止同期时候的超时(默认为10秒)version
打印版本信息
模板文件就是使用compose的核心,大部分指令跟docker run相关参数的含义类似
默认的模板文件名称为 docker-compose.yml
,格式为YAML格式
1 | version: "3" |
每个服务都必须通过image指令指定镜像或build(需要Dockerfile)指令等来自动构建生成镜像
如果使用build命令,在Dockerfile中设置的选项,例如CMD/EXPOSE/VOLUME/ENV等将会被自动获取,无需在docker-compose.yml中再次设置。
指定Dockerfile所在文件夹的路径(可以是绝对路径,或者相对docker-compose.yml文件路径),Compose将会利用它自动构建这个镜像,然后使用这个镜像
1 | version: '3' |
也可以使用context指令指定Dockerfile所在文件夹的路径,dockerfile指令指定Dockerfile文件名,arg指令指定构建镜像时的变量
1 | version: '3' |
使用cache_from指定构建镜像的缓存
1 | build: |
指定容器的内核能力(capacity)分配
1 | # 让容器拥有所有能力 |
仅用于Swarm mode
指定父cgroup组,意味着将继承改组的资源限制
1 | # 创建一个cgroup组名称为cgroups_1 |
指定容器名称。默认使用项目名称_服务名称_序号
1 | container_name:docker-web-container |
指定设备映射关系
1 | devices: |
解决容器的依赖、启动先后问题。
1 | # 先启动redis db 再启动web |
自定义DNS服务器,可以是一个值,也可以是一个列表
1 | dns: 8.8.8.8 |
配置DNS搜索域,可以是一个值,也可以是一个列表
1 | dns_search:example.com |
挂载一个tmpfs文件系统到容器
1 | tmpfs: /run |
从文件中获取环境变量,可以为单独的文件路径或列表
通过 docker-compose -f FILE
方式来指定Compose模板文件,则 env_file
中变量的路径会基于模板文件路径。如果有变量名称与environment指令冲突,则按照惯例,以后者为准
1 | env_file: .env |
环境变量文件中每一行必须符合格式,支持#
开头的注释行
1 | # common.env: Set development environment |
设置环境变量,可以使用数组和字典两种格式
只给定名称的变量会自动获取运行Compose主机上对应变量的值,可以用阿里防止泄露不必要的数据
1 | environment: |
如果变量名称或者值中用到true|false,yse|no
等表示布尔含义的词汇,最好放到引号中,避免YAML自动解析某些内容为对应的布尔语义,这些特定词汇,包括
1 | y|Y|Yes|YES|n|N|no|NO|true|True|TRUE|false|False|FALSE|on|ON|off|Off|OFF |
暴露端口,但不映射到宿主机,只被连接的服务访问。仅可以指定内部端口为参数
1 | expose: |
链接到docker-compose.yml外部的容器,甚至并非Compose管理的外部容器,不建议使用
1 | external_links: |
类似Docker中的--add-host
参数,指定额外的host名称映射信息
1 | extra_hosts: |
会在启动后的服务容器中 /etc/hosts
文件中添加如下两条条目
1 | 8.8.8.8 googledns |
通过命令检查容器是否健康运行
1 | healthcheck: |
指定为镜像名称或者镜像ID,如果镜像在本地不存在,Compose将会尝试拉取这个镜像
1 | image: ubuntu |
为容器添加Docker元数据(metadata)信息,可以为容器添加辅助说明信息
1 | labels: |
不推荐使用该指令
配置日志选项
1 | loggin: |
设置网络模式,使用和 docker run 的 network参数一样的值
1 | network_mode: "bridge" |
配置容器连接的网络
1 | version: "3" |
跟主机系统共享进程命名空间,打开该选项的容器之间,以及容器和宿主机系统之间可以通过进程ID来互相访问和操作
1 | pid: "host" |
暴露端口信息
使用宿主端口:容器端口(HOST:CONTAINER)格式,或者仅仅指定容器的端口(宿主将会随机选择端口)都可以
1 | ports: |
存储敏感数据,例如mysql服务密码
1 | version: "3.1" |
指定容器模板标签label机制的默认属性(用户、角色、类型、级别等)。例如配置标签的用户名和角色名
1 | security_opt: |
设置另一个信号来停止容器。在默认情况下使用的是SIGTERM停止容器
1 | stop_signal: SIGUSR1 |
配置容器内核参数
1 | sysctls: |
指定容器的ulimits限制值
例如,指定最大容器的进程数为65535.指定文件句柄数为20000(软限制,应用可以随时修改,不能超过硬限制)和40000(系统硬限制,只能root用户提高)
1 | ulimits: |
数据卷所挂载路径设置,可以设置宿主主机路径(HOST:CONTAINER)或加上访问模式(HOST:CONTAINER:ro)
该指令中路径支持相对路径
1 | volumes: |
domainname,entrypoint,hostname,ipc,mac_address,privileged,read_only,shm_size,restart,stdin_open,tty,user,wor等指令,基本跟 docker run中对应参数的功能一致
1 | # 指定服务容器启动后执行的入口文件 |
Compose模板文件支持动态读取主机的系统环境变量和当前目录下的 .env文件中的变量
例如,下面的Compose文件将从运行它的环境中读取变量 ${MONGO_VERSION}
的值,并写入执行的指令中
1 | verison: '3' |
如果执行 MONGO_VERSION=3.2 docker-compose up
则会启动一个 mongo:3.2
镜像的容器;如果执行 MONGO_VERSION=2.8 docker-compose up
则会启动一个 mongo:2.8
镜像的容器。
若当前目录存在 .env
文件,执行 docker-compose
命令时将从该文件中读取变量。
在当前目录新建 .env
文件并写入以下内容。
1 | # 支持 # 号注释 |
执行 docker-compose up
则会启动一个 mongo:3.6
镜像的容器。
系统模块
第三方模块:
非Node.js自带的模块被称为第三方模块,会分成路径形式的文件模块(以.
、..
、/
开头的)和自定义的模块(比如express/koa/moment.js等)
在 Node.JS 中模块加载一般会经历3个步骤:路径分析、文件定位、编译执行
按照模块的分类,按照以下的顺序进行优先加载:
.
、..
、/
开头的,如果文件没有加上拓展名,会依次按照.js
、.json
、.node
尝试进行拓展名补足。(在尝试过程中也是以同步阻塞模式来判断文件是否存在的,从性能优化角度来看,.json
、node
最好还是加上文件的拓展名)exports相当于 module.exports 的快捷方式:
1 | const exports = module.exports |
但是不能改变 exports 的指向,我们可以通过 exports.test = 'a'
,这样来导出一个对象,但是不能通过下面的例子直接赋值,这样会改变 exports 的指向
1 | // 错误的写法,将会得到 undefined |
1 | // a.js |
执行 node a.js
,结果:
1 | a模块start |
启动a.js会加载b.js,那么在b.js中又加载到了a.js,但是此时a.js模块hi阿咩有执行完,返回的是一个a.js模块的exports对象未完成的副本给到b.js模块(因此不会陷入死循环),然后b.js完成加载之后将exports 对象退供给a.js模块
undeclaredVariable 是一个未声明的变量,也就是一个挂在全局的变量,那么在其他地方是可以拿得到的
不会陷入死循环
缓冲是用于处理二进制流数据,将数据存储起来,它是临时性的,对于流式数据来说,会采用缓冲区将数据临时存储起来,等缓冲到一定大小的时候存入硬盘中。视频播放器就是一个经典例子。
缓存可以看做一个中间层,可以是永久性的将热点数据进行缓存,使得访问速度更快。例如我们通过对 memory、Redis等将数据从硬盘或者第三方接口中请求过来进行缓存,目的就是将数据存于内存的缓存区中,这样对同一个资源进行访问,速度会更快,也就是性能优化的一个重要点。
进程是计算中程序关于数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器。启动一个服务,运行一个实例,就是开一个服务进程。多进程就是进程的复制(fork),fork出来的每个进程都有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了IPC通信,进程之间才可以数据共享。
线程是操作系统能够进行运算调度的最小单位,线程是隶属进程的,被包含与进程之中的。一个线程只能隶属一个进程,但是一个进程是可以拥有多个线程的。
同一个代码,可以根据系统CPU核心数启动多个进程,每个进程都有属于自己的独立运行空间,进程之间是不互相影响的。同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述和信号处理等。但 同一进程中的多个线程有各自的调用栈,自己的寄存器环境,自己的线程本地存储。
父进程创建子进程之后,父进程退出了,但是父进程对应的一个或者多个子进程还在运行,这些子进程会被系统的init进程收养,对应的程序ppid为1,这就是孤儿进程。
app.listen(port)
在进行fork时,为什么没有报端口被占用端口被占用的情况:
1 | // master.js |
多进程模式可通过句柄传递不会有端口占用的问题。
当父子进程之间建立IPC通道之后,通过子进程对象的send方法发送消息,第二个参数sendHandle就是句柄,可以是TCP套接字,TCP服务器、UDP套接字等,为了解决上面多进程端口占用问题,将主进程的socket传递到子进程:
1 | // master.js |
IPC(inter-process communication),即进程间通信技术,由于每个进程创建之后都有自己的独立地址空间,实现IPC的目的就是进程之间的资源共享访问,实现IPC的方式有多种:管道、消息队列、信号量、Domain Socket,Nodejs通过pipe来实现。
未使用IPC的情况:
1 | // pipe.js |
创建一个父进程和子进程之间传递消息的IPC通道实现输出信息
1 | // 修改pipe.js让子进程的stdio和当前进程的stdio之间建立管道链接,还可以通过spawn方法的stdio选项建立IPC机制 |
javascript是单线程,在服务端运行环境的nodejs不是单线程。
浏览器环境中对于DOM的操作是单线程的,避免DOM渲染冲突,在浏览器中UI渲染线程和JS执行引擎是互斥的,一方在执行式都会导致另一方被挂起,这是由JS引擎所决定的。
守护进程运行在后台不受终端影响。
1 | // index.js文件的处理逻辑使用 spawn创建子进程完成第一步,设置options.detached为true可以使得子进程在父进程退出后继续运行(系统层会调用setsid方法),这是第二步。options.cwd指定当前子进程工作目录不做设置默认继承当前工作目录,这是第三步。运行daemon.unref()退出父进程,第四步。 |
实际工作中守护进程很多,例如PM2,Egg-Cluster等,实际工作上对于守护进程的健壮性要求还是很高的,例如:进程的异常监听,工作进程管理调度,进程挂掉之后重启等等。
采用子进程 child_process的spawn方法:
1 | const spawn = require('child_process').spawn; |
进程的当前工作目录可以通过process.cwd()命令获取,默认为当前启动的目录,如果是创建子进程则继承于父进程的目录,可以通过process.chdir()命令重置,例如通过spawn命令创建的子进程可以指定cwd选项设置子进程的工作目录。
有什么用,例如fs读取文件,如果设置为相对路径则是相当于当前进程启动的目录进行查找,所以,启动目的设置有误的情况下将无法得到正确的结果。还有一种情况程序里引用第三方模块也是根据当前进程启动的目录来进行查找的
多进程模式下各个进程之间是相互独立的,例如用户登录之后seesion的保存,如果保存在服务进程里,那么如果我有4个工作进程,每个进程都要保存一份这是没有必要的。假设服务重启了数据也会丢失。多个Web服务也是一样的,还会出现在A机器创建了Session,当负载均衡分到B机器上之后还需要再创建一份,一般的做法是通过Redis或者数据库来做数据共享
使用fork可以创建子进程,正常情况进程退出,内核要释放进程所占用的资源:打开的文件、占用的内存等,但是进程的PID、退出状态、运行时间等会进行保留,知道父进程调用wait/waitpid来获取子进程的状态信息时,这些资源才会释放。
如果子进程退出之后,父进程没有调用wait/waitpid来获取子进程的状态,那么保留的进程号将会一直被占用,且占用系统资源,称为僵死或僵尸进程。
元凶是其父进程,我们把元凶kill掉之后,僵尸进程会变为孤儿进程被系统的 init 进程pid=1的进程所收养,init进程会对这些孤儿进程进行管理(调用wait/waitpid)释放其占用资源。
console既不是总是同步的,也不总是异步的,是否为同步取决于链接是什么流以及操作系统是Window还是Posix
同步写将会阻塞实践循环直到写完成。
可利用 process.stdout将输入流数据输出到输出流(即输出到终端)
1 | process.stdout.write('xxx'+'\n') |
一旦产生事件循环,就会长产生一个While(true)的死循环,例如定时器,console.log则没有产生watch/handlers,在事件循环一次就退出了。
Nodejs进程退出会等待异步处理完成,常见的运维过程中会碰到需要进程优雅退出的场景,Nodejs自然退出是最好的,process.exit是比较粗暴的。
常见的异步请求:
OSI七层模型 | TCP/IP五层模型 | 描述 |
---|---|---|
应用层 | 应 | 构建于传输层之上常用的HTTP、FTP文件传输协议、SMTP邮件传输协议等 |
表示层 | 用 | 构建于传输层之上常用的HTTP、FTP文件传输协议、SMTP邮件传输协议等 |
会话层 | 层 | 构建于传输层之上常用的HTTP、FTP文件传输协议、SMTP邮件传输协议等 |
传输层 | 传输层 | 向用户提供可靠的端到端服务TCP、UDP |
网络层 | 网络层 | IPV4、IPV6 |
数据链路层 | 数据链路层 | 设备驱动和硬件 |
物理层 | 物理层 | 设备驱动和硬件 |
IP协议是无连接通信协议,IP协议不会占用两个设备之间通信的线路,IP实际上主要负责将每个数据包路由至目的地,但是IP协议没有能确保数据包是否到达,传过去的数据是否按照顺序排列,所以IP数据包是不可靠的。而解决数据不可靠的问题就是由TCP协议来完成。
TCP(Transmission Control Protocol)是可靠的传输控制协议,三个特点:
TCP报文
源端口(16) | 目的端口(16) |
---|---|
TCP序号(32) | TCP序号(32) |
捎带的确认(32) | 捎带的确认(32) |
首部长度(4)保留(6)Flag(6:URG、ACK、PSH、RST、SYN、FIN) | 窗口尺寸(16) |
TCP校验和(16) | 紧急指针(16) |
数据包内容 | 数据包内容 |
6个标志位:
3次握手协议:
SYN是同步序号,当SYN=1而ACK=0时表明这是一个连接请求报文,对方若同意连接,那应在报文中使SYN=1和ACK=1,因此SYN置1表示这是一个连接请求或者连接接受报文。而ACK状态是用来确认是否同意连接。也就是传了SYN,证明发送方到接收方的通道没有问题,但是接收方到发送方的通道还需要ACK信号来验证
当在传送完数据之后,客户端与服务器端之间有四次握手协议:
TIME_WAIT状态的作用?(MSL:网络中数据报文存在的最大时间)
客户端(发送一端)在发送之前会将短时间有多个发送的数据块缓冲到一起(发送缓冲区),形成了一个大的数据块一并发送,同样接收端也有一个接收缓冲区,收到的数据先存放在接收端缓冲区,然后程序从这里读取部分数据进行消费,这样做也是为了减少I/O消耗达到性能优化。
数据达到缓冲区什么时间开发发送这个取决于TCP拥塞控制,是任何时刻内确定能被发送出去的字节数的控制因素之一,是阻止发送方至接收方之间的链路变得拥塞的手段
TCP粘包解决方案:
延迟发送:设置延迟发送,sleep休眠一段时间。简单但是传输效率大大降低,只适用于交互频率低的情况
关闭nagle算法。nagle算法是一种改善网络传输效率的算法,避免网络中充斥着大量小的数据块,它所期望的是尽可能发送大的数据块,因此在每次请求一个数据块给TCP发送时,TCP并不会立即执行发送,而是等待一小段时间进行发送。
当网络中充斥着大量小的数据块时,Nagle算法能将小的数据块集合起来一起发送减少了网络拥堵,但并不是所有场景都需要这样。例如,REPL终端交互,当用户输入单个字符以获取响应,所以在nodejs中可以设置 socket.setNoDelay方法来关闭Nagle算法。const server = net.createServer(); server.on('connection',socket=>{socket.setNoDelay(true)})
封包/拆包。使用长度编码的方式,通信双方约定好格式,将消息分为定长的消息头(Header)和不定长的消息体(Body),在解析时读取消息头获取到内容的占用的长度,之后读取到的消息体内容字节数等于字节头的字节数时,认为它是一个完整的包。
消息头序号(Header) | 消息体长度(Header) | 消息体(Body) |
---|---|---|
SerialNumber | bodyLength | body |
2字节 | 2字节 | N字节 |
Buffer的几个api:
TCP顶层是基于二进制数据,应用层通常是易于表达的字符串、数字等,需要先将数据通过Buffer转换为二进制,取出的时候同样需要解码操作。
1 | // transcoder.js |
客户端
1 | const net = require('net'); |
服务端
1 | const net = require('net'); |
DNS模块是基于UDP协议来实现的,在Nodejs中可以通过require('dns')
实现域名的解析查询,Nodejs DNS模块分成两大类:
1 | // Nodejs DNS模块的 dns.lookup()方法使用底层操作系统进行域名解析,是不需要经过网络通信的 |
1 | // dns 模块除了 dns.lookup之外的函数,都会连接到实际DNS服务器以执行名称解析并始终使用网络执行DNS查询 |
dns.lookup与dns.resolve
不同
虽然用异步的角度来使用dns.lookup,但是内部的libuv底层线程池中确实同步的调用 getaddrinfo(3),所以可能有由于一些不确定的因素造成Node进程阻塞
与dns.lookup不同的是dns.resolve没有使用getaddrinfo(3),而通过网络执行的DNS查询,始终保持异步不会对其他进程产生负面影响
DNS本地解析指的是系统缓存这一阶段,在浏览器缓存没有命中的情况下, 会从本地系统的一个hosts文件中寻找对应的IP
在PM2的配置文件中可以设置exec_model:'cluster
和instance
两个属性来设置开启多个进程,PM2其实就是利用Nodejs Cluster这个模块来实现的,还有eggJs中的egg-cluster模块在启用Worker进程也是用到这个模块。
1 | const cluster = require('cluster'); |
集群模式通常实现有两种:
第一个方案存在的一个问题就是占用多个端口,造成资源浪费,由于多个实例是独立运行的,进程间通信不太好做。好处是稳定性高,各实例之间没有影响。
第二个方案多个Node进程去监听同一个端口,好处是进程间通信相对简单,减少了端口的资源浪费,但是这个时候需要保证服务进程的稳定性,特别是对Master进程稳定性要求会更高,编码也会复杂。
Nodejs中自带的Cluster模块正是采用了第二种方案。
端口不是被所有的进程全部监听,仅仅受到Master进程的监听。Master进程创建一个Socket并绑定监听到目标端口,通过子进程之间建立IPC通道之后,通过调用子进程的send方法,将Socket(链接句柄)传递过去。(Master通过cluster.fork方法创建的,本质上还是使用了child_process.fork这个方法)
使用 child_process.fork()创建的子进程,进行Socket传递的示例
1 | // master.js |
端口会被主进程绑定监听一次,但是主进程和子进程在建立IPC通信之后,发送Socket到子进程实现端口共享,在之后Master接受到新的客户端链接后,通过负载均衡技术再转发到各Worker进程。
由于cluster.fork本质上还是使用child_process.fork()这个方法来创建子进程,进程间通信无非几种:pipe(管道)、消息队列、信号量、Domain Socket。Nodejs中是通过pipe(管道)实现的,pipe作用于之间有血缘关系的进程,通过fork传递,其本身也是一个进程,将一个进程的输出作为另外一个进程的输入。
在Nodejs中使用了RoundRobin负载均衡策略,简称RP,它的实现原理是一种无状态的轮询策略,假定每台服务器的硬件资源、处理性能都是相同的,根据进程的数量,依次分配,直到所有进程处理完了,再开始重新计算分配。优点是实现起来简洁也易用,缺点是如果出现某个请求占用的时间较长,就会导致负载不会太均衡。
RP这种负载均衡技术适用于同一组服务器拥有相同的软硬件配置且平均的服务请求响应
RP是一种常见的复杂均衡技术,Nginx中也有使用,另外在RP的基础上还衍生了一个Weighted Round-Robin权重负载均衡轮询算法,简称WRR,同样也是使用轮询的技术,但是在基础上考虑了服务器的处理能力,实现时为服务器加上权重,这种负载均衡算法能够确保高性能的服务器得到更多的使用率,避免低性能的服务器负载过重。
1 | // cluster对象的schedulingPolicy属性设置 |
创建一个可读流readable一个可写流writeable,通过管道pipe将可写流绑到可读流,一个简单的Stream操作就可以完成
1 | const fs = require('fs'); |
如果可读流期间发什么什么错误,则写入的目标流将不会关闭,所以需要监听错误事件,手动关闭可写流,防止内存泄露。
设置可读流的end为false可以保持写入流一直处于打开状态,通过这种方式,一开始可写流处于打开状态,知道所有的可读流结束,我们再将可写流关闭。
1 | const fs = require('fs'); |
通过流我们可以将一大块数据拆分称为一小部分一点一点的流动起来,不需要一次性全部读入,在Linux下可以通过|
符号实现,类似的在Nodejs的Stream模块中同样也为我们提供了 pipe方法来实现
在Nodejs中I/O操作都是异步的,先用util模块的promiseify方法将fs.readFile的callback形式转换为Promise形式
,它将数据一次性读入内存然后再进行返回,当数据文件很大的时候也是对内存的一种消耗,不推荐
1 | // koa 的例子 |
1 | app.use(async ctx=>{ |
使用了可读流,通过pipe接口监听data与end事件,把data的可读流拆分称为一小块一小块的数据(chunks),像流水一样源源不断吐给客户端,而不再需要等待整个文件都加载到内存后才发送数据。pipe可以视为流的管道/通道方法,任何类型的流都会有这个方法来处理流的输入与输出。
总体来说,使用流可以大大提升响应时间,又能有效减轻服务器内存的压力
在应用层调用 fs.createReadStream 方法,找到这个方法创建的可读流对象pipe的方法实现
1 | // 导出一个createReadStream方法,在这个方法里面创建一个ReadSream可读流对象,且ReadStream来自internal/fs/streams |
1 | // 这个方法定义了构造函数 ReadStream,且在原型上定义了 open、_read、_destroy等方法,没有pipe方法,通过ObjectSetPrototypeOf方法实现了继承,ReadStream继承了Readable在原型中定义的函数,继续查找Readable的实现 |
1 | // to avoid cross-reference(require) issue |
1 | // 继承了Events 模块,然后在原型上定义了pipe方法,而_stream_readable继承了Stream之后又自己实现了pipe方法 |
1 | // 定义了Readable构造函数,且继承于lib/stream.js的Stream,然后重写pipe方法 |
1.声明构造函数Readable,继承Stream的构造函数和原型。
文件继承了events事件,拥有了events在原型中定义的属性,例如on、emit
2.声明pipe方法,订阅data事件
在Stream原型上声明pipe方法,订阅data事件,src为可读对象,dest为可写流对象。在使用pipe方法的时候也是监听的data事件,一边读取一边写入数据。
ondata方法的几个核心的实现:
调用src.pause是为了防止读入数据过快来不及写入,如果缓存未满即dest.write(chunk),这个缓存是根据创建流时创建的highWaterMark属性,默认为16384(16k),对象模式的流默认为16
1 | Readable.prototype.pipe = function(dest,options){ |
3.订阅drain事件,继续流动数据。继续写入事件到流时会触发drain事件,也就是dest.write(chunk)等于false(缓存满了)时,如果ondrain不存在则注册drain事件
1 | Readable.prototype.pipe = function(dest,options){ |
4.触发data事件。调用readable的resume方法,触发可读流的data事件,进入流动模式
1 | Readable.prototype.pipe = function(dest,options){ |
resume方法内部又调用resume_(),最终执行了stream.read(0)读取了一次空数据(size设置为0),将会触发实例上的_read()方法,再触发data事件。
1 | function resume(stream,state){ |
5.订阅end事件
end事件:当可读流中没有数据可供消费时触发,调用onend函数,执行dest.end()方法,表明已没有数据要被写入可写流,进行关闭(关闭可写流的id),之后再调用stream.write会导致错误。
1 | Readable.prototype.pipe = function(dest,options){ |
6.触发pipe事件,传入可读流对象
1 | Readable.prototype.pipe = function(dest,options){ |
在应用层使用但的时候可以在可写流上订阅pipe事件,做一些判断。
7.支持链式调用,最后返回dest
1 | Stream.protptype.pipe = function(dest,options){ |
声明构造函数Stream继承于事件events,此时就拥有了events在原型定义的属性,例如on/emit等方法
1 | const {ObjectSetPrototypeOf} = primordials; |
在Stream原型上声明pipe方法,订阅data事件,source为可读流对象,dest为可写流对象
在使用pipe方法的时候也是监听的data事件,一边读取数据一边写入数据
ondata方法的几个API:
1 | Stream.prototype.pipe = function(dest,options){ |
如果调用dest.write(chunk)返回false,就会调用source.pause()停止数据流动,继续写入事件到流时会触发drain事件,ondrain方法的几个API:
1 | Stream.prototype.pipe = function(dest,options){ |
1 | // 如果end选项没有被提供,可读流订阅end或者close事件,后续将会触发该事件,指定dest.end方法,仅被调用一次,didOnEnd变量做了控制,主要是为了关闭可写流的id |
可读流、可写流发生错误时触发error事件,调用onerror方法,首先移除可读流,可写流订阅的所有事件
1 | Stream.prototype.pipe = function(){ |
在pipe方法里面最后还会触发一个pipe事件,传入可读流对象
1 | Stream.prototype.pipe = function(dest,options){ |
最后返回dest,支持A.pipe(B).pipe(c)
的写法:
1 | Stream.prototype.pipe = function(dest,options){ |
util模块提供了很多工具函数,其中promisify方法可以将callback转换为Promise对象,解决回调地狱的问题。
将callback转为promise对象,首先确保这个callback为一个错误优先的回调函数,即(err,value)=>err
指定一个错误参数,value为返回值
1 | // 创建一个text.txt文件,写入一些自定义内容,使用fs.readFile来读取这个文件进行测试 |
自定义mayJunPromisify函数实现
自定义mayJunPromisify函数实现callback转换为promise,核心实现如下:
1 | function mayJunPromisify(original){ |
另一个功能是可以使用util.promise.custom符号重写util.promisify返回值。
在fs.readFile上定义util.promisify.custom符号,其功能为禁止读取文件
1 | // 注意顺序要在 util.promisify之前 |
自定义mayJunPromisify.custom实现
1 | // 所以说util.promisify.custom是一个符号 |
有些函数的回调形式是多个参数的,例如dns.lookup,它的回调形式是(err,address,family)=>...
拥有 三个参数,对这种情况也做兼容
基本使用:
1 | const dns = require('dns'); |
实现解析:
为了支持util.promisify也都会在函数上定义一个customPromisifyArgs参数,value为回调的多个参数的名称,类型为数组,例如dns.lookup绑定的customPromisifyArgs的value为['address','family']
,其主要目的也是为了适配util.promisify
dns.lookup支持util.promisify核心实现
1 | const {customPromisifyArgs} = require('internal/util'); |
customPromisifyArgs这个参数是从internal/util模块导出的,仅内部调用,在外部util.promisify是没有这个参数的。也就意味着只有Node模块中例如dns.lookup、fs.read等方法在多参数的时候可以使用util.promisify转换为Promise,如果自定义的callback存在多参数的情况,使用util.promisify则不行,不过可以基于util.promisify自己封装一个:
1 | module.exports = { |
(err,result)
改为(err,...values)
,原先的result仅接受一个参数,改为...values
接收多个参数1 | const kCustomPromisifyArgsSymbol = Symbol('customPromisifyArgs'); |
1 | // 由于kCustomPromiseArgsSymbol使用Symbol声明(每次重新定义都会不一样),且没有对外提供。要实现这个功能,需要每次在cb重新定义kCustomPromisifyArgsSymbol属性 |
使用:
1 | const {kCustomPromisifyArgsSymbol,mayJunPromisify} = require('./may-jun-promisify'); |
I/O即Input/Output,输入输出端口,是信息处理系统与外部世界之间的通信,输入手是系统接收的信号或数据,输出的则是从其发送的信号或数据
一次I/O操作分为等待资源,使用资源两个阶段,常见的词网络I/O,磁盘I/O
是对于操作系统内核而言的,发生在等待资源阶段,根据发起的I/O请求是否阻塞来判断
阻塞I/O:这种模式下一个用户进程在发起一个I/O操作之后,只有接收到响应或者超时时才可进行处理其他事情,否则I/O将会一直阻塞。以读取磁盘上的一段文件为例子,系统内核在完成磁盘寻道、读取数据、复制数据到内存之中之后,这个调用才算完成。阻塞的这段时间对CPU资源是浪费的。
非阻塞I/O:这种模式下一个用户进程发起一个I/O操作之后,如果数据没有就绪,会立刻返回(标志数据资源不可用),此时CPU时间片可以用来做一些其他事情。
同步与异步I/O发生在使用资源阶段。
同步I/O:应用发送或接受数据后,如果不返回,继续等待(此处发生阻塞),直到数据成功或失败返回。
异步I/O:应用发送或接受数据后立刻返回,数据写入OS缓存,由OS完成数据发送或接收,并返回成功或者失败的信息给应用,NodeJs就是典型异步编程的例子。
操作系统为了多个应用同时运行,需要保证不同进程相对独立、内核的安全。所以操作系统把内存空间划分为用户空、内核空间两部分。用户空间存放用户程序代码和数据,而内核空间则存放内核代码和数据。
OSI七层模型与网际网协议族图:传输层之上(会话层、表示层、应用层)为用户空间(Web客户端、浏览器、FTP),下四层(传输层,网络层,数据链路层,物理层)为内核空间,例如传输层的TCP/UDP就对应内核空间。
当进程调用 recvfrom() 函数的时候阻塞,应用程序开始系统调用,在系统内核数据就绪,将数据从内核中拷贝出来后结束。这个过程应用程序都处于等待状态,不能做其他事情,直到将数据拷贝到用户空间或出错才返回,我们称之为阻塞I/O模式。
想对于同步非阻塞I/O模式,同步非阻塞I/O在 每次调用之后,如果数据没有就绪就会立即返回,之后重复调用检查I/O操作是否就绪,这对CPU资源非常浪费,直到数据就绪将数据从内核拷贝到用户空间,返回成功指示到应用程序。
链接(Socket)并发大的时候,上面的两种就不适合了,前面一个处理不完,后面的就只能干等。多路复用技术先进行select数据就绪后,调用recvfrom进行真正的I/O读写操作。高级之处在于能够一个线程同时处理多个Socket
多路复用中的I/O通常指的是网络I/O,多路指的是多个Socker链接,复用指操作系统进行运算调度最小单位线程,整体的意思就是多个网络I/O复用一个或少量线程来处理Socket
I/O多路复用的四种实现:select/poll/epoll/kqueue
仅在Unix上支持,与I/O多路复用相比避免了select的阻塞轮询,应用程序进行系统调用后立即返回,处理其他事情,在数据就绪之后系统会发送一个SIGIO信号到应用程序,应用程序开始读取数据
目前最理想形式的一种,应用程序发起系统调用后无需等待直接返回当前调用状态,进行后续的其他任务,结果由内核完成I/O操作之后通过回调通知到应用程序,中间没有阻塞过程。Linux2.6增加了AIO,但是很少系统能够实现
操作方式上
文件描述符限制
操作系统的支持
目前高性能的Web服务器Nginx是基于epoll来实现高并发的
在Nodejs中,关于垃圾回收、内存释放不需要像C语言创建一个对象之后需手动创建一个delete/free的一个操作之后进行GC,Nodejs与java一样,由虚拟机进行内存自动管理。
node.js是基于Chrome v8引擎的javascript运行环境,V8就是虚拟机。
node提供process.memoryUsage方法来查看当前进程内存使用情况,单位为节
1 | /** |
堆用来存放对象引用类型,例如字符串、对象、在代码中创建一个Fruit存放在堆中
1 | // example.js |
1 | banana = null; |
垃圾回收指的是回收那些在应用程序中不再引用的对象,当一个对象无法从根节点访问这个对象就会作为垃圾回收的候选对象。这里的根对象可以为全局对象、局部变量,无法从根节点访问指的也就是不会再被其他活动对象所引用。
在V8中限制64位机制大约为1.4G,32位的大概是0.7G,对于一些大内存的操作需要谨慎否则超出V8内存限制会造成进程退出
内存溢出边界的例子:
1 | // overflow.js |
total为全局变量每次增长大概在160M左右且不会被回收,在接近V8边界时无法分配内存导致进程内存溢出
1 | node overflow.js |
v8提供了两个参数仅在启用阶段调整内存限制大小,分别为调整老生代、新生代:
内存不是越大越好,一方面是服务器资源昂贵,另外是V8以1.5G的堆内存进行一次小的垃圾回收大约需要50毫秒以上时间,会导致JavaScript进程暂停,这也是最主要的一方面。
新生代空间
由于新空间的垃圾回收机制很频繁,所以处理方式必须非常快,采用Scavenge算法,这是一种复制算法,新生代空间会被一分为二划分为两个相等大小的from-space和to-space工作方式是将from space中存活的对象复制出来,然后移动它们到to space 中或者被提升到老生代空间中,对于from space中没有存活的对象将会被释放,完成这些复制后再将from space和to space进行互换。
Scavenge算法适用少量内存的垃圾回收,但是有很大的空间开销,对于新生代少量内存是可以接受的
老生代空间
新生代空间在垃圾回收满足于一定的条件(是否经过Scavenge空间、to space内存占比)会被晋升到老生代空间中,在老生代空间中的对象都已经至少经历了一次或者多次的回收所以它们的存活概率会更大。在使用Scavenge算法会有两个缺点,一是将会重复的复制存活对象使得效率低下,二是对空间资源的浪费,所以在老生代空间中采用了 Mark-Sweep(标记清除)和Mark-Compact(标记整理)算法
Mark-Sweep处理时分为标记、清除两个步骤,与Scavenge算法只复制活对象相反的是在老生代空间中由于活对象占多数Mark-Sweep在标记阶段遍历堆中的所有对象仅标记活对象把未标记的死对象清除,这时一次标记清除就已经完成了。有一个问题是被清除的对象遍布于各内存地址,产生很多内存碎片
Mark-Compact(标记整理算法)为了解决内存碎片问题,在其工作过程中将活着的对象往一端移动,这时内存空间是紧凑的,移动完成之后,直接整理边界之外的内存。
V8使用了不同的垃圾回收算法Scavenge/Mark-Sweep/Mark-Compact.这三种垃圾回收算法都避免不了在进行垃圾回收时需要将应用程序暂停,待垃圾回收完成之后在恢复应用逻辑,对于新生代空间来说由于很快所以影响不大,但是对于老生代空间由于存活对象较多,停顿还是会造成影响的,因此V8又新增了增量标记的方式减少停顿时间。
内存泄露(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统奔溃等严重后果。
全局变量,未声明的变量或挂在全局global下的变量不会自动回收,将会常驻内存直到直到进程退出才会释放,除非通过delete或者重新赋值为undefined/null解决之间的引用关系,才会被回收。
闭包,也是一个常见的内存泄露问题,闭包会引用父级函数中的变量,如果闭包得不到释放,闭包引用的父级变量也不会释放从而导致内存泄露
例如:
1 | var theThing = null; |
代码运行时,每次执行replaceThing方法都会生成一个新的对象,但是之前的对象没有释放导致的内存泄露。
慎将内存作为缓存
通过内存来做缓存是最快的实现方式,缓存中的存储的键越多,长期存活的对象就越多,垃圾回收时将这些对象做无用功。
1 | // 下面一个获取用户Token的例子,memoryStore对象会随着用户数的增加而增长,当启动多个线程或者部署在多台机器会造成每个进程都保存一份,显然是资源的浪费,最好是通过Redis做共享 |
模块私有变量内存常驻
加载一个模块代码之前,Nodejs会使用一个如下的函数封装器将其封装,保证了顶层的变量(var,const,let)在模块范围内,而不是全局对象。这个时候就会形成一个闭包,在require时会被加载一次,将exports对象保存在内存中,直到进程退出才会回收,这个将会导致的是内存常驻,所以避免一些没必要的模块加载,否则也会造成内存增加
1 | (function(exports,require,module,__filename,__dirname){ |
事件反复监听
NodeJs中对一个事件反复监听则会报下面的错误,实际上使用的EventEmitter类,包含一个listeners数组,默认为10个监听器超出这个数则会报警,用于发现内存泄露,也可以通过emitter.setMaxListeners()方法为指定的EventEmitter实例修改限制
1 | MaxListenersExceededWarning:Possible EventEmitter memory leak detected, 11 connect added.Use emitter.setMaxListeners() to increase limit |
其他注意事项
使用定时器setInterval时记得使用对应的clearInterval进行清除。因为setInterval执行完之后会返回一个值且不会自动释放,另外还有map/filter等对数组进行操作,每次操作之后都会创建一个新的数组,将会占用内存,如果单纯的遍历map可以用forEach。
[Cache]
memory-fs 将文件写入内存[Cache]
Memory Cache[Cache]
Node Cache[Schedule]
node-schedule[Schedule]
Agenda 将Node中的定时任务存储在数据库中(官方推荐MongoDB)[Schedule]
Node.js结合RabbitMQ延迟队列实现定时任务[Template]
Ejs[Template]
Handlebarsjs[Template]
Jade[Framework]
Express 中文版、Express of English Version[Framework]
Koa 中文版、Koa of English Version[Framework]
Egg 中文版、Egg of English Version[Framework]
Nest.js 中文版、Nest.jsQPS
QPS(Query Per Second)指每秒查询量,规定时间内所能处理的流量大小,通常QPS值越大服务器的吞吐量也就越大,相对服务器负荷也会越高
QPS=并发量/平均响应时间并发量 = QPS * 平均响应时间
TPS
TPS(TransactionPerSecond)指每秒事物处理量,每秒钟系统所能处理的交易或事务的数量,用来形容系统的性能。
两者区别
一次下单请求,访问一次创建接口产生一次TPS,对于服务器的请求可能会产生多次,比如查询用户地址信息、商品数据信息、商品报价信息,这些请求计入QPS,也就是产生了3次QPS
根据二八法则来评估系统扩容需要多少台机器,二八法则即20%的时间承载80%的流量,把20%的时间称为峰值时间,换算公式:
1 | (总PV数 * 80%) / (每天描述 * 20%) = 峰值时间每秒请求数 |
假设有1000wPV,总共需要的QPS为多少?
1 | (1000000 * 0.8) / (24 * 60 * 60 * 0.2) = 463(QPS) |
假设每台机器支撑100QPS,则共需要的机器为
1 | 463(总有QPS)/100(单机QPS) = 5(约需要5台机器) |
返回的对象数组中有一个times字段,包含了user/nice/sys/idle/irq几个指标数据,分别代表CPU在用户模式、良好模式、系统模式、空闲模式、中断模式下话费的毫秒数。
1 | [ |
定义方法getCPUInfo用来获取系统CPU信息,提供了CPU利用率的实时监控,这个实时不是绝对的实时,会有时差,下面实现中默认设置的1秒钟,可以通过Options.ms进行调整
1 | const os = require('os'); |
使用方式:
1 |
|
node之前的世界,多线程服务器中,Web应用程序是用一个client/server(客户端/服务端)模式所编写的,其中client将向server请求资源并且将会根据这个资源以响应,server仅在client请求时做出响应,并在每次响应后关闭连接。这种模式是有效的,因为对服务器的每一个请求都需要时间和资源(内存、CPU等等),服务器必须完成上一个请求,才能接受下一个请求。需要同时处理N个请求,服务器就需要N个线程,如果有N+1个请求,就必须等N个线程中的任何一个可用。
服务器中的线程不是唯一的问题,一个线程为什么不能同时处理2个或者更多请求,是因为阻塞了Input/Output操作。
操作 | CPU时钟周期数(ticks) |
---|---|
CPU寄存器 | 3 |
L1 Cache(一级缓存) | 8 |
L2 Cache(二级缓存) | 12 |
RAM(随机存取存储器) | 150 |
Disk(磁盘) | 30 000 000 |
NetWork(网络) | 250 000 000 |
时钟周期也称为tick/clock cycle/clock period等,指一个硬件在被使用过程中,被划分为多个时间周期,当我们需要比较不同硬件性能时,就在不同硬件上测试同一个软件,观察它们的时钟周期时间和周期指数,如果时钟周期越长、周期越多,就意味着这个硬件需要的性能较低。
事件循环实际上是一个无限循环,并且线程里唯一可用的。
1 | static void StartNodeInstance(void* arg){ |
事件循环经历6个阶段,所有阶段的执行被称为tick
1 | pending callbacks ->idle,prepare->poll->check->close callbacks |
当Event loop需要执行I/O操作时,它将从一个池(通过Libuv库)中使用系统线程,当这个作业完成时,回调将排队等待在pending callbacks阶段被执行
设定一个函数,给定一个数,计算在一个数组中返回N个质数
1 | // primes.js |
工作线程对于执行CPU密集型的javascript操作非常有用,在I/O密集型的工作中用途不大,NodeJs内置的异步I/O操作比工作线程效率更高
修改代码:
1 | const { workerData, parentPort } = require('worker_threads'); |
Redis是一个免费开源、基于内存的高性能Key-Value数据库,具有速度快,数据持久化,多语言和多功能等特性。
速度快:10w OPS能力,单线程模型,数据存于内存中,注意一次只能运行一次命令,使用过程拒绝使用慢命令,例如keys/flushall/flushdb/slow lua script/mutil/exec等
持久化:基于内存模型,断电后或者服务重启会造成数据丢失,针对这个问题,提出两种数据持久化策略,分别为RDB和AOF,会将Redis在内存中的数据异步更新到磁盘中,实现数据的持久化功能。
丰富的数据类型:除了常见的String/HashTable/List/Set/Zset之外还有BitMaps(位图)、HyperLogLog(超小内存唯一计数)
多语言:基于TCP的通信方式,支持Node.Js/Python/Java/Ruby/Lua等。
多功能:提供了发布订阅、简单的事务功能、pipeline提高客户端并发效率。另外在Redis中所有单个命令操作都是原子性的,如果想对多个命令一起操作,可以借助Lua脚本(实现自定义命令,保证原子性)
keys *
:遍历所有key,生产环境不建议使用,时间复杂度O(n)dbsize key
:计算key的总数,Redis内置了这个计数器,会实时更新key的总数,时间复杂度O(1)exists key
:检查key是否存在,时间复杂度为O(1)expire key seconds
:key在指定second后过期,时间复杂度O(1)ttl key
:key剩余的过期时间,时间复杂度O(1)persist key
: 去掉key的过期时间,时间复杂度O(1)type key
:查看key的类型,时间复杂度O(1)最大限制512MB,适用于缓存、计算器、分布式锁等,字符串类型的值可以为简单的字符串、JSON、XML、数组甚至是二进制(视频)
命令 | 含义 | 时间复杂度 |
---|---|---|
set get del | 对key进行设置、读取、删除 | O(1) |
incr decr | 计数 | O(1) |
incrby decrby | 对计数设置增量 | O(1) |
setnx | key存在不做任何操作 | O(1) |
setex | key存在做操作与setnx相反 | O(1) |
getset | 设置新值返回旧值 getset key newValue | O(1) |
mset mget | 多个key进行设置、读取 | O(1) |
set
1 | set key value [ex seconds] [px milliseconds] [nx|xx] |
mget mset
mget/mset可以批量获取或设置值,如果使用get多次读取数据等价于n次网络时间+n次命令时间,这种方法可以用mget优化,等价于1次网络时间+n次命令时间,这是一个O(n)操作,避免命令过多客户端阻塞
1 | 127.0.0.1:6379> mset key1 val1 key2 val2 key3 val3 |
incr decr incrby decrby
1 | incr key |
缓存,对城市列表数据进行缓存
1 | // 伪代码 |
分布式锁
1 | set key value [EX seconds] [PX milliseconds] [NX|XX] |
计数器,网站的PV/UV统计,文章点赞、阅读量,视频网络的播放量,Redis提供的incr命令可实现计数器功能,性能好复杂度为O(1)
1 | const incrPageViewsCounter = pageId =>{ |
Session存储,Redis不会因为服务器重启导致Session数据丢失,具有数据持久化功能。
限流,短信发送为了避免接口被频繁调用,通常要在指定时间内避免重复发送
1 | const SMSLimit = async phone =>{ |
哈希结构有一个特点,所有命令都是H开头,hash类型其值本身就由一个或多个filed-value构成
1 | hashKey = { |
命令 | 含义 | 时间复杂度 |
---|---|---|
hset | 对key的field进行设置 | O(1) |
hget | 获取key指定的field | O(1) |
hdel | 删除key指定的field | O(1) |
hincrby hincrbyfloat | 类似于incrby incryfloat 这个操作的是field | O(1) |
hmset hmget | 对多个filed value 设置 读取 | O(n) |
hgetall | 获取key的所有field value | O(n) |
hvals | 获取key的所有value | O(n) |
hkeys | 获取key的所有fields | O(n) |
1 | 127.0.0.1:6379> hset student name Jack |
hash适合将一些数据存储在一起,例如缓存用户信息,与字符串不同的是,hash可以对用户信息结构中的每个字段单独存储,当需要获取信息时可以仅获取需要的部分字段。如果用字符串存储,两种方式,一种是将信息拆分为多个键(每个属性一个键)来存储,占用过的key同时占用空间;另外一种方式是序列化字符串存储,这种方式如果去数据只能全部取出并且还要进行反序列化,序列化/反序列化也有一定的内存开销
1 | // 缓存用户信息例子: |
Redis的列表用来存储字符串元素的集合,基于Linked Lists实现,意味着插入、删除操作非常快,时间复杂度为O(1),索引很慢,时间复杂度为O(n)
Redis列表命令都是L开头,在实际运用中可以作为队列或者栈
lpush+lpop
lpush+rpop
lpush+ltrim
lpush+brpop
命令 | 含义 | 时间复杂度 |
---|---|---|
lpush rpush | 列表左端/右端插入一个值 | O(1~n) |
linsert | 列表指定的值前/后插入新值 | O(n) |
lpop rpop | 列表左侧或者右侧弹出一个值 | O(1) |
blpop brpop | lpop rpop 的阻塞版本,需设置timeout | O(1) |
ltrim | 按照索引范围修剪列表 | O(n) |
lrange | 获取指定范围内的元素列表 | O(n) |
llen | 获取列表长度 | O(1) |
lset | 设置列表指定索引值为新值 | O(n) |
1 | 列表左侧加入三个元素 |
消息队列,Redis List结构的lpush与brpop命令可实现消息队列,lpush命令是从左端插入数据,brpop命令是从右端阻塞式的读取数据,阻塞读过程中如果队列中没有数据,会立即进入休眠直到数据到来或超过设置的timeout时间,会立即醒过来
1 | async function test(){ |
Redis集合类型可用来存储多个字符串元素,和列表不同,集合元素不允许重复,集合中的元素是无须的,也不能通过索引下标获取元素。Redis集合的命令都是S开头
命令 | 含义 | 时间复杂度 |
---|---|---|
sadd | 集合中添加元素,如果元素重复则添加失败 | O(1) |
srem | 删除集合中的元素 | O(1) |
scard | 计算集合中的元素个数 | O(1) |
sismember | 判断集合中是否存在指定元素 | O(count) |
srandmember | 随机从集合中返回指定元素 | O(n) |
sinter | 求集合交集 | |
sunion | 求集合并集 | |
sdiff | 求集合差集 |
1 | 集合中添加元素 |
抽奖,Redis的结合有去重功能,一些抽奖类项目中可以存储中奖的用户Id,能够保证同一个用户Id不会中奖两次
1 | async function test(userId){ |
计算用户共同感兴趣的商品,sadd与sinter可以用来统计用户共同感兴趣的商品,sadd保存每个用户喜欢的商品标签,使用sinter对每个用户感兴趣的商品标签求交集
Redis有序集合zset保留了集合set元素不能重复的特征之外,在有序集合的元素中是可以排序的,与列表使用索引下标不同的是有序集合是有序集合给每个元素设置一个分值(score)作为排序的依据
Redis有序集合是Z开头的
命令 | 含义 | 时间复杂度 |
---|---|---|
zadd | 集合中添加元素 | O(logN) |
zrem | 集合中删除元素 | O(1) |
zscore | 返回集合的分数 | O(1) |
zincrby | 增加或者减少集合的分数 | O(1) |
zcard | 返回元素的个数 | O(1) |
zadd
1 | zadd key [NX|XX] [CH] [INCR] score member [sroce member ...] |
1 | 有序集合 grages 中添加3个元素 |
Redis整个生命周期:发送命令->排队->执行命令->返回结果,慢查询通常发生在执行命令阶段,可以通过日志查询系统slowlog进行问题定位跟踪
在配置文件中redis.conf
设置:
Redis是每秒万级别,设置阀值时候,默认为10000微秒(10毫秒),不要设置太大,建议1毫秒之下,才有意义。定期将慢查询持久化到其他数据库,便于排查。
慢查询命令:
核心1次网络请求处理n条命令,redis本身命令处理时间是微秒级别,pipeline主要减少网络传输要求
角色:发布者(publisher)/订阅者(subscriber)/频道(channel):
publish channel message
subscribe [channel]
,可以订阅多个频道unsubscribe [channel]
用于地理位置定位,基于zset实现的:
geoadd key longitude latitude member
增加地理位置信息,例如:geoadd cities: 163.31 39.99 beijing
geopos key member [member...]
,获取地理位置消息,例如geopos cities: beijing
geodist key member1 member2 [unit]
:获取两地址位置距离,unit为单位(m/km/mi/ft),例如:geodist cities: beijing shanghai km
单机带来的问题机器故障、容量限制、QPS瓶颈,主从复制是一种一主多从的模式提供了数据副本,解决了单机带来的机器故障问题,另外主从分离模式还提供了Redis读的性能,也是高可用,分布式的基础。
所谓的主从复制就是一个Redis主节点拥有多个从节点,由主节点的数据单向复制到从节点,在一些读多写少的业务场景非常受用
Redis数据存储都是内存里,对数据的更新异步的存储在磁盘里,在Redis中的数据持久化有两种策略,RDB快照,AOF日历
把当前内存中的数据集快照写入磁盘,恢复时将快照文件直接读到内存里。
save和bgsave生成快照
save:使用save会造成客户端阻塞,它使用一种同步的方式生成RDB快照文件,因为Redis是单线程,如果save过程很长也会阻塞其他客户端的命令,在生产中是不建议使用的
bgsave:使用bgsave Redis会fork一个子进程来负责生成RDB文件,由于bgsave是异步进行的并不会阻塞其他客户端的命令,bgsave模式下阻塞阶段发生在fork过程中
其中stop-writes-on-bgsave-error这个配置很重要,如果子进程(也就是备用经常)在后台生成快照失败,主经常会停止新的写入操作,也是为了保持数据一致性
1 | 持久化默认时间策略 |
1 | 127.0.0.1:6379> save |
性能、时间耗时,存在不稳定性
RDB生成的过程就是将Redis内存中的dump到硬盘生成一个RDB文件,其实就是生成一个内存快照,save会造成阻塞,不建议生产环境去使用。besave的fork也是一个重量级操作,遵循copy-on-write(写入时复制)策略,新fork出的子进程会继续共享父进程的物理空间,使用COW技术可以避免不必要的资源分配,父进程的代码段和只读数据段都不允许修改,所以无需复制,当父进程处理些请求时会把要修改的页创建副本,而子进程在fork过冲中会共享父进程的内存快照
Copy on Write技术实现原理:fork之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写入内存时,CPU硬件检测到内存页是read-only,于是触发页异常中间(page-fault),陷入kernel的一个终端例程。中断例程中,kernal就会把触发的异常的页复制一份,于是分子进程各自持有独立的一份
COW技术可以减少分配和复制大量资源时带来的瞬间延迟,减少不必要的资源分配。
如果在fork之后,父子进程还需要进行读写操作,那么会产生大量的分页错误(页异常中断page-fault),这样就得不偿失了
Redis会根据自定义时间策略或者定时任务手动执行besave进行快照备份,如果某个出现宕机会丢失部分数据
以写日志的方式执行redis命令后,将数据写入AOF日志文件
redis命令写入过程,是先写入硬盘的缓冲区,缓冲区根据选择的策略写入到系统中,linux的glibc提供了fsync(init fd)函数可以将指定文件的内容强制从内核写入至磁盘,只要redis进程实时调用了fsync函数就可以保证AOF日志不丢失,由于fsync是一个磁盘I/O操作,所以不能每条redis指令都执行一次fsync,这样redis高性能就没有保证,有三种策略,默认推荐everysec策略:
将那些过期、重复的命令进行压缩减少,从而达到减少硬盘占用量,提高数据恢复速度
实现方式:
配置
1 | 是否开启 aof |
“在过去曾经发现一些很罕见的 BUG 导致使用 AOF 重建的数据跟原数据不一致的问题。” 这句话来自 Redis 实践官方,为了应对这种罕见的 BUG 可以使用 redis-check-aof 命令修复原始的 AOF 文件
1 | redis-check-aof --fix appendonly-6379.aof # appendonly-6379.aof 对应你的 aof 日志文件 |
一般来说,应该同时使用两种持久化功能,如果可以承受数分钟以内的数据丢失,可以只使用RDB持久化。很多用户都只使用AOF持久化,而定时生成RDB非常便于进行数据库备份,并且RDB恢复数据集的速度也要比AOF恢复的速度要快,除此之外,使用RDB还可以避免之前说过的AOFbug。
主从复制如果主节点发生故障,Redis Sentinel功能可以自动提升而不是人工干预
Sentinel是一个分布式系统,类似于Cousul集群,一般由3~5个节点组成,使用Raft算法实现领导者选举因为故障转移只需要一个Sentinel节点来完成
计数器在实际中有很多需求:PV/UV、接口并发限制、抽奖、优惠券领取等
业务需求方做优惠券发放活动,共优惠券10张,参与用户100人,先到先得,假设每次并发20用户同时访问,如何保证不超领取
每发送一次领取请求,采用incr命令进行自增,由于redis单线程的原因,可以保证原子性,不会出现超领
1 | // luck.js |
Lua是一种轻量小巧的脚本语言,用标准c语言编写并以源代码形式开放,设计目的是为嵌入应用程序中,从而为应用程序提供灵活的扩张和定制功能。由于Lua语言具有原子性,其在执行过程中不会被其他程序打断,对于并发下的数据一致性有一定的帮助
Redis支持两种运行Lua脚本,一种是直接Redis中输入Lua代码,适用于一些简单的脚本,另一种方式是编写Lua脚本文件,Redis使用SHA1算法支持对脚本签名和Script Load 预先缓存,需要运行的时候通过签名返回的标识符即可。
通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值
1 | EVAL script numkeys key[key...] arg[arg...] |
redis.call何redis.pcall是两个不同的Lua函数来调用redis命令,区别是如果redis命令中出现错误异常,redis.call会直接返回一个错误信息给调用者,而redis.pcall会以Lua的形式对错误进行捕获并返回
1 | 这里执行了两条 Redis 命令,第一条故意写了一个 SET_ 这是一个错误的命令,可以看到出错后,错误信息被抛出给了调用者,同时你执行 get name2 会得到 (nil),第二条命令也没有被执行 |
1 | // ioredis 支持所有脚本命令,比如EVAL/EVALSHA/SCRIPT,在现实场景中使用它是很繁琐的,要注意脚本缓存,并检测何时使用EVAL、EVALSHA.ioredis公开了一个defineCommand方法,使得脚本更容易使用 |
EVAL命令要求每次执行脚本的时候都发送一次脚本主体script body。Redis有一个内部缓存机制,因此它不会每次都重新编译脚本,通过EVALSHA来实现,根据给定的SHA1校验码,对缓存在服务器中的脚本进行求值
1 | EVALSHA sha1 numkeys key [key...] arg[arg...] |
1 | const Redis = require('ioredis'); |
有逻辑运算的脚本,可以编写Lua脚本文件
下面是一个测试代码,通过读取两个值比较返回不同的值,通过lua脚本实现后可以多条redis命令原子性
1 | ---test.lua |
1 | const Redis = require('ioredis'); |
线程锁:单线程编程模式下请求是顺序的,一个好处不需要考虑线程安全、资源竞争问题
进程锁:一个服务部署于一台服务器,同时开启多个进程,nodejs中为了利用操作系统资源,根据CPU的核心数可以开启多程模式,这个时候如果对于一个共享资源操作还是会遇到资源竞争的问题,另外每一个进程都是相互独立,拥有自己独立的内存空间,关于进程锁通过java的synchronized也很难解决,它仅局限在同一个JVM中有效
分布式锁:一个服务无论是单线程还是多进程模式,当多机部署、处于分布式环境下对统一共享资源进行操作还是会面临同样的问题。由于先读数据在通过业务逻辑修改之后进行SET操作,这并不是一个原子操作,当多个客户端对同一个资源进行先读后写操作就会引发并发问题,这个时候就需要分布式锁去解决。
实现分布式锁的方式:数据库、redis、zookeeper,通过redis来实现一个分布式锁,保持三个特性:安全性、死锁、容错
安全性:上锁,在任意时刻保持仅有一个客户端持有该锁
死锁:造成死锁可能是由于某种原因,本该释放的锁没有被释放,因此在上锁的时候可以同步设置过期时间,由于客户端自己的原因没有被释放,也要保证锁能够自动释放
容错:容错是在多节点的模式下需要考虑的,只要保证N/2+1节点可用,客户端就可以成功获取、释放锁
在Redis的单节点实例下实现一个简单的分布式锁,会借助简单lua脚本来实现原子性
通过setnx命令占坑,为了防止死锁,通常在占坑之后还会设置一个过期时间expire:
1 | setnx key value |
上面的操作不是一个原子性操作,原子性操作是指命令在执行过程中并不会被其他线程或者请求打断,如果setnx执行成功之后,出现网络闪断expire命令便不会得到执行,会导致死锁出现。
通过:
1 | set key value [EX seconds] [PX milliseconds] [NX|XX] |
释放锁的过程是将原来占有的坑删除,加锁的过程把value设置为一个随机值,在del key之前先判断这个key存在且value等于自己制定的值才执行删除操作,判断和删除不是一个原子性操作,借助Lua脚本实现
1 | if redis.call("get",KEYS[1]) == ARGV[1] then |
1 | class RedisLock { |
1 | const Redis = require('ioredis'); |
同时调用了两次test方法进行上锁,只有第一次是成功的,第二次name1 上锁的时候发现key=name1已被占坑,开始重试,由于以上的测试中设置了3秒之后自动释放,name1 在经过两次重试之后上锁成功
1 | name1 26e00260-0532-11ea-b978-2160dffafa30 上锁成功 |
在Sentinel/Redis Cluster下,如果客户端A在主节点获取到锁之后,主节点还未来得及同步信息到从节点就挂掉了,这时候Sentinel 会选举另外一个从节点作为主节点,那么客户端B此时也申请相同的锁,就会出现同一个锁被多个客户端持有,对数据的最终一致性有很高的要求还不行。
鉴于这些问题,官方提供了一个使用Redis实现分布式锁的规范算法Redlock,在单实例或者多实例提供强有力的保障,本身具备容错能力,会从N个实例使用相同的key、随机值尝试set ket value [EX seconds] [PX milliseconds] [NX|XX]
命令去获取所,在有效时间内至少 N/2+1
个Redis实例取到所,否则取锁失败,失败情况下客户端应该在所有Redis实例上进行解锁
1 | const Redis = require('ioredis'); |
对同一个key name1 两次上锁,由于client1 先取到了锁,client2 无法获取锁,重试5次之后报错:LockError:Exceeded 5 attempts to lock the resource 'name1'
分析一下从获取服务器时间到前端显示倒计时的过程:
服务器响应完成的时间就是服务器时间,但是经过网络传输就会有误差,由网络环境决定误差大小,一般是几十ms或者几百ms。
可得出:当前服务器时间=服务器系统返回时间+网络传输时间+前端渲染时间+常量(可选)
前端渲染时间,避免不同浏览器渲染快慢差异差异造成明显的时间不同步,可以在服务器返回当前时间和本地签单的时间的差值得出
获得服务器时间后,前端进入倒计时计算和计时器显示,要考虑js代码和线程阻塞造成计时器延时问题,思路是通过引入计数器,判断计时器延迟执行的时间来调整,尽量让误差缩小,不同浏览器不同时间段打开页面倒计时误差可控制在1s以内
1 | // 线程占用 |
知识点:
高阶函数,解决异步问题
、发布订阅模式和观察者模式
、promise核心应用,promise解决异步问题
、实现一个完成的promise库
、promise中常见的面试题
、扩展promise中常见的方法
、掌握 generator的使用以及co库的应用
、异步方案 async+await
1 | // 高阶函数 函数参数如果是函数,或者这个函数返回一个新的函数,我们就叫它高阶函数 |
1 | class Transaction { |
1 | // 判断数据类型 |
1 | // lodash after 在执行多少次之后 |
1 | // node 方法异步的 I/O |
1 | // 观察者模式 |
又到了跟着文档码字学习的阶段,hook
从提案到现在已经很久了。在这之前但是还没有真正地去了解这个 react 新玩具。跟随文档学习,并尝试重构一些项目
官方自带的视频已经很好地介绍了 hook
Hook 是 React.16.8 新增特征,可以让你在不编写 class的情况下使用 state 以及其他 React 特性
State Hook
简单的示例:
1 | import React,{useState} from 'react' |
通过在函数组件里调用它来给组件添加一些内部的 state
,React 会在渲染的时候保留这个 state
。useState
会返回一堆值:当前值和一个让你更新它的函数,可以在时间处理函数中或者其他一些地方调用这个函数。类似 class
组件的 this.setState
,但是它不会把新的 state
和旧的 state
进行合并。
useState
唯一的参数就是初始的state
。上面的例子中,计数器从零开始的,所有初始 state
就是0。注意不同于 this.state
,这里的 state
不一定要是一个对象。
在一个组件中声明多个变量
1 | function ExampleWithManyStates(){ |
Hook 是一些可以让你在函数组件里 钩入 React state
以及生命周期函数等特性的函数。Hook 不能在 class 组件中使用。使得不用 class 也可以使用 React.
Effect Hook
React 组件中数据获取、订阅或者手动修改 DOM,都统称为副作用,或者称为作用
useEffect
就是 Effect Hook,给函数组件增加了操作副作用的能力,跟 class 组件的 componentDid
、componentDidUpdate
、componentWillUnmount
具有相同的用途,只不过被合并成了一个AOU
例子,在 React 更新 DOM 后设置一个页面的标题
1 | import React, { useState, useEffect } from 'react' |
当调用 useEffect
,就是在告诉 React 在完成对 DOM 的更改后运行副作用函数,由于副作用函数是在组件内声明的,所有可以访问到组件的 props
或者 state
。默认情况下,React 会在每次渲染后调用副作用函数——包括第一次渲染的时候
副作用函数还可以通过返回一个函数来指定清除副作用。例如,在下面的组件中使用副作用函数来订阅好友的在线状态,并通过取消订阅来进行清除操作:
1 | import React,{useState,useEffect} from 'react' |
上面的实例中,React会在组件销毁或者后续渲染时重新执行副作用函数,取消对 ChatAPI
的订阅。
跟 useState
一样,可以在组件中多次使用 useEffect
1 | function FriendStatusWithCounter(props){ |
通过使用 Hook,可以把组件内相关的副作用组织在一起(例如创建订阅以及及时取消),而不要把它们拆分到不同的生命周期函数
Hook
使用规则Hook 就是 javascript 函数,但是使用它们会有两个额外的规则:
只能在函数最外层调用Hook。不要在循环、条件判断或者子函数中调用
只能在React 的函数组件中调用Hook。
Hook
在之前,组件之间复用一些状态逻辑,有两种主流方案:高阶组件、render props
。自定义 Hook
可以在不增加组件的情况下达到相同的目的
FriendStatus
组件,通过调用 useState
和 useEffect
的 Hook 来订阅一个好友的在线状态,假设我们想在另一个组件里复用这个订阅逻辑
首先,把逻辑提取到一个叫做 useFriendStatus
的自定义 Hook
里:
1 | import React,{useState,useEffect} from 'react' |
它将 friendID
作为参数,并返回该好友是否在线,我们可以在两个组件中用到它:
1 | function FriendStatus(props){ |
两个组件中的 state 是完全独立的。Hook 是一种复用状态逻辑的方式,不复用 state 本身,事实上,Hook 每次调用都有一个完全独立的 state,因此可以在单个组件中多次调用同一个自定义 Hook
Hook
还有一些使用频率较低的但很有用的 Hook
,比如使用 useContext
可以不使用组件嵌套订阅 React 的 Context
1 | function Example(){ |
State Hook
1 | // class 示例 |
Hook
和函数组件1 | const Example = (props)=>{ |
Hook 在 class 内部是不起作用的,可以使用来替代 class
Effect Hook
Effect Hook
可以让你在函数组件中执行副作用操作
1 | import React,{useState,useEffect} from 'react' |
数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。在 React 组件中有两种常见副作用操作:需要清除的和不需要清除的。
有时候,我们只想在 React 更新DOM 之后运行一些额外的代码,比如网络请求、手动变更 DOM,记录日志,这些都是常见的无需清除的操作。因为我们在执行完这些操作之后,就可以忽略他们了。
在 React 的 class 组件中,render
函数是不应该有任何副作用的,一般来说,在这里执行操作太早了,我们都希望在 React 更新 DOM 之后才执行我们的操作
这也是为什么把副作用放在 componentDidMount
和componentDidUpdate
函数中。
下面的示例,React 计数器的 class 组件,在 React 对 DOM 进行操作后,立即更新了 document 的 title 属性
1 | class Example extends React.Component{ |
1 | import React,{useState,useEffect} from 'react' |
与 componentDidMount 和 componentDidUpdate不同,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕,让应用看起来响应很快。大多数情况下,effect不需要同步地执行,在个别情况下(例如测试布局),有单独的 useLayoutEffect Hook 使用
订阅外部数据源等一些副作用是需要清除的,可以防止内存泄露。
通常会在 componentDidMount
中设置订阅,在 componentWillMount
中清除它。假设我们有一个 ChatApI
模块,运行我们订阅好友的在线状态
1 | class FriendStatus extends React.Component{ |
useEffect 设计在同一个地方执行添加和删除订阅,effect返回一个函数,React就会在指定清除的时候调用它
1 | import React,{useState,useEffect} from 'react' |
使用hook其中一个目的就是要解决class 中声明周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。上面代码是示例中计数器和好友状态指示器逻辑组合在一起的组件:
1 | class FriendStatusWithCounter extends React.Component{ |
而使用 hook,跟使用多个 state 的 hook一样,可以使用多个 effect将不相关逻辑分离到不同的 effect中
1 | function FriendStatusWithCounter(props){ |
为什么 effect 在每次重渲染都会执行,而不是在卸载组件的时候执行一次。
上述用于显示好友是否在线的 FriendStatus 组件,从 class 中 props 读取 friend.id,然后在组件挂载后订阅好友状态,并在卸载组件的时候取消订阅
1 | componentDidMount(){ |
Hook版本
1 | function FriendStatus(props){ |
不需要特定的代码来处理更新逻辑,useEffect默认就会处理。它会在调用一个新的 effect 之前对前一个 effect 进行清理。下面按时间列出一个可能会产生的订阅和取消订阅调用序列
1 | // Mount with {friend:{id:100}} props |
默认行为保证了一致性,避免了在 class 组件因为没有处理更新逻辑而导致常见 bug
在某些情况下,每次渲染后都会执行清理或者执行 effect 可能会导致性能问题。在 class 组件中,可以通过 componentDidUpate 中添加 prevProps 或者 prevState 的比较逻辑解决:
1 | componentDidUpdate(prevProps,prevState){ |
这是很常见的需求,被内置到了 useEffect 的 hook api中,如果某些特定值在两次重渲染中没有发生变化,可以通过 React 跳过对 effect 的调用,只要传递数组作为 useEffect 的第二个参数即可:
1 | useEffect(()=>{ |
注意:
如果要使用这种优化方法,确保数组中包含了所有外部作用域中会随着时间变化并且在 effect 中使用的变量,否则代码会引用到先前渲染中的旧变量。
如果想只执行一次 effect 仅在组件挂载或者卸载时执行,可以传递一个空数组([]),作为第二个参数,告诉 React 的 effect 不依赖于props 或者 state 中的任何值,所以它永远都不需要被重复执行,这不属于特殊情况,依然遵循数组的工作方式。
如果传入了一个空数组,effect 内部的 props 和 state 就会一直拥有其初始值。尽管传入的空数组作为第二个参数更加接近熟悉的 componentDidMount 和 componentWillUnMount 思维方式。React 会等待浏览器完成画面渲染后才会延迟调用 useEffect,因此会使得额外操作很方便。
启用 eslint-plugin-react-hooks
中的 exhaustive-deps
规则。此规则会在添加错误依赖时发出警告并给出修复建议。
Hook
规则Hook 本质就是 JS 函数,使用它需要遵循两条规则。 linter 插件来强制执行这些规则:
Hook
不要在循环,条件或者嵌套函数中调用 Hook,确保总是在 React 函数的最顶层调用它们。遵循这条规则,就能确保 hook 在每一次渲染中都按照同样的顺序被执行.
Hook
不要在普通的 JS 函数中调用 Hook,可以在React 函数组件中调用 Hook,在自定义 Hook 中调用其他 Hook
遵循以上规则,确保组件的状态逻辑在代码中清晰可见
eslint-plugin-react-hooks
的 ESLint 插件来强制执行这两条规则。如果你想尝试一下,可以将此插件添加到你的项目中:
1 | npm install eslint-plugins-react-hooks --save-dev |
1 | // ESLint 的配置 |
单个组件中使用多个 State Hook或者 Effect Hook
1 | function Form(){ |
React 怎么知道哪个state 对应哪个 useState,答案是React靠Hook调用的顺序。
1 | // 首次渲染 |
只要 Hook 的调用顺序在多次渲染中保持一致,React 就能正确将内部 state 和对应的 hook 进行关联。
1 | // 倘若将一个 hook 调用放入到一个条件语句中会发生什么 |
React 不知道第二个useState 的 Hook 应该返回什么,React 以为在该组件中第二个 Hook 的调用像上次渲染一样。对应的是 persistForm的 effect,但并非如此。从这里开始,后面的 Hook调用都被提前执行,导致了bug的产生。
这就是为什么 Hook 需要在我们组件的最顶层调用,如果要有条件地执行一个 effect,可以将判断放在 Hook 的内部
1 | useEffect(function persistForm(){ |
Hook
通过自定义 Hook,可以将组件逻辑提取到可重用的函数中
1 | import React,{useState,useEffect} from 'react' |
假设聊天应用中有一个联系人列表,当用户在线时需要把名字设置为 绿色,我们可以把上面类似的逻辑复制并粘贴到 FriendListItem 组件中,但这并不是理想的解决方案
1 | import React,{useState,useEffect} from 'react' |
我们希望在FriendStatus以及FriendListItem 之间共享逻辑。
Hook
当我们想在两个函数之间共享逻辑时,会把它提取到第三个函数中,而组件和Hook都是函数,所以也使用这种方式
自定义Hook 是一个函数,名称以 use 开头,函数内部可以调用其他 hook,下面 useFriendStatus 就是定义的 Hook
1 | import React,{useState,useEffect} from 'react' |
此处 useFriendStatus
的 Hook 目的是订阅某个好友的在线状态。这就是我们需要将 friendID
作为参数,并返回这位好友的在线状态的原因。
Hook
1 | function FriendStatus(props){ |
代码等价于原来的示例代码 ,自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。
自定义 Hook 必须以 “use
” 开头
在两个组件中使用相同的 Hook 不会共享 state
自定义 Hook 每次调用 都会获取独立的 state
Hook
之间传递信息由于 Hook 本身就是函数,因此我们可以在它们之间传递信息。
将使用聊天程序中的另一个组件来说明这一点,这是一个聊天消息接收者的选择器。会显示当前的好友是否在线
1 | const friendList = [ |
将当前选择的好友ID保存在 recepientID 状态变量中,并在用户从 Select 中选择其他好友时更新这个state
由于 useState 提供了 recipientID 状态变量的最新值,我们可以将它作为参数传递给自定义的 useFriendStatus Hook
1 | const [recipientID,setRecipientID] = useState(1) |
当我们选择不同的好友并更新 recipientID 状态变量时,useFriendStatusHook 将会取消订阅之前选中的好友,并订阅新选中的好友状态
Hook API
索引Hook
1 | const [state,setState] = useState(initialState) |
返回一个state,以及更新 state 的函数。在初始渲染期间,返回的状态(state)与传入的第一个参数(initialState)值相同。
setState
函数用于更新state,它接收一个新的state值并将组件的一次重新渲染加入队列
1 | setState(newState); |
后续渲染中,useState 返回的第一个值始终是更新后最新的 state。React会确保 setState 函数的标识是稳定的,并且不会在组件重新渲染时发生变化,这就是为什么可以安全地从 useEffect 或 useCallback 的依赖列表中省略 setState
函数式更新
如果新的state需要通过使用先前的 state计算得出,那么可以将函数传递给 setState,该函数将接收先前的 state,并返回一个更新后的值,下面的例子展示了 setState 的两种用法:
1 | function Counter({initialCount}){ |
+
和-
采用函数式形式,因为被更新的的 state 需要基于之前的 state,但是重置按钮则采用普通形式,因为它总是把 count 设置回初始值
与 class 组件的 setState 方法不一致,useState 不会自动合并更新对象,使用函数式的 setState 结合展开运算符达到合并更新对象的效果
1 | setState(prevState=>{ |
惰性初始 state
initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数计算并返回初始 state,次函数只在初始渲染被调用
1 | const [state,setState] = useState(()=>{ |
跳过state 更新
调用 State Hook 的更新函数并传入当前的 state ,React 将跳过子组件的渲染以及 effect 的执行,React 使用Object.is 比较算法来比较state
需要注意的是,React可能仍需要在跳过渲染前渲染该组件,不过由于React不会对组件数的深层节点进行不必要的渲染,所以不用担心。如果在渲染期间执行了高开销的计算,则可以使用 useMemo 优化。
1 | useEffect(didUpdate) |
该hook 接收一个包含命令式、并且可能有副作用代码的函数。
在函数组件主体内改变 DOM,添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性
使用 useEffect
完成副作用操作,赋值给 useEffect
的函数会在组件渲染到屏幕之后执行。默认情况下,effect 将在每轮渲染结束后执行,可以渲染让它在只有某些值的时候才执行。
清除effect
通常,组件卸载时需要清除 effect 创建的诸如订阅或者定时器ID 等资源,要实现这一点,需要返回一个清除函数。
1 | useEffect(()=>{ |
为了防止内存泄露,清除函数会在组件卸载前执行,另外,如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已经被清除。上面的例子意味着组件的每一次更新都会创建新的订阅,若想避免每次更新都触发 effect 的。
effect的执行时机
与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会被延迟调用。这使得它适用于很多常见的副作用场景,如设置订阅和事件处理等情况,因为不应该在函数中执行阻塞浏览器更新屏幕的操作。
然后不是所有的 effect 都可以被延迟执行的,例如在浏览器执行 下一次绘制前,用户可见的 DOM变更就必须同步执行,这样永不才不会感觉到视觉上的不一致。React 提供了 useLayoutEffect Hook 来处理这类 effect,和 useEffect 结构相同,但是调用时机不同。
虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何渲染前执行,React 将在组件更新前刷新上一轮的渲染的 effect
effect的条件执行
默认情况下,effect 会在每轮组件渲染完成后执行,一旦 effect 的依赖发生变化,它就会被重新创建。但我们不需要再每次更新时都创建新的订阅,而仅在 props 改变的时候重新创建,可以给 useEffect 传递第二个参数,它是 effect 依赖的值数组
1 | useEffect(()=>{ |
1 | const value = useContext(myContext); |
接收一个 context 对象(React.createContext的返回值)并返回该 context 的当前值。当前 context 值由上层组件中距离当前组件最新的 <MyContext.Provider>
的 value prop 决定。
当上层最近的 <MyContext.Provider>
更新时,该Hook 会触发重新渲染,并使用最新传递给 MyContext provider 的 context value 值
别忘记 useContext
的参数必须是 context 对象本身:
useContext(MyContext)
useContext(MyContext.Consumer)
useContext(MyContext.Provider)
调用了 useContext的组件总会在 context 值变化时重新渲染,如果重新渲染组件开销比较大的话,可以通过 memoization 优化
useContext(MyContext)
只是能够读取 context 的值以及订阅 context 的变化,仍然需要在上层组件树中使用 <MyContext.Provider>
来为下层组件提供 context
1 | const [state,dispatch] = useReducer(reducer,initialArg,init) |
它接受一个 (state,action)=> newState
的 reducer,并返回一个当前的 state以及与其配套的 dispatch
方法。
在某些场景下,useReducer 比 useState更适用。例如 state 逻辑复杂且包含多个子值,或者下一个 state 依赖之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为可以向子组件传递 dispatch 而不是回调函数。
reducer 重写 useState 的计数器
1 | const initialState = {count:0} |
指定初始state
有两种初始化 useReducer state 的方式,可以根据使用场景选择其中的一种。将初始 state 作为第二个参数传入 useReducer 是最简单的方法:
1 | const [state,dispatch] = useReducer( |
惰性初始化
选择惰性地创建初始化 state,为此需要将 init 函数作为 useReducer 的第三个参数传入,这样初始 state 将被设置为 init(initialArg)
这样做,可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 state 的 action 做处理提供了便利:
1 | function init(initialCount){ |
跳过 dispatch
如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染以及副作用的执行。需要注意的是,React 可能仍需要跳过渲染前再次渲染该组件。不过由于React 不会对组件树的深层节点进行不必要的渲染,所以不用担心。如果在渲染期间执行了高开销的计算,则可以使用 useMemo
来进行优化
1 | const memoziedCallback = useCallBack( |
返回一个 memoized 回调函数
把内联回调函数以及依赖数组作为参数传入 useCallback ,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会跟新。当你把回调函数给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件,它将非常有用
useCallback(fn,deps)
相当于 useMemo(()=>fn,deps)
1 | const memoizedValue = useMemo(()=>computeExpensiveValue(a,b),[a,b]) |
返回一个 memoized值。
把创建函数和依赖项作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized值,这种优化有助于避免在每次渲染时都进行高开销计算。
传入 useMemo 的函数会在渲染期间执行,不要在这个函数内部执行于渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo
如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值
1 | const refContainer = useRef(initialValue) |
useRef 返回一个可变的 ref 对象,其 .current
属性被初始化为传入的参数 initialValue 。返回的 ref 对象在组件的整个生命周期内保持不变。
命令式访问子组件:
1 | function TextInputWithFocusButton(){ |
1 | useImperativeHandle(ref,createHandle,[deps]) |
useImperativeHandle
可以让你在使用 ref
时自定义暴露给父组件的实例值。useImperativeHandle
应当与 forwardRef
一起使用:
1 | function FancyInput(props,ref){ |
其函数签名与 useEffect 相同,但它会在所有的DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划会被同步刷新。尽可能使用 标准的 useEffect 以避免阻塞视觉更新
注意:
useLayoutEffect 与 componentDidMount、componentDIdUpdate的调用阶段是一样的。
如果使用服务端渲染,无论是 useLayoutEffect 或者 useEffect 都无法在 JS 代码加载完成之前执行。这就是为什么在服务端渲染组件中引入 useLayoutEffect 代码会触发React 警告。解决这个问题,需要将代码逻辑移到 useEffect 中(如果首次渲染不需要这段逻辑的情况下),或是将该组件延迟到客户端渲染完成后再显示(直到 useLayoutEffect执行之前HTML 都显示错误的情况下)
若要从服务端渲染的 HTML 中排除依赖布局 effect 的组件,可以通过使用 showChild && <Child>
进行条件渲染,并使用 useEffect(()=>{setShowChild(true)},[])
延迟展示组件。这样在客户单渲染完成之前,UI就不会像之前那样显示错乱了。
1 | useDebugValue(value) |
useDebugValue
可用于 React 开发工具中显示自定义 hook 标签
1 | function useFriendStatus(friendID){ |
延迟格式化 debug值
某些情况下,格式化值的显示可能是一项开销很大的操作,除非检查 Hook,否则没有必要这么做。因此,useDebugValue 接受一个格式化函数作为可选的第二个参数。该函数只有在 hook 检查才会被调用。它接受 debug 值作为参数,并且会返回一个格式化的显示值。
例如,一个返回 Date 值的自定义 Hook 可以通过格式化函数来避免不必要的 toDateString 函数调用
1 | useDebugValue(date,date=>date.toDateString()) |
ustbhuangyi 老师的 基于TypeScript从零重构axios学习记录。
知识点
TypeScript 常用语法:
基础类型
、 函数
、 变量声明
、 接口
、 类
、 泛型
、 类型推新
、 高级类型
axios js库:
项目脚手架
、 基础功能实现
、 异常情况处理
、 接口扩展
、 拦截器实现
、 配置化实现
、 取消功能实现
、 其他功能实现等等
主要工具:Jest
、 TSLint
、 Commitizen
、 Prettier
、 RollupJS
、 Semantic release
src/types/index.ts
1 | export type Method = 'get' | 'GET' | 'delete' | 'Delete' | 'head' | 'HEAD' | 'options' | 'OPTIONS' | 'post' | 'POST' | 'put' | 'PUT' | 'patch' | 'PATCH' |
src/xhr.ts
1 | import { AxiosRequestConfig } from './types' |
src/index.ts
1 | import { AxiosRequestConfig } from './types' |
data.ts
1 | import { isPlainObject } from "./util"; |
headers.ts
1 | import { isPlainObject } from "./util" |
url.ts
1 | import { isDate, isPlainObject } from './util' |
定义响应接口
types/index
1 | export interface AxiosResponse { |
处理 headers 的数据
helpers/header.ts
1 | export function processHeaders(headers: any, data: any): any { |
处理 响应data
helpers/data.ts
1 | export function transformResponse(data: any): any { |
修改 xhr, 返回一个 Promise
xhr.ts
1 | import { AxiosRequestConfig, AxiosPromise, AxiosResponse } from './types' |
)
.replace(/%2C/ig, ',').replace(/%20/g, '+').replace(/%5B/ig, '[').replace(/%5D/ig, ']')
}
export function buildURL(url: string, params?: any): string {
if (!params) {
return url
}
const parts: string[] = []
Object.keys(params).forEach(key => {
const val = params[key]if (val === null || typeof val === 'undefined') { return}let values = []if (Array.isArray(val)) { values = val key += '[]'} else { values = [val]}values.forEach(val => { if (isDate(val)) { val = val.toISOString() } else if (isPlainObject(val)) { val = JSON.stringify(val) } parts.push( `${encode(key)}=${encode(val)}` )})
})
let serializedParams = parts.join(‘&’)
if (serializedParams) {
const markIndex = url.indexOf('#')if (markIndex !== -1) { url = url.slice(0, markIndex)}url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams
}
return url
}
1 |
|
定义响应接口
types/index
1 | export interface AxiosResponse { |
处理 headers 的数据
helpers/header.ts
1 | export function processHeaders(headers: any, data: any): any { |
处理 响应data
helpers/data.ts
1 | export function transformResponse(data: any): any { |
修改 xhr, 返回一个 Promise
xhr.ts
1 | import { AxiosRequestConfig, AxiosPromise, AxiosResponse } from './types' |
DellLee 老师的 前端要学的测试课 从Jest入门到TDD/BDD双实战学习记录。
知识点
Jest 基础
基础API
、异步测试
、Mock技巧
、快照
、timer测试
、Dom测试
实现项目
Vue
、Vue-test-utils
、React
、Enzyme
、TDD+单元测试
、BDD+集成测试
1 | // math.js |
优点:性能、功能、易用性、速度快、Api简单、易配置、隔离性好、监控模式、IDE整合、Snapshot、多项目并行、覆盖率、Mock丰富
1 | 项目内部调用 jest |
jest.config.js
1 | // For a detailed explanation regarding each configuration property, visit: |
jest 未配置转换时,默认只支持 commonjs 语法。
1 | { |
jest转换(commonjs -> es module)大概工作原理:
第二个参数为精度,代表几位小数点,默认为2位
1 | // 数字相关 |
toBeNull
只匹配 null
toBeUndefined
只匹配 undefined
toBeDefined
与 toBeUndefined
相反toBeTruthy
匹配任何 if
语句为真toBeFalsy
匹配任何 if
语句为假在JavaScript中,有六个falsy值:false
,0
,''
,null
,undefined
,和NaN
。其他一切都是真实的。
1 | // 真假相关 |
1 | test('匹配器:not-不是xx', () => { |
匹配不是接收值的子集
1 | describe('not.arrayContaining', () => { |
匹配不包含某个对象
1 | describe('not.objectContaining', () => { |
匹配不包含某个字符串
1 | describe('not.stringContaining', () => { |
同上,不过参数可以是正则表达式
1 | describe('not.stringMatching', () => { |
regexpOrString
)1 | // 字符串 |
匹配包含某个字符串
匹配字符串,可用正则
1 | describe('stringMatching in arrayContaining', () => { |
1 |
|
匹配子集
1 | describe('arrayContaining', () => { |
匹配任何递归预期属性的接收对象
1 | test('测试onPress函数回调参数匹配对象', () => { |
检查对象中各种属性存在和值,第二个参数是可选的
1 | // Object containing house features to be tested |
检查对象的属性的子集相匹配
1 | const houseForSale = { |
检查对象是一个类的实例
1 | class A {} |
1 | const throwNewErrorFunc = () => { throw new Error('this is a new error') } |
匹配除了 null
、undefined
的任意值,可以它使用在 toEqual
或者 toBeCalledWith
里面替代文字值
1 | test('map 遍历一个非空的参数',()=>{ |
匹配任意构造器生成的实例
1 | function randocall(fn){ |
检查对象有个 length 属性并将设为某一数值。这对于检查数组或字符串大小特别有用。
1 | expect([1, 2, 3]).toHaveLength(3); |
检查项目在数组或者字符串是否是另一个字符串的子串
1 | test('the flavor list contains lime', () => { |
检查具有特定结构和值的元素是否包含在数据中
1 | it('test contain', () => { |
匹配在测试用例里面使用断言的次数。确保在异步函数为了确保在回调函数里面断言被调用特别有用。
1 | test('异步调用所有回调',()=>{ |
匹配在测试用例里面至少使用一次断言
使用 resolves
解开 fulfilled promise,如果 reject promise,则断言失败
1 | test('resolves to lemon', () => { |
测试是异步的,需要告诉 jest 等待返回解开的断言
也可以用 async/await
结合 .resolves
1 | test('resolves to lemon', async () => { |
使用 rejects
解开 rejected promise,如果 fulfilled promise,则断言失败
1 | test('rejects to octopus', async () => { |
测试是异步的,需要告诉 jest 等待返回解开的断言
同样使用 async/await
结合 .rejects
1 | test('rejects to octopus', async () => { |
确保模拟功能得到调用
1 | function drinkAll(cb, flavour) { |
确保模拟功能得到调用次数与指定数字一致
1 | test('drinkEach drinks each drink', () => { |
确保模拟功能被调用的具体参数
1 | function calledWithArg(cb) { |
确保模拟功能被最后一次调用的具体参数
确保模拟功能多次调用的顺序
1 | test('drinkEach drinks each drink', () => { |
n 必须是从 1开始的正整数
测试模拟函数成功返回(即没有抛出错误)至少一次
1 | test('test calledWithArg', () => { |
确保模拟函数返回成功的次数,抛出错误的模拟函数的任何调用都不计入函数返回的次数
1 | test('drink returns twice', () => { |
确保模拟函数返回特定的值
1 | test('test Return 123', () => { |
确保模拟函数最后一次返回特定的值
确保模拟函数第n次调用返回特定的值
第n个参数必须是从1开始的正整数。
pass
表示是否有匹配,message
提供一个没有参数的函数,在出现错误的情况下返回消息。当pass:false
,message
返回 expect(x).matcher()
失败的错误信息,pass:true
,message
返回当expect(x).not.matcher()
失败时的错误消息
1 | // expect.extend(matchers) |
异步扩展,需要结合 async
和 await
函数来使用
1 | expect.extend({ |
回调形式
1 | // fetchData.js |
Promise
1 | // fetchData.js |
1 | // Couter.js |
describe 里面就是一个作用域,嵌套作用域可以有多个钩子函数,钩子函数执行顺序由外到内
test.only
,只执行某个测试用例
1 | describe('测试减少相关代码',()=>{ |
1 | // demo |
1 | // xx.js |
__mocks__
文件夹1 | // __mock__/xx.js |
接着改测试,模拟函数
1 | // xx.test.js |
上述函数顶部那步模拟也可以通过在 jest.config.js 中修改配置 automock:true
改为自动模拟,那么引入对应的函数的时候就会自动去 __mocks__
文件夹里面去寻找对应的模拟函数。注意一旦开启这个配置,需要启动才会生效,另外也会导致很多测试需要重新修改。
__mocks__
以及导入函数在原来 xx.js 基础上面新增 一个 getNumber
函数
1 | xx.js |
接着修改测试用例
1 | // xx.test.js |
上述代码会报错,getNumebr
找不到对应的函数,因为测试用例还是会去__mocks__
文件夹去寻找 getNumber
。但是我们只希望模拟异步的函数,对于同步函数希望通过导入的方式来测试,那么就从原来的js文件中导入 getNumber
1 | // xx.test.js |
写一个 setTimeout 函数
1 | // timer.js |
测试用例
1 | // timer.test.js |
上面的测试用例会直接通过, timer
是一个异步函数,并不会执行函数体内的内容,需要像之前的异步函数一样,加个 done 参数
1 | import timer from './timer' |
接着测试用例便会运行,并报错
1 | FAIL src/lesson3/timer.test.js (7.997s) |
也可以通过模拟 timer 这类异步函数,来达到目的
1 | // timer.test.js |
对于嵌套 timer 异步函数,jest.runOnlyPendingTimers
可以让只最外层的第一个 timer 运行。
另外还有快进时间的api,jest.advanceTimersByTime(n)
1 | // timer.test.js |
嵌套 timer 的测试用例结合 钩子beforeEach
以及两个上面讲的api的例子
1 | // timer.js |
Mock 函数可以轻松测试代码之间的连接——实现方式包括:擦除函数实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new
实例化时捕获构造函数的实例,允许测试时配置返回值
测试函数 forEach
的内部实现,这个函数为传入的数组中的每个元素调用一次回调函数
1 | function forEach(items,callback){ |
为了测试此函数,可以使用一个 mock 函数,然后检查 mock 函数的状态来确保回调函数如期调用
1 | const mockCallback = jest.fn(x => 42 + x); |
.mock
属性所有 mock 函数都有这个特殊的 .mock
属性,它保存了关于此函数如何调用、调用时的返回值的信息。.mock
属性还追踪每次调用时 this
的值,所以我们同样也可以检视(inspect)this
1 | const myMock = jest.fn(); |
1 | const myMock = jest.fn(); |
结合一些函数灵活模拟
1 | const filterTestFn = jest.fn(); |
1 | // users.js |
模拟 axios.get 返回一个假的 response
1 | // users.test.js |
通过模拟函数 jest.fn
或者mockImplementationOnce
方法来完成
1 | const myMockFn = jest.fn(cb=> cb(null,true)) |
mockImplementation
当您需要定义从另一个模块创建的模拟函数的默认实现时,该方法很有用
1 | // foo.js |
当需要重新创建模拟函数的复杂行为,以便多个函数调用产生不同的结果时,可以使用 mockImplementationOnce
方法
1 | const myMockFn = jest |
.mockReturnThis()
返回 this
1 | const myObj = { |
选择为模拟函数提供一个名称,改名称将在测试错误输出中显示,而不是 jest.fn()
,使用这个可以快速识别在测试输出中报告错误的模拟函数
1 | const myMockFn = jest |
适合测试配置文件
1 | // xx.js |
jest --watchAll
里面出现了 u
、i
模式分别对应 更新所有的快照跟更新单个快照
安装 prettier
,运行 toMatchInlineSnapshot
,会将 快照自动存到代码下面
1 | test('测试 generateConfig 函数', () => { |
1 | // util.js |
在别的函数里面使用这个类
1 | // useUtil.js |
写这个使用类的函数的测试用例的时候,我们会发现这个函数因为使用到了类里面的函数,而函数又很复杂,直接调用会损耗性能。所以这里我们用几种方法来模拟
1 | // useUtil.test.js |
另一种方法就是通过在 __mocks__
文件夹中模拟
1 | // __mocks__/util.js |
还有一种写法,是在原来的测试用例修改
1 | // useUtil.test.js |
1 | // dom.js |
全称:Test Driven Development
开始
1 | 安装脚手架 |
1 | // HelloWorld.test.js |
shallowMount
浅层渲染,只渲染第一层,不渲染子组件,适合单元测试
mount
则会渲染子组件,适合集成测试
测试用例先行
1 | // Header.test.js |
根据测试用例写代码
1 | // Header.vue |
1 | // jest.config.js |
1 | 安装 |
同样,shallow
适合单元测试,mount
则是集成测试
例子:
1 | // App.je |
另外它海域一些扩展 API,例如 jest-enzyme,让语法易懂简洁
1 | // App.test.js |
别忘记在 jest.config.js
里面进行配置
1 | module.exports = { |
同样测试先行
1 | import React from 'react'; |
根据测试写代码
1 | import React, { Component } from 'react'; |
优势:代码质量提高
单元测试
测试覆盖率高,业务耦合度高,代码量大,过于独立
全称:Behavior Driven Development
1 | // vue integration/todoList |
TDD
BDD
增加 redux 在项目
createStore
1 | // src/store/createStore |
store/actions.js
1 | import { CHANGE_INPUT_VALUE } from './constants'; |
store/constants.js
1 | export const CHANGE_INPUT_VALUE = 'CHANGE_INPUT_VALUE' |
store/reducer.js
1 | import { CHANGE_INPUT_VALUE } from './constants'; |
store/index.js
1 | import reducer from './reducer' |
TodoList/index.js
1 | import React, { Component } from 'react'; |
src/index.js
1 | import React from 'react'; |
然后修改集成测试的测试用例
src/containers/TodoList/__test__/integration/TodoList.test.js
1 | import React from 'react'; |
1 | componentDidMount(){ |
模拟函数
src/__mock__/axios.js
1 | const mockUndoList = { |
测试用例
1 | it(` |
1 | componentDidMount(){ |
测试用例
1 | jest.useFakeTimers(); |
Enzyme 是 React 的 JavaScript 测试应用程序,可以轻松测试 React Components 的输出。还可以在给定输出的情况下,遍历以某种方式模拟运行。
主要通过模仿 Jq 用于 DOM 操作和遍历
.at(index) => shallowWrapper
返回当前 wrapper 中指定索引的节点
1 | const wrapper = shallow(<MyComponent />) |
.first() => ShallowWrapper
将匹配节点集合减少到集合中的第一个,就像.at(0)
。
1 | expect(wrapper.find(Foo).first().props().foo).to.equal('bar'); |
.last() => ShallowWrapper
将匹配节点集减少到集合中的最后一个,就像.at(length - 1)
。
1 | const wrapper = shallow(<MyComponent />); |
.childAt(index) => ShallowWrapper
返回指定索引的子节点
1 | const wrapper = shallow(<TodoList items={items}/>); |
.children([selector]) => ShallowWrapper
返回父节点某个元素的所有子节点
1 | const wrapper = shallow(<TodoList items={items}/>); |
.closest(selector) => shallowWrapper
通过遍历节点祖先,返回第一个相匹配的节点
1 | const wrapper = shallow(<MyComponent />); |
.contains(nodeOrNodes) => Boolean
返回所有给定的react元素是否与渲染树中的元素匹配。它将通过检查期望元素是否与包装器元素具有相同的 props 并共享相同的值来确定包装器中的元素是否与预期元素匹配。
1 | let wrapper; |
.containsAllMatchingElements(patternNodes) => Boolean
返回所有给定的react元素是否patternNodes
与包装器的渲染树中的元素匹配。每个元素patternNodes
必须匹配一次或多次。匹配遵循规则containsMatchingElement
。
1 | const style = { fontSize: 13 }; |
.containsAnyMatchingElements(patternNodes) => Boolean
返回至少一个给定的react元素是否patternNodes
与包装器的渲染树中的元素匹配。一个或多个元素patternNodes
必须匹配一次或多次。匹配遵循规则containsMatchingElement
。
1 | const style = { fontSize: 13 }; |
.containsMatchingElement(patternNode) => Boolean
返回patternNode
react元素是否与渲染树中的任何元素匹配。
1 | const wrapper = shallow(( |
返回包装器根节点的上下文哈希。可选地传入一个props,它将只返回该值。
1 | const wrapper = shallow( |
.debug([options]) => String
返回包装器的类似HTML的字符串,以便进行调试。当测试没有通过时,打印到控制台很有用。
options
(Object
[可选]):
options.ignoreProps
:(Boolean
[可选]):是否应在结果字符串中省略props。默认情况下包含道具。options.verbose
:(Boolean
[可选]):是否应该详细打印作为道具传递的数组和对象。.dive([options]) => ShallowWrapper
浅呈现当前包装器的一个非DOM子项,并返回结果周围的包装器。它必须是单节点包装器,并且该节点必须是React组件。
注意:只能在单个非DOM组件元素节点的包装上调用,否则会引发错误。如果必须使用多个子节点对包装器进行浅包装,请使用.shallow()
1 | function Bar() { |
.equals(node) => Boolean
返回当前包装器根节点呈现树是否与传入的树相似
1 | const wrapper = shallow(<MyComponent />); |
.every(selector) => Boolean
返回包装器中的所有节点是否与提供的选择器匹配。
1 | const wrapper = shallow(( |
.everyWhere(fn) => Boolean
1 | const wrapper = shallow(( |
.exists([selector]) => Boolean
返回包装器中是否存在任何节点。或者,如果传入选择器,则该选择器是否在包装器中具有任何匹配项。
1 | const wrapper = mount(<div className="some-class" />); |
.filter(selector) => ShallowWrapper
返回一个新的包装器,其中只包含与提供的选择器匹配的当前包装器的节点。
1 | const wrapper = shallow(<MyComponent />); |
.filterWhere(fn) => ShallowWrapper
返回一个新的包装器,它只包含当前包装器的节点,当传递给提供的谓词函数时,返回true
1 | const wrapper = shallow(<MyComponent />); |
.find(selector) => ShallowWrapper
查找当前包装器的呈现树中与提供的选择器匹配的每个节点。
1 | import Foo from '../components/Foo'; |
.findWhere(fn) => ShallowWrapper
查找渲染树中为提供的谓词函数返回true的每个节点。
1 | const wrapper = shallow(<MyComponent />); |
.forEach(fn) => Self
迭代当前包装器的每个节点,并使用围绕作为第一个参数传入的相应节点的包装器执行提供的函数。
1 | const wrapper = shallow(( |
.get(index) => ReactElement
1 | const wrapper = shallow(<MyComponent />); |
.getWrappingComponent() => ShallowWrapper
如果wrappingComponent
传入了a options
,则此方法返回ShallowWrapper
渲染的周围wrappingComponent
。这ShallowWrapper
可以用来更新wrappingComponent
props,state等。
1 | import { Provider } from 'react-redux'; |
.getElement() => ReactElement
返回包装的ReactElement。如果当前包装器正在包装根组件,则返回根组件的最新呈现输出。
1 | const element = ( |
.getElements() => Array<ReactElement>
1 | const one = <span />; |
.hasClass(className) => Boolean
返回包装节点是否具有className
包含传入的类名称的prop。它必须是单节点包装器。
1 | const wrapper = shallow(<MyComponent />); |
.hostNodes() => ShallowWrapper
返回仅包含主机节点的新包装器。当使用react-dom
,主机节点是HTML元素,而不是定制反应的组分
1 | const wrapper = shallow(( |
.html() => String
返回整个当前渲染树(不仅仅是浅渲染部分)的渲染HTML标记的字符串。只能在单个节点的包装器上调用
1 | function Foo() { |
.instance() => ReactComponent
返回单节点包装器节点的底层类实例; this
在它的方法。
1 | function Stateless() { |
.is(selector) => Boolean
返回单个包装节点是否与提供的选择器匹配。它必须是单节点包装器。
1 | const wrapper = shallow(<div className="some-class other-class" />); |
.isEmptyRender() => Boolean
返回包装器是否最终只呈现允许的假值:false
或null
。
1 | function Foo() { |
.key() => String
返回当前包装器节点的键值。它必须是单节点包装器。
1 | const wrapper = shallow(( |
.map(fn) => Array<Any>
将当前节点数组映射到另一个数组。每个节点作为a传递ShallowWrapper
给map函数。
1 | const wrapper = shallow(( |
.matchesElement(patternNode) => Boolean
回给定的react元素是否patternNode
与包装器的渲染树匹配。它必须是单节点包装器,并且仅检查根节点。
这些patternNode
行为就像一张通配符。为了匹配包装器中的节点:
patternNode
props(attributes)必须出现在包装器的节点中,而不是相反。如果它们出现,它们的值必须匹配。patternNode
样式CSS属性必须出现在包装器节点的样式中,而不是相反。如果它们出现,它们的值必须匹配。1 | class MyComponent extends React.Component { |
.name() => String|null
返回此包装器的当前节点的名称。如果它是复合组件,则这将是最顶层渲染组件的名称。如果它是本机DOM节点,则它将是标记名称的字符串。如果是的话null
,那就是null
。
返回名称的优先顺序是:type.displayName
- > type.name
- > type
。
1 | const wrapper = shallow(<div />); |
Prettier是一个固定的代码格式化程序,支持:
1 | yarn |
1 | { |
命名必须传递足够的信息。fetchUserInfoAsync
比getData
更加具体
命名基础
Husky 可以阻止无效的 git commit
、git push
以及其他woff行为
1 | npm install husky --save-dev |
针对暂存的git文件运行linters并且不要让💩滑入你的代码库!
1 | npm install lint-staged --save-dev |
规范 commit message
,便于自动生成 CHANGELOG
1 | npm install commitlint @commitlint/cli @commitlint/config-conventional --save-dev |
配置:
1 | // commitlint.config.js |
自动生成 CHANGELOG
1 | npm install conventional-changelog conventional-changelog-cli --save-dev |
1 | { |
1 | 当有新的改变 |
相关属性应该为一组,可以以下面的样式为编写顺序
1 | .declaration-order{ |
简单算法:
字符串
、数组
、正则
、排序
、递归
反转字符串中的单词③
给定一个字符串,需要反转字符串中每个单词的字符顺序,同时仍保留空格和单词的初始顺序
实例:
1 | 输入:"Let's take LeetCode contest" |
注意:在字符串中,每个单词由单个空格分隔,并且字符串中不会有任何额外的空格
1 | /** |
测试用例:
1 | import reverseByWord from '../../code/string/lession1' |
知识点:
String.prototype.split
、String.prototype.match
、Array.prototype.map
、Array.prototype.reverse
、Array.prototype.join
扩展:上面讲的是单个空格隔开,那如果不是当个空格呢?
1 | // 同样可以使用正则的贪婪匹配来解决 |
计数二进制子串
给定一个字符串 s
,计算具有相同数量 0 和 1的非空(连续)子符串的数量,并且这些子字符串中的所有 0 和 1 都是组合在一起的。
重复出现的子串要计算它们出现的次数。
示例:
1 | 输入:"00110011" |
1 | /** |
测试用例:
1 | test('countBinarySubstring(00110011)', () => { |
知识点:
发现规律
,RegExp
电话号码的组合
给定一个仅包含 2-9
的字符串,返回它能表示的字母组合。给出数字到字母的映射如下(与电话按钮相同)。注意1不对应任何字母
1 | 1[] 2[abc] 3[def] |
示例:
1 | 输入: "23" |
说明:上面的答案是按字典排序的,可以任意选择答案输出的顺序
1 | /** |
测试用例:
1 | test('letterCombinations(23)', () => { |
知识点:
公式运算
卡牌分组
给定一副牌,每张牌上都写着一个整数。
此时,需要选定一个数字 x
,使得可以将整部牌按下述规则分成 1 组或者更多:
x
张牌仅当可选的 x>=2
时返回 true
示例:
1 | 输入:[1,2,3,4,4,3,2,1] |
提示:
1 <= deck.length <= 10000
0 <= deck[i] < 10000
知识点:
归并运算
种花问题
知识点:
筛选运算
格雷编码
知识点:
二进制运算
知识点:
React Api
createElement
、createContext
、JSX=>JS
、ConcurrentMode
、Ref
、Component
、Suspense
、Hooks
React 中的更新创建
ReactDOM.render
、Fiber
、UpdateQueue
、FiberRoot
、Update
、expirationTime
Fiber Scheduler
scheduleWork
、batchedUpdates
、performWork
、performUnitOfWork
、requestWork
、react scheduler
、renderRoot
开始更新
beginWork以及优化
、各类组件的更新过程
、调节子节点的过程
完成各个节点的更新
completeUnitOfWork
、虚拟DOM对比
、completeWork
、错误捕获处理
、unwindwork
、完成整棵树更新
提交更新
commitRoot整体流程
、开发时的帮助方法
、提交快照
、提交DOM插入
、提交DOM更新
、提交DOM删除
、提交所有生命周期
各功能的实现功能
context的实现过程
、ref的实现过程
、hydrate的实现过程
、React的事件体系
Suspense
更新优先级的概念
、更新挂起的概念
、Suspense组件更新
、timeout处理
、retry重新尝试渲染
、lazy组件更新
Hooks
核心原理
、useState
、useEffect
、useContent
、其他Hooks API
1 | const React = { |
1 | // hasOwnProperty 方法返回一个布尔值,指示对象自身属性中是否具有指定的属性(也就是知否有指定的键) |
ReactElement
通过 createElement
创建的,传入三个参数:
type(指代 ReactElement
的类型)
'div'
等,被称为 HostComponent
Component
或者 PureComponent
的组件,称为 ClassComponent
functinal Component
Fragment
、AsyncModel
、Symbol
config
ReactElement
只是一个用来承载信息的容器,里面有几个信息:
type
,用来判断如何创建节点key
和 ref
这些特殊信息props
新的属性内容$$type
用于确定是否属于 ReactElement
1 | import ReactNoopUpdateQueue from './ReactNoopUpdateQueue'; |
1 | export function createRef(){ |
Refs 提供了一种方式,允许我们访问 DOM 节点或者在 render 方法中创建的 React 元素。
使用 Refs 的情况:
使用 Refs 的方法:
例子:
1 | export default class RefDemo extends React.Component { |
1 | export default function forwardRef(render:(props,ref)){ |
React.forwardRef 会创建一个 React 组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。
主要使用的是:
React.forwardRef 接受渲染函数作为参数。React 将使用 props 和 refs 作为参数来调用此函数。此函数应该返回 React 节点。
1 | // PureComponent |
ReactContext.js
1 | export function createContext(defaultValue,calculateChangedBits){ |
1 | export function lazy(ctor:()=>Thenable<T,R>){ |
简单用法:
1 | import React, { useState,useEffect } from 'react'; |
1 | const SEPARATOP = '.'; |
主要方法流程图:
graph TDA(开始)contextPool(contextPool)mapIntoWithKeyPrefixInternal(mapIntoWithKeyPrefixInternal)traverseAllChildren(traverseAllChildren)traverseAllChildrenImpl(traverseAllChildrenImpl)mapSingleChildrenContext(mapSingleChildrenContext)是否多个节点{是否多个节点}isArray{isArray}循环每个节点((循环每个节点))对每个节点调用mapFunc返回map之后的节点(对每个节点调用mapFunc返回map之后的节点)往result中推入clone节点并替换key(往result中推入clone节点并替换key)A --> mapIntoWithKeyPrefixInternalmapIntoWithKeyPrefixInternal -. 开始的时候获取 .-> contextPoolmapIntoWithKeyPrefixInternal -. 结束之后归还 .-> contextPoolmapIntoWithKeyPrefixInternal --> traverseAllChildrentraverseAllChildren --> traverseAllChildrenImpltraverseAllChildrenImpl --> 是否多个节点是否多个节点 --Y--> 循环每个节点是否多个节点 --N--> mapSingleChildrenContext循环每个节点 --> traverseAllChildrenImplmapSingleChildrenContext --> 对每个节点调用mapFunc返回map之后的节点对每个节点调用mapFunc返回map之后的节点 --> isArrayisArray --Y--> mapIntoWithKeyPrefixInternalisArray --N--> 往result中推入clone节点并替换key
1 | export function createCOntext(defaultValue,calculateChangedBits){ |
1 | export default function memo( |
主要是下面的知识点:
ReactDOM.render
、ReactDOM.render
、setState
、forceUpdate
步骤:
1 | // ReactDOM |
render 与 hydrate 方法都可以传入三个参数,包括 ReactElement、DOM包裹节点和渲染结束后执行的回调方法,返回 legacyRenderSubtreeIntoContainer
方法执行的结果。
1 | function legacyRenderSubtreeIntoContainer( |
先判断 root 是否不存在,不存在调用legacyCreateRootFromDOMContainer
,传入 container
,forceHydrate
两个参数。legacyCreateRootFromDOMContainer
函数创建了一个 ReactRoot
,而 forceHydrate
布尔类型参数在 hydrate
传入 true
,在 render
里面传入 false
。前者是服务器渲染,后者是客户端渲染。服务器渲染当服务器呈现标记的节点,React 会保留它并附加事件处理程序,从而具有高性能的首屏加载,客户端渲染当初始DOM与当前 DOM 存在差异,则可能会更改节点。可以看到 如果是 false
会 container.removeChild(rootSibling)
1 | function ReactRoot( |
编写处理字符串的程序或者网页时候,经常会有查找符合复杂规则的字符串的需要。正则表达式就是用于描述这些规。即记录文本规则的代码。
在一篇英文小说中查找 hi
,可以使用正则 hi
。它可以精确匹配这样的字符串:由两个字符组成,前一个字符是 h
,后一个字符串是 i
。通常,处理正则表达式的工具会提供一个忽略大小写的选项,如果选中了这个选项,可以匹配 hi
、HI
、Hi
、hI
这四种情况中的任意的一种。
hi
这个正则表达式也会匹配 him
、history
、high
等,如果要精确查找到 hi
这个单词的话,应该使用正则表达式 \bhi\b
。
\b
是正则表达式规定一个特殊代码(元字符),代表着单词的开头或者结尾,也就是单词的分界处。英文的单词一般是由空格,标点符号或者换行符来分隔的,但是 \b
并不匹配这些单词分隔字符中的任何一个,它只匹配一个位置。
如果要找到是 hi
后面不远处跟着一个 Lucy
,就要使用正则表达式 \bhi\b.*\bLucy\b
。这里的 .
是一个元字符,匹配除了换行符外的任意字符。*
同样是元字符,不过它代表的不是字符,也不是位置。而是数量,它指定 *
前边的内容可以连续重复使用任意次以使整个正则表达式得到匹配。因此 .*
连在一起就意味着任意数量的不包括换行的字符。现在 \bhi\b.*\bLucy\b
的意思就是:先是一个单词hi
,然后是任意个任意字符(但不能是换行),最后是 Lucy
整个单词。
如果使用其他元字符,可以构造更强大的正则表达式,比如下面的例子
1 | 0\d\d-\d\d\d\d\d\d\d\d |
常用的元字符
代码 | 说明 | 相当于 |
---|---|---|
. | 匹配换行符以外的任意字符 | |
\w | 匹配字母或数字或下划线或汉字 | |
\s | 匹配任意的空白符(包括空格、制表符(Tab)、换行符,中文全角空格) | |
\d | 匹配数字 | |
\b | 匹配单词的开始或者结束 | |
^ | 匹配字符串的开始 | |
$ | 匹配字符串的结束 |
元字符 ^
和 $
都匹配一个位置,这和 \b
有点类似。^
匹配想要用来查找的字符串的开头,$
匹配结尾。
\d+
匹配1个或者更多连续的数字。+
是和 *
类似的元字符,不同的是 *
匹配重复任意次(可能是0次),而 +
则匹配重复1次或者更多次。
\b\w{6}\b
匹配刚好 6个字符的单词
\d{5,12}
表示匹配字符串里面包含 5 到 12 连续位数字,而不是整个字符串是 5 到 12 位数字
^\d{5,12}$
则是精确匹配整个字符串就是 5 到 12 位数字
如果想要查找元字符本身的话,比如查找.
或者 *
就需要转义,\
可以来取消这些字符的特殊意义
1 | # 查找 . |
下面是正则表达式中所有限定符(指定数量的代码)
代码/语法 | 说明 |
---|---|
* | 重复0次或者更多次 |
+ | 重复1次或者更多次 |
? | 重复0次或者1次 |
{n} | 重复n次 |
{n,} | 重复n次或更多次 |
{n,m} | 重复n次到m次 |
Windows\d+
匹配 Windows
后面跟1个或者跟多数字
^\w+
匹配一行的第一个单词(或者整个字符串的第一个单词)
要想查找数字、字母或者数字,空白是很简单的,因为已经有对应的这些字符集合的元字符,但是想匹配没有预定字符的字符集合(比如元音字母 a
/e
/i
/o
/u
)。
只需要在方括号中列出它们就可以了,[aeiou]
就匹配任何一个英文元音字母,[.?!]
匹配标点符号(.
或?
或!
)
也可以轻松指定一个字符范围,[0-9]
的含义和 \d
是完全一致的:一个数字。
[a-z0-9A-Z]
也完全等同于 \w
(如果值考虑英文的话)
思考:
\(?0\d{2}[) -]?\d{8}
上面的表达式,可以匹配几种格式的电话号码,像(010)88888888,或者010-88888888。它首先是一个转义字符,它能出现0次或者1次(?),然后是一个0,后面跟着两个数字(d{2}),然后是)
或者-
或者空格中的一个,它出现1次或者不出现,最后是8个数字。
上面思考题,其实也可以匹配 010)8888888或者 (010-88888888 这样不正确的格式。要解决这个问题,需要用到分枝条件。正则表达式里面的分枝条件是有几种规则的。如果满足其中任意一个规则应该都当成匹配,具体的方法是用 |
把不同的规则都分隔开。
1 | 0\d{2}-\d{8}|0\d{3}-d{7} |
重复单个字符,直接在字符后面加上限定符就好了。如果想要重复多个字符的话,可以用小括号来指定子表达式(分组),然后就可以指定这个子表达式的重复次数了,或者是其他的操作。
1 | (\d{1,3}\.){3}\d{1,3} |
有时候需要查找的不是某个能简单定义的字符类的字符。比如想查找除了数字以外的,其他任意字符都行的情况,这时候就需要反义。
代码/语法 | 说明 |
---|---|
\W | 匹配任意不是字母、数字、下划线或者汉字的字符 |
\S | 匹配任意不是空白符的字符 |
\D | 匹配任意不是数字的字符 |
\B | 匹配不是单词开头或者结束的位置 |
[^X] | 匹配除了 X 以外的任意的字符 |
[^aeiou] | 匹配除了 aeiou 这个字母与以外的任意字符 |
例子:
\S+
匹配不包括空白符的字符串
<a[^>]+>
匹配用尖括号括起来的以 a
开头的字符串
使用小括号指定一个子表达式后,匹配这个子表达式的文本(也就是此分组捕获的内容)可以在表达式或者其他程序中作进一步的处理。默认情况下,每个分组都会自动拥有一个组号,规则就是:从左到右,以分组的左括号为标志,第一个出现的分组的组号为1,第二个为2,依次类推。
分组0代表整个正则表达式,实际上组号分配过程是要从左向右扫描两遍的,第一遍只给未命名组分配,第二遍只给命名组分配,因为所有命名组的组号都大于未命名的组号。
可以使用 (?:exp)这样的语法来剥夺一个分组对组号分配的参与权
后向引用,用于重复搜索前面的某个分组匹配的文本。例如,\1
代表分组1匹配的文本。
1 | \b(\w+)\b\s+\1\b |
也可以自己指定子表达式的组名,要指定一个子表达式的组名,使用这样的语法:
(?<Word>\w+)
或者(?'Word'\w+)
这样就把 \w
的组名指定为 Word。要反向引用这个分组捕获的内容,可以使用\k<Word>
,上面的例子也可以写成这样:
1 | \b(?<Word>\w+)\b\s+\k<Word>\b |
下表是常用分组语法:
分类 | 代码/语法 | 说明 |
---|---|---|
捕获 | (exp) | 匹配 exp,并捕获文本到自动命名的组里 |
(?\ | 匹配 exp,并捕获文本到名称为 name 的组里,也可以写成 (?'name'exp) | |
(?:exp) | 匹配 exp,不捕获匹配的文本,也不给此分组的分配组号 | |
零宽断言 | (?= exp) | 匹配 exp 前面的位置 |
(?<= exp) | 匹配 exp 后面的位置 | |
(?!exp) | 匹配后面跟的不是 exp 的位置 | |
(?<!exp) | 匹配前面不是 exp 的位置 | |
注释 | (?#comment) | 这种类型的分组不对正则表达式的处理产生任何影响,用于注释让人阅读 |
断言用来声明一个应该为真的事实,正则表达式中只有当断言为真的时候才会继续进行匹配。
用于查找在某些内容前或者之后的东西,像 \b
、^
、$
那样用于指定一个位置,这个位置应该满足一定的条件(断言),因此它们被称为零宽断言。
(?=exp)
也叫零宽度正预测先行断言(Zero-width positive lookahead),它断言自身出现的位置的后面能匹配表达式 exp。比如,\b\w+(?=ing\b)
,匹配以 ing
为结尾的单词的前面部分(除了 ing 以外的部分),如查找 I'm singing while you're dancing.
时,它会匹配 sing
和 danc
(?<=exp)
也叫零宽度正回顾后发断言(Zero-width positive lookbehind),它断言自身出现的为位置的前面能匹配表示达 exp,比如 (?<=\bre)\w+\b
会匹配以 re
开头的单词的后半部分(除了re 以外的部分)。例如在查找 reading a book
时,它匹配 ading
。
假如想要给一个很长的数字中每三位加一个逗号,从右边加。可以这样查找前面和里面添加逗号的部分:
1 | ((?<=\d)\d{3})+\b |
前面提到怎么查找不是某个字符或者不在某个字符类里面的字符的方法(反义)。但是如果我们只是想确保某个字符没有出现,但不想去匹配它时,例如查找这样的单词——它里面的出现了字母q,但是q后面跟着的不是字母 u,我们可以尝试:
1 | \b\w*q[^u]\w*\b |
零宽度负预测先行断言(?!exp)(Zero-width negative lookahead),断言此位置后面不能匹配表达式exp。
例如:
1 | \d{3}(?!\d) |
同理可以用零宽度负回顾后发断言(?<!exp)(Zero-width negative lookbehind),来断言此位置的前面不能匹配表达式 (?<![a-z])\d{7}
,即前面不是小写字母的七位数字
一个更加复杂的例子:
(?<=<(\w+)>).*(?=<\/\1>)
匹配不包含属性的简单 html 标签内的内容。(?<=<(\w+)>)
指定了这样的前缀:被尖括号括起来的单词,然后是 .*
(任意的字符串),最后一个是后缀(?=<\/\1>)
。\/
转义了,\1
反向引用,整个内容就是闭合标签之间的内容,不包括两个标签本身。
小括号的另一种用途是通过语法(?#comment)
来包含注释。
例如:
1 | 2[0-4]\d(?#200-249)|25[0-5](?#250-255)|[01]?\d\d?(?#0-199) |
要包含注释的话,最好是启用“忽略模式里面的空白符”选项。这样在编写表达式时能任意的添加空格/TAB/换行,而实际使用时这些都被忽略。启用这个选项后,在 #
后面到这一行结束的所有文本都被当做注释忽略掉。例如,我们可以前面的一个表达式写成这样:
1 | (?<=# 断言要匹配的文本的前缀 |
当正则表达式中包含能接受重复的限定符时,通常的行为是(在使整个表达式能得到匹配的前提下)匹配尽可能多的字符。以这个表达式为例子:a.*b,它将会匹配最长的以a开始,以b结束的字符串。如果用它来搜索 aabab
的话,它就会匹配整个 aabab
,这被称为贪婪匹配。
有时候,我们需要懒惰匹配,也就是尽可能少的字符。前面给出的限定符都可以被转化为懒惰匹配模式,只要在它后面加上一个问号 ?
。这样,.?
这意味着匹配任意数量的重复,但是在能使整个匹配成功的前提下使用最少的重复。
a.*?b
匹配最短的,以a开始,以b结束的字符串。如果把它应用于 aabab
的话,它会匹配 aab
和 ab
代码/语法 | 说明 |
---|---|
*? | 重复任意次,但尽可能少重复 |
+? | 重复 1次或者更多次,但尽可能少重复 |
?? | 重复 0 次或者 1 次,但尽可能少重复 |
{n,m}? | 重复 n 到 m 次,但尽可能少重复 |
{n,}? | 重复 n 次以上,但尽可能少重复 |
正则表达式是用于匹配字符串中字符组合的模式。在 JavaScript 中,正则表达式也是对象,这些模式被用于 RegExp
的test
和 exec
,以及 String
的 match
、replace
、search
和 split
方法。
1.使用一个正则表达式字面量,其由包含斜杠之间的模式组成:
1 | const regex = /ab+c/; |
2.调用RegExp
对象的构造函数:
1 | let regex = new RegExp('ab+c'); |
一个正则表达式的模式由简单的字符所构成,比如 /abc/
,或是简单和特殊字符的组合,比如 /ab*c/
或者 /Chapter(\d+)\.\d*/
。后者用到了括号,它在正则表达中可以被当作是一个记忆设备。这一部分正则所匹配的字符会被记住,在后面可以利用。
使用简单的模式
简单的模式由找到的直接匹配所构成的。
1 | /abc/ |
使用特殊字符
1 | /ab*c/ |
字符 | 含义 | |
---|---|---|
\ | 在非特殊字符之前的反斜杆表示下一个字符是特殊的。 反斜杆也可以将其后的特殊字符,转义为字面量。 使用 new RegExp('pattern') 的时候要将 \ 转义,因为 \ 在字符串里面也是一个转义字符 | |
^ | 匹配输入的开始。如果多行标志被设置为 true,那么也匹配换行符后紧跟的位置。 | |
$ | 匹配输入的结束。如果多行标识符被设置为 true,那么也会匹配换行符之前的位置。0 | |
* | 匹配前一个表示0 次或者多次。等价于{0,} | |
+ | 匹配前面一个表达式1次或者更多次,等价于 {1,} | |
? | 匹配前面一个表达式0次或者1次。等价于{0,1} 如果紧跟在任何量词 * 、+ 、? 、{} 后面,将会使得量词变成非贪婪(匹配尽量少的字符),和缺省使用的贪婪模式(匹配尽可能多的字符)正好相反 | |
. | (小数点)匹配除换行符之外面的任何单个字符 | |
(x) | 匹配 ‘x’并记住匹配项。括号被称为捕获括号。 模式 /(foo)(bar)\1\2/ 在正式表达式的替换环节,使用 $1 的语法,例如 'bar.foo'.replace(/(..)(..)/,'$2$1') | |
(?:x) | 匹配‘x’但不记住匹配项。这种叫做非捕获括号,使得你能够定义与正则表达式运算符一起使用的子表达式。 | |
x(?=y) | 匹配‘x’仅仅当’x’后面跟着‘y’。这种叫先行断言。/Jack(?=Spart)/ 会匹配到 ‘Jack’仅当它后面跟着’Spart’。但是‘Spart’都不是匹配结果中的一部分 | |
(?<=y)x | 匹配‘x’仅仅当’x’前面是‘y’,这种叫做后行断言。/(?<=Jack)Spart/ 会匹配到 ‘Spart’仅仅当它前面是‘Jack’。`/(?<=Jack | Tom)Spart/`会匹配到 ’Spart‘仅仅当它前面是 ’Jack‘或者是 ‘Tom’。但是’Tom‘和’Jack‘都不是匹配结果的一部分 |
x(?!y) | 匹配’x‘仅仅当’x‘后面不跟着’y‘,这个叫做正向否定查找。/\d+(?!\.)/ 匹配一个数字仅仅当这个数字后面没有跟小数点的时候。 | |
`x | y` | 匹配’x‘或者’y‘。 |
{n} | n是一个正整数,匹配了前面一个字符刚好发生了 n 次 | |
{n,m} | n 和 m 都是整数。匹配前面的字符至少 n 次,最多 m 次。如果 n 或者 m 的值是0,这个值会被忽略。 | |
[xyz] | 一个字符的集合。匹配方括号中的任意字符,包括转义序列。可以用破折号(-)来指定一个字符范围。对于点(.)和星号(*)这样的特殊符号在一个字符集中没有特殊意义。 | |
[^xyz] | 一个反向字符集。匹配任何没有包含在方括号中的字符。同样可以使用破折号来指定一个字符范围。 | |
[\b] | 匹配一个退格(U+0008) | |
\b | 匹配一个词的边界。一个词的边界就是一个词不被另外一个字字符跟随的位置或者没有其他“字”字符在其前面的位置。 | |
\B | 匹配一个非单词边界。匹配一个前后字符都是相同类型的位置。都是’字‘字符或者都不是’字‘字符。一个字符串的开始和结尾都被认为不是‘字’字符,或者是字符串。 | |
\cX | 当 X 是处于A 到 Z 之间的字符的时候,匹配字符串中的一个控制符 | |
\d | 匹配一个数字 | |
\D | 匹配一个非数字字符 | |
\f | 匹配一个换页符(U+000C) | |
\n | 匹配一个换行符(U+000A) | |
\r | 匹配一个回车符(U+000D) | |
\s | 匹配一个空白符号,包括空格、制表符、换页符和换行符。 等价于 [\f\n\r\t\v\u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff] | |
\S | 匹配一个非空白字符。 等价于 [^ \f\n\r\t\v\u00a0\u1680\u180e\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff] 。 | |
\t | 匹配一个水平制表符(U+0009) | |
\v | 匹配一个垂直制表符(U+000B) | |
\w | 匹配一个单字字符(字母、数字或者下划线) 等价于 [A-Za-z0-9_] | |
\W | 匹配一个单字字符 等价于 [^A-Za-z0-9_] | |
\n | 在正则表达式中,它返回最后的第 n 个子捕获匹配的子字符串(捕获的数目以左括号计数) | |
\0 | 匹配 NULL(U+0000)字符,不要在这后面跟着其他小数,因为 \0<digits> 是一个八进制转义序列 | |
\xhh | 与代码 hh 匹配字符(两个十六进制数字) | |
\uhhhh | 与代码 hhhh 匹配字符(四个十六进制数字) |
正则表达式被用于 RegExp 的 exec 和 test 方法,还有 String 的 match、replace、search和 split 方法。
方法 | 描述 |
---|---|
exec | 一个在字符串中执行查找匹配的 RegExp 方法,它返回一个数组(未匹配返回 null) |
test | 一个在字符串中测试是否匹配的 RegExp 方法,它返回 true 或者 false |
match | 一个在字符串中执行查找匹配的 String 方法,它返回一个数组或者在未匹配时返回 null |
search | 一个在字符串中测试匹配的 String 方法,它返回匹配到的位置索引,或者在失败时返回 -1 |
replace | 一个在字符串中执行查找匹配的 String 方法,并且使用替换字符串替换掉后面的子字符串 |
split | 一个使用正则表达式或者一个固定字符串分隔一个字符串,并将分隔后的字符串存储到数组中的 String 方法 |
例子:
1 | // 脚本使用 exec 方法在一个字符串中查找一个匹配 |
一个正则表达式模式使用括号,将导致相应的子匹配被记住。使用括号匹配的子字符串的数量是无限的。返回的数组中保存所有被发现的子匹配。
1 | let reg = /(\w+)\s(\w+)/; |
正则表达式有四个可选参数进行全局和不区分大小写搜索,这些参数可以单独使用也可以一起使用在任何顺序和包含正则表达式的部分中。
标志 | 描述 |
---|---|
g | 全局搜索 |
i | 不区分大小写搜索 |
m | 多行搜索(使用该标志,^ 和 $ 匹配的开始或者结束输入字符串的) |
y | 执行“粘性”搜索,匹配从目标字符串的当前位置开始,可以使用 y 标志 |
格式:
1 | let reg = /pattern/flags; |
发展历史
1 | CSS1 -> CSS2 -> CSS2.1 ->CSS3 |
模块与模块化结构
CSS3采用了分工协作的模块化结构
其中最重要的 CSS3 模块包括:
属性选择器
选择器 | 含义 | 实例 |
---|---|---|
E[att^=”val”] | 属性 att 的值以 val 开头的元素 | div[id^='haha'] |
E[att$=”val”] | 属性 att 的值以 val 结尾的元素 | div[id^='haha'] |
E[att=”val”] | 属性 att 的值包含 val 字符串的元素 | div[id^='haha'] |
结构性伪类选择器
选择器 | 含义 | 实例 |
---|---|---|
E:root | 匹配文档的根元素,对于 HTML 文档就是 HTML元素 | |
E:not(s) | 匹配不符合当前选择器的任何元素 | div:not([class="demo"]) :除了class 为demo 的div以外 |
E:empty | 匹配一个不包含任何子元素的元素(文本节点也被看成子元素,空格也是一个元素) | |
E:target | 匹配被 location.hash 选中的元素(即锚点元素),选择器可以用来选取当前活动的目标元素 | |
E:first-child | 匹配E 父元素的第一个子元素,如果当前位置元素不是前面所修饰的元素,那么无效 | li:first-child :匹配页面中所有第一个li |
E:last-child | 匹配E 父元素的最后一个子元素,等同于 E:nth-last-child(1) ,如果当前位置元素不是前面所修饰的元素,那么无效 | |
E:nth-child(n) | 匹配E 父元素的第n 个元素,第一个编号为1,如果当前位置元素不是前面所修饰的元素,那么无效 | |
E:nth–last-child(n) | 匹配E 父元素的倒数第n个子元素,第一个编号为1,如果当前位置元素不是前面所修饰的元素,那么无效 | |
E:nth-of-type(n) | 与E:nth-child() 类似,但是仅匹配使用同种标签的元素 | p:nth-of-type(2) :匹配父元素的第2个子元素p |
E:first-of-type(n) | 匹配父元素使用同种标签的第一个子元素,等同于 E:nth-of-type(1) | |
E:last-of-type(n) | 匹配父元素使用同种标签的最后一个子元素,等同于 E:nth-last-of-type(1) | |
E:nth-last-of-type(n) | 与 E:nth-last-child(n) 类似,但是仅匹配使用相同标签的元素 | |
E:only-child | 匹配父元素下仅有的一个子元素,等同于 E:first-child:last-child 或者 E:nth-child(1):nth-last-child(1) | |
E:only-of-type | 匹配父元素下使用同种标签的唯一一个子元素,等同于 E:first-of-type:last-of-type 或者 E:nth-of-type(1):nth-last-of-type(1) | |
备注: | n 里面可以是 odd(奇数)、even(偶数) | 循环使用样式:li:nth-child(2n+1) |
UI状态伪类选择器
选择器 | 含义 | 实例 |
---|---|---|
E:enabled | 匹配表单中激活的元素 | |
E:disabled | 匹配表单中禁用的元素 | |
E:checked | 匹配表单中被选中的单选框或者复选框 | |
E::selection | 匹配用户用鼠标当前选中的元素 | user-select: none; 取消鼠标选中的默认样式 |
E:read-only | 匹配选中只读的元素 | <input type="text" readonly="readonly" value="hahha"/> |
E:read-write | 匹配选中非只读的元素 | <input type="text" value="hahha"/> |
级元素通用选择器
选择器 | 含义 | 实例 |
---|---|---|
E~F | 匹配任何在 E 元素之后的同级元素 F | p~ul{background:#ff9;} |
1 | /*把包含某个attribute的所有边元素变成红色*/ |
1 | /*某个具体的a标签*/ |
1 | /*选择 class属性中包含 important元素的 */ |
类型 | 描述 |
---|---|
[abc^=”def”] | 选择 abc 属性值以 “def” 开头的所有元素 |
[abc$=”def”] | 选择 abc 属性值以 “def” 结尾的所有元素 |
[abc*=”def”] | 选择 abc 属性值中包含子串 “def” 的所有元素 |
1 | /*这个规则会选择 lang 属性等于 en 或以 en- 开头的所有元素*/ |
css属性选择器表
选择器 | 描述 | |
---|---|---|
[attribute] | 用于选取带有指定属性的元素。 | |
[attribute=value] | 用于选取带有指定属性和值的元素。 | |
[attribute~=value] | 用于选取属性值中包含指定词汇的元素。 | |
[attribute\ | =value] | 用于选取带有以指定值开头的属性值的元素,该值必须是整个单词。 |
[attribute^=value] | 匹配属性值以指定值开头的每个元素。(CSS3新增) | |
[attribute$=value] | 匹配属性值以指定值结尾的每个元素。(CSS3新增) | |
[attribute**=value*] | 匹配属性值中包含指定值的每个元素。(CSS3新增) |
CSS2定义:用于向某些选择器添加特殊的效果。
CSS3定义:
:
开头,冒号后面是伪类的名称和包含在圆括号中的可选参数1 | selector : pseudo-class{ property:value } |
锚伪类(不存在于 DOM树的信息)
1 | a:link{} |
1 | <style> |
伪类表
属性 | 描述 | CSS |
---|---|---|
:active | 向被激活的元素添加样式。 | 1 |
:focus | 向拥有键盘输入焦点的元素添加样式。 | 2 |
:hover | 当鼠标悬浮在元素上方时,向元素添加样式。 | 1 |
:link | 向未被访问的链接添加样式。 | 1 |
:visited | 向已被访问的链接添加样式。 | 1 |
:first-child | 向元素的第一个子元素添加样式。 | 2 |
:lang | 向带有指定 lang 属性的元素添加样式。 | 2 |
CSS2定义:用于向某些选择器设置特殊效果
CSS3定义:
:before
/:after
)::
开头,然后是伪元素的名称::
是为了区分伪类和伪元素(CSS2中没有区别)。考虑兼容性的话,CSS2中的已存伪元素可以使用 :
,但是 CSS3的就要使用 ::
1 | selector:pseudo-element{property:value} |
:first-line
用于向文本的首行设置特殊样式(只能用于块级元素)
下面属性可以应用这个伪元素
:first-letter
用于向文本的首字母设置特殊样式
下面的属性可以应用这个伪元素
:before
在元素内容前面插入新内容
:after
在元素内容之后插入新内容
伪元素表
属性 | 描述 | CSS |
---|---|---|
:first-letter | 向文本的第一个字母添加特殊样式。 | 1 |
:first-line | 向文本的首行添加特殊样式。 | 1 |
:before | 在元素之前添加内容。 | 2 |
:after | 在元素之后添加内容。 | 2 |
:
与::
)选择器 | 示例 | 示例说明 | CSS | |
---|---|---|---|---|
.class | .intro | 选择所有class=”intro”的元素 | 1 | |
#id | #firstname | 选择所有id=”firstname”的元素 | 1 | |
* | * | 选择所有元素 | 2 | |
element | p | 选择所有 元素 | 1 | |
element,element | div,p | 选择所有 元素和 元素 | 1 | |
element element | div p | 选择 元素内的所有 元素 | 1 | |
element>element | div>p | 选择所有父级是 元素的 元素 | 2 | |
element+element | div+p | 选择所有紧接着 元素之后的 元素 | 2 | |
attribute | [target] | 选择所有带有target属性元素 | 2 | |
attribute=value | [target=-blank] | 选择所有使用target=”-blank”的元素 | 2 | |
attribute~=value | [title~=flower] | 选择标题属性包含单词”flower”的所有元素 | 2 | |
attribute | =language[lang\ | =en] | 选择 lang 属性以 en 为开头的所有元素 | 2 |
:link | a:link | 选择所有未访问链接 | 1 | |
:visited | a:visited | 选择所有访问过的链接 | 1 | |
:active | a:active | 选择活动链接 | 1 | |
:hover | a:hover | 选择鼠标在链接上面时 | 1 | |
:focus | input:focus | 选择具有焦点的输入元素 | 2 | |
:first-letter | p:first-letter | 选择每一个 元素的第一个字母 | 1 | |
:first-line | p:first-line | 选择每一个 元素的第一行 | 1 | |
:first-child | p:first-child | 指定只有当 元素是其父级的第一个子级的样式。 | 2 | |
:before | p:before | 在每个 元素之前插入内容 | 2 | |
:after | p:after | 在每个 元素之后插入内容 | 2 | |
:lang(language) | p:lang(it) | 选择一个lang属性的起始值=”it”的所有 元素 | 2 | |
element1~element2 | p~ul | 选择p元素之后的每一个ul元素 | 3 | |
attribute^=value | a[src^=”https”] | 选择每一个src属性的值以”https”开头的元素 | 3 | |
attribute$=value | a[src$=”.pdf”] | 选择每一个src属性的值以”.pdf”结尾的元素 | 3 | |
attribute=value | a[src*=”runoob”] | 选择每一个src属性的值包含子字符串”runoob”的元素 | 3 | |
:first-of-type | p:first-of-type | 选择每个p元素是其父级的第一个p元素 | 3 | |
:last-of-type | p:last-of-type | 选择每个p元素是其父级的最后一个p元素 | 3 | |
:only-of-type | p:only-of-type | 选择每个p元素是其父级的唯一p元素 | 3 | |
:only-child | p:only-child | 选择每个p元素是其父级的唯一子元素 | 3 | |
:nth-child(n) | p:nth-child(2) | 选择每个p元素是其父级的第二个子元素 | 3 | |
:nth-last-child(n) | p:nth-last-child(2) | 选择每个p元素的是其父级的第二个子元素,从最后一个子项计数 | 3 | |
:nth-of-type(n) | p:nth-of-type(2) | 选择每个p元素是其父级的第二个p元素 | 3 | |
:nth-last-of-type(n) | p:nth-last-of-type(2) | 选择每个p元素的是其父级的第二个p元素,从最后一个子项计数 | 3 | |
:last-child | p:last-child | 选择每个p元素是其父级的最后一个子级。 | 3 | |
:root | :root | 选择文档的根元素 | 3 | |
:empty | p:empty | 选择每个没有任何子级的p元素(包括文本节点) | 3 | |
:target | #news:target | 选择当前活动的#news元素(包含该锚名称的点击的URL) | 3 | |
:enabled | input:enabled | 选择每一个已启用的输入元素 | 3 | |
:disabled | input:disabled | 选择每一个禁用的输入元素 | 3 | |
:checked | input:checked | 选择每个选中的输入元素 | 3 | |
:not(selector) | :not(p) | 选择每个并非p元素的元素 | 3 | |
::selection | ::selection | 匹配元素中被用户选中或处于高亮状态的部分 | 3 | |
:out-of-range | :out-of-range | 匹配值在指定区间之外的input元素 | 3 | |
:in-range | :in-range | 匹配值在指定区间之内的input元素 | 3 | |
:read-write | :read-write | 用于匹配可读及可写的元素 | 3 | |
:read-only | :read-only | 用于匹配设置 “readonly”(只读) 属性的元素 | 3 | |
:optional | :optional | 用于匹配可选的输入元素 | 3 | |
:required | :required | 用于匹配设置了 “required” 属性的元素 | 3 | |
:valid | :valid | 用于匹配输入值为合法的元素 | 3 | |
:invalid | :invalid | 用于匹配输入值为非法的元素 | 3 |
:before
或者 :after
1 | /* 插入文字 */ |
content
插入项目编号
1 | <h1>大标题</h1> |
1 | /*多个标题加上连续编号*/ |
list-style-type 的值
值 | 描述 |
---|---|
none | 无标记。 |
disc | 默认。标记是实心圆。 |
circle | 标记是空心圆。 |
square | 标记是实心方块。 |
decimal | 标记是数字。 |
decimal-leading-zero | 0开头的数字标记。(01, 02, 03, 等。) |
lower-roman | 小写罗马数字(i, ii, iii, iv, v, 等。) |
upper-roman | 大写罗马数字(I, II, III, IV, V, 等。) |
lower-alpha | 小写英文字母The marker is lower-alpha (a, b, c, d, e, 等。) |
upper-alpha | 大写英文字母The marker is upper-alpha (A, B, C, D, E, 等。) |
lower-greek | 小写希腊字母(alpha, beta, gamma, 等。) |
lower-latin | 小写拉丁字母(a, b, c, d, e, 等。) |
upper-latin | 大写拉丁字母(A, B, C, D, E, 等。) |
hebrew | 传统的希伯来编号方式 |
armenian | 传统的亚美尼亚编号方式 |
georgian | 传统的乔治亚编号方式(an, ban, gan, 等。) |
cjk-ideographic | 简单的表意数字 |
hiragana | 标记是:a, i, u, e, o, ka, ki, 等。(日文片假名) |
katakana | 标记是:A, I, U, E, O, KA, KI, 等。(日文片假名) |
hiragana-iroha | 标记是:i, ro, ha, ni, ho, he, to, 等。(日文片假名) |
katakana-iroha | 标记是:I, RO, HA, NI, HO, HE, TO, 等。(日文片假名) |
HTML5 中的一个新增元素,可以在页面绘制出各种漂亮的图形与图像。
canvas 元素就是 HTML5 中新增的一个用来绘制图形。在页面上放置一个 canvas
元素,相当于在页面上放置一块画布,可以在其中进行图形绘制。
1 |
|
首先,要指定 ID,width,height 这三个属性。
getContext
方法来获得图形上下文。1 | context.fillRect(x,y,width,height); |
还有一个 clearRect 方法,擦除指定矩形区域中的图形,使得矩形中的颜色全部变成透明。
1 | context.clearRect(x,y,width,height); |
使用路径绘制图形的过程:
大概的意思就是,先用路径勾勒图形轮廓,然后设置颜色,进行绘制。
1 | function drawCircle(id) { |
其中,context.arc(x,y,radius,startAngle,endAngle,anticlockwise)
这个方法使用了六个参数,x
为绘制圆形的横坐标,y
为纵坐标,radius
为圆形半径,startAngle
为开始的角度,endAngle
为结束角度,anticlockwise
为是否按顺时针方向进行绘制。
关于角度与弧度的计算
1 | let radius = degress * Math.PI / 180; |
arc
方法不仅可以用阿里绘制圆形,也可以用来绘制圆弧。因此,使用时必须要指定开始角度与结束角度。因为这两个角度决定了弧度。anticlockwise
为一个布尔值的参数,参数为 true 时,按顺时针绘制,参数为 false时,按逆时针绘制。
绘制完图形后,使用 context.closePath()
将路径关闭,接着就可以使用 fill
方法(stroke
)填充图形与绘制图形边框了。
如果不关闭路径,已经创建的路径会被永远保留,使用 fill 或者 stroke 绘制的时候会重复绘制已存在的路径
1 | function drawCircle(id) { |
moveTo 与 lineTo
moveTo 方法的作用是将光标移动到指定坐标,绘制直线的时候以这个坐标点为起点
1 | moveTo(x,y) |
lineTo方法,也是用两个参数,x ,y表示直线终点的横纵坐标。使用该方法绘制直线后,光标会自动移动到 lineTo 方法参数所指定的直线终点。
1 | function drawLine(id) { |
1 | context.bezierCurveTo(cp1x,cp1y,cp2x,cpy2y,x,y); |
绘制曲线,需要两个控制点以及一点终点。
1 | function drawBezierLine(id) { |
quadraticCurveTo
方法绘制二次贝塞尔曲线
1 | context.quadraticCurveTo(cpx,cpy,x,y) |
绘制线性渐变需要用到 lineargradient
对象。
1 | context.createLinearGradient(xStart,yStart,xEnd,yEnd); |
接着使用 addColorStop 方法设定
1 | context.addColorStop(offset,color); |
offset 为所设定的颜色离开渐变起始点的偏移量。参数的值是0到1之间的浮点值,渐变起始点的偏移量是0,渐变结束点的偏移量是1.
1 | function drawLineGradient(id) { |
1 | context.createRadialGradient(xStart,yStart,radiusStart,xEnd,yEnd,radiusEnd); |
分别指定两个圆的大小和位置。从第一个圆的圆心处向外进行扩散渐变,一直扩散到第二个圆的外轮廓处。
1 | function drawRadialGradient(id) { |
主要有三种形式:
translate
方法来移动图形坐标轴原点。1 | context.translate(x,y); |
scale
方法将图形放大1 | context.scale(x,y) |
rotate
方法将图形进行旋转1 | context.rotate(angle); |
旋转中西是坐标轴的原点,顺时针方法
1 | function drawDeformationGraphics(id) { |
上面的代码使用坐标变换的方法。首先绘制了一个长方形,然后在一个循环中反复使用平移坐标轴,图形缩小,图形旋转三种技巧绘制出来变形图形。
1 | function drawDeformationGraphicsWithFivePointedStar(id) { |
当图形上下文被创建完毕时,事实上也创建了一个默认的变换矩阵,如果不对这个变换矩阵进行修改,绘制的图形将以画布的最左上角为坐标原点绘制图形,绘制出来的图形也不经过缩放、变形的处理。我们可以对这个变换矩形进行修改
1 | context.transform(a , b , c , d , e , f ) |
参数 | 描述 |
---|---|
a | 水平缩放绘图 |
b | 水平倾斜绘图 |
c | 垂直倾斜绘图 |
d | 垂直缩放绘图 |
e | 水平移动绘图 |
f | 垂直移动绘图 |
1 | function drawTransform(id) { |
setTransform 会将变换矩阵重置
1 | setTransform(m11,m12,m21,m22,dx,dy) |
1 | function drawSetTransform(id) { |
设定图形上下文对象的 globalCompositeOperation
属性能自己决定图形的组合方式
1 | context.globalCompositeOperation = type |
type 的值必须是下面几种字符串之一:
source-over(默认值)
表示新图形覆盖杂原有图形上
destination-over
表示在原有图形之下绘制新图形
source-in
新图形与原有图形作 in 运算,只显示新图形中与原有图像相互重叠的部分,新图形与原有图形的其他部分均变成透明
destination-in
原有图形与新图形作 in 运算,只显示原有图形中与新图形相重叠的部分,新图形与原有图形的其他部分均变成透明
source-out
新图形与原有图形 out 运算。只显示新图形中与原有图形不重叠的部分,新图形与原有图形的其他部分变成透明
destination-out
新图形与原有图形 out 运算。只显示原有图形中与新图形不重叠的部分,原有图形与新图形的其他部分变成透明
source-atop
只绘制新图形中与原有图形重叠的部分与未被重叠覆盖的原有图形,新图形的其他部分变成透明
destination-atop
只绘制原有图形中被新图形重叠覆盖的部分与新图形的其他部分,原有图形中的其他部分变成透明,不绘制新图形中与原有图形相重叠的部分
lighter
原有图形与新图形均绘制,重叠部分做加色处理
xor
只绘制新图形中与原有图形不重叠的部分,重叠部分变成透明
copy
只绘制新图形,原有图形中未与新图形重叠的部分变成透明
1 | function drawShadowFifthStar(id) { |
1 | // 宽高 为原图像宽高 |
1 | function drawCanvasImage(id) { |
1 | function drawTileImage(canvas, context, image) { |
上面使用变量和循环来实现图像平铺。还可以用更简便的图形上下文的 createPattern
方法。
1 | context.createPattern(image,type) |
image 参数为要平铺的图像,type 参数:
1 | function drawCanvasImage(id) { |
当图像太大的时候,可以通过把图像绘制在一个临时的 canvas 里面,然后再用主canvas 重复这个临时 canvas
使用 clip 方法设置裁剪区域
1 | function cutImage(id) { |
使用 getImageData 方法可以获取图像中的像素,获得像素组依次 rgba 值。
1 | imageData = context.getImageData(x,y,width,height); |
参数 | 描述 |
---|---|
x | 开始复制的左上角位置的 x 坐标。 |
y | 开始复制的左上角位置的 y 坐标。 |
width | 将要复制的矩形区域的宽度。 |
height | 将要复制的矩形区域的高度。 |
对于 ImageData 对象中的每个像素,都存在着四方面的信息,即 RGBA 值:
利用 putImageData 将图像数据放回画布,期间做一些颜色处理,可以模拟滤镜。
1 | image.onload = function () { |
1 | context.putImageData(imgData,x,y,dirtyX,dirtyY,dirtyWidth,dirtyHeight); |
参数 | 描述 |
---|---|
imgData | 规定要放回画布的 ImageData 对象。 |
x | ImageData 对象左上角的 x 坐标,以像素计。 |
y | ImageData 对象左上角的 y 坐标,以像素计。 |
dirtyX | 可选。水平值(x),以像素计,在画布上放置图像的位置。 |
dirtyY | 可选。水平值(y),以像素计,在画布上放置图像的位置。 |
dirtyWidth | 可选。在画布上绘制图像所使用的宽度。 |
dirtyHeight | 可选。在画布上绘制图像所使用的高度。 |
fillText 填充方式绘制字符串
1 | context.fillText(text,x,y,maxWidth); |
maxWidth,允许最大的宽度,像素单位
strokeText 用轮廓方式绘制字符串
1 | context.strokeText(text,x,y,maxWidth); |
一些关于文字绘制的属性:
获得文字的宽度
1 | context.measureText(text) |
接受一个参数 text,该参数为需要绘制的文字,返回一个 TextMetrics 对象,TextMetrics对象的 width 属性表示使用当前指定的文字后 text 参数中指定的文字的总文字宽度
1 | function drawText(id) { |
1 | // 将当前状态保存到 栈中 |
绘画状态包括坐标的原点、变形时的变换矩阵,图形上下文对象的当前属性值。
可以应用在下面但不仅仅下面的场景:
原理实际上是把当前的绘画状态输出到一个 data URL 地址所指向的数据中的过程。data URL指的是目前大部分浏览器能够识别的一种 base64编码的 URL,主要用于小型的、可以在网页中直接嵌入的,而不需要从外部文件嵌入的数据,例如 img 元素中的图像文件等。
toDataURL 方法把绘画状态输出到一个 data URL中,然后重新装载,客户可以直接把装载后的文件进行保存。
1 | canvas.toDataURL(type); |
1 | function drawDataURL(id) { |
canvas 画布中制作的动画实际上就是一个不断擦除(clearRect)、重绘的过程。
1 | function animationOnCanvas(id) { |
HTML4页面中播放视频或者音频数据
1 | <object classid="clsid:F08DF954-8592-11D1-B16A-00C0F0283628" id="Slider1" width="100" height="50"> |
可以看出上面代码的一些缺点:冗长,需要使用 Flash 插件,用户没有安装的话,则视频看不了。需要结合多个元素,并且需要添加很多属性。
HTML5 中的则是:
1 | <audio src='xxx.mp3'> |
src
指定媒体数据的 URL 地址
autoplay
指定媒体是否在页面加载后自动播放
preload
指定数据是否预加载。预加载浏览器会预先将视频或者音频数据缓冲,加快播放速度,因为播放时数据已经缓冲完毕。该属性有 none(不进行预加载)、metadata(只预加载媒体的元数据:媒体字节数、第一帧、播放列表、持续时间等)、auto(默认,表示预加载全部视频或者音频)。
poster(video独有)
当视频不可用的时候,可以使用该元素向用户展示一副替代用的图片。用户体验良好
loop
循环播放
controls
添加浏览器自带的播放用的控制条,具有播放、暂停等按钮。
width 与 height(video独有)
该属性中指定视频的宽度和高度(像素)
error
读取使用媒体数据的过程中出错的话,返回一个 MediaError对象,该对象的code返回对应的错误状态,有4个值
MEDIA_ERR_ABORTED(数字值为1):媒体数据的下载过程由于用户操作的原因而被中止
MEDIA_ERR_NETWORK(数字值为2):确认媒体资源可用,但是在下载时出现网络错误,媒体数据的下载过程被中止
MEDIA_ERR_DECODE(数字值为3):确认媒体资源可用,但是解码时发生错误。
MEDIA_ERR_SRC_NOT_SUPPORTED(数字值为4):媒体资源不可用媒体格式不被支持,error属性为只读属性
1 | const video = document.getElementById('video element'); |
1 | video.addEventListener('progress', function (e) { |
currentSrc
读取播放中的媒体数据的 URL地址,为只读属性
buffered(只读)
buffered 属性返回 TimeRanges 对象。该对象表示用户的音视频缓冲范围,缓冲范围指的是已缓冲音视频的时间范围。如果用户在音频中跳跃播放,会得到多个缓冲范围。
语法
1 | audio|video.buffered |
返回值
值 | 描述 |
---|---|
TimeRanges 对象 | 表示音视频的已缓冲部分。TimeRanges 对象属性:length - 获得音视频中已缓冲范围的数量 start(index) - 获得某个已缓冲范围的开始位置 end(index) - 获得某个已缓冲范围的结束位置 注释:首个缓冲范围的下表是 0。 |
readyState(只读)
返回媒体当前的播放位置的就绪状态值:
HAVE_NOTHING(0):没有获取到媒体的任何信息,当前的播放位置没有可播放数据
HAVE_METADATA(1):已经获取到足够的媒体数据,但是当前播放位置没有有效的媒体数据(获取到的媒体数据无效)
HAVE_CURRENT_DATA(2):当前播放位置已经有数据可以播放,但没有获取到可以让播放器前进的数据。当媒体为视频,即当前帧的数据已经获得,也获得了下一帧大数据,或者当前帧已经是最后一帧
HAVE_FUTURE_DATA(3):当前播放位置已经有数据可以播放,而且也获得到了可以让播放器前进的数据。当媒体为视频时,即当前帧的数据已经获得,而且也获得了下一帧的数据,当前帧是播放的最后一帧时,readyState 属性不可能为 HAVE_FUTURE_DATA
HAVE_ENOUGH_DATA(4):当前播放位置已经有数据可以播放,同时也获得了可以让播放器前进的数据,而且浏览器确认媒体数据以某一种速度可以加载,可以保证有足够的后续数据进行播放。
seeking属性与seekable(只读)
seeking属性返回一个布尔值,表示浏览器是否正在请求某一特定播放为孩子的数据,true表示浏览器正在请求数据,false表示浏览器正在停止请求。
seekable返回一个 TimeRanges对象,该对象表示请求到的数据的时间范围。当媒体为视频时,开始时间为请求到视频数据第一帧的时间结束为请求到视频数据最后一帧的时间。
currentTime(可读写)/startTime(只读)/duration(只读)
currentTime获取当前播放位置,也可以通过修改currentTime来修改当前播放位置。如果修改的位置上没有可用的媒体数据时,将抛出 INVALID_STATE_ERR 异常。如果修改的位置超出了浏览器在一次请求中可以请求的数据范围,将抛出 INDEX_SIZE_ERR 异常。
startTime 来读取媒体播放的开始时间,通常为0
duration 读取媒体文件总的播放时间
三者单位均为秒
played/paused/ended(均为只读)
played 返回一个 TimeRanges 对象,从该对象中可以去读媒体文件的以播放部分的时间段。开始时间为已播放部分的开始时间,结束时间为已播放部分的结束时间。
paused 返回一个布尔值,表示是否处于暂停播放中,true 表示媒体暂停播放,false 表示媒体正在播放
end 返回一个布尔值,表示是否播放完毕,true表示播放完毕,false表示还没有播放完毕
defaultPlaybackRate/playbackRate
前者读取或者修改默认的播放速率,后者可以读取修改当前的播放速率
volume与 muted
Volume 读取或者修改媒体的播放音量,范围为0到1。0为静音,1为最大音量
muted读取或者修改媒体的静音状态,布尔值。true表示静音状态,false表示非静音状态
video与 audio都有以下四种方法:
support = videoElement.canPlayType(type)
。该方法使用一个参数type,该参数的指定方法与 source元素的type参数的指定方法相同,都用播放文件的 MIME 类型来指定,可以在指定的字符串中加上表示媒体编码格式的 codes参数。该方法返回3个可能值:事件处理方式分为两种:
事件 | 描述 |
---|---|
loadstart | 浏览器开始在网上寻找媒体数据 |
progress | 浏览器正在获取媒体数据 |
suspend | 浏览器暂停获取媒体数据,但是下载过程并没有正常结束 |
abort | 浏览器在下载完全部媒体数据之前中止获取媒体数据,但是并不是由错误引起的 |
error | 获取媒体数据过程中出错 |
emptied | video元素或者 audio元素所在网络突然变为未初始状态。可能引起的原因有两个:载入媒体过程中突然发生一个致命错误和在浏览器正在选择支持的播放格式时,又调用了 load 方法重新载入媒体 |
stalled | 浏览器尝试获取媒体数据失败 |
play | 即使开始播放,当执行了 play 方法时触发,或者数据下载完之后元素被谁 autoplay |
pause | 播放暂停,当执行了 pause 方法时触发 |
loadedmetadata | 浏览器获取完毕媒体的时长好字节数 |
loadeddata | 浏览器已经加载完毕当前播放位置的媒体数据,准备播放 |
waiting | 播放过程由于得不到下一帧而暂停播放(例如下一帧尚未加载完毕),但很快就能够播放下一帧 |
playing | 正在播放 |
canplay | 浏览器能够播放媒体,但估计以前播放速率不能直接将媒体播放完毕,播放需要缓冲 |
canplaythrough | 浏览器能够播放媒体,而且以当前播放速率能够将媒体播放完毕,不再需要进行缓冲 |
seeking | seeking属性变为 true,浏览器正在请求数据 |
seeked | seeking属性变成false,浏览器停止请求数据 |
timeupdate | 当前播放位置被改变,可能是播放过程中的自然改变,也可能是人为地改变,或由于播放不能连续而发生的跳变 |
ended | 播放结束后停止播放 |
ratechange | defaultplaybackRate属性(默认播放速率)或 playbackRate属性(当前播放速率)被改变 |
durationchange | 播放时长被改变 |
volumechange | volume(音量)被改变或者 muted(静音状态) 被改变 |
1 | video.addEventListener('timeupdate', function () { |
我们可以使用 cookies 在客户端保存注入用户等简单的用户信息,但它有一些限制:
在 HTML5中重新提供了一种在客户端本地保存数据的功能,就是 Web Storage 功能。它分为两种
sessionStorage
将数据保存在 seesion对象中。所谓的 session,是指用户在浏览某个网站的时候,从进入网站到浏览器关闭所经过这段时间,也就是用户浏览器这个网站所花费的时间,session 对象可以用来保存这时间所要求保存的任何数据、
localStorage
将数据保存在客户端本地的硬件设备(通常值硬盘,但也可以是其他硬件设备)中,即使浏览器被关闭了,该数据仍然存在,下次打开浏览器访问网站仍然可以使用。
1 | // sessionStorage |
1 | <body> |
1 | <body> |
HTML5中内置了一个可以通过 SQL 语言来访问的数据库
1 | let db = openDatabase('mydb','1.0','Test DB',2*1024*1024) |
openDatabase接收五个参数:
transaction 方法来执行事务处理,防止在对数据库进行访问以及有关操作的时候收到外界的干扰。当一条语法执行失败的时候,整个事务会回滚
1 | db.transaction(function(context){ |
executeSql 方法
1 | transaction.executeSql(sqlquery,[],dataHandler,errorHandler); |
executeSql 接收四个参数:
1 | transaction.executeSql("UPDATE people set age=?where name=?;",[age,name],(transaction,result)=>{},(transaction,errmsg)=>{}) |
1 | <body onload="init()"> |
不过,Web SQL Database规范已经被废弃。因为每个浏览器都有自己的实现,浏览器的兼容性就不重要了。
HTML5 引入了应用程序缓存,这意味着 web 应用可进行缓存,并可在没有因特网连接时进行访问。
应用程序缓存为应用带来三个优势:
本地缓存与浏览器网页缓存的区别
本地缓存为整个 Web 程序服务的,浏览器的网页缓存只服务于单个网页。任何网页都有网页缓存,而本地缓存值缓存指定的网页。本地缓存可以控制缓存更新,利用缓存对象的各种属性、状态和事件来开发出离线应用程序
首先在 index.html 中引入
1 | <!DOCTYPE html> |
每个指定了 manifest 的页面在用户对其访问时都会被缓存。如果未指定 manifest 属性,则页面不会被缓存(除非在 manifest 文件中直接指定了该页面)。
manifest 文件的建议的文件扩展名是:”.appcache”。
请注意,manifest 文件需要配置正确的 MIME-type,即 “text/cache-manifest”。必须在 web 服务器上进行配置。
Web 应用程序的本地缓存是通过每个页面的 manifest
文件来管理的。是一个简单的文件文本,清单列举了需要被缓存或者不需要被缓存的资源文件的文件名以及访问路径。
manifest 文件可分为三个部分:
1 | CACHE MANIFEST |
更新缓存
一旦应用被缓存,它就会保持缓存知道下列的情况发生:
场景:访问 网站A,以 index.html 为主页,使用 manifest 缓存了 index.html、1.js、1.css、1.jpg几个资源文件。
第一次访问:
manifest文件没有被修改,第二次访问:
manifest文件被修改了,第三次访问:
浏览器缓存过程中会触发一系列事件,该事件处理程序注册在ApplicationCache对象上,此对象是window的applicationCache属性的值。
下面详细描述了加载文档与更新应用缓存的流程:
manifest
特性的文档时,如果应用缓存不存在,浏览器会加载文档,然后获取所有在清单文件中列出的文件,生成应用缓存的第一个版本。window.applicationCache 对象发送一个
checking
事件,在遵循合适的 HTTP 缓存规则前提下,获取清单文件。applicationCache 对象发送一个
noupdate
事件,到此,更新过程结束。注意,如果你在服务器修改了任何缓存资源,同时也应该修改清单文件,这样浏览器才能知道它需要重新获取资源。applicationCache.add() 方法添加到缓存中的那些文件
—会被获取并放到一个临时缓存中,遵循适当的 HTTP 缓存规则。对于每个加入到临时缓存中的文件,浏览器会向 applicationCache 对象发送一个
progress
事件。如果出现任何错误,浏览器会发送一个 error
事件,并暂停更新。applicationCache
对象发送一个 cached
事件。鉴于文档早已经被从缓存加载到浏览器中,所以更新后的文档不会重新渲染,直到页面重新加载(可以手动或通过程序).1 | //载入的时候,检查该清单文件。 |
可以在不同网页文档、不同端口、不同域之间进行消息传递。
HTML5 提供了在网页文档之间互相接受与发送消息的功能,只要获取到网页所在窗口对象的实例,不仅同源(域+端口号)的 Web 之间可以互相通信,甚至可以实现跨域通信。
1 | window.postMessage(); |
派发事件
1 | window.addEventListener('message',receiveMessage,false); |
示例
1 | // A窗口域名是 http://example.com:8080 下面是 A 窗口 script 里面代码 |
1 | // 弹出页 popup域名是 http://example.org 下面是 script 里面代码 |
使用 Web Sockets API 可以在服务器与客户端之间建立一个非 HTTP 的双向连接。这个连接是实时的,也是要永久的,除非某一方显示关闭。
常量
Constant | Value |
---|---|
WebSocket.CONNECTING | 0 |
WebSocket.OPEN | 1 |
WebSocket.CLOSING | 2 |
WebSocket.CLOSED | 3 |
以上是WebSocket 构造函数的原型中存在的一些常量,可通过 WebSocket.readyState
对照上述常量判断 WebSocket 连接 当前所处的状态
用法:
1 | // URL字符串以 ws 或者 wss(加密通信时)文字开头 |
web worker 是运行在后台的 javaScript,不会影响页面的性能。
创建后台线程的步骤很简单。将需要在后台线程中指定的脚本文件的 URL 地址作为参数,然后创建 Worker对象就可以了
1 | let worker = new Worker('worker.js'); |
可以通过发送和接收消息来与后台线程互相传递数据。通过 Worker 对象的 onmessage 事件句柄活期户后台线程之间的消息
1 | worker.onmessage = function(event){ |
1 | <body> |
1 | // SumCalculate.js |
1 | <body> |
1 | // script.js |
单层嵌套
1 | // html script |
多个线程中进行数据的交互
实现子线程与子线程之间数据交互的,大致需要下面步骤:
1 | onmessage = function(event){ |
self
表示本线程范围内的作用域
postMessage(message)
向创建线程的源窗口发送信息
onmessage
获取接受消息的事件句柄
importScripts(urls)
导入其他脚本文件,参数为文件的 URL地址,可以导入多个
navigator
与 window.navigator对象类似,具有 appName、platform、userAgent、appVersion这些属性
sessionStorage/localStorage
可以在线程中使用 Web Storage
XMLHttpRequest
在线程中处理 Ajax请求
setTimeout/setInterval
在线程中实现定时处理
close
结束本线程
eval/isNaN/escape
使用 javascipt 的核心函数
object
可以创建对象
WebSockets
使用 WebSockets API 来想服务器发送和接收信息
常见代码区别:
新增的一些属性:
1 | <!--html4--> |
结构上:
1 | <!--html4--> |
HTML 语法是在 SGML(Standard Generalized Markup Language)语言的基础上建立起来的。对于 HTML 的执行在各个浏览器之间没有统一的一个标准。
HTML5 就是围绕这个Web标准,重新定义了一套在现有的 HTML 的基础上修改而来的语法,使它运行在各浏览器时它们都能符合这个通用标准。
1.内容类型(ContentType)
扩展符仍为 ‘.html’或者 ‘.htm’。内容类型仍然为 ‘text/html’
2.DOCTYPE 声明
1 | <!--html4--> |
3.指定字符编码
1 | <!--html4--> |
1.可以省略标记的元素
1 | 不允许写结束标记的元素有:area/base/br/col/command/embed/hr/img/input/keygen/link/meta/param/source/track/wbr |
2.具有 boolean 值的属性
例如 disable/readonly/checked 等,只写属性不写属性值或者属性值为空字符表示属性值为 true。
3.省略引号
1 | <input type="text"> |
html5(html4)
section(div)
表示页面中的一个内容区块,用于章节、页眉、页脚或者页面中的其他部分。与 h1-h6元素结合使用,表示文档结构
article(div)
表示页面中的一块与上下文不相关的独立内容,例如博客中的一篇文章或者报纸中的一篇文章
aside(div)
aside 元素表示 article 元素的内容之外的,与 article 元素内容相关的辅助信息
header(div)
表示页面一个内容区块或者整个页面的标题
hgroup(div)
用于整个页面或者页面中的一个内容块的标题进行组合
footer(div)
整个页面或者页面中的一个内容区块的脚注。一般来说,会包括作者的姓名、创作日期以及作者的联系信息。
nav(ul)
页面中导航链接部分
figure(dl)
一段独立的流内容,一般表示文档主体流内容中的一个独立单元,使用 figcaption
元素 为 figure
元素组添标题
1 | <!--html5--> |
video(object)
定义视频,比如电影片段或者其他视频流
1 | <!--html5--> |
audio(object)
定义音频,比如音乐或者其他音频流
1 | <!--html5--> |
embed(object)
用来插入各种多媒体,格式可以是 Midi/Wav/AIFF/AU/MP3
1 | <!--html5--> |
source
为媒介元素定义媒介资源
1 | <!--html5--> |
mark(span)
元素
用来在视觉上向用户呈现那些需要突出显示或者高亮显示的文字。mark 元素的一个比较经典的应用就是在搜索结果中向用户高亮显示搜索关键词
progress(无)
表示进程运行中的进程,可以用 progress 来显示 javascript 中耗费时间的函数的进程
meter(无)
表示度量衡。仅用于已知最大值和最小值的度量。必须定义度量的范围,既可以在元素的文本中,也可以在 min/max 属性中定义。
time(span)
表示日期或者时间,也可以同时表示两者
ruby(无)
表示 ruby 注释(中文注音或者字符)
在东亚使用,显示的是东亚字符的发音。
与 <ruby>
以及 <rt>
标签一同使用:
ruby 元素由一个或多个字符(需要一个解释/发音)和一个提供该信息的 rt 元素组成,还包括可选的 rp 元素,定义当浏览器不支持 “ruby” 元素时显示的内容。
1 | <<ruby> |
rt(无)
表示元素字符的解释或者发音
rp(无)
在 ruby 注释中使用,以定义不支持 ruby 元素的浏览器所显示的内容
wbr
表示软换行,wbr 与 br 区别在于后者表示此处必须换行,前者是浏览器窗口或者父级元素的宽度足够宽的时候不进行换行,而当宽度不够时,主动在此处进行换行。wbr 元素好像对字符型的语言作用挺大,但是对中文没有多大用处。
1 | <p>学习 AJAX ,您必须熟悉 <wbr>Http<wbr>Request 对象。</p> |
canvas
canvas 表示图形,比如图表和其他图像。元素本身没有行为,仅提供一块画布,但它把一个绘图 API 展示给 客户端的 javascript 以使得脚本能够把想绘制的东西绘制到这块画布上面。
1 | <!--html5--> |
details
details 元素表示用户要求得到并且可以得到的细节信息,可以与 summary
元素配合使用。summary
提供标题或者图例。标题是可见的,用户点击标题时,会显示出细节信息。summary
元素应该是 details
元素的第一个子元素。
1 | <details> |
datalist
表示可选数据的列表,与 input 元素配合使用,可以制作出 输入值的下拉列表。
1 | <input list="cars" /> |
datagrid
表示可选数据的列表,以树形列表的形式来显示
1 | <datagrid> |
output(span)
表示不同类型的输出,比如脚本输出
1 | <form oninput="x.value=parseInt(a.value)+parseInt(b.value)"> |
menu
表示菜单列表,当希望列出表单控件的时候使用该标签
1 | <menu type="toolbar"> |
但是目前主流所有浏览器都不支持这个标签
email
表示必须输入 E-mail
url
输入 URL 地址
number
输入数值
range
输入一定范围内数字值
Date Pickers
拥有多个选择日期和时间的新型输入文本框
data-日、月、年
month-月、年
week-周、年
time-小时、分钟
datetime-日、月、年(UTC)
datetime-local-日、月、年(本地时间)
1.能用css替代的元素
1 | basefont/big/center/font/s/strike(del)/tt/u |
2.不再使用 frame 框架
1 | frameset/frame/noframes,现只支持 iframe框架。 |
3.只有部分浏览器支持的元素
1 | applet(embed/obejct)/bgsound(audio)/blink/marquee |
4.其他被废除的元素
1 | rb(ruby)/acronym(abbr)/dir(ul)/isindex(form+input)/listing(pre)/xmp(code)/nextid(GUIDS)/plaintext('text/plain' MIME 类型) |
input[type=text]
、select
与 button
指定 autofucus
属性,以指定的方式让元素在画面打开的时候自动获得焦点input[type=text]
与 textarea
指定 placeholder
属性,会对用户的输入进行提示,提示用户输入的内容input
、output
、select
、textarea
、button
与 fieldset
指定 form
属性,声明它们属性哪个表单,然后将其放置任何位置,而不是在表单之内input[type=text]
与 textarea
指定 required
。表示用户提交的时候进行检查,检查该元素内一定要有输入内容input
其他新增的属性:autocomplete
、min
、max
、multiple
、pattern
、step
。同时还有一个 新的 list
元素可以与 datalist
配合使用。datalist
与 autocomplete
属性配合使用。multiple
属性允许在上传文件的时候一次上传多个文件。input
、button
增加了新的属性 formaction
、formenctype
、formmethod
、formnovalidate
与 formtarget
,它们可以重载 form
元素的 action
、enctype
、method
、novalidate
与 target
属性。为 fileset
增加了 disabled
,可以把它的子元素设为 disabled(无效)
状态input
、button
、form
增加了 novalidate
属性,该属性可以取消提交时进行的有关检查,表单可以被无条件提交。a
与 area
增加 media
属性,该属性规定目标 URL是什么类型的媒介/设备进行优化,只能在 href
属性存在时使用area
元素增加了 hreflang
属性与 rel
属性,以保持与 a
元素、link
元素的一致。link
元素增加了新的属性 sizes
。该属性可以与 icon
属性元素结合使用(通过 rel
属性),该属性指定关联图标(icon
元素)的大小。base
元素增加了target
属性,主要目的是保持与 a
元素的一致性。除了上面介绍的与表单和链接相关的属性外,HTML5 还增加了下面的属性:
ol
元素增加 reversed
,它指定了列表倒序显示meta
增加 charset
属性。因为这个属性被广泛支持了,而且为文档的字符编码的执行提供了一种良好的方式style
属性增加scoped
属性,用来规定样式的作用范围,例如只对页面某个树起作用。script
增加 async
属性,定义脚本是否异步执行html
增加 mainfest
,开发离线Web 应用程序时它与 API 结合使用,定义一个 URL,在这个 URL 上描述文档的缓存信息。iframe
元素增加了三个属性 sandbox
、seamless
与 srcdoc
,用来提高页面安全性,防止不信任的 Web 页面执行某些操作。在HTML 4中使用的属性 | 使用该属性的元素 | 在HTML 5中的替代方案 |
---|---|---|
rev | link、a | rel |
charset | link、a | 在被链接的资源的中使用HTTP Content-type头元素 |
shape、coords | a | 使用area元素代替a元素 |
longdesc | img、iframe | 使用a元素链接到校长描述 |
target | link | 多余属性,被省略 |
nohref | area | 多余属性,被省略 |
profile | head | 多余属性,被省略 |
version | html | 多余属性,被省略 |
name | img | id |
scheme | meta | 只为某个表单域使用scheme |
archive**、chlassid、codebose、codetype、declare、standby** | object | 使用data与typc属性类调用插件。需要使用这些属性来设置参数时,使用param属性 |
valuetype、type | param | 使用name与value属性,不声明之的MIME类型 |
axis、abbr | td、th | 使用以明确简洁的文字开头、后跟详述文字的形式。可以对更详细内容使用title属性,来使单元格的内容变得简短 |
scope | td | 在被链接的资源的中使用HTTP Content-type头元素 |
align | caption、input、legend、div、h1、h2、h3、h4、h5、h6、p | 使用CSS样式表替代 |
alink、link、text、vlink、background、bgcolor | body | 使用CSS样式表替代 |
align、bgcolor、border、cellpadding、cellspacing、frame、rules、width | table | 使用CSS样式表替代 |
align、char、charoff、height、nowrap、valign | tbody、thead、tfoot | 使用CSS样式表替代 |
align、bgcolor、char、charoff、height、nowrap、valign、width | td、th | 使用CSS样式表替代 |
align、bgcolor、char、charoff、valign | tr | 使用CSS样式表替代 |
align、char、charoff、valign、width | col、colgroup | 使用CSS样式表替代 |
align、border、hspace、vspace | object | 使用CSS样式表替代 |
clear | br | 使用CSS样式表替代 |
compace、type | ol、ul、li | 使用CSS样式表替代 |
compace | dl | 使用CSS样式表替代 |
compace | menu | 使用CSS样式表替代 |
width | pre | 使用CSS样式表替代 |
align、hspace、vspace | img | 使用CSS样式表替代 |
align、noshade、size、width | hr | 使用CSS样式表替代 |
align、frameborder、scrolling、marginheight、marginwidth | iframe | 使用CSS样式表替代 |
autosubmit | menu |
允许用户编辑元素中的内容,该元素必须是可以获得用户鼠标焦点的元素,在点击鼠标后要向用户提供一个插入符号,提示用户该元素中的内容允许被编辑。contentEditable
属性是一个布尔值属性,可以被指定 true
或者 false
除此之外,还有一个隐藏的 inherit
状态,属性为 true
,元素被指定为允许编辑,属性为 false
时,元素被指定为不允许编辑。未指定 true
或者 false
时,则由 inherit
状态来决定,如果元素的父元素是可以编辑的,则该元素就是可编辑的。
元素还具有一个叫做 isContentEditable
属性,当元素可编辑时,该属性为 true,当元素不可编辑时,该属性 为 false。
1 | <ul contentEditable> |
designMode
属性用来指定整个页面是否可编辑,当页面可编辑时,页面中任何支持上文所述的 contentEditable
属性的元素都变成了可编辑状态。designMode
只能在 javascript 脚本里被编辑修改。该属性有两个值——“on” 和 “off”。属性被指定为 on
时,页面可编辑,被指定为 off
时,页面不可编辑。使用 javascript 来指定 designMode 属性的方法如下:
1 | document.designMode = 'on' |
针对 designMode
属性,各个浏览器的支持情况也不一样:
designMode
属性让页面进行编辑状态designMode
属性让页面进入编辑状态frame
方式,该内嵌 frame
是可编辑的designMode
属性让页面进入编辑状态在 HTML5 中,所有的元素都允许有一个 hidden
属性,该属性类似于 input
中的 hidden
元素,功能是通知浏览器不渲染该元素,使该元素处于不可见状态。但是元素中的内容还是浏览器创建的,也就是说页面装载后允许使用 javascript 脚本将该属性取消,取消后该元素变为可见状态,同时元素中的内容页即时显示出来。hidden
属性是一个布尔值的属性,为设为 true
后,元素处于不可见状态,当设为 false
后,元素属于可见状态。
spellcheck
是针对 input
与 textarea
这两个文本输入框提供的一个新属性,它的功能为对用户输入的文本内容进行拼写和语法检查。是一个布尔值属性,具有 true 和 false 两种值。必须明确书写属性值
1 | <textarea contentEditable spellcheck="true"></textarea> |
需要注意的是如果元素的 readonly
或者 disabled
设为 true
,则不执行拼写检查。
当不断敲击 Tab 键让窗口或者页面中的控件获得焦点,对窗口或者页面的所有控件进行遍历的时候,每一个控件的 tabindex
表示该控件是第几个页面访问到的。
过去的这个属性在编辑网页的时候非常有用,但如今控件的遍历顺序是由元素在页面上所处的位置决定的,所以不再需要了。
但是 tabindex
还有另外一个作用,在默认属性下,只有链接元素与表单可以通过按键获得焦点。如果对其他元素使用 tabindex
属性后,也能让该元素获得焦点,那么当脚本中执行 focus()
语句的时候,就可以让该元素获得焦点了。但这样做会有一个副作用:钙元素也可以通过按 Tab 键获得焦点,而这时有可能也不是开发者想要的结果。
把元素的 tabindex
设为为负数(通常为-1)后就可以解决这个问题。tabindex
的值为负数后,仍然可以通过编程的方式让元素获得焦点,但按下 Tab 键时该元素就不能获得焦点了。这在复杂的页面中或复杂的 Web 应用程序中是非常有用的。在 HTML4 中,-1 是一个无用的属性值,但到了 HTML5 中,通过巧妙运用让该属性得到了极大的应用。
1 | <a href="http://laibh.top" tabindex="2">赖同学</a><br /> |
使用 data-* 属性来嵌入自定义数据
1 | <ul id="target"> |
规定元素是否可以拖动,链接和图像默认是可以拖动的。
语法:
1 | <element draggable="true|false|auto"> |
例子:
1 | <style> |
兼容旧版本浏览器的 hack
1 | <style> |
新的 form 属性:
新的 input 属性:
浏览器支持
Input type | IE | Firefox | Opera | Chrome | Safari |
---|---|---|---|---|---|
autocomplete | 8.0 | 3.5 | 9.5 | 3.0 | 4.0 |
autofocus | No | No | 10.0 | 3.0 | 4.0 |
form | No | No | 9.5 | No | No |
form overrides | No | No | 10.5 | No | No |
height and width | 8.0 | 3.5 | 9.5 | 3.0 | 4.0 |
list | No | No | 9.5 | No | No |
min, max and step | No | No | 9.5 | 3.0 | No |
multiple | No | 3.5 | No | 3.0 | 4.0 |
novalidate | No | No | No | No | No |
pattern | No | No | 9.5 | 3.0 | No |
placeholder | No | No | No | 3.0 | 3.0 |
required | No | No | 9.5 | 3.0 | No |
form
在 HTML4 中表单内的从属元素必须写在表单内容,但是 HTML5 中,可以把它书写在页面的任何地方,然后给该元素指定一个 form
属性。属性值为该表单的 id,这样就可以声明该元素从属于指定表单了。
1 | <form id="testform"> |
目前只有 Opera 支持这个属性
formaction
HTML4中,一个表单内的所有元素都只能通过表单的 action
属性统一提交到另一个页面,而在 HTML5 可以给所有的提交按钮,<input type="submit">
、<input type="image">
、<button type="submit">
都增加 formaction
属性,使得点击不同的按钮,可以将表单提交到不同的页面
1 | <form id="testform"> |
目前没有浏览器支持这一属性
formmethod
在 HTML4 中只有一个表单内有 action
属性来对表单内所有元素统一指定提交页面,所以每个表单内只有一个 method
属性来指统一指定提交方法。在 HTML5 中,可以使用 formaction
属性来对每个表单元素分别指定不同的提交页面,也可以用 formmethod
对每个表单元素分别指定不同的提交方式。
1 | <form id="testform" action="serve.jsp"> |
目前没有浏览器支持这一属性
placeholder
是指文本框为输入状态时,文本框里面显示的输入提示。
1 | <input type="text" placeholder="input me"> |
autofocus
给文本框、选择框或者按钮控件加上该属性,当画面打开的时候,该控件自动获得光标焦点。
1 | <input type="text" autofocus> |
一个页面只能有一个控件具有该属性。不要滥用,建议只有当一个页面是以使用某个控件为主要目的的时候才使用。例如搜索页面中的搜索文本框。
list
为单行文本框添加一个 list
属性,它的值为某个 datalist
元素的 id。类似于 select
,不同的是它除了可以选择之外,还可以自己输入。
1 | <input list="cars" /> |
autocomplete
辅助输入所用的自动完成功能,是一个节省输入时间,同时也非常方便的功能。可以指定 on
、off
、""
三个值。不指定时用浏览器的默认值。
1 | <input type="text" name="greeting" autocomplete="on" list="greetings"> |
url
、email
、date
、time
、datetime
、datetime-local
、month
、week
、number
、range
、search
、tel
、color
1 | <input type="url" value="http://laibh.top"> |
output
定义了不同类型的输出,比如计算结果或者脚本的输出。output 元素必须从属某个表单,必须将它书写在表单内部,或者对它添加 form 属性。
1 | <form id="testform1"> |
属性 | 值 | 描述 |
---|---|---|
for | element_id | 定义输出域相关的一个或多个元素。 |
form | form_id | 定义输入字段所属的一个或多个表单。 |
name | name | 定义对象的唯一名称。(表单提交时使用) |
自动验证
require
可以应用在大部分输入元素(除了隐藏元素,图片按钮上)。在提交时,如果元素中内容为空白,则不允许提交,同时在浏览器中显示信息提示文字。
pattern
要求输入内容格式的,对 input
使用 pattern
属性,设为某个正则表达式
1 | <input pattern="[0-9][A-Z]{3}" name="part" placeholder="输入内容;一个数字与三个大写字母"> |
min
与 max
数值类型与日期类型元素专用属性。限制了 input
元素输入的数值与日期范围。
step
控制 input
元素中的值增加或者减少的步幅。
显式验证
HTML5 中,form
与 input
(除了 select
与 textarea
)都具有一个 checkValidity
。使用这个方法,可以显示对表单内所有元素内容或者单个元素内容进行有效性验证。返回 boolen值
1 | <form id="textform4" onsubmit="return check()"> |
但其实一般提交按钮的时候,会自动检验格式
有两种方法取消校验,第一种用 form
的 novalidate
属性
1 | <form id="textform4" onsubmit="return check()" novalidate> |
但是上述的方法里面的 checkValidity()
仍会生效
第二种是利用 input
或者 submit
元素的 formnovalidate
。
1 | <form id="textform4" onsubmit="return check()"> |
前者是单单让一个 input
取消验证,后者是取消验证整个 form
表单
HTML5 中可以利用 javascript 调用各个 input 元素的 setCustomValidity
方法来自定义错误信息。需要注意的是一旦设置了 setCustomValidity
,检验通过的条件变成了 setCustomValidity('') && !valueMissing && !patternMismatch
1 | <input type="text" id="code" required pattern="^\d{4}$" placeholder="请输入代码" oninput="check(this)"> |
figure
和 figcaption
figure
是一种元素的组合,带有可选标题。figure 元素用来表示网页上一块独立内容,将其从网页上移除后不会对网页上的其他内容产生任何影响。figure
元素所表示的内容可以是图片、统计图或者代码示例。
figcaption
表示 figure
元素的标题,从属于 figure
。一个 figure
最多只允许防止一个 figcaption
元素,但是运行放置多个其他元素
1 | <figure> |
figure
所表示的内容通常是图片、统计图或者代码示例,也可以是音频插件、视频插件或者统计表格。
details
提供了一种替代 javascript 的将画面上的局部区域进行展开或者收缩的方法,目前只有 Chrome 和 Safari 6 支持
1 | <details> |
mark
表示页面需要高亮或者突出显示的。只好是对网页全文检索某个关键词时显示的检索结果。
1 | <p>Do not forget to buy <mark>milk</mark> today.</p> |
为了某种特殊目的把原文作者没有特别重点标示的内容给标示出来。
与 em
、strong
元素的区别在于前者是作者自己标出来的重点要注意的,而mark
跟作者本身没有太多关系,是在后来引用的时候添加上去的。
progress
表示一个任务的完成进度。
该元素有两个参数来表示当前任务完成情况。value
表示完成了多少工作量,max
表示总共多少工作量。
在属性设定的时候,这两个属性只能设定为有效的浮点数,value
的值必须大于0,小于等于 max
。
1 | <h2>progress 使用示例</h2> |
meter
表示规定范围内的数量值。例如磁盘使用量,对于某个候选者的投票人数占总投票人数的比例等。
meter 元素有六个属性:
属性 | 值 | 描述 |
---|---|---|
high | number | 定义度量的值位于哪个点,被界定为高的值。 |
low | number | 定义度量的值位于哪个点,被界定为低的值。 |
max | number | 定义最大值。默认值是 1。 |
min | number | 定义最小值。默认值是 0。 |
optimum | number | 定义什么样的度量值是最佳的值。如果该值高于 “high” 属性,则意味着值越高越好。如果该值低于 “low” 属性的值,则意味着值越低越好。 |
value | number | 定义度量的值。 |
1 | <meter value="5" min="0" max="10" high="8" low="2" optimum="5">3/10</meter><br> |
menu
与 command
用于菜单工具条与弹出菜单。两个元素的浏览器支持不是很好,不做介绍。
ol
增加了 start
与 reversed
属性
1 | <ol reversed> |
dl
重新定义后的 dl
列表包含多个带名字的列表项。每一项包含一条或者多条带名字的 dt
元素,用来表示术语,dt
元素后面紧跟一个或者多个 dd
元素,用来表示定义。在一个元素内,不允许带有相同的 dt
元素,不允许有重复的术语。dl
可以用来定义文章或者网页上的术语解释
1 | <dl> |
cite
标签定义作品(比如书籍、歌曲、电影、电视节目、绘画、雕塑等等)的标题。不能是人名
1 | <img src="/i/ct_fcsz.jpg" alt="富春山居图" /> |
small
由原来的通用展示性元素变成更具体的、专门用来标识所谓的”小字印刷体”的元素。通常用于免责声明、注意事项、法律法规、与版权相关等的法律性声明文字中,同时不允许被应用在页面主内容中,只允许被当做辅助信息用 inline
方式内嵌在页面上使用。同时 small
元素也不意味着元素中内容字体会变小,如果需要将字体变小,需要配合 CSS 样式来用。
HTML5 中提供了一个关于文件操作的文件 API.
FileList
对象表示用户选择的文件列表。在 HTML4 中,file
控件只允许放置一个文件,到了 HTML5 中。通过添加 multiple
属性,file
控件允许一次放置多个文件。控件内的每一个用户选择的文件都是一个 file
对象,而 FileList
对象则为这些 file
对象的列表,代表用户选择的所有文件。 file
对象有两个属性,name
属性表示文件名,不包括路径,lastModifiedDate
属性表示文件的最后修改日期。
1 | <input type="file" id="file" multiple size="80"> |
Blob 表示二进制原始数据,它提供一个 slice
方法,可以通过该方法访问到字节内部数据块。事实上,上面的 file
对象也继承了这个 Blob 对象。
Blob 对象有两个属性,size
属性表示一个 Blob 对象的字节长度,type
属性表示 Blob 的 MIME 类型,如果是未知类型的话,返回一个空字符串。
1 | function showFileName(){ |
通过对 file.size
或者 file.type
判断可以进行文件大小与文件类型的限制。另外 HTML5 已经对 file
控件添加了 accept
属性,企图让 file
控件只能接受某种类型的文件。
1 | <input type="file" id="file" multiple size="80" accept="image/gif"> |
这样打开文件的时候就会显示 gif结尾的文件,当然如果你强行传其他类型的也是没有报错的。所以如果要做限制上传文件类型的话要结合 file.type
来实现。
主要用来把文件读入内存,并且读取文件中的数据。FileReader 接口有了一个异步 API,使用该 API可以在浏览器主线程中异步访问文件系统,读取文件中的数据。
检查是否可以使用:
1 | if(!typeof FileReader){ |
接口方法
这个接口拥有4个方法,其中三个用来读取文件,另一个用来读取过程中断
方法名 | 参数 | 描述 |
---|---|---|
readAsBinaryString | file | 将文件读取为二进制码 |
readAsText | file,[encoding] | 将文件读取为文本 |
readAsDataURL | file | 将文件读取为 DataURL |
abort | (none) | 中断读取操作 |
UTF-8
。将文件以文本方式读取,读取的结果是这个文本文件中的内容。接口的事件
除了上面打方法,FileReader 接口还包含了一套完整的事件模型,用于捕获读取文件时的状态。
事件 | 描述 |
---|---|
onabort | 数据读取中断时触发 |
onerror | 数据读取出错时触发 |
onloadstart | 数据取数开始时触发 |
onprogress | 数据读取中 |
onload | 数据读取成功完成时触发 |
onloadend | 数据读取完成时触发,无论成功或者失败 |
1 | <p> |
关于读取状态的先后顺序:
1 | function readAsDataURL() { |
在 onprogress
里面可以用 progress
来显示文件读取的百分比。
虽然在 HTML5 之前已经可以使用 mousedown
、mousemove
、mouseup
来实现拖放操作,但是这只是在浏览器内容的拖放。在 HTML5 中,支持在浏览器与其他应用程序之间的数据互相拖动,同时也大大简化了拖放方面的代码。
draggable
属性设为 true
,这样才能将该元素进行拖放。另外,img元素与 a 元素默认运行拖放。事件 | 产生事件的元素 | 描述 |
---|---|---|
drastart | 被拖放的元素 | 开始施放操作 |
drag | 被拖放的元素 | 拖放过程中 |
dragenter | 拖放过程中鼠标经过的元素 | 被拖放的元素开始进入本元素的范围 |
dragover | 拖放过程中鼠标经过的元素 | 被拖放的元素正在本元素范围内移动 |
dragleave | 拖放过程中鼠标经过的元素 | 被拖放的元素离开本元素的范围 |
drop | 拖放的目标元素 | 有其他元素被拖放到了本元素中 |
dragend | 拖放的对象元素 | 拖放操作结束 |
1 | <body onload="init()"> |
dragstart
事件发生)时,将要拖动的数据存入 DataTransfer
对象(setData()方法
)。DataTransfer
对象专门用来存放拖放时要携带的数据,它可以被设置为拖动事件对象的 dataTransfer
属性。setData
方法中的第一个参数为携带数据的数据种类的字符串,第二个参数为要修改的数据。第一个参数中表示数据种类的字符串里只能填入类似 text/plain
或者text/html
的表示 MIME 类型的文字,不能填入其他文字。dt.setData("text/plain","你好")
改成 dt.setData("text/plain",this.id)
。因为把被拖动元素的 id 当成了参数,所以携带的数据就是被拖动元素中的数据了,因为浏览器在使用 getData()
方法读取数据时会自动读取该元素中的数据。dragend
或者 dragover
事件内调用事件对象的 preventDefault()
方法。因为默认情况下,被拖放的目标元素不允许接受元素的,为了把元素拖放到其中,必须把默认处理给关闭掉。getData
方法从 DataTransfer
获得数据。getData
方法的参数为 setData
方法中指定的数据种类drop
事件中关闭默认处理(拒绝被拖放),否则目标元素不能接受被拖放的元素draggable
属性设为 true
,另外,为了让这个示例在所有支持拖放 API 的浏览器中都能正常运行,需要指定 -webkit-user-drag:element
这种 Webkit 特有的 CSS 属性现在支持拖放处理的 MIME 类型主要有一下几种:
属性/方法 | 描述 |
---|---|
dropEffect属性 | 表示拖放操作的视觉效果,运行对其进行值的设定。该效果必须用 effectAllowed 属性置顶的允许的效果范围内。允许指定的值为 none、copy、move、link |
effectAllowed属性 | 用来指定当元素被拖放时所运行的视觉效果,可以指定的值为none、copy、copyLink、copyMove、link、linkMove、move、all、unintialize |
types属性 | 存入数据的种类,字符串的伪数组 |
void clearData(DOMString format)方法 | 清除 DataTransfer 对象中存放的数据,如果省略参数 format,则清除全部数据 |
void setData(DOMString format、DOMString data) | 向 DataTransfer 对象内存入数据 |
DOMString getData(DOMString format) | 从 DataTransfer 对象中读数据 |
void setDragImage(Element image,long x,long y) | 用 img 元素来设置拖放图标(部分浏览器可以用 canvas 等其他元素来设置) |
dropEffect
与 effectAllowed
属性结合起来可以设定拖放时的视觉效果。effectAllowed
属性表示当一个元素被拖动时所运行的视觉效果,一般在 ondragstart
事件中设定,运行使用的值为 none、copy、copyLink、copyMove、link、move、all、unintialize。
dropEffect
属性表示实际拖放时的视觉效果,一般在 ondragover
事件中指定,运行设定的值为 none、copy、link、move。dropEffect
属性所表示的实际视觉效果必须在 effectAllowed
属性所表示的允许的视觉效果范围内。规则如下:
effectAllowed
属性被设为 none
,则不允许拖放元素。dropEffect
属性设定为 none
,则不允许被拖放到目标元素中effectAllowed
属性设定为 all
或者不设定,则 dropEffect
属性允许被设定为任何值,并按照指定的视觉效果进行显示effectAllowed
属性设定为具体效果(不为 none或者 all),dropEffect
属性也设定了具体视觉效果,则两个具体效果值必须完全相等,否则不允许被拖放元素拖放到目标元素中。1 | source.addEventListener('dragstart',function(ev){ |
除了上面所说的使用 effectAllowed
属性与dropEffect
属性外,HTML5 还允许自定义拖放图标——指的是在用鼠标拖动元素的过程中,位于鼠标指针下部的小图标。
setDragImage
方法有三个惨呼,第一个参数 image 设定为拖放图标的图标元素,第二个参数 x 为拖放图标离鼠标指针x轴方向的位移量,第三个参数 y 为拖放图标距离鼠标指针的 y 轴方向的位移量。
1 | dragIcon.src = 'http://laibh.top/images/favicon-32x32-next.png?v=5.1.4'; |
是一个用于构建用户界面的 javaScript 库,主要用于构建 UI,很多人认为是 MVC 中的 View 层
特点:
页面打开的时候,React 会调用 render 函数来构建一颗 DOM 树,在 state/props
改变的时候, render 函数会再次调用渲染出来另外一颗树,接着, React 会对两棵树进行对比,找出需要更新的地方进行批量改动
作用
计算出 Virtual DOM 中真正变化的部分,并只针对该部分进行原生 DOM 操作,而非重新渲染整个页面。
传统 diff 算法的劣处
通过循环递归节点进行依次对比,算法复杂度达到 O(n^3)
,n 是树的节点数。假设展示1000个节点,得执行上亿次比较。CPU 无法进行在很短的时间内计算出差异
React 的 diff 算法
将 Virtual DOM 树转换成为 Actual DOM 树的最少操作过程被称为调和。React 的 diff 算法就是调和的具体实现。
React 的diff 策略
React 用三大策略将 O(n^3)
复杂都转换为 O(n)
复杂度
策略1(tree diff):
Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计
策略2(component diff):
拥有相同类的两个组件生成相似的树形结构
拥有不同类的两个组件生成不同的树形结构
策略三(element diff):
对于同一层级的一组子节点,通过唯一的 id 区分
diff 策略详解
Tree diff
1 | updateChildren:function(nextNestedChildrenElements,transaction,context){ |
Component diff
shouldComponentUpdate()
来判断是否需要判断计算dirty component(脏组件)
,从而替换整个组件的所有节点。注意:如果组件 A 和 组件 B 的结构相似,但是 React 判断是不同类型的组件,则不会比较其结构,而是删除 组件 A 以及其子节点,创建组件 B 以其子节点。
Element diff
当节点处于同一层级时,diff 提供三个节点操作:删除、插入、移动
插入:组件 C 不在集合 (AB)中,需要插入
删除:组件 D 在集合 (ABD)中,但是集合变成(AB),D就需要被删除
移动:组件 D 已经在集合(ABCD)中,且集合更新时,D 没有发生更新,只是位置的改变,如新的集合(ADBC)。D 在第二个,无须像传统的 diff,让旧集合的第二个 B 和新集合的比较,并且删除第二个位置的 B,再在第二个位置插入 D 。需要做的是(对同一层级的同组子节点)添加唯一的 key 进行区分,移动即可。
移动逻辑详解
情形一:新旧集合中存在相同节点但是位置不同时,如何移动节点
lastIndex
有点像浮标,或者一个 map 的索引,默认值是 0,会与 map 中的元素进行比较后改变自己的值(取 index
和 lastIndex
中的较大值)
index = 1
,它 的 lastIndex = 0
lastIndex > index
,无需移动操作。lastindex = Math.max(lastindex,index) = 1
index=0
,此时 lastIndex=1
。满足 lastIndex > index
,对 A 进行移动到 lastIndex=1
。接着操作 lastIndex=Math.max(lastIndex,index)
还是等于1index=3
,lastIndex=1
,改 lastIndex=Math.max(lastIndex,index)=3
index=2
,满足lastIndex(3)>index
,移动情形二:新集合中有新加入的节点,旧集合中有删除的节点
lastIndex=1
lastIndex=1
上创建E,更新 lastIndex=1
index=2
,不满足lastIndex>index
,不移动,更新 lastIndex=Math.max(lastIndex,index)=2
index=0
,符合 lastIndex>index
,移动,lastIndex还是等于 2
diff 的不足之处与待优化的地方
情形三:将最后一个元素移动到最前面
D,此时D 不移动,但是因为它的 index 最大的,导致 lastIndex=3
,从而使得其他元素的 index < lastIndex
,导致都要去移动。
理想的情况是值移动D,不移动 ABC.因此在开发中,应该尽量减少将最后一个节点移动到列表的首部,当节点数量过大或者操作更新过于频繁的时候,会影响到 React 的渲染性能。
React 使用 JSX 来替代常规的 javascript,有几个优点:
在 React 应用中,元素是构成 React 的最小单位,JSX 就是用来声明 React 当中的元素。
与浏览器的 DOM 元素不同,React 当中的元素是普通的对象,ReactDOM 可以确保浏览器的数据内容与 React 元素一致。通过 ReactDOM.render
将元素渲染到 DOM 节点上。
1 | const App = <div className="foo " /> |
原理
如何用 JavaScript 元素来表示一个 DOM 元素的结构:
1 | <div class="box" id="content"> |
一个 DOM 元素包含的信息其实只有三个:标签、属性,子元素,所以上面的内容可以表示为
1 | { |
用 javaScript 有个缺点就是内容太长,结构不够清晰,用 HTML 的方式会方便很多。而 React 把 javascript 扩展了一下,让 javascript 能够支持直接在 javascript 代码中编写类似 HTML 标签结构的语法。编译的过程中会把类似 HTML 的 JSX 结构转换成为 javascript 的对象结构。例如下面的代码:
1 | import React,{Component} from 'react'; |
经过编译后:
1 | import React,{Component} from 'react'; |
React.createElement
会构建一个 JavaScript 对象来描述 HTML 结构的信息,包括标签名,属性,还有子元素等等。
所谓的JSX 其实就是 JavaScript 对象
React.render
功能就是把组件渲染并且构造 DOM 树,然后插入到页面上特定的元素上。
总结过程:
1 | Babel 编译 + |
分成四个阶段 Initialization、Mounting、Updation、Unmounting
setup props and states
1 | import React,{Component} from 'react'; |
Test 类继承 react 的基本类,拥有了 render ,生命周期等等方法可以使用。
super(props)
,用来调用基类的构造方法(constructor()),将父组件中的props 注入给子组件,供其读取。
使用ES6 classes构建组件的时候static defaultProps={}其实就是调用内部的getDefaultProps方法。
constructor
可以做一些组件初始化的功能,例如初始化 state
,就是调用内部的 getIntialState
方法
getDefaultProps => getInitialState => componentWillMount => render => componentDidMount
在组件挂载到 DOM 之前调用且只会被调用一次,这里调用 setState
不会引起组件重新渲染,也可以把这边的内容写在 constructor
中。另外 在 React SSR 中,这个生命周期函数很重要。React-Helmet等等插件都是根据这个函数来实现在 Server 插入内容
根据组件的 props
和 state
,返回一个 React 元素,不负责组件的实际渲染工作,之后 React 根据元素去渲染 页面的 DOM 工作。render 是纯函数,不能在里面执行 this.setState
,会有改变组件状态的副作用
组件挂载到 DOM 后调用,且只会被调用一次
props:
componentWillReciveProps => shouldComponentUpdate => ComponentWillUpdate => render => componentDidUpdate
states:
shouldComponentUpdate => componentWillUpdate => render => componentDidUpdate
造成组件更新的情况有两类(三种)情况
1.父组件重新 render
父组件重新 render 引起子组件重新 render 的情况有两种
a) 直接使用,每当父组件重新 render 导致 重传 props
,子组件将跟着重新渲染,无论 props
知否有变化,可通过 shouldComponentUpdate
方法优化
1 | class Child extends Component{ |
b) 在 componentWillReceiveProps
方法中,将 props
换成自己的 state
1 | class Child extends Component{ |
官网的描述,在 componentWillReceiveProps
中调用 this.setState()
不会引起第二次渲染
是因为 componentWillReceiveProps
中判断 props
是否变化了,如果变化了, this.setState
将引起 state
的变化,从而引起 render
,此时就没有必要再做第二次重传 props
引起的 render
,避免重复做一样的渲染。
2.组件本身调用 setState
,无论 state
是否有变化,可通过 shouldComponentUpdate
方法优化
1 | class Child extends Component{ |
这个方法只会调用于props
引起的组件更新过程中,参数 nextProps
是父组件传给当前组件的新 props
。但是父组件 render
方法的调用不能保证重传给当前组件的 props
是否有变化,所以在此方法根据 nextProps
和 this.props
来查明重传的 props
是否有变化,以及如果改变了要执行什么,例如根据新的 props
调用 this.setState()
触发当前组件重新 render
通过比较 nextProps
,nextState
以及 this.state
,this.props
返回 true 时当前组件将继续执行更新过程,返回 false 当前组件停止更新,从而来减少组件的不必要的渲染,优化组件性能。
在 调用 render 之前调用,在这里可以执行一些组件更新前的工作,一般比较少用
这个方法组件更新后调用,可以操作组件更新的 DOM,prevProps
,prevState
这两个参数是指组件更新前的 props
和 state
componentWillUnMount
这个方法在组件被卸载前调用,可以在这里执行一些清理工作。例如组件中使用的定时器,componentDidMount 中手动创建的 DOM 元素等等,可以避免内存泄露
具体实现可以看看这篇文章,一次性搞懂 React 生命周期
生命周期在React v16 推出 Fiber 后就不合适了,如果开启 async rendering
,在 render
函数之前的所有函数,都有可能被执行多次。
v.16 前的生命周期中,componentWillMount
,componentReceiveProps
, shouldComponentUpdate
,componentWillUpdate
是在 render
之前执行的。
开发者如果开启了 async rendering
,而且在上述的生命周期方法做 ajax 请求的话,那就将会被无谓多次调用。明显不是我们期望的结果。在 componentWillMount
请求 ajax ,不管多快的结果都跟不上首次 render
,而且 componentWillMount
在服务器渲染也会被调用到,这样的 IO 操作放在 componentDidMount
里面更加适合。
禁止不能用比劝导开发者不要这样用更好,所以除了 shouldComponentUpdate
,其他在 render
函数之前的所有函数 componentWillMount
、componentWillReceiveProps
,componentWillUpdate
都被 getDerivedStateFromProps
替代。
就是用一个静态函数 getDerivedStateFromProps
来取代被 deprecate
的几个生命周期函数,强制开发者在 render
之前只做无副作用的操作,而且能做的操作局限在根据 props
和 state
决定新的 state
官网API-getDerivedStateFromProps()
v16.0 刚推出的时候,增加了一个 componentDidCatch
生命周期函数,新引入的两个生命周期函数:getDerivedStateFromProps
和 getSnapshotBeforeUpdate
getDerivedStateFromProps
在v.16.3 中只是创建和更新(由父组件引发的)。如果不由父组件引发,这个方法是不会调用的,如自身的 setState
和 forceUpate
引发的。
在 v.16.4 中改正了这一点,让 getDerivedStateFromProps
无论是 Mounting
还是 Updating
,也无论是因为什么引起的 Updating
全部都会被调用。
static getDerivedStateFromProps(props,state)在组件创建时和更新时的 render
方法之前调用,它应该返回一个对象来更新状态,或者返回 null 来不更新任何内容
getSnapshotBeforeUpdate
被调用于 render
之后,可以读取但是无法使用 DOM 的时候。它使得组件可以在可能更改之前从 DOM 中捕获一些信息(例如滚动位置)。这个生命周期返回的任何值都将作为参数传递给 componentDidUpdate
官网的例子:
1 | class ScrollingList extends React.Component{ |
另外可以看看这篇文章,你可能不需要派生状态
官方的解释:
React Fiber 是对核心算法的一次重新实现
v16 之前,更新过程是同步的,这可能会导致性能问题。
React 决定要加载或者更新组件树的时候,会做很多事情,比如调用各个组件的生命周期函数,计算和对比 Virtual DOM ,最后更新 DOM 树,这整个过程是同步进行的,也就是说只要一个加载或者更新过程开始,那么 React 就会一直更新下去,中途不会停歇。
表面看,这样的设计也是合理的,更新过程中不会有 I/O 操作,完全是 CPU 计算,所以不需异步操作。但是当组件书比较庞大的时候,问题就来了。
例如更新一个组件需要1毫秒,如果有200个组件需要更新,那就有200毫秒。用户在 input 按钮输入一个子,要等待200毫秒才会有反应,造成界面卡顿,带来不好的用户体验。
另外一个生动的比较就是潜水员的比喻,潜水员进行潜水任务,在潜到底的过程中,如果岸上有更加优先的任务他是不知道的,只有当他重新浮出水面才能去执行优先级更高的任务。
因为 javascript 单线程的特点,每个同步任务的耗时不能太长,不然就会让程序不会对其他输入做出反应,React 的更新就是犯了这个禁止,而 React Fiber 是要改变现状。
分片是破解 Javascript 同步操作时间过长的方法。
把一个耗时长的任务分成很多小片,每一个小片的运行时间都很短,虽然总时间依然很长,但是在每个小片执行之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的时间。
React Fiber 把更新过程碎片化,每执行一段更新过程,就把控制权交还给 React 负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。
维护每一个分片的数据结构,就是 Fiber
在计算机科学中,Fiber 的含义是 “纤维”,意指比 Thread(线程) 更细的线,也就是比线程控制更精密的并发处理机制。
在 React Fiber 中,一次更新过程会分成多个分片完成,所以完全可能一个更新任务还没有完成,会被另一个更高优先级的更新过程打断,这时候,优先级的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来。
因为一个更新过程可能会被打断,所以 React Fiber 更新过程会被分成两个阶段(Phase):第一个阶段 Reconciliation 和 第二个阶段 Commit Phase.
在第一个阶段中,React Fiber 会找出需要更新哪些 DOM,这个阶段可以被打断,但是到了第二个阶段,那就是一鼓作气把 DOM 更新完成,不会被打断。
这两个阶段大部分工作是结合 React Fiber 和 生命周期函数。
以 render
为界,第一个阶段可能会调用下面这些生命周期函数:
下面这些生命周期函数则会在第二个阶段调用
因为第一个阶段会被打断,重新再来,会造成意想不到的情况。
比如说,一个低优先级的任务 A 正在执行,已经调用了某个组件的 componentWillUpdate
函数,接下来发现自己的时间分片已经用完了,于是冒出水面,看看有没有紧急任务,发现有个紧急任务B,接下来 React Fiber 就去执行这个紧急任务B。任务A 虽然执行了一半,但是没有办法,只能完全放弃,等到任务B 完成后,任务 A 重头来一遍,也就是说 componentWillUpdate
函数会被再重新调用一次。
在现有的 React 中,每个生命周期函数在一个加载或者更新过程中决定只会被调用一次,在 React Fiber 中,不再是这样了,第一阶段的声生命周期函数在一次加载和更新过程中可能会被多次调用。
使用 React Fiber 之后,一定要检查一下第一阶段相关的这些生命周期函数,看看有没有逻辑是假设在一个更新过程中只调用一次的,有的话就要改了。
看看可能会重复调用的函数
componentWillReceiveProps
即使当前组件不更新,只要父组件更新也会引起和这个函数被调用多次,调用多次无妨
shouldComponentUpdate
这个函数的作用是返回一个 true 或者 false,不应该有任何副作用,调用多次无妨
render
是纯函数,调用多次无妨
剩下 componentWillMount
和 componentWillUpdate
这两个函数往往包含副作用,所以当使用 React Fiber 的时候一定要重点看这两个函数的实现。
在 React 中,如果是由 React 引发的事件处理(比如通过 onClick 引发的合成事件处理)和组件生命周期函数内(比如 componentDidMount
),调用 this.setState
不会同步更新 this.state
,除此之外的 setState
调用会同步执行 this.state
。除此之外指的是绕过 React 通过 addEventListener
直接添加的事件处理函数,还有通过 setTimeout
/setInterval
产生的异步调用。另外在合成事件和生命周期函数中的异步调用 setState
(比如 ajax
和 setTimeout
) 也是会同步更新 this.setState
假设 setState 是同步的:
1 | console.log(this.state.value); //0 |
这种只用到了 state 的情况下,同步 setState 是可以的。
然而这时需要将状态提升到父组件,以供多个兄弟组件共享:
1 | console.log(this.props.value) // 0 |
在同步模型中,虽然 this.state 会立即更新,但是 this.props 并不会。而且在没有重渲染父组件的情况下,我们不能立即更新 this.props。如果要立即更新 this.props(立即重新渲染父组件),必须放弃批处理(根据不同的情况,性能可能有显著下降)
所以为了解决这样的问题,在 React 中,this.state 和 this.props 都是异步更新的,在上面的例子中重构前后都会打印出0,这让状态提升更加安全。
我们通常认为状态更新会按照既定顺序被应用,无论 state 是同步更新还是异步更新,然而事实并不一定如此。
React 会依据不同的调用源,给不同的 setState 调用分配不同的优先级。调用源包括事件处理、网络请求、动画等。
1 | // 用法1 |
不要直接修改 state
1 | this.state.comment = 'Hello' (X) |
state 更新可能会异步
从上面也知道了,React 为了性能优化,有可能会将多个 setState 调用合并为一次更新,因为 this.props 与 this.state 可能是异步更新的,你不可能依赖他们的值进行下一个 state 的计算
1 | this.setState({counter:this.state.counter + this.props.increment}) (X) |
为了弥补这个问题,使用了另一种 setState 形式,接受一个函数。这个函数接收前一个状态作为第一个参数,应用更新时的 props 作为第二个参数,代码如下:
1 | this.setState((prevState,props)=>{ |
多次setState函数调用产生的效果会合并
当调用 setState ,React 会合并你提供的对象到当前的状态。所以当 state 是一个多键值的结构,可以单独更新其中一个,此时会 差分 更新,不会影响到其他的属性值
总结起来就是:
重绘指的更新的生命周期函数:
shouldComponentUpdate
:被调用时 this.state 没有更新,如果返回 false,生命周期被中断,虽然不调用之后的函数,但是 state 仍然会被更新componentWillUpdate
:被调用时 this.state 没有更新render
:被调用时 this.state 得到更新componentDidUpdate
React会将setState的效果放在队列中,积攒着一次引发更新过程。为的就是把Virtual DOM和DOM树操作降到最小,用于提高性能。
执行setState 后拿到最新的 state
1 | updateData = (newData)=>{ |
循环调用风险
不要在 shouldComponentUpdate
和 componentWillUpdate
中调用 setState
,不然会出现死循环
在调用 setState
时候实际上会执行 enqueueSetState
方法,并对 partialState
、pendingStateQueue
更新队列进行合并操作,最终通过 enqueueUpdate
执行 state 更新。
而 performUpdateIfNecessary
方法会获取 pendingElement
、pendingStateQueue
、pendingForceUpdate
并调用 receiveComponent
和 updateComponent
方法进行组件更新
如果在 componentWillUpdate
和 shouldComponentUpdate
中调用 setState
,此时 pendingStateQueue !== null
则 performUpdateIfNessary
会调用 updateComponent
进行组件更新,而 updateComponent
又会调用 shouldComponentUpdate
和 componentWillUpdate
这样就导致了循环调用
setState 源码:
1 | // React 组件继承自 React.Component,而 SetState 是 React.Component 的方法,对于组件来说,setState 属于其原型方法 |
setState 调用栈
graph TDA[this.setState]B[newState存入pending队列]C{是否处于 batch update}D(保存组件在 dirtyComponent中)E(遍历所有的dirtyComponents 调用updateComponent 更新 pending state or props)F[调用enqueueUpdate]A --> BB --> FF --> CC == Y ==> DC == N ==> E
React的setState函数实现中,会根据一个变量 isBatchingUpdates
判断是 直接更新 this.state还是 放到队列 中。
isBatchingUpdates
默认false
,表示setState会同步更新this.state,但是函数batchedUpdates
函数会把isBatchingUpdates
修改为true
。当React在调用事件处理函数之前就会调用batchedUpdates
,造成的后果,就是由React控制的事件处理过程setState不会同步更新this.state。
1 | function enqueueUpdate(internalInstance){ |
Transaction
关于 React 中的事务 Transaction,源码中有这样的 ASCII图
1 | /** |
React 中的事务 Transaction,会给方法包装一个个 wrapper,其中每个 wrapper 都有两个方法:initialize 与 close。当执行方法时,需要执行事务的 perform 方法。这个方法会首先一次执行 wrapper 的 initialize,然后执行函数本身,最后执行 wrapper 的 close 方法
定义 Transaction 需要给构造函数混入 Transaction.Mixin,并需要提供一个原型方法 getTransactionWrappers 用于返回 wrapper 数组。
1 | var RESET_BATCHED_UPDATES = { |
重新回顾一下更新的过程,如果处于批量更新的过程中(isBatchingUpdates
为 true)的时候,则直接将组件传入 dirtyComponents
。如果不是的话,就开启批量更新,用事务 transaction.perform
执行 enqueueUpdate
,这时候 isBatchingUpdates
经过上次执行已经是 true,将直接传入 dirtyComponents
。那么更新的组件传入 dirtyComponent
会发生什么?
batchUpdates
是处于一个事务中的,该事务在 close 阶段做了两件事,首先是将 ReactDefaultBatchingStrategy.isBatchingUpdates
设置为 false,即关闭批量更新的标志位,第二个就是调用方法 ReactUpdates.flushBatchedUpdates
。
1 | // 代码有省略 |
这一部分代码相对来说不算是很难,replace
是存在是由于之前被废弃的APIthis.replaceState
,我们现在不需要关心这一部分,现在我们可以知道为什么给 setState
传入的参数是函数时,就可以解决 setState
的潜在问题。
1 | this.setState({value:this.state.value + 1}) |
调用 setState
是批量更新,那么在第一次调用之后,this.state.value的值并没有发生拜年话。两次更新的指都是一样的。如果传递的是会调用函数形式,那么 partial.call(inst,nextState,props,context)
接受的 state 都是上一轮更新之后的新值,因此可以达到我们想要的结果。
_processPendingState
在计算完新的 state 之后会执行 _performComponentUpdate
1 | function _performComponentUpdate( |
到目前为止,我们已经基本介绍完了setState的更新过程,只剩一个部分没有介绍,那就是setState执行结束之后的回调函数。我们知道,setState函数中如果存在callback,则会有:
1 | if (callback) { |
call函数会被传递给this.updater
的函数enqueueCallback
,然后非常类似于setState,callback
会存储在组件内部实例中的_pendingCallbacks
属性之中。我们知道,回调函数必须要setState真正完成之后才会调用,那么在代码中是怎么实现的。大家还记得在函数flushBatchedUpdates
中有一个事务ReactUpdatesFlushTransaction
:
1 | //代码有省略 |
我们现在看看ReactUpdatesFlushTransaction
的wrapper是怎么定义的:
1 | var UPDATE_QUEUEING = { |
我们看到在事务的close
阶段定义了this.callbackQueue.notifyAll()
,即执行了回调函数,通过这种方法就能保证回调函数一定是在setState真正完成之后才执行的。
现在可以回答,为什么下面这两种改代码会产生不同的情况:
1 | //未按预期执行 |
在第一种情况下,如果打断点追踪你会发现,在第一次执行setState
前,已经触发了一个 batchedUpdates
,等到执行setState时已经处于一个较大的事务,因此两个setState都是会被批量更新的(相当于异步更新的过程,this.state.value值并没有立即改变),执行setState只不过是将两者的partialState
传入dirtyComponents
,最后再通过事务的close
阶段的flushBatchedUpdates
方法去执行重新渲染。但是通过setTimeout
函数的包装,两次setState都会在click触发的批量更新batchedUpdates
结束之后执行,这两次setState会触发两次批量更新batchedUpdates,当然也会执行两个事务以及函数flushBatchedUpdates
,这就相当于一个同步更新的过程,自然可以达到我们的目的,这也就解释了为什么React文档中既没有说setState是同步更新或者是异步更新,只是模糊地说到,setState并不保证同步更新。
graph TDA(DOM)B(生命周期)C[dispatchEvent-ReactEventListener.js]D[_renderNewRootComponent-ReactMount.js]E[ReactUpdates.batchedUpdates-ReactUpdates.js]F[isBatchingUpdates=true]G[transaction.perform 执行事件回调和生命周期进入事务]H[close isBatchingUpdates=false ReactUpdates.flushBatchedUpdates]I[检查 dirtyComponents 完成更新]A == type:'click' ==> CB == componentDidMount ==> DC --> ED --> EE --> FF --> GG --> HH --> I
###
传统 react项目,在浏览器打开项目,查看源代码,可以看到 index.js 页面里面只有一个基本的 DOM 节点,下面引入了一个 js 文件。而项目的所有内容就是通过这个 js 来渲染,通过禁止浏览器的 javascript 功能可以看到浏览器提示要 开启 javascript 功能的提示,没有项目的内容生成。
CSR 过程:
1 | 浏览器发送请求 => 服务器返回HTML => 浏览器发送 bundle.js 请求 => 服务器返回 bundle.js => 浏览器运行 React 代码 => 页面准备就绪 |
优势:前后端分离,前端 ajax 请求数据,后端内部处理好逻辑返回 JSON 给前端处理。代码在客户端运行,性能良好
劣势:首屏加载速度慢。SEO 差(百度爬虫不能识别 js 文件)
SSR 过程:
1 | 浏览器发送请求 => 服务器运行 React 代码生成页面 => 服务器返回页面 |
优势:首屏速度快
劣势:服务器性能需求大
1 | import {renderToString} from 'react-dom/server'; |
可识别嵌套路由,内置 ssr 方案
服务器上呈现的路由都是无状态的,所以我们将应用程序包装在无状态的 <StaticRouter>
e而不是<BrowserRouter>
。从服务器传入请求的路由,以便路由可以匹配。
1 | // client |
在 <Redirect>
客户端呈现 a 标签的时候,浏览器历史记录会更改状态,我们获得新的屏幕。在静态服务器 换机中,我们无法更改应用程序的状态。我们可以使用 context 来找出需要渲染的结果。我们找到了 context.url 就知道应用是否被重定向了。
1 | const context = {} |
路由器只会添加 context.url,如果希望重定向 301或者 302 可以在希望呈现的 UI 的某个特定分支发送 404 响应,或者如果未授权则发送 401。
1 | function RedirectWithStats({from,to,status}){ |
跟上面的操作差不多,可以创建一个组件,添加一些上下文并在应用程序中的任何位置呈现它以获取不同的状态码
1 | function Status({code,children}){ |
这不是一个真正的应用程序,但是它显示了将所有这些组合在一起所需要的所有常规部分
1 | // server |
在页面呈现之前加载数据,React Router 导出 matchPath 它在内部使用的静态函数,以匹配路由的位置。可以在服务器上使用此功能来帮助确定在呈现之前数据的依赖项。
这种方法的要求是依赖于静态路由的配置,用于在呈现之前根据路由匹配来加载对应的数据
1 | // routes.js |
1 | module.exports = { |
1 | const path = require('path'); |
不打包引入 的 node modules
服务端打包 CSS 可以通过这个插件在对应的 DOM 元素上生成 class 类名,然后返回 CSS 样式代码。在高阶组件中,利用插件内置 API ._getCss()
将CSS 数据通过注水与脱水的方式在服务端获取,将这些字符串格式的 css 添加到服务器渲染的 HTML 就可以完成 CSS 服务器渲染
1 | const path = require('path'); |
1 | { |
一个本地服务器,可以监控文件的变化来重启服务器,全局安装
并行运行同名前缀的所有 script
语句,全局安装
前端监控一般分为三种,页面埋点、性能监控以及异常监控
页面埋点一般会监控下面几个数据:
实现的思路分成两种,手写埋点和无埋点的方式
第一种自主选择需要监控的数据然后在相应的地方写入代码,这种方式灵活性很大,唯一的缺点就是工作量大,每个需要监控的地方都要插入代码
另一种的无埋点的方式基本不需要开发者手写埋点,而是统计所有的事件并且定时上报,这种方式虽然没有前一种繁琐,但是因为统计的是所有事件,所以还需要后期过滤出需要的数据
可以使用浏览器期自带的 Performance API 来实现功能。一行代码就可以获得页面中各种详细的性能
1 | performance.getEntriesByType('navigation') |
对于代码运行错误,通常的办法是使用 window.onerror 拦截报错,该方法能拦截到大部分的详细报错信息,但是也有例外的
对于异步代码来说,可以使用 catch 的方式捕获错误。比如 Promise 可以直接使用 catch 函数,async await 可以使用 try catch
线上的代码是经过压缩的,打包的时候要设置 sourceMap
UDP 协议是面向无连接,不需要在正式传递数据之前先连接双方,然后 UDP 协议只是数据报文的搬运工,不保证有序且不丢失的传递到对端,并且 UDP 协议也没有任何控制流量的算法,总的来说 UDP 相较于 TCP 更加的轻便
UDP 不要跟 TCP 一样在发送数据前进行三次握手建立连接,想发数据就可以发送了。
并且也只是数据的搬运工,不会对数据进行拆分和拼接操作。
具体来说:
不可靠性体现在无连接上,通信都不需要建立连接,想发就发。不用备份,也不用关心对方是否已经正确接收到数据。UDP 没有拥塞控制,一直会以恒定的速度发送数据,即使网络条件不好,也不会对发送速率进行调整。这样实现的弊端是在网络条件不好的情况下可能会导致丢包,但是优点很明显是在某些实时性要求很高的场景(如电话会议)就需要使用 UDP 而不是 TCP
UDP 的头部开销小,只有八字节,相比 TCP 的至少二十字节要小得多,在传输数据报文时是很高效的。
UDP 头部包含了以下几个数据:
UDP 不止支持一对一的传输方式,同样支持一对多,多对一的方式,也就说说 UDP 提供了单播,多播,广播的功能。
在很多实时性要求很高的地方都可以看到 UDP 的身影
如果使用基于 TCP 的协议,TCP 会严格控制传输的正确性,一旦有某个数据对端没有收到,就会停下来直到对端收到这个数据。这种问题在网络条件不错的情况下不会有问题,但是如果网络差就会变成画面卡着,然后在继续播放下一帧的情况。
TCP 这种情况下会降低用户体验,不适合
作者说不知道王者荣耀底层是使用了什么协议,但是对于这种实时性很高的游戏来说,UDP 是跑不了的。用户体量相当大的情况下,如果使用 TCP 连接可能会出现服务器不够用情况,因为每台服务器可供支撑的 TCP 连接数据是有限制的。
对于TCP 头部来说,以下几个字段是很重要的
TCP 的状态机是很复杂的,并且与建立连接时的握手息息相关
一个重要的性能指标 RTT.该指标表示发送端发送数据到接收到对端所需的往返时间
首先假设主动发起请求的一端为客户端,被动连接的一端称为服务端。不管是客户端还是服务端,TCP 连接建立完后都可以发送和接收数据,所以 TCP 是一个全双工的协议。
起初,两端都是 CLOSED 状态,在通信开始前,双方都会建立 TCB,服务端创建完TCB 后便进入 LISTEN 状态,此时开始等待客户端发送数据
客户端向服务端发送连接请求报文段,该报文段中包含自身数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态
服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。
当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端接收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功
第三次握手中可以包含数据,通过快速打开(TFO)技术就可以实现这一功能。其中只要涉及到握手的协议,都可以使用类似 TFO 方式,客户端和服务端存储相同的 cookie ,下次握手发出 cookie 达到减少 RTT 的目的。
为什么TCP 建立连接需要三次握手,明明两次就可以建立起连接?
因为这是为了防止出现失效的连接请求报文段被服务端接收后,从而产生错误。
如果客户端发送了一个连接报文请求A,但是因为网络原因造成了超时,这时 TCP 会启动超时重传的机制再次发送一个连接请求报文B。此时请求顺利达到服务器,服务端应答完就建立了请求,然后接受数据后释放了连接。
假设这时候请求 A 在两端关闭后终于达到了服务端,那么此时服务端会认为客户端有需要建立 TCP 连接,从而应答了该请求并进入了 ESTABLISHED 状态,但是实际上客户端是 CLOSED 状态,那么就会导致服务端一直等待,造成资源的浪费。
在建立连接中,任意一端掉线,TCP 都会重发 SYN 包,一般会重试5次,在建立连接可能遇到 SYN Flood 攻击。遇到这种情况可以选择调低重试次数或者干脆在不能处理的情况下拒绝请求。
TCP 是全双工的,在断开连接时两端都需要发送 FIN 和 ACK
若客户端 A 认为数据发送完成,则它需要向服务端 B 发送连接释放请求。
B 收到连接释放请求后,会告诉应用层就要释放 TCP 连接,然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,此时表明 A 到 B 的连接已经释放,不再接收 A 发的数据了。但是因为 TCP 连接是双工的,所以 B 依旧可以发送数据给 A
B 如果此时还有没有发完的数据会继续发送,完毕后向 A 发送连接释放请求,然后 B 便进入 LAST-ACK 状态。通过延迟确认的技术(通常有时间限制,否则对方会误认为需要重传),可以将第二次和第三次握手合并,延迟 ACK 包的发送
A 收到释放请求后,向 B 发送确认应答,A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最大生存期,指报文段在网络中生存的时间,超时会被抛弃)时间,若该时间段内没有 B 的重发请求的话,就会进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态
为什么 A 要进入 TIME-WAIT 状态,等待 2MSL 时间后再进入CLOSED 状态?
为了保证 B 能接受到 A 的确认应答,若 A 发完确认应答后直接进入 CLOSED 状态,如果确认应答因为网络问题一直没有到达,那么会造成 B 不能正常关闭
ARQ 协议也就是超时重传机制。通过确认和超时机制保证了数据的正确送达,ARQ 协议包含停止等待 ARQ 和连续 ARQ 两种协议
只要 A 向 B 发送一段报文,都要停止发送并且启动一个定时器,等待对端应答,在定时器内接收到对端应答就取消定时器并发送下一段报文
在报文传输过程中可能会出现丢包,这时候超过定时器设定的时间就会再次发送丢失的数据直到对端响应,所以需要每次都备份发送的数据
即使报文正常传输到对端,有可能出现在传输过程中的报文出错的问题。这时候对端会抛弃该报文并等待 A 端重传
一般定时器设定的时间会大于一个 RTT 的平均时间
对端传输的应答也可能出现丢失或者超时的情况,那么超过定时器时间 A 端照样会重报文,这时候 B 端会接收到相同序号的报文之后丢弃该报文并重传应答,直到 A 端发送下一个序号的报文。
在超时的情况下也可能会出现应答很迟到达,这时候 A 端会判断该序号是否已经接收过,如果接收过只需要丢弃应答即可。
假设在良好的网络环境中,每次发送数据都需要等待片刻肯定是不能接受的,这个协议看起来不是很高效。
在连续 ARQ 中,发送端拥有一个发送窗口,可以在没有收到应答的情况下持续发送窗口内的数据,这样相比停止等待 ARQ 协议来说减少了等待时间,提高了效率
连续 ARQ 中,接收端会持续不断收到报文,如果和停止等待 ARQ 中接收一个报文就发送一个应答一样,就太浪费资源了。通过累计确认,可以在收到多个报文以后统一回复一个应答报文。报文中的 ACK 标示位可以用来告诉发送端这个序号之前的数据已经全部接收到了,下次请发送这个序号后的数据。
但是累计确认也有一个弊端,在连续接收报文时,可能会遇到接收到序号5 的报文后,并未接收到序号6 的报文,然而序号7 的报文已经接收。遇到这种情况,ACK 只能回复 6,这样就会造成发送端重复发送数据的情况。
TCP 中,两端其实都维护者窗口,分别为发送端窗口和接收端窗口
发送端窗口包含已经发送但未收到应答的数据和可以发送但是未发送的数据
发送端窗口是由接收端窗口剩余大小决定的。接收方会把当前接收窗口的剩余大小写入应答报文,发送端收到应答后根据该值和当前网络拥塞情况设置发送窗口的大小,所以发送窗口的大小是不断变化的。
当发送端接收到应答报文后,会随着窗口进入滑动。
滑动窗口是一个很重要概念,它帮助了 TCP 实现了流量控制的功能。接收方通过报文告知发送方还可以发送多少数据,从而保证了接收方能够来得及接收数据,防止出现接收方带宽已满,但是发送方还是一直发送数据的情况。
在发送报文的过程中,可能会遇到对端出现零窗口的情况,在该情况下,发送端会停止发送数据,并启动 persistent timer.该定时器会定时发送请求给对端,让对端告知窗口大小。在重试次数超过一定次数后,可能会中断 TCP 链接。
拥塞处理和流量控制不同,后者是作用于接收方,保证接收方来得及接收数据,而后者是作用于网络,防止过多的数据拥塞网络,避免出现网络负载过大的情况。
拥塞处理包括了四个算法,分别是:慢开始,拥塞避免,快速重传,快递恢复
慢开始算法,就是在传输开始时将发送窗口慢慢指数等级扩大,从而避免一开始就传输最大量数据导致网络阻塞。
慢开始算法步骤具体如下:
拥塞避免算法相比简单点,每过一个 RTT 窗口大小只加一,这样就能避免指数级增长导致网络拥塞,慢慢将大小调整到最佳值
在传输过程中可能定时器超时的情况,这时候 TCP 会认为网络拥塞,会马上进行一下步骤:
快速重传一般和快恢复一起出现。一旦接收端收到的报文出现失序的情况,接收端只会回复最后一个顺序正确的报文序号。如果发送端收到三个重复的 ACK ,无需等待定时器超时而是启动快速重传算法。具体是:
TCP Taho 的实现:
TCP Reno 实现:
TCP New Reno 算法改进了之前 TCP Reno 算法的缺陷。在之前,快恢复中只要收到一个新的 ACK 包,就会退出快恢复。
在 TCP New Reno 中,TCP 发送方先记下三个重复 ACK 的分段的最大序号。
假如我有一个分段数据是 1 ~ 10 这十个序号的报文,其中丢失了序号为 3 和 7 的报文,那么该分段的最大序号就是 10。发送端只会收到 ACK 序号为 3 的应答。这时候重发序号为 3 的报文,接收方顺利接收的话就会发送 ACK 序号为 7 的应答。这时候 TCP 知道对端是有多个包未收到,会继续发送序号为 7 的报文,接收方顺利接收并会发送 ACK 序号为 11 的应答,这时发送端认为这个分段接收端已经顺利接收,接下来会退出快恢复阶段。
HTTP 请求由三部分组成,分别是:
请求行基本由请求方法、URL、版本协议组成。
请求方法分很多种,POST,GET,HEAD,OPTION,等等,更多具体的点击这里
副作用是指对服务器上的资源做改变,搜素是无副作用的,注册是副作用的。
幂等指发送 M 和 N 次请求(两者不同且大于1),服务器上资源的状态一致,比如注册10个和11个账号是不幂等的,对文章进行更改10次和11次是幂等的。因为前者多了一个账号(资源),后者只是更新同一个资源
在规范的应用场景来说,Get 多用于无副作用的,幂等的场景,例如搜索关键字。Post 多用于副作用的,不幂等的场景,例如注册
技术上来说:
首部分成请求首部和响应首部,并且部分首部两种通用。下面是常见的首部
通用字段 | 作用 |
---|---|
Cache-Control | 控制缓存的行为 |
Connection | 浏览器想要优先使用的连接类型,比如 keep-alive |
Date | 创建报文的时间 |
Pragma | 报文指令 |
Via | 代理服务器相关信息 |
Transfer-Encoding | 制定报文主体的传输编码方式 |
Upgrade | 要求客户端升级协议 |
Warning | 在内容中可能存在错误 |
具体解释的可以点击这里
请求首部 | 作用 |
---|---|
Accept | 能正确接收的媒体类型 |
Accept-Charset | 能正确接收的字符集 |
Accept-Encoding | 能正确接收的编码格式列表 |
Accept-Language | 能正确接收的语言列表 |
Expect | 期待服务端的指定行为 |
From | 请求方邮箱地址 |
Host | 服务器的域名 |
If-Match | 两端资源标记比较 |
If-Modified-Since | 本地资源未修改返回 304(比较时间) |
If-None-Match | 本地资源未修改返回 304(比较标记) |
User-Agent | 客户端信息 |
Max-Forwards | 限制可被代理以及网关转发的次数 |
Proxy-Authorization | 向代理服务器发送验证信息 |
Range | 请求某个内容的一部分 |
Referer | 表示浏览器所访问的前一个页面 |
TE | 传输编码方式 |
响应头部 | 作用 |
---|---|
Accept-Ranges | 是否支持某些种类的范围 |
Age | 资源在代理缓存中存在的时间 |
ETag | 资源标识 |
Location | 客户端重定向到某个 URL |
Proxy-Authenticate | 想代理服务器发送验证信息 |
Server | 服务器名字 |
WWW-Authenticate | 获取资源需要的验证信息 |
实体首部 | 作用 |
---|---|
Allow | 资源的正确请求方式 |
Content-Encoding | 内容编码的格式 |
Content-Language | 内容使用的语言 |
Content-Length | request body 长度 |
Content-Location | 返回数据的备用地址 |
Content-MD5 | Base64 加密格式的内容 MD5 校验值 |
Content-Range | 内容的位置范围 |
Content-Type | 内容的媒体类型 |
Expires | 内容的过期时间 |
Last_modified | 内容的最后修改时间 |
状态码的责任是当客户端向服务端发送请求时,描述返回的请求结果。借助状态码,用户可以知道服务单是正常处理了请求,还是出现了错误。
类别 | 原因短语 | |
---|---|---|
1XX | Information(信息性状态码) | 接受的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
HTTPS 通过 HTTP 来传输信息,但是信息通过 TLS 协议进行了 加密
TLS 协议位于传输层之上,应用层之下。首先进行 TLS 协议传输需要两个 RTT,可以通过 Session Resumption 减少到一个 RTT
在 TLS 中使用了两种加密技术,分别为:对称加密和非对称加密。
对称加密就是两边都拥有相同的密钥,两边都知道药怎么将密文加密解密
这种加密方式缺点在于因为传输数据走的都是网络,如果密钥被截获那就没有加密的意义了
有公钥私钥之分,公钥所有人都可以知道,如果将数据用公钥加密,但是将数据解密必须使用私钥解密,私钥只有分发公钥的一方才知道。
这种加密方式就可以完美解决对称加密存在的问题,假设两端都需要使用对称加密,那么在这之前,可以先使用非对称加密交换密钥。
简单流程化如下:服务端将公钥发布出去,那么客户端也知道公钥,就下来客户端创建了一个密钥,然后通过公钥加密后发送给服务端,服务端接收到私钥解密出正确的密钥,这时候两端就都知道密钥是什么了。
TLS 握手过程如下图:
通过上面步骤,可以知道 TLS 握手阶段,两端使用非对称加密的方式进行通信,但是因为非对称加密损耗的性能比对称加密大,所以在正式传输数据的时候,两端使用对称加密的方式进行通信。
关于 http 安全的具体可以点击这里
HTTP/2 很好的解决了当下最常用的 HTTP/1 所存在的一些性能问题,只需要升级到该协议就可以减少很多之前需要做的性能优化工作,当然兼容问题以及如何优雅降级应该是国内还不普遍使用的原因之一。
虽然 HTTP/2 已经解决了很多问题,但是并不代表它已经是完美的了,HTTP/3 就是为了解决 HTTP/2 所存在的一些问题而被推出来的。
HTTP/2 相比对 HTTP/1 可以说是大幅度提高了网页的性能。
在 HTTP/1 为了性能考虑,我们会引入雪碧图、将小图内联、使用多个域名等等的方式。这一切都是因为浏览器限制了同一个域名下的请求数量(Chrome 下一般是限制六个连接),当页面中需要请求很多资源的时候,队头阻塞(Head of line blocking)会导致在达到最大请求数量时,剩余的资源需要等待其他资源请求完成后才能发起请求。
在 HTTP/2 中引入了多路复用的技术,这个技术可以只通过一个 TCP 连接就可以传输所有的请求数据。多路复用很好的解决了浏览器限制同一个域名下的请求数量的问题,同时也接更容易实现全速传输,毕竟新开一个 TCP 连接都需要慢慢提升传输速度。
大家可以通过 该链接 感受下 HTTP/2 比 HTTP/1 到底快了多少。
HTTP/2 中所有加强性能的核心在于此,在之前的 HTTP 版本,我们是通过文本的方式传输数据,在 HTTP/2 中引入了新的编码机制,所有的传输数据都会被分割,并采用二进制格式编码。
在 HTTP/2 中有两个非常重要的概念,分别是 帧(frame)和 流 (stream)
帧代表着最小的数据单位,每个帧都会标识出该帧属于哪个流,流也就是多个帧组成的数据流
多路复用,就是在一个 TCP 连接中可以存在多条流。换句话说,就是可以发送多个请求,对端可以通过帧中的标识知道属于哪个请求。通过这个技术,可以避免 HTTP 旧版本中的对头阻塞的问题,极大提高了传输的性能。
在 HTTP/1 中使用文本的形式传输 header,在 header 携带 cookie 的情况下,可能每次都需要重复传输几百到几千字节。
在 HTTP/2 中使用 HPACK 压缩格式对传输的 header 进行编码,减少了 header 的大小。并在两端维护了索引表,用于记录出现过的 header。后面在传输过程中可以传输以及记录过的 header 的键名,对端收到的数据后就可以通过键名找到对应的值
在 HTTP/2 中,服务端可以在客户端某个请求后,主动推送其他资源
某些资源客户端是一定会请求的,这时候可以采用服务端 push 的技术,提前给客户端推送必要的资源,这样就可以相对减少一点延迟时间。当然在浏览器兼容的情况下也可以使用 prefetch
底层的 TCP 协议导致 HTTP/2 版本存在一个问题,因为使用了多路复用,一般来说同一个域名下面只需要一个 TCP 连接。当这个连接中出现了丢包的情况,就会导致 HTTP/2 的表现不如 HTTP/1
在丢包的情况下,整个 TCP 都要开始等待重传,就导致了后面的所有的数据都会被阻塞。但是对于 HTTP/1 来说可以开启多个 TCP 连接,出现这种情况只会影响一个连接,剩余的 TCP 连接可以正常传输数据
这个协议是由操作系统实现的,如果要去修改 TCP 协议看起来不怎么现实。
基于这个原因,Google 就更起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上,
HTTP/3 之前名为 HTTP-over-QUIC
UDP 协议虽然效率很高,但是并不是那么的可靠.QUIC 是基于 UDP ,在原生的基础上新增了很多功能,比如多路复用、0-RTT、使用 TLS1.3 加密、流量控制、有序交付、重传等功能。
虽然 HTTP/2 支持多路复用,但是 TCP 协议终究是没有这个功能的。QUIC 就原生实现了这个功能,并且传输的单个数据流可以保证有序交付但不会影响其他的数据流,这样的技术就解决了之前的 TCP 存在的问题。
并且 QUIC 在移动端会比 TCP 好,因为 TCP 是基于 IP 和端口去识别连接的,这种方式在多变的移动端网络环境下是很脆弱的。但是因为 QUIC 是通过 ID 的方式去识别一个连接的,不会随着网络的变化而变化,可以迅速重连上。
通过使用类似 TCP 的快速打开的技术,缓存当前会话的上下文,在下次恢复会话的时候,只需要将之前的缓存传递给服务端验证通过就可以进行传输了。
假如说这次我要发送三个包,那么协议会算出这三个包的异或值并单独发出一个校验包,也就是总共发出了四个包。当出现其中的非校验丢包的 情况的时候,可以通过另外三个包计算出丢失的数据包的内容。
当然这种技术只能使用在丢失一个包的情况下,如果出现丢失多个包就不能使用纠错机制了,只能使用重传的方式了。
关于 HTTP/3 更多点击这里
首先是 DNS 查询,如果这一步做到了智能 DNS 解析的话,会提供速度最快的 IP 地址回来
DNS 的作用就是通过域名查询到具体的 IP 地址
因为 IP 存在数据和英文的组合(IPv6),不利于记忆,所以就出现了域名,可以把它看成某个 IP 地址的别名,DNS 就是通过这个别名去查询真正的名字是什么
在 TCP 握手之前以及先进行了 DNS 查询,这个查询是操作系统自己做的,在浏览器中访问 www.googel.com 的时候,会进行以下的操作:
com
这个域名的服务器google
这个二级域名www
这个域名配置一个IP,然后还可以给别的三级域名配置一个 IP上面是DNS 迭代查询,还有一种格式递归查询,区别就是前者是由客户端去做请求,后者是由系统配置的 DNS 服务器做请求,得到结果将数据返回给客户端。
DNS 是基于 UDP 做的查询
接下来是 TCP 握手协议,应用层会下发数据给传输层,这里的 TCP 协议会指明两端的端口号,然后下发给网络层。网络层的 IP 协议会确定 IP 地址,并制定了数据传输中如何跳转路由器。然后包会再被封装到数据链路层的数据帧结构中,最后就是物理层面的传输了。
在这一部分中,TCP 握手结束后,会进行 TLS 握手,然后开始正式的传输数据了。
数据在进入服务端之前,可能还会警告负责负载均衡的服务器,它的作用是将请求合理的分发到多态服务器上面,这时假设服务端会响应一个 HTML 文件
首先浏览器会判断状态码是什么,如果是200 就继续解析,如果是 400 或者 500 的话就会报错,如果是 300 就会重定向,这里会有一个 重定向计数器,避免多次的重定向,超过次数也会报错。
浏览器开始解析文件,如果是 gzip 格式的会先解压一下,然后通过文件的编码格式知道该如何取解码文件。
文件解码后就会正式开始渲染流程,先根据 HTML 创建 DOM 树,有 CSS 的话就是 CSSOM 树,如果遇到 script 标签的话,会判断是否存在 async 或者 defer,前者会并行进行下载并执行 JS,后者会先下载文件,然后等待 HTML 解析完成后顺序执行。
如果以上都没有,就会阻塞住渲染流程直到 JS 执行完毕。遇到下载文件就会去下载文件,如果使用 HTTP/2 协议的话就会极大提高多图的下载效率
CSSOM 树 和 DOM 树构建完成之后会生成 render 树,这一步就是确定页面布局、样式等诸多方面的东西。
在生成 Render 树的过程中,浏览器就会开始调用 GPU 绘制,合成图层,将内容显示在 屏幕上了。
关于这个流程,更多的可以点击这里
关于具体的设计模式我有过一些笔记
下面还是根据作者的脚步来回顾这一点知识点
有一份很复杂的代码需要用户去调用,但是用户并不关心这些代码是怎么实现的,只需要提供一个接口去调用,用户只负责传递需要的参数。参数的使用都在内部逻辑去完成,不暴露出来给用户。只要给用户返回一个实例,这种构造过程就是工厂。
简而言之就是隐藏了创建实例的复杂度,只需要提供一个接口
1 | class Man{ |
Vue 源码中,也可以看到工厂模式的使用,比如创建异步组件
1 | export function createComponent( |
在上述代码中,我们可以看到我们只需要调用 createComponent
传入参数就能创建一个组件实例,但是创建这个实例是很复杂的一个过程,工厂帮助我们隐藏了这个复杂的过程,只需要一句代码调用就能实现功能。
单例模式很常用,比如全局缓存、全局状态管理等等只需要一个对象,就可以使用单例模式
单例模式的核心就是保证全局只有一个对象可以访问。因为 JS 是门无类的语言,所以别的语言实现单例的方式不能套入 JS 中,我们需要用一个变量确保值创建一次就可以了,下面是如何实现单例模式的例子:
1 | class Singleton { |
在 Vuex 源码中也可以看到单例模式的运用,虽然实现的方式不大一样,通过一个外部变量来控制只安装一次 Vuex
1 | let Vue // bind on install |
适配器用来解决两个接口不兼容的情况,不需要改变已有的接口,通过包装一层的方式实现两个接口的正常协作
简单实现:
1 | class Plug{ |
在 Vue 中经常使用到适配器模式,比如父组件传递给子组件一个时间戳属性,组件内部需要将时间戳转为正常的日期显示,一般会使用 computed 来做转换这件事情,这个过程就是用到了适配器模式
装饰模式不需要改变已有的接口,作用是给对象添加功能,就像我们经常需要给手机戴个保护套防摔一样,不改变手机自身,给手机添加了保护套提供防摔的功能。
以下是如何实现装饰模式的例子,使用了 ES7 中的装饰器语法
1 | function readonly(target,key,descriptor){ |
在 React 中,装饰模式是随处可见的
1 | import { connect } from 'react-redux'; |
代理是为了控制对对象的访问,不让外部直接访问到对象,事件代理也是用到了代理模式
1 | <ul id="ul"> |
因为存在太多 li,不可能每个都去绑定事件,这时候可以通过给父节点绑定一个事件,让父节点作为代理去拿到真实点击的节点。
发布-订阅模式也叫做观察者模式,通过一对一或者一对多的依赖关系,当对象发生改变的时候,订阅方都会收到通知。现实生活中的例子是当我需要在购物网站购买一个产品的时候,但是发现该产品目前处于缺货状态,这时候我们可以点击有货通知的按钮,让网站在产品有货的时候通过短信通知我。
在实际代码中,其实发布-定于模式也很常见,比如我们点击一个按钮触发了点击事件其实就是使用了该模式
1 | <ul id="ul"></ul> |
在 Vue 中,如何实现响应式也是使用了该模式,对于需要实现想爱你过隐式的对象来说,在 get 的时候会进行依赖手机,当改变了对象的属性的时候,就会触发派发更新
外观模式提供了一个接口,隐藏了内部的逻辑,更加方便外部的调用
我们需要实现一个兼容多种浏览器的添加事件方法
1 | function addEvent(elm, evType, fn, useCapture) { |
对于不同的浏览器,添加事件的方式可能会存在兼容问题,如果每次都要这样写一遍是不可能,我们将逻辑判断统一在一个接口里面,外部需要添加事件只需要调用 addEvent 就可以了
关于数据结构我之前的笔记也有过记录,具体的可以点击这里
下面还是跟着作者来重温一遍常见的数据结构
如何衡量算法的效率?通常是用资源,例如CPU(时间)占用、内存占用、硬盘占用和网络占用。当讨论大O表示法时,一般考虑的是CPU(时间)占用。
分析算法时,时常遇到以下几类函数
符号 | 名称 |
---|---|
O(1) | 常数的 |
O(log(n)) | 对数的 |
O((log(n)c)) | 对数多项式的 |
O(n) | 线性的 |
O(n2) | 二次的 |
O(nc) | 多项式的 |
O(cn) | 指数的 |
数据结构 | 一般情况 | 最差情况 | ||||
---|---|---|---|---|---|---|
插入 | 删除 | 搜索 | 插入 | 删除 | 搜索 | |
数组-栈-队列 | O(1) | O(1) | O(n) | O(1) | O(1) | O(n) |
链表 | O(1) | O(1) | O(n) | O(1) | O(1) | O(n) |
双向链表 | O(1) | O(1) | O(n) | O(1) | O(1) | O(n) |
散列表 | O(1) | O(1) | O(1) | O(n) | O(n) | O(n) |
二分搜索树 | O(log(n)) | O(log(n)) | O(log(n)) | O(n) | O(n) | O(n) |
AVL树 | O(log(n)) | O(log(n)) | O(log(n)) | O(log(n)) | O(log(n)) | O(log(n)) |
算法(用于数组) | 最好情况 | 一般情况 | 最差情况 |
---|---|---|---|
冒泡排序 | O(n) | O(n2) | O(n2) |
选择排序 | O(n2) | O(n2) | O(n2) |
插入排序 | O(n) | O(n2) | O(n2) |
归并排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) |
快速排序 | O(nlog(n)) | O(nlog(n)) | O(n2) |
堆排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) |
桶排序 | O(n+k) | O(n+k) | O(n2) |
基数排序 | O(nk) | O(nk) | O(nk) |
栈是一个线性结构,在计算机中是一个相当常见的数据结构,栈的特点是只能在某一端添加或者删除数据,遵循先进后出的规则(FILO)的原则
1 | class Stack { |
题意是匹配括号,可以通过栈的特性来完成这道题目
1 | const isVaild = function (s) { |
在 Vue 中关于模板解析的代码,就有应用到匹配尖括号的内容
队列是一个线性结构,特点是在某一段添加数据,在另一端删除数据,遵循先进先出的原则(FIFO)
分成单链队列和循环队列
1 | class Queue { |
因为单链队列在出队操作的时候㤇 O(n)的时间复杂度,所以引入了循环队列,循环队列的出队操作平均是 O(1)的时间复杂度
1 | class sqQueue { |
链表是一个线性结构,同时也是一个天然的递归结构。链表结构可以充分利用计算机内存空间,实现灵活的内存活动管理。但是链表失去了数组随机读取的优点,同时链表也增加了结点的指针域,空间开销比较大。
单向链表
1 | class LinkList { |
树拥有很多种结构,二叉树是树中最常见的结构,同时也是一个天然的递归结构
二叉树拥有一个根节点,每个节点至多拥有两个子节点,分别为:左节点和右节点。树的最底部节点称之为叶节点,当一棵树的数量为满,该树就可以称之为二叉树
graph TDA[2] --> B[7]A[2] --> C[5]C[5] --> F[9]F[9] --> J[4]B[7] --> D[2]B[7] --> E[6]E[6] --> G[5]E[6] --> H[11]
二分搜索树也是二叉树,拥有二叉树的特性。但是区别于在二分搜索树每个节点的值都比它的左子树的值大,比右子树的值小。
这种存储方式很适合数据搜索。如下图显示,当需要查找6的时候,因为需要查找的值比根节点的值大,所以只需要在根节点的右子树上寻找,大大提高了搜索效率
graph TDA[5] --> B[2]B[2] --> C[1]B[2] --> D[4]D[4] --> E[3]A[5] --> F[6]F[6] --> G[7]
具体可以点击这里
React 核心团队成员 Dan Abramov 的 blog。
Medium 上我并没有怎么固定阅读,更多的是订阅它的日报或者从别的周报上看到的 Medium 的文章,但是如果一定要推荐两个组织的话,我只推荐这两个,毕竟他们的文章质量都很高。
Youtube 有很多高质量的视频,但是门槛大家都知道,这里我推荐一些值得订阅的频道。
知识点:
Loader
HMR
Create React App
Caching
Plugin
SourceMap
Vue Cli 3.0
Shimming
WebpackDevServer
TreeShaking
CodeSplitting
Babel
React
Library
Eslint
PWA
Vue
Mode
性能优化
多页应用
原理
PreLoading
PreFetching
环境变量
TypeScript
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。
1 | const path = require('path'); |
module 处理方案,loader 有先后顺序,从下到上,从右到左
处理图片
1 | module.exports = { |
会把图片打包到js 文件中,如果图片很小(1-2kb)的话就适合以base64的形式打包到js里面
1 | module.exports = { |
也可以用来处理字体文件
1 | module: { |
处理css 样式
css-loader 分析有几个css文件以及它们之间的关系是怎么样的然后合并为一个css
style-loader 会把 css-loader 合并的内容挂载到页面上
node-sass、sass-loader:应对 sass 文件,上面的组合处理sass 文件不会报错,但是不会转义sass 特殊的语法
1 | module: { |
自动添加css前缀
在文件的根目录配置 postcss.config.js
1 | module.exports = { |
webapck.config.js 更改
1 | module: { |
如果有样式引用其他样式的情况要重新定义 css-loader 使得被引入的 scss 等有语义的css 文件可以重新跑多一次全部的 loader
1 | module: { |
开启模块化:
1 | module: { |
可以在webpack 运行打包到某个时刻帮你实现一些事情
会在打包结束后自动生成一个 html 文件,并把打包生成的 js 自动引入到这个 html 文件中
1 | module.exports = { |
打包之前去掉旧的dist 项目
1 | plugins: [ |
多文件输出预设的名字
1 | module.export = { |
development 模式下 devtool 默认是true,开启souceMap 通过映射当代码出错的时候可以找到源文件是哪里出错而不是编译后的文件哪里出错.
当然建立这种映射会影响打包速度
1 | module.exports = { |
souce-map会在dist 自动生成一个 js.map 映射文件,而inline-souce-map 则是将这个关系包含在 打包的js文件里面。
使用 cheap-xxx–xxx 的参数会加快打包方式,但是只会提示到几行不会精确到几列。
开发环境(development)推荐使用:cheap-module-eval-source-map(module代表module里面的错误也可以检测到,eval 可以加快编译速度)
生产环境(pruduction)则是:cheap-module-source-map
webpack-dev-server 帮助打包后的运行在自动打开的服务器,并会跟随文件的改变而改变
1 | module.exports = { |
webpack-dev-server 打包的时候会把打包的 dist 目录内置到内存里面而不会显示出来,可以提高编译速度
一开始的时候webpack-dev-server 因为配置不是很完善,一般人都会自己创建一个 server.js 文件来自己建一个服务器
这里使用 express 和 webpack-dev-middleware 来实现
1 | const express = require('express'); |
但是上面的内容是没有实现浏览器自动加载的。
热模块更新,不刷新页面,重新加载css文件
1 | module.exports = { |
只修改对应的 js 文件
1 | if(module.hot){ |
而css 对应的代码是在 css-loader 里面底层实现了,所以不用手写 module.hot
babel-loader 与 @babel-core(webpack与 babel 桥梁)
@babel/preset-env
1 | module.exports = { |
这里可以将 ES6 语法转换为 ES5 语法,但是还不完善,接着在index.js里面直接引入
1 | // index.js |
@babel/polyfill 可以自动添加内容实现兼容低版本的浏览器。打包之后会发现打包的js会很大,因为它把所有低版本可能要兼容的代码都写了进去,而不是按需要来增加内容。这个时候可以通过配置来达到按需
1 | module.exports = { |
webpack 进行打包的时候发现会报错
1 | Module not found: Error: Can't resolve 'core-js/modules/es6.array.map' |
之类的错误,通过查阅发现要下载一个 core-js 的插件便可以解决问题,具体可以查看 core-js
而当你使用这个按需加载的时候,就会提示你去掉文件中 import ‘@babel/polyfill’ ,因为会自动加载,另外可以配置要兼容的浏览器版本来判断需要引入那些兼容代码
1 | module.exports = { |
而上面的版本的谷歌浏览器是支持 ES6 语法的,因为打包出来的文件就会很小。可以看出来上面的方式有可能会全局污染(适合小项目),可以使用另外一种方式去配置(适合多模块)
1 | module.exports = { |
配置上面的内容之前需要安装以下几个依赖
1 | npm i -D @babel/runtime @babel/plugin-transfrom-runtime @babel/runtime-corejs2 |
具体可以查看这里
另外可以把 babel-loader options 里面的内容放在一个单独名为 .babelrc 的文件中
1 | { |
安装依赖包
1 | npm i -D @babel/preset-react |
配置 .babelrc
1 | { |
这里的顺序也是从下往上
development 环境,按需加载,只支持 ES Module ,底层是一个静态引入实现
1 | // index.js 只引入 add 的打包结果 |
production 环境甚至需要 optimization 配置,会自动配置,但是 package.json 中的 sideEffects 需要保留
1 | { |
1 | // webpack.dev.js |
可以看出上面有很多重复的代码,可以进行抽取优化,用 webpack-merge 进行合并
1 | // webpack.common.js |
遇到公共类库,会自动打包出来一个文件,例如 vendors~main.js
1 | module.exports = { |
安装 babel-plugin-dynamic-import-webpack 后在 .babelrc 里面配置,这个插件不是官方的,所以不支持魔法注释
1 | { |
官方的 @babel/plugin-syntax-dynamic-import 可以支持魔术注释
重新配置 .babelrc
1 | { |
webpack.common.js
1 | module.exports = { |
index.js
1 | // 使用魔法注释 |
打包编译之后就可以看到异步引入的库被自己定义的名字打包出来了
默认配置:
1 | module.exports = { |
通过 import 语法来异步加载,什么时候要使用则是取决于个人
1 | function getComponent(){ |
配置 package.json
1 | { |
上面代码的意思是分析webapck 打包的文件生成后生成 stats.json 。
点击这个网站可以将这个json 文件上传,会帮你自动分析版本,时长以及相关错误,模块之间的关系等等
在谷歌浏览器 ctrl+shift+p 输入show Coverage 点击录制后可以看到一个网站首屏加载 js 文件的利用率,以及可以优化的可能。
1 | // 当浏览器空闲的时候,可以先加载这个文件 |
性能优化在缓存上面可以优化的地方有限,可以考虑更多是代码的使用率,将一开始不会用的代码尽量使用异步加载的方式来加载
mini-css-extract-plugin,没有实现 HMR 需要手动配置,所以一般是适用在线上环境使用。
配置如下:
1 | // webpack.common.js |
将之前共同配置的 webpack,common.js module rules 里面关于css 的配置移动到 webpack.dev.js 里面不作修改。然后同样复制一遍到 webpack.prod.js 里面,把关于css-loader 里面的的 style-loader 换成 MiniCssExtractPlugin.loader 作为最后一步来处理。
一个要注意的点是在公共的 optimization 里面加了一个 usedExports:true,同时也要在 package.json 里面配置 sideEffects:[‘@babel/polyfill’,’*.css’]
运行 npm run build 便可以打包分割开 css 文件了
optimize-css-assets-webpack-plugin
1 | // webpack.prod.js |
1 | // webpack.prod.js |
1 | // webpack.prod.js |
增加 hash 值,如果文件没有改变的话,那么打包前后几次打包出来的文件里面的哈希值就不会发生变化,浏览器的缓存也就起到了作用。如果改变了,浏览器则会去请求先的文件而不是继续用原来的缓存文件
1 | // webpack.prod.js |
上面的配置是新版本的webpack直接有的,但是旧版本的 webpack 每次打包 hash 值都会发生变化,可以在 webpack.common.js 里面配置一个参数
1 | module.exports = { |
这个参数会把旧版本中的 manifest 单独抽离出来一个 runtime 开头的 js文件里面,这个 文件主要描述的是库与业务逻辑代码之间的一些关系,旧版本webpack 中这个关系会因为每次打包而发生变化。
1 | module.exports ={ |
imports-loader
1 | module.exports ={ |
可以考虑使用,用个人,具体是在 package.json 里面传递一个 env 参数
1 | { |
而 开发与生产环境的webpack 则是都放到 common 里面来判断分发
1 | // webpack.dev.js |
简单配置
1 | // webpack.config.js |
网站在被访问过一次之后,如果服务器挂掉了,浏览器还可以利用缓存来访问这个网站。
workbox-webpack-plugin
1 | // 配置 webpack.prod.js |
之后打包编译的是时候会生成多一个 service-worker.js 文件,在项目中运用
1 | // index.js |
这样配置之后,当用户访问过一次网站之后便会进行缓存,如果当服务器挂掉的时候,用户还是可以在浏览器访问到网站
webpack基本配置
1 | const path = require('path'); |
除了webpack 配置之后,在 打包 ts 的时候还必须要配置一个 tsconfig.json
1 | { |
如果要引入其他的库,例如 lodash ,需要安装对应的说明,@types/lodash ,具体的可以查看这里
1 | // 基本配置 |
当配置路由发现找不到内容的时候,可以配置 historyApiFallback
先安装
1 | npm i eslint -D |
接着是初始化配置
1 | eslint -init |
采用目前流行 airbnb 的方式
会看到项目生成一个 .eslintrc.js
如果没有使用vscode 只能用 eslint src 命令行来查看错误信息
可以进行基本配置:
1 | module.exports = { |
webpack 也有先关的配置,但是会影响打包的速度,一般会建议使用这种方式来配置。还是使用 vscode 的插件式
1 | module.exports = { |
devServer 配置 overlay 可以在页面提示错误信息
1.升级工具的版本(node,npm,yarn)
2.loader(include/exclude)在尽可能少的模块上使用
3.plugin 尽可能精简可靠
4.resolve 参数合理
1 | module.exports ={ |
5.使用 DllPlugin 提高打包速度
add-asset-html-webpack-plugin
1 | // webpack.dll.js |
6.控制包文件的大小
7.thread-loader,parallel-webpack,happypack 多进程打包
8.合理使用 sourceMap(信息越详细打包越久)
9.结合打包分析 stats.json 优化分析
10.开发环境内存编译,无用插件剔除
1 | // webpack.common.js |
1 | // replaceLoader.js |
自定义 loader 的用途很多,可以拿来做一个简单的错误检验
1 | const loaderUtils = require('loader-utils'); |
或者是国际化,在相关页面弄一个占位符
1 | module.exports = function (source) { |
可以具体参数可以点击这里
简单的实现
1 | // plugin/copyright-webpack-plugin |
调试
1 | { |
npm run debug
进入下一步,添加 watch compilation 可以对一些类似这样的参数进行调试观察
具体的可以点击这里
文件目录src 下面有三个文件,分别是 index.js message.js word.js ,引用关系是从左到右导入下一个的文件,具体代码如下:
1 | // word.js |
1 | // 基本配置 |
运行
1 | node bundler.js | highlight |
控制台输出
1 | (function (graph) { |
复制粘贴到浏览器便可以运行
graph 输出内容是
1 | { |
可以看到 code 里面的内容有一个require 函数和一个 exports 对象,所以为了这些代码能够在浏览器运行,我们需要自己创建一个 require函数和exports 空对象。
]]>