艾特老干部

艾特老干部 查看完整档案

上海编辑浙江大学  |  控制工程 编辑字节跳动  |  前端 编辑 segmentfault.com/u/xuchaobei 编辑
编辑

《React进阶之路》作者。
全栈路上的半栈工程师。
微信公众号:老干部的大前端(ID:Broad-FE)

个人动态

艾特老干部 收藏了文章 · 2019-06-25

从渲染原理谈前端性能优化

作者:李佳晓 原文:学而思网校技术团队

前言

合格的开发者知道怎么做,而优秀的开发者知道为什么这么做。

这句话来自《web性能权威指南》,我一直很喜欢,而本文尝试从浏览器渲染原理探讨如何进行性能提升。
全文将从网络通信以及页面渲染两个过程去探讨浏览器的行为及在此过程中我们可以针对那些点进行优化,有些的不足之处还请各位不吝雅正。

一、关于浏览器渲染的容易误解点总结

关于浏览器渲染机制已经是老生常谈,而且网上现有资料中有非常多的优秀资料对此进行阐述。遗憾的是网上的资料良莠不齐,经常在不同的文档中对同一件事的描述出现了极大的差异。怀着严谨求学的态度经过大量资料的查阅和请教,将会在后文总结出一个完整的流程。

1、DOM树的构建是文档加载完成开始的?

DOM树的构建是从接受到文档开始的,先将字节转化为字符,然后字符转化为标记,接着标记构建dom树。这个过程被分为标记化和树构建
而这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。
参考文档:
http://taligarsiel.com/Projec...

2、渲染树是在DOM树和CSS样式树构建完毕才开始构建的吗?

这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一边解析,一边渲染的工作现象。
参考文档:

http://www.jianshu.com/p/2d52...

3、css的标签嵌套越多,越容易定位到元素

css的解析是自右至左逆向解析的,嵌套越多越增加浏览器的工作量,而不会越快。
因为如果正向解析,例如「div div p em」,我们首先就要检查当前元素到 html 的整条路径,找到最上层的 div,再往下找,如果遇到不匹配就必须回到最上层那个 div,往下再去匹配选择器中的第一个 div,回溯若干次才能确定匹配与否,效率很低。
逆向匹配则不同,如果当前的 DOM 元素是 div,而不是 selector 最后的 em,那只要一步就能排除。只有在匹配时,才会不断向上找父节点进行验证。
打个比如 p span.showing
你认为从一个p元素下面找到所有的span元素并判断是否有class showing快,还是找到所有的span元素判断是否有class showing并且包括一个p父元素快
参考文档:
http://www.imooc.com/code/4570

二、页面渲染的完整流程

当浏览器拿到HTTP报文时呈现引擎将开始解析 HTML 文档,并将各标记逐个转化成“内容树”上的 DOM 节点。同时也会解析外部 CSS 文件以及样式元素中的样式数据。HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构:呈现树。浏览器将根据呈现树进行布局绘制。

以上就是页面渲染的大致流程。那么浏览器从用户输入网址之后到底做了什么呢?以下将会进行一个完整的梳理。鉴于本文是前端向的所以梳理内容会有所偏重。而从输入到呈现可以分为两个部分:网络通信页面渲染

我们首先来看网络通信部分:

1、用户输入url并敲击回车。

2、进行DNS解析。

如果用户输入的是ip地址则直接进入第三条。但去记录毫无规律且冗长的ip地址显然不是易事,所以通常都是输入的域名,此时就会进行dns解析。所谓DNS(Domain Name System)指域名系统。因特网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。通过主机名,最终得到该主机名对应的IP地址的过程叫做域名解析(或主机名解析)。这个过程如下所示:

浏览器会首先搜索浏览器自身的DNS缓存(缓存时间比较短,大概只有2分钟左右,且只能容纳1000条缓存)。

  • 如果浏览器自身缓存找不到则会查看系统的DNS缓存,如果找到且没有过期则停止搜索解析到此结束.
  • 而如果本机没有找到DNS缓存,则浏览器会发起一个DNS的系统调用,就会向本地配置的首选DNS服务器发起域名解析请求(通过的是UDP协议向DNS的53端口发起请求,这个请求是递归的请求,也就是运营商的DNS服务器必须得提供给我们该域名的IP地址),运营商的DNS服务器首先查找自身的缓存,找到对应的条目,且没有过期,则解析成功。
  • 如果没有找到对应的条目,则有运营商的DNS代我们的浏览器发起迭代DNS解析请求,它首先是会找根域的DNS的IP地址(这个DNS服务器都内置13台根域的DNS的IP地址),找打根域的DNS地址,就会向其发起请求(请问www.xxxx.com这个域名的IP地址是多少啊?)
  • 根域发现这是一个顶级域com域的一个域名,于是就告诉运营商的DNS我不知道这个域名的IP地址,但是我知道com域的IP地址,你去找它去,于是运营商的DNS就得到了com域的IP地址,又向com域的IP地址发起了请求(请问www.xxxx.com这个域名的IP地址是多少?),com域这台服务器告诉运营商的DNS我不知道www.xxxx.com这个域名的IP地址,但是我知道xxxx.com这个域的DNS地址,你去找它去,于是运营商的DNS又向linux178.com这个域名的DNS地址(这个一般就是由域名注册商提供的,像万网,新网等)发起请求(请问www.xxxx.com这个域名的IP地址是多少?),这个时候xxxx.com域的DNS服务器一查,诶,果真在我这里,于是就把找到的结果发送给运营商的DNS服务器,这个时候运营商的DNS服务器就拿到了www.xxxx.com这个域名对应的IP地址,并返回给Windows系统内核,内核又把结果返回给浏览器,终于浏览器拿到了www.xxxx.com对应的IP地址,这次dns解析圆满成功。

3、建立tcp连接

拿到域名对应的IP地址之后,User-Agent(一般是指浏览器)会以一个随机端口(1024< 端口 < 65535)向服务器的WEB程序(常用的有httpd,nginx等)80端口发起TCP的连接请求。这个连接请求(原始的http请求经过TCP/IP4层模型的层层封包)到达服务器端后(这中间通过各种路由设备,局域网内除外),进入到网卡,然后是进入到内核的TCP/IP协议栈(用于识别该连接请求,解封包,一层一层的剥开),还有可能要经过Netfilter防火墙(属于内核的模块)的过滤,最终到达WEB程序,最终建立了TCP/IP的连接。

tcp建立连接和关闭连接均需要一个完善的确认机制,我们一般将连接称为三次握手,而连接关闭称为四次挥手。而不论是三次握手还是四次挥手都需要数据从客户端到服务器的一次完整传输。将数据从客户端到服务端经历的一个完整时延包括:

  • 发送时延:把消息中的所有比特转移到链路中需要的时间,是消息长度和链路速度的函数
  • 传播时延:消息从发送端到接受端需要的时间,是信号传播距离和速度的函数
  • 处理时延:处理分组首部,检查位错误及确定分组目标所需的时间
  • 排队时延:到来的分组排队等待处理的时间以上的延迟总和就是客户端到服务器的总延迟时间

以上的延迟总和就是客户端到服务器的总延迟时间。因此每一次的连接建立和断开都是有巨大代价的。因此去掉不必要的资源和资源合并(包括js及css资源合并、雪碧图等)才会成为性能优化绕不开的方案。但是好消息是随着协议的发展我们将对性能优化这个主题有着新的看法和思考。虽然还未到来,但也不远了。如果你感到好奇那就接着往下看。

以下简述下tcp建立连接的过程:

clipboard.png

  • 第一次握手:客户端发送syn包(syn=x,x为客户端随机序列号)的数据包到服务器,并进入SYN_SEND状态,等待服务器确认;
  • 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(syn=y,y为服务端生成的随机序列号),即SYN+ACK包,此时服务器进入SYN_RECV状态;
  • 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1)

此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。理想状态下,TCP连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP连接都将被一直保持下去

这里注意, 三次握手是不携带数据的,而是在握手完毕才开始数据传输。因此如果每次数据请求都需要重新进行完整的tcp连接建立,通信时延的耗时是难以估量的!这也就是为什么我们总是能听到资源合并减少请求次数的原因。

下面来看看HTTP如何在协议层面帮我们进行优化的:

HTTP1.0

在http1.0时代,每个TCP连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。 TCP连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(TCP的拥塞控制开始时会启动慢启动算法)。在数据传输的开始只能发送少量包,并随着网络状态良好(无拥塞)指数增长。但遇到拥塞又要重新从1个包开始进行传输。

以下图为例,慢启动时第一次数据传输只能传输一组数据,得到确认后传输2组,每次翻倍,直到达到阈值16时开始启用拥塞避免算法,既每次得到确认后数据包只增加一个。当发生网络拥塞后,阈值减半重新开始慢启动算法。

clipboard.png

因此为避免tcp连接的三次握手耗时及慢启动引起的发送速度慢的情况,应尽量减少tcp连接的次数。

而HTTP1.0每个数据请求都需要重新建立连接的特点使得HTTP 1.0版本的性能比较差。随着网页加载的外部资源越来越多,这个问题就愈发突出了。 为了解决这个问题,有些浏览器在请求时,用了一个非标准的Connection字段。 Kepp-alive 一个可以复用的TCP连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段,不同实现的行为可能不一致,因此不是根本的解决办法。

HTTP1.1

http1.1(以下简称h1.1) 版的最大变化,就是引入了持久连接(persistent connection),即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive。 客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送Connection: close,明确要求服务器关闭TCP连接。 目前,对于同一个域名,大多数浏览器允许同时建立6个持久连接。相比与http1.0,1.1的页面性能有了巨大提升,因为省去了很多tcp的握手挥手时间。下图第一种是tcp建立后只能发一个请求的http1.0的通信状态,而拥有了持久连接的h1.1则避免了tcp握手及慢启动带来的漫长时延。

clipboard.png

从图中可以看到相比h1.0,h1.1的性能有所提升。然而虽然1.1版允许复用TCP连接,但是同一个TCP连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为"队头堵塞"(Head-of-line blocking)。 为了避免这个问题,只有三种方法:一是减少请求数,二是同时多开持久连接。这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入CSS代码、域名分片(domain sharding)等等。如果HTTP协议能继续优化,这些额外的工作是可以避免的。三是开启pipelining,不过pipelining并不是救世主,它也存在不少缺陷:

    • pipelining只能适用于http1.1,一般来说,支持http1.1的server都要求支持pipelining
    • 只有幂等的请求(GET,HEAD)能使用pipelining,非幂等请求比如POST不能使用,因为请求之间可能会存在先后依赖关系。
    • head of line blocking并没有完全得到解决,server的response还是要求依次返回,遵循FIFO(first in first out)原则。也就是说如果请求1的response没有回来,2,3,4,5的response也不会被送回来。
    • 绝大部分的http代理服务器不支持pipelining。 和不支持pipelining的老服务器协商有问题。 可能会导致新的队首阻塞问题。

    鉴于以上种种原因,pipelining的支持度并不友好。可以看看chrome对pipelining的描述:

    https://www.chromium.org/deve...

    clipboard.png

    HTTP2

    2015年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3。HTTP2将具有以下几个主要特点:

    • 二进制协议 :HTTP/1.1 版的头信息肯定是文本(ASCII编码),数据体可以是文本,也可以是二进制。HTTP/2 则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为"帧"(frame):头信息帧和数据帧。
    • 多工 :HTTP/2 复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了"队头堵塞"。
    • 数据流:因为 HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。 HTTP/2 将每个请求或回应的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流ID,用来区分它属于哪个数据流。另外还规定,客户端发出的数据流,ID一律为奇数,服务器发出的,ID为偶数。 数据流发送到一半的时候,客户端和服务器都可以发送信号(RST_STREAM帧),取消这个数据流。1.1版取消数据流的唯一方法,就是关闭TCP连接。这就是说,HTTP/2 可以取消某一次请求,同时保证TCP连接还打开着,可以被其他请求使用。 客户端还可以指定数据流的优先级。优先级越高,服务器就会越早回应。
    • 头信息压缩: HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如Cookie和User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。 HTTP2对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息使用gzip或compress压缩后再发送;另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
    • 服务器推送: HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。 常见场景是客户端请求一个网页,这个网页里面包含很多静态资源。正常情况下,客户端必须收到网页后,解析HTML源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。

    就这几个点我们分别讨论一下:
    就多工来看:虽然http1.1支持了pipelining,但是仍然会有队首阻塞问题,如果浏览器同时发出http请求请求和css,服务器端处理css请求耗时20ms,但是因为先请求资源是html,此时的css尽管已经处理好了但仍不能返回,而需要等待html处理好一起返回,此时的客户端就处于盲等状态,而事实上如果服务器先处理好css就先返回css的话,浏览器就可以开始解析css了。而多工的出现就解决了http之前版本协议的问题,极大的提升了页面性能。缩短了通信时间。我们来看看有了多工之后有那些影响:

    • 无需进行资源分片:为了避免请求tcp连接耗时长的和初始发送速率低的问题,浏览器允许同时打开多个tcp连接让资源同时请求。但是为了避免服务器压力,一般针对一个域名会有最大并发数的限制,一般来说是6个。允许一个页面同时对相同域名打开6个tcp连接。为了绕过最大并发数的限制,会将资源分布在不同的域名下,避免资源在超过并发数后需要等待才能开始请求。而有了http2,可以同步请求资源,资源分片这种方式就可以不再使用。
    • 资源合并:资源合并会不利于缓存机制,因为单文件修改会影响整个资源包。而且单文件过大对于 HTTP/2 的传输不好,尽量做到细粒化更有利于 HTTP/2 传输。而且内置资源也是同理,将资源以base64的形式放进代码中不利于缓存。且编码后的图片资源大小是要超过图片大小的。这两者都是以减少tcp请求次数增大单个文件大小来进行优化的。

    就头部压缩来看:HTTP/1.1 版的头信息是ASCII编码,也就是不经过压缩的,当我们请求只携带少量数据时,http头部可能要比载荷要大许多,尤其是有了很长的cookie之后这一点尤为显著,头部压缩毫无疑问可以对性能有很大提升。

    就服务器推送来看:少去了资源请求的时间,服务端可以将可能用到的资源推送给服务端以待使用。这项能力几乎是革新了之前应答模式的认知,对性能提升也有巨大帮助。

    因此很多优化都是在基于tcp及http的一些问题来避免和绕过的。事实上多数的优化都是针对网络通信这个部分在做。

    4、建立TCP连接后发起http请求

    5、服务器端响应http请求,浏览器得到html代码

    以上是网络通信部分,接下来将会对页面渲染部分进行叙述。

    • 当浏览器拿到HTML文档时首先会进行HTML文档解析,构建DOM树。
    • 遇到css样式如link标签或者style标签时开始解析css,构建样式树。HTML解析构建和CSS的解析是相互独立的并不会造成冲突,因此我们通常将css样式放在head中,让浏览器尽早解析css。
    • 当html的解析遇到script标签会怎样呢?答案是停止DOM树的解析开始下载js。因为js是会阻塞html解析的,是阻塞资源。其原因在于js可能会改变html现有结构。例如有的节点是用js动态构建的,在这种情况下就会停止dom树的构建开始下载解析js。脚本在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。而因此就会推迟页面首绘的时间。可以在首绘不需要js的情况下用async和defer实现异步加载。这样js就不会阻塞html的解析了。当HTML解析完成后,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”事件将随之触发。

    注意,异步执行是指下载。执行js时仍然会阻塞。

    • 在得到DOM树和样式树后就可以进行渲染树的构建了。应注意的是渲染树和 DOM 元素相对应的,但并非一一对应。比如非可视化的 DOM 元素不会插入呈现树中,例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在呈现树中(但是 visibility 属性值为“hidden”的元素仍会显示)

    clipboard.png

    渲染树构建完毕后将会进行布局。布局使用流模型的Layout算法。所谓流模型,即是指Layout的过程只需进行一遍即可完成,后出现在流中的元素不会影响前出现在流中的元素,Layout过程只需从左至右从上至下一遍完成即可。但实际实现中,流模型会有例外。Layout是一个递归的过程,每个节点都负责自己及其子节点的Layout。Layout结果是相对父节点的坐标和尺寸。其过程可以简述为:

    clipboard.png

    • 此时renderTree已经构建完毕,不过浏览器渲染树引擎并不直接使用渲染树进行绘制,为了方便处理定位(裁剪),溢出滚动(页内滚动),CSS转换/不透明/动画/滤镜,蒙版或反射,Z (Z排序)等,浏览器需要生成另外一棵树 - 层树。因此绘制过程如下:1、获取 DOM 并将其分割为多个层(RenderLayer) 2、将每个层栅格化,并独立的绘制进位图中 3、将这些位图作为纹理上传至 GPU 4、复合多个层来生成最终的屏幕图像(终极layer)。

    三、HTML及CSS样式的解析

    HTML解析是一个将字节转化为字符,字符解析为标记,标记生成节点,节点构建树的过程。。CSS样式的解析则由于复杂的样式层叠而变得复杂。对此不同的渲染引擎在处理上有所差异,后文将会就这点进行详细讲解

    1、HTML的解析分为标记化和树构建两个阶段

    标记化算法:

    是词法分析过程,将输入内容解析成多个标记。HTML标记包括起始标记、结束标记、属性名称和属性值。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。
    该算法的输出结果是 HTML 标记。该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。这意味着,即使接收的字符相同,对于下一个正确的状态也会产生不同的结果,具体取决于当前的状态。
    树构建算法:

    在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。
    标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为“插入模式”。

    以下将会举一个例子来分析这两个阶段:

    clipboard.png

    标记化:初始状态是数据状态。

    • 遇到字符 < 时,状态更改为“标记打开状态”。接收一个 a-z字符会创建“起始标记”,状态更改为“标记名称状态”。这个状态会一直保持到接收> 字符。在此期间接收的每个字符都会附加到新的标记名称上。在本例中,我们创建的标记是 html 标记。
    • 遇到 > 标记时,会发送当前的标记,状态改回“数据状态”。 标记也会进行同样的处理。目前 html 和 body 标记均已发出。现在我们回到“数据状态”。接收到 Hello world 中的 H 字符时,将创建并发送字符标记,直到接收</body> 中的<。我们将为 Hello world 中的每个字符都发送一个字符标记。
    • 现在我们回到“标记打开状态”。接收下一个输入字符 / 时,会创建 end tag token 并改为“标记名称状态”。我们会再次保持这个状态,直到接收 >。然后将发送新的标记,并回到“数据状态”。 输入也会进行同样的处理。

    还是以上的例子,我们来看看树构建

    树构建:树构建阶段的输入是一个来自标记化阶段的标记序列。

    • 第一个模式是“initial mode”。接收 HTML 标记后转为“before html”模式,并在这个模式下重新处理此标记。这样会创建一个 HTMLHtmlElement 元素,并将其附加到 Document 根对象上。
    • 然后状态将改为“before head”。此时我们接收“body”标记。即使我们的示例中没有“head”标记,系统也会隐式创建一个 HTMLHeadElement,并将其添加到树中。
    • 现在我们进入了“in head”模式,然后转入“after head”模式。系统对 body 标记进行重新处理,创建并插入 HTMLBodyElement,同时模式转变为“body”。
    • 现在,接收由“Hello world”字符串生成的一系列字符标记。接收第一个字符时会创建并插入“Text”节点,而其他字符也将附加到该节点
    • 接收 body 结束标记会触发“after body”模式。现在我们将接收 HTML 结束标记,然后进入“after after body”模式。接收到文件结束标记后,解析过程就此结束。解析结束后的操作

    在此阶段,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”事件将随之触发。

    完整解析过程如下图:

    clipboard.png

    2、CSS的解析与层叠规则

    每一个呈现器都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,这一点在 CSS2 规范中有所描述。它包含诸如宽度、高度和位置等几何信息。就是我们 CSS 里常提到的盒子模型。构建呈现树时,需要计算每一个呈现对象的可视化属性。这是通过计算每个元素的样式属性来完成的。由于应用规则涉及到相当复杂的层叠规则,所以给样式树的构建造成了巨大的困难。为什么说它复杂?因为同一个元素可能涉及多条样式,就需要判断最终到底哪条样式生效。首先我们来了解一下css的样式层叠规则

    ①层叠规则:

    根据不同的样式来源优先级排列从小到大:

    • 1>、用户端声明:来自浏览器的样式,被称作 UA style,是浏览器默认的样式。 比如,对于 DIV 元素,浏览器默认其 ‘display’ 的特性值是 “block”,而 SPAN 是 “inline”。
    • 2>、一般用户声明:这个样式表是使用浏览器的用户,根据自己的偏好设置的样式表。比如,用户希望所有 P 元素中的字体都默认显示成蓝色,可以先定义一个样式表,存成 css 文件。
    • 3>、一般作者声明:即开发者在开发网页时,所定义的样式表。
    • 4>、加了’!important’ 的作者声明
    • 5>、加了’!important’ 的用户声明

    !important 规则1:根据 CSS2.1 规范中的描述,’!important’ 可以提高样式的优先级,它对样式优先级的影响是巨大的。
    注意,’!important’ 规则在 IE7 以前的版本中是被支持不完善。因此,经常被用作 CSS hack2。

    如果来源和重要性相同则根据CSS specificity来进行判定。

    特殊性的值可以看作是一个由四个数组成的一个组合,用 a,b,c,d 来表示它的四个位置。 依次比较 a,b,c,d 这个四个数比较其特殊性的大小。比如,a 值相同,那么 b 值大的组合特殊性会较大,以此类推。 注意,W3C 中并不是把它作为一个 4 位数来看待的。
    a,b,c,d 值的确定规则:

    • 如果 HTML 标签的 ‘style’ 属性中该样式存在,则记 a 为 1;
    • 数一下选择器中 ID 选择器的个数作为 b 的值。比如,样式中包含 ‘#c1’ 和 ‘#c2’ 的选择器;
    • 其他属性以及伪类(pseudo-classes)的总数量是 c 的值。比如’.con’,’:hover’ 等;
    • 元素名和伪元素的数量是 d 的值

    在这里我们来看一个W3C给出的例子:

    clipboard.png

    那么在如下例子中字体的显示应当为绿色:

    clipboard.png

    总结为表格的话计算规则如下:

    clipboard.png

    ②CSS解析

    为了简化样式计算,Firefox 还采用了另外两种树:规则树和样式上下文树。Webkit 也有样式对象,但它们不是保存在类似样式上下文树这样的树结构中,只是由 DOM 节点指向此类对象的相关样式。

    1>、Firefox的规则树和样式上下文树:

    样式上下文包含端值。要计算出这些值,应按照正确顺序应用所有的匹配规则,并将其从逻辑值转化为具体的值。例如,如果逻辑值是屏幕大小的百分比,则需要换算成绝对的单位。规则树的点子真的很巧妙,它使得节点之间可以共享这些值,以避免重复计算,还可以节约空间。
    所有匹配的规则都存储在树中。路径中的底层节点拥有较高的优先级。规则树包含了所有已知规则匹配的路径。规则的存储是延迟进行的。规则树不会在开始的时候就为所有的节点进行计算,而是只有当某个节点样式需要进行计算时,才会向规则树添加计算的路径。
    这个想法相当于将规则树路径视为词典中的单词。如果我们已经计算出如下的规则树:

    clipboard.png

    假设我们需要为内容树中的另一个元素匹配规则,并且找到匹配路径是 B - E - I(按照此顺序)。由于我们在树中已经计算出了路径 A - B - E - I - L,因此就已经有了此路径,这就减少了现在所需的工作量。

    那么Firefox是如何解决样式计算难题的呢?接下来看一个样例,假设我们有如下HTML代码:

    clipboard.png

    并且我们有如下规则:

    clipboard.png

    为了简便起见,我们只需要填充两个结构:color 结构和 margin 结构。color 结构只包含一个成员(即“color”),而 margin 结构包含四条边。
    形成的规则树如下图所示(节点的标记方式为“节点名 : 指向的规则序号”):

    clipboard.png

    上下文树如下图所示(节点名 : 指向的规则节点):

    clipboard.png

    假设我们解析 HTML 时遇到了第二个 <div> 标记,我们需要为此节点创建样式上下文,并填充其样式结构。
    经过规则匹配,我们发现该 <div> 的匹配规则是第 1、2 和 6 条。这意味着规则树中已有一条路径可供我们的元素使用,我们只需要再为其添加一个节点以匹配第 6 条规则(规则树中的 F 节点)。
    我们将创建样式上下文并将其放入上下文树中。新的样式上下文将指向规则树中的 F 节点。
    现在我们需要填充样式结构。首先要填充的是 margin 结构。由于最后的规则节点 (F) 并没有添加到 margin 结构,我们需要上溯规则树,直至找到在先前节点插入中计算过的缓存结构,然后使用该结构。我们会在指定 margin 规则的最上层节点(即 B 节点)上找到该结构。
    我们已经有了 color 结构的定义,因此不能使用缓存的结构。由于 color 有一个属性,我们无需上溯规则树以填充其他属性。我们将计算端值(将字符串转化为 RGB 等)并在此节点上缓存经过计算的结构。
    第二个 元素处理起来更加简单。我们将匹配规则,最终发现它和之前的 span 一样指向规则 G。由于我们找到了指向同一节点的同级,就可以共享整个样式上下文了,只需指向之前 span 的上下文即可。
    对于包含了继承自父代的规则的结构,缓存是在上下文树中进行的(事实上 color 属性是继承的,但是 Firefox 将其视为 reset 属性,并缓存到规则树上)。
    例如,如果我们在某个段落中添加 font 规则:

    clipboard.png

    那么,该段落元素作为上下文树中的 div 的子代,就会共享与其父代相同的 font 结构(前提是该段落没有指定 font 规则)。

    2>、Webkit的样式解析

    在 Webkit 中没有规则树,因此会对匹配的声明遍历 4 次。首先应用非重要高优先级的属性(由于作为其他属性的依据而应首先应用的属性,例如 display),接着是高优先级重要规则,然后是普通优先级非重要规则,最后是普通优先级重要规则。这意味着多次出现的属性会根据正确的层叠顺序进行解析。最后出现的最终生效。

    四、渲染树的构建

    样式树和DOM树连接在一起形成一个渲染树,渲染树用来计算可见元素的布局并且作为将像素渲染到屏幕上的过程的输入。值得一提的是,Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。Webkit 使用的术语是“渲染树”,它由“呈现对象”组成。 Webkit 和 Gecko 使用的术语略有不同,但整体流程是基本相同的。

    接下来将来看一下两种渲染引擎的工作流程:
    Webkit 主流程:

    ![clipboard.png

    Mozilla 的 Gecko 呈现引擎主流程

    clipboard.png

    虽然 Webkit 和 Gecko 使用的术语略有不同,但整体流程是基本相同的。

    Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。
    Webkit 使用的术语是“呈现树”,它由“呈现对象”组成。
    对于元素的放置,Webkit 使用的术语是“布局”,而 Gecko 称之为“重排”。
    对于连接 DOM 节点和可视化信息从而创建呈现树的过程,Webkit 使用的术语是“附加”。有一个细微的非语义差别,就是 Gecko 在 HTML 与 DOM 树之间还有一个称为“内容槽”的层,用于生成 DOM 元素。我们会逐一论述流程中的每一部分。

    五、关于浏览器渲染过程中需要了解的概念

    Repaint(重绘)——屏幕的一部分要重画,比如某个CSS的背景色变了。但是元素的几何尺寸没有变。
    Reflow(重排)——意味着元件的几何尺寸变了,我们需要重新验证并计算Render Tree。是Render Tree的一部分或全部发生了变化。这就是Reflow,或是Layout。reflow 会从这个root frame开始递归往下,依次计算所有的结点几何尺寸和位置,在reflow过程中,可能会增加一些frame,比如一个文本字符串必需被包装起来。
    onload事件——当 onload 事件触发时,页面上所有的DOM,样式表,脚本,图片,flash都已经加载完成了。
    DOMContentLoaded事件——当 DOMContentLoaded 事件触发时,仅当DOM加载完成,不包括样式表,图片,flash。
    首屏时间——当浏览器显示第一屏页面所消耗的时间,在国内的网络条件下,通常一个网站,如果“首屏时间”在2秒以内是比较优秀的,5秒以内用户可以接受,10秒以上就不可容忍了。
    白屏时间——指浏览器开始显示内容的时间。但是在传统的采集方式里,是在HTML的头部标签结尾里记录时间戳,来计算白屏时间。在这个时刻,浏览器开始解析身体标签内的内容。而现代浏览器不会等待CSS树(所有CSS文件下载和解析完成)和DOM树(整个身体标签解析完成)构建完成才开始绘制,而是马上开始显示中间结果。所以经常在低网速的环境中,观察到页面由上至下缓慢显示完,或者先显示文本内容后再重绘成带有格式的页面内容。

    六、页面优化方案

    本文的主题在于从浏览器的渲染过程谈页面优化。了解浏览器如何通信并将拿到的数据如何进行解析渲染,本节将从网络通信、页面渲染、资源预取及如何除了以上方案外,如何借助chrome来针对一个页面进行实战优化四个方面来谈。

    从网络通信过程入手可以做的优化

    减少DNS查找

    每一次主机名解析都需要一次网络往返,从而增加请求的延迟时间,同时还会阻塞后续请求。

    重用TCP连接

    尽可能使用持久连接,以消除 TCP 握手和慢启动延迟;

    减少HTTP重定向

    HTTP 重定向极费时间,特别是不同域名之间的重定向,更加费时;这里面既有额外的 DNS 查询、TCP 握手,还有其他延迟。最佳的重定向次数为零。

    使用 CDN(内容分发网络)

    把数据放到离用户地理位置更近的地方,可以显著减少每次 TCP 连接的网络延迟,增大吞吐量。

    去掉不必要的资源

    任何请求都不如没有请求快。说到这,所有建议都无需解释。延迟是瓶颈,最快的速度莫过于什么也不传输。然而,HTTP 也提供了很多额外的机制,比如缓存和压缩,还有与其版本对应的一些性能技巧。

    在客户端缓存资源

    应该缓存应用资源,从而避免每次请求都发送相同的内容。(浏览器缓存)

    传输压缩过的内容

    传输前应该压缩应用资源,把要传输的字节减至最少:确保每种要传输的资源采用最好的压缩手段。(Gzip,减少60%~80%的文件大小)

    消除不必要的请求开销

    减少请求的 HTTP 首部数据(比如HTTPcookie),节省的时间相当于几次往返的延迟时间。

    并行处理请求和响应

    请求和响应的排队都会导致延迟,无论是客户端还是服务器端。这一点经常被忽视,但却会无谓地导致很长延迟。

    针对协议版本采取优化措施

    HTTP 1.x 支持有限的并行机制,要求打包资源、跨域分散资源,等等。相对而言,
    HTTP 2.0 只要建立一个连接就能实现最优性能,同时无需针对 HTTP 1.x 的那些优化方法。
    但是压缩、使用缓存、减少dns等的优化方案无论在哪个版本都同样适用

    你需要了解的资源预取

    preload :可以对当前页面所需的脚本、样式等资源进行预加载,而无需等到解析到 script 和 link 标签时才进行加载。这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。
    用法文档:

    https://developer.mozilla.org...

    prefetch:prefetch 和 preload 一样,都是对资源进行预加载,但是 prefetch 一般预加载的是其他页面会用到的资源。 当然,prefetch 不会像 preload 一样,在页面渲染的时候加载资源,而是利用浏览器空闲时间来下载。当进入下一页面,就可直接从 disk cache 里面取,既不影响当前页面的渲染,又提高了其他页面加载渲染的速度。
    用法文档:

    https://developer.mozilla.org...

    subresource: 被Chrome支持了有一段时间,并且已经有些搔到预加载当前导航/页面(所含有的资源)的痒处了。但它有一个问题——没有办法处理所获取内容的优先级(as也并不存在),所以最终,这些资源会以一个相当低的优先级被加载,这使得它能提供的帮助相当有限

    prerender:prerender 就像是在后台打开了一个隐藏的 tab,会下载所有的资源、创建DOM、渲染页面、执行js等等。如果用户进入指定的链接,隐藏的这个页面就会立马进入用户的视线。 但是要注意,一定要在十分确定用户会点击某个链接时才使用该特性,否则客户端会无端的下载很多资源和渲染这个页面。 正如任何提前动作一样,预判总是有一定风险出错。如果提前的动作是昂贵的(比如高CPU、耗电、占用带宽),就要谨慎使用了。

    preconnect: preconnect 允许浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括

    dns-prefetch:通过 DNS 预解析来告诉浏览器未来我们可能从某个特定的 URL 获取资源,当浏览器真正使用到该域中的某个资源时就可以尽快地完成 DNS 解析

    这些属性虽然并非所有浏览器都支持,但是不支持的浏览器也只是不处理而已,而是别的话则会省去很多时间。因此,合理的使用资源预取可以显著提高页面性能。

    高效合理的css选择符可以减轻浏览器的解析负担。

    因为css是逆向解析的所以应当避免多层嵌套。

    避免使用通配规则。如 *{} 计算次数惊人!只对需要用到的元素进行选择

    尽量少的去对标签进行选择,而是用class。如:#nav li{},可以为li加上nav_item的类名,如下选择.nav_item{}

    不要去用标签限定ID或者类选择符。如:ul#nav,应该简化为#nav

    尽量少的去使用后代选择器,降低选择器的权重值。后代选择器的开销是最高的,尽量将选择器的深度降到最低,最高不要超过三层,更多的使用类来关联每一个标签元素。

    考虑继承。了解哪些属性是可以通过继承而来的,然后避免对这些属性重复指定规则

    从js层面谈页面优化

    ①解决渲染阻塞
    如果在解析HTML标记时,浏览器遇到了JavaScript,解析会停止。只有在该脚本执行完毕后,HTML渲染才会继续进行。所以这阻塞了页面的渲染。
    解决方法:在标签中使用 async或defer特性
    ②减少对DOM的操作
    对DOM操作的代价是高昂的,这在网页应用中的通常是一个性能瓶颈。
    解决办法:修改和访问DOM元素会造成页面的Repaint和Reflow,循环对DOM操作更是罪恶的行为。所以请合理的使用JavaScript变量储存内容,考虑大量DOM元素中循环的性能开销,在循环结束时一次性写入。
    减少对DOM元素的查询和修改,查询时可将其赋值给局部变量。
    ③使用JSON格式来进行数据交换
    JSON是一种轻量级的数据交换格式,采用完全独立于语言的文本格式,是理想的数据交换格式。同时,JSON是 JavaScript原生格式,这意味着在 JavaScript 中处理 JSON数据不需要任何特殊的 API 或工具包。
    ④让需要经常改动的节点脱离文档流
    因为重绘有时确实不可避免,所以只能尽可能限制重绘的影响范围。

    如何借助chrome针对性优化页面

    首先打开控制台,点击Audits一栏,会看到如下表单。在选取自己需要模拟测试的情况后点击run audits,即可开始页面性能分析。

    clipboard.png

    然后将会得到分析结果及优化建议:

    clipboard.png

    我们可以逐项根据现有问题进行优化,如性能类目(performance)中的第一项优化建议延迟加载屏幕外图像(defer offscreen images),点击后就能看到详情以下详情:

    clipboard.png

    而具体页面的指标优化可以根据给出的建议进行逐条优化。目前提供的性能分析及建议的列表包括性能分析、渐进式web应用、最佳实践、无障碍访问及搜索引擎优化五个部分。基本上涵盖了常见优化方案及性能点的方方面面,开发时合理使用也能更好的提升页面性能

    相信以上优化方案之所以行之有效的原因大都可以在本文中找出原因。理论是用来指导实践的,即不能闭门造车式的埋头苦干,也不能毫不实践的夸夸其谈。这样才会形成完整的知识体系,让知识体系树更加庞大。知道该如何优化是一回事,真正合理应用是另一回事,要有好的性能,要着手于能做的每一件“小事”。

    七、附录

    性能优化是一门艺术,更是一门综合艺术。这其中涉及很多知识点。而这些知识点都有很多不错的文章进行了总结。如果你想深入探究或许这里推荐的文章会给你启发。

    HTTP2详解:

    https://www.jianshu.com/p/e57...
    TCP拥塞控制:

    https://www.cnblogs.com/losby...
    页面性能分析网站:

    https://gtmetrix.com/analyze....
    Timing官方文档:

    https://www.w3.org/TR/navigat...
    chrome中的高性能网络:

    https://www.cnblogs.com/xuan5...

    查看原文

    艾特老干部 赞了文章 · 2019-03-25

    Sequelize Docs 中文文档 v4

    Sequelize Docs 中文版

    此系列文章的应用示例已发布于 GitHub: sequelize-docs-Zh-CN. 可以 Fork 帮助改进或 Star 关注更新. 欢迎 Star.

    Travis build
    npm
    npm

    此项目同步自 sequelize / sequelize 项目中的 docs. 除特殊情况, 将保持每月一次的同步频率.

    更新日志请参阅: CHANGELOG

    Sequelize 是一个基于 promise 的 Node.js ORM, 目前支持 Postgres, MySQL, SQLite 和 Microsoft SQL Server. 它具有强大的事务支持, 关联关系, 读取和复制等功能.

    文档

    使用示例

    Basic usage - 基本用法

    const Sequelize = require('sequelize');
    const sequelize = new Sequelize('database', 'username', 'password');
    
    const User = sequelize.define('user', {
      username: Sequelize.STRING,
      birthday: Sequelize.DATE
    });
    
    sequelize.sync()
      .then(() => User.create({
        username: 'janedoe',
        birthday: new Date(1980, 6, 20)
      }))
      .then(jane => {
        console.log(jane.get({
          plain: true
        }));
      });

    如果这篇文章对您有帮助, 感谢 下方点赞 或 Star GitHub: sequelize-docs-Zh-CN 支持, 谢谢.

    查看原文

    赞 20 收藏 21 评论 1

    艾特老干部 关注了专栏 · 2019-03-25

    DemoPark

    For DemoPark

    关注 85

    艾特老干部 赞了文章 · 2019-03-25

    Sequelize Docs 中文文档 v4

    Sequelize Docs 中文版

    此系列文章的应用示例已发布于 GitHub: sequelize-docs-Zh-CN. 可以 Fork 帮助改进或 Star 关注更新. 欢迎 Star.

    Travis build
    npm
    npm

    此项目同步自 sequelize / sequelize 项目中的 docs. 除特殊情况, 将保持每月一次的同步频率.

    更新日志请参阅: CHANGELOG

    Sequelize 是一个基于 promise 的 Node.js ORM, 目前支持 Postgres, MySQL, SQLite 和 Microsoft SQL Server. 它具有强大的事务支持, 关联关系, 读取和复制等功能.

    文档

    使用示例

    Basic usage - 基本用法

    const Sequelize = require('sequelize');
    const sequelize = new Sequelize('database', 'username', 'password');
    
    const User = sequelize.define('user', {
      username: Sequelize.STRING,
      birthday: Sequelize.DATE
    });
    
    sequelize.sync()
      .then(() => User.create({
        username: 'janedoe',
        birthday: new Date(1980, 6, 20)
      }))
      .then(jane => {
        console.log(jane.get({
          plain: true
        }));
      });

    如果这篇文章对您有帮助, 感谢 下方点赞 或 Star GitHub: sequelize-docs-Zh-CN 支持, 谢谢.

    查看原文

    赞 20 收藏 21 评论 1

    艾特老干部 发布了文章 · 2018-07-17

    合并HTTP请求 vs 并行HTTP请求,到底谁更快?

    面试时,经常会问候选人一个问题:如何提高网页性能?

    有些基础的人都会提到这么一条:减少/合并HTTP请求。

    继续问:浏览器不是可以并行下载资源吗?将多个资源合并成一个资源,只使用一个HTTP请求下载,难道要比用多个HTTP请求并行下载没有合并过的多个资源速度更快?

    候选人:……(至今,还没有遇到让我满意的回答)

    减少HTTP请求,是雅虎前端性能优化35条军规的第1条,2006年雅虎提出了这35条军规,从那以后,就深深地影响到了一批又一批的前端开发者,即使在12年后的今天,影响力依旧不减…..

    但是,雅虎军规中还有1条是:拆分资源以最大化利用浏览器并行下载的能力。现在问题就来了,减少HTTP请求,但网页所需的资源并不能减少(否则网页就不再是之前的网页了),所以减少HTTP请求,主要是通过合并资源来实现的,一边是建议合并资源,一边是建议拆分资源,显然是有冲突的地方,那么到底该怎么做呢?网上有些文章也讨论过这个问题,但大多是停留在想当然的理论分析上,而且忽略了TCP传输机制的影响。今天,老干部就带大家一起用实验+理论,仔细探讨下这个问题。

    HTTP请求过程

    一个HTTP请求的主要过程是:

    DNS解析(T1) -> 建立TCP连接(T2) -> 发送请求(T3) -> 等待服务器返回首字节(TTFB)(T4) -> 接收数据(T5)。

    如下图所示,是Chrome Devtools中显示的一个HTTP请求,显示了HTTP请求的主要阶段,注意,Queueing阶段是请求在浏览器队列中的排队时间,并不计入HTTP请求时间

    图片描述

    从这个过程中,可以看出如果合并N个HTTP请求为1个,可以节省(N-1)* (T1+T2+T3+T4) 的时间。

    但实际场景并没有这么理想,上面的分析存在几个漏洞:

    1. 浏览器会缓存DNS信息,因此不是每次请求都需要DNS解析。
    2. HTTP 1.1 keep-alive的特性,使HTTP请求可以复用已有TCP连接,所以并不是每个HTTP请求都需要建立新的TCP连接。
    3. 浏览器可以并行发送多个HTTP请求,同样可能影响到资源的下载时间,而上面的分析显然只是基于同一时刻只有1个HTTP请求的场景。

    实验论证

    我们来做4组实验,对比一个HTTP请求加载合并后的资源所需时间,和多个HTTP请求并行加载拆分的资源所需时间。每组实验所用资源的体积大小有显著差异。

    实验环境:

    服务器:阿里云ECS 1核 2GB内存 带宽1M

    Web服务器:Nginx (未启用Gzip)

    Chrome v66 隐身模式,禁用缓存

    Client 网络:wifi 带宽20M

    实验代码地址:https://github.com/xuchaobei/...

    实验 1

    测试文件:large1.css、large2.css … large6.css,每个文件141K;large-6in1.css,由前面6个css文件合并而成,大小为846K。parallel-large.html引用large1.css、large2.css … large6.css, combined-large.html引用large-6in1.css,代码如下:

    // parallel-large.html
    <!DOCTYPE html>
    <html>
    
      <head>
        <meta charset="utf-8" />
        <title>Parallel Large</title>
        <link rel="stylesheet" type="text/css" media="screen" href="large1.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large2.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large3.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large4.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large5.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large6.css" />
      </head>
    
      <body>
        Hello, world!
      </body>
    
    </html>
    // combined-large.html
    <!DOCTYPE html>
    <html>
    
      <head>
        <meta charset="utf-8" />
        <title>Combined Large</title>
        <link rel="stylesheet" type="text/css" media="screen" href="large-6in1.css" />
      </head>
    
      <body>
        Hello, world!
      </body>
    
    </html>

    分别刷新2个页面各10次,利用Devtools 的Network计算CSS资源加载的平均时间。

    注意事项:

    1. large1.css、large2.css … large6.css的加载时间,计算方式为从第一个资源的HTTP请求发送开始,到6个文件都下载完成的时间,如图2红色框内的时间。
    2. 两个html页面不能同时加载,否则带宽为两个页面所共享,会影响测试结果。需要等待一个页面加载完毕后,再手动刷新加载另外一个页面。
    3. 页面两次刷新时间间隔在1分钟以上 ,以避免HTTP 1.1 连接复用对实验的影响。

    图片描述

    实验结果如下:

    large-6in1.csslarge1.css、large2.css … large6.css
    平均时间(s)5.525.3

    我们再把large1.css、large2.css … large6.css合并为3个资源large-2in1a.css、large-2in1b.css、large-2in1c.css,每个资源282K,在combined-large-1.html中引用这3个资源:

    // combined-large-1.html
    <!DOCTYPE html>
    <html>
    
      <head>
        <meta charset="utf-8" />
        <title>Parallel Large 1</title>
        <link rel="stylesheet" type="text/css" media="screen" href="large-2in1a.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large-2in1b.css" />
        <link rel="stylesheet" type="text/css" media="screen" href="large-2in1c.css" />
      </head>
    
      <body>
        Hello, world!
      </body>
    
    </html>

    测试10次,平均加载时间为5.20s。

    汇总实验结果如下:

    large-6in1.csslarge1.css、large2.css … large6.csslarge-2in1a.css、... large-2in1c.css
    平均时间(s)5.525.305.20

    从实验1结果可以看出,合并资源和拆分资源对于资源的总加载时间没有显著影响。实验中耗时最少的是拆分成3个资源的情况(5.2s),耗时最多的是合并成一个资源的情况(5.52s),但两者也只不过相差6%。考虑到实验环境具有一定随机性,以及实验重复次数只有10次,这个时间差并不能表征3种场景有明显的时间差异性。

    实验 2

    继续增加css文件大小。

    测试文件:xlarge1.css、xlarge2.css 、xlarge3.css,每个文件1.7M;xlarge-3in1.css,由前面3个css文件合并而成,大小为5.1M。parallel-xlarge.html引用xlarge1.css、xlarge2.css 、xlarge3.css, combined-xlarge.html引用xlarge-3in1.css。

    测试过程同上,实验结果如下:

    xlarge-3in1.cssxlarge1.css、xlarge2.css、xlarge3.css
    平均时间(s)37.7236.88

    这组实验的时间差只有2%,更小了,所以更无法说明合并资源和拆分资源的总加载时间有明显差异性。

    实际上,理想情况下,随着资源体积变大,两种资源加载方式所需时间将趋于相同。

    从理论上解释,因为HTTP的传输通道是基于TCP连接的,而TCP连接具有慢启动的特性,刚开始时并没有充分利用网络带宽,经过慢启动过程后,逐渐占满可利用的带宽。对于大资源而言,带宽总是会被充分利用的,所以带宽是瓶颈,即使使用更多的TCP连接,也不能带来速度的提升。资源越大,慢启动所占总的下载时间的比例就越小,绝大部分时间,带宽都是被充分利用的,总数据量相同(拆分资源导致的额外Header在这种情况下完全可以忽略不计),带宽相同,传输时间当然也相同。

    实验 3

    减小css文件大小。

    测试文件:medium1.css、medium2.css … medium6.css,每个文件9.4K;medium-6in1.css,由前面6个css文件合并而成,大小为56.4K。parallel-medium.html引用medium1.css、medium2.css … medium6.css, combined-medium.html 引用 medium-6in1.css。

    实验结果如下:

    medium-6in1.cssmedium1.css、medium2.css … medium6.css
    平均时间(ms)34.8746.24

    注意单位变成ms

    实验3的时间差是33%,虽然数值上只差12ms。先不多分析,继续看实验4。

    实验 4

    继续减小css文件大小,至几十字节级别。

    测试文件:small1.css、small2.css … small6.css,每个文件28B;small-6in1.css,由前面6个css文件合并而成,大小为173B。parallel-medium.html引用small1.css、small2.css … small6.css, combined-medium.html 引用 small-6in1.css。

    实验结果如下:

    small-6in1.csssmall1.css、small2.css … small6.css
    平均时间(ms)20.3335

    实验4的时间差是72%。

    根据实验3和实验4,发现当资源体积很小时,合并资源和拆分资源的加载时间有了比较明显的差异。图3和图4是实验4中的某次测试结果的截图,当资源体积很小时,数据的下载时间(图中水平柱的蓝色部分所示)占总时间的比例就很小了,这时候影响资源加载时间的关键就是DNS解析(T1) 、 TCP连接建立(T2) 、发送请求(T3) 和等待服务器返回首字节(TTFB)(T4) 。但同时建立多个HTTP连接本身就存在额外的资源消耗,每个HTTP的DNS查询时间、TCP连接的建立时间等也存在一定的随机性,这就导致并发请求资源时,出现某个HTTP耗时明显增加的可能性变大。如图3所示,small1.css加载时间最短(16ms),small5.css加载时间最长(32ms),两者相差了1倍,但计算时间是以所有资源都加载完成为准,这种情况下,同时使用多个HTTP请求就会导致更大的时间不均匀性和不确定性,表现结果就是往往要比使用一个HTTP请求加载合并后的资源慢。

    图片描述

    图片描述

    更复杂的情况

    对于小文件一定是合并资源更快吗?

    其实未必,在一些情况下,合并小文件反而有可能明显增加资源加载时间。

    再说些理论的东西。为了提高传输效率,TCP通道上,并不是发送方每发送一个数据包,都要等到收到接收方的确认应答(ACK)后,再发送下一个报文。TCP引入了”窗口“的概念,窗口大小指无需等待确认应答而可以继续发送数据的最大值,例如窗口大小是4个MSS(Maximum Segment Size,TCP数据包每次能够传输的最大数据分段),表示当前可以连续发送4个报文段,而不需要等待接收方的确认信号,也就是说,在1次网络往返(round-trip)中完成了4个报文段的传输。如下图所示(MSS为1,窗口大小为4),1 - 4000 数据是连续发送的,并没有等待确认应答,同样的,4001 - 8000也是连续发送的。请注意,这只是理想情况下的示意图,实际情况要比这里更复杂。

    图片描述

    在慢启动阶段,TCP维护一个拥塞窗口变量,这个阶段窗口的大小就等于拥塞窗口,慢启动阶段,随着每次网络往返,拥塞窗口的大小就会翻一倍,例如,假设拥塞窗口的初始大小为1,拥塞窗口的大小变化为:1,2,4,8……。如下图所示。

    图片描述

    实际网络中,拥塞窗口的初始值一般是10,所以拥塞窗口的大小变化为:10,20,40 ... ,MSS的值取决于网络拓扑结构和硬件设备,以太网中MSS值一般是1460字节,按每个报文段传输的数据大小都等于MSS计算(实际情况可以小于MSS值),经过第1次网络往返后,传输的最大数据为14.6K,第2次后,为(10+20) 1.46 = 43.8K, 第3次后,为(10+20+40) 1.46 = 102.2K。

    根据上面的理论介绍,实验4中,不管是合并资源,还是拆分资源,都是在1次网络往返中传输完成。但实验3,拆分后的资源大小为9.4K,可以在1次网络往返中传输完成,而合并后的资源大小为56.4K,需要3次网络往返才能传输完成,如果网络延时很大(例如1s),带宽又不是瓶颈,多了两次网络往返将导致耗时增加1s,这时候合并资源就可能得不偿失了。实验3并没有产生这个结果的原因是,实验中网络延时是10ms左右,由于数值太小而没有对结果产生明显影响。

    总结

    对于大资源,是否合并对于加载时间没有明显影响,但拆分资源可以更好的利用浏览器缓存,不会因为某个资源的更新导致所有资源缓存失效,而资源合并后,任一资源的更新都会导致整体资源的缓存失效。另外还可以利用域名分片技术,将资源拆分部署到不同域名下,既可以分散服务器的压力,又可以降低网络抖动带来的影响。

    对于小资源,合并资源往往具有更快的加载速度,但在网络带宽状况良好的情况下,因为提升的时间单位以ms计量,收益可以忽略。如果网络延迟很大,服务器响应速度又慢,则可以带来一定收益,但在高延迟的网络场景下,又要注意合并资源后可能带来网络往返次数的增加,进而影响到加载时间。

    其实,看到这里,是合是分已经不重要了,重要的是我们要知道合分背后的原理是什么,和业务场景是怎样的。

    查看原文

    赞 367 收藏 266 评论 19

    艾特老干部 发布了文章 · 2018-05-22

    React 深入系列7:React 常用模式

    React 深入系列,深入讲解了React中的重点概念、特性和模式等,旨在帮助大家加深对React的理解,以及在项目中更加灵活地使用React。

    本篇是React深入系列的最后一篇,将介绍开发React应用时,经常用到的模式,这些模式并非都有官方名称,所以有些模式的命名并不一定准确,请读者主要关注模式的内容。

    1. 受控组件

    React 组件的数据流是由state和props驱动的,但对于input、textarea、select等表单元素,因为可以直接接收用户在界面上的输入,所以破坏了React中的固有数据流。为了解决这个问题,React引入了受控组件,受控组件指input等表单元素显示的值,仍然是通过组件的state获取的,而不是直接显示用户在界面上的输入信息。

    受控组件的实现:通过监听表单元素值的变化,改变组件state,根据state显示组件最终要展示的值。一个简单的例子如下:

    class NameForm extends React.Component {
      constructor(props) {
        super(props);
        this.state = {value: ''};
    
        this.handleChange = this.handleChange.bind(this);
        this.handleSubmit = this.handleSubmit.bind(this);
      }
    
      handleChange(event) {
        this.setState({value: event.target.value});
      }
    
      handleSubmit(event) {
        alert('A name was submitted: ' + this.state.value);
        event.preventDefault();
      }
    
      render() {
        return (
          <form onSubmit={this.handleSubmit}>
            <label>
              Name:
              <input type="text" value={this.state.value} onChange={this.handleChange} />
            </label>
            <input type="submit" value="Submit" />
          </form>
        );
      }
    }

    和受控组件对应的概念是非受控组件,非受控组件通过ref获取表单元素的值,在一些场景下有着特有的作用(如设置表单元素的焦点)。

    2. 容器组件

    容器组件和展示组件是一组对应的概念,关注的是组件逻辑和组件展示的分离。逻辑由容器组件负责,展示组件聚焦在视图层的展现上。在React 深入系列2:组件分类中已对容器组件和展示组件作过详细介绍,这里不再赘述。

    3. 高阶组件

    高阶组件是一种特殊的函数,接收组件作为输入,输出一个新的组件。高阶组件的主要作用是封装组件的通用逻辑,实现逻辑的复用。在React 深入系列6:高阶组件中已经详细介绍过高阶组件,这里也不再赘述。

    4. Children传递

    首先,这个模式的命名可能并不恰当。这个模式中,借助React 组件的children属性,实现组件间的解耦。常用在一个组件负责UI的框架,框架内部的组件可以灵活替换的场景。

    一个示例:

    // ModalDialog.js
    export default function ModalDialog({ children }) {
      return <div className="modal-dialog">{ children }</div>;
    };
    
    // App.js
    render() {
      <ModalDialog>
        <SomeContentComp/>
      </ModalDialog>
    }

    ModalDialog组件是UI的框,框内组件可以灵活替换。

    5. Render Props

    Render Props是把组件部分的渲染逻辑封装到一个函数中,利用组件的props接收这个函数,然后在组件内部调用这个函数,执行封装的渲染逻辑。

    看一个官方的例子:

    class Cat extends React.Component {
      render() {
        const mouse = this.props.mouse;
        return (
          <img data-original="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
        );
      }
    }
    
    class Mouse extends React.Component {
      constructor(props) {
        super(props);
        this.handleMouseMove = this.handleMouseMove.bind(this);
        this.state = { x: 0, y: 0 };
      }
    
      handleMouseMove(event) {
        this.setState({
          x: event.clientX,
          y: event.clientY
        });
      }
    
      render() {
        return (
          <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
            {/*
            *   Mouse组件并不知道应该如何渲染这部分内容,
            *   这部分渲染逻辑是通过props的render属性传递给Mouse组件 
            */}
            {this.props.render(this.state)}
          </div>
        );
      }
    }
    
    class MouseTracker extends React.Component {
      render() {
        return (
          <div>
            <h1>Move the mouse around!</h1>
            <Mouse render={mouse => (
              <Cat mouse={mouse} />
            )}/>
          </div>
        );
      }
    }
    

    Mouse监听鼠标的移动,并将鼠标位置保存到state中。但Mouse组件并不知道最终要渲染出的内容,需要调用this.props.render方法,执行渲染逻辑。本例中,Cat组件会渲染到鼠标移动到的位置,但完全可以使用其他效果来跟随鼠标的移动,只需更改render方法即可。由此可见,Mouse组件只关注鼠标位置的移动,而跟随鼠标移动的界面效果,由使用Mouse的组件决定。这是一种基于切面编程的思想(了解后端开发的同学应该比较熟悉)。

    使用这种模式,一般习惯将封装渲染逻辑的函数赋值给一个命名为render的组件属性(如本例所示),但这并不是必需,你也可以使用其他的属性命名。

    这种模式的变种形式是,直接使用React组件自带的children属性传递。上面的例子改写为:

    class Mouse extends React.Component {
      // 省略
    
      render() {
        return (
          <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
            {/*
            *   Mouse组件并不知道应该如何渲染这部分内容,
            *   这部分渲染逻辑是通过props的children属性传递给Mouse组件 
            */}
            {this.props.children(this.state)}
          </div>
        );
      }
    }
    Mouse.propTypes = {
      children: PropTypes.func.isRequired
    };
    
    
    class MouseTracker extends React.Component {
      render() {
        return (
          <div>
            <h1>Move the mouse around!</h1>
            <Mouse>
              {mouse => (
                <Cat mouse={mouse} />
              )}
            </Mouse>
          </div>
        );
      }
    }

    注意children的赋值方式。

    React Router 和 React-Motion 这两个库都使用到了Render Props模式。很多场景下,Render Props实现的功能也可以通过高阶组件实现。本例也可以用高阶组件实现,请读者自行思考。

    6. Provider组件

    这种模式借助React的context,把组件需要使用的数据保存到context,并提供一个高阶组件从context中获取数据。

    一个例子:

    先创建MyProvider,将共享数据保存到它的context中,MyProvider一般作为最顶层的组件使用,从而确保其他组件都能获取到context中的数据:

    import React from "react";
    import PropTypes from "prop-types";
    
    const contextTypes = {
      sharedData: PropTypes.shape({
        a: PropTypes.bool,
        b: PropTypes.string,
        c: PropTypes.object
      })
    };
    
    export class MyProvider extends React.Component {
    
      static childContextTypes = contextTypes;
    
      getChildContext() {
        // 假定context中的数据从props中获取
        return { sharedData: this.props.sharedData };
      }
    
      render() {
        return this.props.children;
      }
    }

    然后创建高阶组件connectData,用于从context中获取所需数据:

    export const connectData = WrappedComponent =>
      class extends React.Component {
        static contextTypes = contextTypes;
    
        render() {
          const { props, context } = this;
          return <WrappedComponent {...props} {...context.sharedData} />;
        }
      };

    最后在应用中使用:

    const SomeComponentWithData = connectData(SomeComponent)
    
    const sharedData = {
        a: true,
        b: "react",
        c: {}
    };
    
    class App extends Component {
      render() {
        return (
          <MyProvider sharedData={sharedData}>
            <SomeComponentWithData />
          </MyProvider>
        );
      }
    }

    Provider组件模式非常实用,在react-redux、mobx-react等库中,都有使用到这种模式。

    React 深入系列文章到此完结,希望能帮助大家更加深入的理解React,更加纯熟的应用React。

    我的新书《React进阶之路》已上市,请大家多多支持!
    链接:京东当当

    图片描述

    查看原文

    赞 3 收藏 2 评论 0

    艾特老干部 发布了文章 · 2018-05-22

    React 深入系列6:高阶组件

    React 深入系列,深入讲解了React中的重点概念、特性和模式等,旨在帮助大家加深对React的理解,以及在项目中更加灵活地使用React。

    1. 基本概念

    高阶组件是React 中一个很重要且比较复杂的概念,高阶组件在很多第三方库(如Redux)中都被经常使用。在项目中用好高阶组件,可以显著提高代码质量。

    高阶组件的定义类比于高阶函数的定义。高阶函数接收函数作为参数,并且返回值也是一个函数。类似的,高阶组件接收React组件作为参数,并且返回一个新的React组件。高阶组件本质上也是一个函数,并不是一个组件,这一点一定不要弄错。

    2. 应用场景

    为什么React引入高阶组件的概念?它到底有何威力?让我们先通过一个简单的例子说明一下。

    假设有一个组件MyComponent,需要从LocalStorage中获取数据,然后渲染数据到界面。我们可以这样写组件代码:

    import React, { Component } from 'react'
    
    class MyComponent extends Component {
    
      componentWillMount() {
          let data = localStorage.getItem('data');
          this.setState({data});
      }
      
      render() {
        return <div>{this.state.data}</div>
      }
    }

    代码很简单,但当有其他组件也需要从LocalStorage中获取同样的数据展示出来时,需要在每个组件都重复componentWillMount中的代码,这显然是很冗余的。下面让我们来看看使用高阶组件可以怎么改写这部分代码。

    import React, { Component } from 'react'
    
    function withPersistentData(WrappedComponent) {
      return class extends Component {
        componentWillMount() {
          let data = localStorage.getItem('data');
            this.setState({data});
        }
        
        render() {
          // 通过{...this.props} 把传递给当前组件的属性继续传递给被包装的组件WrappedComponent
          return <WrappedComponent data={this.state.data} {...this.props} />
        }
      }
    }
    
    class MyComponent2 extends Component {  
      render() {
        return <div>{this.props.data}</div>
      }
    }
    
    const MyComponentWithPersistentData = withPersistentData(MyComponent2)

    withPersistentData就是一个高阶组件,它返回一个新的组件,在新组件的componentWillMount中统一处理从LocalStorage中获取数据的逻辑,然后将获取到的数据以属性的方式传递给被包装的组件WrappedComponent,这样在WrappedComponent中就可以直接使用this.props.data获取需要展示的数据了,如MyComponent2所示。当有其他的组件也需要这段逻辑时,继续使用withPersistentData这个高阶组件包装这些组件就可以了。

    通过这个例子,可以看出高阶组件的主要功能是封装并分离组件的通用逻辑,让通用逻辑在组件间更好地被复用。高阶组件的这种实现方式,本质上是一个装饰者设计模式。

    高阶组件的参数并非只能是一个组件,它还可以接收其他参数。例如,组件MyComponent3需要从LocalStorage中获取key等于name的数据,而不是上面例子中写死的key等于data的数据,withPersistentData这个高阶组件就不满足我们的需求了。我们可以让它接收额外的一个参数,来决定从LocalStorage中获取哪个数据:

    import React, { Component } from 'react'
    
    function withPersistentData(WrappedComponent, key) {
      return class extends Component {
        componentWillMount() {
          let data = localStorage.getItem(key);
            this.setState({data});
        }
        
        render() {
          // 通过{...this.props} 把传递给当前组件的属性继续传递给被包装的组件WrappedComponent
          return <WrappedComponent data={this.state.data} {...this.props} />
        }
      }
    }
    
    class MyComponent2 extends Component {  
      render() {
        return <div>{this.props.data}</div>
      }
      
      //省略其他逻辑...
    }
    
    class MyComponent3 extends Component {  
      render() {
        return <div>{this.props.data}</div>
      }
      
      //省略其他逻辑...
    }
    
    const MyComponent2WithPersistentData = withPersistentData(MyComponent2, 'data');
    const MyComponent3WithPersistentData = withPersistentData(MyComponent3, 'name');

    新版本的withPersistentData就满足我们获取不同key的值的需求了。高阶组件中的参数当然也可以是函数,我们将在下一节进一步说明。

    3. 进阶用法

    高阶组件最常见的函数签名形式是这样的:

    HOC([param])([WrappedComponent])

    用这种形式改写withPersistentData,如下:

    import React, { Component } from 'react'
    
    const withPersistentData = (key) => (WrappedComponent) => {
      return class extends Component {
        componentWillMount() {
          let data = localStorage.getItem(key);
            this.setState({data});
        }
        
        render() {
          // 通过{...this.props} 把传递给当前组件的属性继续传递给被包装的组件WrappedComponent
          return <WrappedComponent data={this.state.data} {...this.props} />
        }
      }
    }
    
    class MyComponent2 extends Component {  
      render() {
        return <div>{this.props.data}</div>
      }
      
      //省略其他逻辑...
    }
    
    class MyComponent3 extends Component {  
      render() {
        return <div>{this.props.data}</div>
      }
      
      //省略其他逻辑...
    }
    
    const MyComponent2WithPersistentData = withPersistentData('data')(MyComponent2);
    const MyComponent3WithPersistentData = withPersistentData('name')(MyComponent3);

    实际上,此时的withPersistentData和我们最初对高阶组件的定义已经不同。它已经变成了一个高阶函数,但这个高阶函数的返回值是一个高阶组件。HOC([param])([WrappedComponent])这种形式中,HOC([param])才是真正的高阶组件,我们可以把它看成高阶组件的变种形式。这种形式的高阶组件因其特有的便利性——结构清晰(普通参数和被包裹组件分离)、易于组合,大量出现在第三方库中。如react-redux中的connect就是一个典型。connect的定义如下:

    connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])(WrappedComponent)

    这个函数会将一个React组件连接到Redux 的 store。在连接的过程中,connect通过函数类型的参数mapStateToProps,从全局store中取出当前组件需要的state,并把state转化成当前组件的props;同时通过函数类型的参数mapDispatchToProps,把当前组件用到的Redux的action creators,以props的方式传递给当前组件。

    例如,我们把组件ComponentA连接到Redux上的写法类似于:

    const ConnectedComponentA = connect(mapStateToProps, mapDispatchToProps)(ComponentA);

    我们可以把它拆分来看:

    // connect 是一个函数,返回值enhance也是一个函数
    const enhance = connect(mapStateToProps, mapDispatchToProps);
    // enhance是一个高阶组件
    const ConnectedComponentA = enhance(ComponentA);

    当多个函数的输出和它的输入类型相同时,这些函数是很容易组合到一起使用的。例如,有f,g,h三个高阶组件,都只接受一个组件作为参数,于是我们可以很方便的嵌套使用它们:f( g( h(WrappedComponent) ) )。这里可以有一个例外,即最内层的高阶组件h可以有多个参数,但其他高阶组件必须只能接收一个参数,只有这样才能保证内层的函数返回值和外层的函数参数数量一致(都只有1个)。

    例如我们将connect和另一个打印日志的高阶组件withLog联合使用:

    const ConnectedComponentA = connect(mapStateToProps)(withLog(ComponentA));

    这里我们定义一个工具函数:compose(...functions),调用compose(f, g, h) 等价于 (...args) => f(g(h(...args)))。用compose函数我们可以把高阶组件嵌套的写法打平:

    const enhance = compose(
      connect(mapStateToProps),
      withLog
    );
    const ConnectedComponentA = enhance(ComponentA);

    像Redux等很多第三方库都提供了compose的实现,compose结合高阶组件使用,可以显著提高代码的可读性和逻辑的清晰度。

    4.与父组件区别

    有些同学可能会觉得高阶组件有些类似父组件的使用。例如,我们完全可以把高阶组件中的逻辑放到一个父组件中去执行,执行完成的结果再传递给子组件。从逻辑的执行流程上来看,高阶组件确实和父组件比较相像,但是高阶组件强调的是逻辑的抽象。高阶组件是一个函数,函数关注的是逻辑;父组件是一个组件,组件主要关注的是UI/DOM。如果逻辑是与DOM直接相关的,那么这部分逻辑适合放到父组件中实现;如果逻辑是与DOM不直接相关的,那么这部分逻辑适合使用高阶组件抽象,如数据校验、请求发送等。

    5. 注意事项

    1)不要在组件的render方法中使用高阶组件,尽量也不要在组件的其他生命周期方法中使用高阶组件。因为高阶组件每次都会返回一个新的组件,在render中使用会导致每次渲染出来的组件都不相等(===),于是每次render,组件都会卸载(unmount),然后重新挂载(mount),既影响了效率,又丢失了组件及其子组件的状态。高阶组件最适合使用的地方是在组件定义的外部,这样就不会受到组件生命周期的影响了。

    2)如果需要使用被包装组件的静态方法,那么必须手动拷贝这些静态方法。因为高阶组件返回的新组件,是不包含被包装组件的静态方法。hoist-non-react-statics可以帮助我们方便的拷贝组件所有的自定义静态方法。有兴趣的同学可以自行了解。

    3)Refs不会被传递给被包装组件。尽管在定义高阶组件时,我们会把所有的属性都传递给被包装组件,但是ref并不会传递给被包装组件。如果你在高阶组件的返回组件中定义了ref,那么它指向的是这个返回的新组件,而不是内部被包装的组件。如果你希望获取被包装组件的引用,你可以把ref的回调函数定义成一个普通属性(给它一个ref以外的名字)。下面的例子就用inputRef这个属性名代替了常规的ref命名:

    function FocusInput({ inputRef, ...rest }) {
      return <input ref={inputRef} {...rest} />;
    }
    
    //enhance 是一个高阶组件
    const EnhanceInput = enhance(FocusInput);
    
    // 在一个组件的render方法中...
    return (<EnhanceInput 
      inputRef={(input) => {
        this.input = input
      }
    }>)
    
    // 让FocusInput自动获取焦点
    this.input.focus();
    

    下篇预告:

    React 深入系列7:React 常用模式


    我的新书《React进阶之路》已上市,请大家多多支持!
    链接:京东当当

    图片描述

    查看原文

    赞 14 收藏 9 评论 0

    艾特老干部 发布了文章 · 2018-05-22

    React 深入系列5:事件处理

    React 深入系列,深入讲解了React中的重点概念、特性和模式等,旨在帮助大家加深对React的理解,以及在项目中更加灵活地使用React。

    Web应用中,事件处理是重要的一环,事件处理将用户的操作行为转换为相应的逻辑执行或界面更新。在React中,处理事件响应的方式有多种,本文将详细介绍每一种处理方式的用法、使用场景和优缺点。

    使用匿名函数

    先上代码:

    //代码1
    class MyComponent extends React.Component {
      render() {
        return (
          <button onClick={()=>{console.log('button clicked');}}>
            Click
          </button>
        );
      }
    }

    点击Button的事件响应函数是一个匿名函数,这应该是最常见的处理事件响应的方式了。这种方式的好处是,简单直接。哪里需要处理事件响应,就在哪里定义一个匿名函数处理。代码1中的匿名函数使用的是箭头函数,我们也可以不使用箭头函数:

    //代码2
    class MyComponent extends React.Component {
      render() {
        return (
          <button onClick={function(){console.log('button clicked');}}>
            Click
          </button>
        );
      }
    }

    虽然代码2的运行效果和代码1相同,但实际项目中很少见到代码2的这种写法。这是因为箭头函数解决了this绑定的问题,可以将函数体内的this绑定到当前对象,而不是运行时调用函数的对象。如果响应函数中需要使用this.state,那么代码2就无法正常运行了。所以项目中一般直接使用箭头函数定义的匿名函数作为事件响应。

    使用匿名函数的缺点是:当事件响应逻辑比较复杂时,匿名函数的代码量会很大,会导致render函数变得臃肿,不容易直观地看出组件最终渲染出的元素结构。另外,每次render方法调用时,都会重新创建一个匿名函数对象,带来额外的性能开销,当组件的层级越低时,这种开销就越大,因为任何一个上层组件的变化都可能会触发这个组件的render方法。当然,在大多数情况下,这点性能损失是可以不必在意的。

    使用组件方法

    代码如下:

    //代码3
    class MyComponent extends React.Component {
      constructor(props) {
        super(props);
        this.state = {number: 0};
        this.handleClick = this.handleClick.bind(this); // 手动绑定this
      }
    
      handleClick() {
        this.setState({
          number: ++this.state.number
        });
      }
      
      render() {
        return (
          <div>
            <div>{this.state.number}</div>
            <button onClick={this.handleClick}>
              Click
            </button>
          </div>
        );
      }
    }

    点击Button的事件响应函数是组件的方法:handleClick。这种方式的好处是:每次render方法的调用,不会重新创建一个新的事件响应函数,没有额外的性能损失。但是,使用这种方式要在构造函数中为作为事件响应的方法(handleClick),手动绑定this: this.handleClick = this.handleClick.bind(this),这是因为ES6 语法的缘故,ES6 Class 的方法默认不会把this绑定到当前的实例对象上,需要我们手动绑定。每次都手动绑定this是不是有点繁琐?好吧,让我们来看下一种方式。

    使用属性初始化语法

    使用ES7的属性初始化语法( property initializers ),代码可以这样写:

    //代码4
    class MyComponent extends React.Component {
      constructor(props) {
        super(props);
        this.state = {number: 0};
      }
    
      handleClick = () => {
        this.setState({
          number: ++this.state.number
        });
      }
      
      render() {
        return (
          <div>
            <div>{this.state.number}</div>
            <button onClick={this.handleClick}>
              Click
            </button>
          </div>
        );
      }
    }

    这样一来,再也不用手动绑定this了。但是你需要知道,这个特性还处于试验阶段,默认是不支持的。如果你是使用官方脚手架Create React App 创建的应用,那么这个特性是默认支持的。你也可以自行在项目中引入babel的transform-class-properties插件获取这个特性支持。

    事件响应函数的传参问题

    事件响应函数默认是会被传入一个事件对象Event作为参数的。如果想传入其他参数给响应函数应该怎么办呢?

    使用第一种方式的话很简单,直接使用新参数:

    //代码5
    class MyComponent extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          list: [1,2,3,4],
          current: 1
        };
      }
      
      handleClick(item,event) {
        this.setState({
          current: item
        });
      }
    
      render() {
        return (
          <ul>
            {this.state.list.map(
              (item)=>(
                <li className={this.state.current === item ? 'current':''} 
                onClick={(event) => this.handleClick(item, event)}>{item}
                </li>
              )
            )}
          </ul>
        );
      }
    }

    onClick的响应函数中,方法体内可以直接使用新的参数item。

    使用第二种方式的话,可以把绑定this的操作延迟到render中,在绑定this的同时,绑定额外的参数:

    //代码6
    class MyComponent extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          list: [1,2,3,4],
          current: 1
        };
      }
      
      handleClick(item) {
        this.setState({
          current: item
        });
      }
    
      render() {
        return (
          <ul>
            {this.state.list.map(
              (item)=>(
                <li className={this.state.current === item ? 'current':''} 
                onClick={this.handleClick.bind(this, item)}>{item}
                </li>
              )
            )}
          </ul>
        );
      }
    }

    使用第三种方式,解决方案和第二种基本一致:

    //代码7
    class MyComponent extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          list: [1,2,3,4],
          current: 1
        };
      }
      
      handleClick = (item) =>  {
        this.setState({
          current: item
        });
      }
    
      render() {
        return (
          <ul>
            {this.state.list.map(
              (item)=>(
                <li className={this.state.current === item ? 'current':''} 
                onClick={this.handleClick.bind(undefined, item)}>{item}
                </li>
              )
            )}
          </ul>
        );
      }
    }

    不过这种方式就有点鸡肋了,因为虽然你不需要通过bind函数绑定this,但仍然要使用bind函数来绑定其他参数。

    关于事件响应函数,还有一个地方需要注意。不管你在响应函数中有没有显式的声明事件参数Event,React都会把事件Event作为参数传递给响应函数,且参数Event的位置总是在其他自定义参数的后面。例如,在代码6和代码7中,handleClick的参数中虽然没有声明Event参数,但你依然可以通过arguments[1]获取到事件Event对象。

    总结一下,三种事件处理的方式,第一种有额外的性能损失;第二种需要手动绑定this,代码量增多;第三种用到了ES7的特性,目前并非默认支持,需要Babel插件的支持,但是写法最为简洁,也不需要手动绑定this。一般推荐使用第二种和第三种方式。

    下篇预告:

    React 深入系列6:高阶组件


    我的新书《React进阶之路》已上市,请大家多多支持!
    链接:京东当当

    图片描述

    查看原文

    赞 3 收藏 1 评论 0

    艾特老干部 发布了文章 · 2018-05-22

    React 深入系列4:组件的生命周期

    React 深入系列,深入讲解了React中的重点概念、特性和模式等,旨在帮助大家加深对React的理解,以及在项目中更加灵活地使用React。

    组件是构建React应用的基本单位,组件需要具备数据获取、业务逻辑处理、以及UI呈现的能力,而这些能力是要依赖于组件不同的生命周期方法的。组件的生命周期分为3个阶段:挂载阶段、更新阶段、卸载阶段,每个阶段都包含相应的生命周期方法。因为是深入系列文章,本文不会仔细介绍每个生命周期方法的使用,而是会重点讲解在使用组件生命周期时,经常遇到的疑问和错误使用方式。

    服务器数据请求

    初学者在使用React时,常常不知道何时向服务器发送请求,获取组件所需数据。对于组件所需的初始数据,最合适的地方,是在componentDidMount方法中,进行数据请求,这个时候,组件完成挂载,其代表的DOM已经挂载到页面的DOM树上,即使获取到的数据需要直接操作DOM节点,这个时候也是绝对安全的。有些人还习惯在constructor或者componentWillMount中,进行数据请求,认为这样可以更快的获取到数据,但它们相比componentDidMount的执行时间,提前的时间实在是太微乎其微了。另外,当进行服务器渲染时(SSR),componentWillMount是会被调用两次的,一次在服务器端,一次在客户端,这时候就会导致额外的请求发生。

    组件进行数据请求的另一种场景:由父组件的更新导致组件的props发生变化,如果组件的数据请求依赖props,组件就需要重新进行数据请求。例如,新闻详情组件NewsDetail,在获取新闻详情数据时,需要传递新闻的id作为参数给服务器端,当NewsDetail已经处于挂载状态时,如果点击其他新闻,NewsDetail的componentDidMount并不会重新调用,因而componentDidMount中进行新闻详情数据请求的方法也不会再次执行。这时候,应该在componentWillReceiveProps中,进行数据请求:

    componentWillReceiveProps(nextProps) {
      if(this.props.newId !== nextProps.newsId) {
        fetchNewsDetailById(nextProps.newsId)  // 根据最新的新闻id,请求新闻详情数据
      }
    }

    如果进行数据请求的时机是由页面上的交互行为触发的,例如,点击查询按钮后,查询数据,这时只需要在查询按钮的事件监听函数中,执行数据请求即可,这种情况一般是不会有疑问的。

    更新阶段方法的调用

    组件的更新是组件生命周期中最复杂的阶段,也是涉及到最多生命周期方法的阶段。

    正常情况下,当组件发生更新时,组件的生命周期方法的调用顺序如下:

    componentWillReceiveProps -> shouldComponentUpdate -> componentWillUpdate -> render -> componentDidUpdate
    
    // 组件收到新的props(props中的数据并不一定真正发生变化)-> 决定是否需要继续执行更新过程 -> 组件代表的虚拟DOM即将更新 -> 组件重新计算出新的虚拟DOM -> 虚拟DOM对应的真实DOM更新到真实DOM树中

    父组件发生更新或组件自身调用setState,都会导致组件进行更新操作。父组件发生更新导致的组件更新,生命周期方法的调用情况同上所述。如果是组件自身调用setState,导致的组件更新,其生命周期方法的调用情况如下:

    shouldComponentUpdate -> componentWillUpdate -> render -> componentDidUpdate

    可见,这种情况下componentWillReceiveProps并不会被调用。

    当组件的shouldComponentUpdate返回false时,组件会停止更新过程,这时候生命周期方法的调用顺序如下:

    componentWillReceiveProps -> shouldComponentUpdate -> 结束

    或(组件自身调用setState,导致的组件更新):

    shouldComponentUpdate -> 结束

    setState的时机

    组件的生命周期方法众多,哪些方法中可以调用setState更新组件状态?哪些方法中不可以呢?

    • 可以的方法

      componentWillMount、componentDidMount、componentWillReceiveProps、componentDidUpdate

      这里有几个注意点:

      1. componentWillMount 中同步调用setState不会导致组件进行额外的渲染,组件经历的生命周期方法依次是componentWillMount -> render -> componentDidMount,组件并不会因为componentWillMount中的setState调用再次进行更新操作。如果是异步调用setState,组件是会进行额外的更新操作。不过实际场景中很少在componentWillMount中调用setState,一般可以通过直接在constructor中定义state的方式代替。
      2. 一般情况下,当调用setState后,组件会执行一次更新过程,componentWillReceiveProps等更新阶段的方法会再次被调用,但如果在componentWillReceiveProps中调用setState,并不会额外导致一次新的更新过程,也就是说,当前的更新过程结束后,componentWillReceiveProps等更新阶段的方法不会再被调用一次。(注意,这里仍然指同步调用setState,如果是异步调用,则会导致组件再次进行渲染)
      3. componentDidUpdate中调用setState要格外小心,在setState前必须有条件判断,只有满足了相应条件,才setState,否组组件会不断执行更新过程,进入死循环。因为setState会导致新一次的组件更新,组件更新完成后,componentDidUpdate被调用,又继续setState,死循环就产生了。
    • 不可以的方法

      其他生命周期方法都不能调用setState,主要原因有两个:

      1. 产生死循环。例如,shouldComponentUpdate、componentWillUpdate 和 render 中调用setState,组件本次的更新还没有执行完成,又会进入新一轮的更新,导致不断循环更新,进入死循环。
      2. 无意义。componentWillUnmount 调用时,组件即将被卸载,setState是为了更新组件,在一个即将卸载的组件上更新state显然是无意义的。实际上,在componentWillUnmount中调用setState也是会抛出异常的。

    render次数 != 浏览器界面更新次数

    先看下面的一个例子:

    class App extends React.Component {
    
      constructor(props) {
        super(props)
        this.state = {
          bgColor: "red"
        }
      }
    
      render() {
        var {bgColor} = this.state
        return (
          <div style = {{backgroundColor: bgColor}}> 
            Test
          </div>
        );
      }
      
      componentDidMount() {
        this.setState({
          bgColor: "yellow"
        })
      }
    }

    当我们观察浏览器渲染出的页面时,页面中Test所在div的背景色,是先显示红色,再变成黄色呢?还是直接就显示为黄色呢?

    答案是:直接就显示为黄色!

    这个过程中,组件的生命周期方法被调用的顺序如下:

    constructor -> componentWillMount -> render -> componentDidMount -> shouldComponentUpdate -> componentWillUpdate -> render -> componentDidUpdate

    组件在挂载完成后,因为setState的调用,将立即执行一次更新过程。虽然render方法被调用了两次,但这并不会导致浏览器界面更新两次,实际上,两次DOM的修改会合并成一次浏览器界面的更新。React官网介绍componentDidMount方法时也有以下说明:

    Calling setState() in this method will trigger an extra rendering, but it will happen before the browser updates the screen. This guarantees that even though the render() will be called twice in this case, the user won’t see the intermediate state.

    这说明,组件render的次数 不一定等于 浏览器界面更新次数。虽然JS的执行和DOM的渲染分别由浏览器不同的线程完成,但JS的执行会阻塞DOM的渲染,而上面的两次render是在一个JS事件周期内执行的,所以在两次render结束前,浏览器不会更新界面。

    下篇预告:

    React 深入系列5:事件处理


    我的新书《React进阶之路》已上市,请大家多多支持!
    链接:京东当当

    图片描述

    查看原文

    赞 2 收藏 2 评论 0

    认证与成就

    • SegmentFault 讲师
    • 获得 811 次点赞
    • 获得 17 枚徽章 获得 1 枚金徽章, 获得 7 枚银徽章, 获得 9 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    • 《React进阶之路》

      本书详细介绍了React技术栈。本书分为基础篇、进阶篇和实战篇三个部分。基础篇主要介绍React的基本用法,包括React 16的新特性;进阶篇深入讲解了组件state、虚拟DOM、高级组件等React中的重要概念,同时对初学者容易困惑的知识点做了介绍;实战篇介绍了React Router、Redux和MobX 这3个React技术栈的重要成员,并通过实战项目讲解这些技术如何和React结合使用。

    注册于 2017-07-20
    个人主页被 5.3k 人浏览